Skip to content

Instantly share code, notes, and snippets.

@jb3
Last active September 25, 2024 14:34
Show Gist options
  • Save jb3/47c0496920dd61a50ed9c503d7d676e9 to your computer and use it in GitHub Desktop.
Save jb3/47c0496920dd61a50ed9c503d7d676e9 to your computer and use it in GitHub Desktop.
A simple implementation of Discord Slash Commands in Python with Microsoft Function Apps.

Dice rolling — Discord Interaction Edition

This is a very basic Discord Interaction designed to run on Azure Function Apps using the Python runtime.

Steps to get running

  1. Head to Discord and create a new application, take note of the Client ID, Client Secret and Interactions Public Key.
  2. Create a new Azure function app with the code in azure_function.py, make sure to add an app setting called INTERACTION_PUBLIC_KEY with the value taken from Discord. Make sure to add discord_interactions to your requirements.txt file, the function depends on it!
  3. Place the URL of your App Function HTTP trigger into the Discord Developer portal. If things are working it will allow you to save.
  4. Add the application to your server by visiting the OAuth2 URL generator in the Developer portal and creating a link with the application.commands scope.
  5. Run client_credentials.py and input your Client ID and Secret, take note of the returned access token in the access_token field.
  6. Run the register_command.py script with the token fetched through the client credentials script.
  7. Run the /dice command in your Discord server!

Going public!

At some point you'll want to have the commands appear by default in your users Discord servers without having to manually add them to the guild. To do this swap the URL in register_command.py for one that looks like the following:

url = f"https://discord.com/api/v8/applications/{APP_ID}/commands"

After you run the script once more the new command will begin to propagate across your applications Discord servers over a period of an hour.

Licensing

All code in this project is licensed under MIT. The full license can be found in the file named LICENSE.

