|
| 1 | +# --- |
| 2 | +# deploy: true |
| 3 | +# --- |
| 4 | + |
| 5 | +# # Serve a Discord Bot on Modal |
| 6 | + |
| 7 | +# In this example we will demonstrate how to use Modal to build and serve a Discord bot that uses |
| 8 | +# [slash commands](https://discord.com/developers/docs/interactions/application-commands). |
| 9 | + |
| 10 | +# Slash commands send information from Discord server members to a service at a URL. |
| 11 | +# Here, we set up a simple [FastAPI app](https://fastapi.tiangolo.com/) |
| 12 | +# to run that service and deploy it easily Modal’s |
| 13 | +# [`@asgi_app`](https://modal.com/docs/guide/webhooks#serving-asgi-and-wsgi-apps) decorator. |
| 14 | + |
| 15 | +# As our example service, we hit a simple API: |
| 16 | +# the [Bored API](https://bored-api.appbrewery.com/), |
| 17 | +# which suggests activities to do if you're bored. |
| 18 | + |
| 19 | +# [Try it out on Discord](https://discord.gg/PmG7P47EPQ)! |
| 20 | + |
| 21 | +# ## Set up our App and its Image |
| 22 | + |
| 23 | +# First, we define the [container image](https://modal.com/docs/guide/images) |
| 24 | +# that all the pieces of our bot will run in. |
| 25 | + |
| 26 | +# We set that as the default image for a Modal [App](https://modal.com/docs/guide/apps). |
| 27 | +# The App is where we'll attach all the components of our bot. |
| 28 | + |
| 29 | +import json |
| 30 | +from enum import Enum |
| 31 | + |
| 32 | +import modal |
| 33 | + |
| 34 | +image = modal.Image.debian_slim(python_version="3.11").pip_install( |
| 35 | + "fastapi[standard]==0.115.4", "pynacl~=1.5.0", "requests~=2.32.3" |
| 36 | +) |
| 37 | + |
| 38 | +app = modal.App("example-discord-bot", image=image) |
| 39 | + |
| 40 | +# ## Hit the Bored API |
| 41 | + |
| 42 | +# We start by defining the core service that our bot will provide. |
| 43 | + |
| 44 | +# In a real application, this might be [music generation](https://modal.com/docs/examples/musicgen), |
| 45 | +# a [chatbot](https://modal.com/docs/examples/chat_with_pdf_vision), |
| 46 | +# or [interactiving with a database](https://modal.com/docs/examples/covid_datasette). |
| 47 | + |
| 48 | +# Here, we just hit a simple API: the [Bored API](https://bored-api.appbrewery.com/), |
| 49 | +# which suggests activities to help you pass the time, |
| 50 | +# like "play a harmless prank on your friends" or "learn Express.js". |
| 51 | +# We convert this suggestion into a Markdown-formatted message. |
| 52 | + |
| 53 | +# We turn our Python function into a Modal Function by attaching the `app.function` decorator. |
| 54 | +# We make the function `async` and set `allow_concurrent_inputs` to a large value because |
| 55 | +# communicating with an external API is a classic case for better performance from asynchronous execution. |
| 56 | +# Modal handles things like the async event loop for us. |
| 57 | + |
| 58 | + |
| 59 | +@app.function(allow_concurrent_inputs=1000) |
| 60 | +async def fetch_activity() -> str: |
| 61 | + import aiohttp |
| 62 | + |
| 63 | + url = "https://bored-api.appbrewery.com/random" |
| 64 | + |
| 65 | + async with aiohttp.ClientSession() as session: |
| 66 | + try: |
| 67 | + async with session.get(url) as response: |
| 68 | + response.raise_for_status() |
| 69 | + data = await response.json() |
| 70 | + message = f"# 🤖: Bored? You should _{data['activity']}_." |
| 71 | + except Exception as e: |
| 72 | + message = f"# 🤖: Oops! {e}" |
| 73 | + |
| 74 | + return message |
| 75 | + |
| 76 | + |
| 77 | +# This core component has nothing to do with Discord, |
| 78 | +# and it's nice to be able to interact with and test it in isolation. |
| 79 | + |
| 80 | +# For that, we add a `local_entrypoint` that calls the Modal Function. |
| 81 | +# Notice that we add `.remote` to the function's name. |
| 82 | + |
| 83 | +# Later, when you replace this component of the app with something more interesting, |
| 84 | +# test it by triggering this entrypoint with `modal run discord_bot.py`. |
| 85 | + |
| 86 | + |
| 87 | +@app.local_entrypoint() |
| 88 | +def test_fetch_activity(): |
| 89 | + result = fetch_activity.remote() |
| 90 | + if "Oops!" in result: |
| 91 | + raise Exception(result) |
| 92 | + else: |
| 93 | + print(result) |
| 94 | + |
| 95 | + |
| 96 | +# ## Integrate our Modal Function with Discord Interactions |
| 97 | + |
| 98 | +# Now we need to map this function onto Discord's interface -- |
| 99 | +# in particular the [Interactions API](https://discord.com/developers/docs/interactions/overview). |
| 100 | + |
| 101 | +# Reviewing the documentation, we see that we need to send a JSON payload |
| 102 | +# to a specific API URL that will include an `app_id` that identifies our bot |
| 103 | +# and a `token` that identifies the interaction (loosely, message) that we're participating in. |
| 104 | + |
| 105 | +# So let's write that out. This function doesn't need to live on Modal, |
| 106 | +# since it's just encapsulating some logic -- we don't want to turn it into a service or an API on its own. |
| 107 | +# That means we don't need any Modal decorators. |
| 108 | + |
| 109 | + |
| 110 | +async def send_to_discord(payload: dict, app_id: str, interaction_token: str): |
| 111 | + import aiohttp |
| 112 | + |
| 113 | + interaction_url = f"https://discord.com/api/v10/webhooks/{app_id}/{interaction_token}/messages/@original" |
| 114 | + |
| 115 | + async with aiohttp.ClientSession() as session: |
| 116 | + async with session.patch(interaction_url, json=payload) as resp: |
| 117 | + print("🤖 Discord response: " + await resp.text()) |
| 118 | + |
| 119 | + |
| 120 | +# Other parts of our application might want to both hit the BoredAPI and send the result to Discord, |
| 121 | +# so we both write a Python function for this and we promote it to a Modal Function with a decorator. |
| 122 | + |
| 123 | +# Notice that we use the `.local` suffix to call our `fetch_activity` Function. That means we run |
| 124 | +# the Function the same way we run all the other Python functions, rather than treating it as a special |
| 125 | +# Modal Function. This reduces a bit of extra latency, but couples these two Functions more tightly. |
| 126 | + |
| 127 | + |
| 128 | +@app.function(allow_concurrent_inputs=1000) |
| 129 | +async def reply(app_id: str, interaction_token: str): |
| 130 | + message = await fetch_activity.local() |
| 131 | + await send_to_discord({"content": message}, app_id, interaction_token) |
| 132 | + |
| 133 | + |
| 134 | +# ## Set up a Discord app |
| 135 | + |
| 136 | +# Now, we need to actually connect to Discord. |
| 137 | +# We start by creating an application on the Discord Developer Portal. |
| 138 | + |
| 139 | +# 1. Go to the |
| 140 | +# [Discord Developer Portal](https://discord.com/developers/applications) and |
| 141 | +# log in with your Discord account. |
| 142 | +# 2. On the portal, go to **Applications** and create a new application by |
| 143 | +# clicking **New Application** in the top right next to your profile picture. |
| 144 | +# 3. [Create a custom Modal Secret](https://modal.com/docs/guide/secrets) for your Discord bot. |
| 145 | +# On Modal's Secret creation page, select 'Discord'. Copy your Discord application’s |
| 146 | +# **Public Key** and **Application ID** (from the **General Information** tab in the Discord Developer Portal) |
| 147 | +# and paste them as the value of `DISCORD_PUBLIC_KEY` and `DISCORD_CLIENT_ID`. |
| 148 | +# Additionally, head to the **Bot** tab and use the **Reset Token** button to create a new bot token. |
| 149 | +# Paste this in the value of an additional key in the Secret, `DISCORD_BOT_TOKEN`. |
| 150 | +# Name this Secret `discord-secret`. |
| 151 | + |
| 152 | +# We access that Secret in code like so: |
| 153 | + |
| 154 | +discord_secret = modal.Secret.from_name( |
| 155 | + "discord-secret", |
| 156 | + required_keys=[ # included so we get nice error messages if we forgot a key |
| 157 | + "DISCORD_BOT_TOKEN", |
| 158 | + "DISCORD_CLIENT_ID", |
| 159 | + "DISCORD_PUBLIC_KEY", |
| 160 | + ], |
| 161 | +) |
| 162 | + |
| 163 | +# ## Register a Slash Command |
| 164 | + |
| 165 | +# Next, we’re going to register a [Slash Command](https://discord.com/developers/docs/interactions/application-commands#slash-commands) |
| 166 | +# for our Discord app. Slash Commands are triggered by users in servers typing `/` and the name of the command. |
| 167 | + |
| 168 | +# The Modal Function below will register a Slash Command for your bot named `bored`. |
| 169 | +# More information about Slash Commands can be found in the Discord docs |
| 170 | +# [here](https://discord.com/developers/docs/interactions/application-commands). |
| 171 | + |
| 172 | +# You can run this Function with |
| 173 | + |
| 174 | +# ```bash |
| 175 | +# modal run discord_bot::create_slash_command |
| 176 | +# ``` |
| 177 | + |
| 178 | + |
| 179 | +@app.function(secrets=[discord_secret], image=image) |
| 180 | +def create_slash_command(force: bool = False): |
| 181 | + """Registers the slash command with Discord. Pass the force flag to re-register.""" |
| 182 | + import os |
| 183 | + |
| 184 | + import requests |
| 185 | + |
| 186 | + BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN") |
| 187 | + CLIENT_ID = os.getenv("DISCORD_CLIENT_ID") |
| 188 | + |
| 189 | + headers = { |
| 190 | + "Content-Type": "application/json", |
| 191 | + "Authorization": f"Bot {BOT_TOKEN}", |
| 192 | + } |
| 193 | + url = f"https://discord.com/api/v10/applications/{CLIENT_ID}/commands" |
| 194 | + |
| 195 | + command_description = { |
| 196 | + "name": "bored", |
| 197 | + "description": "Run this command when you are bored and we'll tell you what to do", |
| 198 | + } |
| 199 | + |
| 200 | + # first, check if the command already exists |
| 201 | + response = requests.get(url, headers=headers) |
| 202 | + try: |
| 203 | + response.raise_for_status() |
| 204 | + except Exception as e: |
| 205 | + raise Exception("Failed to create slash command") from e |
| 206 | + |
| 207 | + commands = response.json() |
| 208 | + command_exists = any( |
| 209 | + command.get("name") == command_description["name"] |
| 210 | + for command in commands |
| 211 | + ) |
| 212 | + |
| 213 | + # and only recreate it if the force flag is set |
| 214 | + if command_exists and not force: |
| 215 | + print(f"🤖: command {command_description['name']} exists") |
| 216 | + return |
| 217 | + |
| 218 | + response = requests.post(url, headers=headers, json=command_description) |
| 219 | + try: |
| 220 | + response.raise_for_status() |
| 221 | + except Exception as e: |
| 222 | + raise Exception("Failed to create slash command") from e |
| 223 | + print(f"🤖: command {command_description['name']} created") |
| 224 | + |
| 225 | + |
| 226 | +# ## Host a Discord Interactions endpoint on Modal |
| 227 | + |
| 228 | +# If you look carefully at the definition of the Slash Command above, |
| 229 | +# you'll notice that it doesn't know anything about our bot besides an ID. |
| 230 | + |
| 231 | +# To hook the Slash Commands in the Discord UI up to our logic for hitting the Bored API, |
| 232 | +# we need to set up a service that listens at some URL and follows a specific protocol, |
| 233 | +# described [here](https://discord.com/developers/docs/interactions/overview#configuring-an-interactions-endpoint-url). |
| 234 | + |
| 235 | +# Here are some of the most important facets: |
| 236 | + |
| 237 | +# 1. We'll need to respond within five seconds or Discord will assume we are dead. |
| 238 | +# Modal's fast-booting serverless containers usually start faster than that, |
| 239 | +# but it's not guaranteed. So we'll add the `keep_warm` parameter to our |
| 240 | +# Function so that there's at least one live copy ready to respond quickly at any time. |
| 241 | +# Modal charges a minimum of about 2¢ an hour for live containers (pricing details [here](https://modal.com/pricing)). |
| 242 | +# Note that that still fits within Modal's $30/month of credits on the free tier. |
| 243 | + |
| 244 | +# 2. We have to respond to Discord that quickly, but we don't have to respond to the user that quickly. |
| 245 | +# We instead send an acknowledgement so that they know we're alive and they can close their connection to us. |
| 246 | +# We also trigger our `reply` Modal Function, which will respond to the user via Discord's Interactions API, |
| 247 | +# but we don't wait for the result, we just `spawn` the call. |
| 248 | + |
| 249 | +# 3. The protocol includes some authentication logic that is mandatory |
| 250 | +# and checked by Discord. We'll explain in more detail in the next section. |
| 251 | + |
| 252 | +# We can set up our interaction endpoint by deploying a FastAPI app on Modal. |
| 253 | +# This is as easy as creating a Python Function that returns a FastAPI app |
| 254 | +# and adding the `modal.asgi_app` decorator. |
| 255 | +# For more details on serving Python web apps on Modal, see |
| 256 | +# [this guide](https://modal.com/docs/guide/webhooks). |
| 257 | + |
| 258 | + |
| 259 | +@app.function( |
| 260 | + secrets=[discord_secret], keep_warm=1, allow_concurrent_inputs=1000 |
| 261 | +) |
| 262 | +@modal.asgi_app() |
| 263 | +def web_app(): |
| 264 | + from fastapi import FastAPI, HTTPException, Request |
| 265 | + from fastapi.middleware.cors import CORSMiddleware |
| 266 | + |
| 267 | + web_app = FastAPI() |
| 268 | + |
| 269 | + # must allow requests from other domains, e.g. from Discord's servers |
| 270 | + web_app.add_middleware( |
| 271 | + CORSMiddleware, |
| 272 | + allow_origins=["*"], |
| 273 | + allow_credentials=True, |
| 274 | + allow_methods=["*"], |
| 275 | + allow_headers=["*"], |
| 276 | + ) |
| 277 | + |
| 278 | + @web_app.post("/bored") |
| 279 | + async def get_bored_api(request: Request): |
| 280 | + body = await request.body() |
| 281 | + |
| 282 | + # confirm this is a request from Discord |
| 283 | + authenticate(request.headers, body) |
| 284 | + |
| 285 | + print("🤖: parsing request") |
| 286 | + data = json.loads(body.decode()) |
| 287 | + if data.get("type") == DiscordInteractionType.PING.value: |
| 288 | + print("🤖: acking PING from Discord during auth check") |
| 289 | + return {"type": DiscordResponseType.PONG.value} |
| 290 | + |
| 291 | + if data.get("type") == DiscordInteractionType.APPLICATION_COMMAND.value: |
| 292 | + print("🤖: handling slash command") |
| 293 | + app_id = data["application_id"] |
| 294 | + interaction_token = data["token"] |
| 295 | + |
| 296 | + # kick off request asynchronously, will respond when ready |
| 297 | + reply.spawn(app_id, interaction_token) |
| 298 | + |
| 299 | + # respond immediately with defer message |
| 300 | + return { |
| 301 | + "type": DiscordResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE.value |
| 302 | + } |
| 303 | + |
| 304 | + print(f"🤖: unable to parse request with type {data.get('type')}") |
| 305 | + raise HTTPException(status_code=400, detail="Bad request") |
| 306 | + |
| 307 | + return web_app |
| 308 | + |
| 309 | + |
| 310 | +# The authentication for Discord is a bit involved and there aren't, |
| 311 | +# to our knowledge, any good Python libraries for it. |
| 312 | + |
| 313 | +# So we have to implement the protocol "by hand". |
| 314 | + |
| 315 | +# Essentially, Discord sends a header in their request |
| 316 | +# that we can use to verify the request comes from them. |
| 317 | +# For that, we use the `DISCORD_PUBLIC_KEY` from |
| 318 | +# our Application Information page. |
| 319 | + |
| 320 | +# The details aren't super important, but they appear in the `authenticate` function below |
| 321 | +# (which defers the real cryptography work to [PyNaCl](https://pypi.org/project/PyNaCl/), |
| 322 | +# a Python wrapper for [`libsodium`](https://github.com/jedisct1/libsodium)). |
| 323 | + |
| 324 | +# Discord will also check that we reject unauthorized requests, |
| 325 | +# so we have to be sure to get this right! |
| 326 | + |
| 327 | + |
| 328 | +def authenticate(headers, body): |
| 329 | + import os |
| 330 | + |
| 331 | + from fastapi.exceptions import HTTPException |
| 332 | + from nacl.exceptions import BadSignatureError |
| 333 | + from nacl.signing import VerifyKey |
| 334 | + |
| 335 | + print("🤖: authenticating request") |
| 336 | + # verify the request is from Discord using their public key |
| 337 | + public_key = os.getenv("DISCORD_PUBLIC_KEY") |
| 338 | + verify_key = VerifyKey(bytes.fromhex(public_key)) |
| 339 | + |
| 340 | + signature = headers.get("X-Signature-Ed25519") |
| 341 | + timestamp = headers.get("X-Signature-Timestamp") |
| 342 | + |
| 343 | + message = timestamp.encode() + body |
| 344 | + |
| 345 | + try: |
| 346 | + verify_key.verify(message, bytes.fromhex(signature)) |
| 347 | + except BadSignatureError: |
| 348 | + # either an unauthorized request or Discord's "negative control" check |
| 349 | + raise HTTPException(status_code=401, detail="Invalid request") |
| 350 | + |
| 351 | + |
| 352 | +# The code above used a few enums to abstract bits of the Discord protocol. |
| 353 | +# Now that we've walked through all of it, |
| 354 | +# we're in a position to understand what those are |
| 355 | +# and so the code for them appears below. |
| 356 | + |
| 357 | + |
| 358 | +class DiscordInteractionType(Enum): |
| 359 | + PING = 1 # hello from Discord during auth check |
| 360 | + APPLICATION_COMMAND = 2 # an actual command |
| 361 | + |
| 362 | + |
| 363 | +class DiscordResponseType(Enum): |
| 364 | + PONG = 1 # hello back during auth check |
| 365 | + DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5 # we'll send a message later |
| 366 | + |
| 367 | + |
| 368 | +# ## Deploy on Modal |
| 369 | + |
| 370 | +# You can deploy this app on Modal by running the following commands: |
| 371 | + |
| 372 | +# ``` shell |
| 373 | +# modal run discord_bot.py # checks the BoredAPI wrapper, little test |
| 374 | +# modal run discord_bot.py::create_slash_command # creates the slash command, if missing |
| 375 | +# modal deploy discord_bot.py # deploys the web app and the BoredAPI wrapper |
| 376 | +# ``` |
| 377 | + |
| 378 | +# Copy the Modal URL that is printed in the output and go back to the **General Information** section on the |
| 379 | +# [Discord Developer Portal](https://discord.com/developers/applications). |
| 380 | +# Paste the URL, making sure to append the path of your `POST` route (`/bored`), in the |
| 381 | +# **Interactions Endpoint URL** field, then click **Save Changes**. If your |
| 382 | +# endpoint URL is incorrect or if authentication is incorrectly implemented, |
| 383 | +# Discord will refuse to save the URL. Once it saves, you can start |
| 384 | +# handling interactions! |
| 385 | + |
| 386 | +# ## Finish setting up Discord bot |
| 387 | + |
| 388 | +# To start using the Slash Command you just set up, you need to invite the bot to |
| 389 | +# a Discord server. To do so, go to your application's **Installation** section on the |
| 390 | +# [Discord Developer Portal](https://discord.com/developers/applications). |
| 391 | +# Copy the **Discored Provided Link** and visit it to invite the bot to your bot to the server. |
| 392 | + |
| 393 | +# Now you can open your Discord server and type `/bored` in a channel to trigger the bot. |
| 394 | +# You can see a working version [in our test Discord server](https://discord.gg/PmG7P47EPQ). |
0 commit comments