Compare commits

...

38 Commits

Author SHA1 Message Date
aki
99a1417c43 debug :> 2025-04-24 03:33:44 +08:00
aki
6546cb8d63 if wsl wasn't so broken, i didn't need to commit this much :)
fix(ShoukakuEvents.js): ensure playback track payload structure matches expected format
2025-04-24 03:15:04 +08:00
aki
b958e79a98 fix(ShoukakuEvents.js): correct property name for track encoding in search results
hopefully the final fix :)
2025-04-24 03:12:01 +08:00
aki
81c65a3644 chore(ShoukakuEvents.js): add logging for track encoded data during playback 2025-04-24 03:07:08 +08:00
aki
68a3f4fb58 chore: update .env.example and logger.js for improved configuration and logging level management 2025-04-24 03:03:05 +08:00
aki
5c5574c06e >,> 2025-04-24 02:55:39 +08:00
aki
9e02e50693 fix(ShoukakuEvents.js): improve error handling during track playback and logging 2025-04-24 02:50:17 +08:00
aki
9d7ff5e7e7 feat(play.js): add source option for track search and enhance search logic 2025-04-24 02:50:11 +08:00
aki
3ba230e6e9 chore: update Dockerfile to use Node.js 23-slim and adjust application.yml for plugin settings 2025-04-24 02:49:36 +08:00
aki
253f369a89 refactor(application.yml): streamline Lavalink configuration and enhance plugin management 2025-04-24 02:23:15 +08:00
aki
7500ea01c8 untest the testing earlier 2025-04-24 02:06:06 +08:00
aki
f31bba40fb add youtube plugin 2025-04-24 02:01:39 +08:00
aki
4d5c301c46 <,< 2025-04-24 01:59:13 +08:00
aki
8f8ff6aa81 fix(lavalink): Update Lavalink configuration and enhance healthcheck with plugin management 2025-04-24 01:50:06 +08:00
aki
ca9e531541 pause fix again hope -o- 2025-04-24 01:40:14 +08:00
aki
f1991f7716 testing 2025-04-24 01:33:42 +08:00
aki
ce635cb32b sleepy 2025-04-24 01:29:28 +08:00
aki
30b5b23868 fix(docker-compose): Update healthcheck for Lavalink service to use correct endpoint and reduce interval 2025-04-24 01:27:17 +08:00
aki
74cac2bfbb fix(lavalink): Refactor search method to use identifier string for track resolution 2025-04-24 01:22:19 +08:00
aki
bb7a796cf9 fix(docker-compose): Add healthcheck for Lavalink service and update depends_on condition 2025-04-24 01:22:09 +08:00
aki
a54becb3a0 fix(lavalink): Update leave for Shoukaku integration 2025-04-24 01:21:44 +08:00
aki
0d0125bf55 fix(lavalink): Update join, play, and voice state handling for Shoukaku integration 2025-04-24 01:01:33 +08:00
aki
854cf12d64 fix(lavalink): Correct Lavalink connection URL format and update deprecated code 2025-04-24 00:52:16 +08:00
aki
e54c23cc63 feat(lavalink): Migrate from Erela.js to Shoukaku for music playback management 2025-04-24 00:25:02 +08:00
aki
5a29fe3d9d chore(lavalink): another application.yml fix cuz i'm sleepy 2025-04-24 00:19:05 +08:00
aki
42de01e004 chore(lavalink): Add YouTube plugin in application.yml to fix deprecation error 2025-04-24 00:14:42 +08:00
aki
537a8c6709 fix(docker): Update environment variables and improve connection handling for Lavalink 2025-04-24 00:05:57 +08:00
Aki Amane
0b86b5d891 fix(docker): Fix Dockerfile errors 2025-04-23 23:55:29 +08:00
aki
d4de2feaaa chore(docker): Add application.yml for lavaplayer 2025-04-23 23:28:48 +08:00
aki
95ea55d972 chore(docker): switch to pnpm, update Dockerfile, docker-compose, and README 2025-04-23 23:27:29 +08:00
aki
170faf7d01 fix(package): format scripts section and add js-yaml to devDependencies 2025-04-23 22:09:35 +08:00
aki
f50c88515e test(scripts): add npm start script test 2025-04-23 22:09:00 +08:00
aki
57d10ddf70 test(deploy): add tests for deploy-commands script 2025-04-23 22:05:13 +08:00
aki
6daf1993d1 build(docker): add Dockerfile, Docker Compose config, and update README 2025-04-23 21:55:40 +08:00
aki
47de3823f3 test(startup): add startup test for missing DISCORD_TOKEN 2025-04-23 21:54:52 +08:00
aki
74dfdbf667 feat(bot): add NodeJS implementation and deploy script 2025-04-23 21:40:02 +08:00
aki
5c632556b7 chore(rust): remove Rust source files and Cargo configuration 2025-04-23 21:39:06 +08:00
aki
05fec6747d Initial project setup with basic structure, including environment configuration, command handling, and Lavalink integration. 2025-04-20 09:02:34 +08:00
23 changed files with 1408 additions and 19 deletions

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# Discord Bot Token
DISCORD_TOKEN=your_token_here
# Discord Application Client ID (for command deployment)
CLIENT_ID=your_client_id_here
# Discord Guild ID (optional, for deploying commands to a specific test server)
# GUILD_ID=your_guild_id_here
# Lavalink Configuration
# Use 'lavalink' if running via docker-compose, '127.0.0.1' or 'localhost' if running Lavalink directly
LAVALINK_HOST=lavalink
LAVALINK_PORT=2333
LAVALINK_PASSWORD=your_password_here
# Logging Level (e.g., debug, info, warn, error)
LOG_LEVEL=info

