Star ✨ on GitHub

Creating a Music Bot

Learn how to create a simple music bot with Moonlink.js

Introduction

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:

  1. Connect to a voice channel when requested
  2. Play music from YouTube, SoundCloud, and other supported sources
  3. Handle basic playback controls (play, pause, resume, skip, stop)
  4. Manage a queue of songs for continuous playback

This step-by-step guide will help you understand how each component works together to create a seamless music experience for your Discord server.

Prerequisites

Before we begin building our music bot, make sure you have:

  1. Installed Moonlink.js and its dependencies (as covered in the Installation guide)
  2. Set up Lavalink and have it running (as explained in the Lavalink guide)
  3. Created a Discord bot and added it to your server with the proper permissions
  4. Set up the basic configuration for Moonlink.js (as shown in the Basic Setup guide)

If you haven't completed these steps, please go back to the previous sections before continuing.

Project Structure

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.

Step 1: Setting Up the Main File

First, let's create the main file (index.js) that will serve as the entry point for our bot. This file will:

  1. Initialize the Discord client
  2. Set up the Moonlink.js manager
  3. Load commands and event handlers
  4. 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);

Configuration File

Create a config.json file to store your bot's configuration:

{
  "token": "YOUR_DISCORD_BOT_TOKEN",
  "prefix": "!",
  "lavalink": {
    "host": "localhost",
    "port": 2333,
    "password": "youshallnotpass",
    "secure": false
  }
}

Replace YOUR_DISCORD_BOT_TOKEN with your actual bot token from the Discord Developer Portal.

Step 2: Setting Up Event Handlers

Next, let's create the event handler files that will respond to various events from Discord and Moonlink.js.

Ready Event Handler

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');
  },
};

Message Create Event Handler

This event handler processes commands from text messages:

// events/messageCreate.js
module.exports = {
  name: 'messageCreate',
  execute(message, client) {
    // Ignore messages from bots or messages without the prefix
    const prefix = client.config?.prefix || '!';
    if (message.author.bot || !message.content.startsWith(prefix)) return;
    
    // Extract the command name and arguments
    const args = message.content.slice(prefix.length).trim().split(/ +/);
    const commandName = args.shift().toLowerCase();
    
    // Check if the command exists
    const command = client.commands.get(commandName);
    if (!command) return;
    
    // Execute the command
    try {
      command.execute(message, args, client);
    } catch (error) {
      console.error(error);
      message.reply('There was an error executing that command.');
    }
  },
};

Interaction Create Event Handler

This handler processes slash commands (we'll implement these later):

// events/interactionCreate.js
module.exports = {
  name: 'interactionCreate',
  execute(interaction, client) {
    if (!interaction.isCommand()) return;
    
    const command = client.commands.get(interaction.commandName);
    if (!command) return;
    
    try {
      command.execute(interaction, client);
    } catch (error) {
      console.error(error);
      interaction.reply({
        content: 'There was an error executing this command!',
        ephemeral: true,
      });
    }
  },
};

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
});

Step 4: Creating Music Commands

Now, let's create the basic music commands for our bot. Each command will be in its own file in the commands directory.

Play Command

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;
    }
  },
};

Skip Command

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}**`);
  },
};

Stop Command

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.');
  },
};

Pause Command

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.');
  },
};

Resume Command

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.');
  },
};

Queue Command

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] });
   },
};

Step 5: Running the Bot

Now that we've created all the necessary files, it's time to run our bot. Follow these steps:

  1. Make sure Lavalink is running
    java -jar Lavalink.jar
    
  2. Start your bot:
    node index.js
    

You should see output indicating that your bot has logged in and the Moonlink Manager has been initialized.

Step 6: Testing the Bot

Once your bot is running, you can test it with the following commands:

  • !play <song name or URL> - Play a song or add it to the queue
  • !skip - Skip the current song
  • !stop - Stop playback and clear the queue
  • !pause - Pause the current song
  • !resume - Resume playback
  • !queue - Show the current queue

Try each command to ensure your bot is working correctly. Here are some examples:

  • !play never gonna give you up - Plays Rick Astley's classic
  • !play https://www.youtube.com/watch?v=dQw4w9WgXcQ - Plays the same song using a direct URL
  • !play https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M - Plays a Spotify playlist (if you have a Spotify plugin)

Next Steps

Congratulations! You've created a basic music bot with Moonlink.js. From here, you can expand its functionality by:

  1. Adding more commands like volume control, seeking within tracks, and audio filters
  2. Implementing slash commands for a better user experience
  3. Adding error handling and logging for better reliability
  4. Implementing a DJ role system to restrict certain commands to specific users
  5. Adding playlist support for saving and loading playlists

In the next section, we'll explore how to implement these advanced music commands to make your bot even more powerful.

Troubleshooting Common Issues

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.