This shows you the differences between two versions of the page.
ii:labs:03:tasks:02 [2021/11/24 01:16] radu.mantu |
ii:labs:03:tasks:02 [2024/11/07 12:02] (current) florin.stancu [02. [20p] Making HTTP Requests] |
||
---|---|---|---|
Line 1: | Line 1: | ||
- | ==== 02. [60p] Discord bot ==== | + | ==== 02. [20p] Making HTTP Requests ==== |
- | 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. | + | Now that we have the ''requests'' library, we can easily send HTTP requests to any URL. This prompts the server to respond with the information we need. When the request is successful, the server will reply with a standard status code: ''200'' (Success), indicating everything went smoothly. Simply replace the URL with the desired website (try a [[https://en.m.wikipedia.org/wiki/List_of_presidents_of_the_United_States|wikipedia page]]), and you’re ready to go! |
- | + | ||
- | 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 [[https://docs.pycord.dev/en/master/api.html#client|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? | + | |
- | + | ||
- | To get you started, we prepared a code skeleton for you: | + | |
- | + | ||
- | <spoiler> | + | |
- | <file python 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 | + | |
- | bot = commands.Bot(command_prefix='!') | + | |
- | + | ||
- | # 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']) | + | |
- | </file> | + | |
- | </spoiler> | + | |
- | + | ||
- | === [5p] Task A - Test server and bot application === | + | |
- | + | ||
- | Before we dive into the script above, we need to set up a [[https://discord.com/|discord]] account (no need to register), [[https://support.discord.com/hc/en-us/articles/204849977-How-do-I-create-a-server-|create a server]] for testing purposes, and [[https://docs.pycord.dev/en/master/discord.html|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. | + | |
- | + | ||
- | === [5p] Task B - Code skeleton === | + | |
- | + | ||
- | 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. In stead, 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 [[https://docs.python.org/3/library/typing.html|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 [[https://www.reddit.com/r/iamsosmart/|r/iamsosmart]] 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 [[https://docs.pycord.dev/en/master/api.html#discord.Bot.event|event]] decorator belonging to our global bot instance (line 58). The "processing" that the **event** decorator does it 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 [[https://docs.python.org/3/library/asyncio-task.html|coroutines]]. 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 in stead of the random number. This way, he isn't greeted by silence, but in stead 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 python> | <code python> | ||
- | code.interact(local=dict(globals(), **locals())) | + | import requests # Imports the library in the script |
- | </code> | + | |
- | 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 (in stead 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! [[https://docs.python.org/3/library/stdtypes.html#dict|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! | + | url = "https://example.com" |
- | + | response = requests.get(url) # Send request to website | |
- | Here's an easier example where ''x'' is basically **globals()** and ''c=3, d=4'' is **%%**%%locals()**. | + | html_content = response.text # Get the HTML content of the page |
- | + | # optionally, print it on console (see its ugliness) | |
- | <code python> | + | print(html_content) |
- | 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} | + | |
</code> | </code> | ||
- | |||
- | For now, use **code.interact()** in the **on_message()** function. Inspect the //bot// and //msg// variables. | ||
- | |||
- | === [40p] Task D - 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 [[https://docs.pycord.dev/en/master/api.html|API Reference page]]. | ||
- | Here is what you have to add: | ||
- | - A **play** command that takes a song name (e.g.: [[https://www.youtube.com/watch?v=yK0P1Bk8Cx4|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 you have on hand, or a YouTube downloader). | ||
- | - A **list** command that lists all available songs in the discord chat. | ||
- | - A **scram** command that tells the bot to disconnect from the current voice channel immediately. | ||
- | - 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. |