Daniel Azuma

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

This is the second of a four-part series on writing a Discord “slash” command in Ruby using Google Cloud Functions. In the previous part, we set up a Discord application and deployed its webhook to Google Cloud Functions. In this part, we investigate how to add a command to a Discord channel. It will illustrate how to authenticate and interact with the Discord API, how to use it to add a command to a Discord server, and demonstrate some nice tools for invoking these tasks from the command line.

Previous articles in this series:

Installing on a Discord server

In part 1, we set up a Discord application. But to make commands available in a Discord server, we’ll need to add our application to the server.

First, we’ll create a Bot user for the application. According to the Discord documentation, this is not always necessary for slash commands, but in our case we’ll need it later to make additional API calls. Back in the application page in the Discord Developer console, under the Bot tab, click “Add Bot.”

Adding a new bot

Next, we need to add the bot to a Discord server (also known as a “guild” in the Discord API). More precisely, we’re granting it permissions in the guild to behave as a bot user and to respond to commands.

Discord’s documentation shows how you can authorize a bot via an OAuth2 flow. If you have “manage server” permissions on the guild, you can go to a particular page and grant guild permissions to the bot. The URL for that page looks something like this:

https://discord.com/api/oauth2/authorize?client_id=MY_APPLICATION_ID&scope=bot%20applications.commands

Replace MY_APPLICATION_ID with the application ID from the Discord application’s page in the Discord developer console.

Authorizing a bot with a server

On the authorization page, choose the server from the dropdown, and authorize the application. Once this is done, the bot will show up as a user in the server. Behind the scenes, it will have the bot and applications.commands permissions that it will need to implement commands.

Creating a command

Next we’ll use the bot to create a command on the Discord server. This requires making calls to the Discord API. In this section, I’ll demonstrate how to call the API, provide credentials with those calls, and perform them from the command line.

Toys Scripts

I kind of wish Discord had a UI in its console for registering and managing commands, or at least an official CLI that will do so. As it is, creating a command is pretty involved and you pretty much have to write code to make an API call. To make it a bit easier to invoke this code, we’ll use the Toys gem to write quick command line scripts. Toys works similar to the familiar tool Rake, but is designed specifically for writing command line tools rather than make-style dependencies.

$ gem install toys

Writing a hello-world script is simple. Create a file called .toys.rb (the leading period is important.)

# .toys.rb

tool "hello" do
  def run
    puts "Hello, world!"
  end
end

The script can be run using:

$ toys hello
Hello, world!
$

Below, we’ll use scripts like this to run code that talks to the API.

Writing an API client

There are a few gems out there for Discord—I briefly looked into discordrb—but the needs of this bot are simple, and it’s instructive to roll your own client. Start by adding the HTTP client library Faraday to your Gemfile:

# Gemfile

source "https://rubygems.org"

gem "ed25519", "~> 1.2"
gem "faraday", "~> 1.4"
gem "functions_framework", "~> 0.9"

Then start a basic API client class. First, a helper method that makes different kinds of API calls and handles results.

# discord_api.rb

require "faraday"
require "json"

class DiscordApi

  private

  def call_api(path,
               method: :get,
               body: nil,
               params: nil,
               headers: nil)
    faraday = Faraday.new(url: "https://discord.com")
    response = faraday.run_request(method, "/api/v9#{path}", body, headers) do |req|
      req.params = params if params
    end
    unless (200...300).include?(response.status)
      raise "Discord API failure: #{response.status} #{response.body.inspect}"
    end
    return nil if response.body.nil? || response.body.empty?
    JSON.parse(response.body)
  end
end

Next, let’s write a method that lists the commands defined in a server by a Discord application.

# discord_api.rb

require "faraday"
require "json"

class DiscordApi
  DISCORD_APPLICATION_ID = "838132693479850004"
  DISCORD_GUILD_ID = "828125771288805436"

  def initialize
    @client_id = DISCORD_APPLICATION_ID
    @guild_id = DISCORD_GUILD_ID
  end

  def list_commands
    call_api("/applications/#{@client_id}/guilds/#{@guild_id}/commands")
  end

  private

  # def call_api...

end

The DISCORD_APPLICATION_ID is the Application ID of the Discord app (from the application’s general information page in the Discord developer console). The DISCORD_GUILD_ID is the ID of the Discord server, which is part of the server URL. Replace these with the values for your app. These are not secret values, and are safe to have in code, although normally you might want to read them from environment variables or a config file.

Now it should be easy to write a quick Toys script to call this method.

# .toys.rb

tool "list-commands" do
  def run
    require_relative "discord_api"
    result = DiscordApi.new.list_commands
    puts JSON.pretty_generate(result)
  end
end

And try running it:

$ toys list-commands
RuntimeError: Discord API failure: 401 "{\"message\": \"401: Unauthorized\", \"code\": 0}"
$

So that didn’t quite work. We got a 401 Unauthorized result from the API call. And of course that makes sense: we need to provide credentials, otherwise anyone can access our commands.

Authorizing Discord API calls

Discord uses a variety of OAuth2 flows for authorization, and these can of course be complicated, especially for applications that need to act on behalf of other users. For our case, however, a simple flow will work: authorizing as the bot user.

Each bot is assigned a bot token that it can use to authenticate to the Discord API. The bot token is a secret value; it’s essentialy the bot’s password. If you own a bot, you can find it on the bot’s page in the Discord developer console. Find your Discord application, navigate to the “Bot” tab, and click the “Copy” button for the Token, to copy the token to your clipboard. It will be a series of around 60 characters.

