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.
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.
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.
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.
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.
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.
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:
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.
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.
Now let’s redeploy the webhook function to put this change into production. Recall the command to deploy the webhook function:
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:
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:
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.
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:
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.
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
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:
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.)
And upload the updated file to Secret Manager:
Now let’s update our startup code to access these secrets and pass them into the DeferredTask constructor:
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:
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.
Redeploy both the webhook and the subscriber, and our final app is ready!
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!
I work at Google in my day job, so all code in this article is: