Tasks
Today we're picking up where we left off last time. By now you should already know the basics of working with Python, Virtual Environments and PIP package management. Developing a project in Python however, requires more than interacting with a shell or editing some scripts. In this lab, you will (hopefully) learn to use remote services, debug errors in your scripts and, one of the most important skills, how to consult an official API documentation.
As a more tangible goal, you will have to write your very own discord music bot! Exciting stuff, right?
Just like last time, you'll need to create a virtualenv in order to install specific libraries.
First, if you reinstalled your Linux/WSL/VM from last time, ensure that you have the prerequisites installed (it never hurts!):
sudo apt install python3 python3-venv python3-pip
Navigate to your work directory, e.g., lab04
and build your virtual environment:
mkdir -p lab04 && cd lab04 python3 -m venv .venv # don't forget to activate your environment each time you start a new shell: source .venv/bin/activate
Now, to get to the specifics of today's lab, we need some extra dependencies:
# you need extra system libraries installed with your distro's package manager sudo apt install libffi-dev libnacl-dev python3-dev # and our target Python libraries: pip3 install 'py-cord[voice]' PyNaCl ipython pip3 list
So, what are these, exactly?
In the previous labs, you used the python3 interpreter in interactive mode. Now, we upgrade to ipython. This new interpreter offers an enhanced user experience by means of color coding, tab completion, multi-line editing, etc. For scripting purposes, python3 should remain your interpreter of choice. But for prototyping, we suggest using this. For now, let's see if the discord module in the py-cord[voice] package is available to import.
$ ipython Python 3.9.7 (default, Oct 10 2021, 15:13:22) Type 'copyright', 'credits' or 'license' for more information IPython 7.29.0 -- An enhanced Interactive Python. Type '?' for help. In [1]: import discord In [2]: discord? In [3]: discord?? In [4]: help(discord)
Note how in stead of help, in ipython we can ?
or ??
to something to access a brief / more complete documentation.
Let's move on to the good part. In this exercise, you will write your very own discord bot. In case you've been living under a rock, discord is pretty much like teamspeak from back in the day, but you can create ad-hoc servers for you and your friends. These servers have both text and voice channels and can be joined via a referral link.
Each account (provisional or fully registered) is allocated a token. This token is a string of characters that uniquely identifies you to the discord servers. If you use this token with pycord (see the Client class), you will be able to automate a human account. This is fine, if you want to write a terminal discord client for yourself. However, as a developer, you are also able to create bot applications under your account. These bots belong to you, but are issued separate tokens.
While human accounts are added to a server by receiving an invite link, bot owners give out join links to server admins. Once the admin approves the join request for your bot (reviewing a list of permissions that the bot needs to function), the bot will appear as a normal user in that respective server. When you run your Python script and authenticate yourselves with the bot's token, the bot will come online. When you stop your script and the connection is lost, the bot goes offline in discord. Simple, right?
Before we dive into the script above, we need to set up a discord account (no need to register), create a server for testing purposes, and create a bot account.
Normally, for bot permissions, you would want to be as restrictive as possible. People will be reticent to give admin privileges to a random bot. In this case, we want our bot able to view channels and logged users, view their text messages and respond in kind, and also to connect to the voice channel and maybe play some music.
Finish the setup as per the links above and save the bot's token. You'll need it in the following task. If you encounter any problem, ask a friend. If you don't have any, the lab assistant will have to suffice.
We've prepared a code skeleton for this part:
Let's take a closer look at the code skeleton and see what each thing does:
from discord.ext import commands
: as you guessed, this works pretty much like the regular import module
. The difference here is that we don't import it as a whole, but bits and pieces such as submodules, functions, etc. Additionally, we don't have to specify the whole module chain (i.e.: discord.ext.commands
) every time we use it. Instead, we can just say commands
and the interpreter will know what we're referring to.log_msg()
: this is a helper function that colors your message according to its urgency and also displays the line where it was invoked. Use it if you want, but it's not mandatory. Note however the arguments: msg: str, level: str
. Since Python 3.5 you can use typing hints for variables and function return values. When possible, use these hints for clarity if nothing else.@bot.event
: this is called a decorator. Most likely, you've never seen this before and we can't blame you. Things like this belong on r/iamverysmart and not in programming language specifications. A decorator is basically a function processing function. As the name implies, by specifying a decorator before the declaration of that function, the declared function will be passed to the decorator function for “some processing”. In this case, our on_ready() function is passed to the event decorator belonging to our global bot instance (line 58). The “processing” that the event decorator does is to assign on_ready() as the bot's callback method for the event with the same name. So when the bot finishes its initialization and connects to the server, it raises an on_ready event and our method will intercept it and resolve it.async
& await
: in Python there's these things called coroutines (also quickly explained in PyCord FAQ). Coroutines are functions that can be entered, exited and resumed at different points in their execution. Diving into this subject may prove too difficult right now, so we'll try to keep it simple. Thus, please excuse the hand waving: basically, any event handler can be triggered at any time (even when other handlers are already executing), so we mark them as asynchronous with the async keyword. When we try to call on asynchronous functions, we need to await their execution to finish before continuing on our merry way.@bot.command
: yet another decorator, but this one registers the following function as a command. Commands are identified by the prefix established during the bot's instantiation (in this case, !
). So, by writing ”!roll 100” in the discord chat, the bot will read the message, recognize roll as a command, parse the argument and reply with a random number between 1 and 100.@roll.error
: this decorator defines an error callback routine for the roll command. Without this, if the user forgets to specify the argument let's say, the roll() function will fail with a nasty error printed to stdout but the script will not crash! By specifying the error handler, we can output its message to the discord user instead of the random number. This way, he isn't greeted by silence, but instead learns what he did wrong.os.environ['BOT_TOKEN']
: generally, it's a bad idea to hardcode IDs and passwords into the source code. As such, we save our bot's token inside an environment variable in bash (or zsh). When running a program from your shell, it inherits all your variables. So before you run the skeleton, export the token under the name BOT_TOKEN.This must have been a lot to take in. So take some time and ask any question that you may have. If you've done everything correctly so far, once you run the script you should see your bot coming online in your test discord server. Try interacting with it!
Notice that our bot is event driven, so debugging it would be kind of annoying. For this, you can use a little hack:
code.interact(local=dict(globals(), **locals()))
When executed, this line of code will pause the script and open a python shell for you to investigate the state of both the local and global variables. The syntax might seem a bit weird. The interact() function takes a dictionary as the local argument. The local argument is used to initialize the variables available in the newly opened shell. Because we want to see both local and global variables, we need to combine them in a single dictionary. dict is the dictionary type. When using it to create a new dictionary (instead of the basic { }
syntax), we can specify another dictionary for initialization: dict(globals()). But we want to combine two dictionaries. However, we can't just add them with the +
operator since it doesn't work that way. Also, we don't want to change any of the dictionaries returned by globals() and locals() since it might get us into trouble with the interpreter later. But wait! dict can also be initialized from optional keyword arguments, represented by key-value pairs. So **locals() can be used to break down the second dictionary in optional keyword arguments for the resulting dictionary's constructor!
Here's an easier example where x
is basically globals() and c=3, d=4
is **locals().
In [1]: x = { 'a' : 1, 'b' : 2 } In [2]: y = dict(x, c=3, d=4) In [3]: y Out[3]: {'a': 1, 'b': 2, 'c': 3, 'd': 4}
For now, use code.interact() in the on_message() function. Inspect the bot and msg variables.
Up until now, everything was pretty tutorial-ish. From here on out, it's time for you to get your hands dirty and add some new features to the bot! This means looking stuff on the Internet. For yourselves. Scary stuff… I know!
Hint: a good starting point would be the API Reference page.
Let's begin by adding the most important feature of a music bot: a play
command that takes a song name (e.g.: danger_zone.mp3) as argument. Invoking this command while present in a voice channel should cause the bot to connect to that channel and play the song. The song should be loaded from a local file (hint: use any music file that you have on hand, or a YouTube downloader).
In order to do that, please note how the commands are defined:
@bot.command(brief='Generate random number between 1 and <arg>') async def roll(ctx, max_val: int): # implementation goes here ...
To create new discord bot commands, simply define your function with the name of the command and decorate them with @bot.command as above. Oh, and whats with that : int
declaration? I thought Python doesn't require us to give types to our variables… that's right! But we're now able to, if we want ;)
But, as expected, the hardest part will be implementing it. There are several questions: how can you load an .mp3
file from disk? how can you send it streaming over the Discord server? etc.
You will need to read some more API references to find the appropiate calls:
play()
function, though note that you cannot instantiate this directly… so we'll move on through the links!VoiceChannel.connect()
routine that gives us a voice channel, but how to we get a voice channel object?ctx
variable, which we can find that is of type ApplicationContext (ofc!); we follow up with the author
, which has a voice
property (of type VoiceState
), which finally brings us to our desired channel!VoiceChannel
, we connect to it and we finally get a VoiceClient
, phew! Now we can simply use play()
… play()
requires an AudioSource-type argument? We just need something that reads mp3
files… oh, we see a ffmpeg naming in there, which is a very popular open source library which can read almost every audio/video format in existence (check the right side menu!), so problem solved!
This one is easier: build a list command that lists all available songs in the discord chat.
Use the basic Python OS functions for easily reading directory contents and filtering by mp3
extension.
Let's assume someone enters your channel and plays this. We'll need a quick solution…
So: create a scram command that tells the bot to disconnect from the current voice channel immediately (which will stop the play!).
Finally, make an event handler for on_voice_state_update
that checks if the bot was left alone in the channel after a user left. If the bot is indeed alone, it should also disconnect.
That's it!
Please take a minute to fill in the feedback form for this lab.