Building a Discord Command in Ruby on Google Cloud Functions: Part 3
Tags: ruby, serverless, googlecloud, discord
Published: 2021-05-04 06:54:10

---
title: Building a Discord Command in Ruby on Google Cloud Functions: Part 3
published: true
date: 2021-05-03 00:00:00 UTC
tags: ruby, serverless, googlecloud, discord
canonical_url: https://daniel-azuma.com/blog/2021/05/03/discord-command-in-ruby-on-google-cloud-functions-part-3
---

This is the third of a four-part series on writing a [Discord “slash” command](https://discord.com/developers/docs/interactions/slash-commands) in [Ruby](https://www.ruby-lang.org) using [Google Cloud Functions](https://cloud.google.com/functions). In previous parts, we deployed a Discord app to Google Cloud Functions, and added a command to a server using the Discord API. In this part, we actually implement the command, calling an external API and displaying results in the channel. It will illustrate how to respond to commands, and how to handle secrets such as API keys in production.

Previous articles in this series:

- [Introduction](https://dev.to/googlecloud/building-a-discord-command-in-ruby-on-google-cloud-functions-g25)
- [Part 1](https://dev.to/googlecloud/building-a-discord-command-in-ruby-on-google-cloud-functions-part-1-a20)
- [Part 2](https://dev.to/googlecloud/building-a-discord-command-in-ruby-on-google-cloud-functions-part-2-570l)

## Responding to commands

When we left off in [part 2](https://dev.to/googlecloud/building-a-discord-command-in-ruby-on-google-cloud-functions-part-2-570l), we had created a command in a Discord server, but hadn’t yet implemented the back end. We’ll do so now.

When someone invokes a command, an [interaction request](https://discord.com/developers/docs/interactions/slash-commands#interaction) of type 2 gets sent to our webhook. The JSON data sent with this request will include the command that was sent, and any options that were provided. For our command, that includes a Scripture reference.

To start off, let’s update the `Responder#respond` method to recognize type 2, and call a new method `handle_command` that we will implement:

```
# responder.rb

# ...

class Responder

  # ...

  def respond(rack_request)
    raw_body = rack_request.body.read
    unless verify_request(raw_body, rack_request.env)
      # Discord expects a 401 response if the verification failed
      return [401,
        {"Content-Type" => "text/plain"},
        ["invalid request signature"]]
    end
    interaction = JSON.parse(raw_body)
    case interaction["type"]
    when 1
      # Ping
      {type: 1}
    when 2
      # Command
      handle_command(interaction)
    else
      [400,
        {"Content-Type" => "text/plain"},
        ["Unrecognized interaction type"]]
    end
  end

  # ...

end
```

Now for the `handle_command` method. Discord expects an [interaction response](https://discord.com/developers/docs/interactions/slash-commands#interaction-response) JSON message in the HTTP response. Typically, the response includes a message that the command will then display in the Discord channel. We signal such a response by setting the “type” field to 4, and returning the text to display in a “content” field.

To start off, let’s construct a response by echoing back the Scripture reference that we were asked to look up. First we’ll write code to parse the interaction and get the option with name “reference”. For simplicity, we’ll just raise an exception (which Cloud Functions will translate to a 500 response) if the command data doesn’t have the expected format.

```
# responder.rb

# ...

class Responder

  # ...

  def reference_from_interaction(interaction)
    command_data = interaction["data"]
    unless command_data["name"] == "bible"
      raise "Unexpected command: #{command_data['name']}"
    end
    command_data["options"].each do |option|
      if option["name"] == "reference"
        return option["value"]
      end
    end
    raise "No reference option found"
  end

  # ...

end
```

Now we’ll use that to implement `handle_command` to return a message to display. Remember that if we return a hash from a Cloud Function, it is automatically rendered as JSON in the HTTP response.

```
# responder.rb

# ...

class Responder

  # ...

  def handle_command(interaction)
    reference = reference_from_interaction(interaction)
    {
      type: 4,
      data: {
        content: "You looked up #{reference}"
      }
    }
  end

  # ...

end
```

Now let’s redeploy the function:

```
$ bundle install
$ gcloud functions deploy discord_webhook \
    --project=$MY_PROJECT --region=us-central1 \
    --trigger-http --entry-point=discord_webhook \
    --runtime=ruby27 --allow-unauthenticated
```

And when we go back to our Discord channel and invoke the command… success!

![Invoking a command](https://daniel-azuma.com/img/discord-bot/test-input.png)

![Simple output](https://daniel-azuma.com/img/discord-bot/temp-output.png)

## Calling an external API

Now it’s time to implement the main functionality of our command, that of actually returning Scripture content from an API. We’ll access a suitable API, show how to manage its API key in production using [Google Secret Manager](https://cloud.google.com/secret-manager), and write code to make API calls and include the results in the command response.

### Signing up with API.Bible

[API.Bible](https://scripture.api.bible) is a basic Scripture lookup API that boasts thousands of versions and translations, and allows reference lookup and keyword search. Their free tier includes access to public domain translations like the World English Bible, and a modest number of calls per day that will be more than adequate for my church’s needs.

I created an account and registered an application. The site claims that new accounts are subject to approval, but it looks like, since mine was for non-commercial use by a small church, it pretty much went through immediately. As part of my account, they gave me an API key. This is effectively a password to my account, and I need to handle it like any secret, keeping it out of code, source control, and any unencrypted communication.

The API website also includes “live” [reference documentation](https://scripture.api.bible/livedocs) that includes the ability to make API calls directly from the web page. From here, I identified the “passages” API call as the likely call I’ll need for my Discord command. The resource path for the request includes the ID of the Bible version, and the reference in a specific format. Then the response is a JSON payload including the passage content. Looks good, let’s experiment with it!

### Calling the API

As we did in part 2 with the Discord API, we’ll create a simple client class for the Bible API. We’ll start with a helper method that makes HTTP calls and handles results. This helper will also set the needed API key header. As we did with the Discord client, we’ll pass in the API key as a constructor argument.

```
# bible_api.rb

require "faraday"
require "json"

class BibleApi
  def initialize(api_key:)
    @api_key = api_key
  end

  private

  def call_api(path, params: nil)
    faraday = Faraday.new(
      url: "https://api.scripture.api.bible",
      headers: {
        "api-key" => @api_key
      }
    )
    response = faraday.get(path) do |req|
      req.params = params if params
    end
    body = JSON.parse(response.body)
    unless response.status == 200 && body["data"]
      raise body["message"] || "Unknown error"
    end
    body["data"]
  end
end
```

Now we’ll implement a method for the “passages” call. It will take the reference as an argument. For simplicity, we’ll just hard-code the Bible ID for a reasonable translation, the World English Bible, Ecumenical version.

```
# bible_api.rb

# ...

class BibleApi

  WEB_BIBLE_ID = "9879dbb7cfe39e4d-01"

  # ...

  def lookup_passage(reference)
    params = {
      "content-type" => "text",
      "include-titles" => "false"
    }
    resource = "/v1/bibles/#{WEB_BIBLE_ID}/passages/#{reference}"
    response_object = call_api(resource, params: params)
    response_object["content"]
  end

  # ...

end
```

We can now test this by creating a simple command-line script using Toys. As before, we’ll pass the API key in on the command line, to keep it out of our code. We’ll also pass the Scripture reference on the command line.

```
# .toys.rb

tool "lookup" do
  flag :api_key, "--api-key TOKEN"
  required_arg :reference

  def run
    require_relative "bible_api"
    client = BibleApi.new(api_key: api_key)
    result = client.lookup_passage(reference)
    puts result
  end
end

# ... and other scripts from before
```

Note that the Bible API uses a particular format for Scripture references. We need to use that format when we test our API client:

```
$ toys lookup --api-key=$MY_API_KEY JHN.1.1
     [1] In the beginning was the Word, and the Word was with God, and the Word was God.
```

### Calling the API from the webhook

Now that we have the Bible API working, we can write the rest of the code to integrate it into our webhook. We’ll construct a Bible API client in our Responder class, and rewrite our `Responder#handle_command` method to call it to get the passage content.

```
# responder.rb

# ...

require_relative "bible_api"

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
    @bible_api = BibleApi.new(api_key: api_key)
  end

  # ...

  def handle_command(interaction)
    # Call the Bible API to get the content to return
    reference = reference_from_interaction(interaction)
    content = @bible_api.lookup_passage(reference)
    {
      type: 4,
      data: {
        content: "#{reference}\n#{content}"
      }
    }
  end

  # ...

end
```

Okay, we’re almost there. We have a working Bible API client, and our webhook command handler calls it to get content, and returns it to Discord for display. There’s just one problem. The Bible API client needs an API key. We’ve added an `api_key` argument to the Responder constructor so we can pass it down, but where does the Responder get the API key from? Remember from part 1 that a Responder is created when the function starts up:

```
# app.rb

require "functions_framework"
require_relative "responder"

FunctionsFramework.on_startup do
  set_global(:responder, Responder.new(api_key: "???"))
end

FunctionsFramework.http "discord_webhook" do |request|
  global(:responder).respond(request)
end
```

Somehow we need to pass a secret into this app, and do so safely and securely. Let’s talk about how to do that.

### Handling secrets

There are many ways to handle secrets without exposing them in our code. In the past, it has been common to set secrets in environment variables, or store them in special source files that we exclude from source control. These techniques work, but each has its flaws. Environment variables can be logged by accident or read by malicious code. Files can be accessed by anyone with access to the deployment image.

The technique we’ll implement here uses a local file for local testing, but uses a secret handling service, [Google Cloud Secret Manager](https://cloud.google.com/secret-manager), for production. When we test locally, the secrets will be present in a local file that is excluded from source control, letting us test without having to make calls to a cloud service. When we deploy, however, we _exclude_ this file, avoiding the security risk of having the file present in our deployment image. Instead, in production, we load the secret directly into memory from the Secret Manager service.

So the basic logic is: first check for a file, and if that’s not present, invoke Secret Manager. We’ll implement this logic in a new class, Secrets.

First, we’ll add the client library for Secret Manager to our Gemfile:

```
# Gemfile

source "https://rubygems.org"

gem "ed25519", "~> 1.2"
gem "faraday", "~> 1.4"
gem "functions_framework", "~> 0.9"
gem "google-cloud-secret_manager", "~> 1.1"
```

Don’t forget to `bundle install` to ensure your `Gemfile.lock` gets updated.

Now let’s implement the Secrets class. In the code below, make sure you substitute the name of your Google Cloud project as the value of the`PROJECT_ID` constant. I’ve also chosen a name for the secret in the `SECRET_NAME` constant, but you can use a different name if you want.

```
# secrets.rb

require "psych"
require "google/cloud/secret_manager"

class Secrets
  SECRETS_FILE = File.join( __dir__ , "secrets.yaml")
  SECRET_NAME = "discord-bot-secrets"
  PROJECT_ID = "my-project-id"

  def initialize
    if File.file?(SECRETS_FILE)
      load_from_file
    else
      load_from_secret_manager
    end
  end

  attr_reader :bible_api_key

  def load_from_file
    secret_data = Psych.load_file(SECRETS_FILE)
    load_from_hash(secret_data)
  end

  def load_from_secret_manager
    secret_manager = Google::Cloud::SecretManager.secret_manager_service
    version_name = secret_manager.secret_version_path(
      project: PROJECT_ID, secret: SECRET_NAME, secret_version: "latest"
    )
    version_data = secret_manager.access_secret_version(name: version_name)
    secret_data = Psych.load(version_data.payload.data)
    load_from_hash(secret_data)
  end

  def load_from_hash(hash)
    @bible_api_key = hash["bible_api_key"]
  end
end
```

Now we can invoke this class from our function and use it to retrieve the API key:

```
# app.rb

require "functions_framework"
require_relative "responder"
require_relative "secrets"

FunctionsFramework.on_startup do
  secrets = Secrets.new
  set_global(:responder, Responder.new(api_key: secrets.bible_api_key))
end

FunctionsFramework.http "discord_webhook" do |request|
  global(:responder).respond(request)
end
```

To get this to work for local testing, we just need to create the “secrets.yaml” file, substituting your actual API key:

```
# secrets.yaml

bible_api_key: "mybibleapikey12345"
```

Make sure it stays out of source control. I use git, so I’ll add a `.gitignore` file:

```
# .gitignore

secrets.yaml
```

We also want to avoid _deploying_ this file. By default, Cloud Functions honors `.gitignore` when deploying, so it will do this by default. But if you don’t use git, or you have more complex requirements, you can configure which files are omitted from deployment by providing a [gcloudignore file](https://cloud.google.com/sdk/gcloud/reference/topic/gcloudignore).

In production, we want to read this data from Secret Manager, so we’ll need to upload it to Secret Manager first, using the Google Cloud command line:

```
$ gcloud secrets create discord-bot-secrets \
    --project=$MY_PROJECT --data-file=secrets.yaml
```

There’s one more thing we need to do. We’ve written code that loads the secrets from Secret Manager in production, but we need to grant that code _access_ to Secret Manager. To do so, we note that when an app runs in Cloud Functions, by default it uses a particular service account called `$MY_PROJECT@appspot.gserviceaccount.com` to talk to Google Cloud APIs. So we need to grant that service account access to the secret:

```
$ gcloud secrets add-iam-policy-binding discord-bot-secrets \
    --project=$MY_PROJECT --role=roles/secretmanager.secretAccessor \
    --member=serviceAccount:$MY_PROJECT@appspot.gserviceaccount.com
```

For both the above commands, make sure the project name and the secret name `discord-bot-secrets` matches the corresponding names you used in the constants in the Secrets class.

Now we can redeploy our Function, and try it out.

![Invoking a command](https://daniel-azuma.com/img/discord-bot/test-input.png)

![Displaying a short passage](https://daniel-azuma.com/img/discord-bot/short-passage.png)

Woot!

## Now what?

When I got to this point, things were mostly working, but I encountered a few issues.

The bot wasn’t very stable. Sometimes, the first time I tried running a command, I’d get a failure message, but then the next time it would work. Oddly, when I looked at the logs in Cloud Functions, everything seemed to be fine, even for the supposedly failed request.

The cause, it turns out, was that Discord will wait for a [maximum of 3 seconds](https://discord.com/developers/docs/interactions/slash-commands#responding-to-an-interaction) for a response, and if it doesn’t receive one in a timely manner, it will give up and display an error. This can sometimes be a challenge for a webhook that makes API requests to external services, if those calls may incur significant latency. It’s also a particular issue for serverless hosting. An environment like Cloud Functions might shut down your app if it’s not in use, in order to conserve resources. Then, the next time it receives a request, it might take some time to boot back up. This is called a “cold start” in serverless lingo, and it can be a challenge for app developers to keep it low. In addition to calling the Bible API, our app makes a call out to the Secret Manager during cold start, and sometimes the latency of that additional call is enough to push the entire cold start over the 3 second threshold.

Additionally, the syntax that the Bible API requires to specify the scripture reference, wasn’t very nice. Culturally, we’re used to a syntax like “John 1:1-5” rather than “JHN.1.1-JHN.1.5”. So I had to write a parser for “human” references, and convert them to the format needed by the Bible API.

Finally, displaying longer passages would also fail. There was no way, for example, to display the entire first chapter of John. Even though the Bible API would return the passage content, it turns out that Discord imposes a [2000-character limit](https://discord.com/developers/docs/resources/webhook#execute-webhook) on message length, and it refused to post a message that is any longer.

So I had a working command, but I wasn’t really satisfied with the results. Parsing reference syntax is just programming, and I’ll leave improving it as an exercise for the reader. But the latency and the longer passages were a real usability hindrance. The good news is, there are solutions, and we’ll explore them in [part 4](https://dev.to/googlecloud/building-a-discord-command-in-ruby-on-google-cloud-functions-part-4-3ch1).