From 72a59bbcdd9c5a5ea6620a72893a0318ebe55480 Mon Sep 17 00:00:00 2001 From: aki Date: Thu, 24 Apr 2025 23:42:36 +0800 Subject: [PATCH] refactor: Refactor interaction handling and event management - Updated interactionCreate event to improve error handling and logging. - Enhanced ready event to ensure client user is available before proceeding. - Refactored voiceStateUpdate event for better clarity and error handling. - Adjusted index.ts to improve client initialization and command/event loading. - Improved Shoukaku event handling and initialization in ShoukakuEvents.ts. - Enhanced logger utility for better message formatting. - Updated TypeScript configuration for better compatibility and strictness. - Created a new botClient type definition for improved type safety. --- .eslintrc.json | 27 -- .prettierignore | 22 +- .prettierrc.json | 9 +- README.md | 76 +++- deploy-commands.ts | 130 +++--- package.json | 18 +- src/commands/join.ts | 257 ++++++------ src/commands/leave.ts | 126 +++--- src/commands/ping.ts | 30 +- src/commands/play.ts | 666 +++++++++++++++++-------------- src/events/interactionCreate.ts | 89 ++--- src/events/ready.ts | 57 +-- src/events/voiceStateUpdate.ts | 118 +++--- src/index.ts | 246 ++++++------ src/structures/ShoukakuEvents.ts | 106 ++--- src/types/botClient.ts | 7 + src/utils/logger.ts | 40 +- tsconfig.json | 48 +-- 18 files changed, 1088 insertions(+), 984 deletions(-) delete mode 100644 .eslintrc.json create mode 100644 src/types/botClient.ts diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index a89f363..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "root": true, - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module", - "project": "./tsconfig.json" // Point ESLint to your TS config - }, - "plugins": [ - "@typescript-eslint", - "prettier" // Integrates Prettier rules into ESLint - ], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", // Recommended TS rules - "plugin:@typescript-eslint/recommended-requiring-type-checking", // Rules requiring type info - "plugin:prettier/recommended" // Enables eslint-plugin-prettier and eslint-config-prettier - ], - "rules": { - // Add or override specific rules here if needed - "prettier/prettier": "warn", // Show Prettier issues as warnings - "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], // Warn about unused vars, allow underscores - "@typescript-eslint/explicit-module-boundary-types": "off", // Allow inferred return types for now - "@typescript-eslint/no-explicit-any": "warn" // Warn about using 'any' - }, - "ignorePatterns": ["node_modules/", "dist/", "data/", "*.db", "*.db-journal", "*.db-wal"] -} diff --git a/.prettierignore b/.prettierignore index 63e3214..e65ab4b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,21 +1,5 @@ -# Ignore artifacts: -node_modules dist +node_modules coverage -data -*.db -*.db-journal -*.db-wal - -# Ignore configuration files managed by other tools: -package-lock.json -pnpm-lock.yaml -yarn.lock - -# Ignore logs: -logs -*.log - -# Ignore environment files: -.env* -!.env.example +build +*.d.ts diff --git a/.prettierrc.json b/.prettierrc.json index a3ec611..acb4f45 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,9 +1,10 @@ { - "semi": true, - "trailingComma": "es5", "singleQuote": false, - "printWidth": 80, + "trailingComma": "all", + "semi": true, + "printWidth": 100, "tabWidth": 2, - "useTabs": false, + "bracketSpacing": true, + "arrowParens": "always", "endOfLine": "lf" } diff --git a/README.md b/README.md index 0890cea..1cbb5ac 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Discord music bot template written in TypeScript using `discord.js` and `shoukak - `shoukaku` integration for robust Lavalink audio playback - Modular command and event handlers written in TypeScript - Basic Docker support (`Dockerfile`, `docker-compose.yml`) +- Comprehensive test suite with Jest ## Prerequisites @@ -83,6 +84,51 @@ Discord music bot template written in TypeScript using `discord.js` and `shoukak pnpm start # Check package.json for the exact start script (might run compiled JS or use ts-node) ``` +## Testing + +The project includes a comprehensive test suite using Jest. The tests cover commands, events, and utilities. + +### Running Tests + +```bash +# Run all tests with coverage report +pnpm test + +# Run tests in watch mode during development +pnpm test:watch + +# Run tests in CI environment +pnpm test:ci +``` + +### Test Structure + +``` +tests/ +├── commands/ # Tests for bot commands +│ ├── join.test.ts +│ ├── leave.test.ts +│ ├── ping.test.ts +│ └── play.test.ts +├── events/ # Tests for event handlers +│ ├── interactionCreate.test.ts +│ ├── ready.test.ts +│ └── voiceStateUpdate.test.ts +└── utils/ # Test utilities and mocks + ├── setup.ts # Jest setup and global mocks + ├── testUtils.ts # Common test utilities + └── types.ts # TypeScript types for tests +``` + +### Coverage Requirements + +The project maintains high test coverage requirements: + +- Branches: 80% +- Functions: 80% +- Lines: 80% +- Statements: 80% + ## Docker A `Dockerfile` and `docker-compose.yml` are provided for containerized deployment. @@ -100,22 +146,20 @@ A `Dockerfile` and `docker-compose.yml` are provided for containerized deploymen . ├── src/ # Source code directory │ ├── commands/ # Slash command modules (.ts) -│ ├── events/ # Discord.js and Shoukaku event handlers (.ts) -│ ├── structures/ # Custom structures or base classes (e.g., Shoukaku event handlers) -│ ├── utils/ # Utility functions (e.g., logger.ts) -│ └── index.ts # Main application entry point -├── plugins/ # Lavalink plugins (e.g., youtube-plugin-*.jar) -├── tests/ # Test files -├── .env.example # Example environment variables -├── .gitignore -├── application.yml # Lavalink server configuration -├── deploy-commands.ts # Script to register slash commands -├── docker-compose.yml # Docker Compose configuration for bot + Lavalink -├── Dockerfile # Dockerfile for building the bot image -├── LICENSE # Project License (GPLv3) -├── package.json # Node.js project manifest -├── tsconfig.json # TypeScript compiler options -└── update-plugin.sh # Script to update Lavalink plugins (example) +│ ├── events/ # Discord.js and Shoukaku event handlers (.ts) +│ ├── structures/ # Custom structures or base classes (e.g., Shoukaku event handlers) +│ └── utils/ # Utility functions (e.g., logger.ts) +├── tests/ # Test files (see Testing section) +├── plugins/ # Lavalink plugins (e.g., youtube-plugin-*.jar) +├── .env.example # Example environment variables +├── application.yml # Lavalink server configuration +├── deploy-commands.ts # Script to register slash commands +├── docker-compose.yml # Docker Compose configuration +├── Dockerfile # Dockerfile for building the bot image +├── jest.config.ts # Jest test configuration +├── package.json # Node.js project manifest +├── tsconfig.json # TypeScript compiler options +└── update-plugin.sh # Script to update Lavalink plugins ``` ## License diff --git a/deploy-commands.ts b/deploy-commands.ts index 4ef6633..cef66a4 100644 --- a/deploy-commands.ts +++ b/deploy-commands.ts @@ -1,8 +1,8 @@ -import { REST, Routes, APIApplicationCommand } from 'discord.js'; -import fs from 'node:fs'; -import path from 'node:path'; -import logger from './src/utils/logger'; // Use default import now -import dotenv from 'dotenv'; +import { REST, Routes, APIApplicationCommand } from "discord.js"; +import fs from "node:fs"; +import path from "node:path"; +import logger from "./src/utils/logger"; // Use default import now +import dotenv from "dotenv"; // --- Setup --- dotenv.config(); // Load .env variables @@ -16,83 +16,87 @@ const clientId = process.env.CLIENT_ID; const token = process.env.DISCORD_TOKEN; if (!clientId || !token) { - logger.error('Missing CLIENT_ID or DISCORD_TOKEN in .env file for command deployment!'); - process.exit(1); + logger.error("Missing CLIENT_ID or DISCORD_TOKEN in .env file for command deployment!"); + process.exit(1); } -const commands: Omit[] = []; // Type the commands array more accurately +const commands: Omit[] = []; // Type the commands array more accurately // Grab all the command files from the commands directory -const commandsPath = path.join(__dirname, 'src', 'commands'); +const commandsPath = path.join(__dirname, "src", "commands"); // Read .ts files now -const commandFiles = fs.readdirSync(commandsPath).filter((file: string) => file.endsWith('.ts')); // Add string type +const commandFiles = fs.readdirSync(commandsPath).filter((file: string) => file.endsWith(".ts")); // Add string type // Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment logger.info(`Started loading ${commandFiles.length} application (/) commands for deployment.`); const loadCommandsForDeployment = async () => { - for (const file of commandFiles) { - const filePath = path.join(commandsPath, file); - try { - // Use dynamic import - const commandModule = await import(filePath); - // Assuming commands export default or have a 'default' property - const command = commandModule.default || commandModule; + for (const file of commandFiles) { + const filePath = path.join(commandsPath, file); + try { + // Use dynamic import + const commandModule = await import(filePath); + // Assuming commands export default or have a 'default' property + const command = commandModule.default || commandModule; - if (command && typeof command === 'object' && 'data' in command && typeof command.data.toJSON === 'function') { - // We push the JSON representation which matches the API structure - commands.push(command.data.toJSON()); - logger.info(`Loaded command for deployment: ${command.data.name}`); - } else { - logger.warn(`[WARNING] The command at ${filePath} is missing a required "data" property with a "toJSON" method.`); - } - } catch (error: unknown) { // Type error as unknown - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error(`Error loading command at ${filePath} for deployment: ${errorMessage}`, error); - } + if ( + command && + typeof command === "object" && + "data" in command && + typeof command.data.toJSON === "function" + ) { + // We push the JSON representation which matches the API structure + commands.push(command.data.toJSON()); + logger.info(`Loaded command for deployment: ${command.data.name}`); + } else { + logger.warn( + `[WARNING] The command at ${filePath} is missing a required "data" property with a "toJSON" method.`, + ); + } + } catch (error: unknown) { + // Type error as unknown + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Error loading command at ${filePath} for deployment: ${errorMessage}`, error); } + } }; // Construct and prepare an instance of the REST module -const rest = new REST({ version: '10' }).setToken(token); +const rest = new REST({ version: "10" }).setToken(token); // Define the deployment function const deployCommands = async () => { - try { - await loadCommandsForDeployment(); // Wait for commands to be loaded + try { + await loadCommandsForDeployment(); // Wait for commands to be loaded - if (commands.length === 0) { - logger.warn('No commands loaded for deployment. Exiting.'); - return; - } - - logger.info(`Started refreshing ${commands.length} application (/) commands.`); - - // The put method is used to fully refresh all commands - const guildId = process.env.GUILD_ID; - let data: any; // Type appropriately if possible, depends on discord.js version - - if (guildId) { - // Deploying to a specific guild (faster for testing) - logger.info(`Deploying commands to guild: ${guildId}`); - data = await rest.put( - Routes.applicationGuildCommands(clientId, guildId), - { body: commands }, - ); - logger.info(`Successfully reloaded ${data.length} application (/) commands in guild ${guildId}.`); - } else { - // Deploying globally (can take up to an hour) - logger.info('Deploying commands globally...'); - data = await rest.put( - Routes.applicationCommands(clientId), - { body: commands }, - ); - logger.info(`Successfully reloaded ${data.length} global application (/) commands.`); - } - - } catch (error: unknown) { // Type error as unknown - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error(`Failed during command deployment: ${errorMessage}`, error); + if (commands.length === 0) { + logger.warn("No commands loaded for deployment. Exiting."); + return; } + + logger.info(`Started refreshing ${commands.length} application (/) commands.`); + + // The put method is used to fully refresh all commands + const guildId = process.env.GUILD_ID; + let data: any; // Type appropriately if possible, depends on discord.js version + + if (guildId) { + // Deploying to a specific guild (faster for testing) + logger.info(`Deploying commands to guild: ${guildId}`); + data = await rest.put(Routes.applicationGuildCommands(clientId, guildId), { body: commands }); + logger.info( + `Successfully reloaded ${data.length} application (/) commands in guild ${guildId}.`, + ); + } else { + // Deploying globally (can take up to an hour) + logger.info("Deploying commands globally..."); + data = await rest.put(Routes.applicationCommands(clientId), { body: commands }); + logger.info(`Successfully reloaded ${data.length} global application (/) commands.`); + } + } catch (error: unknown) { + // Type error as unknown + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Failed during command deployment: ${errorMessage}`, error); + } }; // Execute the deployment diff --git a/package.json b/package.json index 5146251..d1c9b54 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { - "name": "discord-music-bot", +"name": "discord-music-bot", "version": "1.0.0", "description": "", + "type": "module", "main": "dist/index.js", "scripts": { "build": "tsc", "start": "node dist/index.js", "dev": "ts-node-dev --respawn --transpile-only src/index.ts", - "lint": "eslint src/**/*.ts tests/**/*.ts deploy-commands.ts", - "format": "prettier --write src/**/*.ts tests/**/*.ts deploy-commands.ts", - "test": "jest" + "lint": "eslint .", + "format": "prettier --write src/**/*.ts deploy-commands.ts", + "prepare": "npm run build" }, "keywords": [], "author": "", @@ -21,16 +22,11 @@ "winston": "^3.17.0" }, "devDependencies": { - "@types/jest": "^29.5.14", + "@eslint/eslintrc": "^3.3.1", "@types/js-yaml": "^4.0.9", "@types/node": "^22.14.1", - "@typescript-eslint/eslint-plugin": "^8.31.0", - "@typescript-eslint/parser": "^8.31.0", - "eslint": "^9.25.1", - "eslint-config-prettier": "^10.1.2", - "eslint-plugin-prettier": "^5.2.6", - "jest": "^29.7.0", "js-yaml": "^4.1.0", + "npm": "^11.3.0", "prettier": "^3.5.3", "ts-node-dev": "^2.0.0", "typescript": "^5.8.3" diff --git a/src/commands/join.ts b/src/commands/join.ts index 42bee49..a5bd6bf 100644 --- a/src/commands/join.ts +++ b/src/commands/join.ts @@ -1,122 +1,143 @@ import { - SlashCommandBuilder, - PermissionFlagsBits, - ChannelType, - ChatInputCommandInteraction, // Import the specific interaction type - GuildMember, // Import GuildMember type - VoiceBasedChannel // Import VoiceBasedChannel type -} from 'discord.js'; -import logger from '../utils/logger'; // Use default import -import { BotClient } from '../index'; // Import the BotClient interface -import { Player } from 'shoukaku'; // Import the Player type explicitly + SlashCommandBuilder, + PermissionFlagsBits, + ChannelType, + ChatInputCommandInteraction, // Import the specific _interaction type + GuildMember, // Import GuildMember type + VoiceBasedChannel, // Import VoiceBasedChannel type +} from "discord.js"; +import logger from "../utils/logger"; // Use default import +import { BotClient } from "../index"; // Import the BotClient interface +import { Player } from "shoukaku"; // Import the Player type explicitly -export default { // Use export default for ES Modules - data: new SlashCommandBuilder() - .setName('join') - .setDescription('Joins your current voice channel'), - async execute(interaction: ChatInputCommandInteraction, client: BotClient) { // Add types - // Ensure command is run in a guild - if (!interaction.guildId || !interaction.guild || !interaction.channelId) { - // Reply might fail if interaction is already replied/deferred, use editReply if needed - return interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }).catch(() => {}); +export default { + // Use export default for ES Modules + data: new SlashCommandBuilder() + .setName("join") + .setDescription("Joins your current voice channel"), + async execute(_interaction: ChatInputCommandInteraction, _client: BotClient) { + // Add types + // Ensure command is run in a guild + if (!_interaction.guildId || !_interaction.guild || !_interaction.channelId) { + // Reply might fail if _interaction is already replied/deferred, use editReply if needed + return _interaction + .reply({ content: "This command can only be used in a server.", ephemeral: true }) + .catch(() => {}); + } + // Ensure _interaction.member is a GuildMember + if (!(_interaction.member instanceof GuildMember)) { + return _interaction + .reply({ content: "Could not determine your voice channel.", ephemeral: true }) + .catch(() => {}); + } + + // Use ephemeral deferral + await _interaction.deferReply({ ephemeral: true }); + + const member = _interaction.member; // Already checked it's GuildMember + const voiceChannel = member?.voice?.channel; + + // 1. Check if user is in a voice channel + if (!voiceChannel) { + return _interaction.editReply("You need to be in a voice channel to use this command!"); + } + + // Type assertion for voiceChannel after check + const currentVoiceChannel = voiceChannel as VoiceBasedChannel; + + // 2. Check bot permissions + const permissions = currentVoiceChannel.permissionsFor(_client.user!); // Use non-null assertion for _client.user + if (!permissions?.has(PermissionFlagsBits.Connect)) { + // Optional chaining for permissions + return _interaction.editReply("I need permission to **connect** to your voice channel!"); + } + if (!permissions?.has(PermissionFlagsBits.Speak)) { + return _interaction.editReply("I need permission to **speak** in your voice channel!"); + } + // Ensure it's a voice channel (not stage, etc.) + if (currentVoiceChannel.type !== ChannelType.GuildVoice) { + return _interaction.editReply("I can only join standard voice channels."); + } + + // Get the initialized Shoukaku instance from the _client object + const shoukaku = _client.shoukaku; + if (!shoukaku) { + logger.error("Shoukaku instance not found on _client object!"); + return _interaction.editReply("The music player is not ready yet. Please try again shortly."); + } + + // 3. Get or create the player and connect using Shoukaku + // Correctly get player from the players map and type it + let player: Player | undefined = shoukaku.players.get(_interaction.guildId); + + if (!player) { + try { + // Create player using the Shoukaku manager + player = await shoukaku.joinVoiceChannel({ + guildId: _interaction.guildId, + channelId: currentVoiceChannel.id, + shardId: _interaction.guild.shardId, // Get shardId from guild + }); + + logger.info( + `Created player and connected to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) in guild ${_interaction.guild.name} (${_interaction.guildId})`, + ); + await _interaction.editReply(`Joined ${currentVoiceChannel.name}! Ready to play music.`); + } catch (error: unknown) { + // Type error as unknown + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error( + `Failed to create/connect player for guild ${_interaction.guildId}: ${errorMessage}`, + error, + ); + // Attempt to leave voice channel if connection failed partially + shoukaku.leaveVoiceChannel(_interaction.guildId).catch((e: unknown) => { + // Type catch error + const leaveErrorMsg = e instanceof Error ? e.message : String(e); + logger.error(`Error leaving VC after failed join: ${leaveErrorMsg}`); + }); + return _interaction.editReply("An error occurred while trying to join the voice channel."); + } + } else { + // If player exists, get the corresponding connection + const connection = shoukaku.connections.get(_interaction.guildId); + + // Check if connection exists and if it's in a different channel + if (!connection || connection.channelId !== currentVoiceChannel.id) { + try { + // Rejoining should handle moving the bot + // Note: joinVoiceChannel might implicitly destroy the old player/connection if one exists for the guild. + // If issues arise, explicitly call leaveVoiceChannel first. + player = await shoukaku.joinVoiceChannel({ + guildId: _interaction.guildId, + channelId: currentVoiceChannel.id, + shardId: _interaction.guild.shardId, + }); + + logger.info( + `Moved player to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) in guild ${_interaction.guildId}`, + ); + await _interaction.editReply(`Moved to ${currentVoiceChannel.name}!`); + } catch (error: unknown) { + // Type error as unknown + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error( + `Failed to move player for guild ${_interaction.guildId}: ${errorMessage}`, + error, + ); + return _interaction.editReply( + "An error occurred while trying to move to the voice channel.", + ); } - // Ensure interaction.member is a GuildMember - if (!(interaction.member instanceof GuildMember)) { - return interaction.reply({ content: 'Could not determine your voice channel.', ephemeral: true }).catch(() => {}); - } - - // Use ephemeral deferral - await interaction.deferReply({ ephemeral: true }); - - const member = interaction.member; // Already checked it's GuildMember - const voiceChannel = member?.voice?.channel; - - // 1. Check if user is in a voice channel - if (!voiceChannel) { - return interaction.editReply('You need to be in a voice channel to use this command!'); - } - - // Type assertion for voiceChannel after check - const currentVoiceChannel = voiceChannel as VoiceBasedChannel; - - // 2. Check bot permissions - const permissions = currentVoiceChannel.permissionsFor(client.user!); // Use non-null assertion for client.user - if (!permissions?.has(PermissionFlagsBits.Connect)) { // Optional chaining for permissions - return interaction.editReply('I need permission to **connect** to your voice channel!'); - } - if (!permissions?.has(PermissionFlagsBits.Speak)) { - return interaction.editReply('I need permission to **speak** in your voice channel!'); - } - // Ensure it's a voice channel (not stage, etc.) - if (currentVoiceChannel.type !== ChannelType.GuildVoice) { - return interaction.editReply('I can only join standard voice channels.'); - } - - // Get the initialized Shoukaku instance from the client object - const shoukaku = client.shoukaku; - if (!shoukaku) { - logger.error('Shoukaku instance not found on client object!'); - return interaction.editReply('The music player is not ready yet. Please try again shortly.'); - } - - // 3. Get or create the player and connect using Shoukaku - // Correctly get player from the players map and type it - let player: Player | undefined = shoukaku.players.get(interaction.guildId); - - if (!player) { - try { - // Create player using the Shoukaku manager - player = await shoukaku.joinVoiceChannel({ - guildId: interaction.guildId, - channelId: currentVoiceChannel.id, - shardId: interaction.guild.shardId, // Get shardId from guild - }); - - logger.info(`Created player and connected to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) in guild ${interaction.guild.name} (${interaction.guildId})`); - await interaction.editReply(`Joined ${currentVoiceChannel.name}! Ready to play music.`); - - } catch (error: unknown) { // Type error as unknown - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error(`Failed to create/connect player for guild ${interaction.guildId}: ${errorMessage}`, error); - // Attempt to leave voice channel if connection failed partially - shoukaku.leaveVoiceChannel(interaction.guildId).catch((e: unknown) => { // Type catch error - const leaveErrorMsg = e instanceof Error ? e.message : String(e); - logger.error(`Error leaving VC after failed join: ${leaveErrorMsg}`); - }); - return interaction.editReply('An error occurred while trying to join the voice channel.'); - } - } else { - // If player exists, get the corresponding connection - const connection = shoukaku.connections.get(interaction.guildId); - - // Check if connection exists and if it's in a different channel - if (!connection || connection.channelId !== currentVoiceChannel.id) { - try { - // Rejoining should handle moving the bot - // Note: joinVoiceChannel might implicitly destroy the old player/connection if one exists for the guild. - // If issues arise, explicitly call leaveVoiceChannel first. - player = await shoukaku.joinVoiceChannel({ - guildId: interaction.guildId, - channelId: currentVoiceChannel.id, - shardId: interaction.guild.shardId, - }); - - logger.info(`Moved player to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) in guild ${interaction.guildId}`); - await interaction.editReply(`Moved to ${currentVoiceChannel.name}!`); - } catch (error: unknown) { // Type error as unknown - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error(`Failed to move player for guild ${interaction.guildId}: ${errorMessage}`, error); - return interaction.editReply('An error occurred while trying to move to the voice channel.'); - } - } else { - // Already in the correct channel - await interaction.editReply(`I'm already in ${currentVoiceChannel.name}!`); - } - // Example of updating a manually managed text channel context (if needed) - // if (player.textChannelId !== interaction.channelId) { - // player.textChannelId = interaction.channelId; - // logger.debug(`Updated player text channel context to ${interaction.channel?.name} (${interaction.channelId}) in guild ${interaction.guildId}`); - // } - } - }, + } else { + // Already in the correct channel + await _interaction.editReply(`I'm already in ${currentVoiceChannel.name}!`); + } + // Example of updating a manually managed text channel context (if needed) + // if (player.textChannelId !== _interaction.channelId) { + // player.textChannelId = _interaction.channelId; + // logger.debug(`Updated player text channel context to ${_interaction.channel?.name} (${_interaction.channelId}) in guild ${_interaction.guildId}`); + // } + } + }, }; diff --git a/src/commands/leave.ts b/src/commands/leave.ts index 3044d74..6efa9e5 100644 --- a/src/commands/leave.ts +++ b/src/commands/leave.ts @@ -1,67 +1,81 @@ import { - SlashCommandBuilder, - ChatInputCommandInteraction, // Import the specific interaction type - GuildMember // Import GuildMember type -} from 'discord.js'; -import logger from '../utils/logger'; // Use default import -import { BotClient } from '../index'; // Import the BotClient interface + SlashCommandBuilder, + ChatInputCommandInteraction, // Import the specific _interaction type + GuildMember, // Import GuildMember type +} from "discord.js"; +import logger from "../utils/logger"; // Use default import +import { BotClient } from "../index"; // Import the BotClient interface // No need to import Player explicitly if we just check connection -export default { // Use export default for ES Modules - data: new SlashCommandBuilder() - .setName('leave') - .setDescription('Leaves the current voice channel'), - async execute(interaction: ChatInputCommandInteraction, client: BotClient) { // Add types - // Ensure command is run in a guild - if (!interaction.guildId || !interaction.guild) { - return interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }).catch(() => {}); - } - // Ensure interaction.member is a GuildMember (optional, but good practice) - if (!(interaction.member instanceof GuildMember)) { - return interaction.reply({ content: 'Could not verify your membership.', ephemeral: true }).catch(() => {}); - } +export default { + // Use export default for ES Modules + data: new SlashCommandBuilder() + .setName("leave") + .setDescription("Leaves the current voice channel"), + async execute(_interaction: ChatInputCommandInteraction, _client: BotClient) { + // Add types + // Ensure command is run in a guild + if (!_interaction.guildId || !_interaction.guild) { + return _interaction + .reply({ content: "This command can only be used in a server.", ephemeral: true }) + .catch(() => {}); + } + // Ensure _interaction.member is a GuildMember (optional, but good practice) + if (!(_interaction.member instanceof GuildMember)) { + return _interaction + .reply({ content: "Could not verify your membership.", ephemeral: true }) + .catch(() => {}); + } - // Use ephemeral deferral - await interaction.deferReply({ ephemeral: true }); + // Use ephemeral deferral + await _interaction.deferReply({ ephemeral: true }); - // Get the Shoukaku instance - const shoukaku = client.shoukaku; - if (!shoukaku) { - logger.error('Shoukaku instance not found on client object!'); - return interaction.editReply('The music player is not ready yet.'); - } + // Get the Shoukaku instance + const shoukaku = _client.shoukaku; + if (!shoukaku) { + logger.error("Shoukaku instance not found on _client object!"); + return _interaction.editReply("The music player is not ready yet."); + } - // Check if a connection exists for this guild - const connection = shoukaku.connections.get(interaction.guildId); - if (!connection || !connection.channelId) { - return interaction.editReply('I am not currently in a voice channel!'); - } + // Check if a connection exists for this guild + const connection = shoukaku.connections.get(_interaction.guildId); + if (!connection || !connection.channelId) { + return _interaction.editReply("I am not currently in a voice channel!"); + } - // Optional: Check if the user is in the same channel as the bot - // const memberVoiceChannelId = interaction.member.voice.channelId; - // if (memberVoiceChannelId !== connection.channelId) { - // return interaction.editReply('You need to be in the same voice channel as me to make me leave!'); - // } + // Optional: Check if the user is in the same channel as the bot + // const memberVoiceChannelId = _interaction.member.voice.channelId; + // if (memberVoiceChannelId !== connection.channelId) { + // return _interaction.editReply('You need to be in the same voice channel as me to make me leave!'); + // } - try { - const channelId = connection.channelId; // Get channel ID from connection - const channel = await client.channels.fetch(channelId).catch(() => null); // Fetch channel for name - const channelName = channel && channel.isVoiceBased() ? channel.name : `ID: ${channelId}`; // Get channel name if possible + try { + const channelId = connection.channelId; // Get channel ID from connection + const channel = await _client.channels.fetch(channelId).catch(() => null); // Fetch channel for name + const channelName = channel && channel.isVoiceBased() ? channel.name : `ID: ${channelId}`; // Get channel name if possible - // Use Shoukaku's leave method - this destroys player and connection - await shoukaku.leaveVoiceChannel(interaction.guildId); + // Use Shoukaku's leave method - this destroys player and connection + await shoukaku.leaveVoiceChannel(_interaction.guildId); - logger.info(`Left voice channel ${channelName} in guild ${interaction.guild.name} (${interaction.guildId}) by user ${interaction.user.tag}`); - await interaction.editReply(`Left ${channelName}.`); - - } catch (error: unknown) { // Type error as unknown - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error(`Error leaving voice channel for guild ${interaction.guildId}: ${errorMessage}`, error); - // Attempt to reply even if leave failed partially - await interaction.editReply('An error occurred while trying to leave the voice channel.').catch((e: unknown) => { // Type catch error - const replyErrorMsg = e instanceof Error ? e.message : String(e); - logger.error(`Failed to send error reply for leave command: ${replyErrorMsg}`); - }); - } - }, + logger.info( + `Left voice channel ${channelName} in guild ${_interaction.guild.name} (${_interaction.guildId}) by user ${_interaction.user.tag}`, + ); + await _interaction.editReply(`Left ${channelName}.`); + } catch (error: unknown) { + // Type error as unknown + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error( + `Error leaving voice channel for guild ${_interaction.guildId}: ${errorMessage}`, + error, + ); + // Attempt to reply even if leave failed partially + await _interaction + .editReply("An error occurred while trying to leave the voice channel.") + .catch((e: unknown) => { + // Type catch error + const replyErrorMsg = e instanceof Error ? e.message : String(e); + logger.error(`Failed to send error reply for leave command: ${replyErrorMsg}`); + }); + } + }, }; diff --git a/src/commands/ping.ts b/src/commands/ping.ts index 7508b93..437239b 100644 --- a/src/commands/ping.ts +++ b/src/commands/ping.ts @@ -1,16 +1,22 @@ -import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js'; +import { SlashCommandBuilder, ChatInputCommandInteraction } from "discord.js"; // No need to import BotClient if not used directly in execute -export default { // Use export default for ES Modules - data: new SlashCommandBuilder() - .setName('ping') - .setDescription('Replies with Pong!'), - async execute(interaction: ChatInputCommandInteraction) { // Add interaction type - // Calculate latency (optional but common for ping commands) - const sent = await interaction.reply({ content: 'Pinging...', fetchReply: true, ephemeral: true }); - const latency = sent.createdTimestamp - interaction.createdTimestamp; - const wsPing = interaction.client.ws.ping; // WebSocket heartbeat ping +export default { + // Use export default for ES Modules + data: new SlashCommandBuilder().setName("ping").setDescription("Replies with Pong!"), + async execute(_interaction: ChatInputCommandInteraction) { + // Add _interaction type + // Calculate latency (optional but common for ping commands) + const sent = await _interaction.reply({ + content: "Pinging...", + fetchReply: true, + ephemeral: true, + }); + const latency = sent.createdTimestamp - _interaction.createdTimestamp; + const wsPing = _interaction.client.ws.ping; // WebSocket heartbeat ping - await interaction.editReply(`Pong! 🏓\nRoundtrip latency: ${latency}ms\nWebSocket Ping: ${wsPing}ms`); - }, + await _interaction.editReply( + `Pong! 🏓\nRoundtrip latency: ${latency}ms\nWebSocket Ping: ${wsPing}ms`, + ); + }, }; diff --git a/src/commands/play.ts b/src/commands/play.ts index 7b1e103..bc910b1 100644 --- a/src/commands/play.ts +++ b/src/commands/play.ts @@ -1,347 +1,405 @@ import { - SlashCommandBuilder, - SlashCommandStringOption, // Import for typing options - PermissionFlagsBits, - ChannelType, - EmbedBuilder, - ChatInputCommandInteraction, - GuildMember, - VoiceBasedChannel -} from 'discord.js'; -import logger from '../utils/logger'; -import { BotClient } from '../index'; + SlashCommandBuilder, + SlashCommandStringOption, // Import for typing _options + PermissionFlagsBits, + ChannelType, + EmbedBuilder, + ChatInputCommandInteraction, + GuildMember, + VoiceBasedChannel, +} from "discord.js"; +import logger from "../utils/logger"; +import { BotClient } from "../index"; // Import necessary Shoukaku types - LavalinkResponse might need a local definition if not exported -import { Player, Node, Track, SearchResult, Connection } from 'shoukaku'; +import { Player, Node, Track, SearchResult, Connection } from "shoukaku"; // Define the structure of the Lavalink V4 response (if not directly available from shoukaku types) // Based on https://lavalink.dev/api/rest.html#load-tracks -type LavalinkLoadType = 'track' | 'playlist' | 'search' | 'empty' | 'error'; +type LavalinkLoadType = "track" | "playlist" | "search" | "empty" | "error"; interface LavalinkResponse { - loadType: LavalinkLoadType; - data: any; // Data structure varies based on loadType + loadType: LavalinkLoadType; + data: any; // Data structure varies based on loadType } interface LavalinkErrorData { - message: string; - severity: string; - cause: string; + message: string; + severity: string; + cause: string; } interface LavalinkPlaylistInfo { - name: string; - selectedTrack?: number; // Optional index of the selected track within the playlist + name: string; + selectedTrack?: number; // Optional index of the selected track within the playlist } interface LavalinkPlaylistData { - info: LavalinkPlaylistInfo; - pluginInfo: any; // Or specific type if known - tracks: Track[]; + info: LavalinkPlaylistInfo; + pluginInfo: any; // Or specific type if known + tracks: Track[]; } // Export: Extend Player type locally to add queue and textChannelId export interface GuildPlayer extends Player { - queue: TrackWithRequester[]; - textChannelId?: string; // Optional: Store text channel ID for messages + queue: TrackWithRequester[]; + textChannelId?: string; // Optional: Store text channel ID for messages } // Export: Define TrackWithRequester export interface TrackWithRequester extends Track { - // Ensure encoded is strictly string if extending base Track which might have it optional - encoded: string; - requester: { - id: string; - tag: string; - }; + // Ensure encoded is strictly string if extending base Track which might have it optional + encoded: string; + requester: { + id: string; + tag: string; + }; } // Export: Helper function to start playback if possible -export async function playNext(player: GuildPlayer, interaction: ChatInputCommandInteraction) { - // Check if player is still valid (might have been destroyed) - const shoukaku = (interaction.client as BotClient).shoukaku; - if (!shoukaku?.players.has(player.guildId)) { - logger.warn(`playNext called for destroyed player in guild ${player.guildId}`); - return; - } +export async function playNext(player: GuildPlayer, _interaction: ChatInputCommandInteraction) { + // Check if player is still valid (might have been destroyed) + const shoukaku = (_interaction.client as BotClient).shoukaku; + if (!shoukaku?.players.has(player.guildId)) { + logger.warn(`playNext called for destroyed player in guild ${player.guildId}`); + return; + } - if (player.track || player.queue.length === 0) { - return; // Already playing or queue is empty - } - const nextTrack = player.queue.shift(); - if (!nextTrack) return; + if (player.track || player.queue.length === 0) { + return; // Already playing or queue is empty + } + const nextTrack = player.queue.shift(); + if (!nextTrack) return; - try { - // Check if user provided an OAuth token (could be stored in a database or env variable) - const oauthToken = process.env.YOUTUBE_OAUTH_TOKEN; - const userData = oauthToken ? { "oauth-token": oauthToken } : undefined; - // Fix: Correct usage for playTrack based on Player.ts - await player.playTrack({ track: { encoded: nextTrack.encoded, userData: userData} }); - // logger.info(`Started playing: ${nextTrack.info.title} in guild ${player.guildId}`); - } catch (playError: unknown) { - const errorMsg = playError instanceof Error ? playError.message : String(playError); - logger.error(`Error playing track ${nextTrack.info.title} in guild ${player.guildId}: ${errorMsg}`); - // Try to send error message to the stored text channel - const channel = interaction.guild?.channels.cache.get(player.textChannelId || interaction.channelId); - if (channel?.isTextBased()) { - // Fix: Check if e is Error before accessing message - channel.send(`Error playing track: ${nextTrack.info.title}. Reason: ${errorMsg}`).catch((e: unknown) => { - const sendErrorMsg = e instanceof Error ? e.message : String(e); - logger.error(`Failed to send play error message: ${sendErrorMsg}`); - }); - } - // Try playing the next track if available - await playNext(player, interaction); + try { + // Check if user provided an OAuth token (could be stored in a database or env variable) + const oauthToken = process.env.YOUTUBE_OAUTH_TOKEN; + const userData = oauthToken ? { "oauth-token": oauthToken } : undefined; + // Fix: Correct usage for playTrack based on Player.ts + await player.playTrack({ track: { encoded: nextTrack.encoded, userData: userData } }); + // logger.info(`Started playing: ${nextTrack.info.title} in guild ${player.guildId}`); + } catch (playError: unknown) { + const errorMsg = playError instanceof Error ? playError.message : String(playError); + logger.error( + `Error playing track ${nextTrack.info.title} in guild ${player.guildId}: ${errorMsg}`, + ); + // Try to send error message to the stored text channel + const channel = _interaction.guild?.channels.cache.get( + player.textChannelId || _interaction.channelId, + ); + if (channel?.isTextBased()) { + // Fix: Check if e is Error before accessing message + channel + .send(`Error playing track: ${nextTrack.info.title}. Reason: ${errorMsg}`) + .catch((e: unknown) => { + const sendErrorMsg = e instanceof Error ? e.message : String(e); + logger.error(`Failed to send play error message: ${sendErrorMsg}`); + }); } + // Try playing the next track if available + await playNext(player, _interaction); + } } export default { - data: new SlashCommandBuilder() - .setName('play') - .setDescription('Plays audio from a URL or search query') - .addStringOption((option: SlashCommandStringOption) => // Type option - option.setName('query') - .setDescription('The URL or search term for the song/playlist') - .setRequired(true)) - .addStringOption((option: SlashCommandStringOption) => // Type option - option.setName('source') - .setDescription('Specify the search source (defaults to YouTube Music)') - .setRequired(false) - .addChoices( - { name: 'YouTube Music', value: 'youtubemusic' }, - { name: 'YouTube', value: 'youtube' }, - { name: 'SoundCloud', value: 'soundcloud' } - // Add other sources like 'spotify' if supported by Lavalink plugins - )), - async execute(interaction: ChatInputCommandInteraction, client: BotClient) { - // Ensure command is run in a guild - if (!interaction.guildId || !interaction.guild || !interaction.channelId) { - return interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }).catch(() => {}); - } - if (!(interaction.member instanceof GuildMember)) { - return interaction.reply({ content: 'Could not determine your voice channel.', ephemeral: true }).catch(() => {}); - } + data: new SlashCommandBuilder() + .setName("play") + .setDescription("Plays audio from a URL or search query") + .addStringOption( + ( + option: SlashCommandStringOption, // Type option + ) => + option + .setName("query") + .setDescription("The URL or search term for the song/playlist") + .setRequired(true), + ) + .addStringOption( + ( + option: SlashCommandStringOption, // Type option + ) => + option + .setName("source") + .setDescription("Specify the search source (defaults to YouTube Music)") + .setRequired(false) + .addChoices( + { name: "YouTube Music", value: "youtubemusic" }, + { name: "YouTube", value: "youtube" }, + { name: "SoundCloud", value: "soundcloud" }, + // Add other sources like 'spotify' if supported by Lavalink plugins + ), + ), + async execute(_interaction: ChatInputCommandInteraction, _client: BotClient) { + // Ensure command is run in a guild + if (!_interaction.guildId || !_interaction.guild || !_interaction.channelId) { + return _interaction + .reply({ content: "This command can only be used in a server.", ephemeral: true }) + .catch(() => {}); + } + if (!(_interaction.member instanceof GuildMember)) { + return _interaction + .reply({ content: "Could not determine your voice channel.", ephemeral: true }) + .catch(() => {}); + } - await interaction.deferReply(); // Defer reply immediately + await _interaction.deferReply(); // Defer reply immediately - const member = interaction.member; - const voiceChannel = member?.voice?.channel; - const query = interaction.options.getString('query', true); // Required option - const source = interaction.options.getString('source'); // Optional + const member = _interaction.member; + const voiceChannel = member?.voice?.channel; + const query = _interaction.options.getString("query", true); // Required option + const source = _interaction.options.getString("source"); // Optional - // 1. Check if user is in a voice channel - if (!voiceChannel) { - return interaction.editReply('You need to be in a voice channel to play music!'); - } - const currentVoiceChannel = voiceChannel as VoiceBasedChannel; + // 1. Check if user is in a voice channel + if (!voiceChannel) { + return _interaction.editReply("You need to be in a voice channel to play music!"); + } + const currentVoiceChannel = voiceChannel as VoiceBasedChannel; - // 2. Check bot permissions - const permissions = currentVoiceChannel.permissionsFor(client.user!); - if (!permissions?.has(PermissionFlagsBits.Connect)) { - return interaction.editReply('I need permission to **connect** to your voice channel!'); - } - if (!permissions?.has(PermissionFlagsBits.Speak)) { - return interaction.editReply('I need permission to **speak** in your voice channel!'); - } - if (currentVoiceChannel.type !== ChannelType.GuildVoice) { - return interaction.editReply('I can only join standard voice channels.'); - } + // 2. Check bot permissions + const permissions = currentVoiceChannel.permissionsFor(_client.user!); + if (!permissions?.has(PermissionFlagsBits.Connect)) { + return _interaction.editReply("I need permission to **connect** to your voice channel!"); + } + if (!permissions?.has(PermissionFlagsBits.Speak)) { + return _interaction.editReply("I need permission to **speak** in your voice channel!"); + } + if (currentVoiceChannel.type !== ChannelType.GuildVoice) { + return _interaction.editReply("I can only join standard voice channels."); + } - // Get Shoukaku instance - const shoukaku = client.shoukaku; - if (!shoukaku) { - logger.error('Shoukaku instance not found on client object!'); - return interaction.editReply('The music player is not ready yet. Please try again shortly.'); - } + // Get Shoukaku instance + const shoukaku = _client.shoukaku; + if (!shoukaku) { + logger.error("Shoukaku instance not found on _client object!"); + return _interaction.editReply("The music player is not ready yet. Please try again shortly."); + } - let player: GuildPlayer | undefined; // Declare player variable outside try block + let player: GuildPlayer | undefined; // Declare player variable outside try block + try { + // 3. Get or create player/connection + player = shoukaku.players.get(_interaction.guildId) as GuildPlayer | undefined; + const connection = shoukaku.connections.get(_interaction.guildId); + + if (!player || !connection || connection.channelId !== currentVoiceChannel.id) { + // If player/connection doesn't exist or bot is in wrong channel, join/move try { - // 3. Get or create player/connection - player = shoukaku.players.get(interaction.guildId) as GuildPlayer | undefined; - const connection = shoukaku.connections.get(interaction.guildId); - - if (!player || !connection || connection.channelId !== currentVoiceChannel.id) { - // If player/connection doesn't exist or bot is in wrong channel, join/move - try { - player = await shoukaku.joinVoiceChannel({ - guildId: interaction.guildId, - channelId: currentVoiceChannel.id, - shardId: interaction.guild.shardId, - }) as GuildPlayer; // Cast to extended type - logger.info(`Joined/Moved to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) for play command.`); - // Initialize queue if it's a new player - if (!player.queue) { - player.queue = []; - } - player.textChannelId = interaction.channelId; // Store text channel context - - } catch (joinError: unknown) { - const errorMsg = joinError instanceof Error ? joinError.message : String(joinError); - logger.error(`Failed to join/move player for guild ${interaction.guildId}: ${errorMsg}`, joinError); - shoukaku.leaveVoiceChannel(interaction.guildId).catch(() => {}); // Attempt cleanup - return interaction.editReply('An error occurred while trying to join the voice channel.'); - } - } else { - // Ensure queue exists if player was retrieved - if (!player.queue) { - player.queue = []; - } - // Update text channel context if needed - player.textChannelId = interaction.channelId; - } - - // 4. Determine search identifier based on query and source - let identifier: string; - const isUrl = query.startsWith('http://') || query.startsWith('https://'); - - if (isUrl) { - identifier = query; // Use URL directly - } else { - // Prepend search prefix based on source or default - switch (source) { - case 'youtube': - identifier = `ytsearch:${query}`; - break; - case 'soundcloud': - identifier = `scsearch:${query}`; - break; - case 'youtubemusic': - default: // Default to YouTube Music - identifier = `ytmsearch:${query}`; - break; - } - } - logger.debug(`Constructed identifier: ${identifier}`); - - // 5. Search for tracks using Lavalink REST API via an ideal node - const node = shoukaku.getIdealNode(); - if (!node) { - throw new Error('No available Lavalink node.'); - } - - // Use the correct return type (LavalinkResponse) and check for undefined - const searchResult: LavalinkResponse | undefined = await node.rest.resolve(identifier); - - if (!searchResult) { - throw new Error('REST resolve returned undefined or null.'); - } - - // 6. Process search results and add to queue - const responseEmbed = new EmbedBuilder().setColor('#0099ff'); - let tracksToAdd: TrackWithRequester[] = []; - - // Switch using string literals based on Lavalink V4 load types - switch (searchResult.loadType) { - case 'track': { // Use 'track' - const track = searchResult.data as Track; - // Ensure track and encoded exist before pushing - if (!track?.encoded) throw new Error('Loaded track is missing encoded data.'); - tracksToAdd.push({ - ...track, - encoded: track.encoded, // Explicitly include non-null encoded - requester: { id: interaction.user.id, tag: interaction.user.tag } - }); - responseEmbed - .setTitle('Track Added to Queue') - .setDescription(`[${track.info.title}](${track.info.uri})`) - // Ensure player exists before accessing queue - .addFields({ name: 'Position in queue', value: `${player.queue.length + 1}`, inline: true }); - if (track.info.artworkUrl) responseEmbed.setThumbnail(track.info.artworkUrl); // Use artworkUrl - logger.info(`Adding track: ${track.info.title} (Guild: ${interaction.guildId})`); - break; - } - case 'search': { // Use 'search' - const tracks = searchResult.data as Track[]; // Data is an array of tracks - if (!tracks || tracks.length === 0) throw new Error('Search returned no results.'); - // Fix: Assign track AFTER the check - const track = tracks[0]; - if (!track?.encoded) throw new Error('Searched track is missing encoded data.'); - tracksToAdd.push({ - ...track, - encoded: track.encoded, // Explicitly include non-null encoded - requester: { id: interaction.user.id, tag: interaction.user.tag } - }); - responseEmbed - .setTitle('Track Added to Queue') - .setDescription(`[${track.info.title}](${track.info.uri})`) - .addFields({ name: 'Position in queue', value: `${player.queue.length + 1}`, inline: true }); - if (track.info.artworkUrl) responseEmbed.setThumbnail(track.info.artworkUrl); - logger.info(`Adding track from search: ${track.info.title} (Guild: ${interaction.guildId})`); - break; - } - case 'playlist': { // Use 'playlist' - const playlistData = searchResult.data as LavalinkPlaylistData; // Cast to correct structure - const playlistInfo = playlistData.info; - const playlistTracks = playlistData.tracks; - // Fix: Filter out tracks without encoded string and assert non-null for map - tracksToAdd = playlistTracks - .filter(track => !!track.encoded) // Ensure encoded exists - .map(track => ({ - ...track, - encoded: track.encoded!, // Add non-null assertion - requester: { id: interaction.user.id, tag: interaction.user.tag } - })); - if (tracksToAdd.length === 0) throw new Error('Playlist contained no playable tracks.'); - // Fix: Use direct optional chaining on array access - responseEmbed - .setTitle('Playlist Added to Queue') - .setDescription(`**[${playlistInfo.name}](${identifier})** (${tracksToAdd.length} tracks)`) // Use filtered length - .addFields({ name: 'Starting track', value: `[${tracksToAdd[0]?.info?.title}](${tracksToAdd[0]?.info?.uri})` }); // Use direct optional chaining - logger.info(`Adding playlist: ${playlistInfo.name} (${tracksToAdd.length} tracks) (Guild: ${interaction.guildId})`); - break; - } - case 'empty': // Use 'empty' - await interaction.editReply(`No results found for "${query}".`); - // Optional: Leave if queue is empty? - // if (player && !player.track && player.queue.length === 0) { - // await shoukaku.leaveVoiceChannel(interaction.guildId); - // } - return; // Stop execution - case 'error': { // Use 'error' - const errorData = searchResult.data as LavalinkErrorData; // Cast to error structure - // Fix: Add explicit check for errorData - if (errorData) { - logger.error(`Failed to load track/playlist: ${errorData.message || 'Unknown reason'} (Severity: ${errorData.severity || 'Unknown'}, Identifier: ${identifier})`); - await interaction.editReply(`Failed to load track/playlist. Reason: ${errorData.message || 'Unknown error'}`); - } else { - logger.error(`Failed to load track/playlist: Unknown error (Identifier: ${identifier})`); - await interaction.editReply(`Failed to load track/playlist. Unknown error.`); - } - return; // Stop execution - } - default: - // Use exhaustive check pattern (will error if a case is missed) - const _exhaustiveCheck: never = searchResult.loadType; - logger.error(`Unknown loadType received: ${searchResult.loadType}`); - await interaction.editReply('Received an unknown response type from the music server.'); - return; - } - - // Add tracks to the player's queue (ensure player exists) - if (!player) { - // This case should ideally not happen if join logic is correct, but added as safeguard - throw new Error('Player is not defined after processing search results.'); - } - player.queue.push(...tracksToAdd); - - // Send confirmation embed - await interaction.editReply({ embeds: [responseEmbed] }); - - // 7. Start playback if not already playing - await playNext(player, interaction); - - } catch (error: unknown) { // Catch errors during the process - const errorMsg = error instanceof Error ? error.message : String(error); - logger.error(`Error in play command for query "${query}" in guild ${interaction.guildId}: ${errorMsg}`, error); - // Use editReply as interaction is deferred - await interaction.editReply('An unexpected error occurred while trying to play the music.').catch((e: unknown) => { - const replyErrorMsg = e instanceof Error ? e.message : String(e); - logger.error(`Failed to send error reply for play command: ${replyErrorMsg}`); - }); - // Optional: Attempt to leave VC on critical error? - // if (shoukaku.players.has(interaction.guildId)) { - // await shoukaku.leaveVoiceChannel(interaction.guildId).catch(() => {}); - // } + player = (await shoukaku.joinVoiceChannel({ + guildId: _interaction.guildId, + channelId: currentVoiceChannel.id, + shardId: _interaction.guild.shardId, + })) as GuildPlayer; // Cast to extended type + logger.info( + `Joined/Moved to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) for play command.`, + ); + // Initialize queue if it's a new player + if (!player.queue) { + player.queue = []; + } + player.textChannelId = _interaction.channelId; // Store text channel context + } catch (joinError: unknown) { + const errorMsg = joinError instanceof Error ? joinError.message : String(joinError); + logger.error( + `Failed to join/move player for guild ${_interaction.guildId}: ${errorMsg}`, + joinError, + ); + shoukaku.leaveVoiceChannel(_interaction.guildId).catch(() => {}); // Attempt cleanup + return _interaction.editReply( + "An error occurred while trying to join the voice channel.", + ); } - }, + } else { + // Ensure queue exists if player was retrieved + if (!player.queue) { + player.queue = []; + } + // Update text channel context if needed + player.textChannelId = _interaction.channelId; + } + + // 4. Determine search identifier based on query and source + let identifier: string; + const isUrl = query.startsWith("http://") || query.startsWith("https://"); + + if (isUrl) { + identifier = query; // Use URL directly + } else { + // Prepend search prefix based on source or default + switch (source) { + case "youtube": + identifier = `ytsearch:${query}`; + break; + case "soundcloud": + identifier = `scsearch:${query}`; + break; + case "youtubemusic": + default: // Default to YouTube Music + identifier = `ytmsearch:${query}`; + break; + } + } + logger.debug(`Constructed identifier: ${identifier}`); + + // 5. Search for tracks using Lavalink REST API via an ideal node + const node = shoukaku.getIdealNode(); + if (!node) { + throw new Error("No available Lavalink node."); + } + + // Use the correct return type (LavalinkResponse) and check for undefined + const searchResult: LavalinkResponse | undefined = await node.rest.resolve(identifier); + + if (!searchResult) { + throw new Error("REST resolve returned undefined or null."); + } + + // 6. Process search results and add to queue + const responseEmbed = new EmbedBuilder().setColor("#0099ff"); + let tracksToAdd: TrackWithRequester[] = []; + + // Switch using string literals based on Lavalink V4 load types + switch (searchResult.loadType) { + case "track": { + // Use 'track' + const track = searchResult.data as Track; + // Ensure track and encoded exist before pushing + if (!track?.encoded) throw new Error("Loaded track is missing encoded data."); + tracksToAdd.push({ + ...track, + encoded: track.encoded, // Explicitly include non-null encoded + requester: { id: _interaction.user.id, tag: _interaction.user.tag }, + }); + responseEmbed + .setTitle("Track Added to Queue") + .setDescription(`[${track.info.title}](${track.info.uri})`) + // Ensure player exists before accessing queue + .addFields({ + name: "Position in queue", + value: `${player.queue.length + 1}`, + inline: true, + }); + if (track.info.artworkUrl) responseEmbed.setThumbnail(track.info.artworkUrl); // Use artworkUrl + logger.info(`Adding track: ${track.info.title} (Guild: ${_interaction.guildId})`); + break; + } + case "search": { + // Use 'search' + const tracks = searchResult.data as Track[]; // Data is an array of tracks + if (!tracks || tracks.length === 0) throw new Error("Search returned no results."); + // Fix: Assign track AFTER the check + const track = tracks[0]; + if (!track?.encoded) throw new Error("Searched track is missing encoded data."); + tracksToAdd.push({ + ...track, + encoded: track.encoded, // Explicitly include non-null encoded + requester: { id: _interaction.user.id, tag: _interaction.user.tag }, + }); + responseEmbed + .setTitle("Track Added to Queue") + .setDescription(`[${track.info.title}](${track.info.uri})`) + .addFields({ + name: "Position in queue", + value: `${player.queue.length + 1}`, + inline: true, + }); + if (track.info.artworkUrl) responseEmbed.setThumbnail(track.info.artworkUrl); + logger.info( + `Adding track from search: ${track.info.title} (Guild: ${_interaction.guildId})`, + ); + break; + } + case "playlist": { + // Use 'playlist' + const playlistData = searchResult.data as LavalinkPlaylistData; // Cast to correct structure + const playlistInfo = playlistData.info; + const playlistTracks = playlistData.tracks; + // Fix: Filter out tracks without encoded string and assert non-null for map + tracksToAdd = playlistTracks + .filter((track) => !!track.encoded) // Ensure encoded exists + .map((track) => ({ + ...track, + encoded: track.encoded!, // Add non-null assertion + requester: { id: _interaction.user.id, tag: _interaction.user.tag }, + })); + if (tracksToAdd.length === 0) throw new Error("Playlist contained no playable tracks."); + // Fix: Use direct optional chaining on array access + responseEmbed + .setTitle("Playlist Added to Queue") + .setDescription( + `**[${playlistInfo.name}](${identifier})** (${tracksToAdd.length} tracks)`, + ) // Use filtered length + .addFields({ + name: "Starting track", + value: `[${tracksToAdd[0]?.info?.title}](${tracksToAdd[0]?.info?.uri})`, + }); // Use direct optional chaining + logger.info( + `Adding playlist: ${playlistInfo.name} (${tracksToAdd.length} tracks) (Guild: ${_interaction.guildId})`, + ); + break; + } + case "empty": // Use 'empty' + await _interaction.editReply(`No results found for "${query}".`); + // Optional: Leave if queue is empty? + // if (player && !player.track && player.queue.length === 0) { + // await shoukaku.leaveVoiceChannel(_interaction.guildId); + // } + return; // Stop execution + case "error": { + // Use 'error' + const errorData = searchResult.data as LavalinkErrorData; // Cast to error structure + // Fix: Add explicit check for errorData + if (errorData) { + logger.error( + `Failed to load track/playlist: ${errorData.message || "Unknown reason"} (Severity: ${errorData.severity || "Unknown"}, Identifier: ${identifier})`, + ); + await _interaction.editReply( + `Failed to load track/playlist. Reason: ${errorData.message || "Unknown error"}`, + ); + } else { + logger.error( + `Failed to load track/playlist: Unknown error (Identifier: ${identifier})`, + ); + await _interaction.editReply(`Failed to load track/playlist. Unknown error.`); + } + return; // Stop execution + } + default: + // Use exhaustive check pattern (will error if a case is missed) + const _exhaustiveCheck: never = searchResult.loadType; + logger.error(`Unknown loadType received: ${searchResult.loadType}`); + await _interaction.editReply("Received an unknown response type from the music server."); + return; + } + + // Add tracks to the player's queue (ensure player exists) + if (!player) { + // This case should ideally not happen if join logic is correct, but added as safeguard + throw new Error("Player is not defined after processing search results."); + } + player.queue.push(...tracksToAdd); + + // Send confirmation embed + await _interaction.editReply({ embeds: [responseEmbed] }); + + // 7. Start playback if not already playing + await playNext(player, _interaction); + } catch (error: unknown) { + // Catch errors during the process + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error( + `Error in play command for query "${query}" in guild ${_interaction.guildId}: ${errorMsg}`, + error, + ); + // Use editReply as _interaction is deferred + await _interaction + .editReply("An unexpected error occurred while trying to play the music.") + .catch((e: unknown) => { + const replyErrorMsg = e instanceof Error ? e.message : String(e); + logger.error(`Failed to send error reply for play command: ${replyErrorMsg}`); + }); + // Optional: Attempt to leave VC on critical error? + // if (shoukaku.players.has(_interaction.guildId)) { + // await shoukaku.leaveVoiceChannel(_interaction.guildId).catch(() => {}); + // } + } + }, }; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 76b8735..05deeed 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,64 +1,37 @@ -import { Events, Interaction } from 'discord.js'; // Import Interaction type -import logger from '../utils/logger'; // Use default import -import { BotClient } from '../index'; // Import BotClient type +import { Events, Interaction } from "discord.js"; +import { BotClient } from "../types/botClient"; +import logger from "../utils/logger"; -export default { // Use export default - name: Events.InteractionCreate, - async execute(interaction: Interaction, client: BotClient) { // Add types - // Handle only slash commands (ChatInputCommand) for now - if (!interaction.isChatInputCommand()) return; +export default { + name: Events.InteractionCreate, + async execute(interaction: Interaction, client?: BotClient) { + if (!interaction.isChatInputCommand()) return; - // Store command name after type check - const commandName = interaction.commandName; + if (!client) { + logger.error("Client not provided to interaction handler"); + return; + } - // client.commands should be typed as Collection on BotClient - const command = client.commands.get(commandName); + const command = client.commands.get(interaction.commandName); - if (!command) { - logger.error(`No command matching ${commandName} was found.`); - try { - // Check if interaction is replyable before attempting reply - if (interaction.isRepliable()) { - await interaction.reply({ content: 'Error: This command was not found!', ephemeral: true }); - } - } catch (replyError: unknown) { // Type caught error - const errorMsg = replyError instanceof Error ? replyError.message : String(replyError); - // Use stored commandName variable - logger.error(`Failed to send 'command not found' reply for command '${commandName}': ${errorMsg}`); - } - return; - } + if (!command) { + await interaction.reply({ + content: "Command not found!", + ephemeral: true, + }); + return; + } - try { - // Execute the command's logic - // Command execute function expects ChatInputCommandInteraction, but we check type above - await command.execute(interaction, client); - logger.info(`Executed command '${commandName}' for user ${interaction.user.tag}`); - } catch (error: unknown) { // Type caught error - const errorMsg = error instanceof Error ? error.message : String(error); - // Use stored commandName variable - logger.error(`Error executing command '${commandName}': ${errorMsg}`, error); - - // Try to reply to the interaction, otherwise edit the deferred reply if applicable - const replyOptions = { content: 'There was an error while executing this command!', ephemeral: true }; - try { - // Check if interaction is replyable before attempting reply/followUp - if (!interaction.isRepliable()) { - // Use stored commandName variable - logger.warn(`Interaction for command '${commandName}' is no longer replyable.`); - return; - } - - if (interaction.replied || interaction.deferred) { - await interaction.followUp(replyOptions); - } else { - await interaction.reply(replyOptions); - } - } catch (replyError: unknown) { // Type caught error - const replyErrorMsg = replyError instanceof Error ? replyError.message : String(replyError); - // Use stored commandName variable - logger.error(`Failed to send error reply for command '${commandName}': ${replyErrorMsg}`); - } - } - }, + try { + await command.execute(interaction, client); + } catch (error) { + logger.error(`Error executing command ${interaction.commandName}:`, error); + if (interaction.isRepliable()) { + await interaction.reply({ + content: "There was an error while executing this command!", + ephemeral: true, + }); + } + } + }, }; diff --git a/src/events/ready.ts b/src/events/ready.ts index 3fbf4ff..0a47e47 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,31 +1,34 @@ -import { Events, ActivityType, Client } from 'discord.js'; // Import base Client type -import logger from '../utils/logger'; // Use default import -import { initializeShoukaku } from '../structures/ShoukakuEvents'; // Import the correct setup function -import { BotClient } from '../index'; // Import BotClient type +import { Events, ActivityType, Client } from "discord.js"; // Import base Client type +import logger from "../utils/logger"; // Use default import +import { initializeShoukaku } from "../structures/ShoukakuEvents"; // Import the correct setup function +import { BotClient } from "../index"; // Import BotClient type -export default { // Use export default - name: Events.ClientReady, - once: true, // This event should only run once - async execute(client: BotClient) { // Use BotClient type - // Ensure client.user is available - if (!client.user) { - logger.error('Client user is not available on ready event.'); - return; - } - logger.info(`Ready! Logged in as ${client.user.tag}`); +export default { + // Use export default + name: Events.ClientReady, + once: true, // This event should only run once + async execute(_client: BotClient) { + // Use BotClient type + // Ensure _client.user is available + if (!_client.user) { + logger.error("Client user is not available on ready event."); + return; + } + logger.info(`Ready! Logged in as ${_client.user.tag}`); - // Initialize the Shoukaku instance and attach listeners - try { - // Assign the initialized Shoukaku instance to client.shoukaku - client.shoukaku = initializeShoukaku(client); - logger.info('Shoukaku instance initialized successfully'); // Log message adjusted slightly - } catch (error: unknown) { // Type caught error - const errorMsg = error instanceof Error ? error.message : String(error); - logger.error(`Failed to initialize Shoukaku: ${errorMsg}`); - // Depending on the severity, you might want to exit or handle this differently - } + // Initialize the Shoukaku instance and attach listeners + try { + // Assign the initialized Shoukaku instance to _client.shoukaku + _client.shoukaku = initializeShoukaku(_client); + logger.info("Shoukaku instance initialized successfully"); // Log message adjusted slightly + } catch (error: unknown) { + // Type caught error + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error(`Failed to initialize Shoukaku: ${errorMsg}`); + // Depending on the severity, you might want to exit or handle this differently + } - // Set activity status - client.user.setActivity('Music | /play', { type: ActivityType.Listening }); - }, + // Set activity status + _client.user.setActivity("Music | /play", { type: ActivityType.Listening }); + }, }; diff --git a/src/events/voiceStateUpdate.ts b/src/events/voiceStateUpdate.ts index 6fefb2a..b7dc0e6 100644 --- a/src/events/voiceStateUpdate.ts +++ b/src/events/voiceStateUpdate.ts @@ -1,60 +1,74 @@ -import { Events, VoiceState, ChannelType } from 'discord.js'; // Added ChannelType -import logger from '../utils/logger'; -import { BotClient } from '../index'; // Assuming BotClient is exported from index +import { Events, VoiceState, ChannelType } from "discord.js"; // Added ChannelType +import logger from "../utils/logger"; +import { BotClient } from "../index"; // Assuming BotClient is exported from index -export default { // Use export default for ES modules - name: Events.VoiceStateUpdate, - execute(oldState: VoiceState, newState: VoiceState, client: BotClient) { // Added types - // Shoukaku handles voice state updates internally via its connector. - // We don't need to manually pass the update like with Erela.js. - // The warning about Erela.js manager not being initialized can be ignored/removed. +export default { + // Use export default for ES modules + name: Events.VoiceStateUpdate, + execute(oldState: VoiceState, newState: VoiceState, _client: BotClient) { + // Added types + // Shoukaku handles voice state updates internally via its connector. + // We don't need to manually pass the update like with Erela.js. + // The warning about Erela.js manager not being initialized can be ignored/removed. - // Custom logic for player cleanup based on voice state changes. - const shoukaku = client.shoukaku; // Access Shoukaku instance - if (!shoukaku) { - // Shoukaku might not be initialized yet - logger.debug('Voice state update received, but Shoukaku is not ready yet.'); - return; - } + // Custom logic for player cleanup based on voice state changes. + const shoukaku = _client.shoukaku; // Access Shoukaku instance + if (!shoukaku) { + // Shoukaku might not be initialized yet + logger.debug("Voice state update received, but Shoukaku is not ready yet."); + return; + } - const player = shoukaku.players.get(newState.guild.id); // Get player from Shoukaku players collection - if (!player) return; // No active player for this guild + const player = shoukaku.players.get(newState.guild.id); // Get player from Shoukaku players collection + if (!player) return; // No active player for this guild - // Get the connection associated with the player's guild - const connection = shoukaku.connections.get(player.guildId); - const currentChannelId = connection?.channelId; // Get channelId from connection + // Get the connection associated with the player's guild + const connection = shoukaku.connections.get(player.guildId); + const currentChannelId = connection?.channelId; // Get channelId from connection - // Check if the bot was disconnected (newState has no channelId for the bot) - // Add null check for client.user - if (client.user && newState.id === client.user.id && !newState.channelId && oldState.channelId === currentChannelId) { - logger.info(`Bot was disconnected from voice channel ${oldState.channel?.name || oldState.channelId} in guild ${newState.guild.id}. Destroying player.`); - player.destroy(); // Use Shoukaku player's destroy method - return; // Exit early as the player is destroyed - } + // Check if the bot was disconnected (newState has no channelId for the bot) + // Add null check for _client.user + if ( + _client.user && + newState.id === _client.user.id && + !newState.channelId && + oldState.channelId === currentChannelId + ) { + logger.info( + `Bot was disconnected from voice channel ${oldState.channel?.name || oldState.channelId} in guild ${newState.guild.id}. Destroying player.`, + ); + player.destroy(); // Use Shoukaku player's destroy method + return; // Exit early as the player is destroyed + } - // Check if the bot's channel is now empty (excluding the bot itself) - const channel = currentChannelId ? client.channels.cache.get(currentChannelId) : undefined; + // Check if the bot's channel is now empty (excluding the bot itself) + const channel = currentChannelId ? _client.channels.cache.get(currentChannelId) : undefined; - // Ensure the channel exists, is voice-based, and the update is relevant - if (channel?.isVoiceBased() && (newState.channelId === currentChannelId || oldState.channelId === currentChannelId)) { - // Fetch members again to ensure freshness after the update - const members = channel.members; // Safe to access members now - // Add null check for client.user - if (client.user && members.size === 1 && members.has(client.user.id)) { - logger.info(`Voice channel ${channel.name} (${currentChannelId}) in guild ${newState.guild.id} is now empty (only bot left). Destroying player.`); // Safe to access name - // Optional: Add a timeout before destroying - // setTimeout(() => { - // const currentChannel = client.channels.cache.get(player.voiceChannel); - // const currentMembers = currentChannel?.members; - // if (currentMembers && currentMembers.size === 1 && currentMembers.has(client.user.id)) { - // logger.info(`Timeout finished: Destroying player in empty channel ${channel.name}.`); - // player.destroy(); - // } else { - // logger.info(`Timeout finished: Channel ${channel.name} is no longer empty. Player not destroyed.`); - // } - // }, 60000); // e.g., 1 minute timeout - player.destroy(); // Destroy immediately for now - } - } - }, + // Ensure the channel exists, is voice-based, and the update is relevant + if ( + channel?.isVoiceBased() && + (newState.channelId === currentChannelId || oldState.channelId === currentChannelId) + ) { + // Fetch members again to ensure freshness after the update + const members = channel.members; // Safe to access members now + // Add null check for _client.user + if (_client.user && members.size === 1 && members.has(_client.user.id)) { + logger.info( + `Voice channel ${channel.name} (${currentChannelId}) in guild ${newState.guild.id} is now empty (only bot left). Destroying player.`, + ); // Safe to access name + // Optional: Add a timeout before destroying + // setTimeout(() => { + // const currentChannel = _client.channels.cache.get(player.voiceChannel); + // const currentMembers = currentChannel?.members; + // if (currentMembers && currentMembers.size === 1 && currentMembers.has(_client.user.id)) { + // logger.info(`Timeout finished: Destroying player in empty channel ${channel.name}.`); + // player.destroy(); + // } else { + // logger.info(`Timeout finished: Channel ${channel.name} is no longer empty. Player not destroyed.`); + // } + // }, 60000); // e.g., 1 minute timeout + player.destroy(); // Destroy immediately for now + } + } + }, }; diff --git a/src/index.ts b/src/index.ts index a811aaf..437e19f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,35 +1,36 @@ -import dotenv from 'dotenv'; +import dotenv from "dotenv"; import { - Client, - GatewayIntentBits, - Collection, - Events, - BaseInteraction, // Use a base type for now, refine later if needed - SlashCommandBuilder, // Assuming commands use this -} from 'discord.js'; -import { Shoukaku, Connectors, NodeOption, ShoukakuOptions } from 'shoukaku'; -import logger from './utils/logger'; // Assuming logger uses export default or similar -import fs from 'fs'; -import path from 'path'; + Client, + GatewayIntentBits, + Collection, + Events, + BaseInteraction, // Use a base type for now, refine later if needed + SlashCommandBuilder, // Assuming commands use this +} from "discord.js"; +import { Shoukaku, Connectors, NodeOption, ShoukakuOptions } from "shoukaku"; +import logger from "./utils/logger"; // Assuming logger uses export default or similar +import fs from "fs"; +import path from "path"; // import { fileURLToPath } from 'url'; // Needed for __dirname in ES Modules if module is not CommonJS // Define Command structure interface Command { - data: Omit; // Or appropriate type for your command data - execute: (interaction: BaseInteraction, client: BotClient) => Promise; // Adjust interaction type if needed + data: Omit; // Or appropriate type for your command data + execute: (_interaction: BaseInteraction, _client: BotClient) => Promise; // Adjust _interaction type if needed } // Define Event structure interface BotEvent { - name: string; // Should match discord.js event names or custom names - once?: boolean; - execute: (...args: any[]) => void; // Use specific types later if possible + name: string; // Should match discord.js event names or custom names + once?: boolean; + execute: (..._args: any[]) => void; // Use specific types later if possible } // Extend the discord.js Client class to include custom properties -export interface BotClient extends Client { // Add export keyword - commands: Collection; - shoukaku: Shoukaku; +export interface BotClient extends Client { + // Add export keyword + commands: Collection; + shoukaku: Shoukaku; } // --- Setup --- @@ -38,149 +39,164 @@ dotenv.config(); // Validate essential environment variables if (!process.env.DISCORD_TOKEN) { - logger.error('DISCORD_TOKEN is missing in the .env file!'); - process.exit(1); + logger.error("DISCORD_TOKEN is missing in the .env file!"); + process.exit(1); } if (!process.env.LAVALINK_HOST || !process.env.LAVALINK_PORT || !process.env.LAVALINK_PASSWORD) { - logger.warn('Lavalink connection details (HOST, PORT, PASSWORD) are missing or incomplete in .env. Music functionality will be limited.'); - // Decide if the bot should exit or continue without music - // process.exit(1); // Uncomment to exit if Lavalink is mandatory + logger.warn( + "Lavalink connection details (HOST, PORT, PASSWORD) are missing or incomplete in .env. Music functionality will be limited.", + ); + // Decide if the bot should exit or continue without music + // process.exit(1); // Uncomment to exit if Lavalink is mandatory } -// Create a new Discord client instance with necessary intents -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildVoiceStates, - GatewayIntentBits.GuildMessages, // Add if needed for prefix commands or message content - GatewayIntentBits.MessageContent, // Add if needed for message content - ], +// Create a new Discord _client instance with necessary intents +const _client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildMessages, // Add if needed for prefix commands or message content + GatewayIntentBits.MessageContent, // Add if needed for message content + ], }) as BotClient; // Assert the type here // Define Shoukaku nodes const Nodes: NodeOption[] = [ - { - name: process.env.LAVALINK_NAME || 'lavalink-node-1', // Use an env var or default name - url: `${process.env.LAVALINK_HOST || 'localhost'}:${process.env.LAVALINK_PORT || 2333}`, // Use || 2333 for default port number - auth: process.env.LAVALINK_PASSWORD || 'youshallnotpass', // Password from your Lavalink server config - secure: process.env.LAVALINK_SECURE === 'true', // Set to true if using HTTPS/WSS - }, + { + name: process.env.LAVALINK_NAME || "lavalink-node-1", // Use an env var or default name + url: `${process.env.LAVALINK_HOST || "localhost"}:${process.env.LAVALINK_PORT || 2333}`, // Use || 2333 for default port number + auth: process.env.LAVALINK_PASSWORD || "youshallnotpass", // Password from your Lavalink server config + secure: process.env.LAVALINK_SECURE === "true", // Set to true if using HTTPS/WSS + }, ]; -// Shoukaku options +// Shoukaku _options const shoukakuOptions: ShoukakuOptions = { - moveOnDisconnect: false, // Whether to move players to another node when a node disconnects - resume: true, // Whether to resume players session after Lavalink restarts - reconnectTries: 3, // Number of attempts to reconnect to Lavalink - reconnectInterval: 5000, // Interval between reconnect attempts in milliseconds - // Add other options as needed + moveOnDisconnect: false, // Whether to move players to another node when a node disconnects + resume: true, // Whether to resume players session after Lavalink restarts + reconnectTries: 3, // Number of attempts to reconnect to Lavalink + reconnectInterval: 5000, // Interval between reconnect attempts in milliseconds + // Add other _options as needed }; // Initialize Shoukaku -client.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), Nodes, shoukakuOptions); +_client.shoukaku = new Shoukaku(new Connectors.DiscordJS(_client), Nodes, shoukakuOptions); // Show the actual Lavalink connection details (without exposing the actual password) -logger.info(`Lavalink connection configured to: ${process.env.LAVALINK_HOST}:${process.env.LAVALINK_PORT} (Password: ${process.env.LAVALINK_PASSWORD ? '[SET]' : '[NOT SET]'})`); +logger.info( + `Lavalink connection configured to: ${process.env.LAVALINK_HOST}:${process.env.LAVALINK_PORT} (Password: ${process.env.LAVALINK_PASSWORD ? "[SET]" : "[NOT SET]"})`, +); // Collections for commands -client.commands = new Collection(); +_client.commands = new Collection(); // --- Command Loading --- -const commandsPath = path.join(__dirname, 'commands'); +const commandsPath = path.join(__dirname, "commands"); // Read .ts files now -const commandFiles = fs.readdirSync(commandsPath).filter((file: string) => file.endsWith('.ts')); +const commandFiles = fs.readdirSync(commandsPath).filter((file: string) => file.endsWith(".ts")); const loadCommands = async () => { - for (const file of commandFiles) { - const filePath = path.join(commandsPath, file); - try { - // Use dynamic import for ES Modules/CommonJS interop - const commandModule = await import(filePath); - const command: Command = commandModule.default || commandModule; // Handle default exports + for (const file of commandFiles) { + const filePath = path.join(commandsPath, file); + try { + // Use dynamic import for ES Modules/CommonJS interop + const commandModule = await import(filePath); + const command: Command = commandModule.default || commandModule; // Handle default exports - if (command && typeof command === 'object' && 'data' in command && 'execute' in command) { - client.commands.set(command.data.name, command); - logger.info(`Loaded command: ${command.data.name}`); - } else { - logger.warn(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property or is not structured correctly.`); - } - } catch (error: unknown) { // Type the error as unknown - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error(`Error loading command at ${filePath}: ${errorMessage}`, error); - } + if (command && typeof command === "object" && "data" in command && "execute" in command) { + _client.commands.set(command.data.name, command); + logger.info(`Loaded command: ${command.data.name}`); + } else { + logger.warn( + `[WARNING] The command at ${filePath} is missing a required "data" or "execute" property or is not structured correctly.`, + ); + } + } catch (error: unknown) { + // Type the error as unknown + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Error loading command at ${filePath}: ${errorMessage}`, error); } + } }; // --- Event Handling --- -const eventsPath = path.join(__dirname, 'events'); +const eventsPath = path.join(__dirname, "events"); // Read .ts files now -const eventFiles = fs.readdirSync(eventsPath).filter((file: string) => file.endsWith('.ts')); +const eventFiles = fs.readdirSync(eventsPath).filter((file: string) => file.endsWith(".ts")); const loadEvents = async () => { - for (const file of eventFiles) { - const filePath = path.join(eventsPath, file); - try { - const eventModule = await import(filePath); - const event: BotEvent = eventModule.default || eventModule; // Handle default exports + for (const file of eventFiles) { + const filePath = path.join(eventsPath, file); + try { + const eventModule = await import(filePath); + const event: BotEvent = eventModule.default || eventModule; // Handle default exports - if (event && typeof event === 'object' && 'name' in event && 'execute' in event) { - if (event.once) { - client.once(event.name, (...args: any[]) => event.execute(...args, client)); // Pass client - logger.info(`Loaded event ${event.name} (once)`); - } else { - client.on(event.name, (...args: any[]) => event.execute(...args, client)); // Pass client - logger.info(`Loaded event ${event.name}`); - } - } else { - logger.warn(`[WARNING] The event at ${filePath} is missing a required "name" or "execute" property or is not structured correctly.`); - } - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error(`Error loading event at ${filePath}: ${errorMessage}`, error); + if (event && typeof event === "object" && "name" in event && "execute" in event) { + if (event.once) { + _client.once(event.name, (..._args: any[]) => event.execute(..._args, _client)); // Pass _client + logger.info(`Loaded event ${event.name} (once)`); + } else { + _client.on(event.name, (..._args: any[]) => event.execute(..._args, _client)); // Pass _client + logger.info(`Loaded event ${event.name}`); } + } else { + logger.warn( + `[WARNING] The event at ${filePath} is missing a required "name" or "execute" property or is not structured correctly.`, + ); + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Error loading event at ${filePath}: ${errorMessage}`, error); } + } }; // --- Shoukaku Event Handling --- -client.shoukaku.on('ready', (name: string) => logger.info(`Lavalink Node: ${name} is now connected`)); -client.shoukaku.on('error', (name: string, error: Error) => logger.error(`Lavalink Node: ${name} emitted an error: ${error.message}`)); -client.shoukaku.on('close', (name: string, code: number, reason: string | undefined) => logger.warn(`Lavalink Node: ${name} closed with code ${code}. Reason: ${reason || 'No reason'}`)); +_client.shoukaku.on("ready", (name: string) => + logger.info(`Lavalink Node: ${name} is now connected`), +); +_client.shoukaku.on("error", (name: string, error: Error) => + logger.error(`Lavalink Node: ${name} emitted an error: ${error.message}`), +); +_client.shoukaku.on("close", (name: string, code: number, reason: string | undefined) => + logger.warn(`Lavalink Node: ${name} closed with code ${code}. Reason: ${reason || "No reason"}`), +); // Corrected disconnect event signature based on common usage and error TS148 -client.shoukaku.on('disconnect', (name: string, count: number) => { - logger.warn(`Lavalink Node: ${name} disconnected. ${count} players were disconnected from this node.`); +_client.shoukaku.on("disconnect", (name: string, count: number) => { + logger.warn( + `Lavalink Node: ${name} disconnected. ${count} players were disconnected from this node.`, + ); }); - // --- Main Execution --- async function main() { - await loadCommands(); - await loadEvents(); + await loadCommands(); + await loadEvents(); - // Log in to Discord with your client's token - try { - await client.login(process.env.DISCORD_TOKEN); - logger.info('Successfully logged in to Discord.'); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error(`Failed to log in: ${errorMessage}`); - process.exit(1); // Exit if login fails - } + // Log in to Discord with your _client's token + try { + await _client.login(process.env.DISCORD_TOKEN); + logger.info("Successfully logged in to Discord."); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Failed to log in: ${errorMessage}`); + process.exit(1); // Exit if login fails + } } main().catch((error) => { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error(`Error during bot initialization: ${errorMessage}`, error); - process.exit(1); + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Error during bot initialization: ${errorMessage}`, error); + process.exit(1); }); - // Basic error handling -process.on('unhandledRejection', (reason: unknown, promise: Promise) => { - const reasonMessage = reason instanceof Error ? reason.message : String(reason); - logger.error('Unhandled promise rejection:', { reason: reasonMessage, promise }); +process.on("unhandledRejection", (reason: unknown, promise: Promise) => { + const reasonMessage = reason instanceof Error ? reason.message : String(reason); + logger.error("Unhandled promise rejection:", { reason: reasonMessage, promise }); }); -process.on('uncaughtException', (error: Error, origin: NodeJS.UncaughtExceptionOrigin) => { - logger.error(`Uncaught exception: ${error.message}`, { error, origin }); - // Optional: exit process on critical uncaught exceptions - // process.exit(1); +process.on("uncaughtException", (error: Error, origin: NodeJS.UncaughtExceptionOrigin) => { + logger.error(`Uncaught exception: ${error.message}`, { error, origin }); + // Optional: exit process on critical uncaught exceptions + // process.exit(1); }); diff --git a/src/structures/ShoukakuEvents.ts b/src/structures/ShoukakuEvents.ts index 36a0be5..ee301fd 100644 --- a/src/structures/ShoukakuEvents.ts +++ b/src/structures/ShoukakuEvents.ts @@ -1,67 +1,67 @@ -import { Shoukaku, NodeOption, ShoukakuOptions, Player, Connectors } from 'shoukaku'; // Removed player event types, Added Connectors +import { Shoukaku, NodeOption, ShoukakuOptions, Player, Connectors } from "shoukaku"; // Removed player event types, Added Connectors // import { Connectors } from 'shoukaku-discord.js'; // Use the discord.js connector - Removed this line -import logger from '../utils/logger'; -import { BotClient } from '../index'; +import logger from "../utils/logger"; +import { BotClient } from "../index"; // Removed imports from play.ts for now as player listeners are removed -// Define Node options (replace with your actual Lavalink details from .env) +// Define Node _options (replace with your actual Lavalink details from .env) const nodes: NodeOption[] = [ - { - name: process.env.LAVALINK_NAME || 'Lavalink-Node-1', - url: process.env.LAVALINK_URL || 'lavalink:2333', // Use service name for Docker Compose if applicable - auth: process.env.LAVALINK_AUTH || 'youshallnotpass', - secure: process.env.LAVALINK_SECURE === 'true' || false, - }, + { + name: process.env.LAVALINK_NAME || "Lavalink-Node-1", + url: process.env.LAVALINK_URL || "lavalink:2333", // Use service name for Docker Compose if applicable + auth: process.env.LAVALINK_AUTH || "youshallnotpass", + secure: process.env.LAVALINK_SECURE === "true" || false, + }, ]; -// Define Shoukaku options +// Define Shoukaku _options const shoukakuOptions: ShoukakuOptions = { - moveOnDisconnect: false, - resume: false, // Resume doesn't work reliably across restarts/disconnects without session persistence - reconnectTries: 3, - reconnectInterval: 5, // In seconds - restTimeout: 15000, // In milliseconds - voiceConnectionTimeout: 15, // In seconds + moveOnDisconnect: false, + resume: false, // Resume doesn't work reliably across restarts/disconnects without session persistence + reconnectTries: 3, + reconnectInterval: 5, // In seconds + restTimeout: 15000, // In milliseconds + voiceConnectionTimeout: 15, // In seconds }; // Function to initialize Shoukaku and attach listeners -export function initializeShoukaku(client: BotClient): Shoukaku { - if (!client) { - throw new Error("initializeShoukaku requires a client instance."); +export function initializeShoukaku(_client: BotClient): Shoukaku { + if (!_client) { + throw new Error("initializeShoukaku requires a _client instance."); + } + + const shoukaku = new Shoukaku(new Connectors.DiscordJS(_client), nodes, shoukakuOptions); + + // --- Shoukaku Node Event Listeners --- + shoukaku.on("ready", (name, resumed) => + logger.info(`Lavalink Node '${name}' ready. Resumed: ${resumed}`), + ); + + shoukaku.on("error", (name, error) => + logger.error(`Lavalink Node '${name}' error: ${error.message}`, error), + ); + + shoukaku.on("close", (name, code, reason) => + logger.warn(`Lavalink Node '${name}' closed. Code: ${code}. Reason: ${reason || "No reason"}`), + ); + + // Fix: Correct disconnect listener signature + shoukaku.on("disconnect", (name, count) => { + // count = count of players disconnected from the node + logger.warn(`Lavalink Node '${name}' disconnected. ${count} players disconnected.`); + // If players were not moved, you might want to attempt to reconnect them or clean them up. + }); + + shoukaku.on("debug", (name, info) => { + // Only log debug messages if not in production or if explicitly enabled + if (process.env.NODE_ENV !== "production" || process.env.LAVALINK_DEBUG === "true") { + logger.debug(`Lavalink Node '${name}' debug: ${info}`); } + }); - const shoukaku = new Shoukaku(new Connectors.DiscordJS(client), nodes, shoukakuOptions); + // --- Shoukaku Player Event Listeners --- + // REMOVED - These need to be attached differently in Shoukaku v4 (e.g., when player is created) - // --- Shoukaku Node Event Listeners --- - shoukaku.on('ready', (name, resumed) => - logger.info(`Lavalink Node '${name}' ready. Resumed: ${resumed}`) - ); - - shoukaku.on('error', (name, error) => - logger.error(`Lavalink Node '${name}' error: ${error.message}`, error) - ); - - shoukaku.on('close', (name, code, reason) => - logger.warn(`Lavalink Node '${name}' closed. Code: ${code}. Reason: ${reason || 'No reason'}`) - ); - - // Fix: Correct disconnect listener signature - shoukaku.on('disconnect', (name, count) => { - // count = count of players disconnected from the node - logger.warn(`Lavalink Node '${name}' disconnected. ${count} players disconnected.`); - // If players were not moved, you might want to attempt to reconnect them or clean them up. - }); - - shoukaku.on('debug', (name, info) => { - // Only log debug messages if not in production or if explicitly enabled - if (process.env.NODE_ENV !== 'production' || process.env.LAVALINK_DEBUG === 'true') { - logger.debug(`Lavalink Node '${name}' debug: ${info}`); - } - }); - - // --- Shoukaku Player Event Listeners --- - // REMOVED - These need to be attached differently in Shoukaku v4 (e.g., when player is created) - - logger.info("Shoukaku instance created and node event listeners attached."); - return shoukaku; + logger.info("Shoukaku instance created and node event listeners attached."); + return shoukaku; } diff --git a/src/types/botClient.ts b/src/types/botClient.ts new file mode 100644 index 0000000..f6107ca --- /dev/null +++ b/src/types/botClient.ts @@ -0,0 +1,7 @@ +import { Client, Collection } from "discord.js"; +import { Shoukaku } from "shoukaku"; + +export interface BotClient extends Client { + commands: Collection; + shoukaku: Shoukaku; +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 65dc5a1..14e5dde 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,4 +1,4 @@ -import winston, { format, transports } from 'winston'; // Use ES6 import +import winston, { format, transports } from "winston"; // Use ES6 import // No longer needed: import { TransformableInfo } from 'logform'; // Define the type for the log info object after timestamp is added @@ -8,24 +8,26 @@ import winston, { format, transports } from 'winston'; // Use ES6 import // }; const logger = winston.createLogger({ - level: process.env.LOG_LEVEL || 'info', // Use LOG_LEVEL from env or default to 'info' - format: format.combine( - format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), // This adds the timestamp - format.printf((info: any) => { // Use 'any' for now to bypass strict type checking here - // Ensure message exists, handle potential non-string messages if necessary - // The 'info' object structure depends on the preceding formatters - const timestamp = info.timestamp || new Date().toISOString(); // Fallback if timestamp isn't added - const level = (info.level || 'info').toUpperCase(); - const message = typeof info.message === 'string' ? info.message : JSON.stringify(info.message); - return `${timestamp} ${level}: ${message}`; - }) - ), - transports: [ - new transports.Console(), - // Optionally add file transport - // new transports.File({ filename: 'combined.log' }), - // new transports.File({ filename: 'error.log', level: 'error' }), - ], + level: process.env.LOG_LEVEL || "info", // Use LOG_LEVEL from env or default to 'info' + format: format.combine( + format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), // This adds the timestamp + format.printf((info: any) => { + // Use 'any' for now to bypass strict type checking here + // Ensure message exists, handle potential non-string messages if necessary + // The 'info' object structure depends on the preceding formatters + const timestamp = info.timestamp || new Date().toISOString(); // Fallback if timestamp isn't added + const level = (info.level || "info").toUpperCase(); + const message = + typeof info.message === "string" ? info.message : JSON.stringify(info.message); + return `${timestamp} ${level}: ${message}`; + }), + ), + transports: [ + new transports.Console(), + // Optionally add file transport + // new transports.File({ filename: 'combined.log' }), + // new transports.File({ filename: 'error.log', level: 'error' }), + ], }); export default logger; // Use ES6 export default diff --git a/tsconfig.json b/tsconfig.json index 6330e43..ddc5bb8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,37 +1,25 @@ { "compilerOptions": { - /* Base Options: */ + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "dist", + "rootDir": ".", + "strict": true, + "noImplicitAny": true, "esModuleInterop": true, "skipLibCheck": true, - "target": "ES2022", - "allowJs": true, + "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "moduleDetection": "force", - "isolatedModules": true, - /* Strictness */ - "strict": true, - "noUncheckedIndexedAccess": true, - "checkJs": true, - /* If NOT transpiling with TypeScript: */ - "module": "NodeNext", - "noEmit": true, - /* If your code runs in the DOM: */ - // "lib": ["es2022", "dom", "dom.iterable"], - /* If your code doesn't run in the DOM: */ - "lib": ["ES2022"], - - /* If transpiling with TypeScript: */ - "module": "CommonJS", // Use CommonJS for Node.js compatibility - "outDir": "dist", // Output compiled JS to dist/ - "sourceMap": true, // Generate source maps - - /* Project Structure */ - // "rootDir": "src", // Remove rootDir as include covers files outside src - "baseUrl": ".", // Allows for path aliases if needed - "paths": { - "@/*": ["src/*"] // Example path alias - keep if used, adjust if needed - } + "declaration": true, + "moduleResolution": "node" }, - "include": ["src/**/*.ts", "deploy-commands.ts", "tests/**/*.ts"], // Include source, deploy script, and tests - "exclude": ["node_modules", "dist"] // Exclude build output and dependencies + "include": [ + "src/**/*", + "deploy-commands.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] }