Skip to content

Commit 3be6b34

Browse files
Add discord bot example (#1027)
* Add discord bot example * Example checklist changes * Ruff fixes * Ruff fix * remove trailing space * Add back newline * Ruff * shell * Some more changes * Update discord_bot.py * Update discord_bot.py * Move to web endpoints folder * Update discord_bot.py * minor adjustments * Update 07_web_endpoints/discord_bot.py Co-authored-by: Charles Frye <[email protected]> * Update 07_web_endpoints/discord_bot.py Co-authored-by: Charles Frye <[email protected]> * Update 07_web_endpoints/discord_bot.py Co-authored-by: Charles Frye <[email protected]> * rewrite as BoredAPI, reorganize, make testable --------- Co-authored-by: Charles Frye <[email protected]>
1 parent 93ceb44 commit 3be6b34

File tree

1 file changed

+394
-0
lines changed

1 file changed

+394
-0
lines changed

07_web_endpoints/discord_bot.py

Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
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

Comments
 (0)