7 Commits

Author SHA1 Message Date
dependabot[bot]
b8d2948953 Bump axios from 1.11.0 to 1.12.0
Bumps [axios](https://github.com/axios/axios) from 1.11.0 to 1.12.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.11.0...v1.12.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-13 14:52:51 +00:00
Chris Ham
2e6cfe21d8 remove unused imports 2025-08-17 09:57:04 -07:00
Chris Ham
5d72159cb2 Fix scheduled events not executing due to property name mismatch
The scheduler was correctly scheduling events but they weren't executing because the code was checking for camelCase property names (channelId, pingRoleId) while the database returns snake_case names (channel_id, ping_role_id). This caused channel and role validation to be skipped, resulting in silent failures.

Also added sqlite3 CLI to Docker container for debugging database issues.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-17 09:53:38 -07:00
Chris Ham
8823eac094 Convert to database-first configuration with token storage
- Rename config.json → seed.json to clarify seeding purpose
- Update ConfigManager to be database-first with minimal file fallbacks
- Store Discord token in database instead of environment variables
- Remove allowedSfxChannels functionality completely
- Update seeding script to import token from seed.json to database
- Add token field to bot_config table in database schema
- Update Docker volume mount to use seed.json
- Update gitignore to protect seed.json while allowing seed.example.json

Configuration Flow:
1. First run: Import from seed.json to database (one-time seeding)
2. Runtime: All configuration from SQLite database
3. Fallback: Environment variables if database unavailable

Security: All sensitive data now stored in encrypted SQLite database

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-16 23:37:06 -07:00
Chris Ham
6d93f3dcad /soundboard -> /sfxboard 2025-08-16 22:32:37 -07:00
Chris Ham
7dc7a92dd1 ephemeral 2025-08-16 22:22:40 -07:00
Chris Ham
b2821d412c SFX -> Sfx 2025-08-16 22:21:10 -07:00
14 changed files with 295 additions and 292 deletions

4
.gitignore vendored
View File

@@ -54,8 +54,8 @@ data/
start.bat start.bat
tokens.json tokens.json
config.*json seed.json
!config.example.json !seed.example.json
.env .env
*.todo *.todo

View File

@@ -1,6 +1,8 @@
# Use Node 20 LTS with full Debian for better compatibility # Use Node 20 LTS with full Debian for better compatibility
FROM node:20 FROM node:20
RUN apt update && apt install -y sqlite3
WORKDIR /app WORKDIR /app
# Copy package files (npm will work better for native modules in Docker) # Copy package files (npm will work better for native modules in Docker)

View File

@@ -8,8 +8,8 @@ services:
container_name: discord-bot container_name: discord-bot
restart: unless-stopped restart: unless-stopped
volumes: volumes:
# Configuration files (read-only) # Seed file for initial database population (read-only)
- ./config.json:/app/config.json:ro - ./seed.json:/app/seed.json:ro
- ./conf:/app/conf:ro - ./conf:/app/conf:ro
# Sound effects directory (read-only) # Sound effects directory (read-only)

View File

@@ -6,7 +6,7 @@
"dependencies": { "dependencies": {
"@discordjs/opus": "^0.9.0", "@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.18.0", "@discordjs/voice": "^0.18.0",
"axios": "^1.11.0", "axios": "^1.12.0",
"better-sqlite3": "^11.10.0", "better-sqlite3": "^11.10.0",
"discord.js": "^14.21.0", "discord.js": "^14.21.0",
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
@@ -32,7 +32,7 @@
"restart": "docker compose restart", "restart": "docker compose restart",
"logs": "docker compose logs -f", "logs": "docker compose logs -f",
"boom": "pnpm stop && pnpm build && pnpm start", "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'", "reset-db": "pnpm stop && rm -f data/*.db* && echo 'Database reset complete. Run pnpm start to re-seed from seed.json'",
"image:build": "docker build -t ghbot:${VERSION:-latest} .", "image:build": "docker build -t ghbot:${VERSION:-latest} .",
"image:run": "docker run -d --name ghbot --restart always ghbot:${VERSION:-latest}" "image:run": "docker run -d --name ghbot --restart always ghbot:${VERSION:-latest}"
}, },

8
pnpm-lock.yaml generated
View File

@@ -15,8 +15,8 @@ dependencies:
specifier: ^0.18.0 specifier: ^0.18.0
version: 0.18.0(ffmpeg-static@5.2.0)(opusscript@0.1.1)(opusscript@0.1.1) version: 0.18.0(ffmpeg-static@5.2.0)(opusscript@0.1.1)(opusscript@0.1.1)
axios: axios:
specifier: ^1.11.0 specifier: ^1.12.0
version: 1.11.0 version: 1.12.0
better-sqlite3: better-sqlite3:
specifier: ^11.10.0 specifier: ^11.10.0
version: 11.10.0 version: 11.10.0
@@ -201,8 +201,8 @@ packages:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: false dev: false
/axios@1.11.0: /axios@1.12.0:
resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==}
dependencies: dependencies:
follow-redirects: 1.15.11 follow-redirects: 1.15.11
form-data: 4.0.4 form-data: 4.0.4

View File

