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.”
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.
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:
…you’ll get this:
And that’s because we haven’t actually implemented the command in our webhook yet. That will be the topic of part 3.
Top comments (0)