-
Notifications
You must be signed in to change notification settings - Fork 208
Add discord bot example #1027
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
Merged
Merged
Add discord bot example #1027
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
b65636c
Add discord bot example
advay-modal 3aeedda
Example checklist changes
advay-modal a242706
Ruff fixes
advay-modal 5cf8b0c
Ruff fix
advay-modal b4bcfa6
remove trailing space
advay-modal f59b441
Add back newline
advay-modal 086be06
Ruff
advay-modal 72abe35
shell
advay-modal 5d95275
Some more changes
advay-modal 061c9be
Update discord_bot.py
advay-modal 8000bcd
Update discord_bot.py
advay-modal 3b27b3b
Move to web endpoints folder
advay-modal 79b00b9
Update discord_bot.py
advay-modal 614cf40
minor adjustments
charlesfrye 2567016
Update 07_web_endpoints/discord_bot.py
advay-modal 55ba9f9
Update 07_web_endpoints/discord_bot.py
advay-modal 24260c1
Update 07_web_endpoints/discord_bot.py
advay-modal 4b0973f
rewrite as BoredAPI, reorganize, make testable
charlesfrye File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,394 @@ | ||
# --- | ||
# deploy: true | ||
# --- | ||
|
||
# # Serve a Discord Bot on Modal | ||
|
||
# In this example we will demonstrate how to use Modal to build and serve a Discord bot that uses | ||
# [slash commands](https://discord.com/developers/docs/interactions/application-commands). | ||
|
||
# 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 Modal’s | ||
# [`@asgi_app`](https://modal.com/docs/guide/webhooks#serving-asgi-and-wsgi-apps) decorator. | ||
|
||
# As our example service, we hit a simple API: | ||
# the [Bored API](https://bored-api.appbrewery.com/), | ||
# which suggests activities to do if you're bored. | ||
|
||
# [Try it out on Discord](https://discord.gg/PmG7P47EPQ)! | ||
|
||
# ## Set up our App and its Image | ||
|
||
# First, we define the [container image](https://modal.com/docs/guide/images) | ||
# that all the pieces of our bot will run in. | ||
|
||
# We set that as the default image for a Modal [App](https://modal.com/docs/guide/apps). | ||
# The App is where we'll attach all the components of our bot. | ||
|
||
import json | ||
from enum import Enum | ||
|
||
import modal | ||
|
||
image = modal.Image.debian_slim(python_version="3.11").pip_install( | ||
"fastapi[standard]==0.115.4", "pynacl~=1.5.0", "requests~=2.32.3" | ||
) | ||
|
||
app = modal.App("example-discord-bot", image=image) | ||
|
||
# ## Hit the Bored API | ||
|
||
# We start by defining the core service that our bot will provide. | ||
|
||
# In a real application, this might be [music generation](https://modal.com/docs/examples/musicgen), | ||
# a [chatbot](https://modal.com/docs/examples/chat_with_pdf_vision), | ||
# or [interactiving with a database](https://modal.com/docs/examples/covid_datasette). | ||
|
||
# Here, we just hit a simple API: the [Bored API](https://bored-api.appbrewery.com/), | ||
# which suggests activities to help you pass the time, | ||
# like "play a harmless prank on your friends" or "learn Express.js". | ||
# We convert this suggestion into a Markdown-formatted message. | ||
|
||
# We turn our Python function into a Modal Function by attaching the `app.function` decorator. | ||
# We make the function `async` and set `allow_concurrent_inputs` to a large value because | ||
# communicating with an external API is a classic case for better performance from asynchronous execution. | ||
# Modal handles things like the async event loop for us. | ||
|
||
|
||
@app.function(allow_concurrent_inputs=1000) | ||
async def fetch_activity() -> str: | ||
import aiohttp | ||
|
||
url = "https://bored-api.appbrewery.com/random" | ||
|
||
async with aiohttp.ClientSession() as session: | ||
try: | ||
async with session.get(url) as response: | ||
response.raise_for_status() | ||
data = await response.json() | ||
message = f"# 🤖: Bored? You should _{data['activity']}_." | ||
except Exception as e: | ||
message = f"# 🤖: Oops! {e}" | ||
|
||
return message | ||
|
||
|
||
# This core component has nothing to do with Discord, | ||
# and it's nice to be able to interact with and test it in isolation. | ||
|
||
# For that, we add a `local_entrypoint` that calls the Modal Function. | ||
# Notice that we add `.remote` to the function's name. | ||
|
||
# Later, when you replace this component of the app with something more interesting, | ||
# test it by triggering this entrypoint with `modal run discord_bot.py`. | ||
|
||
|
||
@app.local_entrypoint() | ||
def test_fetch_activity(): | ||
result = fetch_activity.remote() | ||
if "Oops!" in result: | ||
raise Exception(result) | ||
else: | ||
print(result) | ||
|
||
|
||
# ## Integrate our Modal Function with Discord Interactions | ||
|
||
# Now we need to map this function onto Discord's interface -- | ||
# in particular the [Interactions API](https://discord.com/developers/docs/interactions/overview). | ||
|
||
# Reviewing the documentation, we see that we need to send a JSON payload | ||
# to a specific API URL that will include an `app_id` that identifies our bot | ||
# and a `token` that identifies the interaction (loosely, message) that we're participating in. | ||
|
||
# So let's write that out. This function doesn't need to live on Modal, | ||
# since it's just encapsulating some logic -- we don't want to turn it into a service or an API on its own. | ||
# That means we don't need any Modal decorators. | ||
|
||
|
||
async def send_to_discord(payload: dict, app_id: str, interaction_token: str): | ||
import aiohttp | ||
|
||
interaction_url = f"https://discord.com/api/v10/webhooks/{app_id}/{interaction_token}/messages/@original" | ||
|
||
async with aiohttp.ClientSession() as session: | ||
async with session.patch(interaction_url, json=payload) as resp: | ||
print("🤖 Discord response: " + await resp.text()) | ||
|
||
|
||
# Other parts of our application might want to both hit the BoredAPI and send the result to Discord, | ||
# so we both write a Python function for this and we promote it to a Modal Function with a decorator. | ||
|
||
# Notice that we use the `.local` suffix to call our `fetch_activity` Function. That means we run | ||
# the Function the same way we run all the other Python functions, rather than treating it as a special | ||
# Modal Function. This reduces a bit of extra latency, but couples these two Functions more tightly. | ||
|
||
|
||
@app.function(allow_concurrent_inputs=1000) | ||
async def reply(app_id: str, interaction_token: str): | ||
message = await fetch_activity.local() | ||
await send_to_discord({"content": message}, app_id, interaction_token) | ||
|
||
|
||
# ## Set up a Discord app | ||
|
||
# Now, we need to actually connect to Discord. | ||
# We start by creating an application on the Discord Developer Portal. | ||
|
||
# 1. Go to the | ||
# [Discord Developer Portal](https://discord.com/developers/applications) and | ||
# log in 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. | ||
# On Modal's Secret creation page, select 'Discord'. Copy your Discord application’s | ||
# **Public Key** and **Application ID** (from the **General Information** tab in the Discord Developer Portal) | ||
# and paste them as the value of `DISCORD_PUBLIC_KEY` and `DISCORD_CLIENT_ID`. | ||
# Additionally, head to the **Bot** tab and use the **Reset Token** button to create a new bot token. | ||
# Paste this in the value of an additional key in the Secret, `DISCORD_BOT_TOKEN`. | ||
# Name this Secret `discord-secret`. | ||
|
||
# We access that Secret in code like so: | ||
|
||
discord_secret = modal.Secret.from_name( | ||
"discord-secret", | ||
required_keys=[ # included so we get nice error messages if we forgot a key | ||
"DISCORD_BOT_TOKEN", | ||
"DISCORD_CLIENT_ID", | ||
"DISCORD_PUBLIC_KEY", | ||
], | ||
) | ||
|
||
# ## Register a Slash Command | ||
|
||
# Next, we’re going to register a [Slash Command](https://discord.com/developers/docs/interactions/application-commands#slash-commands) | ||
# for our Discord app. Slash Commands are triggered by users in servers typing `/` and the name of the command. | ||
|
||
# The Modal Function below will register a Slash Command for your bot named `bored`. | ||
# More information about Slash Commands can be found in the Discord docs | ||
# [here](https://discord.com/developers/docs/interactions/application-commands). | ||
|
||
# You can run this Function with | ||
|
||
# ```bash | ||
# modal run discord_bot::create_slash_command | ||
# ``` | ||
|
||
|
||
@app.function(secrets=[discord_secret], image=image) | ||
def create_slash_command(force: bool = False): | ||
"""Registers the slash command with Discord. Pass the force flag to re-register.""" | ||
import os | ||
|
||
import requests | ||
|
||
BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN") | ||
CLIENT_ID = os.getenv("DISCORD_CLIENT_ID") | ||
|
||
headers = { | ||
"Content-Type": "application/json", | ||
"Authorization": f"Bot {BOT_TOKEN}", | ||
} | ||
url = f"https://discord.com/api/v10/applications/{CLIENT_ID}/commands" | ||
|
||
command_description = { | ||
"name": "bored", | ||
"description": "Run this command when you are bored and we'll tell you what to do", | ||
} | ||
|
||
# first, check if the command already exists | ||
response = requests.get(url, headers=headers) | ||
try: | ||
response.raise_for_status() | ||
except Exception as e: | ||
raise Exception("Failed to create slash command") from e | ||
|
||
commands = response.json() | ||
command_exists = any( | ||
command.get("name") == command_description["name"] | ||
for command in commands | ||
) | ||
|
||
# and only recreate it if the force flag is set | ||
if command_exists and not force: | ||
print(f"🤖: command {command_description['name']} exists") | ||
return | ||
|
||
response = requests.post(url, headers=headers, json=command_description) | ||
try: | ||
response.raise_for_status() | ||
except Exception as e: | ||
raise Exception("Failed to create slash command") from e | ||
print(f"🤖: command {command_description['name']} created") | ||
|
||
|
||
# ## Host a Discord Interactions endpoint on Modal | ||
|
||
# If you look carefully at the definition of the Slash Command above, | ||
# you'll notice that it doesn't know anything about our bot besides an ID. | ||
|
||
# To hook the Slash Commands in the Discord UI up to our logic for hitting the Bored API, | ||
# we need to set up a service that listens at some URL and follows a specific protocol, | ||
# described [here](https://discord.com/developers/docs/interactions/overview#configuring-an-interactions-endpoint-url). | ||
|
||
# Here are some of the most important facets: | ||
|
||
# 1. We'll need to respond within five seconds or Discord will assume we are dead. | ||
# Modal's fast-booting serverless containers usually start faster than that, | ||
# but it's not guaranteed. So we'll add the `keep_warm` parameter to our | ||
# Function so that there's at least one live copy ready to respond quickly at any time. | ||
# Modal charges a minimum of about 2¢ an hour for live containers (pricing details [here](https://modal.com/pricing)). | ||
# Note that that still fits within Modal's $30/month of credits on the free tier. | ||
|
||
# 2. We have to respond to Discord that quickly, but we don't have to respond to the user that quickly. | ||
# We instead send an acknowledgement so that they know we're alive and they can close their connection to us. | ||
# We also trigger our `reply` Modal Function, which will respond to the user via Discord's Interactions API, | ||
# but we don't wait for the result, we just `spawn` the call. | ||
|
||
# 3. The protocol includes some authentication logic that is mandatory | ||
# and checked by Discord. We'll explain in more detail in the next section. | ||
|
||
# We can set up our interaction endpoint by deploying a FastAPI app on Modal. | ||
# This is as easy as creating a Python Function that returns a FastAPI app | ||
# and adding the `modal.asgi_app` decorator. | ||
# For more details on serving Python web apps on Modal, see | ||
# [this guide](https://modal.com/docs/guide/webhooks). | ||
|
||
|
||
@app.function( | ||
secrets=[discord_secret], keep_warm=1, allow_concurrent_inputs=1000 | ||
) | ||
@modal.asgi_app() | ||
def web_app(): | ||
from fastapi import FastAPI, HTTPException, Request | ||
from fastapi.middleware.cors import CORSMiddleware | ||
|
||
web_app = FastAPI() | ||
|
||
# must allow requests from other domains, e.g. from Discord's servers | ||
web_app.add_middleware( | ||
CORSMiddleware, | ||
allow_origins=["*"], | ||
allow_credentials=True, | ||
allow_methods=["*"], | ||
allow_headers=["*"], | ||
) | ||
|
||
@web_app.post("/bored") | ||
async def get_bored_api(request: Request): | ||
body = await request.body() | ||
|
||
# confirm this is a request from Discord | ||
authenticate(request.headers, body) | ||
|
||
print("🤖: parsing request") | ||
data = json.loads(body.decode()) | ||
if data.get("type") == DiscordInteractionType.PING.value: | ||
print("🤖: acking PING from Discord during auth check") | ||
return {"type": DiscordResponseType.PONG.value} | ||
|
||
if data.get("type") == DiscordInteractionType.APPLICATION_COMMAND.value: | ||
print("🤖: handling slash command") | ||
app_id = data["application_id"] | ||
interaction_token = data["token"] | ||
|
||
# kick off request asynchronously, will respond when ready | ||
reply.spawn(app_id, interaction_token) | ||
|
||
# respond immediately with defer message | ||
return { | ||
"type": DiscordResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE.value | ||
} | ||
|
||
print(f"🤖: unable to parse request with type {data.get('type')}") | ||
raise HTTPException(status_code=400, detail="Bad request") | ||
|
||
return web_app | ||
|
||
|
||
# The authentication for Discord is a bit involved and there aren't, | ||
# to our knowledge, any good Python libraries for it. | ||
|
||
# So we have to implement the protocol "by hand". | ||
|
||
# Essentially, Discord sends a header in their request | ||
# that we can use to verify the request comes from them. | ||
# For that, we use the `DISCORD_PUBLIC_KEY` from | ||
# our Application Information page. | ||
|
||
# The details aren't super important, but they appear in the `authenticate` function below | ||
# (which defers the real cryptography work to [PyNaCl](https://pypi.org/project/PyNaCl/), | ||
# a Python wrapper for [`libsodium`](https://github.com/jedisct1/libsodium)). | ||
|
||
# Discord will also check that we reject unauthorized requests, | ||
# so we have to be sure to get this right! | ||
|
||
|
||
def authenticate(headers, body): | ||
import os | ||
|
||
from fastapi.exceptions import HTTPException | ||
from nacl.exceptions import BadSignatureError | ||
from nacl.signing import VerifyKey | ||
|
||
print("🤖: authenticating request") | ||
# verify the request is from Discord using their public key | ||
public_key = os.getenv("DISCORD_PUBLIC_KEY") | ||
verify_key = VerifyKey(bytes.fromhex(public_key)) | ||
|
||
signature = headers.get("X-Signature-Ed25519") | ||
timestamp = headers.get("X-Signature-Timestamp") | ||
|
||
message = timestamp.encode() + body | ||
|
||
try: | ||
verify_key.verify(message, bytes.fromhex(signature)) | ||
except BadSignatureError: | ||
# either an unauthorized request or Discord's "negative control" check | ||
raise HTTPException(status_code=401, detail="Invalid request") | ||
|
||
|
||
# The code above used a few enums to abstract bits of the Discord protocol. | ||
# Now that we've walked through all of it, | ||
# we're in a position to understand what those are | ||
# and so the code for them appears below. | ||
|
||
|
||
class DiscordInteractionType(Enum): | ||
PING = 1 # hello from Discord during auth check | ||
APPLICATION_COMMAND = 2 # an actual command | ||
|
||
|
||
class DiscordResponseType(Enum): | ||
PONG = 1 # hello back during auth check | ||
DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5 # we'll send a message later | ||
|
||
|
||
# ## Deploy on Modal | ||
|
||
# You can deploy this app on Modal by running the following commands: | ||
|
||
# ``` shell | ||
# modal run discord_bot.py # checks the BoredAPI wrapper, little test | ||
# modal run discord_bot.py::create_slash_command # creates the slash command, if missing | ||
# modal deploy discord_bot.py # deploys the web app and the BoredAPI wrapper | ||
# ``` | ||
|
||
# Copy the Modal URL that is printed in the output and go back to the **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 (`/bored`), in the | ||
# **Interactions Endpoint URL** field, then click **Save Changes**. If your | ||
# endpoint URL is incorrect or if authentication is incorrectly implemented, | ||
# Discord will refuse to save the URL. Once it saves, you can start | ||
# handling interactions! | ||
|
||
# ## 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 **Installation** section on the | ||
# [Discord Developer Portal](https://discord.com/developers/applications). | ||
# Copy the **Discored Provided Link** and visit it to invite the bot to your bot to the server. | ||
|
||
# Now you can open your Discord server and type `/bored` in a channel to trigger the bot. | ||
# You can see a working version [in our test Discord server](https://discord.gg/PmG7P47EPQ). |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.