diff --git a/bot.py b/bot.py index 8bc565a..da86781 100644 --- a/bot.py +++ b/bot.py @@ -6,12 +6,13 @@ 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, @@ -19,26 +20,19 @@ 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() @@ -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 } @@ -89,7 +74,6 @@ async def handle_request(request: Request): return app - @stub.function() async def respond( question: str, @@ -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 @@ -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