DEV Community

loading...
Google Cloud

Building a Discord Command in Ruby on Google Cloud Functions: Part 4

dazuma profile image Daniel Azuma Originally published at daniel-azuma.com on ・13 min read

This is the last of a four-part series on writing a Discord “slash” command in Ruby using Google Cloud Functions. In previous parts, we created a Discord command that displays a short Scripture passage, and implemented it in a webhook. In this part, we show how to split a longer passage across multiple messages, by triggering a second Function that makes Discord API calls. It will illustrate how to use Google Cloud Pub/Sub to run background jobs in Cloud Functions, combining an event architecture with the Discord API to implement a more complex application.

Previous articles in this series:

The challenge: size and timing

So far in this series, we’ve accomplished quite a bit. We’ve written a Discord app in Ruby. We’ve integrated with the Discord API and the Bible API. We’ve added a Discord command to a server, and implemented it in our app. And we’ve followed best practices for handling secrets such as API keys.

However, Part 3 left off with two significant issues that are impairing the usability of our Discord command.

First, Discord imposes a 2000-character limit on message size. This means our command response cannot display a Scripture passage longer than 2000 characters. It can display John 1:1-5, for example, but not the first chapter of John in its entirety.

Second, if the webhook has been idle for a while, the first call to it sometimes fails because it takes too long. This happens because Cloud Functions may need to spin up a new instance of our webhook to handle the request, and that involves not only starting up the Ruby process, but also making calls to Secret Manager to obtain API credentials, in addition to the latency of the Bible API itself. Together, this “cold start” latency sometimes takes longer than the 3 seconds allowed by Discord.

One way to address these problems is to defer processing of the command. Discord’s documentation describes this technique, which involves sending back an initial response quickly, but then continuing processing in the background and posting further updates to the response later. For our command, we could use a background process to retrieve a long passage, break it up into sections that are under the 2000-character limit, and issue Discord API calls to post each section in a separate message to the channel.

To implement this kind of “deferred” processing in a serverless environment, we’ll have our app post an event to itself, triggering the background task in a separate Function. We could do so using an event broker such as Apache Kafka, or we can remain within Google’s ecosystem by using a service like Cloud Pub/Sub.

The following diagrams illustrate how this works. Previously we had a single responder that makes lengthy calls and can return only a single message response.

Flow diagram for the current webhook

Now we’ll modify the responder to instead just post an event to Cloud Pub/Sub, and return a message saying “I’m working on it…” Then, the Pub/Sub event will trigger another Cloud Function that performs the actual work. This Pub/Sub initiated function is not subject to the 3 second time limit, and can post an arbitrary number of messages to the channel by calling the Discord API.

Flow diagram for the revised webhook

That’s our goal for part 4! Let’s get started.

Deferred processing using pub/sub

These days, the excitement around serverless often has to do with DevOps—or more specifically, the potential to reduce or simplify DevOps. It’s true that DevOps is way too hard, and we’re making progress on improving it, but I think the long-term impact of functions-as-a-service will actually be on software architecture. FaaS has a close affinity with evented systems. As applications get more distributed, and we see more interest in evented control flow to manage that complexity, Functions has the potential to make that really easy.

Acknowledging that trend, Google Cloud Functions already has tight integration with Google’s Pub/Sub service. You can deploy functions that are triggered on Pub/Sub messages, and we’ll use that strategy to implement deferred processing.

Deploying a pub/sub subscriber

Before we can handle events coming from Pub/Sub, we need to create a topic to publish to.

$ gcloud pubsub topics create discord-bot-topic --project=$MY_PROJECT
Enter fullscreen mode Exit fullscreen mode

Now we’ll write and deploy a second function for the deferred task.

First we’ll add the new function to app.rb. Because it handles events coming from Pub/Sub, it takes a CloudEvent (instead of a Rack HTTP request) as the argument. For now we’ll log the event so that we can see when events are triggered.

