Sunday, February 20, 2022

Serverless Discord Bot using AWS Lambda

A Discord admin friend of mine mentioned how an old Bot he really liked was no longer working, website down, developer out of contact, etc.  I'd always been curious about Discord Bot development, but never had a reason to give it a shot.  

One of my concerns was performance vs. hosting costs.  While poking around on the internet I came across a few articles talking about a serverless approach using AWS Lambda and DynamoDB.  This interested me as I have used a variety of AWS offerings in the past but had not yet learned Lambda or DynamoDB.  

As I got started I found - as with other research I've done - there are a lot of people with bits and pieces of the information you need.  Some push this language, that best practice or this design pattern, but in the end it all comes down to what works, is reasonably secure and has the best performance.  And sometimes things have changed since these articles were written and you have to improvise.

First, let's start with an article by "Helen" from Jan 1, 2021:

Serverless Discord Custom Slash Commands bot with AWS API Gateway and Lambda

She gives you a great breakdown of all the steps.  This was the seed for my interest in this project but as it was entirely written in Python it was a bit off-putting.

Then I found this article by Gerald McAlister which favored node.js, from Mar 23, 2021:

Building a Serverless Discord Bot on AWS

It's a pretty exciting article.  Gerald introduces the concept of one Lambda function calling another Lambda function while asynchronously returning a DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE message to Discord so the user sees a "Bot is thinking" message while the main code of your command processor is executing.  The idea was to try to keep the response time under Discord's arbitrary 3 second limit so the command won't time out.  

I'll refer to the front-end Lambda as the "Interaction" and the back-end Lambda as the "Command" function.  Interaction receives a call FROM Discord when users execute slash commands and Command makes a call TO Discord with the output, referencing a token that was supplied by Discord in the original call.

He also talks about keeping your Discord Bot Token and Public Key in AWS Secrets Manager which sounds like a great idea.  

However, Gerald presents his solution as a Construct written in TypeScript which was a challenge for me to work with.  I just wanted to drop some code in a node.js Lambda function and get right into it.

The next struggle was getting libraries loaded into Lambda Layers.  In order for the Interaction function to receive calls from Discord, it has to be able to verify signatures using your application's Public Key.  This requires some implementation of the nacls, tweetnacls or PyNaCl library in your function.  The libraries must be compiled for the specific runtime of your Lambda function and the directory path of the files in the zip is very finicky.  Have fun with that.  Explaining how I finally got that working could be an entirely separate article.  Never give up, never surrender.

Slash commands are created quickly using API calls.  There is no configuration GUI on the Discord Developer Portal. I found Insomnia worked best.  Yes, there are scripts and programs and frameworks and blah blah that will allegedly create and maintain your slash commands for you, if you want to waste more time.  Start with Guild commands as they update immediately then migrate to Global commands when you have tested all your Slash commands.

Regarding AWS API Gateway, I was able to use an HTTP endpoint not a REST endpoint.  The HTTP option apparently keeps the cost down even more.  The articles wanted REST but I haven't needed it.

Eventually I reached a point where I had a test Discord server with my Bot in it, the slash commands were hitting the Interaction function which was calling the Command function which was talking to DynamoDB just perfectly.  

However, if the bot had not been used for a while the Interaction Lambda "cold start" plus execution time was taking longer than 3 seconds.  

This isn't a big deal as simply re-issuing the command is a workaround, but I didn't like it.

Research on Lambda cold starts led me to this article by Aleksandr Filichkin from Sep 16, 2021:

AWS Lambda battle 2021: performance comparison for all languages (cold and warm start)

(I'm not sure why all these articles are on Medium, I'm sure it's just a coincidence!)

The upshot of this article was that Python had the fastest cold start time at 128MB and Rust was the fastest overall.  I'm putting a pin in learning Rust for later.  I also noticed that my Interaction function was taking around 500ms to retrieve my Public Key from AWS Secrets Manager to verify the signature of incoming calls.  Since I'm validating a lot of everything else in the payload from Discord, and I'm still keeping my Bot Token in Secrets Manager (used by Command function), I thought it wouldn't hurt to hard-code my Public Key into a variable in my Interaction function.

Switching my Interaction function to Python and away from Secrets Manager made a huge difference in performance.  The cold start time is under Discord's arbitrary 3 second limit even after the bot has not been used in a while.  This was the best of both worlds; a fast front-end function while my back-end function was still in node.js, a language I'm more comfortable working in.  Even if the Command function takes more than 3 seconds, since the Interaction function has already returned a type 5 response, I have more time for my node.js Lambda function's cold start and execution time.

I'm having fun learning DynamoDB.  There are some great videos by Rick Houlihan on the Serverless Land YouTube channel.  I've come up with a single table design that is working for now.

My Bot is currently undergoing beta testing and I'll see how it goes, but so far this is looking like a good solution for a Discord Slash Command Bot!

No comments:

Post a Comment