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:
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:
- Timeout (30 seconds passed)
- 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 :)
Top comments (0)