-
Notifications
You must be signed in to change notification settings - Fork 184
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add discord bot example #1027
base: main
Are you sure you want to change the base?
Add discord bot example #1027
Changes from 14 commits
b65636c
3aeedda
a242706
5cf8b0c
b4bcfa6
f59b441
086be06
72abe35
5d95275
061c9be
8000bcd
3b27b3b
79b00b9
614cf40
2567016
55ba9f9
24260c1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,230 @@ | ||
# --- | ||
# lambda-test: false | ||
# --- | ||
|
||
# # Serve a Discord Bot on Modal | ||
|
||
# (quick links: [try it out on Discord](https://discord.gg/nR96BxPu)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's a good idea to host an example, but we should take care to:
|
||
|
||
# In this example we will demonstrate how to build a Discord bot that uses | ||
# [slash commands](https://discord.com/developers/docs/interactions/application-commands) | ||
# and serve it from Modal. | ||
|
||
# Slash commands send information from Discord server members to a service at a URL. | ||
# Here, we set up a simple [FastAPI app](https://fastapi.tiangolo.com/) | ||
# to run that service and deploy it easily and serverlessly using Modal’s | ||
# [`@asgi_app`](https://modal.com/docs/guide/webhooks#serving-asgi-and-wsgi-apps) decorator. | ||
|
||
# As our example application, we build a simple weather service. | ||
|
||
# ## Create a Discord app | ||
|
||
# To connect our model to a Discord bot, we’re first going to create an | ||
# application on the Discord Developer Portal. | ||
|
||
# 1. Go to the | ||
# [Discord Developer Portal](https://discord.com/developers/applications) and | ||
# login with your Discord account. | ||
# 2. On the portal, go to **Applications** and create a new application by | ||
# clicking **New Application** in the top right next to your profile picture. | ||
# 3. [Create a custom Modal secret](https://modal.com/docs/guide/secrets) for your Discord bot. | ||
advay-modal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# On Modal's secret creation page, select 'Discord'. Copy your Discord application’s | ||
advay-modal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# **Public Key** (in **General Information**) and paste the value of the public key | ||
# as the value of the `DISCORD_PUBLIC_KEY` environment variable. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is actually pretty weird -- the public key is not secret nor is it really ours. It's owned by Discord and they use it to authenticate with us. It is specific to our app, but it's actually public information. Leaking it is harmless on its own, because Discord is the entity that takes actions using it. An adversary would need to compromise our secrets or Discord's in order to trigger Discord to use the key. Really, we should put the Secrets used to register the slash command into Modal, along with the logic, and then this public key can come along for the ride. See this code I wrote for Full Stack Deep Learning. The whole thing is a lot cleaner than the code in Boombot. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the advantage of having the ability to register a slash command as a modal function? Imo if we do that we'd have to get the user to run this separate function before they run anything else on the app There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
# Name this secret `weather-discord-secret`. | ||
advay-modal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
# ## Register a Slash Command | ||
|
||
# Next, we’re going to register a command for our Discord app via an HTTP | ||
# endpoint. | ||
|
||
# Run the following command in your terminal, replacing the appropriate variable | ||
# inputs. `BOT_TOKEN` can be found by resetting your token in the application’s | ||
# **Bot** section, and `CLIENT_ID` is the **Application ID** available in | ||
# **General Information**. | ||
|
||
|
||
# ``` shell | ||
# BOT_TOKEN='replace_with_bot_token' | ||
# CLIENT_ID='replace_with_client_token' | ||
# curl -X POST \ | ||
# -H 'Content-Type: application/json' \ | ||
# -H "Authorization: Bot $BOT_TOKEN" \ | ||
# -d '{ | ||
# "name":"get_weather", | ||
# "description":"get weather", | ||
# "options":[ | ||
# { | ||
# "name":"city", | ||
# "description":"The city for which you want to get the weather", | ||
# "type":3, | ||
# "required":true | ||
# } | ||
# ] | ||
# }' "https://discord.com/api/v10/applications/$CLIENT_ID/commands" | ||
# ``` | ||
|
||
|
||
# This will register a Slash Command for your bot named `get_weather`, and has a | ||
# parameter called `city`. More information about the | ||
# command structure can be found in the Discord docs | ||
# [here](https://discord.com/developers/docs/interactions/application-commands). | ||
|
||
# ## Defining the asgi app with modal | ||
advay-modal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
# We now create a `POST /get_weather` endpoint using [FastAPI](https://fastapi.tiangolo.com/) and Modal's | ||
# [@asgi_app](https://modal.com/docs/guide/webhooks#serving-asgi-and-wsgi-apps) decorator to handle | ||
# interactions with our Discord app (so that every time a user does a slash | ||
# command, we can respond to it). | ||
|
||
# Let's get the imports out of the way and define an [`App`](https://modal.com/docs/reference/modal.App) | ||
|
||
import json | ||
|
||
import modal | ||
|
||
app = modal.App("example-discord-weather-bot") | ||
|
||
# We define an [Image](https://modal.com/docs/guide/images) that has the [`python-weather`](https://github.com/null8626/python-weather) package and | ||
advay-modal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# the [FastAPI](https://fastapi.tiangolo.com/) package installed. | ||
|
||
image = modal.Image.debian_slim(python_version="3.11").pip_install( | ||
"python-weather==2.0.7", "fastapi[standard]==0.115.4", "pynacl==1.5.0" | ||
) | ||
|
||
# We define a function that uses the `python_weather` library to get the weather of a city | ||
# Note that since Discord requires an interaction response within 3 seconds, we | ||
advay-modal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# use [`spawn`](https://modal.com/docs/reference/modal.Function#spawn) to kick off | ||
# `get_weather_for_city`as a background task from the asgi app while returning a `defer` message to | ||
# Discord within the time limit. | ||
|
||
|
||
@app.function(image=image) | ||
advay-modal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
async def get_weather_forecast_for_city( | ||
city: str, interaction_token, application_id | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function is doing a bit too much -- figuring out the Discord URL to talk to, hitting a different external API, and then submitting the request. That makes it harder to write about and harder to effectively name. |
||
): | ||
import aiohttp | ||
from python_weather import IMPERIAL, Client, Error, RequestError | ||
|
||
interaction_url = f"https://discord.com/api/v10/webhooks/{application_id}/{interaction_token}/messages/@original" | ||
async with Client(unit=IMPERIAL) as client: | ||
try: | ||
weather = await client.get(city) | ||
daily_forecasts = "\n".join( | ||
[ | ||
f"Date: {daily.date}, Highest temperature: {daily.highest_temperature}°F, Lowest Temperature: {daily.lowest_temperature}°F" | ||
for daily in weather | ||
] | ||
) | ||
message = f"The forecast for {weather.location} is as follows:\n{daily_forecasts}" | ||
except RequestError: | ||
message = "An error occurred, issue with connecting to weather api" | ||
except Error: | ||
message = "An error occurred, please check city name" | ||
|
||
json_payload = {"content": message} | ||
async with aiohttp.ClientSession() as session: | ||
async with session.patch(interaction_url, json=json_payload) as resp: | ||
print(await resp.text()) | ||
|
||
|
||
# We now define an asgi app using the Modal ASGI syntax [@asgi_app](https://modal.com/docs/guide/webhooks#serving-asgi-and-wsgi-apps). | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This comment doesn't add much. This is where I would put the information that is beneath the "Defining the asgi app" header above. I would also comment on the Note that the need to keep a container warm to hit Discord's time limits pulls us away from a "true serverless" model. It's also pretty unlikely that someone would need to scale up a Discord bot beyond what a single FastAPI replica can handle, so we should focus on the idea of scaling up the backend and on separation of concerns between the backend service and the API in front of it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "so we should focus on the idea of scaling up the backend and on separation of concerns between the backend service and the API in front of it." -> wdym by focus? do you want me to leave a comment about that or something? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, "focus on" as in "focus the prose in the example on". My point is that the Discord bot hosting itself is not super compelling, especially now that we have a floor for CPU usage. |
||
|
||
|
||
@app.function( | ||
secrets=[modal.Secret.from_name("weather-discord-secret")], | ||
keep_warm=1, # eliminates risk of container startup making discord ack time too long | ||
advay-modal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
image=image, | ||
) | ||
@modal.asgi_app() | ||
def web_app(): | ||
advay-modal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
from fastapi import FastAPI, HTTPException, Request | ||
from fastapi.middleware.cors import CORSMiddleware | ||
|
||
web_app = FastAPI() | ||
|
||
# allow CORS | ||
advay-modal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
web_app.add_middleware( | ||
CORSMiddleware, | ||
allow_origins=["*"], | ||
allow_credentials=True, | ||
allow_methods=["*"], | ||
allow_headers=["*"], | ||
) | ||
|
||
@web_app.post("/get_weather_forecast") | ||
async def get_weather_forecast_api(request: Request): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The names on the functions in this are a bit messy. Could we clean them up? Like this could just be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, we can't shadow it as-is because we later need a handle to call I think we might be able to remove that constraint if we change the composition here -- but it might just end up uglier. In that case, can only get slightly cleaner names. |
||
import os | ||
|
||
from nacl.exceptions import BadSignatureError | ||
from nacl.signing import VerifyKey | ||
|
||
# Verify the request using the Discord public key | ||
advay-modal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
public_key = os.getenv("DISCORD_PUBLIC_KEY") | ||
verify_key = VerifyKey(bytes.fromhex(public_key)) | ||
|
||
signature = request.headers.get("X-Signature-Ed25519") | ||
timestamp = request.headers.get("X-Signature-Timestamp") | ||
body = await request.body() | ||
|
||
message = timestamp.encode() + body | ||
|
||
try: | ||
verify_key.verify(message, bytes.fromhex(signature)) | ||
except BadSignatureError: | ||
raise HTTPException(status_code=401, detail="Invalid request") | ||
|
||
# Parse request | ||
data = json.loads(body.decode()) | ||
if data.get("type") == 1: # ack ping from Discord | ||
advay-modal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return {"type": 1} | ||
|
||
if data.get("type") == 2: # triggered by slash command interaction | ||
options = data["data"]["options"] | ||
for option in options: | ||
name = option["name"] | ||
if name == "city": | ||
city = option["value"] | ||
|
||
app_id = data["application_id"] | ||
interaction_token = data["token"] | ||
|
||
# Kick off request asynchronously, send value when we have it | ||
get_weather_forecast_for_city.spawn(city, interaction_token, app_id) | ||
|
||
# respond immediately with defer message | ||
return {"type": 5} | ||
|
||
raise HTTPException(status_code=400, detail="Bad request") | ||
|
||
return web_app | ||
|
||
|
||
# ## Deploy on Modal | ||
|
||
# You can deploy this app by running the following command: | ||
|
||
# ``` shell | ||
# modal deploy discord_bot.py | ||
# ``` | ||
|
||
# Copy the Modal URL that is printed in the output and go back to your | ||
# application's **General Information** section on the | ||
# [Discord Developer Portal](https://discord.com/developers/applications). Paste | ||
# the URL, making sure to append the path of your `POST` route, in the | ||
# **Interactions Endpoint URL** field, then click **Save Changes**. If your | ||
# endpoint is valid, it will properly save and you can start receiving | ||
# interactions via this web endpoint. | ||
|
||
# ## Finish setting up Discord bot | ||
|
||
# To start using the Slash Command you just set up, you need to invite the bot to | ||
# a Discord server. To do so, go to your application's **OAuth2** section on the | ||
# [Discord Developer Portal](https://discord.com/developers/applications). Select | ||
# `applications.commands` as the scope of your bot and copy the invite URL that is | ||
# generated at the bottom of the page. | ||
|
||
# Paste this URL in your browser, then select your desired server (create a new | ||
# server if needed) and click **Authorize**. Now you can open your Discord server | ||
# and type `/{name of your slash command}` - your bot should be connected and | ||
# ready for you to use! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not test this?
Ideally, we QA this two ways:
deploy: true
so that the example is deployed continuously and include a link to the hosted version so that users can try it and complain to us when it's brokenmodal run
in Lambda testing to check that the example is working (either the full deployed version or some subset of the logic).It's a bit tough to check that the whole example application is working because we are not Discord (the requests have to be signed using Discord's private key and the responses are sent to a URL defined by an interaction token from Discord).
So I would maybe advocate for splitting up the logic here -- the
get_weather_forecast_for_city
function handles talking to the external API AND sending back to Discord. If it just talked to the external API, we could wrap it in another function that handles Discord integration.Decomposing it also provides an opportunity to explain what's going on in the logic -- why do we have these interaction tokens and application IDs? What's with
session.patch
(that's a rare HTTP verb)? This all comes from the Discord docs, to which we should link.