Skip to content

Instantly share code, notes, and snippets.

@greut
Created April 27, 2017 19:18
Show Gist options
  • Save greut/a1f0a503f96916622bc3594dd27b4197 to your computer and use it in GitHub Desktop.
Save greut/a1f0a503f96916622bc3594dd27b4197 to your computer and use it in GitHub Desktop.

A Discord Bot with asyncio

Following last year’s article on Slack, here is how to create a bot for Discord. We will go through a minimalist bot that does close to nothing. Most of the provided examples using libraries like discord.py hide asyncio away. Here we will make it explicit how it works under the hood. Be aware that this is not the easiest way to build a bot but a step-stone to understand what complete libraries do for you.

All you need is a Discord account, Python 3.6 or higher and a way to install packages, e.g. pip or conda. The examples are using the f-strings, change them to proper .format() to be compatible with Python 3.5.

Create a bot user on Discord:

  1. Go on My Apps to create a New App.

  2. Give it a name and a description.

  3. Then make it a Bot User.

  4. You’ll be able to copy and paste the App Bot User Token into your Python code.

    bot.py

    TOKEN = " "

To had the bot to any server, add the App Details Client ID to the following URL and visit it. You’ll be prompted with a invitation form.

https://discordapp.com/oauth2/authorize?scope=bot&permissions=o&client_id=

At this point, the bot should appear in the contact list of the server.

Discord Gateways

To build the real-time communication between the application and an application, representing a bot as well as a real user, Discord went for WebSockets. A WebSocket is a two-ways communicating channel that remains open between a client and a server. This overcomes the HTTP protocol that closes the connection when the last bit of data was sent.

If WebSockets are more powerful than plain HTTP, it also means they are a bit more complex. But no worries, it still not rocket science.

Obtaining the gateway

Before being able to connect to gateway, we have to ask the REST API where it is. Here, we will be using plain HTTP using aiohttp to perform call to the GET /gateway entrypoint that gives us the address of the WebSocket.

import asyncio
import json

import aiohttp

TOKEN = "
"
URL = "https://discordapp.com/api"

 api_call(path):
    
    
 aiohttp.ClientSession() 
 session:
        
 session.get(f"{URL}{path}") 
 response:
            assert 200 == response.status, response.reason
            
 response.json()

 main():
    
    response = 
 api_call("/gateway")
    print(response)

loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()

If you already know Python but not yet async/await, let me introduce them to you.

Asynchronous programming

A computer software spends most of its time to wait. The CPU is so fast compared to ready data from the disk or the network that it sits there waiting for data to crunch. One way to speed up a program is giving it many things to do and letting him switch between those tasks the way it pleases.

With async/await we make it explicit where the program may have to wait using async/await. For example, await response.json() means I want to continue when the JSON representation of the response is ready. Or await with session.get(…) means I want to wait until the response of this HTTP GET request has been performed. The event loop puts aside a waiting task and do the other stuffs waiting until it is ready. Provided there are other things to be done.

In comparison with programming using threads, using async/await is more explicit. Just like the Zen of Python taught us.

Explicit is better than implicit.

Now, let’s go back to our WebSocket.

Connecting to the WebSocket

The reponse JSON contains an URL that will be used to connect to the WebSocket.

 main():
    
    response = 
 api_call("/gateway")
    
 start(response["url"])

This start function in a nutshell connects to the WebSocket URL, and waits for messages to come in.

 start(url):
    
 aiohttp.ClientSession() 
 session:
        
 session.ws_connect(
                f"{url}?v=5&encoding=json") 
 ws:
            
 msg 
 ws:
                print(msg.tp, msg.data)

In Python, as you may recall, for works on an iterator. An iterator doesn’t have to have an end. Hence this async for can be seen as an infinite loop until something bad happens to the WebSocket connection.

Working with the Discord protocol

Now that we are connected to it, starts the real and complex programming. When you connect, the Discord server will send you an OP 10 Hello message containing information about the heartbeat. It then expects you to identify yourself with the secret token from before.

Identification

With the identification we start touching the protocol. The minimum message contains an opcode such as 2 for Identify or 10 for Hello and a data block.

 msg 
 ws:
    data = json.loads(msg.data)

    
 data["op"] == 10:  
        
ws.send_json({
            "op": 2,  # Identify
            "d": {
                "token": TOKEN,
                "properties": {},
                "compress": False,
                "large_threshold": 250
            }
        })

    
 data["op"] == 0:  
        print(data['t'], data['d'])

    
:
        print(data)

Unlike reading data, sending can be done without having to wait. After that, you’ll receive some 0 Dispatch (zero) messages. Those are identified by their type. Propertly managing all the events is left as an exercice to the reader.

Heartbeat

The first 10 Hello message gives information about the heartbeat. The heartbeat is a mecanism for the Discord servers to ensure that you’re still alive. Discord requires from us to send periodically a message.

asyncio simplifies spawning new tasks like this.

 msg 
 ws:
        data = json.loads(msg.data)

 data["op"] == 10:  
asyncio.ensure_future(heartbeat(
                ws,
                data['d']['heartbeat_interval']))
 data["op"] == 11:  
            pass

 heartbeat(ws, interval):
    
    
 True:
        
 asyncio.sleep(interval / 1000)  # seconds
        
 ws.send_json({
            "op": 1,  # Heartbeat
            "d": 
})

The heartbeat loops forever and sends a 1 Heartbeat message every interval milliseconds. last_sequence must be set to the last 0 Dispatch message received. Changing this is left as an exercise to the reader.

Sending messages back to Discord

The WebSocket is there for Discord to send you event but when you want to publish content to Discord, the traditional HTTP REST API has to be used. For bot users though, one cannot go without the other as stated by the documentation. We’ve done that, so are good.

A bot account must connect and identify to a Gateway at least once before being able to send messages.

The time has come to improve/refactor the api_call function to be able to POST content as well.

 api_call(path, method="GET", **kwargs):
    
    defaults = {
        "headers": {
            "Autorization": f"Bot {TOKEN}",
            "User-Agent": "dBot (https://medium.com/@greut, 0.1)"
        }
    }
    kwargs = dict(defaults, **kwargs)
    
 aiohttp.ClientSession() as session():
        
 session.request(method, path,
                                   **kwargs) 
 response:
         assert 200 == response.status, response.reason
         return 
 response.json()

The big changes are that it accepts a method argument to either GET or POST and create some HTTP headers by default for the authentication.

Sending a message to a user identified by its snowflake id can be done in two steps.

  1. Creating the private channel.

  2. Sending a message to that channel.

    send_message(recipient_id, content):

    channel = await api_call("/users/@me/channels", "POST",
                             json={"recipient_id": recipient_id})
    

    api_call(f"/channels/{channel['id']}/messages", "POST", json={"content": content})

You may now send messages from the WebSocket queue or any other tasks as long as the 2 Identify has been done. Dive into the official documentation to perform more advanced task like sending files, creating complex messages, adding reactions, etc.

Conclusion

If your goal is to build a bot for Discord, use the Python API. If you want to learn about WebSocket and asynchronous programming, starting from the ground up is a good exercise. Discord is fundamentally not very different from Slack when work directly with the REST API and the WebSocket.

Let me know if this was useful to you and/or how it could be improved.

Swiss developer...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment