6 Commits

Author SHA1 Message Date
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
13 changed files with 290 additions and 287 deletions

4
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -32,7 +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'",
"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:run": "docker run -d --name ghbot --restart always ghbot:${VERSION:-latest}"
},

View File

@@ -1,24 +1,21 @@
const axios = require('axios');
const { chunkSubstr } = require('../../utils/helpers');
const sfxManager = require('../../services/sfxManager');
const voiceService = require('../../services/voiceService');
const sfxManager = require("../../services/sfxManager");
module.exports = {
name: 'sfx',
description: 'Play a sound effect',
name: "sfx",
description: "Play a sound effect",
/**
* Smart chunking that respects markdown block boundaries
* @param {string} content
* @param {number} maxLength
* @param {string} content
* @param {number} maxLength
* @returns {Array<string>}
*/
smartChunkMarkdown(content, maxLength) {
const chunks = [];
const sections = content.split(/(\*\*[^*]+\*\*)/); // Split on headers while keeping them
let currentChunk = '';
let currentChunk = "";
for (const section of sections) {
// If adding this section would exceed the limit
if (currentChunk.length + section.length > maxLength) {
@@ -31,58 +28,69 @@ module.exports = {
currentChunk += section;
}
}
// Add the final chunk
if (currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
return chunks;
},
async execute(message, args, guildConfig) {
const sfxName = args[0];
// If no SFX specified, show the list
if (!sfxName) {
try {
const fs = require('fs');
const path = require('path');
const sfxReadmePath = path.join(__dirname, '..', '..', '..', 'sfx', 'README.md');
const fs = require("fs");
const path = require("path");
const sfxReadmePath = path.join(
__dirname,
"..",
"..",
"..",
"sfx",
"README.md"
);
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)
if (sfxListContent.length <= 2000) {
await message.channel.send(sfxListContent);
} else {
// Smart chunking that respects markdown block boundaries
const chunks = this.smartChunkMarkdown(sfxListContent, 1900);
for (const chunk of chunks) {
await message.channel.send(chunk);
}
}
} else {
// Fallback to generated list if README doesn't exist
const sfxNames = sfxManager.getSFXNames();
const sfxList = `**Available Sound Effects (${sfxNames.length}):**\n\`\`\`\n${sfxNames.join(', ')}\n\`\`\``;
const sfxNames = sfxManager.getSfxNames();
const sfxList = `**Available Sound Effects (${
sfxNames.length
}):**\n\`\`\`\n${sfxNames.join(", ")}\n\`\`\``;
await message.channel.send(sfxList);
}
} catch (error) {
console.error('Error reading SFX list:', error);
await message.reply('Could not load the SFX list.');
console.error("Error reading SFX list:", error);
await message.reply("Could not load the SFX list.");
}
return;
}
// Check if user is in a 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
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 sfxManager = require('../../services/sfxManager');
const voiceService = require('../../services/voiceService');
const { SlashCommandBuilder, MessageFlags } = require("discord.js");
const sfxManager = require("../../services/sfxManager");
const voiceService = require("../../services/voiceService");
module.exports = {
data: new SlashCommandBuilder()
.setName('sfx')
.setDescription('Play a sound effect')
.addStringOption(option =>
option.setName('sound')
.setDescription('The sound effect to play')
.setName("sfx")
.setDescription("Play a sound effect")
.addStringOption((option) =>
option
.setName("sound")
.setDescription("The sound effect to play")
.setRequired(true)
.setAutocomplete(true)
),
async execute(interaction, guildConfig) {
const sfxName = interaction.options.getString('sound');
const sfxName = interaction.options.getString("sound");
// Check if user is in a voice channel
if (!interaction.member.voice.channel) {
return interaction.reply({
content: 'You need to be in a voice channel to use this command!',
flags: [MessageFlags.Ephemeral]
return interaction.reply({
content: "You need to be in a voice channel to use this command!",
flags: [MessageFlags.Ephemeral],
});
}
// Use the reusable SFX playing method
await sfxManager.playSFXInteraction(interaction, sfxName, guildConfig, 'slash');
await sfxManager.playSfxInteraction(
interaction,
sfxName,
guildConfig,
"slash"
);
},
async autocomplete(interaction, guildConfig) {
const focusedValue = interaction.options.getFocused().toLowerCase();
// Get all SFX names
const choices = sfxManager.getSFXNames();
const choices = sfxManager.getSfxNames();
// Filter based on what the user has typed
const filtered = choices
.filter(choice => choice.toLowerCase().includes(focusedValue))
.filter((choice) => choice.toLowerCase().includes(focusedValue))
.slice(0, 25); // Discord limits autocomplete to 25 choices
// Respond with the filtered choices
await interaction.respond(
filtered.map(choice => ({
name: choice,
value: choice
filtered.map((choice) => ({
name: choice,
value: choice,
}))
);
}
};
},
};

View File

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

View File

@@ -1,41 +1,9 @@
const fs = require("fs");
const path = require("path");
// Dynamic config that combines file-based config with database
// Database-first configuration manager
class ConfigManager {
constructor() {
this.fileConfig = this.loadFileConfig();
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)
*/
@@ -44,89 +12,68 @@ class ConfigManager {
}
/**
* Get bot configuration (combines file and database)
* Get bot configuration from database (with environment variable fallbacks)
*/
getBotConfig() {
const fileConfig = this.fileConfig;
const dbConfig = this.databaseService
? this.databaseService.getBotConfiguration()
: {};
if (!this.databaseService) {
// Fallback to environment variables if database not available
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 {
// 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 || "GHBot",
debug: dbConfig.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 ||
[],
token: dbConfig.token || process.env.DISCORD_TOKEN,
adminUserId: dbConfig.adminUserId || process.env.ADMIN_USER_ID,
activities: dbConfig.activities || ["Playing sounds", "Serving facts"],
blacklistedUsers: dbConfig.blacklistedUsers || [],
},
};
}
/**
* Get guild configuration (from database primarily, file as fallback)
* Get guild configuration (database only)
*/
getGuildConfig(guildId) {
if (this.databaseService) {
const dbConfig = this.databaseService.getGuildConfig(guildId);
if (dbConfig) {
return dbConfig;
}
if (!this.databaseService) {
// Return minimal default config if database not available
return {
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
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,
};
return this.databaseService.getGuildConfig(guildId);
}
/**
* Get all guild configurations
* Get all guild configurations (database only)
*/
getAllGuildConfigs() {
if (this.databaseService) {
return this.databaseService.getAllGuildConfigs();
if (!this.databaseService) {
return [];
}
// 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 [];
return this.databaseService.getAllGuildConfigs();
}
}

View File

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

View File

@@ -1,4 +1,4 @@
const schedule = require('node-schedule');
const schedule = require("node-schedule");
class SchedulerService {
constructor() {
@@ -7,14 +7,14 @@ class SchedulerService {
/**
* Initialize scheduled events for all guilds
* @param {Client} client
* @param {ConfigManager} configManager
* @param {Client} client
* @param {ConfigManager} configManager
*/
async initialize(client, configManager) {
console.log('Initializing scheduled events...');
console.log("Initializing scheduled events...");
const guildConfigs = configManager.getAllGuildConfigs();
for (const guildConfig of guildConfigs) {
try {
const guild = await client.guilds.fetch(guildConfig.id);
@@ -27,45 +27,53 @@ class SchedulerService {
const databaseService = configManager.databaseService;
if (!databaseService) continue;
const scheduledEvents = databaseService.getScheduledEvents(guildConfig.id);
const scheduledEvents = databaseService.getScheduledEvents(
guildConfig.id
);
if (!scheduledEvents || scheduledEvents.length === 0) {
continue;
}
for (const event of scheduledEvents) {
await this.scheduleEvent(guild, event, guildConfig);
await this.scheduleEvent(guild, event);
}
} 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
* @param {Guild} guild
* @param {Object} event
* @param {Object} guildConfig
* @param {Guild} guild
* @param {Object} event
*/
async scheduleEvent(guild, event, guildConfig) {
async scheduleEvent(guild, event) {
try {
// Validate channel
let channel = null;
if (event.channelId) {
channel = await guild.channels.fetch(event.channelId);
if (event.channel_id) {
channel = await guild.channels.fetch(event.channel_id);
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;
}
}
// Validate role
let pingRole = null;
if (event.pingRoleId) {
pingRole = await guild.roles.fetch(event.pingRoleId);
if (event.ping_role_id) {
pingRole = await guild.roles.fetch(event.ping_role_id);
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
const jobKey = `${guild.id}-${event.id}`;
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 {
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) {
console.error(`Error scheduling event ${event.id}:`, error);
@@ -92,9 +106,9 @@ class SchedulerService {
/**
* Execute a scheduled event
* @param {TextChannel} channel
* @param {Object} event
* @param {Role} pingRole
* @param {TextChannel} channel
* @param {Object} event
* @param {Role} pingRole
*/
async executeEvent(channel, event, pingRole) {
try {
@@ -112,7 +126,7 @@ class SchedulerService {
// Send the message
if (content.length > 0 && channel) {
await channel.send(content.join(' '));
await channel.send(content.join(" "));
console.log(`Executed scheduled event ${event.id}`);
}
} catch (error) {
@@ -122,13 +136,13 @@ class SchedulerService {
/**
* Cancel a scheduled job
* @param {string} guildId
* @param {string} eventId
* @param {string} guildId
* @param {string} eventId
*/
cancelJob(guildId, eventId) {
const jobKey = `${guildId}-${eventId}`;
const job = this.jobs.get(jobKey);
if (job) {
job.cancel();
this.jobs.delete(jobKey);
@@ -138,7 +152,7 @@ class SchedulerService {
/**
* Cancel all jobs for a guild
* @param {string} guildId
* @param {string} guildId
*/
cancelGuildJobs(guildId) {
for (const [key, job] of this.jobs) {
@@ -158,8 +172,8 @@ class SchedulerService {
job.cancel();
}
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 path = require('path');
const { MessageFlags } = require('discord.js');
const voiceService = require('./voiceService');
const fs = require("fs");
const path = require("path");
const { MessageFlags } = require("discord.js");
const voiceService = require("./voiceService");
class SFXManager {
class SfxManager {
constructor() {
this.sfxPath = path.join(__dirname, '..', '..', 'sfx');
this.sfxPath = path.join(__dirname, "..", "..", "sfx");
this.sfxList = [];
this.cachedNames = [];
this.searchCache = new Map(); // Cache for autocomplete searches
// Load SFX list initially
this.loadSFXList();
this.loadSfxList();
// Watch for changes
this.watchSFXDirectory();
this.watchSfxDirectory();
}
/**
* Load the list of available SFX files
*/
loadSFXList() {
loadSfxList() {
try {
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 });
}
const files = fs.readdirSync(this.sfxPath);
this.sfxList = files
.filter(file => file.endsWith('.mp3') || file.endsWith('.wav'))
.map(file => {
.filter((file) => file.endsWith(".mp3") || file.endsWith(".wav"))
.map((file) => {
const ext = path.extname(file);
return {
name: file.replace(ext, ''),
name: file.replace(ext, ""),
filename: file,
path: path.join(this.sfxPath, file)
path: path.join(this.sfxPath, file),
};
});
// Cache sorted names for autocomplete
this.cachedNames = this.sfxList
.map(sfx => sfx.name)
.map((sfx) => sfx.name)
.sort((a, b) => a.localeCompare(b));
// Clear search cache when SFX list changes
this.searchCache.clear();
console.log(`Loaded ${this.sfxList.length} sound effects`);
} catch (error) {
console.error('Error loading SFX list:', error);
console.error("Error loading SFX list:", error);
}
}
/**
* Watch the SFX directory for changes
*/
watchSFXDirectory() {
watchSfxDirectory() {
fs.watch(this.sfxPath, (eventType, filename) => {
if (eventType === 'rename') {
console.log('SFX directory changed, reloading...');
this.loadSFXList();
if (eventType === "rename") {
console.log("SFX directory changed, reloading...");
this.loadSfxList();
}
});
}
@@ -69,7 +69,7 @@ class SFXManager {
* Get all available SFX
* @returns {Array} List of SFX objects
*/
getAllSFX() {
getAllSfx() {
return this.sfxList;
}
@@ -77,59 +77,61 @@ class SFXManager {
* Get SFX names for autocomplete (cached and sorted)
* @returns {Array} List of SFX names
*/
getSFXNames() {
getSfxNames() {
return this.cachedNames;
}
/**
* Find an SFX by name
* @param {string} name
* @param {string} name
* @returns {Object|undefined} SFX object or undefined
*/
findSFX(name) {
return this.sfxList.find(sfx => sfx.name.toLowerCase() === name.toLowerCase());
findSfx(name) {
return this.sfxList.find(
(sfx) => sfx.name.toLowerCase() === name.toLowerCase()
);
}
/**
* Check if an SFX exists
* @param {string} name
* @param {string} name
* @returns {boolean}
*/
hasSFX(name) {
return this.findSFX(name) !== undefined;
hasSfx(name) {
return this.findSfx(name) !== undefined;
}
/**
* Get the file path for an SFX
* @param {string} name
* @param {string} name
* @returns {string|null}
*/
getSFXPath(name) {
const sfx = this.findSFX(name);
getSfxPath(name) {
const sfx = this.findSfx(name);
return sfx ? sfx.path : null;
}
/**
* Search SFX names (for autocomplete) with caching
* @param {string} query
* @param {string} query
* @returns {Array} Matching SFX names
*/
searchSFX(query) {
searchSfx(query) {
const lowerQuery = query.toLowerCase();
// Check cache first
if (this.searchCache.has(lowerQuery)) {
return this.searchCache.get(lowerQuery);
}
// Perform search on cached names (already sorted)
const results = this.cachedNames
.filter(name => name.toLowerCase().includes(lowerQuery))
.filter((name) => name.toLowerCase().includes(lowerQuery))
.slice(0, 25); // Discord autocomplete limit
// Cache the result for future use
this.searchCache.set(lowerQuery, results);
return results;
}
@@ -141,18 +143,25 @@ class SFXManager {
* @param {string} commandType - Type of command ('slash' or 'soundboard')
* @returns {Promise<void>}
*/
async playSFXInteraction(interaction, sfxName, guildConfig, commandType = 'slash') {
async playSfxInteraction(
interaction,
sfxName,
guildConfig,
commandType = "slash"
) {
// Log the request
const logPrefix = commandType === 'soundboard' ? 'Soundboard' : '/sfx';
const logPrefix = commandType === "soundboard" ? "Soundboard" : "/sfx";
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
if (!this.hasSFX(sfxName)) {
if (!this.hasSfx(sfxName)) {
await interaction.reply({
content: `❌ This sound effect does not exist!`,
flags: [MessageFlags.Ephemeral]
flags: [MessageFlags.Ephemeral],
});
return;
}
@@ -161,14 +170,14 @@ class SFXManager {
// Immediately reply with playing status
await interaction.reply({
content: `🔊 Playing: **${sfxName}**`,
flags: [MessageFlags.Ephemeral]
flags: [MessageFlags.Ephemeral],
});
// Join the voice channel
await voiceService.join(interaction.member.voice.channel);
// Get the SFX file path and play
const sfxPath = this.getSFXPath(sfxName);
const sfxPath = this.getSfxPath(sfxName);
await voiceService.play(interaction.guild.id, sfxPath, {
volume: guildConfig.sfxVolume || 0.5,
});
@@ -176,10 +185,13 @@ class SFXManager {
// Update the interaction to show completion
try {
await interaction.editReply({
content: `✅ Finished playing: **${sfxName}**`
content: `✅ Finished playing: **${sfxName}**`,
});
} 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
@@ -187,18 +199,26 @@ class SFXManager {
voiceService.leave(interaction.guild.id);
}, 500);
console.log(`✅ Successfully played ${logPrefix.toLowerCase()} '${sfxName}'`);
console.log(
`✅ Successfully played ${logPrefix.toLowerCase()} '${sfxName}'`
);
} catch (error) {
console.error(`❌ Error playing ${logPrefix.toLowerCase()} '${sfxName}':`, error);
console.error(
`❌ Error playing ${logPrefix.toLowerCase()} '${sfxName}':`,
error
);
// Update the reply with error message
try {
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) {
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
* @returns {Promise<void>}
*/
async playSFXMessage(message, sfxName, guildConfig) {
async playSfxMessage(message, sfxName, guildConfig) {
// Log the request
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
if (!this.hasSFX(sfxName)) {
await message.reply('❌ This sound effect does not exist!');
if (!this.hasSfx(sfxName)) {
await message.reply("❌ This sound effect does not exist!");
return;
}
try {
// React with speaker icon to show playing status
await message.react('🔊');
await message.react("🔊");
// Join the voice channel
await voiceService.join(message.member.voice.channel);
// Get the SFX file path and play
const sfxPath = this.getSFXPath(sfxName);
const sfxPath = this.getSfxPath(sfxName);
await voiceService.play(message.guild.id, sfxPath, {
volume: guildConfig.sfxVolume || 0.5,
});
// Add completion reaction (keep both speaker and checkmark)
await message.react('✅');
await message.react("✅");
// Leave the voice channel after playing
setTimeout(() => {
@@ -244,19 +266,20 @@ class SFXManager {
}, 500);
console.log(`✅ Successfully played SFX '${sfxName}'`);
} catch (error) {
console.error(`❌ Error playing SFX '${sfxName}':`, error);
// Add error reaction
try {
await message.react('❌');
await message.react("❌");
} catch (reactionError) {
// 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,
entersState,
getVoiceConnection,
generateDependencyReport
} = require('@discordjs/voice');
const { ChannelType } = require('discord.js');
} = require("@discordjs/voice");
const { ChannelType } = require("discord.js");
// Try to use ffmpeg-static as fallback if system ffmpeg is not available
try {
const ffmpegPath = require('ffmpeg-static');
const ffmpegPath = require("ffmpeg-static");
if (ffmpegPath && !process.env.FFMPEG_PATH) {
process.env.FFMPEG_PATH = ffmpegPath;
}
@@ -28,17 +27,17 @@ class VoiceService {
/**
* Join a voice channel
* @param {VoiceChannel} channel
* @param {VoiceChannel} channel
* @returns {VoiceConnection}
*/
async join(channel) {
if (!channel || channel.type !== ChannelType.GuildVoice) {
throw new Error('Invalid voice channel');
throw new Error("Invalid voice channel");
}
// Check if already connected
let connection = getVoiceConnection(channel.guild.id);
if (!connection) {
connection = joinVoiceChannel({
channelId: channel.id,
@@ -79,7 +78,7 @@ class VoiceService {
/**
* Leave a voice channel
* @param {string} guildId
* @param {string} guildId
*/
leave(guildId) {
const connection = this.connections.get(guildId);
@@ -92,15 +91,15 @@ class VoiceService {
/**
* Play an audio file
* @param {string} guildId
* @param {string} filePath
* @param {Object} options
* @param {string} guildId
* @param {string} filePath
* @param {Object} options
* @returns {AudioPlayer}
*/
async play(guildId, filePath, options = {}) {
const connection = this.connections.get(guildId);
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
@@ -131,8 +130,8 @@ class VoiceService {
resolve();
});
player.once('error', (error) => {
console.error('Player error:', error);
player.once("error", (error) => {
console.error("Player error:", error);
reject(error);
});
});
@@ -140,7 +139,7 @@ class VoiceService {
/**
* Stop playing audio
* @param {string} guildId
* @param {string} guildId
*/
stop(guildId) {
const player = this.players.get(guildId);
@@ -151,7 +150,7 @@ class VoiceService {
/**
* Check if connected to a voice channel
* @param {string} guildId
* @param {string} guildId
* @returns {boolean}
*/
isConnected(guildId) {
@@ -160,7 +159,7 @@ class VoiceService {
/**
* Get the current voice connection
* @param {string} guildId
* @param {string} guildId
* @returns {VoiceConnection|undefined}
*/
getConnection(guildId) {
@@ -168,4 +167,4 @@ class VoiceService {
}
}
module.exports = new VoiceService();
module.exports = new VoiceService();