This guide will walk you through the process of creating a simple yet functional music bot using Moonlink.js and Discord.js. By the end, you'll have a bot that can play music, manage queues, and respond to basic commands.
Now that you have set up Moonlink.js and configured it with your Discord bot, it's time to create a fully functional music bot. In this guide, we'll build a bot with the following capabilities:
Connect to a voice channel when requested
Play music from YouTube, SoundCloud, and other supported sources
A well-organized project structure makes your code easier to maintain and understand. Let's organize our music bot project with the following structure:
music-bot/
├── commands/ # Directory for all command files
│ ├── play.js # Command to play music
│ ├── skip.js # Command to skip the current track
│ ├── stop.js # Command to stop playback
│ ├── pause.js # Command to pause playback
│ ├── resume.js # Command to resume playback
│ └── queue.js # Command to display the current queue
├── events/ # Directory for event handler files
│ ├── ready.js # Handles the bot's ready event
│ ├── interactionCreate.js # Handles slash command interactions
│ └── messageCreate.js # Handles message commands
├── config.json # Configuration file for bot settings
└── index.js # Main entry point for the bot
This structure separates commands and events into their own directories, making it easier to add, modify, or remove functionality as needed.
First, let's create the main file (index.js) that will serve as the entry point for our bot. This file will:
Initialize the Discord client
Set up the Moonlink.js manager
Load commands and event handlers
Connect to Discord
Here's a step-by-step breakdown of the code:
// index.js
const { Client, GatewayIntentBits, Collection } = require('discord.js');
const { Manager } = require('moonlink.js');
const fs = require('fs');
const path = require('path');
const config = require('./config.json');
// Create a new Discord client with the necessary intents
// Intents determine what events your bot can receive
const client = new Client({
intents: [
GatewayIntentBits.Guilds, // Needed for guild-related events
GatewayIntentBits.GuildVoiceStates, // Required for voice functionality
GatewayIntentBits.GuildMessages, // Needed to receive messages
GatewayIntentBits.MessageContent, // Required to read message content
],
});
// Create a new Moonlink Manager instance
// This is the main interface for interacting with Lavalink
client.manager = new Manager({
// Configure the Lavalink nodes to connect to
nodes: [
{
host: config.lavalink.host, // The hostname of your Lavalink server
port: config.lavalink.port, // The port your Lavalink server is running on
password: config.lavalink.password, // The password for your Lavalink server
secure: config.lavalink.secure, // Whether to use SSL/TLS for the connection
},
],
// This function sends voice state updates to Discord
// It's required for the bot to join voice channels
sendPayload: (guildId, payload) => {
const guild = client.guilds.cache.get(guildId);
if (guild) guild.shard.send(JSON.parse(payload));
},
autoPlay: true, // Automatically play the next song in the queue
});
// Set up a collection to store commands
client.commands = new Collection();
// Load command files from the commands directory
const commandFiles = fs.readdirSync('./commands').filter(file => file.endsWith('.js'));
// Register each command in the collection
for (const file of commandFiles) {
const command = require(`./commands/${file}`);
client.commands.set(command.name, command);
}
// Load event handler files from the events directory
const eventFiles = fs.readdirSync('./events').filter(file => file.endsWith('.js'));
// Register each event handler
for (const file of eventFiles) {
const event = require(`./events/${file}`);
if (event.once) {
// For events that should only trigger once
client.once(event.name, (...args) => event.execute(...args, client));
} else {
// For events that can trigger multiple times
client.on(event.name, (...args) => event.execute(...args, client));
}
}
// Handle raw events for voice state updates
// This is crucial for Moonlink.js to work properly
client.on('raw', (packet) => {
client.manager.packetUpdate(packet);
});
// Login to Discord with your bot token
client.login(config.token);
The ready event fires when your bot successfully connects to Discord. This is where we'll initialize the Moonlink.js manager:
// events/ready.js
module.exports = {
name: 'ready',
once: true,
execute(client) {
console.log(`Logged in as ${client.user.tag}`);
// Initialize the Moonlink Manager with the bot's user ID
// This is required for the manager to function correctly
client.manager.init(client.user.id);
console.log('Moonlink Manager initialized');
},
};
Moonlink.js emits various events that we need to handle for our music bot to function properly. Add these event listeners to your index.js file:
// Add this to your index.js file after creating the manager
// Node connection events
client.manager.on('nodeConnect', (node) => {
console.log(`Node ${node.identifier} connected`);
});
client.manager.on('nodeDisconnect', (node) => {
console.log(`Node ${node.identifier} disconnected`);
});
client.manager.on('nodeError', (node, error) => {
console.error(`Node ${node.identifier} encountered an error:`, error);
});
// Playback events
client.manager.on('trackStart', (player, track) => {
// Send a message when a track starts playing
const channel = client.channels.cache.get(player.textChannelId);
if (channel) {
channel.send(`Now playing: **${track.title}**`);
}
});
client.manager.on('trackEnd', (player, track) => {
console.log(`Track ended: ${track.title}`);
});
client.manager.on('queueEnd', (player) => {
// Send a message when the queue ends
const channel = client.channels.cache.get(player.textChannelId);
if (channel) {
channel.send('Queue ended. Disconnecting in 30 seconds if no new tracks are added.');
}
// Disconnect after a delay if no new tracks are added
// This helps save resources when the bot is not in use
setTimeout(() => {
if (!player.playing && player.queue.size === 0) {
player.destroy();
if (channel) {
channel.send('Disconnected due to inactivity.');
}
}
}, 30000); // 30 seconds
});
The play command is the most important command for a music bot. It allows users to search for and play music:
// commands/play.js
module.exports = {
name: 'play',
description: 'Play a song',
async execute(message, args, client) {
// Step 1: Check if the user is in a voice channel.
// This is important to ensure the bot knows where to join.
const { channel } = message.member.voice;
if (!channel) {
return message.reply('You need to join a voice channel first!');
}
// Step 2: Check if a search query is provided.
// The bot needs to know what song to search for.
if (!args.length) {
return message.reply('Please provide a song to play!');
}
// Step 3: Create a player for the guild.
// If a player already exists, it will be retrieved. Otherwise, a new one is created.
// This player instance handles everything related to music in a specific guild.
const player = client.manager.players.create({
guildId: message.guild.id, // The ID of the server (guild).
voiceChannelId: channel.id, // The ID of the voice channel to connect to.
textChannelId: message.channel.id, // The ID of the channel where commands are sent.
autoPlay: true, // Enables autoplay of the next song.
});
// Step 4: Connect to the voice channel.
// This establishes the connection so the bot can play audio.
player.connect();
// Step 5: Search for the requested track.
// Moonlink.js will search on the default platform (usually YouTube).
const query = args.join(' ');
const searchResult = await client.manager.search({
query: query,
requester: message.author.id // We store the user who requested the song.
});
// Step 6: Handle the search results.
// First, check if any tracks were found.
if (!searchResult.tracks.length) {
return message.reply('No results found for your query.');
}
// Step 7: Process the results based on the load type.
// The loadType indicates whether a playlist, a single track, or a search result was returned.
switch (searchResult.loadType) {
case 'playlist':
// If a playlist is found, add all its tracks to the queue.
player.queue.add(searchResult.tracks);
message.reply({
content: `Added playlist **${searchResult.playlistInfo.name}** with ${searchResult.tracks.length} tracks to the queue.`,
});
// If the player is not already playing, start playback.
if (!player.playing) {
player.play();
}
break;
case 'search':
case 'track':
// If a single track or a search result is found, add the first track to the queue.
player.queue.add(searchResult.tracks[0]);
message.reply({
content: `Added **${searchResult.tracks[0].title}** to the queue.`,
});
// If the player is not already playing, start playback.
if (!player.playing) {
player.play();
}
break;
case 'empty':
// If no matches are found for the query.
message.reply('No matches found for your query!');
break;
case 'error':
// If an error occurred while loading the track.
message.reply(`An error occurred while loading the track: ${searchResult.error || 'Unknown error'}`);
break;
}
},
};
The skip command allows users to skip the currently playing track:
// commands/skip.js
module.exports = {
name: 'skip',
description: 'Skip the current song',
execute(message, args, client) {
// Step 1: Get the player for the current guild.
// Each guild has its own unique player instance.
const player = client.manager.players.get(message.guild.id);
// Step 2: Check if a player exists for this guild.
// We can't skip if there's no active player.
if (!player) {
return message.reply('There is nothing playing in this server!');
}
// Step 3: Check if the user is in the same voice channel as the bot.
// This prevents users in other channels from controlling the music.
if (message.member.voice.channel?.id !== player.voiceChannelId) {
return message.reply('You need to be in the same voice channel as the bot to use this command!');
}
// Step 4: Check if there is a track currently playing.
if (!player.current) {
return message.reply('There is nothing playing right now!');
}
// Step 5: Skip the current track.
// We store the current track to mention it in the confirmation message.
const currentTrack = player.current;
player.skip();
// Step 6: Inform the user that the track has been skipped.
message.reply(`Skipped: **${currentTrack.title}**`);
},
};
The stop command stops playback and clears the queue:
// commands/stop.js
module.exports = {
name: 'stop',
description: 'Stop playback and clear the queue',
execute(message, args, client) {
// Step 1: Get the player for the guild.
const player = client.manager.players.get(message.guild.id);
// Step 2: Check if a player exists.
if (!player) {
return message.reply('There is nothing playing in this server!');
}
// Step 3: Check if the user is in the same voice channel.
if (message.member.voice.channel?.id !== player.voiceChannelId) {
return message.reply('You need to be in the same voice channel as the bot to use this command!');
}
// Step 4: Stop playback and clear the queue.
player.stop(); // Stops the current track.
player.queue.clear(); // Clears all tracks from the queue.
// Step 5: Inform the user.
message.reply('Stopped playback and cleared the queue.');
},
};
The pause command pauses the currently playing track:
// commands/pause.js
module.exports = {
name: 'pause',
description: 'Pause the current song',
execute(message, args, client) {
// Step 1: Get the player for the guild.
const player = client.manager.players.get(message.guild.id);
// Step 2: Check if a player exists.
if (!player) {
return message.reply('There is nothing playing in this server!');
}
// Step 3: Check if the user is in the same voice channel.
if (message.member.voice.channel?.id !== player.voiceChannelId) {
return message.reply('You need to be in the same voice channel as the bot to use this command!');
}
// Step 4: Check if the player is already paused.
if (player.paused) {
return message.reply('The player is already paused!');
}
// Step 5: Pause the player.
player.pause();
// Step 6: Inform the user.
message.reply('Paused the player.');
},
};
The resume command resumes playback if it's paused:
// commands/resume.js
module.exports = {
name: 'resume',
description: 'Resume playback if paused',
execute(message, args, client) {
// Step 1: Get the player for the guild.
const player = client.manager.players.get(message.guild.id);
// Step 2: Check if a player exists.
if (!player) {
return message.reply('There is nothing playing in this server!');
}
// Step 3: Check if the user is in the same voice channel.
if (message.member.voice.channel?.id !== player.voiceChannelId) {
return message.reply('You need to be in the same voice channel as the bot to use this command!');
}
// Step 4: Check if the player is paused.
if (!player.paused) {
return message.reply('The player is not paused!');
}
// Step 5: Resume the player.
player.resume();
// Step 6: Inform the user.
message.reply('Resumed playback.');
},
};
The queue command displays the current queue of songs:
// commands/queue.js
const { EmbedBuilder } = require('discord.js');
module.exports = {
name: 'queue',
description: 'Show the current queue',
execute(message, args, client) {
// Step 1: Get the player for this guild
const player = client.manager.players.get(message.guild.id);
// Step 2: Check if there is a player
if (!player) {
return message.reply('There is nothing playing in this server!');
}
// Step 3: Check if there are tracks in the queue
if (!player.current && player.queue.size === 0) {
return message.reply('There are no tracks in the queue!');
}
// Step 4: Format duration for display
// This helper function converts milliseconds to a readable format
const formatDuration = (ms) => {
const seconds = Math.floor((ms / 1000) % 60);
const minutes = Math.floor((ms / (1000 * 60)) % 60);
const hours = Math.floor(ms / (1000 * 60 * 60));
return `${hours ? `${hours}:` : ''}${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
// Step 5: Create an embed for the queue
// Embeds provide a nice, formatted way to display information
const embed = new EmbedBuilder()
.setTitle('Current Queue')
.setColor('#0099ff');
// Step 6: Add the current track to the embed
if (player.current) {
embed.setDescription(`**Now Playing:**\n[${player.current.title}](${player.current.url}) | \`${formatDuration(player.current.duration)}\``);
}
// Step 7: Add the queue tracks to the embed
if (player.queue.size > 0) {
const tracks = player.queue.tracks.map((track, index) => {
return `${index + 1}. [${track.title}](${track.url}) | \`${formatDuration(track.duration)}\``;
});
embed.addFields({
name: 'Up Next:',
value: tracks.slice(0, 10).join('\n'),
});
// If there are more than 10 tracks, add a note
if (player.queue.size > 10) {
embed.addFields({
name: 'And more...',
value: `${player.queue.size - 10} more tracks in the queue`,
});
}
}
// Step 8: Send the embed to the channel
message.reply({ embeds: [embed] });
},
};
If you encounter issues with your bot, here are some common problems and solutions:
Bot doesn't join voice channels: Make sure you've set up the sendPayload function correctly and are handling raw events.
No sound is playing: Check that Lavalink is running and configured correctly.
Error connecting to Lavalink: Verify your Lavalink host, port, and password in the config file.
Commands not working: Ensure your command files are in the correct directory and properly exported.
Remember that building a music bot is a complex task, and it's normal to encounter issues along the way. Don't hesitate to check the Moonlink.js documentation or ask for help in the Discord community if you get stuck.