From 3c4dc518555b8819dd814ef0985377d0d8da46ff Mon Sep 17 00:00:00 2001 From: aki Date: Thu, 24 Apr 2025 13:48:10 +0800 Subject: [PATCH] refactor: Convert project from JavaScript to TypeScript - Converted all .js files to .ts - Added TypeScript configuration (tsconfig.json) - Added ESLint and Prettier configuration - Updated package.json dependencies - Modified Docker and application configurations --- .eslintrc.json | 27 ++ .gitignore | 3 + .prettierignore | 21 ++ .prettierrc.json | 9 + Dockerfile | 48 ++- application.yml | 10 +- deploy-commands.js | 82 ----- deploy-commands.ts | 103 ++++++ package.json | 21 +- src/commands/join.js | 88 ----- src/commands/join.ts | 122 ++++++ src/commands/leave.js | 48 --- src/commands/leave.ts | 67 ++++ src/commands/{ping.js => ping.ts} | 7 +- src/commands/play.js | 170 --------- src/commands/play.ts | 347 ++++++++++++++++++ src/events/interactionCreate.js | 41 --- src/events/interactionCreate.ts | 64 ++++ src/events/ready.js | 23 -- src/events/ready.ts | 31 ++ ...oiceStateUpdate.js => voiceStateUpdate.ts} | 40 +- src/index.js | 110 ------ src/index.ts | 186 ++++++++++ src/structures/ErelaEvents.js | 98 ----- src/structures/ShoukakuEvents.js | 292 --------------- src/structures/ShoukakuEvents.ts | 67 ++++ src/utils/logger.js | 17 - src/utils/logger.ts | 31 ++ ...mmands.test.js => deploy-commands.test.ts} | 6 +- ...rt-script.test.js => start-script.test.ts} | 0 tests/{startup.test.js => startup.test.ts} | 0 tsconfig.json | 37 ++ 32 files changed, 1212 insertions(+), 1004 deletions(-) create mode 100644 .eslintrc.json create mode 100644 .prettierignore create mode 100644 .prettierrc.json delete mode 100644 deploy-commands.js create mode 100644 deploy-commands.ts delete mode 100644 src/commands/join.js create mode 100644 src/commands/join.ts delete mode 100644 src/commands/leave.js create mode 100644 src/commands/leave.ts rename src/commands/{ping.js => ping.ts} (66%) delete mode 100644 src/commands/play.js create mode 100644 src/commands/play.ts delete mode 100644 src/events/interactionCreate.js create mode 100644 src/events/interactionCreate.ts delete mode 100644 src/events/ready.js create mode 100644 src/events/ready.ts rename src/events/{voiceStateUpdate.js => voiceStateUpdate.ts} (51%) delete mode 100644 src/index.js create mode 100644 src/index.ts delete mode 100644 src/structures/ErelaEvents.js delete mode 100644 src/structures/ShoukakuEvents.js create mode 100644 src/structures/ShoukakuEvents.ts delete mode 100644 src/utils/logger.js create mode 100644 src/utils/logger.ts rename tests/{deploy-commands.test.js => deploy-commands.test.ts} (91%) rename tests/{start-script.test.js => start-script.test.ts} (100%) rename tests/{startup.test.js => startup.test.ts} (100%) create mode 100644 tsconfig.json diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..a89f363 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,27 @@ +{ + "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/.gitignore b/.gitignore index fd5cd29..4db802a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ pnpm-lock.yaml npm-debug.log* logs/ *.log + +# Data directory +data/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..63e3214 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,21 @@ +# Ignore artifacts: +node_modules +dist +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 diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..a3ec611 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "endOfLine": "lf" +} diff --git a/Dockerfile b/Dockerfile index e3387d2..3ec052e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,45 @@ +# ---- Build Stage ---- +FROM node:23-alpine AS builder + +WORKDIR /app + +# Install pnpm and necessary build tools (if native modules are used) +RUN apk add --no-cache python3 make g++ pnpm + +# Copy package manifests +COPY package.json pnpm-lock.yaml ./ + +# Install ALL dependencies (including devDependencies needed for build) +RUN pnpm install --frozen-lockfile + +# Copy the rest of the source code +COPY . . + +# Compile TypeScript +RUN pnpm run build + +# Prune devDependencies after build (optional but good practice) +RUN pnpm prune --prod + + +# ---- Production Stage ---- FROM node:23-alpine WORKDIR /app -RUN apk add --no-cache python3 make g++ pnpm - -COPY package.json pnpm-lock.yaml ./ - -RUN pnpm install --frozen-lockfile - -COPY . . - ENV NODE_ENV=production -CMD ["node", "src/index.js"] +# Copy necessary files from the builder stage +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/package.json ./package.json +# Copy other runtime necessities (adjust if needed) +# COPY .env.example ./ +# COPY application.yml ./ +# COPY plugins ./plugins + +# Expose port if needed (though likely not for a Discord bot) +# EXPOSE 3000 + +# Run the compiled JavaScript application +CMD ["node", "dist/index.js"] diff --git a/application.yml b/application.yml index e187a22..3b3a082 100644 --- a/application.yml +++ b/application.yml @@ -15,6 +15,13 @@ plugins: clients: - WEB - WEBEMBEDDED + - MUSIC + oauth: + enabled: true + # If you obtain a refresh token after the initial OAuth flow, you can add it here + # refreshToken: "paste your refresh token here if applicable" + # Leave skipInitialization commented for first-time setup + # skipInitialization: true lavalink: plugins: # - dependency: "com.github.example:example-plugin:1.0.0" # required, the coordinates of your plugin @@ -92,7 +99,8 @@ logging: level: root: INFO lavalink: INFO - dev.lavalink.youtube: INFO # Add debug logging for youtube plugin + dev.lavalink.youtube: INFO # General YouTube plugin logging + dev.lavalink.youtube.http.YoutubeOauth2Handler: INFO # Specific OAuth flow logging request: enabled: true diff --git a/deploy-commands.js b/deploy-commands.js deleted file mode 100644 index 9a1408c..0000000 --- a/deploy-commands.js +++ /dev/null @@ -1,82 +0,0 @@ -const { REST, Routes } = require('discord.js'); -const fs = require('node:fs'); -const path = require('node:path'); -const logger = require('./src/utils/logger'); // Assuming logger is setup -require('dotenv').config(); // Load .env variables - -console.log('CLIENT_ID: ', process.env.CLIENT_ID ? 'Present' : process.env.CLIENT_ID); -console.log('DISCORD_TOKEN:', process.env.DISCORD_TOKEN ? 'Present' : process.env.DISCORD_TOKEN); - -// --- Configuration --- -const clientId = process.env.CLIENT_ID; -const token = process.env.DISCORD_TOKEN; -// const guildId = process.env.GUILD_ID; // Uncomment for guild-specific commands during testing - -if (!clientId || !token) { - logger.error('Missing CLIENT_ID or DISCORD_TOKEN in .env file for command deployment!'); - process.exit(1); -} - -const commands = []; -// Grab all the command files from the commands directory you created earlier -const commandsPath = path.join(__dirname, 'src', 'commands'); -const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js')); - -// Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment -logger.info(`Started loading ${commandFiles.length} application (/) commands for deployment.`); -for (const file of commandFiles) { - const filePath = path.join(commandsPath, file); - try { - const command = require(filePath); - if ('data' in command && 'execute' in command) { - commands.push(command.data.toJSON()); - logger.info(`Loaded command: ${command.data.name}`); - } else { - logger.warn(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); - } - } catch (error) { - logger.error(`Error loading command at ${filePath} for deployment: ${error.message}`, error); - } -} - -// Construct and prepare an instance of the REST module -const rest = new REST({ version: '10' }).setToken(token); - -// and deploy your commands! -(async () => { - try { - logger.info(`Started wiping all global and guild application (/) commands.`); - - // 1. Wipe Global Commands - await rest.put( - Routes.applicationCommands(clientId), - { body: [] } - ); - logger.info('Successfully wiped all global application commands.'); - - // 2. Wipe Guild Commands (optional but recommended for dev/testing guilds) - const guildId = process.env.GUILD_ID; // Make sure this is set - if (guildId) { - await rest.put( - Routes.applicationGuildCommands(clientId, guildId), - { body: [] } - ); - logger.info(`Successfully wiped all application commands in guild ${guildId}.`); - } else { - logger.warn('GUILD_ID not set; skipping guild command wipe.'); - } - - // 3. Register New Global Commands - logger.info(`Registering ${commands.length} new global commands...`); - const data = await rest.put( - Routes.applicationCommands(clientId), - { body: commands }, - ); - - logger.info(`Successfully registered ${data.length} new global commands.`); - - } catch (error) { - logger.error('Failed during command reset and deployment:', error); - } -})(); - diff --git a/deploy-commands.ts b/deploy-commands.ts new file mode 100644 index 0000000..4ef6633 --- /dev/null +++ b/deploy-commands.ts @@ -0,0 +1,103 @@ +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 + +// Log presence of required env vars (optional, but helpful for debugging) +// logger.info(`CLIENT_ID: ${process.env.CLIENT_ID ? 'Present' : 'MISSING!'}`); +// logger.info(`DISCORD_TOKEN: ${process.env.DISCORD_TOKEN ? 'Present' : 'MISSING!'}`); + +// --- Configuration --- +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); +} + +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'); +// Read .ts files now +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; + + 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); + +// Define the deployment function +const deployCommands = async () => { + 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); + } +}; + +// Execute the deployment +deployCommands(); + +// Note: The old wipe logic is removed as PUT overwrites existing commands. +// If you specifically need to wipe commands first for some reason, +// you can add separate PUT requests with an empty body before deploying. diff --git a/package.json b/package.json index 1548cfd..5146251 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,13 @@ "name": "discord-music-bot", "version": "1.0.0", "description": "", - "main": "index.js", + "main": "dist/index.js", "scripts": { - "start": "node src/index.js", + "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" }, "keywords": [], @@ -17,7 +21,18 @@ "winston": "^3.17.0" }, "devDependencies": { + "@types/jest": "^29.5.14", + "@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" + "js-yaml": "^4.1.0", + "prettier": "^3.5.3", + "ts-node-dev": "^2.0.0", + "typescript": "^5.8.3" } } diff --git a/src/commands/join.js b/src/commands/join.js deleted file mode 100644 index 987bab2..0000000 --- a/src/commands/join.js +++ /dev/null @@ -1,88 +0,0 @@ -const { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } = require('discord.js'); // Import MessageFlags -const logger = require('../utils/logger'); - -module.exports = { - data: new SlashCommandBuilder() - .setName('join') - .setDescription('Joins your current voice channel'), - async execute(interaction, client) { // Added client parameter - // Use flags for ephemeral deferral - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - const member = interaction.member; - 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!'); - } - - // 2. Check bot permissions - const permissions = voiceChannel.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!'); - } - // Ensure it's a voice channel (not stage, etc.) although erela might handle this - if (voiceChannel.type !== ChannelType.GuildVoice) { - return interaction.editReply('I can only join standard voice channels.'); - } - - // Get the initialized Shoukaku player manager from the client object - const musicPlayer = interaction.client.player; - if (!musicPlayer) { - logger.error('Music player not initialized 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 - let player = musicPlayer.getPlayer(interaction.guildId); - - if (!player) { - try { - // Create player using the Shoukaku manager - player = await musicPlayer.createPlayer({ - guildId: interaction.guildId, - textChannel: interaction.channelId, - voiceChannel: voiceChannel.id - }); - // Connection is handled within createPlayer - logger.info(`Created player and connected to voice channel ${voiceChannel.name} (${voiceChannel.id}) in guild ${interaction.guild.name} (${interaction.guildId})`); - await interaction.editReply(`Joined ${voiceChannel.name}! Ready to play music.`); - - } catch (error) { - logger.error(`Failed to create/connect player for guild ${interaction.guildId}: ${error.message}`, error); - // Player destruction is handled internally if creation fails or via destroy method - return interaction.editReply('An error occurred while trying to join the voice channel.'); - } - } else { - // If player exists but is in a different channel - if (player.voiceChannel !== voiceChannel.id) { - // Destroy the old player and create a new one in the correct channel - player.destroy(); - try { - player = await musicPlayer.createPlayer({ - guildId: interaction.guildId, - textChannel: interaction.channelId, - voiceChannel: voiceChannel.id - }); - logger.info(`Moved player to voice channel ${voiceChannel.name} (${voiceChannel.id}) in guild ${interaction.guildId}`); - await interaction.editReply(`Moved to ${voiceChannel.name}!`); - } catch (error) { - logger.error(`Failed to move player for guild ${interaction.guildId}: ${error.message}`, 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 ${voiceChannel.name}!`); - } - // Update text channel if needed (Shoukaku player object stores textChannel) - if (player.textChannel !== interaction.channelId) { - player.textChannel = interaction.channelId; // Directly update the property - logger.debug(`Updated player text channel to ${interaction.channel.name} (${interaction.channelId}) in guild ${interaction.guildId}`); - } - } - }, -}; diff --git a/src/commands/join.ts b/src/commands/join.ts new file mode 100644 index 0000000..42bee49 --- /dev/null +++ b/src/commands/join.ts @@ -0,0 +1,122 @@ +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 + +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.'); + } + } 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.js b/src/commands/leave.js deleted file mode 100644 index 897296b..0000000 --- a/src/commands/leave.js +++ /dev/null @@ -1,48 +0,0 @@ -const { SlashCommandBuilder, MessageFlags } = require('discord.js'); // Import MessageFlags -const logger = require('../utils/logger'); - -module.exports = { - data: new SlashCommandBuilder() - .setName('leave') - .setDescription('Leaves the current voice channel'), - async execute(interaction, client) { // Added client parameter - // Use flags for ephemeral deferral - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - // Get the Shoukaku player manager - const musicPlayer = interaction.client.player; - if (!musicPlayer) { - logger.error('Music player not initialized on client object!'); - return interaction.editReply('The music player is not ready yet.'); - } - - // Get the player for this guild using Shoukaku manager - const player = musicPlayer.getPlayer(interaction.guildId); - - // Check if the player exists (Shoukaku player object has voiceChannel property) - if (!player || !player.voiceChannel) { - 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 memberVoiceChannel = interaction.member?.voice?.channelId; - // if (memberVoiceChannel !== player.voiceChannel) { - // return interaction.editReply('You need to be in the same voice channel as me to make me leave!'); - // } - - try { - const channelId = player.voiceChannel; // Get channel ID from Shoukaku player - const channel = client.channels.cache.get(channelId); - const channelName = channel ? channel.name : `ID: ${channelId}`; // Get channel name if possible - - player.destroy(); // Use Shoukaku player's destroy method - logger.info(`Player destroyed and left voice channel ${channelName} in guild ${interaction.guild.name} (${interaction.guildId}) by user ${interaction.user.tag}`); - await interaction.editReply(`Left ${channelName}.`); - - } catch (error) { - logger.error(`Error destroying player for guild ${interaction.guildId}: ${error.message}`, error); - // Attempt to reply even if destroy failed partially - await interaction.editReply('An error occurred while trying to leave the voice channel.').catch(e => logger.error(`Failed to send error reply for leave command: ${e.message}`)); - } - }, -}; diff --git a/src/commands/leave.ts b/src/commands/leave.ts new file mode 100644 index 0000000..3044d74 --- /dev/null +++ b/src/commands/leave.ts @@ -0,0 +1,67 @@ +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 +// 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(() => {}); + } + + // 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.'); + } + + // 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!'); + // } + + 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); + + 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.js b/src/commands/ping.ts similarity index 66% rename from src/commands/ping.js rename to src/commands/ping.ts index 9f51562..7508b93 100644 --- a/src/commands/ping.js +++ b/src/commands/ping.ts @@ -1,10 +1,11 @@ -const { SlashCommandBuilder } = require('discord.js'); +import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js'; +// No need to import BotClient if not used directly in execute -module.exports = { +export default { // Use export default for ES Modules data: new SlashCommandBuilder() .setName('ping') .setDescription('Replies with Pong!'), - async execute(interaction) { + 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; diff --git a/src/commands/play.js b/src/commands/play.js deleted file mode 100644 index e4dd394..0000000 --- a/src/commands/play.js +++ /dev/null @@ -1,170 +0,0 @@ -const { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder } = require('discord.js'); -const logger = require('../utils/logger'); -// Removed direct import of musicPlayer - -module.exports = { - data: new SlashCommandBuilder() - .setName('play') - .setDescription('Plays audio from a URL or search query') - .addStringOption(option => - option.setName('query') - .setDescription('The URL or search term for the song/playlist') - .setRequired(true)) - .addStringOption(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' } - )), - async execute(interaction, client) { - await interaction.deferReply(); // Defer reply immediately - - const member = interaction.member; - const voiceChannel = member?.voice?.channel; - const query = interaction.options.getString('query'); - const source = interaction.options.getString('source'); // Get the source option - - // 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!'); - } - - // 2. Check bot permissions - const permissions = voiceChannel.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 (voiceChannel.type !== ChannelType.GuildVoice) { - return interaction.editReply('I can only join standard voice channels.'); - } - - try { - // Get the initialized player from the client object - const musicPlayer = interaction.client.player; - if (!musicPlayer) { - logger.error('Music player not initialized on client object!'); - return interaction.editReply('The music player is not ready yet. Please try again shortly.'); - } - - // 3. Get or create player - let player = musicPlayer.getPlayer(interaction.guildId); - if (!player) { - try { - player = await musicPlayer.createPlayer({ - guildId: interaction.guildId, - textChannel: interaction.channelId, // Use interaction.channelId directly - voiceChannel: voiceChannel.id // Use voiceChannel.id directly - }); - logger.info(`Created player and connected to voice channel ${voiceChannel.name} (${voiceChannel.id}) for play command.`); - } catch (error) { - logger.error(`Failed to create/connect player for guild ${interaction.guildId} during play command: ${error.message}`); - return interaction.editReply('An error occurred while trying to join the voice channel.'); - } - } else if (player.voiceChannel !== voiceChannel.id) { - // If player exists but in a different voice channel, destroy it and create a new one - player.destroy(); - player = await musicPlayer.createPlayer({ - guildId: interaction.guildId, - textChannel: interaction.channelId, - voiceChannel: voiceChannel.id - }); - logger.info(`Moved player to voice channel ${voiceChannel.name} (${voiceChannel.id}) for play command.`); - } - - // 4. Determine search identifier based on query and source - let identifier; - 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 if source is 'youtubemusic' or not provided - identifier = `ytmsearch:${query}`; - break; - } - } - logger.debug(`Constructed identifier: ${identifier}`); - - // 5. Search for tracks using the constructed identifier - const searchResults = await musicPlayer.search({ // Use the player instance from the client - identifier: identifier, // Pass the constructed identifier - requester: interaction.user - }); - - if (!searchResults || searchResults.length === 0) { - await interaction.editReply(`No results found for "${query}".`); - if (!player.playing && player.queue.length === 0) { - player.destroy(); - } - return; - } - - // 6. Add track(s) to queue and create response embed - const responseEmbed = new EmbedBuilder().setColor('#0099ff'); - - // Add first track (or all tracks if it's a playlist) - const firstTrack = searchResults[0]; - - // Detect if it's a playlist based on number of tracks - const isPlaylist = searchResults.length > 1 && - searchResults[0].info.uri.includes('playlist'); - - if (isPlaylist) { - // Add all tracks to the queue - for (const track of searchResults) { - await player.enqueue(track); - } - - // Set up playlist embed - responseEmbed - .setTitle('Playlist Added to Queue') - .setDescription(`**Playlist** (${searchResults.length} tracks)`) - .addFields({ name: 'Starting track', value: `[${firstTrack.info.title}](${firstTrack.info.uri})` }); - - logger.info(`Added playlist with ${searchResults.length} tracks to queue (Guild: ${interaction.guildId})`); - } else { - // Add single track to queue - await player.enqueue(firstTrack); - - // Set up track embed - responseEmbed - .setTitle('Track Added to Queue') - .setDescription(`[${firstTrack.info.title}](${firstTrack.info.uri})`) - .addFields({ name: 'Position in queue', value: `${player.queue.length}`, inline: true }); - - // Add thumbnail if available - if (firstTrack.info.thumbnail) { - responseEmbed.setThumbnail(firstTrack.info.thumbnail); - } - - logger.info(`Added track to queue: ${firstTrack.info.title} (Guild: ${interaction.guildId})`); - } - - // Send response - await interaction.editReply({ embeds: [responseEmbed] }); - - } catch (error) { - logger.error(`Error during search/play for query "${query}" in guild ${interaction.guildId}: ${error.message}`); - await interaction.editReply('An unexpected error occurred while trying to play the music.').catch(e => - logger.error(`Failed to send error reply for play command: ${e.message}`) - ); - } - - logger.info(`Executed command 'play' for user ${interaction.user.tag}`); - }, -}; diff --git a/src/commands/play.ts b/src/commands/play.ts new file mode 100644 index 0000000..7b1e103 --- /dev/null +++ b/src/commands/play.ts @@ -0,0 +1,347 @@ +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'; +// Import necessary Shoukaku types - LavalinkResponse might need a local definition if not exported +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'; + +interface LavalinkResponse { + loadType: LavalinkLoadType; + data: any; // Data structure varies based on loadType +} + +interface LavalinkErrorData { + message: string; + severity: string; + cause: string; +} + +interface LavalinkPlaylistInfo { + 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[]; +} + +// 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 +} + +// 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; + }; +} + +// 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; + } + + 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); + } +} + +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(() => {}); + } + + 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 + + // 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.'); + } + + // 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 + + 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(() => {}); + // } + } + }, +}; diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js deleted file mode 100644 index c9f7085..0000000 --- a/src/events/interactionCreate.js +++ /dev/null @@ -1,41 +0,0 @@ -const { Events, InteractionType } = require('discord.js'); -const logger = require('../utils/logger'); - -module.exports = { - name: Events.InteractionCreate, - async execute(interaction, client) { // Added client parameter - // Handle only slash commands (ChatInputCommand) for now - if (!interaction.isChatInputCommand()) return; - - const command = client.commands.get(interaction.commandName); - - if (!command) { - logger.error(`No command matching ${interaction.commandName} was found.`); - try { - await interaction.reply({ content: 'Error: This command was not found!', ephemeral: true }); - } catch (replyError) { - logger.error(`Failed to send 'command not found' reply: ${replyError.message}`); - } - return; - } - - try { - // Execute the command's logic - await command.execute(interaction, client); // Pass client to command execute - logger.info(`Executed command '${interaction.commandName}' for user ${interaction.user.tag}`); - } catch (error) { - logger.error(`Error executing command '${interaction.commandName}': ${error.message}`, 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 { - if (interaction.replied || interaction.deferred) { - await interaction.followUp(replyOptions); - } else { - await interaction.reply(replyOptions); - } - } catch (replyError) { - logger.error(`Failed to send error reply for command '${interaction.commandName}': ${replyError.message}`); - } - } - }, -}; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts new file mode 100644 index 0000000..76b8735 --- /dev/null +++ b/src/events/interactionCreate.ts @@ -0,0 +1,64 @@ +import { Events, Interaction } from 'discord.js'; // Import Interaction type +import logger from '../utils/logger'; // Use default import +import { BotClient } from '../index'; // Import BotClient type + +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; + + // Store command name after type check + const commandName = interaction.commandName; + + // client.commands should be typed as Collection on BotClient + const command = client.commands.get(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; + } + + 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}`); + } + } + }, +}; diff --git a/src/events/ready.js b/src/events/ready.js deleted file mode 100644 index f454c4f..0000000 --- a/src/events/ready.js +++ /dev/null @@ -1,23 +0,0 @@ -const { Events, ActivityType } = require('discord.js'); -const logger = require('../utils/logger'); -const { setupPlayer } = require('../structures/ShoukakuEvents'); // Import the Shoukaku player - -module.exports = { - name: Events.ClientReady, - once: true, // This event should only run once - async execute(client) { - logger.info(`Ready! Logged in as ${client.user.tag}`); - - // Initialize the Shoukaku music player - try { - // Set up the music player with the client - client.player = setupPlayer(client); - logger.info('Shoukaku music player initialized successfully'); - } catch (error) { - logger.error(`Failed to initialize Shoukaku music player: ${error.message}`); - } - - // Set activity status - client.user.setActivity('Music | /play', { type: ActivityType.Listening }); - }, -}; diff --git a/src/events/ready.ts b/src/events/ready.ts new file mode 100644 index 0000000..3fbf4ff --- /dev/null +++ b/src/events/ready.ts @@ -0,0 +1,31 @@ +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}`); + + // 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 }); + }, +}; diff --git a/src/events/voiceStateUpdate.js b/src/events/voiceStateUpdate.ts similarity index 51% rename from src/events/voiceStateUpdate.js rename to src/events/voiceStateUpdate.ts index 9c3e5cc..6fefb2a 100644 --- a/src/events/voiceStateUpdate.js +++ b/src/events/voiceStateUpdate.ts @@ -1,39 +1,47 @@ -const { Events } = require('discord.js'); -const logger = require('../utils/logger'); +import { Events, VoiceState, ChannelType } from 'discord.js'; // Added ChannelType +import logger from '../utils/logger'; +import { BotClient } from '../index'; // Assuming BotClient is exported from index -module.exports = { +export default { // Use export default for ES modules name: Events.VoiceStateUpdate, - execute(oldState, newState, client) { // Added client parameter + 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 musicPlayer = client.player; - if (!musicPlayer) { - // Player manager might not be ready yet, especially during startup. - // logger.debug('Voice state update received, but Shoukaku player manager is not ready yet.'); + 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 = musicPlayer.getPlayer(newState.guild.id); + 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 + // Check if the bot was disconnected (newState has no channelId for the bot) - if (newState.id === client.user.id && !newState.channelId && oldState.channelId === player.voiceChannel) { + // 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 = client.channels.cache.get(player.voiceChannel); - // Ensure the channel exists and the update is relevant to the bot's channel - if (channel && (newState.channelId === player.voiceChannel || oldState.channelId === player.voiceChannel)) { + 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; - if (members.size === 1 && members.has(client.user.id)) { - logger.info(`Voice channel ${channel.name} (${player.voiceChannel}) in guild ${newState.guild.id} is now empty (only bot left). Destroying player.`); + 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); diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 67374a2..0000000 --- a/src/index.js +++ /dev/null @@ -1,110 +0,0 @@ -// Load environment variables from .env file -require('dotenv').config(); -const { Client, GatewayIntentBits, Collection } = require('discord.js'); -const { Shoukaku, Connectors } = require('shoukaku'); -const logger = require('./utils/logger'); -const fs = require('fs'); -const path = require('path'); - -// Validate essential environment variables -if (!process.env.DISCORD_TOKEN) { - 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 -} - -// 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 - ], -}); - -// Define Shoukaku nodes - fix the URL format to properly connect to Lavalink -const Nodes = [ - { - name: 'lavalink', - url: `${process.env.LAVALINK_HOST || 'localhost'}:${process.env.LAVALINK_PORT || '2333'}`, - auth: process.env.LAVALINK_PASSWORD || 'youshallnotpass', - secure: process.env.LAVALINK_SECURE === 'true' - } -]; - -// Initialize Shoukaku with proper configuration -client.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), Nodes, { - moveOnDisconnect: false, - resume: true, - reconnectTries: 10, - reconnectInterval: 5000, -}); - -// 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]'})`); - -// Collections for commands -client.commands = new Collection(); - -// --- Command Loading --- -const commandsPath = path.join(__dirname, 'commands'); -const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js')); - -for (const file of commandFiles) { - const filePath = path.join(commandsPath, file); - try { - const command = require(filePath); - // Set a new item in the Collection with the key as the command name and the value as the exported module - if ('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.`); - } - } catch (error) { - logger.error(`Error loading command at ${filePath}: ${error.message}`, error); - } -} - -// --- Event Handling --- -const eventsPath = path.join(__dirname, 'events'); -const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js')); - -for (const file of eventFiles) { - const filePath = path.join(eventsPath, file); - const event = require(filePath); - if (event.once) { - client.once(event.name, (...args) => event.execute(...args, client)); // Pass client to event handlers - logger.info(`Loaded event ${event.name} (once)`); - } else { - client.on(event.name, (...args) => event.execute(...args, client)); // Pass client to event handlers - logger.info(`Loaded event ${event.name}`); - } -} - -// --- Shoukaku Event Handling --- -// Set up Shoukaku event handlers -client.shoukaku.on('ready', (name) => logger.info(`Lavalink Node: ${name} is now connected`)); -client.shoukaku.on('error', (name, error) => logger.error(`Lavalink Node: ${name} emitted an error: ${error.message}`)); -client.shoukaku.on('close', (name, code, reason) => logger.warn(`Lavalink Node: ${name} closed with code ${code}. Reason: ${reason || 'No reason'}`)); -client.shoukaku.on('disconnect', (name, reason) => logger.warn(`Lavalink Node: ${name} disconnected. Reason: ${reason || 'No reason'}`)); - -// Log in to Discord with your client's token -client.login(process.env.DISCORD_TOKEN) - .then(() => logger.info('Successfully logged in to Discord.')) - .catch(error => logger.error(`Failed to log in: ${error.message}`)); - -// Basic error handling -process.on('unhandledRejection', error => { - logger.error('Unhandled promise rejection:', error); -}); -process.on('uncaughtException', error => { - logger.error('Uncaught exception:', error); - // Optional: exit process on critical uncaught exceptions - // process.exit(1); -}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a811aaf --- /dev/null +++ b/src/index.ts @@ -0,0 +1,186 @@ +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'; +// 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 +} + +// 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 +} + +// Extend the discord.js Client class to include custom properties +export interface BotClient extends Client { // Add export keyword + commands: Collection; + shoukaku: Shoukaku; +} + +// --- Setup --- +dotenv.config(); +// __dirname is available in CommonJS modules, which is set in tsconfig.json + +// Validate essential environment variables +if (!process.env.DISCORD_TOKEN) { + 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 +} + +// 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 + }, +]; + +// 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 +}; + +// Initialize Shoukaku +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]'})`); + +// Collections for commands +client.commands = new Collection(); + +// --- Command Loading --- +const commandsPath = path.join(__dirname, 'commands'); +// Read .ts files now +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 + + 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'); +// Read .ts files now +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 + + 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'}`)); +// 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.`); +}); + + +// --- Main Execution --- +async function main() { + 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 + } +} + +main().catch((error) => { + 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('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/ErelaEvents.js b/src/structures/ErelaEvents.js deleted file mode 100644 index 903c042..0000000 --- a/src/structures/ErelaEvents.js +++ /dev/null @@ -1,98 +0,0 @@ -const logger = require('../utils/logger'); -const { EmbedBuilder } = require('discord.js'); // Import EmbedBuilder - -module.exports = (client) => { - if (!client || !client.manager) { - logger.error("ErelaEvents requires a client with an initialized manager."); - return; - } - - client.manager - .on('nodeConnect', node => logger.info(`Node "${node.options.identifier}" connected.`)) - .on('nodeError', (node, error) => logger.error(`Node "${node.options.identifier}" encountered an error: ${error.message}`)) - .on('nodeDisconnect', node => logger.warn(`Node "${node.options.identifier}" disconnected.`)) - .on('nodeReconnect', node => logger.info(`Node "${node.options.identifier}" reconnecting.`)) - - .on('trackStart', (player, track) => { - logger.info(`Track started in guild ${player.guild}: ${track.title} requested by ${track.requester?.tag || 'Unknown'}`); - - // Find the text channel associated with the player (if stored) - const channel = client.channels.cache.get(player.textChannel); - if (channel) { - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle('Now Playing') - .setDescription(`[${track.title}](${track.uri})`) - .addFields({ name: 'Requested by', value: `${track.requester?.tag || 'Unknown'}`, inline: true }) - .setTimestamp(); - if (track.thumbnail) { - embed.setThumbnail(track.thumbnail); - } - - channel.send({ embeds: [embed] }).catch(e => logger.error(`Failed to send trackStart message: ${e.message}`)); - } - }) - - .on('trackEnd', (player, track, payload) => { - // Only log track end if it wasn't replaced (e.g., by skip or play next) - // 'REPLACED' means another track started immediately after this one. - if (payload && payload.reason !== 'REPLACED') { - logger.info(`Track ended in guild ${player.guild}: ${track.title}. Reason: ${payload.reason}`); - } else if (!payload) { - logger.info(`Track ended in guild ${player.guild}: ${track.title}. Reason: Unknown/Finished`); - } - // Optional: Send a message when a track ends naturally - // const channel = client.channels.cache.get(player.textChannel); - // if (channel && payload && payload.reason === 'FINISHED') { - // channel.send(`Finished playing: ${track.title}`); - // } - }) - - .on('trackError', (player, track, payload) => { - logger.error(`Track error in guild ${player.guild} for track ${track?.title || 'Unknown'}: ${payload.error}`); - const channel = client.channels.cache.get(player.textChannel); - if (channel) { - channel.send(`An error occurred while trying to play: ${track?.title || 'the track'}. Details: ${payload.exception?.message || 'Unknown error'}`).catch(e => logger.error(`Failed to send trackError message: ${e.message}`)); - } - // Optionally destroy player or skip track on error - // player.stop(); - }) - - .on('trackStuck', (player, track, payload) => { - logger.warn(`Track stuck in guild ${player.guild} for track ${track?.title || 'Unknown'}. Threshold: ${payload.thresholdMs}ms`); - const channel = client.channels.cache.get(player.textChannel); - if (channel) { - channel.send(`Track ${track?.title || 'the track'} seems stuck. Skipping...`).catch(e => logger.error(`Failed to send trackStuck message: ${e.message}`)); - } - // Skip the track - player.stop(); - }) - - .on('queueEnd', (player) => { - logger.info(`Queue ended for guild ${player.guild}.`); - const channel = client.channels.cache.get(player.textChannel); - if (channel) { - channel.send('Queue finished. Add more songs!').catch(e => logger.error(`Failed to send queueEnd message: ${e.message}`)); - } - // Optional: Add a timeout before leaving the channel - // setTimeout(() => { - // if (player.queue.current) return; // Don't leave if something started playing again - // player.destroy(); - // }, 180000); // 3 minutes - player.destroy(); // Destroy player immediately when queue ends - }) - - .on('playerCreate', player => logger.debug(`Player created for guild ${player.guild}`)) - .on('playerDestroy', player => logger.debug(`Player destroyed for guild ${player.guild}`)) - .on('playerMove', (player, oldChannel, newChannel) => { - if (!newChannel) { - logger.info(`Player for guild ${player.guild} disconnected (moved from channel ${oldChannel}). Destroying player.`); - player.destroy(); - } else { - logger.debug(`Player for guild ${player.guild} moved from channel ${oldChannel} to ${newChannel}`); - player.setVoiceChannel(newChannel); // Update player's voice channel reference - } - }); - - logger.info("Erela.js event listeners attached."); -}; diff --git a/src/structures/ShoukakuEvents.js b/src/structures/ShoukakuEvents.js deleted file mode 100644 index 4edeb5b..0000000 --- a/src/structures/ShoukakuEvents.js +++ /dev/null @@ -1,292 +0,0 @@ -const logger = require('../utils/logger'); -const { EmbedBuilder } = require('discord.js'); - -/** - * Manages player instances and track playback using Shoukaku - * @param {Client} client Discord.js client - */ -class MusicPlayer { - constructor(client) { - this.client = client; - this.players = new Map(); // Store active players - } - - /** - * Creates a player for a guild or returns existing one - * @param {Object} options Options for creating the player - * @param {string} options.guildId The guild ID - * @param {string} options.textChannel The text channel ID - * @param {string} options.voiceChannel The voice channel ID - * @returns {Object} The player object - */ - async createPlayer({ guildId, textChannel, voiceChannel }) { - // Check if player already exists - if (this.players.has(guildId)) { - return this.players.get(guildId); - } - - // Get Shoukaku instance and node - const shoukaku = this.client.shoukaku; // Get the main shoukaku instance - const node = shoukaku.options.nodeResolver(shoukaku.nodes); - if (!node) { - throw new Error('No available Lavalink nodes!'); - } - - try { - // Create a new connection to the voice channel using the shoukaku instance - const connection = await shoukaku.joinVoiceChannel({ - guildId: guildId, - channelId: voiceChannel, - shardId: 0, // Assuming shardId 0, adjust if sharding - deaf: true - }); - - // Create a player object to track state and add methods - const player = { - guild: guildId, - textChannel: textChannel, - voiceChannel: voiceChannel, - connection: connection, - queue: [], - current: null, - playing: false, - volume: 100, - - // Play a track - async play(track) { - this.current = track; - logger.debug(`Attempting to play track: ${track.info.title} (${track.info.uri}) in guild ${this.guild}`); - logger.debug(`Track encoded data: ${track.encoded}`); // Log encoded data - try { - // Start playback - Ensure payload matches { track: { encoded: "..." } } - await this.connection.playTrack({ track: { encoded: track.encoded } }); - this.playing = true; - logger.debug(`playTrack called successfully for: ${track.info.title}`); - } catch (playError) { - logger.error(`Error calling playTrack for ${track.info.title}: ${playError.message}`); - console.error(playError); // Log full error object - this.playing = false; - this.current = null; - // Maybe try skipping? Or just log and let the 'end' event handle it if it fires. - } - return this; - }, - - // Stop the current track - stop() { - this.connection.stopTrack(); - return this; - }, - - // Skip to the next track - skip() { - this.stop(); - if (this.queue.length > 0) { - const nextTrack = this.queue.shift(); - this.play(nextTrack); - } else { - this.current = null; - this.playing = false; - } - return this; - }, - - // Set player volume - setVolume(volume) { - this.volume = volume; - this.connection.setGlobalVolume(volume); - return this; - }, - - // Pause playback - pause() { - this.connection.setPaused(true); - return this; - }, - - // Resume playback - resume() { - this.connection.setPaused(false); - return this; - }, - - shoukaku: shoukaku, // Store shoukaku instance on the player object - - // Destroy the player and disconnect - destroy() { - // Use the stored Shoukaku instance to leave the channel - this.shoukaku.leaveVoiceChannel(this.guild); - // Remove the player instance from the manager's map - musicPlayer.players.delete(this.guild); - logger.debug(`Destroyed player for guild ${this.guild}`); - return this; // Return this for potential chaining, though unlikely needed here - }, - - // Add a track to the queue or play it if nothing is playing - async enqueue(track, immediate = false) { - if (immediate || (!this.playing && !this.current)) { - logger.debug(`Enqueue: Playing immediately - ${track.info.title}`); - await this.play(track); - } else { - logger.debug(`Enqueue: Adding to queue - ${track.info.title}`); - this.queue.push(track); - } - return this; - } - }; - - // Set up event listeners for this player - connection.on('start', () => { - logger.info(`Track started in guild ${player.guild}: ${player.current?.info?.title || 'Unknown'}`); - - // Send now playing message - if (player.current) { - const channel = this.client.channels.cache.get(player.textChannel); - if (channel) { - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle('Now Playing') - .setDescription(`[${player.current.info.title}](${player.current.info.uri})`) - .addFields({ name: 'Requested by', value: `${player.current.requester?.tag || 'Unknown'}`, inline: true }) - .setTimestamp(); - - if (player.current.info.thumbnail) { - embed.setThumbnail(player.current.info.thumbnail); - } - - channel.send({ embeds: [embed] }).catch(e => - logger.error(`Failed to send trackStart message: ${e.message}`) - ); - } - } - }); - - connection.on('end', () => { - logger.info(`Track ended in guild ${player.guild}: ${player.current?.info?.title || 'Unknown'}`); - player.playing = false; - player.current = null; - - // Play next track in queue if available - if (player.queue.length > 0) { - const nextTrack = player.queue.shift(); - player.play(nextTrack); - } else { - // Send queue end message - const channel = this.client.channels.cache.get(player.textChannel); - if (channel) { - channel.send('Queue finished. Add more songs!').catch(e => - logger.error(`Failed to send queueEnd message: ${e.message}`) - ); - } - // Optional: Add timeout before disconnecting - // setTimeout(() => { - // if (!player.playing) player.destroy(); - // }, 300000); // 5 minutes - player.destroy(); - } - }); - - connection.on('exception', (error) => { - logger.error(`Track exception in guild ${player.guild}: ${error.message || 'Unknown error'}`); - console.error("Full track exception details:", error); // Log the full error object - const channel = this.client.channels.cache.get(player.textChannel); - if (channel) { - channel.send(`An error occurred during playback: ${error.message || 'Unknown error'}`).catch(e => - logger.error(`Failed to send trackException message: ${e.message}`) - ); - } - // Attempt to skip to the next track on exception - player.skip(); - }); - - // Store the player and return it - this.players.set(guildId, player); - return player; - } catch (error) { - logger.error(`Failed to create player for guild ${guildId}: ${error.message}`); - throw error; - } - } - - /** - * Get an existing player - * @param {string} guildId The guild ID - * @returns {Object|null} The player object or null - */ - getPlayer(guildId) { - return this.players.get(guildId) || null; - } - - /** - * Search for tracks using Shoukaku - * @param {Object} options Options for the search - * @param {string} options.identifier The pre-constructed search identifier (e.g., 'ytsearch:query', 'scsearch:query', or a URL) - * @param {string} options.requester The user who requested the track - * @returns {Promise} Array of track objects - */ - async search({ identifier, requester }) { // Accept identifier directly - // Get the first available node - const node = this.client.shoukaku.options.nodeResolver(this.client.shoukaku.nodes); - if (!node) throw new Error('No available Lavalink nodes!'); - - try { - // Perform the search using the provided identifier string - logger.debug(`Performing search with identifier: ${identifier}`); - const result = await node.rest.resolve(identifier); - if (!result || result.loadType === 'error' || result.loadType === 'empty') { - // Log the identifier for debugging if search fails - logger.debug(`Search failed for identifier: ${identifier}`); - throw new Error(result?.exception?.message || 'No results found'); - } - - // Process results - let tracks = []; - if (result.loadType === 'playlist') { - // Playlist processing - tracks = result.data.tracks.map(track => ({ - encoded: track.encoded, // Correct property name - info: track.info, - requester: requester - })); - } else if (result.loadType === 'track') { - // Single track - const track = result.data; - tracks = [{ - encoded: track.encoded, // Correct property name - info: track.info, - requester: requester - }]; - } else if (result.loadType === 'search') { - // Search results - tracks = result.data.slice(0, 10).map(track => ({ - encoded: track.encoded, // Correct property name - info: track.info, - requester: requester - })); - } - - return tracks; - } catch (error) { - logger.error(`Search error: ${error.message}`); - throw error; - } - } -} - -// Create and export the player manager -const musicPlayer = new MusicPlayer(null); -module.exports = { - setupPlayer: (client) => { - if (!client || !client.shoukaku) { - logger.error("ShoukakuEvents requires a client with an initialized shoukaku instance."); - return; - } - - // Initialize the player with the client - musicPlayer.client = client; - - logger.info("Shoukaku music player initialized and ready."); - return musicPlayer; - }, - musicPlayer -}; diff --git a/src/structures/ShoukakuEvents.ts b/src/structures/ShoukakuEvents.ts new file mode 100644 index 0000000..36a0be5 --- /dev/null +++ b/src/structures/ShoukakuEvents.ts @@ -0,0 +1,67 @@ +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'; +// Removed imports from play.ts for now as player listeners are removed + +// 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, + }, +]; + +// 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 +}; + +// Function to initialize Shoukaku and attach listeners +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}`); + } + }); + + // --- 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; +} diff --git a/src/utils/logger.js b/src/utils/logger.js deleted file mode 100644 index 377df77..0000000 --- a/src/utils/logger.js +++ /dev/null @@ -1,17 +0,0 @@ -const winston = require('winston'); - -const logger = winston.createLogger({ - level: process.env.LOG_LEVEL || 'info', // Use LOG_LEVEL from env or default to 'info' - format: winston.format.combine( - winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - winston.format.printf(info => `${info.timestamp} ${info.level.toUpperCase()}: ${info.message}`) - ), - transports: [ - new winston.transports.Console(), - // Optionally add file transport - // new winston.transports.File({ filename: 'combined.log' }), - // new winston.transports.File({ filename: 'error.log', level: 'error' }), - ], -}); - -module.exports = logger; diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..65dc5a1 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,31 @@ +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 +// We can simplify this for now or try to infer from winston later +// type TimestampedLogInfo = TransformableInfo & { +// timestamp: string; +// }; + +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' }), + ], +}); + +export default logger; // Use ES6 export default diff --git a/tests/deploy-commands.test.js b/tests/deploy-commands.test.ts similarity index 91% rename from tests/deploy-commands.test.js rename to tests/deploy-commands.test.ts index 448e364..f0d23ca 100644 --- a/tests/deploy-commands.test.js +++ b/tests/deploy-commands.test.ts @@ -20,13 +20,13 @@ jest.mock('node:path', () => { const actual = jest.requireActual('node:path'); return { ...actual, - join: (...args) => args.join('/'), - resolve: (...args) => args.join('/'), + join: (...args: string[]) => args.join('/'), + resolve: (...args: string[]) => args.join('/'), }; }); describe('deploy-commands.js', () => { - let origEnv; + let origEnv: typeof process.env; beforeAll(() => { origEnv = { ...process.env }; process.env.CLIENT_ID = '12345'; diff --git a/tests/start-script.test.js b/tests/start-script.test.ts similarity index 100% rename from tests/start-script.test.js rename to tests/start-script.test.ts diff --git a/tests/startup.test.js b/tests/startup.test.ts similarity index 100% rename from tests/startup.test.js rename to tests/startup.test.ts diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6330e43 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + /* Base Options: */ + "esModuleInterop": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": 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 + } + }, + "include": ["src/**/*.ts", "deploy-commands.ts", "tests/**/*.ts"], // Include source, deploy script, and tests + "exclude": ["node_modules", "dist"] // Exclude build output and dependencies +}