Because a token is sensitive information, it should not live in your code or source control. For now, we’ll alter our command line tool to read the token from a command line argument. In later articles, we’ll discuss strategies for accessing such secrets in production using Google Cloud Secret Manager.

First, modify the DiscordApi constructor to take the bot token as an argument.

# discord_api.rb

# ...

class DiscordApi
  def initialize(bot_token:)
    @client_id = DISCORD_APPLICATION_ID
    @guild_id = DISCORD_GUILD_ID
    @bot_token = bot_token
  end

  # ...

end

The token needs to be set in an authorization header in API requests. So modify the call_api helper method to set this header in the Faraday object’s constructor:

# discord_api.rb

# ...

class DiscordApi

  # ...

  private

  def call_api(path,
               method: :get,
               body: nil,
               params: nil,
               headers: nil)
    faraday = Faraday.new(url: "https://discord.com") do |conn|
      # Set the authorization header to include a token of type "Bot"
      conn.authorization(:Bot, @bot_token)
    end
    # make the request ...

  end
end

Finally, add a command line flag to set the token in the Toys script, and pass it into the DiscordApi constructor:

# .toys.rb

tool "list-commands" do
  flag :token, "--token TOKEN"

  def run
    require_relative "discord_api"
    client = DiscordApi.new(bot_token: token)
    result = client.list_commands
    puts JSON.pretty_generate(result)
  end
end

Now we can run the command line tool again, substituting in the actual token:

$ toys list-commands --token=$MY_BOT_TOKEN
[

]
$

If all went well, this should now display an empty array, indicating that the application has not yet installed any commands in this server.

Creating the Command

Now we finally have all the parts in place to make an API call to create a command in a Discord server. First, we’ll add a method to the client class that calls the Create Guild Application Command API. This API adds a command to a specific guild. (You can also create “global commands” that apply to multiple guilds, but they’re a bit more complicated to manage, so we’ll use guild-specific commands for now.)

# discord_api.rb

# ,,,

class DiscordApi

  # ...

  def create_command(command_definition)
    definition_json = JSON.dump(command_definition)
    headers = {"Content-Type" => "application/json"}
    call_api("/applications/#{@client_id}/guilds/#{@guild_id}/commands",
            method: :post,
            body: definition_json,
            headers: headers)
  end

  private

  # def call_api...

end

That method takes a hash object that describes the command to create. For this project, this is a Scripture lookup command called /bible, which takes one required option, the Scripture reference to look up. Here’s the definition of this command:

{
  name: "bible",
  description: "Simple Scripture lookup",
  options: [
    {
      type: 3,
      name: "reference",
      description: "Scripture reference (e.g. `JHN.1.1-JHN.1.5`)",
      required: true
    }
  ]
}

The full format is specified in the Discord documentation.

So I wrote a quick Toys script that takes the above description and feeds it into the API:

# .toys.rb

tool "create-command" do
  flag :token, "--token TOKEN"

  def run
    require_relative "discord_api"
    client = DiscordApi.new(bot_token: token)
    definition = {
      name: "bible",
      description: "Simple Scripture lookup",
      options: [
        {
          type: 3,
          name: "reference",
          description: "Scripture reference (e.g. `JHN.1.1-JHN.1.5`)",
          required: true
        }
      ]
    }
    result = client.create_command(definition)
    puts JSON.pretty_generate(result)
  end
end

# The "list" command from before is still here ...
tool "list-commands" do
  flag :token, "--token TOKEN"

  def run
    require_relative "discord_api"
    client = DiscordApi.new(bot_token: token)
    result = client.list_commands
    puts JSON.pretty_generate(result)
  end
end

Now I was able to call the new script, again substituting in the bot token. The script creates the command and displays the result:

$ toys create-command --token=$MY_BOT_TOKEN
{
  "id": "838195819437228113",
  "application_id": "838132693479850004",
  "name": "bible",
  "description": "Simple Scripture lookup",
  "version": "838195819437228114",
  "default_permission": true,
  "guild_id": "828125771288805436",
  "options": [
    {
      "type": 3,
      "name": "reference",
      "description": "Scripture reference (e.g. `JHN.1.1-JHN.1.5`)",
      "required": true
    }
  ]
}
$

And now calling list again, shows the new command:

$ toys list-commands --token=$MY_BOT_TOKEN
[
  {
    "id": "838195819437228113",
    "application_id": "838132693479850004",
    "name": "bible",
    "description": "Simple Scripture lookup",
    "version": "838195819437228114",
    "default_permission": true,
    "guild_id": "828125771288805436",
    "options": [
      {
        "type": 3,
        "name": "reference",
        "description": "Scripture reference (e.g. `JHN.1.1-JHN.1.5`)",
        "required": true
      }
    ]
  }
]
$

Now what?

Great, we’e created a command in our Discord server! The Discord UI updates immediately, so now it should be possible to go to a chat room in the Discord server, and type /bible, and see the autocomplete kick in.

But of course, if you’re following along, and try to invoke an entire command:

Invoking a command

…you’ll get this:

Interaction failed

And that’s because we haven’t actually implemented the command in our webhook yet. That will be the topic of part 3.

Notes

I work at Google in my day job, so all code in this article is:

Copyright 2021 Google LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Dialogue & Discussion