Skip to content
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

Update bot.py #84

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 20 additions & 129 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,33 @@
import json

from modal import Image, Mount, Secret, Stub, asgi_app

from utils import pretty_log

# Define the image for the Modal container
image = Image.debian_slim(python_version="3.10").pip_install("pynacl", "requests")
discord_secrets = [Secret.from_name("discord-secret-fsdl")]

# Define the Modal stub
stub = Stub(
"askfsdl-discord",
image=image,
secrets=discord_secrets,
mounts=[Mount.from_local_python_packages("utils")],
)


# Enum definitions for Discord interaction types
class DiscordInteractionType(Enum):
PING = 1 # hello from Discord
APPLICATION_COMMAND = 2 # an actual command

PING = 1
APPLICATION_COMMAND = 2

class DiscordResponseType(Enum):
PONG = 1 # hello back
DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5 # we'll send a message later

PONG = 1
DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5

class DiscordApplicationCommandOptionType(Enum):
STRING = 3 # with language models, strings are all you need

STRING = 3

@stub.function(
# keep one instance warm to reduce latency, consuming ~0.2 GB while idle
# this costs ~$3/month at current prices, so well within $10/month free tier credit
keep_warm=1,
)
@stub.function(keep_warm=1)
@asgi_app(label="askfsdl-discord-bot")
def app() -> FastAPI:
app = FastAPI()
Expand All @@ -53,34 +47,25 @@ def app() -> FastAPI:

@app.post("/")
async def handle_request(request: Request):
"Verify incoming requests and if they're a valid command spawn a response."

# while loading the body, check that it's a valid request from Discord
"""Verify incoming requests and handle valid commands."""
body = await verify(request)
data = json.loads(body.decode())

if data.get("type") == DiscordInteractionType.PING.value:
# "ack"nowledge the ping from Discord
return {"type": DiscordResponseType.PONG.value}

if data.get("type") == DiscordInteractionType.APPLICATION_COMMAND.value:
# this is a command interaction
app_id = data["application_id"]
interaction_token = data["token"]
user_id = data["member"]["user"]["id"]

question = data["data"]["options"][0]["value"]
pretty_log(question)

# kick off our actual response in the background
# Kick off the response in the background
respond.spawn(
question,
app_id,
interaction_token,
user_id,
data["application_id"],
data["token"],
data["member"]["user"]["id"],
)

# and respond immediately to let Discord know we're on the case
return {
"type": DiscordResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE.value
}
Expand All @@ -89,7 +74,6 @@ async def handle_request(request: Request):

return app


@stub.function()
async def respond(
question: str,
Expand All @@ -112,33 +96,25 @@ async def respond(
response = construct_error_message(user_id)
await send_response(response, application_id, interaction_token)


async def send_response(
response: str,
application_id: str,
interaction_token: str,
):
"""Send a response to the user interaction."""

interaction_url = (
f"https://discord.com/api/v10/webhooks/{application_id}/{interaction_token}"
)

json_payload = {"content": f"{response}"}

payload = aiohttp.FormData()
payload.add_field(
"payload_json", json.dumps(json_payload), content_type="application/json"
)
json_payload = {"content": response}

async with aiohttp.ClientSession() as session:
async with session.post(interaction_url, data=payload) as resp:
await resp.text()

async with session.post(interaction_url, json=json_payload) as resp:
if resp.status != 204:
pretty_log(f"Failed to send response: {await resp.text()}")

async def verify(request: Request):
"""Verify that the request is from Discord."""

from nacl.signing import VerifyKey
from nacl.exceptions import BadSignatureError

Expand All @@ -153,101 +129,16 @@ async def verify(request: Request):
try:
verify_key.verify(message, bytes.fromhex(signature))
except BadSignatureError:
# IMPORTANT: if you let bad signatures through,
# Discord will refuse to talk to you
raise HTTPException(status_code=401, detail="Invalid request") from None

return body


def construct_response(raw_response: str, user_id: str, question: str) -> str:
"""Wraps the backend's response in a nice message for Discord."""
"""Wraps the backend's response in a message for Discord."""
rating_emojis = {
"👍": "if the response was helpful",
"👎": "if the response was not helpful",
}

emoji_reaction_text = " or ".join(
f"react with {emoji} {reason}" for emoji, reason in rating_emojis.items()
)
emoji_reaction_text = emoji_reaction_text.capitalize() + "."

response = f"""<@{user_id}> asked: _{question}_

Here's my best guess at an answer, with sources so you can follow up:

{raw_response}

Emoji react to let us know how we're doing!

{emoji_reaction_text}
"""

return response


def construct_error_message(user_id: str) -> str:
import os

error_message = (
f"*Sorry <@{user_id}>, an error occured while answering your question."
)

if os.getenv("DISCORD_MAINTAINER_ID"):
error_message += f" I've let <@{os.getenv('DISCORD_MAINTAINER_ID')}> know."
else:
pretty_log("No maintainer ID set")
error_message += " Please try again later."

error_message += "*"
return error_message


@stub.function()
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_AUTH")
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": "ask",
"description": "Ask a question about anything covered by Full Stack",
"options": [
{
"name": "question",
"description": "A question about LLMs, building AI applications, etc.",
"type": DiscordApplicationCommandOptionType.STRING.value,
"required": True,
"max_length": 200,
}
],
}

# 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") == "ask" for command in commands)

# and only recreate it if the force flag is set
if command_exists and not force:
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
f"react with {emoji} {reason}" for emoji, reason