# app.rb

# ...

# Existing webhook function (http)
FunctionsFramework.http "discord_webhook" do |request|
  global(:responder).respond(request)
end

# New subscriber function (cloud event)
FunctionsFramework.cloud_event "discord_subscriber" do |event|
  logger.info("Received event: #{event.data.inspect}")
end
Enter fullscreen mode Exit fullscreen mode

Note that our app now defines multiple functions. When you deploy to Cloud Functions, you deploy one function at a time, and you must specify which one to deploy by name. So to deploy this new Function, we identify it by its name "discord_subscriber" and indicate that it should be triggered from the Pub/Sub topic we created.

$ gcloud functions deploy discord_subscriber \
    --project=$MY_PROJECT --region=us-central1 \
    --trigger-topic=discord-bot-topic --entry-point=discord_subscriber \
    --runtime=ruby27
Enter fullscreen mode Exit fullscreen mode

This time, instead of triggering on http requests, we configure this function to trigger when a message is published to our pubsub topic. We can test this by publishing to the topic manually, and then looking at the Cloud Functions logs to see the log entry written by our function.

$ gcloud pubsub topics publish discord-bot-topic \
    --project=$MY_PROJECT --message=hello
Enter fullscreen mode Exit fullscreen mode

Publishing an event

But what we actually want is for our webhook to publish the event. So we’ll start by adding the client library for the Pub/Sub API to our Gemfile.

Now, normally, I’d recommend using the main google-cloud-pubsub gem for interacting with Pub/Sub, but for this app, we’re actually going to use the lower-level google-cloud-pubsub-v1 library. This is to improve cold start time. The higher-level library takes measurably longer to load in the Cloud Functions environment, and with a three-second budget, any milliseconds we can shave off will be useful. Addintionally, for our purposes, we only need to publish a single message, and don’t need the advanced subscription management code in the higher-level library.

So let’s add the library to our Gemfile:

# Gemfile

source "https://rubygems.org"

gem "ed25519", "~> 1.2"
gem "faraday", "~> 1.4"
gem "functions_framework", "~> 0.9"
gem "google-cloud-pubsub-v1", "~> 0.4"
gem "google-cloud-secret_manager", "~> 1.1"
Enter fullscreen mode Exit fullscreen mode

After bundle installing, we’ll update our Responder class to publish a message to trigger our job. First, we construct a pubsub client in the initialize method. Again, we’re using the low-level publisher client.

# responder.rb

# ...

require "google/cloud/pubsub/v1/publisher"

class Responder

  # ...

  def initialize(api_key:)
    # Create a verification key (from part 1)
    public_key = DISCORD_PUBLIC_KEY
    public_key_binary = [public_key].pack("H*")
    @verification_key = Ed25519::VerifyKey.new(public_key_binary)

    # Create a Bible API client (from part 3)
    @bible_api = BibleApi.new(api_key: api_key)

    # Create a Pub/Sub client
    @pubsub = Google::Cloud::PubSub::V1::Publisher::Client.new
  end

  # ...

end
Enter fullscreen mode Exit fullscreen mode

Now we can enhance the handle_command method to publish an event. For now we’ll keep the existing functionality (i.e. calling the Bible API and returning its content), and we’ll just provide additional code to publish to Pub/Sub.

# responder.rb

# ...

class Responder

  PROJECT_ID = "my-project-id"
  TOPIC_NAME = "discord-bot-topic"

  # ...

  def handle_command(interaction)
    reference = reference_from_interaction(interaction)

    # Publish a simple pubsub event
    topic = "projects/#{PROJECT_ID}/topics/#{TOPIC_NAME}"
    attributes = {message: "Looked up #{reference}"}
    @pubsub.publish(topic: topic, messages: [{attributes: attributes}])

    # We'll leave this here for now.
    content = @bible_api.lookup_passage(reference)
    {
      type: 4,
      data: {
        content: "#{reference}\n#{content}"
      }
    }
  end

  # ...