import json
import os
import random
from typing import Any, Dict
import azure.functions as func
from discord_interactions import verify_key, InteractionResponseFlags, InteractionResponseType
def main(req: func.HttpRequest) -> func.HttpResponse:
# Discord security headers
signature = req.headers.get("X-Signature-Ed25519") or ''
timestamp = req.headers.get('X-Signature-Timestamp') or ''
# Interaction payload
data = req.get_json()
# Can we can verify the payload came from Discord?
if verify_key(req.get_body(), signature, timestamp, os.environ["INTERACTION_PUBLIC_KEY"]):
# Discord may ping our endpoint with a type 1 PING. We respond with a PONG.
if data["type"] == 1:
return func.HttpResponse(json.dumps({
"type": InteractionResponseType.PONG
}), status_code=200)
# Type 2 is a command execution.
if data["type"] == 2:
# Is the command our dice command?
if data["data"]["name"] == "dice":
# Return a JSON response from our do_dice_roll function.
return func.HttpResponse(json.dumps(do_dice_roll(data)))
# Anything else is probably a mistake, let's acknowledge and drop the message.
return func.HttpResponse(json.dumps({
"type": InteractionResponseType.ACKNOWLEDGE
}))
else:
# The payload did not come from Discord.
return func.HttpResponse(json.dumps({
"status": "invalid signature"
}), status_code=400)
def do_dice_roll(data: Dict[str, Any]) -> Dict[str, Any]:
# Convert the list of options into a dictionary
options = {item["name"]:item["value"] for item in data["data"]["options"]}
# Fetch user value or use one of our defaults.
maximum = options.get("maximum") or 6
rolls = options.get("rolls") or 1
# Maximum 20 rolls at once
if rolls > 20:
# Send an ephemeral (hidden) message with the error.
return {
"type": InteractionResponseType.CHANNEL_MESSAGE,
"data": {
"content": "You can have up to 20 rolls!",
"flags": InteractionResponseFlags.EPHEMERAL
}
}
# Maximum 50 as the largest dice faccec
if maximum > 50:
# Send an ephemeral message about this.
return {
"type": InteractionResponseType.CHANNEL_MESSAGE,
"data": {
"content": "You can only roll digits up to 50!",
"flags": InteractionResponseFlags.EPHEMERAL
}
}
fields = []
# For every dice roll, add a field to our embed.
for i in range(rolls):
# Pick a random value between our maximum.
val = random.randint(1, maximum)
fields.append({
"name": f"Roll #{i+1}",
"value": f"`{val}`",
"inline": True
})
# Create an embed to send back to Discord.
embeds = [
{
"title": "Dice rolls",
"color": 5814783,
"fields": fields
}
]
# Send back a message to Discord with the embeds.
return {
"type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
"data": {
"embeds": embeds
}
}
import requests
import base64
# Set some constants
API_ENDPOINT = 'https://discord.com/api/v8'
CLIENT_ID = input("OAuth2 Client ID: ")
CLIENT_SECRET = input("OAuth2 Client Secret: ")
def get_token():
# Construct the payload to send to Discord for the command update scope
data = {
'grant_type': 'client_credentials',
'scope': 'applications.commands.update'
}
# Headers as mandated by OAuth2 specification.
headers = {
'Content-Type': 'application/x-www-form-urlencoded'
}
r = requests.post(
'%s/oauth2/token' % API_ENDPOINT,
data=data,
headers=headers,
# Authenticate with OAuth2 credentails.
auth=(CLIENT_ID, CLIENT_SECRET)
)
# Return the data from Discord.
return r.json()
# Print the response from Discord.
print(get_token())
import requests
# Grab some constants from our user.
APP_ID = input('OAuth2 Client ID: ')
GUILD_ID = input('Guild ID: ')
# Build the URL we are going to post our new commands to.
url = f"https://discord.com/api/v8/applications/{APP_ID}/guilds/{GUILD_ID}/commands"
# Command body
json = {
"name": "dice",
"description": "Roll a dice",
"options": [
{
"name": "rolls",
"description": "Number of dice rolls to perform",
"required": False,
# Type 2 is an integer
"type": 4,
},
{
"name": "maximum",
"description": "The maximum value of the dice rolls",
"required": False,
"type": 4
}
]
}
# Grab the auth for Discord
BEARER_TOKEN = input("OAuth2 Token (gain using client_credentials.py): ")
headers = {
"Authorization": f"Bearer {BEARER_TOKEN}"
}
# Send the request!
requests.post(url, headers=headers, json=json)
@jsleep
Copy link

jsleep commented Sep 17, 2024

Discord requires option names to be lower case now or else you get the generic error: APPLICATION_COMMAND_INVALID_NAME

@jsleep
Copy link

jsleep commented Sep 17, 2024

doesn't seem like this example command responds to discord fast enough now with azure functions :/

@jb3
Copy link
Author

jb3 commented Sep 17, 2024

Hey @jsleep 🙂!

Discord requires option names to be lower case now or else you get the generic error: APPLICATION_COMMAND_INVALID_NAME

Interesting, nice spot, updated now.

doesn't seem like this example command responds to discord fast enough now with azure functions :/

This was always the slight problem, the Functions team took a look at it but sadly Discord has something like a 5 second response timeout and for cold-boots of Python functions it just wasn't performant enough. If the worker was already booted and responded then it was generally fine and would work on, say, the next call, but the initial often timed out if the function went to sleep.

Cloudflare solve this with Workers via the v8 hot-starts by removing the need to boot a runtime, a few other serverless things based on v8 do this, but obviously no nice solution for Azure Functions w/ Python.

There's a chance that using the pre-warming features of Functions premium resolves this, I haven't tested that since it was added to the product: https://learn.microsoft.com/en-us/azure/azure-functions/functions-premium-plan?tabs=portal#eliminate-cold-starts

Obviously, that does come at an increased cost compared to other serverless offerings (and eventually, compared to just using a compute instance/app service and running it there)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment