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
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

@@ -6,7 +6,7 @@
"dependencies": {
"@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.18.0",
"axios": "^1.11.0",
"axios": "^1.12.0",
"better-sqlite3": "^11.10.0",
"discord.js": "^14.21.0",
"ffmpeg-static": "^5.2.0",
@@ -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}"
},

8
pnpm-lock.yaml generated
View File

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

View File

@@ -1,11 +1,8 @@
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
@@ -17,7 +14,7 @@ module.exports = {
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
@@ -46,12 +43,19 @@ module.exports = {
// 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) {
@@ -66,23 +70,27 @@ module.exports = {
}
} 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]
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 => ({
filtered.map((choice) => ({
name: choice,
value: 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,58 +12,43 @@ 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;
}
}
// 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
if (!this.databaseService) {
// Return minimal default config if database not available
return {
id: guildId,
name: "Unknown Guild",
@@ -105,29 +58,23 @@ class ConfigManager {
sfxVolume: 0.5,
enableFunFacts: true,
enableHamFacts: true,
allowedRolesForRequest: null,
allowedRolesForRequest: [],
};
}
return this.databaseService.getGuildConfig(guildId);
}
/**
* Get all guild configurations
* Get all guild configurations (database only)
*/
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;
}
if (!this.databaseService) {
return [];
}
return this.databaseService.getAllGuildConfigs();
}
}
module.exports = new ConfigManager();

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() {
@@ -11,7 +11,7 @@ class SchedulerService {
* @param {ConfigManager} configManager
*/
async initialize(client, configManager) {
console.log('Initializing scheduled events...');
console.log("Initializing scheduled events...");
const guildConfigs = configManager.getAllGuildConfigs();
@@ -27,17 +27,22 @@ 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
);
}
}
}
@@ -46,26 +51,29 @@ class SchedulerService {
* Schedule a single event
* @param {Guild} guild
* @param {Object} event
* @param {Object} guildConfig
*/
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}`
);
}
}
@@ -81,9 +89,15 @@ class SchedulerService {
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);
@@ -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) {
@@ -158,7 +172,7 @@ class SchedulerService {
job.cancel();
}
this.jobs.clear();
console.log('Cancelled all scheduled events');
console.log("Cancelled all scheduled events");
}
}

View File

@@ -1,47 +1,47 @@
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
@@ -49,18 +49,18 @@ class SFXManager {
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,7 +77,7 @@ class SFXManager {
* Get SFX names for autocomplete (cached and sorted)
* @returns {Array} List of SFX names
*/
getSFXNames() {
getSfxNames() {
return this.cachedNames;
}
@@ -86,8 +86,10 @@ class SFXManager {
* @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()
);
}
/**
@@ -95,8 +97,8 @@ class SFXManager {
* @param {string} name
* @returns {boolean}
*/
hasSFX(name) {
return this.findSFX(name) !== undefined;
hasSfx(name) {
return this.findSfx(name) !== undefined;
}
/**
@@ -104,8 +106,8 @@ class SFXManager {
* @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;
}
@@ -114,7 +116,7 @@ class SFXManager {
* @param {string} query
* @returns {Array} Matching SFX names
*/
searchSFX(query) {
searchSfx(query) {
const lowerQuery = query.toLowerCase();
// Check cache first
@@ -124,7 +126,7 @@ class SFXManager {
// 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
@@ -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;
}
@@ -33,7 +32,7 @@ class VoiceService {
*/
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
@@ -100,7 +99,7 @@ class VoiceService {
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);
});
});