DEV Community

Nir
Nir

Posted on • Updated on

Writing a 'music quiz' command for your discord.js bot (Updated March 2020)

If you stumbled upon Discord bots, you've probably seen that some of them offer music commands that let you play music directly from Youtube. I've written a guide on how to write a play command.
But in this guide, I will walk you through writing a music trivia(quiz) command. The popular Discord bot MEE6 offers a similar command part of its premium subscription, but we are going to write a better one for free!

If you don't feel like going through this guide, the code is available here

Prerequisites

You need to have a solid knowledge of JavaScript(ES6 features) and discord.js(we will be using its master branch).
Also, this guide assumes you have a working bot because I'm going to walk you through writing the music trivia command solely.
This guide is about setting up a bot.
You need to install Git as well(No need if you have a Mac).
Install the dependencies required for this command by running this in your terminal:
npm install github:discordjs/discord.js#master github:discordjs/Commando ffmpeg-static node-opus simple-youtube-api ytdl-core@latest

Flowchart

The command will work like this:

Music Trivia flowchart

Bot folders structure

You should(and the code in this guide assumes that) put your commands inside a 'commands' folder. In that folder, you should divide commands by groups(music, guild, gifs, etc...). The music trivia command should be located inside a music folder(so its commands/music/musictrivia.js).
We get the songs data from a JSON file that needs to be located inside a 'music' folder that is inside a 'resources' folder in the root of the project. If that confuses you just take a look at my bot structure on GitHub. The data for the JSON file is here, you can modify it(add more songs or change the existing ones).

Code

If you don't wanna go through the explanations, you can view the full code on my Master-Bot's GitHub repo

In your index.js(or server.js however you called it) extend the 'Guild' structure in order for it to hold our queue:

const { Structures } = require('discord.js'); // add this require on top

Structures.extend('Guild', Guild => {
  class MusicGuild extends Guild {
    constructor(client, data) {
      super(client, data);
// musicData should be here if you followed my play command tutorial, don't copy it if you haven't
      this.musicData = {
        queue: [],
        isPlaying: false,
        nowPlaying: null,
        songDispatcher: null
      };
      this.triviaData = {
        isTriviaRunning: false,
        wasTriviaEndCalled: false,
        triviaQueue: [],
        triviaScore: new Map()
      };
    }
  }
  return MusicGuild;
});

We will start by importing the dependencies we installed earlier:

const { Command } = require('discord.js-commando'); // import only the Command class
const { MessageEmbed } = require('discord.js'); // import only the MessageEmbed class 
const ytdl = require('ytdl-core');
const fs = require('fs');

Create the MusicTriviaCommand class:

module.exports = class MusicTriviaCommand extends Command {
  constructor(client) {
    super(client, {
      name: 'music-trivia',
      memberName: 'music-trivia',
      aliases: ['music-quiz', 'start-quiz'],
      group: 'music',
      description: 'Engage in a music quiz with your friends!',
      guildOnly: true,
      clientPermissions: ['SPEAK', 'CONNECT'],
      throttling: {
        usages: 1,
        duration: 10
      },
      args: [
        {
          key: 'numberOfSongs',
          prompt: 'What is the number of songs you want the quiz to have?',
          type: 'integer',
          default: 5,
          max: 15
        }
      ]
    });
  } // this bracket closes the constructor

Now let's get into the 'run' method(discord.js-commando related):

  async run(message, { numberOfSongs }) {
    // check if user is in a voice channel
    var voiceChannel = message.member.voice.channel;
    if (!voiceChannel)
      return message.say('Please join a voice channel and try again');
    if (message.guild.musicData.isPlaying === true)
      return message.channel.send('A quiz or a song is already running');
    message.guild.musicData.isPlaying = true;
    message.guild.triviaData.isTriviaRunning = true;
    // fetch link array from txt file
    const jsonSongs = fs.readFileSync(
      'resources/music/musictrivia.json',
      'utf8'
    );
    var videoDataArray = JSON.parse(jsonSongs).songs;
    // get random numberOfSongs videos from array
    const randomXVideoLinks = this.getRandom(videoDataArray, numberOfSongs); // get x random urls
    // create and send info embed
    const infoEmbed = new MessageEmbed()
      .setColor('#ff7373')
      .setTitle('Starting Music Quiz')
      .setDescription(
        `Get ready! There are ${numberOfSongs} songs, you have 30 seconds to guess either the singer/band or the name of the song. Good luck!
        You can end the trivia at any point by using the end-trivia command`
      );
    message.say(infoEmbed);

Construct an object for each song, then loop through each user in the channel and set him a score of 0. After that call the playQuizSong method with the queue:

    for (let i = 0; i < randomXVideoLinks.length; i++) {
      const song = {
        url: randomXVideoLinks[i].url,
        singer: randomXVideoLinks[i].singer,
        title: randomXVideoLinks[i].title,
        voiceChannel
      };
      message.guild.triviaData.triviaQueue.push(song);
    }
    const channelInfo = Array.from(
      message.member.voice.channel.members.entries()
    );
    channelInfo.forEach(user => {
      if (user[1].user.bot) return;
      message.guild.triviaData.triviaScore.set(user[1].user.username, 0);
    });
    this.playQuizSong(message.guild.triviaData.triviaQueue, message);
  } // closing bracket of the 'run' method

Now we're going to have a look at the playQuizSong function. If you've taken a look at the flowchart you've seen that it starts playing a song, creates a MessageCollector(listens to incoming messages and decides what to do with them) for 30 seconds. When the collector stops it shifts the queue by 1 song and checks if there are songs left in the queue. if there are more songs, it calls playQuizSong again until there are no songs left.
The collector may stop for 2 reasons:

  1. Timeout (30 seconds passed)
  2. Both the singer/band and song name were guessed

Note that wherever collector.stop() is called, the 'finish' event is emitted and you should "jump" to the code starting from collector.on('finish' ..)

There are code comments explaining the "why's" and "how's" along the way:

  playQuizSong(queue, message) {
    queue[0].voiceChannel.join().then(connection => {
      const dispatcher = connection
        .play(
          ytdl(queue[0].url, {
            quality: 'highestaudio',
            highWaterMark: 1024 * 1024 * 1024 // download part of the song to prevent stutter
          })
        )
        .on('start', () => {
          message.guild.musicData.songDispatcher = dispatcher;
          dispatcher.setVolume(message.guild.musicData.volume);
          let songNameFound = false;
          let songSingerFound = false;

          const filter = m =>
            message.guild.triviaData.triviaScore.has(m.author.username);
          const collector = message.channel.createMessageCollector(filter, { // creates a message collector for 30 seconds
            time: 30000
          });

          collector.on('collect', m => { // this event is emitted whenever a message is sent to the channel
            if (!message.guild.triviaData.triviaScore.has(m.author.username)) // don't process messages sent by users who are not participating
              return;
            if (m.content.startsWith(this.client.commandPrefix)) return; // don't process commands
            // if user guessed song name
            if (m.content.toLowerCase() === queue[0].title.toLowerCase()) {
              if (songNameFound) return; // if song name already found
              songNameFound = true;

              if (songNameFound && songSingerFound) {
                message.guild.triviaData.triviaScore.set(
                  m.author.username,
                  message.guild.triviaData.triviaScore.get(m.author.username) +
                    1
                );
                m.react('');
                return collector.stop(); // stop the collector if both song and singer were found
              }
              message.guild.triviaData.triviaScore.set(
                m.author.username,
                message.guild.triviaData.triviaScore.get(m.author.username) + 1
              );
              m.react('');
            }
            // if user guessed singer
            else if (
              m.content.toLowerCase() === queue[0].singer.toLowerCase()
            ) {
              if (songSingerFound) return;
              songSingerFound = true;
              if (songNameFound && songSingerFound) {
                message.guild.triviaData.triviaScore.set(
                  m.author.username,
                  message.guild.triviaData.triviaScore.get(m.author.username) +
                    1
                );
                m.react('');
                return collector.stop();
              }

              message.guild.triviaData.triviaScore.set(
                m.author.username,
                message.guild.triviaData.triviaScore.get(m.author.username) + 1
              );
              m.react('');
            } else if ( // this checks if the user entered both the singer and the song name in different orders
              m.content.toLowerCase() ===
                queue[0].singer.toLowerCase() +
                  ' ' +
                  queue[0].title.toLowerCase() ||
              m.content.toLowerCase() ===
                queue[0].title.toLowerCase() +
                  ' ' +
                  queue[0].singer.toLowerCase()
            ) {
              if (
                (songSingerFound && !songNameFound) ||
                (songNameFound && !songSingerFound)
              ) {
                message.guild.triviaData.triviaScore.set(
                  m.author.username,
                  message.guild.triviaData.triviaScore.get(m.author.username) +
                    1
                );
                m.react('');
                return collector.stop();
              }
              message.guild.triviaData.triviaScore.set(
                m.author.username,
                message.guild.triviaData.triviaScore.get(m.author.username) + 2
              );
              m.react('');
              return collector.stop();
            } else {
              // wrong answer
              return m.react('');
            }
          });

          collector.on('end', () => {
            /*
            The reason for this if statement is that we don't want to get an
            empty embed returned via chat by the bot if end-trivia command was 
            called
            */
            if (message.guild.triviaData.wasTriviaEndCalled) {
              message.guild.triviaData.wasTriviaEndCalled = false;
              return;
            }
            // sort the score Map before displaying points, so the display will be in order
            const sortedScoreMap = new Map(
              [...message.guild.triviaData.triviaScore.entries()].sort(
                (a, b) => b[1] - a[1]
              )
            );

            const song = `${this.capitalize_Words(
              queue[0].singer
            )}: ${this.capitalize_Words(queue[0].title)}`;
            // display an embed with the previous song and score
            const embed = new MessageEmbed()
              .setColor('#ff7373')
              .setTitle(`The song was:  ${song}`)
              .setDescription(
                this.getLeaderBoard(Array.from(sortedScoreMap.entries()))
              );

            message.channel.send(embed);
            queue.shift();
            dispatcher.end();
            return;
          });
        })
        .on('finish', () => { // emitted when a song ends
          if (queue.length >= 1) { // if there are more songs, continue
            return this.playQuizSong(queue, message);
          } else { // no more songs left
            if (message.guild.triviaData.wasTriviaEndCalled) { // if the end-trivia command was called
              message.guild.musicData.isPlaying = false;
              message.guild.triviaData.isTriviaRunning = false;
              message.guild.me.voice.channel.leave();
              return;
            }
            const sortedScoreMap = new Map( // sort final score Map
              [...message.guild.triviaData.triviaScore.entries()].sort(
                (a, b) => b[1] - a[1]
              )
            );
            // display results embed
            const embed = new MessageEmbed()
              .setColor('#ff7373')
              .setTitle(`Music Quiz Results:`)
              .setDescription(
                this.getLeaderBoard(Array.from(sortedScoreMap.entries()))
              );
            message.channel.send(embed);
            message.guild.musicData.isPlaying = false;
            message.guild.triviaData.isTriviaRunning = false;
            message.guild.triviaData.triviaScore.clear();
            message.guild.me.voice.channel.leave();
            return;
          }
        });
    });
  }

Below the playQuizCommand function add these 3 functions we used:

 // this method was called when we wanted to get 5 random songs from the JSON file
  getRandom(arr, n) {
    var result = new Array(n),
      len = arr.length,
      taken = new Array(len);
    if (n > len)
      throw new RangeError('getRandom: more elements taken than available');
    while (n--) {
      var x = Math.floor(Math.random() * len);
      result[n] = arr[x in taken ? taken[x] : x];
      taken[x] = --len in taken ? taken[len] : len;
    }
    return result;
  }

  getLeaderBoard(arr) {
    if (!arr) return;
    let leaderBoard = '';

    leaderBoard = `👑   **${arr[0][0]}:** ${arr[0][1]}  points`;

    if (arr.length > 1) {
      for (let i = 1; i < arr.length; i++) {
        leaderBoard =
          leaderBoard + `\n\n   ${i + 1}: ${arr[i][0]}: ${arr[i][1]}  points`;
      }
    }
    return leaderBoard;
  }
  // https://www.w3resource.com/javascript-exercises/javascript-string-exercise-9.php
  capitalize_Words(str) {
    return str.replace(/\w\S*/g, function(txt) {
      return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
    });
  }
};

End music trivia command

There is also a command that is used for stopping the trivia, I'm not gonna go through it because it's very simple. View its code here

That's it!

We just went through writing a music trivia command! If you have a question/clarification/issue please comment down below or open an issue in the bot's GitHub repository :)

Oldest comments (0)