@@ -1,24 +1,21 @@
const axios = require('axios'); const sfxManager = require("../../services/sfxManager");
const { chunkSubstr } = require('../../utils/helpers');
const sfxManager = require('../../services/sfxManager');
const voiceService = require('../../services/voiceService');
module.exports = { module.exports = {
name: 'sfx', name: "sfx",
description: 'Play a sound effect', description: "Play a sound effect",
/** /**
* Smart chunking that respects markdown block boundaries * Smart chunking that respects markdown block boundaries
* @param {string} content * @param {string} content
* @param {number} maxLength * @param {number} maxLength
* @returns {Array<string>} * @returns {Array<string>}
*/ */
smartChunkMarkdown(content, maxLength) { smartChunkMarkdown(content, maxLength) {
const chunks = []; const chunks = [];
const sections = content.split(/(\*\*[^*]+\*\*)/); // Split on headers while keeping them const sections = content.split(/(\*\*[^*]+\*\*)/); // Split on headers while keeping them
let currentChunk = ''; let currentChunk = "";
for (const section of sections) { for (const section of sections) {
// If adding this section would exceed the limit // If adding this section would exceed the limit
if (currentChunk.length + section.length > maxLength) { if (currentChunk.length + section.length > maxLength) {
@@ -31,58 +28,69 @@ module.exports = {
currentChunk += section; currentChunk += section;
} }
} }
// Add the final chunk // Add the final chunk
if (currentChunk.trim()) { if (currentChunk.trim()) {
chunks.push(currentChunk.trim()); chunks.push(currentChunk.trim());
} }
return chunks; return chunks;
}, },
async execute(message, args, guildConfig) { async execute(message, args, guildConfig) {
const sfxName = args[0]; const sfxName = args[0];
// If no SFX specified, show the list // If no SFX specified, show the list
if (!sfxName) { if (!sfxName) {
try { try {
const fs = require('fs'); const fs = require("fs");
const path = require('path'); const path = require("path");
const sfxReadmePath = path.join(__dirname, '..', '..', '..', 'sfx', 'README.md'); const sfxReadmePath = path.join(
__dirname,
"..",
"..",
"..",
"sfx",
"README.md"
);
if (fs.existsSync(sfxReadmePath)) { if (fs.existsSync(sfxReadmePath)) {
const sfxListContent = fs.readFileSync(sfxReadmePath, 'utf-8'); const sfxListContent = fs.readFileSync(sfxReadmePath, "utf-8");
// Break into chunks if too long (Discord limit is 2000 characters) // Break into chunks if too long (Discord limit is 2000 characters)
if (sfxListContent.length <= 2000) { if (sfxListContent.length <= 2000) {
await message.channel.send(sfxListContent); await message.channel.send(sfxListContent);
} else { } else {
// Smart chunking that respects markdown block boundaries // Smart chunking that respects markdown block boundaries
const chunks = this.smartChunkMarkdown(sfxListContent, 1900); const chunks = this.smartChunkMarkdown(sfxListContent, 1900);
for (const chunk of chunks) { for (const chunk of chunks) {
await message.channel.send(chunk); await message.channel.send(chunk);
} }
} }
} else { } else {
// Fallback to generated list if README doesn't exist // Fallback to generated list if README doesn't exist
const sfxNames = sfxManager.getSFXNames(); const sfxNames = sfxManager.getSfxNames();
const sfxList = `**Available Sound Effects (${sfxNames.length}):**\n\`\`\`\n${sfxNames.join(', ')}\n\`\`\``; const sfxList = `**Available Sound Effects (${
sfxNames.length
}):**\n\`\`\`\n${sfxNames.join(", ")}\n\`\`\``;
await message.channel.send(sfxList); await message.channel.send(sfxList);
} }
} catch (error) { } catch (error) {
console.error('Error reading SFX list:', error); console.error("Error reading SFX list:", error);
await message.reply('Could not load the SFX list.'); await message.reply("Could not load the SFX list.");
} }
return; return;
} }
// Check if user is in a voice channel // Check if user is in a voice channel
if (!message.member.voice.channel) { if (!message.member.voice.channel) {
return message.reply('You need to be in a voice channel to use this command!'); return message.reply(
"You need to be in a voice channel to use this command!"
);
} }
// Use the reusable SFX playing method for messages // Use the reusable SFX playing method for messages
await sfxManager.playSFXMessage(message, sfxName, guildConfig); await sfxManager.playSfxMessage(message, sfxName, guildConfig);
} },
}; };

View File

@@ -1,50 +1,56 @@
const { SlashCommandBuilder, MessageFlags } = require('discord.js'); const { SlashCommandBuilder, MessageFlags } = require("discord.js");
const sfxManager = require('../../services/sfxManager'); const sfxManager = require("../../services/sfxManager");
const voiceService = require('../../services/voiceService'); const voiceService = require("../../services/voiceService");
module.exports = { module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('sfx') .setName("sfx")
.setDescription('Play a sound effect') .setDescription("Play a sound effect")
.addStringOption(option => .addStringOption((option) =>
option.setName('sound') option
.setDescription('The sound effect to play') .setName("sound")
.setDescription("The sound effect to play")
.setRequired(true) .setRequired(true)
.setAutocomplete(true) .setAutocomplete(true)
), ),
async execute(interaction, guildConfig) { async execute(interaction, guildConfig) {
const sfxName = interaction.options.getString('sound'); const sfxName = interaction.options.getString("sound");
// Check if user is in a voice channel // Check if user is in a voice channel
if (!interaction.member.voice.channel) { if (!interaction.member.voice.channel) {
return interaction.reply({ return interaction.reply({
content: 'You need to be in a voice channel to use this command!', content: "You need to be in a voice channel to use this command!",
flags: [MessageFlags.Ephemeral] flags: [MessageFlags.Ephemeral],
}); });
} }
// Use the reusable SFX playing method // Use the reusable SFX playing method
await sfxManager.playSFXInteraction(interaction, sfxName, guildConfig, 'slash'); await sfxManager.playSfxInteraction(
interaction,
sfxName,
guildConfig,
"slash"
);
}, },
async autocomplete(interaction, guildConfig) { async autocomplete(interaction, guildConfig) {
const focusedValue = interaction.options.getFocused().toLowerCase(); const focusedValue = interaction.options.getFocused().toLowerCase();
// Get all SFX names // Get all SFX names
const choices = sfxManager.getSFXNames(); const choices = sfxManager.getSfxNames();
// Filter based on what the user has typed // Filter based on what the user has typed
const filtered = choices const filtered = choices
.filter(choice => choice.toLowerCase().includes(focusedValue)) .filter((choice) => choice.toLowerCase().includes(focusedValue))
.slice(0, 25); // Discord limits autocomplete to 25 choices .slice(0, 25); // Discord limits autocomplete to 25 choices
// Respond with the filtered choices // Respond with the filtered choices
await interaction.respond( await interaction.respond(
filtered.map(choice => ({ filtered.map((choice) => ({
name: choice, name: choice,
value: choice value: choice,
})) }))
); );
} },
}; };