34
.gitignore vendored
View File

@@ -1,22 +1,20 @@
# ---> Rust # Node modules
# Generated by Cargo node_modules/
# will have compiled files and executables dist/
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # dotenv environment variables
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html .env
Cargo.lock
# These are backup files generated by rustfmt # VSCode settings
**/*.rs.bk .vscode/
# MSVC Windows builds of rustc generate these, which store debugging information # Mac system files
*.pdb .DS_Store
# RustRover # Lockfiles
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can pnpm-lock.yaml
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear # Logs
# option (not recommended) you can uncomment the following to ignore the entire idea folder. npm-debug.log*
#.idea/ logs/
*.log

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
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"]

View File

@@ -1,3 +1,69 @@
# discord-music-bot # discord-music-bot
Discord bot made in Rust using serenity and lavalink-rs Discord music bot template written in NodeJS using `discord.js` and `erela.js`, with Lavalink support.
## Features
- Slash commands: `/ping`, `/join`, `/play`, `/leave`
- Lavalink integration for audio playback
- Modular command handler structure
## Prerequisites
- Node.js (>=14)
- pnpm or npm
- A Discord application with bot token
- LavaLink server for audio streaming
## Setup
1. Copy `.env.example` to `.env` and fill in your credentials:
```env
DISCORD_TOKEN=your_discord_bot_token
CLIENT_ID=your_discord_application_id
LAVALINK_HOST=127.0.0.1
LAVALINK_PORT=2333
LAVALINK_PASSWORD=your_lavalink_password
```
2. Install dependencies:
```sh
pnpm install
```
3. Run tests:
```sh
pnpm test
```
4. Register slash commands:
```sh
pnpm start # or node deploy-commands.js
```
5. Start the bot:
```sh
pnpm start
```
## Docker
A `Dockerfile` and `docker-compose.yml` are provided for containerized deployment.
- Build and run with Docker Compose:
```sh
docker-compose up --build
```
- Environment variables are loaded from `.env`.
- Lavalink service is configured in `docker-compose.yml` alongside the bot.
## Project Structure
- `src/index.js` — Entry point
- `src/commands/` — Slash command modules
- `src/events/` — Discord event handlers
- `src/structures/` — Erela.js (Lavalink) event wiring
- `src/utils/logger.js` — Logging setup
- `deploy-commands.js` — Slash command registration script
- `Dockerfile` — Bot container image
- `docker-compose.yml` — Multi-service setup (bot + Lavalink)
## License
MIT

110
application.yml Normal file
View File

@@ -0,0 +1,110 @@
server: # REST and WS server
port: 2333
address: 0.0.0.0
http2:
enabled: false # Whether to enable HTTP/2 support
# Root level plugin configuration block
plugins:
youtube:
enabled: true # Whether this source can be used.
allowSearch: true # Whether "ytsearch:" and "ytmsearch:" can be used.
allowDirectVideoIds: true # Whether just video IDs can match. If false, only complete URLs will be loaded.
allowDirectPlaylistIds: true # Whether just playlist IDs can match. If false, only complete URLs will be loaded.
# The clients to use for track loading. See below for a list of valid clients.
# Clients are queried in the order they are given (so the first client is queried first and so on...)
clients:
- MUSIC
- ANDROID_VR
- WEB
- WEBEMBEDDED
lavalink:
plugins:
# - dependency: "com.github.example:example-plugin:1.0.0" # required, the coordinates of your plugin
# repository: "https://maven.example.com/releases" # optional, defaults to the Lavalink releases repository by default
# snapshot: false # optional, defaults to false, used to tell Lavalink to use the snapshot repository instead of the release repository
pluginsDir: "/plugins" # Set directory for manually loaded plugins
# defaultPluginRepository: "https://maven.lavalink.dev/releases" # optional, defaults to the Lavalink release repository
# defaultPluginSnapshotRepository: "https://maven.lavalink.dev/snapshots" # optional, defaults to the Lavalink snapshot repository
server:
password: "${LAVALINK_PASSWORD}" # Use environment variable
sources:
# The default Youtube source is now deprecated and won't receive further updates. Please use https://github.com/lavalink-devs/youtube-source#plugin instead.
youtube: false
bandcamp: false
soundcloud: false
twitch: false
vimeo: false
nico: false
http: false # warning: keeping HTTP enabled without a proxy configured could expose your server's IP address.
local: false
filters: # All filters are enabled by default
volume: true
equalizer: true
karaoke: true
timescale: true
tremolo: true
vibrato: true
distortion: true
rotation: true
channelMix: true
lowPass: true
nonAllocatingFrameBuffer: false # Setting to true reduces the number of allocations made by each player at the expense of frame rebuilding (e.g. non-instantaneous volume changes)
bufferDurationMs: 400 # The duration of the NAS buffer. Higher values fare better against longer GC pauses. Duration <= 0 to disable JDA-NAS. Minimum of 40ms, lower values may introduce pauses.
frameBufferDurationMs: 5000 # How many milliseconds of audio to keep buffered
opusEncodingQuality: 10 # Opus encoder quality. Valid values range from 0 to 10, where 10 is best quality but is the most expensive on the CPU.
resamplingQuality: LOW # Quality of resampling operations. Valid values are LOW, MEDIUM and HIGH, where HIGH uses the most CPU.
trackStuckThresholdMs: 10000 # The threshold for how long a track can be stuck. A track is stuck if does not return any audio data.
useSeekGhosting: true # Seek ghosting is the effect where whilst a seek is in progress, the audio buffer is read from until empty, or until seek is ready.
youtubePlaylistLoadLimit: 6 # Number of pages at 100 each
playerUpdateInterval: 5 # How frequently to send player updates to clients, in seconds
youtubeSearchEnabled: true
soundcloudSearchEnabled: true
gc-warnings: true
#ratelimit:
#ipBlocks: ["1.0.0.0/8", "..."] # list of ip blocks
#excludedIps: ["...", "..."] # ips which should be explicit excluded from usage by lavalink
#strategy: "RotateOnBan" # RotateOnBan | LoadBalance | NanoSwitch | RotatingNanoSwitch
#searchTriggersFail: true # Whether a search 429 should trigger marking the ip as failing
#retryLimit: -1 # -1 = use default lavaplayer value | 0 = infinity | >0 = retry will happen this numbers times
#youtubeConfig: # Required for avoiding all age restrictions by YouTube, some restricted videos still can be played without.
#email: "" # Email of Google account
#password: "" # Password of Google account
#httpConfig: # Useful for blocking bad-actors from ip-grabbing your music node and attacking it, this way only the http proxy will be attacked
#proxyHost: "localhost" # Hostname of the proxy, (ip or domain)
#proxyPort: 3128 # Proxy port, 3128 is the default for squidProxy
#proxyUser: "" # Optional user for basic authentication fields, leave blank if you don't use basic auth
#proxyPassword: "" # Password for basic authentication
metrics:
prometheus:
enabled: false
endpoint: /metrics
sentry:
dsn: ""
environment: ""
# tags:
# some_key: some_value
# another_key: another_value
logging:
file:
path: ./logs/
level:
root: INFO
lavalink: INFO
dev.lavalink.youtube: INFO # Add debug logging for youtube plugin
request:
enabled: true
includeClientInfo: true
includeHeaders: false
includeQueryString: true
includePayload: true
maxPayloadLength: 10000
logback:
rollingpolicy:
max-file-size: 1GB
max-history: 30

82
deploy-commands.js Normal file
View File

@@ -0,0 +1,82 @@
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);
}
})();

46
docker-compose.yml Normal file
View File

@@ -0,0 +1,46 @@
services:
lavalink:
image: fredboat/lavalink:latest
container_name: lavalink
restart: unless-stopped
networks:
- bot-network
ports:
- "2333:2333"
environment:
- LAVALINK_SERVER_PASSWORD=${LAVALINK_PASSWORD}
# Removed LAVALINK_PLUGIN_URLS environment variable
volumes:
- ./application.yml:/opt/Lavalink/application.yml:ro,Z
# Mount local plugins directory into the container with SELinux label
- ./plugins:/plugins:ro,Z
# Add healthcheck to verify Lavalink is ready
healthcheck:
# Use CMD-SHELL to allow environment variable expansion for the password
test: ["CMD-SHELL", "curl -H \"Authorization: $$LAVALINK_SERVER_PASSWORD\" -f http://localhost:2333/version || exit 1"]
interval: 10s # Increased interval slightly
timeout: 5s
retries: 5
start_period: 15s # Give Lavalink time to start up initially
# Removed command override, will use default image entrypoint
bot:
build: .
container_name: discord-music-bot
restart: unless-stopped
networks:
- bot-network
env_file:
- .env
environment:
LAVALINK_HOST: lavalink
LAVALINK_PORT: 2333
LAVALINK_PASSWORD: ${LAVALINK_PASSWORD}
# Update depends_on to wait for healthcheck
depends_on:
lavalink:
condition: service_healthy
networks:
bot-network:
driver: bridge

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "discord-music-bot",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node src/index.js",
"test": "jest"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"discord.js": "^14.18.0",
"dotenv": "^16.5.0",
"shoukaku": "^4.1.1",
"winston": "^3.17.0"
},
"devDependencies": {
"jest": "^29.7.0",
"js-yaml": "^4.1.0"
}
}

Binary file not shown.

88
src/commands/join.js Normal file
View File

@@ -0,0 +1,88 @@
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}`);
}
}
},
};

48
src/commands/leave.js Normal file
View File

@@ -0,0 +1,48 @@
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}`));
}
},
};

15
src/commands/ping.js Normal file
View File

@@ -0,0 +1,15 @@
const { SlashCommandBuilder } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with Pong!'),
async execute(interaction) {
// Calculate latency (optional but common for ping commands)
const sent = await interaction.reply({ content: 'Pinging...', fetchReply: true, ephemeral: true });
const latency = sent.createdTimestamp - interaction.createdTimestamp;
const wsPing = interaction.client.ws.ping; // WebSocket heartbeat ping
await interaction.editReply(`Pong! 🏓\nRoundtrip latency: ${latency}ms\nWebSocket Ping: ${wsPing}ms`);
},
};

170
src/commands/play.js Normal file
View File

@@ -0,0 +1,170 @@
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}`);
},
};

View File

@@ -0,0 +1,41 @@
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}`);
}
}
},
};

23
src/events/ready.js Normal file
View File

@@ -0,0 +1,23 @@
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 });
},
};

View File

@@ -0,0 +1,52 @@
const { Events } = require('discord.js');
const logger = require('../utils/logger');
module.exports = {
name: Events.VoiceStateUpdate,
execute(oldState, newState, client) { // Added client parameter
// 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.');
return;
}
const player = musicPlayer.getPlayer(newState.guild.id);
if (!player) return; // No active player for this guild
// 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) {
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)) {
// 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.`);
// Optional: Add a timeout before destroying
// setTimeout(() => {
// const currentChannel = client.channels.cache.get(player.voiceChannel);
// const currentMembers = currentChannel?.members;
// if (currentMembers && currentMembers.size === 1 && currentMembers.has(client.user.id)) {
// logger.info(`Timeout finished: Destroying player in empty channel ${channel.name}.`);
// player.destroy();
// } else {
// logger.info(`Timeout finished: Channel ${channel.name} is no longer empty. Player not destroyed.`);
// }
// }, 60000); // e.g., 1 minute timeout
player.destroy(); // Destroy immediately for now
}
}
},
};

110
src/index.js Normal file
View File

@@ -0,0 +1,110 @@
// 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);
});

View File

@@ -0,0 +1,98 @@
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.");
};

View File

@@ -0,0 +1,292 @@
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
};

17
src/utils/logger.js Normal file
View File

@@ -0,0 +1,17 @@
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;

View File

@@ -0,0 +1,55 @@
jest.mock('discord.js', () => {
const original = jest.requireActual('discord.js');
const mockRest = {
put: jest.fn().mockResolvedValue([{ length: 1 }]),
setToken: jest.fn().mockReturnThis(),
};
return {
...original,
REST: jest.fn(() => mockRest),
Routes: {
applicationCommands: jest.fn().mockReturnValue('/fake-route'),
},
};
});
jest.mock('fs', () => ({
readdirSync: jest.fn(() => ['ping.js']),
}));
jest.mock('node:path', () => {
const actual = jest.requireActual('node:path');
return {
...actual,
join: (...args) => args.join('/'),
resolve: (...args) => args.join('/'),
};
});
describe('deploy-commands.js', () => {
let origEnv;
beforeAll(() => {
origEnv = { ...process.env };
process.env.CLIENT_ID = '12345';
process.env.DISCORD_TOKEN = 'token';
});
afterAll(() => {
process.env = origEnv;
jest.resetModules();
});
test('registers commands via REST API', async () => {
const mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() };
jest.mock('../src/utils/logger', () => mockLogger);
// Run the script
await require('../deploy-commands.js');
const { REST } = require('discord.js');
expect(REST).toHaveBeenCalled();
const restInstance = REST.mock.results[0].value;
expect(restInstance.setToken).toHaveBeenCalledWith('token');
expect(restInstance.put).toHaveBeenCalledWith('/fake-route', { body: expect.any(Array) });
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Started refreshing'));
});
});

View File

@@ -0,0 +1,10 @@
const { spawnSync } = require('child_process');
describe('NPM Start Script', () => {
test('npm start exits without error when DISCORD_TOKEN is provided', () => {
const env = { ...process.env, DISCORD_TOKEN: 'dummy-token', CLIENT_ID: '123', LAVALINK_HOST: 'localhost', LAVALINK_PORT: '2333', LAVALINK_PASSWORD: 'pass' };
const result = spawnSync('pnpm', ['start'], { env, encoding: 'utf-8' });
// The script starts the bot; if it reaches login attempt, exit code is 0
expect(result.status).toBe(0);
});
});

13
tests/startup.test.js Normal file
View File

@@ -0,0 +1,13 @@
const { spawnSync } = require('child_process');
describe('Bot Startup', () => {
test('exits with code 1 if DISCORD_TOKEN is missing', () => {
// Clear DISCORD_TOKEN
const env = { ...process.env };
delete env.DISCORD_TOKEN;
const result = spawnSync('node', ['src/index.js'], { env, encoding: 'utf-8' });
expect(result.status).toBe(1);
expect(result.stderr || result.stdout).toMatch(/DISCORD_TOKEN is missing/);
});
});