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
This commit is contained in:
parent
75185a59c3
commit
3c4dc51855
27
.eslintrc.json
Normal file
27
.eslintrc.json
Normal file
@ -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"]
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -18,3 +18,6 @@ pnpm-lock.yaml
|
||||
npm-debug.log*
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Data directory
|
||||
data/
|
||||
|
||||
21
.prettierignore
Normal file
21
.prettierignore
Normal file
@ -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
|
||||
9
.prettierrc.json
Normal file
9
.prettierrc.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
48
Dockerfile
48
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"]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
103
deploy-commands.ts
Normal file
103
deploy-commands.ts
Normal file
@ -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<APIApplicationCommand, 'id' | 'application_id' | 'version'>[] = []; // 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.
|
||||
21
package.json
21
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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
122
src/commands/join.ts
Normal file
122
src/commands/join.ts
Normal file
@ -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}`);
|
||||
// }
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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}`));
|
||||
}
|
||||
},
|
||||
};
|
||||
67
src/commands/leave.ts
Normal file
67
src/commands/leave.ts
Normal file
@ -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}`);
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
@ -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}`);
|
||||
},
|
||||
};
|
||||
347
src/commands/play.ts
Normal file
347
src/commands/play.ts
Normal file
@ -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(() => {});
|
||||
// }
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
64
src/events/interactionCreate.ts
Normal file
64
src/events/interactionCreate.ts
Normal file
@ -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<string, CommandType> 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}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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 });
|
||||
},
|
||||
};
|
||||
31
src/events/ready.ts
Normal file
31
src/events/ready.ts
Normal file
@ -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 });
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
110
src/index.js
110
src/index.js
@ -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);
|
||||
});
|
||||
186
src/index.ts
Normal file
186
src/index.ts
Normal file
@ -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<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">; // Or appropriate type for your command data
|
||||
execute: (interaction: BaseInteraction, client: BotClient) => Promise<void>; // 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<string, Command>;
|
||||
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<string, Command>();
|
||||
|
||||
// --- 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<any>) => {
|
||||
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);
|
||||
});
|
||||
@ -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.");
|
||||
};
|
||||
@ -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>} 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
|
||||
};
|
||||
67
src/structures/ShoukakuEvents.ts
Normal file
67
src/structures/ShoukakuEvents.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
31
src/utils/logger.ts
Normal file
31
src/utils/logger.ts
Normal file
@ -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
|
||||
@ -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';
|
||||
37
tsconfig.json
Normal file
37
tsconfig.json
Normal file
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user