Serverless Discord Custom Slash Commands bot with AWS API Gateway and Lambda
We’re all used to the way that Discord bots have worked for a long time. You make an application in the Dev Portal, you add a bot user to it, and you copy the token.
Slash Commands and Interactions bring something entirely new to the table. — slash commands documentation
Introduction
The recent addition of custom slash commands and outgoing webhooks finally frees Discord servers everywhere from the tyranny of bots clogging up membership lists. Perhaps even more significantly, it allows bot owners to migrate from the 24/7 listener script to a cheaper, event-based model.
However, the current official documentation is a bit sparse, and the examples given there still revolve around flask and other implementations of personal servers.
This article goes over how to set up a serverless Discord slash bot using free tools provided by Amazon Web Services in Python.
Overview
In the past, Discord did not support outgoing webhooks. Bots and bot requests could only be fulfilled within the Discord ecosystem, which doesn’t provide any infrastructure. Hence, 5.7 million google search results for “how to keep discord bot running”.
Outgoing webhooks forward the requests that your bot receives to an arbitrary web endpoint. This lets you pass off the infrastructure to a cloud service provider, such as AWS (ty jeff), so you can focus on adding features to your bot without having to maintain a constantly running server.
Here, we’ll be using AWS to provide this infra. Specifically, we’ll be using these 2 AWS services:
- API Gateway: provides an endpoint;
- Lambda: runs code;
We make a web endpoint on API Gateway. We give that endpoint to Discord (see screenshot above). Discord sends messages to the endpoint. The connected Lambda function is triggered, running our code and returning stuff to Discord.
Why AWS:
- Reliable uptime
- Free*
- Services can be easily connected and configured; for example, we can easily add free data storage via DynamoDB
- Scaleable
- <insert other buzzwords here>
*AWS offers 12 months of free access to API Gateway, as well as permanent free access to 1 million monthly Lambda requests and 25 GB of storage on DynamoDB. 1 million requests a month is like 30k a day, which is probably more than the traffic my bot will see in its lifetime :(
Walkthrough: Discord part 1
Enable developer mode and make a test server
Here we do some basic setting up.
- Enable developer mode
- Create a new server in the Desktop client (plus sign at the bottom of the left bar):
Create New Application and Bot
- Go to https://discord.com/developers/applications and click the New Application button
- Give your application a name
- Notice this box in the General Information tab; this is where we’ll come back to fill in the endpoint URL later.
- Go to the bot tab and click “Add Bot” > “Yes, do it!”
- If it says “Too many people have this username”, change your application name to be something less generic. (Bots and applications can have different names, but they’re initialized to the same thing when you first make the bot).
- Go to the OAuth2 tab and check the application.commands box. This lets you add new slash commands to which your bot can respond.
- Go to the URL shown at the bottom of the box; it’ll look something like this, after you select your test server from the dropdown list:
- Click authorize; the page should look like this
Walkthrough: AWS API Gateway part 1
Now that the basic Discord configurations are done, we need to create a web endpoint that our application can talk to. We do this in API Gateway. We first create a base API to get a base URL, and then we define resources within the API to get a full, POST-able endpoint. Finally, we’ll deploy the API so we can access it over the internet.
- Register a new free tier AWS account: https://aws.amazon.com/free/
- After logging in, you should end up at this console page
- In the search bar at the top, type “API Gateway” and click on the first result
- Click the “Create API” button at the top right.
- Choose “REST API”; we need to control both the request and the response in order to properly integrate with Discord.
- Choose “New API” > Regional (generic option for use across public networks), and click the “Create API” button.
- See this page. We’ve created an empty root API. We need to populate the API, which is done by defining some Resources and Methods. We can add a new Resource here. Click the “Actions” button next to “Resources” in the second column.
- In the drop down list, select “Create Resource”:
- This creates the endpoint that we’ll eventually add into our Discord application settings. Give it a name (stick to alphanumeric characters to be safe, and don’t use {} for path parameters (if this doesn’t mean anything to you, then don’t worry about it)). Check the enable API Gateway CORS box to enable cross origin resource sharing, since we’re making non-simple requests from discord.com to amazon.com (read more about CORS here). Click “Create Resource”.
- Now that we’ve created a resource, we need to enable POSTs to this resource. With the new resource selected in the second column, click on “Actions” again, and this time click “Create Method”. Select “POST” from the dropdown list, then check the checkmark that appears next to it.
- Resultant page should look like this.
- This means we’ve successfully created an endpoint. Now we need to configure it to a) run actual code by connecting this endpoint to a Lambda function and b) take and return information in the way that Discord expects.
- Let’s do (a) first. We’ll use AWS Lambda to run our code.
Walkthrough: AWS Lambda part 1
AWS Lambda is a compute service that “lets you run code without provisioning or managing servers”. You put your code in a “Lambda function” and give your function a trigger; every time the function is triggered, your code runs. For us, we want to run code every time Discord sends us a request.
- Open a new tab and navigate using the search bar to AWS Lambda.
- See something like this; click “Create Function”.
- Select “Author from scratch”, give the function a name, and choose a language. Here, I’ll be using python3.8. Click “Create Function”.
- The page should look something like this:
This is where your code will actually run. Within your code, you don’t need to and indeed should not start a persistent Discord client. The idea is that we’ll connect this lambda function to your API, which we’ve just created, and every time your bot receives a request, that request will travel to your API, trigger this lambda function, and run the code you’ve specified here, all without the need for a constant listener.
Unless you need the Discord API to get channel statuses or handle members/etc, you don’t need to import discord.py either. If you do, we’ll go over how to import external packages in the next section.
What your code does need to do:
Discord has 2 requirements for outgoing webhooks.
- Your endpoint must be prepared to acknowledge a
PING
message.
- This means that Discord will send you a request (“PING”), where the request body contains the key-value pair
{“type”: 1}
. You need to respond also with{“type”: 1}
(“PONG”) and a200
status. This is pretty straightforward, and you can refer to my demo code here for an example.
This is what the json body of a PING looks like:
{'id': '<id>',
'token': '<token>',
'type': 1,
'version': 1}
2. Your endpoint must be set up to verify signature headers.
- Some metadata is attached to every request you get from Discord. Two of those pieces of information are a
signature
and atimestamp
; these, along with the rawbody
of the request, can be used to verify that the message you received is the same as the message that Discord sent, and that it hasn’t been intercepted/bunged up in the middle. - When Discord sends a request, it notes down the current
timestamp
. Then it calculates asignature
as a hash of thetimestamp
and the requestbody
. It sends you all 3 pieces of information. - Discord requires that you verify the signature. Namely, we need to re-calculate on our end the hash of the
timestamp
and thebody
. This newly calculated signature needs to match the signature we received; otherwise, thebody
may have been altered. - The documentation linked above provides a code snippet in Python; however, it only works for
flask
-based implementations. Some changes need to be made to get it to work in Lambda. The above demo code works in Lambda.
Setting up signature verification
Importing PyNaCl: The encryption library Discord uses is PyNaCl. This package isn’t available by default, so we have to manually import it into Lambda. For this, we’ll use Lambda layers, which are “layers” of files that are accessible to Lambda functions; see a more in-depth tutorial on how to use Lambda layers here.
Do this to import PyNaCl:
- On your own computer, make a new folder somewhere called
python/lib/python3.8/site-packages
. It needs to be called exactly this. I made a folder calledtemp_folder
, and made mypython/lib/python3.8/site-packages
subfolder within it:mkdir -p temp_folder/python/lib/python3.8/site-packages && cd temp_folder
- Do a targeted pip install of PyNaCl into the bottom of that folder:
python3 -m pip install PyNaCl -t python/lib/python3.8/site-packages/
- (Optional) Install
zip
:sudo apt install zip
- Zip up the
python/lib/python3.8/site-packages
folder:zip -r pynacl_layer.zip *
. We’ll upload this zipped file into AWS so we can import the package.
5. Go back to the AWS Lambda home page, and click on “Layers” in the tab on the left
6. The page should look like this. Click “Create layer”.
7. Fill out the form, uploading the zipped file when prompted. Select some compatible runtimes (make sure you include the runtime language of your Lambda function; if you’ve followed this guide, then include python3.8), and click “Create”.
8. It’ll redirect you here if everything goes smoothly:
9. You should see the new layer show up on the Layers page:
10. Go back to the page for your Lambda function, and click on the box that says Layers near the top. A Layers box should show up at the bottom of the page. Click “Add a layer”.
11. Select “Custom layers”, and choose your new layer from the drop down list. It’ll then prompt you for a version; choose the latest. Click “Add”.
Now we can import nacl
as usual.
Ok! Long detour aside, we can now write some code. Once again, here is a demo lambda_function.py that acknowledges pings, verifies the signature. and does a dummy return. Copy and paste the contents into the code editor in your lambda page, and then click the orange “Deploy” button.
Optional: depending on what your bot will be doing, you might need to increase the time-out length, which is by default 3 seconds. To do this, scroll down on the Lambda function page until you find “Basic Settings”, and then edit it:
API Gateway part 2
Now that we have some code, let’s go back and hook it up to API Gateway.
- Returning to the API Gateway page we left open (set up of the POST method on the resource we created), provide the name of your lambda function:
- It’ll prompt you thus; click “OK”. This adds API Gateway as a trigger for the lambda.
- The page will look like this:
This is where we control both the incoming requests that we receive from Discord and the information that we ultimately return back to Discord.
Let’s wrangle the incoming requests first.
- Click “Integration Request”. Expand “Mapping Templates”, then click “Add mapping template”:
- Type
application/json
into the textbox that appears under “Content-Type”, then click the checkmark. It’ll prompt you; click “No, use current settings”. Feel free to come and change this setting to match content-types later; temporarily allowing passthrough makes this part easier to debug later, if anything should go wrong.
- In the large textbox that appears at the bottom, copy and paste the mapping template provided here; this tells API Gateway exactly how to transform the web request that Discord sent us. The transformed request is sent as the
event
field to our lambda function, which can then access it as a dictionary. Click “save”.
- Navigate back to this page:
- Click on “Method Response”. Here, we configure the types of codes we can return back to Discord. Successful runs should return
200
, which is enabled by default; we need to also at a minimum add401
for unauthorized access. This is needed for signature verification to work properly. Click “Add Response”.
- Type
401
into the box that shows up, and then click the checkbox to save:
- Navigate back to this page:
- Click on “Integration Response”. Here, we connect the output/return from the Lambda function to the status codes we set up in “Method Response”. By default, 200 is enabled. Click “Add Integration Response”.
- Configure it like so. This tells API Gateway that whenever the Lambda function returns something with the string
[UNAUTHORIZED]
in it, we should return a401
status code. Click save.
- Finally, we need to deploy the API. Under the “Actions” drop down list, click “Deploy API”:
- In the box that pops up, select “New Stage”:
- Give it whatever name you’d like and click Deploy. It’ll redirect you to this page, where, at the top, you’ll see a URL.
- In this example, the URL is
https://79m220m0r8.execute-api.us-east-2.amazonaws.com/stage_1
. This is the base URL; the endpoint we want is<base URL>/<resource name>
. Here, since my resource was calledevent
, my endpoint ishttps://79m220m0r8.execute-api.us-east-2.amazonaws.com/stage_1/event
. - Since we’ve set up a POST method for this resource, we can now send POST requests to the endpoint
https://79m220m0r8.execute-api.us-east-2.amazonaws.com/stage_1/event
. - Each time changes are made to the API Gateway itself, you need to redeploy it; changes won’t be reflected otherwise.
That’s everything for API Gateway and Lambda! Now we can go back to Discord.
Discord part 2
Application Configuration
- Go back to the Application page, and enter your endpoint URL into the “Interactions Endpoint URL” box at the bottom. Click “Save” when prompted.
- If everything’s gone well, you should see this banner:
- Otherwise, you’ll get an error that looks like this:
- This means that either the PING or the signature verification failed. Logs for your lambda can be found in the “Monitoring” tab on the function page. :( happy debugging~
- Assuming it worked, we can now add slash commands!
Registering Slash Commands
The official documentation for registering a new Slash Command is actually pretty good. If you’ve followed this guide, then use the bot token for header authorization; make sure to actually include the word “Bot” in the string, so that headers = { "Authorization": "Bot <your bot token here>" }
.
Register a guild command according to the docs, where the guild is the test server we created at the beginning.
Now, we can try out our bot in the test server.
- Type and send the slash command you registered (
/blep
if you followed the example)
- You should see your bot do whatever you configured in the Lambda; here (and in the provided github demo), it prints “BEEP BOOP” without showing your original slash command. Notice how the bot isn’t in the channel.
That’s it! To change how it responds to stuff, edit the lambda code.
Summary
We’ve taken a not-so-brief look at how to set up a serverless Discord bot using AWS tools. Was this worth the trouble?
Pros:
- No server maintenance
- Don’t need to write any infra code
- Reliable uptime
- Portable; flexible
- Kinda fun
Cons:
- First-time set up is convoluted (it took me 4 hours to get all of this working the first time without a guide)
- Importing packages in Lambda is a pain
- Subject to AWS limitations
- Maybe Amazon is morally corrupt