end
Enter fullscreen mode Exit fullscreen mode

Now let’s redeploy the webhook function to put this change into production. Recall the command to deploy the webhook function:

$ gcloud functions deploy discord_webhook \
    --project=$MY_PROJECT --region=us-central1 \
    --trigger-http --entry-point=discord_webhook \
    --runtime=ruby27 --allow-unauthenticated
Enter fullscreen mode Exit fullscreen mode

After redploying, we can now invoke the comamnd in Discord, and now in addition to seeing the response in Discord, we can look at the Cloud Function logs and see it trigger the subscriber function.

So far so good—we now know how to trigger background tasks in Cloud Functions using Pub/Sub. Next, we’ll use this mechanism to support splitting a long passage across multiple chat postings.

Creating a multi-part response

For commands that require a longer time to process, or that need to post mulitple responses to the channel, Discord recommends returning an initial “deferred” response (which displays a “thinking” message in the channel) and following it up with additional responses sent via the Discord API. We’ll now change our response to a deferred response, and move the Scripture lookup logic into the Pub/Sub subscriber.

Sending a deferred response

A deferred response is simply a JSON message with the type set to 5. We’ll delete our code that calls the Bible API, and just return a static response:

# responder.rb

# ...

class Responder

  # ...

  def handle_command(interaction)
    reference = reference_from_interaction(interaction)

    # Publish a simple pubsub event
    topic = "projects/#{PROJECT_ID}/topics/#{TOPIC_NAME}"
    attributes = {message: "Looked up #{reference}"}
    @pubsub.publish(topic: topic, messages: [{attributes: attributes}])

    # Return a Type 5 response
    { type: 5 }
  end

  # ...

end
Enter fullscreen mode Exit fullscreen mode

Next, in order to perform the logic, the deferred task needs two pieces of information. First is, of course, the scripture reference to look up. And second is the interaction token which lets us associate additional responses we send, to the original request. So we’ll update the Pub/Sub message to include these two pieces of information:

# responder.rb

# ...

class Responder

  # ...

  def handle_command(interaction)
    reference = reference_from_interaction(interaction)

    # Publish a pubsub event including the reference and interaction token
    topic = "projects/#{PROJECT_ID}/topics/#{TOPIC_NAME}"
    attributes = {reference: reference, token: interaction["token"]}
    @pubsub.publish(topic: topic, messages: [{attributes: attributes}])

    # Return a Type 5 response
    { type: 5 }
  end

  # ...

end
Enter fullscreen mode Exit fullscreen mode

Finally, since we no longer use the BibleApi class here in the Responder, we no longer need to pass in the api_key. So we can remove that code from the Responder’s initialize method. And we can remove the on_startup code that loads the API key from secrets.

# responder.rb

# ...

require "google/cloud/pubsub/v1/publisher"

class Responder

  # ...

  def initialize
    # Create a verification key (from part 1)
    public_key = DISCORD_PUBLIC_KEY
    public_key_binary = [public_key].pack("H*")
    @verification_key = Ed25519::VerifyKey.new(public_key_binary)

    # We can now delete this code
    # @bible_api = BibleApi.new(api_key: api_key)

    # Create a Pub/Sub client
    @pubsub = Google::Cloud::PubSub::V1::Publisher::Client.new
  end

  # ...

end
Enter fullscreen mode Exit fullscreen mode

Deploying this webhook update, we can see the effect on the command. It now displays a “thinking” message in response to the “type 5” response:

Displaying a thinking message

Creating a deferred task handler

Now that we’re going to write some non-trivial code for the Pub/Sub-triggered Function, let’s create a class for it, like we did previously for the Responder class.

# deferred_task.rb

class DeferredTask
  def initialize
    # TODO
  end

  def handle_event(event)
    # TODO
  end
end
Enter fullscreen mode Exit fullscreen mode

Now to create a DeferredTask object on cold start, we can add code to the on_startup block, just as we did for the Responder class. But hang on… we now have two separate functions—the webhook uses only the Responder class but not DeferredTask, and the subscriber uses only the DeferredTask class but not Responder. It would be nice to for each function to construct only the objects that it needs. This will be especially important in the next section because the DeferredTask will require a round-trip to the Secret Manager, and we’d like to avoid needlessly incurring that latency in the webhook Function.

To ensure each Function constructs only the objects that it needs, we can use lazy initialization of globals. The Ruby Functions Framework supports this feature by passing a block to set_global.

# app.rb

FunctionsFramework.on_startup do
  # Define how to construct a Responder
  set_global(:responder) do
    # This does not actually run until the discord_webhook
    # function actually accesses it.
    require_relative "responder"
    Responder.new
  end

  # Define how to construct a DeferredTask
  set_global(:deferred_task) do
    # This does not actually run until the discord_subscriber
    # function actually accesses it.
    require_relative "deferred_task"
    DeferredTask.new
  end
end

# Call the Responder from the webhook function
FunctionsFramework.http "discord_webhook" do |request|
  global(:responder).respond(request)
end

# Call the DeferredTask from the pubsub subscriber function
FunctionsFramework.cloud_event "discord_subscriber" do |event|
  global(:deferred_task).handle_event(event)
end
Enter fullscreen mode Exit fullscreen mode

Notice how even the require instructions are executed lazily, ensuring the libraries are loaded only if they’re actually going to be used. This can often make a significant difference in a serverless or container-based environment where file system access may be relatively slow.

Passing secrets to the deferred task

Now for the functionality of DeferredTask. We’ll need to call both the Bible API and the Discord API, so we’ll need both the Bible API key and the Discord Bot Token. In Part 3, we set up the Bible API key in a local file for local testing, and uploaded it to the Google Secret Manager for production. Now let’s enhance that Secrets class to add support for the Discord Bot Token:

# secrets.rb

# ...

class Secrets

  # ...

  attr_reader :bible_api_key
  attr_reader :discord_bot_token

  # ..

  def load_from_hash(hash)
    @bible_api_key = hash["bible_api_key"]
    @discord_bot_token = hash["discord_bot_token"]
  end
end
Enter fullscreen mode Exit fullscreen mode

Now add discord_bot_token to the secrets.yaml file. (Once again, you can find the token in your bot’s page in the Discord developer console.)

# secrets.yaml

bible_api_key: "mybibleapikey12345"
discord_bot_token: "mydiscordbottoken12345"
Enter fullscreen mode Exit fullscreen mode

And upload the updated file to Secret Manager:

$ gcloud secrets versions add discord-bot-secrets \
    --project=$MY_PROJECT --data-file=secrets.yaml
Enter fullscreen mode Exit fullscreen mode

Now let’s update our startup code to access these secrets and pass them into the DeferredTask constructor:

# app.rb

# ...

FunctionsFramework.on_startup do

  # ...

  # Define how to construct a DeferredTask
  set_global(:deferred_task) do
    # This does not actually run until the discord_subscriber
    # function actually accesses it.
    require_relative "secrets"
    require_relative "deferred_task"
    secrets = Secrets.new
    DeferredTask.new(api_key: secrets.bible_api_key,
                     bot_token: secrets.discord_bot_token)
  end
end

# ...

# deferred_task.rb

require_relative "bible_api"
require_relative "discord_api"

class DeferredTask

  def initialize(api_key:, bot_token:)
    @bible_api_client = BibleApi.new(api_key: api_key)
    @discord_api_client = DiscordApi.new(bot_token: bot_token)
  end

  # ...

end
Enter fullscreen mode Exit fullscreen mode

Now that we’ve accessed the secrets and constructed the needed clients, we’re ready to implement the task.

Sending multiple ressages

