Lab 04 - Python Discord Bot

Objectives

  • Using virtual environments and pip
  • Debugging scripts
  • Understanding public APIs

Contents

Proof of Work

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?

Tasks

01. [20p] Enhancing your environment

Just like last time, you'll need to create a virtualenv in order to install specific libraries.

Click GIF to maximize.

[10p] Task A - Dependencies

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?

  • PyNaCl: wrapper of the NaCl library. This library offers a high-level interface for networking and cryptographic operations. Note that the library must be installed using apt and that which we install with pip is only a wrapper.
  • py-cord[voice]: a Python module that interacts with discord. The previous discord.py project has been discontinued and pycord remains the best alternative. We'll be using this in the following exercise to write a discord bot.
  • ipython: a more interactive Python. See the next task for some details, but there's really not much to it.

[10p] Task B - Testing that it works, with ipython

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.

If you can't import the discord module, try to source the activation script again after installing the packages with pip. Some versions of venv / pip might act up.

02. [40p] Discord bot setup

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?

[10p] Task A - Test server and bot application

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.

[10p] Task B - Code skeleton

We've prepared a code skeleton for this part:

Click to display ⇲

Click to hide ⇱

bot-skel.py
#!./.venv/bin/python
 
import discord      # base discord module
import code         # code.interact
import os           # environment variables
import inspect      # call stack inspection
import random       # dumb random number generator
 
from discord.ext import commands    # Bot class and utils
 
################################################################################
############################### HELPER FUNCTIONS ###############################
################################################################################
 
# log_msg - fancy print
#   @msg   : string to print
#   @level : log level from {'debug', 'info', 'warning', 'error'}
def log_msg(msg: str, level: str):
    # user selectable display config (prompt symbol, color)
    dsp_sel = {
        'debug'   : ('\033[34m', '-'),
        'info'    : ('\033[32m', '*'),
        'warning' : ('\033[33m', '?'),
        'error'   : ('\033[31m', '!'),
    }
 
    # internal ansi codes
    _extra_ansi = {
        'critical' : '\033[35m',
        'bold'     : '\033[1m',
        'unbold'   : '\033[2m',
        'clear'    : '\033[0m',
    }
 
    # get information about call site
    caller = inspect.stack()[1]
 
    # input sanity check
    if level not in dsp_sel:
        print('%s%s[@] %s:%d %sBad log level: "%s"%s' % \
            (_extra_ansi['critical'], _extra_ansi['bold'],
             caller.function, caller.lineno,
             _extra_ansi['unbold'], level, _extra_ansi['clear']))
        return
 
    # print the damn message already
    print('%s%s[%s] %s:%d %s%s%s' % \
        (_extra_ansi['bold'], *dsp_sel[level],
         caller.function, caller.lineno,
         _extra_ansi['unbold'], msg, _extra_ansi['clear']))
 
################################################################################
############################## BOT IMPLEMENTATION ##############################
################################################################################
 
# bot instantiation
intents = discord.Intents.all()
bot = commands.Bot(command_prefix='!', intents=intents)
 
# on_ready - called after connection to server is established
@bot.event
async def on_ready():
    log_msg('logged on as <%s>' % bot.user, 'info')
 
# on_message - called when a new message is posted to the server
#   @msg : discord.message.Message
@bot.event
async def on_message(msg):
    # filter out our own messages
    if msg.author == bot.user:
        return
 
    log_msg('message from <%s>: "%s"' % (msg.author, msg.content), 'debug')
 
    # overriding the default on_message handler blocks commands from executing
    # manually call the bot's command processor on given message
    await bot.process_commands(msg)
 
# roll - rng chat command
#   @ctx     : command invocation context
#   @max_val : upper bound for number generation (must be at least 1)
@bot.command(brief='Generate random number between 1 and <arg>')
async def roll(ctx, max_val: int):
    # argument sanity check
    if max_val < 1:
        raise Exception('argument <max_val> must be at least 1')
 
    await ctx.send(random.randint(1, max_val))
 
# roll_error - error handler for the <roll> command
#   @ctx     : command that crashed invocation context
#   @error   : ...
@roll.error
async def roll_error(ctx, error):
    await ctx.send(str(error))
 
################################################################################
############################# PROGRAM ENTRY POINT ##############################
################################################################################
 
if __name__ == '__main__':
    # check that token exists in environment
    if 'BOT_TOKEN' not in os.environ:
        log_msg('save your token in the BOT_TOKEN env variable!', 'error')
        exit(-1)
 
    # launch bot (blocking operation)
    bot.run(os.environ['BOT_TOKEN'])

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!

[10p] Task C - Debugging

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.

03. [40p] Adding features

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.

[20p] Task A - Playing music

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:

  • Start from VoiceClient. You can read that it provides a play() function, though note that you cannot instantiate this directly… so we'll move on through the links!
  • Next is the VoiceChannel.connect() routine that gives us a voice channel, but how to we get a voice channel object?
  • Here's the idea: find the voice channel the user issuing the play command is connected to!
  • So we start from our 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!
  • Once we got ahold of our VoiceChannel, we connect to it and we finally get a VoiceClient, phew! Now we can simply use play()
  • What's that? 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!
  • Now get to writing the code! :P

Hint: check this out for an example: https://brucecodes.gitbook.io/pycord/guide/voice-commands

[10p] Task B - Enumerating the songs

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.

[10p] Task C - Misc commands

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!

04. [10p] Feedback

Please take a minute to fill in the feedback form for this lab.

ii/labs/04.txt · Last modified: 2024/11/20 16:02 by florin.stancu
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0