View File

@@ -9,10 +9,9 @@ const {
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const sfxManager = require("../../services/sfxManager"); const sfxManager = require("../../services/sfxManager");
const voiceService = require("../../services/voiceService");
// Parse categories from README.md // Parse categories from README.md
function getSFXCategories() { function getSfxCategories() {
try { try {
const sfxReadmePath = path.join( const sfxReadmePath = path.join(
__dirname, __dirname,
@@ -58,7 +57,7 @@ function getSFXCategories() {
module.exports = { module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("soundboard") .setName("sfxboard")
.setDescription("Interactive soundboard with categorized buttons"), .setDescription("Interactive soundboard with categorized buttons"),
async execute(interaction, guildConfig) { async execute(interaction, guildConfig) {
@@ -70,7 +69,7 @@ module.exports = {
}); });
} }
const categories = getSFXCategories(); const categories = getSfxCategories();
if (!categories) { if (!categories) {
return interaction.reply({ return interaction.reply({
@@ -112,8 +111,7 @@ module.exports = {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle("🎛️ Interactive Soundboard") .setTitle("🎛️ Interactive Soundboard")
.setDescription("Choose a category to browse sound effects:") .setDescription("Choose a category to browse sound effects:")
.setColor(0x21c629) .setColor(0x21c629);
.setFooter({ text: "Click a category button to browse sounds" });
await interaction.reply({ await interaction.reply({
embeds: [embed], embeds: [embed],
@@ -145,7 +143,7 @@ module.exports = {
.toUpperCase(); .toUpperCase();
} }
const categories = getSFXCategories(); const categories = getSfxCategories();
if (!categories || !categories[categoryKey]) { if (!categories || !categories[categoryKey]) {
return interaction.reply({ return interaction.reply({
@@ -155,7 +153,7 @@ module.exports = {
} }
const allSounds = categories[categoryKey].filter((sound) => const allSounds = categories[categoryKey].filter((sound) =>
sfxManager.hasSFX(sound) sfxManager.hasSfx(sound)
); );
const soundsPerPage = 16; // 4 sounds per row × 4 rows = 16 sounds per page const soundsPerPage = 16; // 4 sounds per row × 4 rows = 16 sounds per page
const totalPages = Math.ceil(allSounds.length / soundsPerPage); const totalPages = Math.ceil(allSounds.length / soundsPerPage);
@@ -252,7 +250,7 @@ module.exports = {
const soundName = interaction.customId.replace("soundboard_play_", ""); const soundName = interaction.customId.replace("soundboard_play_", "");
// Use the reusable SFX playing method // Use the reusable SFX playing method
await sfxManager.playSFXInteraction( await sfxManager.playSfxInteraction(
interaction, interaction,
soundName, soundName,
guildConfig, guildConfig,

View File

@@ -1,41 +1,9 @@
const fs = require("fs"); // Database-first configuration manager
const path = require("path");
// Dynamic config that combines file-based config with database
class ConfigManager { class ConfigManager {
constructor() { constructor() {
this.fileConfig = this.loadFileConfig();
this.databaseService = null; // Will be injected this.databaseService = null; // Will be injected
} }
/**
* 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;
}
/** /**
* Inject database service (to avoid circular dependency) * Inject database service (to avoid circular dependency)
*/ */
@@ -44,89 +12,68 @@ class ConfigManager {
} }
/** /**
* Get bot configuration (combines file and database) * Get bot configuration from database (with environment variable fallbacks)
*/ */
getBotConfig() { getBotConfig() {
const fileConfig = this.fileConfig; if (!this.databaseService) {
const dbConfig = this.databaseService // Fallback to environment variables if database not available
? this.databaseService.getBotConfiguration() return {
: {}; botName: "GHBot",
debug: false,
discord: {
token: process.env.DISCORD_TOKEN,
adminUserId: process.env.ADMIN_USER_ID,
activities: ["Playing sounds", "Serving facts"],
blacklistedUsers: [],
},
};
}
const dbConfig = this.databaseService.getBotConfiguration();
return { return {
// Use file config as fallback, database as primary botName: dbConfig.botName || "GHBot",
botName: dbConfig.botName || fileConfig.botName || "GHBot", debug: dbConfig.debug || false,
debug:
dbConfig.debug !== undefined
? dbConfig.debug
: fileConfig.debug || false,
discord: { discord: {
token: fileConfig.discord?.token || process.env.DISCORD_TOKEN, token: dbConfig.token || process.env.DISCORD_TOKEN,
adminUserId: adminUserId: dbConfig.adminUserId || process.env.ADMIN_USER_ID,
dbConfig.adminUserId || activities: dbConfig.activities || ["Playing sounds", "Serving facts"],
fileConfig.discord?.adminUserId || blacklistedUsers: dbConfig.blacklistedUsers || [],
process.env.ADMIN_USER_ID,
activities: dbConfig.activities ||
fileConfig.discord?.activities || ["Playing sounds", "Serving facts"],
blacklistedUsers:
dbConfig.blacklistedUsers ||
fileConfig.discord?.blacklistedUsers ||
[],
}, },
}; };
} }
/** /**
* Get guild configuration (from database primarily, file as fallback) * Get guild configuration (database only)
*/ */
getGuildConfig(guildId) { getGuildConfig(guildId) {
if (this.databaseService) { if (!this.databaseService) {
const dbConfig = this.databaseService.getGuildConfig(guildId); // Return minimal default config if database not available
if (dbConfig) { return {
return dbConfig; id: guildId,
} name: "Unknown Guild",
internalName: "Unknown Guild",
prefix: "!",
enableSfx: true,
sfxVolume: 0.5,
enableFunFacts: true,
enableHamFacts: true,
allowedRolesForRequest: [],
};
} }
// Fallback to file config for backward compatibility return this.databaseService.getGuildConfig(guildId);
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,
sfxVolume: 0.5,
enableFunFacts: true,
enableHamFacts: true,
allowedRolesForRequest: null,
};
} }
/** /**
* Get all guild configurations * Get all guild configurations (database only)
*/ */
getAllGuildConfigs() { getAllGuildConfigs() {
if (this.databaseService) { if (!this.databaseService) {
return this.databaseService.getAllGuildConfigs(); return [];
} }
// Fallback to file config return this.databaseService.getAllGuildConfigs();
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 [];
} }
} }

View File

@@ -67,6 +67,7 @@ class DatabaseService {
INSERT OR IGNORE INTO bot_config (key, value) VALUES INSERT OR IGNORE INTO bot_config (key, value) VALUES
('bot_name', 'GHBot'), ('bot_name', 'GHBot'),
('debug', 'false'), ('debug', 'false'),
('token', ''),
('admin_user_id', ''), ('admin_user_id', ''),
('activities', '["Chardee MacDennis", "The Nightman Cometh", "Charlie Work"]'), ('activities', '["Chardee MacDennis", "The Nightman Cometh", "Charlie Work"]'),
('blacklisted_users', '[]') ('blacklisted_users', '[]')
@@ -106,17 +107,17 @@ class DatabaseService {
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const configPath = path.join(__dirname, "..", "..", "config.json"); const seedPath = path.join(__dirname, "..", "..", "seed.json");
if (!fs.existsSync(configPath)) { if (!fs.existsSync(seedPath)) {
console.log("No config.json file found, skipping seed"); console.log("No seed.json file found, skipping seed");
return; return;
} }
const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); const config = JSON.parse(fs.readFileSync(seedPath, "utf-8"));
if (!config.discord?.guilds) { if (!config.discord?.guilds) {
console.log("No guilds found in config.json, skipping seed"); console.log("No guilds found in seed.json, skipping seed");
return; return;
} }
@@ -180,7 +181,7 @@ class DatabaseService {
} }
console.log( console.log(
`✅ Successfully seeded database with ${seededCount} guild(s) from config.json` `✅ Successfully seeded database with ${seededCount} guild(s) from seed.json`
); );
// Update bot configuration in database from file config // Update bot configuration in database from file config
@@ -190,6 +191,9 @@ class DatabaseService {
if (config.debug !== undefined) { if (config.debug !== undefined) {
this.setBotConfig("debug", config.debug.toString()); this.setBotConfig("debug", config.debug.toString());
} }
if (config.discord?.token) {
this.setBotConfig("token", config.discord.token);
}
if (config.discord?.adminUserId) { if (config.discord?.adminUserId) {
this.setBotConfig("admin_user_id", config.discord.adminUserId); this.setBotConfig("admin_user_id", config.discord.adminUserId);
} }
@@ -212,7 +216,7 @@ class DatabaseService {
); );
} }
console.log("✅ Bot configuration updated from config.json"); console.log("✅ Bot configuration updated from seed.json");
} catch (error) { } catch (error) {
console.error("Error seeding database from config file:", error); console.error("Error seeding database from config file:", error);
} }
@@ -510,6 +514,7 @@ class DatabaseService {
getBotConfiguration() { getBotConfiguration() {
const botName = this.getBotConfig("bot_name") || "GHBot"; const botName = this.getBotConfig("bot_name") || "GHBot";
const debug = this.getBotConfig("debug") === "true"; const debug = this.getBotConfig("debug") === "true";
const token = this.getBotConfig("token") || "";
const adminUserId = this.getBotConfig("admin_user_id") || ""; const adminUserId = this.getBotConfig("admin_user_id") || "";
const activities = JSON.parse(this.getBotConfig("activities") || "[]"); const activities = JSON.parse(this.getBotConfig("activities") || "[]");
const blacklistedUsers = JSON.parse( const blacklistedUsers = JSON.parse(
@@ -519,6 +524,7 @@ class DatabaseService {
return { return {
botName, botName,
debug, debug,
token,
adminUserId, adminUserId,
activities, activities,
blacklistedUsers, blacklistedUsers,

View File

@@ -1,4 +1,4 @@
const schedule = require('node-schedule'); const schedule = require("node-schedule");
class SchedulerService { class SchedulerService {
constructor() { constructor() {
@@ -7,14 +7,14 @@ class SchedulerService {
/** /**
* Initialize scheduled events for all guilds * Initialize scheduled events for all guilds
* @param {Client} client * @param {Client} client
* @param {ConfigManager} configManager * @param {ConfigManager} configManager
*/ */
async initialize(client, configManager) { async initialize(client, configManager) {
console.log('Initializing scheduled events...'); console.log("Initializing scheduled events...");
const guildConfigs = configManager.getAllGuildConfigs(); const guildConfigs = configManager.getAllGuildConfigs();
for (const guildConfig of guildConfigs) { for (const guildConfig of guildConfigs) {
try { try {
const guild = await client.guilds.fetch(guildConfig.id); const guild = await client.guilds.fetch(guildConfig.id);
@@ -27,45 +27,53 @@ class SchedulerService {
const databaseService = configManager.databaseService; const databaseService = configManager.databaseService;
if (!databaseService) continue; if (!databaseService) continue;
const scheduledEvents = databaseService.getScheduledEvents(guildConfig.id); const scheduledEvents = databaseService.getScheduledEvents(
guildConfig.id
);
if (!scheduledEvents || scheduledEvents.length === 0) { if (!scheduledEvents || scheduledEvents.length === 0) {
continue; continue;
} }
for (const event of scheduledEvents) { for (const event of scheduledEvents) {
await this.scheduleEvent(guild, event, guildConfig); await this.scheduleEvent(guild, event);
} }
} catch (error) { } catch (error) {
console.error(`Error setting up scheduled events for guild ${guildConfig.id}:`, error); console.error(
`Error setting up scheduled events for guild ${guildConfig.id}:`,
error
);
} }
} }
} }
/** /**
* Schedule a single event * Schedule a single event
* @param {Guild} guild * @param {Guild} guild
* @param {Object} event * @param {Object} event
* @param {Object} guildConfig
*/ */
async scheduleEvent(guild, event, guildConfig) { async scheduleEvent(guild, event) {
try { try {
// Validate channel // Validate channel
let channel = null; let channel = null;
if (event.channelId) { if (event.channel_id) {
channel = await guild.channels.fetch(event.channelId); channel = await guild.channels.fetch(event.channel_id);
if (!channel) { if (!channel) {
console.error(`Invalid channel ${event.channelId} for event ${event.id} in guild ${guild.name}`); console.error(
`Invalid channel ${event.channel_id} for event ${event.id} in guild ${guild.name}`
);
return; return;
} }
} }
// Validate role // Validate role
let pingRole = null; let pingRole = null;
if (event.pingRoleId) { if (event.ping_role_id) {
pingRole = await guild.roles.fetch(event.pingRoleId); pingRole = await guild.roles.fetch(event.ping_role_id);
if (!pingRole) { if (!pingRole) {
console.warn(`Invalid role ${event.pingRoleId} for event ${event.id} in guild ${guild.name}`); console.warn(
`Invalid role ${event.ping_role_id} for event ${event.id} in guild ${guild.name}`
);
} }
} }
@@ -80,10 +88,16 @@ class SchedulerService {
// Store job reference // Store job reference
const jobKey = `${guild.id}-${event.id}`; const jobKey = `${guild.id}-${event.id}`;
this.jobs.set(jobKey, job); this.jobs.set(jobKey, job);
console.log(`Event ${event.id} scheduled. Next invocation: ${job.nextInvocation()}`); console.log(
`Event ${
event.id
} scheduled. Next invocation: ${job.nextInvocation()}`
);
} else { } else {
console.error(`Failed to schedule event ${event.id} - invalid cron expression: ${event.schedule}`); console.error(
`Failed to schedule event ${event.id} with schedule: ${event.schedule}`
);
} }
} catch (error) { } catch (error) {
console.error(`Error scheduling event ${event.id}:`, error); console.error(`Error scheduling event ${event.id}:`, error);
@@ -92,9 +106,9 @@ class SchedulerService {
/** /**
* Execute a scheduled event * Execute a scheduled event
* @param {TextChannel} channel * @param {TextChannel} channel
* @param {Object} event * @param {Object} event
* @param {Role} pingRole * @param {Role} pingRole
*/ */
async executeEvent(channel, event, pingRole) { async executeEvent(channel, event, pingRole) {
try { try {
@@ -112,7 +126,7 @@ class SchedulerService {
// Send the message // Send the message
if (content.length > 0 && channel) { if (content.length > 0 && channel) {
await channel.send(content.join(' ')); await channel.send(content.join(" "));
console.log(`Executed scheduled event ${event.id}`); console.log(`Executed scheduled event ${event.id}`);
} }
} catch (error) { } catch (error) {
@@ -122,13 +136,13 @@ class SchedulerService {
/** /**
* Cancel a scheduled job * Cancel a scheduled job
* @param {string} guildId * @param {string} guildId
* @param {string} eventId * @param {string} eventId
*/ */
cancelJob(guildId, eventId) { cancelJob(guildId, eventId) {
const jobKey = `${guildId}-${eventId}`; const jobKey = `${guildId}-${eventId}`;
const job = this.jobs.get(jobKey); const job = this.jobs.get(jobKey);
if (job) { if (job) {
job.cancel(); job.cancel();
this.jobs.delete(jobKey); this.jobs.delete(jobKey);
@@ -138,7 +152,7 @@ class SchedulerService {
/** /**
* Cancel all jobs for a guild * Cancel all jobs for a guild
* @param {string} guildId * @param {string} guildId
*/ */
cancelGuildJobs(guildId) { cancelGuildJobs(guildId) {
for (const [key, job] of this.jobs) { for (const [key, job] of this.jobs) {
@@ -158,8 +172,8 @@ class SchedulerService {
job.cancel(); job.cancel();
} }
this.jobs.clear(); this.jobs.clear();
console.log('Cancelled all scheduled events'); console.log("Cancelled all scheduled events");
} }
} }
module.exports = new SchedulerService(); module.exports = new SchedulerService();

View File

@@ -1,66 +1,66 @@
const fs = require('fs'); const fs = require("fs");
const path = require('path'); const path = require("path");
const { MessageFlags } = require('discord.js'); const { MessageFlags } = require("discord.js");
const voiceService = require('./voiceService'); const voiceService = require("./voiceService");
class SFXManager { class SfxManager {
constructor() { constructor() {
this.sfxPath = path.join(__dirname, '..', '..', 'sfx'); this.sfxPath = path.join(__dirname, "..", "..", "sfx");
this.sfxList = []; this.sfxList = [];
this.cachedNames = []; this.cachedNames = [];
this.searchCache = new Map(); // Cache for autocomplete searches this.searchCache = new Map(); // Cache for autocomplete searches
// Load SFX list initially // Load SFX list initially
this.loadSFXList(); this.loadSfxList();
// Watch for changes // Watch for changes
this.watchSFXDirectory(); this.watchSfxDirectory();
} }
/** /**
* Load the list of available SFX files * Load the list of available SFX files
*/ */
loadSFXList() { loadSfxList() {
try { try {
if (!fs.existsSync(this.sfxPath)) { if (!fs.existsSync(this.sfxPath)) {
console.log('SFX directory not found, creating...'); console.log("SFX directory not found, creating...");
fs.mkdirSync(this.sfxPath, { recursive: true }); fs.mkdirSync(this.sfxPath, { recursive: true });
} }
const files = fs.readdirSync(this.sfxPath); const files = fs.readdirSync(this.sfxPath);
this.sfxList = files this.sfxList = files
.filter(file => file.endsWith('.mp3') || file.endsWith('.wav')) .filter((file) => file.endsWith(".mp3") || file.endsWith(".wav"))
.map(file => { .map((file) => {
const ext = path.extname(file); const ext = path.extname(file);
return { return {
name: file.replace(ext, ''), name: file.replace(ext, ""),
filename: file, filename: file,
path: path.join(this.sfxPath, file) path: path.join(this.sfxPath, file),
}; };
}); });
// Cache sorted names for autocomplete // Cache sorted names for autocomplete
this.cachedNames = this.sfxList this.cachedNames = this.sfxList
.map(sfx => sfx.name) .map((sfx) => sfx.name)
.sort((a, b) => a.localeCompare(b)); .sort((a, b) => a.localeCompare(b));
// Clear search cache when SFX list changes // Clear search cache when SFX list changes
this.searchCache.clear(); this.searchCache.clear();
console.log(`Loaded ${this.sfxList.length} sound effects`); console.log(`Loaded ${this.sfxList.length} sound effects`);
} catch (error) { } catch (error) {
console.error('Error loading SFX list:', error); console.error("Error loading SFX list:", error);
} }
} }
/** /**
* Watch the SFX directory for changes * Watch the SFX directory for changes
*/ */
watchSFXDirectory() { watchSfxDirectory() {
fs.watch(this.sfxPath, (eventType, filename) => { fs.watch(this.sfxPath, (eventType, filename) => {
if (eventType === 'rename') { if (eventType === "rename") {
console.log('SFX directory changed, reloading...'); console.log("SFX directory changed, reloading...");
this.loadSFXList(); this.loadSfxList();
} }
}); });
} }
@@ -69,7 +69,7 @@ class SFXManager {
* Get all available SFX * Get all available SFX
* @returns {Array} List of SFX objects * @returns {Array} List of SFX objects
*/ */
getAllSFX() { getAllSfx() {
return this.sfxList; return this.sfxList;
} }
@@ -77,59 +77,61 @@ class SFXManager {
* Get SFX names for autocomplete (cached and sorted) * Get SFX names for autocomplete (cached and sorted)
* @returns {Array} List of SFX names * @returns {Array} List of SFX names
*/ */
getSFXNames() { getSfxNames() {
return this.cachedNames; return this.cachedNames;
} }
/** /**
* Find an SFX by name * Find an SFX by name
* @param {string} name * @param {string} name
* @returns {Object|undefined} SFX object or undefined * @returns {Object|undefined} SFX object or undefined
*/ */
findSFX(name) { findSfx(name) {
return this.sfxList.find(sfx => sfx.name.toLowerCase() === name.toLowerCase()); return this.sfxList.find(
(sfx) => sfx.name.toLowerCase() === name.toLowerCase()
);
} }
/** /**
* Check if an SFX exists * Check if an SFX exists
* @param {string} name * @param {string} name
* @returns {boolean} * @returns {boolean}
*/ */
hasSFX(name) { hasSfx(name) {
return this.findSFX(name) !== undefined; return this.findSfx(name) !== undefined;
} }
/** /**
* Get the file path for an SFX * Get the file path for an SFX
* @param {string} name * @param {string} name
* @returns {string|null} * @returns {string|null}
*/ */
getSFXPath(name) { getSfxPath(name) {
const sfx = this.findSFX(name); const sfx = this.findSfx(name);
return sfx ? sfx.path : null; return sfx ? sfx.path : null;
} }
/** /**
* Search SFX names (for autocomplete) with caching * Search SFX names (for autocomplete) with caching
* @param {string} query * @param {string} query
* @returns {Array} Matching SFX names * @returns {Array} Matching SFX names
*/ */
searchSFX(query) { searchSfx(query) {
const lowerQuery = query.toLowerCase(); const lowerQuery = query.toLowerCase();
// Check cache first // Check cache first
if (this.searchCache.has(lowerQuery)) { if (this.searchCache.has(lowerQuery)) {
return this.searchCache.get(lowerQuery); return this.searchCache.get(lowerQuery);
} }
// Perform search on cached names (already sorted) // Perform search on cached names (already sorted)
const results = this.cachedNames const results = this.cachedNames
.filter(name => name.toLowerCase().includes(lowerQuery)) .filter((name) => name.toLowerCase().includes(lowerQuery))
.slice(0, 25); // Discord autocomplete limit .slice(0, 25); // Discord autocomplete limit
// Cache the result for future use // Cache the result for future use
this.searchCache.set(lowerQuery, results); this.searchCache.set(lowerQuery, results);
return results; return results;
} }
@@ -141,18 +143,25 @@ class SFXManager {
* @param {string} commandType - Type of command ('slash' or 'soundboard') * @param {string} commandType - Type of command ('slash' or 'soundboard')
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async playSFXInteraction(interaction, sfxName, guildConfig, commandType = 'slash') { async playSfxInteraction(
interaction,
sfxName,
guildConfig,
commandType = "slash"
) {
// Log the request // Log the request
const logPrefix = commandType === 'soundboard' ? 'Soundboard' : '/sfx'; const logPrefix = commandType === "soundboard" ? "Soundboard" : "/sfx";
console.log( console.log(
`${logPrefix} '${sfxName}' requested in ${guildConfig.internalName || interaction.guild.name}#${interaction.channel.name} from @${interaction.user.username}` `${logPrefix} '${sfxName}' requested in ${
guildConfig.internalName || interaction.guild.name
}#${interaction.channel.name} from @${interaction.user.username}`
); );
// Check if SFX exists // Check if SFX exists
if (!this.hasSFX(sfxName)) { if (!this.hasSfx(sfxName)) {
await interaction.reply({ await interaction.reply({
content: `❌ This sound effect does not exist!`, content: `❌ This sound effect does not exist!`,
flags: [MessageFlags.Ephemeral] flags: [MessageFlags.Ephemeral],
}); });
return; return;
} }
@@ -161,14 +170,14 @@ class SFXManager {
// Immediately reply with playing status // Immediately reply with playing status
await interaction.reply({ await interaction.reply({
content: `🔊 Playing: **${sfxName}**`, content: `🔊 Playing: **${sfxName}**`,
flags: [MessageFlags.Ephemeral] flags: [MessageFlags.Ephemeral],
}); });
// Join the voice channel // Join the voice channel
await voiceService.join(interaction.member.voice.channel); await voiceService.join(interaction.member.voice.channel);
// Get the SFX file path and play // Get the SFX file path and play
const sfxPath = this.getSFXPath(sfxName); const sfxPath = this.getSfxPath(sfxName);
await voiceService.play(interaction.guild.id, sfxPath, { await voiceService.play(interaction.guild.id, sfxPath, {
volume: guildConfig.sfxVolume || 0.5, volume: guildConfig.sfxVolume || 0.5,
}); });
@@ -176,10 +185,13 @@ class SFXManager {
// Update the interaction to show completion // Update the interaction to show completion
try { try {
await interaction.editReply({ await interaction.editReply({
content: `✅ Finished playing: **${sfxName}**` content: `✅ Finished playing: **${sfxName}**`,
}); });
} catch (editError) { } catch (editError) {
console.error('Error updating interaction with completion message:', editError); console.error(
"Error updating interaction with completion message:",
editError
);
} }
// Leave the voice channel after playing // Leave the voice channel after playing
@@ -187,18 +199,26 @@ class SFXManager {
voiceService.leave(interaction.guild.id); voiceService.leave(interaction.guild.id);
}, 500); }, 500);
console.log(`✅ Successfully played ${logPrefix.toLowerCase()} '${sfxName}'`); console.log(
`✅ Successfully played ${logPrefix.toLowerCase()} '${sfxName}'`
);
} catch (error) { } catch (error) {
console.error(`❌ Error playing ${logPrefix.toLowerCase()} '${sfxName}':`, error); console.error(
`❌ Error playing ${logPrefix.toLowerCase()} '${sfxName}':`,
error
);
// Update the reply with error message // Update the reply with error message
try { try {
await interaction.editReply({ await interaction.editReply({
content: "❌ Couldn't play that sound effect. Make sure I have permission to join your voice channel!" content:
"❌ Couldn't play that sound effect. Make sure I have permission to join your voice channel!",
}); });
} catch (editError) { } catch (editError) {
console.error('Error updating interaction with error message:', editError); console.error(
"Error updating interaction with error message:",
editError
);
} }
} }
} }
@@ -210,33 +230,35 @@ class SFXManager {
* @param {Object} guildConfig - Guild configuration * @param {Object} guildConfig - Guild configuration
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async playSFXMessage(message, sfxName, guildConfig) { async playSfxMessage(message, sfxName, guildConfig) {
// Log the request // Log the request
console.log( console.log(
`SFX '${sfxName}' requested in ${guildConfig.internalName || message.guild.name}#${message.channel.name} from @${message.author.username}` `SFX '${sfxName}' requested in ${
guildConfig.internalName || message.guild.name
}#${message.channel.name} from @${message.author.username}`
); );
// Check if SFX exists // Check if SFX exists
if (!this.hasSFX(sfxName)) { if (!this.hasSfx(sfxName)) {
await message.reply('❌ This sound effect does not exist!'); await message.reply("❌ This sound effect does not exist!");
return; return;
} }
try { try {
// React with speaker icon to show playing status // React with speaker icon to show playing status
await message.react('🔊'); await message.react("🔊");
// Join the voice channel // Join the voice channel
await voiceService.join(message.member.voice.channel); await voiceService.join(message.member.voice.channel);
// Get the SFX file path and play // Get the SFX file path and play
const sfxPath = this.getSFXPath(sfxName); const sfxPath = this.getSfxPath(sfxName);
await voiceService.play(message.guild.id, sfxPath, { await voiceService.play(message.guild.id, sfxPath, {
volume: guildConfig.sfxVolume || 0.5, volume: guildConfig.sfxVolume || 0.5,
}); });
// Add completion reaction (keep both speaker and checkmark) // Add completion reaction (keep both speaker and checkmark)
await message.react('✅'); await message.react("✅");
// Leave the voice channel after playing // Leave the voice channel after playing
setTimeout(() => { setTimeout(() => {
@@ -244,19 +266,20 @@ class SFXManager {
}, 500); }, 500);
console.log(`✅ Successfully played SFX '${sfxName}'`); console.log(`✅ Successfully played SFX '${sfxName}'`);
} catch (error) { } catch (error) {
console.error(`❌ Error playing SFX '${sfxName}':`, error); console.error(`❌ Error playing SFX '${sfxName}':`, error);
// Add error reaction // Add error reaction
try { try {
await message.react('❌'); await message.react("❌");
} catch (reactionError) { } catch (reactionError) {
// If reactions fail, fall back to reply // If reactions fail, fall back to reply
await message.reply("❌ Couldn't play that sound effect. Make sure I have permission to join your voice channel!"); await message.reply(
"❌ Couldn't play that sound effect. Make sure I have permission to join your voice channel!"
);
} }
} }
} }
} }
module.exports = new SFXManager(); module.exports = new SfxManager();

View File

@@ -6,13 +6,12 @@ const {
AudioPlayerStatus, AudioPlayerStatus,
entersState, entersState,
getVoiceConnection, getVoiceConnection,
generateDependencyReport } = require("@discordjs/voice");
} = require('@discordjs/voice'); const { ChannelType } = require("discord.js");
const { ChannelType } = require('discord.js');
// Try to use ffmpeg-static as fallback if system ffmpeg is not available // Try to use ffmpeg-static as fallback if system ffmpeg is not available
try { try {
const ffmpegPath = require('ffmpeg-static'); const ffmpegPath = require("ffmpeg-static");
if (ffmpegPath && !process.env.FFMPEG_PATH) { if (ffmpegPath && !process.env.FFMPEG_PATH) {
process.env.FFMPEG_PATH = ffmpegPath; process.env.FFMPEG_PATH = ffmpegPath;
} }
@@ -28,17 +27,17 @@ class VoiceService {
/** /**
* Join a voice channel * Join a voice channel
* @param {VoiceChannel} channel * @param {VoiceChannel} channel
* @returns {VoiceConnection} * @returns {VoiceConnection}
*/ */
async join(channel) { async join(channel) {
if (!channel || channel.type !== ChannelType.GuildVoice) { if (!channel || channel.type !== ChannelType.GuildVoice) {
throw new Error('Invalid voice channel'); throw new Error("Invalid voice channel");
} }
// Check if already connected // Check if already connected
let connection = getVoiceConnection(channel.guild.id); let connection = getVoiceConnection(channel.guild.id);
if (!connection) { if (!connection) {
connection = joinVoiceChannel({ connection = joinVoiceChannel({
channelId: channel.id, channelId: channel.id,
@@ -79,7 +78,7 @@ class VoiceService {
/** /**
* Leave a voice channel * Leave a voice channel
* @param {string} guildId * @param {string} guildId
*/ */
leave(guildId) { leave(guildId) {
const connection = this.connections.get(guildId); const connection = this.connections.get(guildId);
@@ -92,15 +91,15 @@ class VoiceService {
/** /**
* Play an audio file * Play an audio file
* @param {string} guildId * @param {string} guildId
* @param {string} filePath * @param {string} filePath
* @param {Object} options * @param {Object} options
* @returns {AudioPlayer} * @returns {AudioPlayer}
*/ */
async play(guildId, filePath, options = {}) { async play(guildId, filePath, options = {}) {
const connection = this.connections.get(guildId); const connection = this.connections.get(guildId);
if (!connection) { if (!connection) {
throw new Error('Not connected to voice channel'); throw new Error("Not connected to voice channel");
} }
// Create or get player for this guild // Create or get player for this guild
@@ -131,8 +130,8 @@ class VoiceService {
resolve(); resolve();
}); });
player.once('error', (error) => { player.once("error", (error) => {
console.error('Player error:', error); console.error("Player error:", error);
reject(error); reject(error);
}); });
}); });
@@ -140,7 +139,7 @@ class VoiceService {
/** /**
* Stop playing audio * Stop playing audio
* @param {string} guildId * @param {string} guildId
*/ */
stop(guildId) { stop(guildId) {
const player = this.players.get(guildId); const player = this.players.get(guildId);
@@ -151,7 +150,7 @@ class VoiceService {
/** /**
* Check if connected to a voice channel * Check if connected to a voice channel
* @param {string} guildId * @param {string} guildId
* @returns {boolean} * @returns {boolean}
*/ */
isConnected(guildId) { isConnected(guildId) {
@@ -160,7 +159,7 @@ class VoiceService {
/** /**
* Get the current voice connection * Get the current voice connection
* @param {string} guildId * @param {string} guildId
* @returns {VoiceConnection|undefined} * @returns {VoiceConnection|undefined}
*/ */
getConnection(guildId) { getConnection(guildId) {
@@ -168,4 +167,4 @@ class VoiceService {
} }
} }
module.exports = new VoiceService(); module.exports = new VoiceService();