Our DeferredTask will make two types of calls to the Discord API. First, we’ll call Edit Original Interaction Response to update the original response (which was a Type 5 “thinking” message) to indicate that the bot is done “thinking” and is ready to display content. Then we’ll call Create Followup Message potentially multiple times, to post messages with Scripture content.

First, let’s implement those calls in our Discord API client:

# discord_api.rb

# ,,,

class DiscordApi

  # ...

  def edit_interaction_response(interaction_token, content)
    data_json = JSON.dump({content: content})
    headers = {"Content-Type" => "application/json"}
    call_api("/webhooks/#{@client_id}/#{interaction_token}/messages/@original",
             method: :patch, body: data_json, headers: headers)
  end

  def create_followup(interaction_token, content)
    data_json = JSON.dump({content: content})
    headers = {"Content-Type" => "application/json"}
    call_api("/webhooks/#{@client_id}/#{interaction_token}",
             method: :post, body: data_json, headers: headers)
  end

  # ...

end
Enter fullscreen mode Exit fullscreen mode

And finally, implement the logic in DeferredTask. For simplicity, this code will naively split the content into 2000-character chunks. For a better experience, we’d probably want to split on word boundaries, or even better, verse boundaries.

# deferred_task.rb

class DeferredTask

  # ...

  def handle_event(event)
    # Get data from the Pub/Sub message
    attributes = event.data["message"]["attributes"]
    reference = attributes["reference"]
    interaction_token = attributes["token"]

    # Get the content from the Bible API
    full_content = @bible_api_client.lookup_passage(reference)

    # Edit the original interaction response to signal we're done "thinking"
    @discord_api_client.edit_interaction_response(
      interaction_token, "Looked up #{reference}")

    # Split the content into 2000-character sections and send Discord meessages
    full_content.scan(/.{1,2000}/m) do |section|
      # Space API calls out a bit, to avoid Discord's rate limiting.
      sleep 0.5
      @discord_api_client.create_followup(interaction_token, section)
    end
  end

end
Enter fullscreen mode Exit fullscreen mode

Redeploy both the webhook and the subscriber, and our final app is ready!

The final output

Now what?

We now have a working, nontrivial Discord command, running in Google Cloud Functions! We’ve also covered some of the key techniques in a robust serverless app, including handling secrets in production, and using a pub/sub system to schedule tasks.

We do still have a few TODOs, which I will leave as “exercises for the reader”. For example:

  • It would make for a better experience to parse “normal” Scripture reference syntax such as “Matthew 1:2-3” rather than forcing users to use the Bible API’s reference format. When I wrote this app for my church, I also used a string distance metric to detect book name abbreviations such as “Matt” or “Mt”.

  • Currently, if the Bible API returns an error (e.g. because the Scripture reference was invalid), our BibleApi client raises an exception. This causes the deferred task function to terminate, but the user doesn’t see any indication of this in the discord channel. It would be a good idea to catch any exceptions and post an error message to the channel.

  • It would be nice to split content into sections on verse boundaries rather than just taking 2000-character chunks. When I wrote this for my church, I actually passed an option to the Bible API call that causes it to return the passage in semantic structured JSON rather than a simple string. I then had to parse that data structure to construct a displayable string, but because it was structured data, I could extract the exact verse boundaries, and customize the output format.

Additionally, there are alternative approaches that could be explored. One might consider, for example, using a background task manager such as Google Cloud Tasks instead of Pub/Sub to schedule the deferred task, and there are pros and cons to that approach. Discord also supports receiving events via WebSocket rather than a webhook. This option could provide much better performance, but would require reworking much of the design and deployment of the app, and may increase the cost of running it.

But overall, the techniques that we’ve covered should provide a good foundation for writing a variety of serverless applications using Google Cloud Platform. It should also provide a good set of getting-started information on writing Discord apps. I hope it was helpful!

Discussion (0)

Forem Open with the Forem app