Add SQLite database for dynamic guild management
Features: - SQLite database with better-sqlite3 for guild configurations - Auto-registration when bot joins new guilds with welcome messages - Soft delete system preserves settings when bot is removed - Dynamic configuration via /config slash command with subcommands - Automatic migration from config.json to database on first run - Support for scheduled events with timezone preservation Technical Implementation: - Node.js 20 for better SQLite compatibility in Docker - Full Debian base image with npm for reliable native module compilation - Database persistence via Docker volume (./data) - Hybrid configuration system (database primary, file fallback) - JSON storage for complex schedule objects with timezone support Database Schema: - guilds table with soft delete (is_active flag) - scheduled_events table with JSON schedule storage - bot_config table for global settings - Auto-initialization and seeding from existing config Admin Features: - /config show - View current server settings - /config subcommands - Update prefix, volume, features, etc. - Administrator permissions required for configuration changes - Graceful handling of missing or malformed data 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
182
src/commands/slash/config.js
Normal file
182
src/commands/slash/config.js
Normal file
@@ -0,0 +1,182 @@
|
||||
const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require('discord.js');
|
||||
const configManager = require('../../config/config');
|
||||
|
||||
module.exports = {
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('config')
|
||||
.setDescription('Manage server configuration')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||
.addSubcommand(subcommand =>
|
||||
subcommand
|
||||
.setName('show')
|
||||
.setDescription('Show current server configuration')
|
||||
)
|
||||
.addSubcommand(subcommand =>
|
||||
subcommand
|
||||
.setName('prefix')
|
||||
.setDescription('Set the command prefix')
|
||||
.addStringOption(option =>
|
||||
option.setName('new_prefix')
|
||||
.setDescription('The new command prefix')
|
||||
.setRequired(true)
|
||||
.setMaxLength(5)
|
||||
)
|
||||
)
|
||||
.addSubcommand(subcommand =>
|
||||
subcommand
|
||||
.setName('sfx')
|
||||
.setDescription('Enable or disable sound effects')
|
||||
.addBooleanOption(option =>
|
||||
option.setName('enabled')
|
||||
.setDescription('Enable sound effects')
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(subcommand =>
|
||||
subcommand
|
||||
.setName('volume')
|
||||
.setDescription('Set sound effects volume')
|
||||
.addNumberOption(option =>
|
||||
option.setName('level')
|
||||
.setDescription('Volume level (0.1 to 1.0)')
|
||||
.setRequired(true)
|
||||
.setMinValue(0.1)
|
||||
.setMaxValue(1.0)
|
||||
)
|
||||
)
|
||||
.addSubcommand(subcommand =>
|
||||
subcommand
|
||||
.setName('funfacts')
|
||||
.setDescription('Enable or disable fun facts')
|
||||
.addBooleanOption(option =>
|
||||
option.setName('enabled')
|
||||
.setDescription('Enable fun facts')
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(subcommand =>
|
||||
subcommand
|
||||
.setName('hamfacts')
|
||||
.setDescription('Enable or disable ham facts')
|
||||
.addBooleanOption(option =>
|
||||
option.setName('enabled')
|
||||
.setDescription('Enable ham facts')
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(subcommand =>
|
||||
subcommand
|
||||
.setName('sfxchannels')
|
||||
.setDescription('Set allowed channels for sound effects (regex pattern)')
|
||||
.addStringOption(option =>
|
||||
option.setName('pattern')
|
||||
.setDescription('Channel name pattern (leave empty to allow all channels)')
|
||||
.setRequired(false)
|
||||
)
|
||||
)
|
||||
.addSubcommand(subcommand =>
|
||||
subcommand
|
||||
.setName('roles')
|
||||
.setDescription('Set roles that users can self-assign')
|
||||
.addStringOption(option =>
|
||||
option.setName('pattern')
|
||||
.setDescription('Role pattern (pipe-separated, e.g., "streamer|vip|member")')
|
||||
.setRequired(false)
|
||||
)
|
||||
),
|
||||
|
||||
async execute(interaction, guildConfig) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const databaseService = configManager.databaseService;
|
||||
|
||||
if (!databaseService) {
|
||||
return interaction.reply({
|
||||
content: '❌ Database service not available.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
if (subcommand === 'show') {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`⚙️ Configuration for ${interaction.guild.name}`)
|
||||
.setColor(0x21c629)
|
||||
.addFields([
|
||||
{ name: 'Prefix', value: `\`${guildConfig.prefix}\``, inline: true },
|
||||
{ name: 'SFX Enabled', value: guildConfig.enableSfx ? '✅ Yes' : '❌ No', inline: true },
|
||||
{ name: 'SFX Volume', value: `${Math.round(guildConfig.sfxVolume * 100)}%`, inline: true },
|
||||
{ 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 },
|
||||
])
|
||||
.setFooter({ text: 'Use /config commands to modify settings' });
|
||||
|
||||
return interaction.reply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
// Handle configuration updates
|
||||
const newConfig = { ...guildConfig };
|
||||
let updateMessage = '';
|
||||
|
||||
switch (subcommand) {
|
||||
case 'prefix':
|
||||
const newPrefix = interaction.options.getString('new_prefix');
|
||||
newConfig.prefix = newPrefix;
|
||||
updateMessage = `Command prefix updated to \`${newPrefix}\``;
|
||||
break;
|
||||
|
||||
case 'sfx':
|
||||
const sfxEnabled = interaction.options.getBoolean('enabled');
|
||||
newConfig.enableSfx = sfxEnabled;
|
||||
updateMessage = `Sound effects ${sfxEnabled ? 'enabled' : 'disabled'}`;
|
||||
break;
|
||||
|
||||
case 'volume':
|
||||
const volume = interaction.options.getNumber('level');
|
||||
newConfig.sfxVolume = volume;
|
||||
updateMessage = `SFX volume set to ${Math.round(volume * 100)}%`;
|
||||
break;
|
||||
|
||||
case 'funfacts':
|
||||
const funfactsEnabled = interaction.options.getBoolean('enabled');
|
||||
newConfig.enableFunFacts = funfactsEnabled;
|
||||
updateMessage = `Fun facts ${funfactsEnabled ? 'enabled' : 'disabled'}`;
|
||||
break;
|
||||
|
||||
case 'hamfacts':
|
||||
const hamfactsEnabled = interaction.options.getBoolean('enabled');
|
||||
newConfig.enableHamFacts = hamfactsEnabled;
|
||||
updateMessage = `Ham facts ${hamfactsEnabled ? 'enabled' : 'disabled'}`;
|
||||
break;
|
||||
|
||||
case 'sfxchannels':
|
||||
const channelPattern = interaction.options.getString('pattern');
|
||||
newConfig.allowedSfxChannels = channelPattern || null;
|
||||
updateMessage = channelPattern
|
||||
? `SFX channels restricted to pattern: \`${channelPattern}\``
|
||||
: 'SFX allowed in all channels';
|
||||
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';
|
||||
break;
|
||||
}
|
||||
|
||||
// Update configuration in database
|
||||
databaseService.upsertGuildConfig(newConfig);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('✅ Configuration Updated')
|
||||
.setColor(0x00ff00)
|
||||
.setDescription(updateMessage)
|
||||
.setFooter({ text: 'Use /config show to see all settings' });
|
||||
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
|
||||
console.log(`Configuration updated for ${interaction.guild.name}: ${subcommand} by @${interaction.user.username}`);
|
||||
}
|
||||
};
|
||||
@@ -1,26 +1,121 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Load config from root directory
|
||||
const configPath = path.join(__dirname, "..", "..", "config.json");
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
|
||||
// Validate required config fields
|
||||
function validateConfig(config) {
|
||||
if (!config.discord?.token) {
|
||||
throw new Error("Discord token is required in config.json");
|
||||
// Dynamic config that combines file-based config with database
|
||||
class ConfigManager {
|
||||
constructor() {
|
||||
this.fileConfig = this.loadFileConfig();
|
||||
this.databaseService = null; // Will be injected
|
||||
}
|
||||
|
||||
if (!config.discord?.guilds || !Array.isArray(config.discord.guilds)) {
|
||||
throw new Error("Discord guilds configuration is required");
|
||||
/**
|
||||
* Load static configuration from file
|
||||
*/
|
||||
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");
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Ensure guilds is an array (supporting both old object format and new array format)
|
||||
if (!Array.isArray(config.discord.guilds)) {
|
||||
config.discord.guilds = Object.values(config.discord.guilds);
|
||||
/**
|
||||
* Inject database service (to avoid circular dependency)
|
||||
*/
|
||||
setDatabaseService(databaseService) {
|
||||
this.databaseService = databaseService;
|
||||
}
|
||||
|
||||
return config;
|
||||
/**
|
||||
* Get bot configuration (combines file and database)
|
||||
*/
|
||||
getBotConfig() {
|
||||
const fileConfig = this.fileConfig;
|
||||
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),
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get guild configuration (from database primarily, file as fallback)
|
||||
*/
|
||||
getGuildConfig(guildId) {
|
||||
if (this.databaseService) {
|
||||
const dbConfig = this.databaseService.getGuildConfig(guildId);
|
||||
if (dbConfig) {
|
||||
return dbConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to file config for backward compatibility
|
||||
if (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 default config for new guilds
|
||||
return {
|
||||
id: guildId,
|
||||
name: 'Unknown Guild',
|
||||
internalName: 'Unknown Guild',
|
||||
prefix: '!',
|
||||
enableSfx: true,
|
||||
allowedSfxChannels: null,
|
||||
sfxVolume: 0.5,
|
||||
enableFunFacts: true,
|
||||
enableHamFacts: true,
|
||||
allowedRolesForRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all guild configurations
|
||||
*/
|
||||
getAllGuildConfigs() {
|
||||
if (this.databaseService) {
|
||||
return this.databaseService.getAllGuildConfigs();
|
||||
}
|
||||
|
||||
// Fallback to file config
|
||||
if (this.fileConfig.discord?.guilds) {
|
||||
const guilds = Array.isArray(this.fileConfig.discord.guilds)
|
||||
? this.fileConfig.discord.guilds
|
||||
: Object.values(this.fileConfig.discord.guilds);
|
||||
|
||||
return guilds;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = validateConfig(config);
|
||||
module.exports = new ConfigManager();
|
||||
|
||||
151
src/index.js
151
src/index.js
@@ -8,7 +8,7 @@ const {
|
||||
} = require("discord.js");
|
||||
const { generateDependencyReport } = require("@discordjs/voice");
|
||||
const intents = require("./config/intents");
|
||||
const config = require("./config/config");
|
||||
const configManager = require("./config/config");
|
||||
const { randElement } = require("./utils/helpers");
|
||||
|
||||
// Log audio dependencies status
|
||||
@@ -21,6 +21,13 @@ const client = new Client({ intents });
|
||||
// Services
|
||||
const commandLoader = require("./services/commandLoader");
|
||||
const schedulerService = require("./services/schedulerService");
|
||||
const databaseService = require("./services/databaseService");
|
||||
|
||||
// Inject database service into config manager
|
||||
configManager.setDatabaseService(databaseService);
|
||||
|
||||
// Get bot configuration
|
||||
const config = configManager.getBotConfig();
|
||||
|
||||
// Command handlers
|
||||
const prefixCommands = require("./commands/prefix");
|
||||
@@ -59,7 +66,8 @@ async function registerSlashCommands() {
|
||||
const commands = slashCommands.getSlashCommandDefinitions();
|
||||
|
||||
// Register commands for each guild
|
||||
for (const guild of config.discord.guilds) {
|
||||
const guildConfigs = configManager.getAllGuildConfigs();
|
||||
for (const guild of guildConfigs) {
|
||||
await rest.put(
|
||||
Routes.applicationGuildCommands(client.user.id, guild.id),
|
||||
{ body: commands }
|
||||
@@ -93,7 +101,7 @@ client.once(Events.ClientReady, async () => {
|
||||
await registerSlashCommands();
|
||||
|
||||
// Initialize scheduled events
|
||||
schedulerService.initialize(client, config);
|
||||
schedulerService.initialize(client, configManager);
|
||||
});
|
||||
|
||||
// Message handler for prefix commands
|
||||
@@ -104,10 +112,8 @@ client.on(Events.MessageCreate, async (message) => {
|
||||
// Ignore DMs if not configured
|
||||
if (!message.guild) return;
|
||||
|
||||
// Check if guild is configured
|
||||
const guildConfig = config.discord.guilds.find(
|
||||
(g) => g.id === message.guild.id
|
||||
);
|
||||
// Get guild configuration from database/file
|
||||
const guildConfig = configManager.getGuildConfig(message.guild.id);
|
||||
if (!guildConfig) return;
|
||||
|
||||
// Check blacklist
|
||||
@@ -174,10 +180,8 @@ client.on(Events.InteractionCreate, async (interaction) => {
|
||||
if (!interaction.isChatInputCommand() && !interaction.isAutocomplete())
|
||||
return;
|
||||
|
||||
// Get guild config
|
||||
const guildConfig = config.discord.guilds.find(
|
||||
(g) => g.id === interaction.guild.id
|
||||
);
|
||||
// Get guild configuration from database/file
|
||||
const guildConfig = configManager.getGuildConfig(interaction.guild.id);
|
||||
if (!guildConfig) return;
|
||||
|
||||
try {
|
||||
@@ -204,12 +208,129 @@ client.on(Events.InteractionCreate, async (interaction) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle bot being added to a new guild
|
||||
client.on(Events.GuildCreate, async (guild) => {
|
||||
console.log(`🎉 Bot added to new guild: ${guild.name} (${guild.id})`);
|
||||
|
||||
// Check if this guild previously existed but was removed
|
||||
const existingGuild = databaseService.getGuildConfigIncludingInactive(guild.id);
|
||||
|
||||
if (existingGuild && !existingGuild.isActive) {
|
||||
// Reactivate existing guild with previous settings
|
||||
const guildConfig = {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
internalName: guild.name,
|
||||
};
|
||||
|
||||
databaseService.upsertGuildConfig(guildConfig, true); // true = reactivation
|
||||
|
||||
// Send welcome back message
|
||||
try {
|
||||
const channel = guild.channels.cache.find(
|
||||
(ch) =>
|
||||
ch.type === 0 && // Text channel
|
||||
ch.permissionsFor(guild.members.me).has(["SendMessages", "ViewChannel"])
|
||||
);
|
||||
|
||||
if (channel) {
|
||||
await channel.send({
|
||||
embeds: [
|
||||
{
|
||||
title: "🎉 Welcome back to GHBot!",
|
||||
description: `Great to see you again! Your previous configuration has been restored.
|
||||
|
||||
**Your settings are preserved:**
|
||||
• Command prefix: \`${existingGuild.prefix}\`
|
||||
• Sound effects: ${existingGuild.enableSfx ? '✅ Enabled' : '❌ Disabled'}
|
||||
• Volume: ${Math.round(existingGuild.sfxVolume * 100)}%
|
||||
|
||||
Use \`/config show\` to view all settings or \`/config\` commands to modify them.`,
|
||||
color: 0x00ff00,
|
||||
footer: { text: "All your previous settings have been restored!" },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending welcome back message:", error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-register new guild with default settings
|
||||
const guildConfig = {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
internalName: guild.name,
|
||||
prefix: "!",
|
||||
enableSfx: true,
|
||||
allowedSfxChannels: null, // Allow in all channels by default
|
||||
sfxVolume: 0.5,
|
||||
enableFunFacts: true,
|
||||
enableHamFacts: true,
|
||||
allowedRolesForRequest: null,
|
||||
};
|
||||
|
||||
databaseService.upsertGuildConfig(guildConfig);
|
||||
|
||||
// Send welcome message to first available text channel
|
||||
try {
|
||||
const channel = guild.channels.cache.find(
|
||||
(ch) =>
|
||||
ch.type === 0 && // Text channel
|
||||
ch.permissionsFor(guild.members.me).has(["SendMessages", "ViewChannel"])
|
||||
);
|
||||
|
||||
if (channel) {
|
||||
await channel.send({
|
||||
embeds: [
|
||||
{
|
||||
title: "🎵 GHBot has joined the server!",
|
||||
description: `Thanks for adding me! I'm a sound effects bot with the following features:
|
||||
|
||||
**Commands:**
|
||||
• \`!sfx <sound>\` - Play sound effects (prefix command)
|
||||
• \`/sfx\` - Play sound effects with autocomplete (slash command)
|
||||
• \`!funfact\` - Get random fun facts
|
||||
• \`!hamfact\` - Get ham facts
|
||||
• \`!dance\` - ASCII dance animation
|
||||
• \`!join\` / \`!leave\` - Voice channel controls
|
||||
|
||||
**Setup:**
|
||||
1. Add sound files (.mp3/.wav) to your server
|
||||
2. Use \`!config\` to customize settings
|
||||
3. Set up allowed channels for sound effects
|
||||
|
||||
Get started with \`!sfx\` to see available sounds!`,
|
||||
color: 0x21c629,
|
||||
footer: { text: "Use !help for more information" },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending welcome message:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle bot being removed from a guild
|
||||
client.on(Events.GuildDelete, (guild) => {
|
||||
console.log(`👋 Bot removed from guild: ${guild.name} (${guild.id})`);
|
||||
|
||||
// Soft delete guild configuration (can be restored if they re-add the bot)
|
||||
const deleted = databaseService.softDeleteGuildConfig(guild.id);
|
||||
|
||||
if (deleted) {
|
||||
console.log(`Guild ${guild.name} configuration preserved for potential re-invite`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle new guild members
|
||||
client.on(Events.GuildMemberAdd, (member) => {
|
||||
// Check if guild is configured
|
||||
const guildConfig = config.discord.guilds.find(
|
||||
(g) => g.id === member.guild.id
|
||||
);
|
||||
// Get guild configuration
|
||||
const guildConfig = configManager.getGuildConfig(member.guild.id);
|
||||
if (!guildConfig) return;
|
||||
|
||||
console.log(
|
||||
|
||||
492
src/services/databaseService.js
Normal file
492
src/services/databaseService.js
Normal file
@@ -0,0 +1,492 @@
|
||||
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');
|
||||
this.db = new Database(dbPath);
|
||||
|
||||
// Enable WAL mode for better concurrent access
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
|
||||
this.initializeTables();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database tables
|
||||
*/
|
||||
initializeTables() {
|
||||
// Guild configurations table
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS guilds (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
internal_name TEXT,
|
||||
prefix TEXT DEFAULT '!',
|
||||
enable_sfx BOOLEAN DEFAULT true,
|
||||
allowed_sfx_channels TEXT,
|
||||
sfx_volume REAL DEFAULT 0.5,
|
||||
enable_fun_facts BOOLEAN DEFAULT true,
|
||||
enable_ham_facts BOOLEAN DEFAULT true,
|
||||
allowed_roles_for_request TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
removed_at DATETIME
|
||||
)
|
||||
`);
|
||||
|
||||
// Scheduled events table
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS scheduled_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guild_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
schedule TEXT NOT NULL,
|
||||
channel_id TEXT,
|
||||
message TEXT,
|
||||
ping_role_id TEXT,
|
||||
enabled BOOLEAN DEFAULT true,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (guild_id) REFERENCES guilds (id) ON DELETE CASCADE,
|
||||
UNIQUE(guild_id, event_id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Bot configuration table (for global settings)
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bot_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Insert default bot config if not exists
|
||||
this.db.exec(`
|
||||
INSERT OR IGNORE INTO bot_config (key, value) VALUES
|
||||
('bot_name', 'GHBot'),
|
||||
('debug', 'false'),
|
||||
('admin_user_id', ''),
|
||||
('activities', '["Playing sounds", "Serving facts"]'),
|
||||
('blacklisted_users', '[]')
|
||||
`);
|
||||
|
||||
console.log('Database tables initialized');
|
||||
|
||||
// Prepare statements after tables are created
|
||||
this.prepareStatements();
|
||||
|
||||
// Run migrations after statements are prepared
|
||||
this.runMigrations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run database migrations
|
||||
*/
|
||||
runMigrations() {
|
||||
// Check if we need to seed from config file
|
||||
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...');
|
||||
this.seedFromConfigFile();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed database with guilds from config file
|
||||
*/
|
||||
seedFromConfigFile() {
|
||||
try {
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
|
||||
if (!config.discord?.guilds) {
|
||||
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);
|
||||
|
||||
let seededCount = 0;
|
||||
|
||||
for (const guild of guilds) {
|
||||
if (!guild.id) {
|
||||
console.warn('Skipping guild with missing ID:', guild);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert old config format to new format
|
||||
const guildConfig = {
|
||||
id: guild.id,
|
||||
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,
|
||||
};
|
||||
|
||||
// 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}`);
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seededCount++;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
if (config.debug !== undefined) {
|
||||
this.setBotConfig('debug', config.debug.toString());
|
||||
}
|
||||
if (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?.blacklistedUsers && Array.isArray(config.discord.blacklistedUsers)) {
|
||||
this.setBotConfig('blacklisted_users', JSON.stringify(config.discord.blacklistedUsers));
|
||||
}
|
||||
|
||||
console.log('✅ Bot configuration updated from config.json');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error seeding database from config file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare SQL statements for better performance
|
||||
*/
|
||||
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'),
|
||||
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)
|
||||
`),
|
||||
updateGuild: this.db.prepare(`
|
||||
UPDATE guilds SET
|
||||
name = ?, internal_name = ?, prefix = ?, enable_sfx = ?,
|
||||
allowed_sfx_channels = ?, sfx_volume = ?, enable_fun_facts = ?,
|
||||
enable_ham_facts = ?, allowed_roles_for_request = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND is_active = true
|
||||
`),
|
||||
softDeleteGuild: this.db.prepare(`
|
||||
UPDATE guilds SET is_active = false, removed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`),
|
||||
reactivateGuild: this.db.prepare(`
|
||||
UPDATE guilds SET is_active = true, removed_at = NULL, updated_at = CURRENT_TIMESTAMP
|
||||
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'),
|
||||
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 = ?'),
|
||||
|
||||
// Bot config
|
||||
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)
|
||||
`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get guild configuration
|
||||
* @param {string} guildId
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
getGuildConfig(guildId) {
|
||||
const guild = this.statements.getGuild.get(guildId);
|
||||
if (!guild) return null;
|
||||
|
||||
return {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
internalName: guild.internal_name,
|
||||
prefix: guild.prefix,
|
||||
enableSfx: Boolean(guild.enable_sfx),
|
||||
allowedSfxChannels: guild.allowed_sfx_channels,
|
||||
sfxVolume: guild.sfx_volume,
|
||||
enableFunFacts: Boolean(guild.enable_fun_facts),
|
||||
enableHamFacts: Boolean(guild.enable_ham_facts),
|
||||
allowedRolesForRequest: guild.allowed_roles_for_request,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all guild configurations
|
||||
* @returns {Array}
|
||||
*/
|
||||
getAllGuildConfigs() {
|
||||
const guilds = this.statements.getAllGuilds.all();
|
||||
return guilds.map(guild => ({
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
internalName: guild.internal_name,
|
||||
prefix: guild.prefix,
|
||||
enableSfx: Boolean(guild.enable_sfx),
|
||||
allowedSfxChannels: guild.allowed_sfx_channels,
|
||||
sfxVolume: guild.sfx_volume,
|
||||
enableFunFacts: Boolean(guild.enable_fun_facts),
|
||||
enableHamFacts: Boolean(guild.enable_ham_facts),
|
||||
allowedRolesForRequest: guild.allowed_roles_for_request,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update guild configuration
|
||||
* @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);
|
||||
if (existingGuild && !existingGuild.is_active) {
|
||||
// Reactivate existing guild and update its info
|
||||
this.statements.reactivateGuild.run(guildConfig.id);
|
||||
// Update the guild info
|
||||
this.statements.updateGuild.run(
|
||||
guildConfig.name,
|
||||
guildConfig.internalName || guildConfig.name,
|
||||
existingGuild.prefix, // Keep existing prefix
|
||||
existingGuild.enable_sfx ? 1 : 0, // Keep existing settings
|
||||
existingGuild.allowed_sfx_channels,
|
||||
existingGuild.sfx_volume,
|
||||
existingGuild.enable_fun_facts ? 1 : 0,
|
||||
existingGuild.enable_ham_facts ? 1 : 0,
|
||||
existingGuild.allowed_roles_for_request,
|
||||
guildConfig.id
|
||||
);
|
||||
console.log(`Guild reactivated with existing configuration: ${guildConfig.name} (${guildConfig.id})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new guild or replace completely
|
||||
this.statements.insertGuild.run(
|
||||
guildConfig.id,
|
||||
guildConfig.name,
|
||||
guildConfig.internalName || guildConfig.name,
|
||||
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
|
||||
);
|
||||
|
||||
console.log(`Guild configuration saved: ${guildConfig.name} (${guildConfig.id})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete guild configuration (can be restored)
|
||||
* @param {string} guildId
|
||||
*/
|
||||
softDeleteGuildConfig(guildId) {
|
||||
const result = this.statements.softDeleteGuild.run(guildId);
|
||||
if (result.changes > 0) {
|
||||
console.log(`Guild configuration soft-deleted: ${guildId}`);
|
||||
}
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete guild configuration (permanent)
|
||||
* @param {string} guildId
|
||||
*/
|
||||
hardDeleteGuildConfig(guildId) {
|
||||
const result = this.statements.hardDeleteGuild.run(guildId);
|
||||
if (result.changes > 0) {
|
||||
console.log(`Guild configuration permanently deleted: ${guildId}`);
|
||||
}
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if guild exists (including inactive)
|
||||
* @param {string} guildId
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
getGuildConfigIncludingInactive(guildId) {
|
||||
const guild = this.db.prepare('SELECT * FROM guilds WHERE id = ?').get(guildId);
|
||||
if (!guild) return null;
|
||||
|
||||
return {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
internalName: guild.internal_name,
|
||||
prefix: guild.prefix,
|
||||
enableSfx: Boolean(guild.enable_sfx),
|
||||
allowedSfxChannels: guild.allowed_sfx_channels,
|
||||
sfxVolume: guild.sfx_volume,
|
||||
enableFunFacts: Boolean(guild.enable_fun_facts),
|
||||
enableHamFacts: Boolean(guild.enable_ham_facts),
|
||||
allowedRolesForRequest: guild.allowed_roles_for_request,
|
||||
isActive: Boolean(guild.is_active),
|
||||
removedAt: guild.removed_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduled events for a guild
|
||||
* @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 => ({
|
||||
...event,
|
||||
schedule: this.parseSchedule(event.schedule)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse schedule string back to object or cron string
|
||||
* @param {string} scheduleString
|
||||
* @returns {Object|string}
|
||||
*/
|
||||
parseSchedule(scheduleString) {
|
||||
try {
|
||||
// Try to parse as JSON (object format)
|
||||
return JSON.parse(scheduleString);
|
||||
} catch {
|
||||
// If it fails, it's probably a cron string
|
||||
return scheduleString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add scheduled 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);
|
||||
|
||||
this.statements.insertScheduledEvent.run(
|
||||
guildId,
|
||||
event.id,
|
||||
scheduleString,
|
||||
event.channelId || null,
|
||||
event.message || null,
|
||||
event.pingRoleId || null,
|
||||
event.enabled !== false ? 1 : 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove scheduled event
|
||||
* @param {string} guildId
|
||||
* @param {string} eventId
|
||||
*/
|
||||
removeScheduledEvent(guildId, eventId) {
|
||||
this.statements.deleteScheduledEvent.run(guildId, eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bot configuration value
|
||||
* @param {string} key
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getBotConfig(key) {
|
||||
const result = this.statements.getBotConfig.get(key);
|
||||
return result ? result.value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set bot configuration value
|
||||
* @param {string} key
|
||||
* @param {string} value
|
||||
*/
|
||||
setBotConfig(key, value) {
|
||||
this.statements.setBotConfig.run(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parsed bot configuration
|
||||
* @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') || '[]');
|
||||
|
||||
return {
|
||||
botName,
|
||||
debug,
|
||||
adminUserId,
|
||||
activities,
|
||||
blacklistedUsers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
close() {
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new DatabaseService();
|
||||
@@ -8,12 +8,14 @@ class SchedulerService {
|
||||
/**
|
||||
* Initialize scheduled events for all guilds
|
||||
* @param {Client} client
|
||||
* @param {Object} config
|
||||
* @param {ConfigManager} configManager
|
||||
*/
|
||||
async initialize(client, config) {
|
||||
async initialize(client, configManager) {
|
||||
console.log('Initializing scheduled events...');
|
||||
|
||||
for (const guildConfig of config.discord.guilds) {
|
||||
const guildConfigs = configManager.getAllGuildConfigs();
|
||||
|
||||
for (const guildConfig of guildConfigs) {
|
||||
try {
|
||||
const guild = await client.guilds.fetch(guildConfig.id);
|
||||
if (!guild) {
|
||||
@@ -21,11 +23,17 @@ class SchedulerService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!guildConfig.scheduledEvents || guildConfig.scheduledEvents.length === 0) {
|
||||
// Get scheduled events from database
|
||||
const databaseService = configManager.databaseService;
|
||||
if (!databaseService) continue;
|
||||
|
||||
const scheduledEvents = databaseService.getScheduledEvents(guildConfig.id);
|
||||
|
||||
if (!scheduledEvents || scheduledEvents.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const event of guildConfig.scheduledEvents) {
|
||||
for (const event of scheduledEvents) {
|
||||
await this.scheduleEvent(guild, event, guildConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user