Highly scalable Discord bot on Google Cloud, part 3: Two Cloud Functions and PUBSUB

........     

This post is part of a series of posts which experiment with building a highly scalable Discord bot which reacts to the "/mando" command by generating a unique mandala and serving the image back, implemented using multiple approaches.  

 I did this to play and experiment with GCP. Might want to read the introductive post first. I've experimented with a single serverless function and it's not easy to implement what we want. For the next experiment, we are going to use a serverless function which just received the request sent by the Discord server and posts the data to a PUBSUB topic. This approach allows us to reply to the Discord server immediately so that we don't get a timeout, and still be able to process the mandala (which might take 20 or 30 seconds) in the second function. When the image is generated and uploaded to the bucket, the function does a PATCH invocation to the Discord server to send the image to the user.


TLDR
  • Having two functions, one function which replies immediately with ACK to Discord and one function which does the heavy lifting of generating the mandala works very well; no errors, we do things exactly as stated in the Discord docs.

Deploying the PUBSUB topic

gcloud pubsub topics create raw-discord-data
The result:

raw-discord-data function

Deploying it:
gcloud functions deploy raw-image-func \
                      --runtime=python39 \
                      --region=europe-west6 \
                      --source=. --entry-point=handle_request \
                      --trigger-http \
                      --allow-unauthenticated
Source code:
main.py
import json
import functions_framework
from flask import abort
from flask import jsonify
from google.cloud import pubsub_v1
from nacl.exceptions import BadSignatureError
from nacl.signing import VerifyKey
from config import public_discord_key, project_name, topic_name

def publish_to_pub_sub(interaction_id, interaction_token, message_id):
    client = pubsub_v1.PublisherClient()
    topic_path = client.topic_path(project_name, topic_name)
    data = {
        "interaction_id": interaction_id,
        "interaction_token": interaction_token,
        "message_id": message_id,
    }
    data_bytes = json.dumps(data).encode("utf-8")
    future = client.publish(topic_path, data=data_bytes)

def validate_request(request):
    verify_key = VerifyKey(bytes.fromhex(public_discord_key))
    signature = request.headers["X-Signature-Ed25519"]
    timestamp = request.headers["X-Signature-Timestamp"]
    body = request.data.decode("utf-8")
    try:
        verify_key.verify(f"{timestamp}{body}".encode(), bytes.fromhex(signature))
    except BadSignatureError:
        return False
    return True

@functions_framework.http
def handle_request(request):
    is_valid = validate_request(request)
    if not is_valid:
        abort(401, "invalid request signature")
    request_type = request.json["type"]
    if request_type == 1:
        return jsonify({"type": 1})
    else:
        interaction_token = request.json["token"]
        interaction_id = request.json["id"]
        message_id = request.json["data"]["id"]
        publish_to_pub_sub(interaction_id, interaction_token, message_id)
        return jsonify(
            {
                "type": 4,
                "data": {
                    "tts": False,
                    "content": "generating, please wait...",
                    "embeds": [],
                    "allowed_mentions": {"parse": []},
                },
            }
        )
        
Same as in the previous experiment the function validates the Discord request, then fetches the data from the request and post a message to the PUBSUB topic. This takes little time, so the response does not timeout. Next, it returns a response to the Discord server ("type": 5, meaning the bot had ACK the request and is going to reply later).



image-generator function

Deploying it:
gcloud functions deploy raw-image-func \
                      --runtime=python39 \
                      --region=europe-west6 \
                      --source=. --entry-point=handle_request \
                      --trigger-http \
                      --allow-unauthenticated
                      
Notice the "trigger-topic" parameter which instruct Google Cloud to execute the image-generator function every time a new message is posted to the "trigger-topic" PUBSUB topic.

Source code:
main.py
import base64
import json
import uuid

import requests
from google.cloud import storage

from config import discord_app_id, bucket_name, local_tmp_folder
from mando import create_mando


def upload_blob(source_file_name, destination_blob_name):
    storage_client = storage.Client()
    bucket = storage_client.bucket(bucket_name)
    blob = bucket.blob(destination_blob_name)
    blob.upload_from_filename(source_file_name)


def reply_mando_result(interaction_token, mando_filename):
    mando_url = f"https://storage.googleapis.com/{bucket_name}/{mando_filename}"
    reply_response = requests.patch(
        url=f"https://discord.com/api/v8/webhooks/{discord_app_id}/{interaction_token}/messages/@original",
        json={
            "tts": False,
            "content": "image",
            "embeds": [
                {
                    "thumbnail": {"url": mando_url},
                    "image": {"url": mando_url},
                }
            ],
            "allowed_mentions": {"parse": []},
        },
    )
    reply_response.raise_for_status()


def handle_request(event, context):
    pubsub_message = base64.b64decode(event["data"]).decode("utf-8")
    msg_json = json.loads(pubsub_message)
    local_file_name = f"{str(uuid.uuid4())}.png"
    create_mando(local_tmp_folder=local_tmp_folder, local_file_name=local_file_name)
    upload_blob(f"{local_tmp_folder}/{local_file_name}", local_file_name)
    interaction_token = msg_json["interaction_token"]
    reply_mando_result(
        interaction_token=interaction_token,
        mando_filename=local_file_name,
    )
    return {}, 200

The mando.py file is here.

Results below. No more errors this time. We do things exactly as instructed by the Discord docs. We reply immediately with ACK (as the post to the PUBSUB operation is quite fast), then we do the heavy lifting in another function and send the result via a webhook PATCH invocation.



Comments

Post a Comment