Elixir for Rubyists - Cat Facts in Discord [Part II]

Marcin Ruszkiewicz - Senior Ruby on Rails Developer
7 minutes read

In the first part of this article, we've built aa working Discord bot, and learned some Elixir basics on the way. So far, it is able to react to a !catfact command in any channel, and respond with a static text message.

In this part, we'll be working to improve how our bot works. We'll be adding a way to create some cat facts of our own, save them in a database, and let the bot respond with a random fact from the list it has.


In this part, we'll be working to improve how our bot works. We'll be adding a way to create some cat facts of our own, save them in a database, and let the bot respond with a random fact from the list it has.

Creating a Discord slash command

If we want to add some facts to our bot, we should use slash commands rather than regular text messages. First of all, we don't necessarily want other people to see what we're doing with the bot (it's pretty innocent in this case, but you can imagine it would matter more if we used the bot for managing roles and members, for example) and we might want to restrict it to e.g., server administrators. While there are several solutions to this, slash commands are the easiest.We'll be implementing the /fact command that will be only available to admins and will take a string argument. For this, we need to declare the command first, changing our :READY event handler:

 def handle_event({:READY, info, _state}) do
    command = %{
      name: "fact",
      description: "Add a cat fact.",
      default_member_permissions: 8, # admin
      options: [
        %{
          type:3, # string
          name: "content",
          description: "Cat fact to be added.",
          required: true
        }
      ]
    }

    Enum.each(info.guilds, fn guild ->
      Api.bulk_overwrite_guild_application_commands(guild.id, [command])
    end)
  end

The command itself is just the Elixir map type variable. Unfortunately Nostrum doesn't provide any constants for the magic numbers in it. The first one is default_member_permissions: 8, which indicates that this command will be accessible only to server administrators.

The type:3 inside options tells Discord that this particular argument should be treated as a string. You can see the list of possible options in the Discord documentation.

This whole map will be automatically converted to JSON by Nostrum and sent to Discord to set up our command.

Then, we use a Enum.each function to iterate over the list of guilds provided to us in the event. This function takes two arguments. First one is our List of guilds, and the second one is just a function. In this case, we're using an anonymous function that takes an element of the List that we iterate on.

In this anonymous function, we're once again calling a function from the Nostrum.Api module we aliased in the first part. This will set up all our commands in the specified server.

Let's go over to Discord and see if our setup worked. As an admin, we should be able to see and use our /fact command – and that is exactly what we can see:

If we change over to our regular user, we can see that the command is unavailable just as expected:

As you can see, our command works correctly, even though for now it does nothing. First we need to have some database support in our application and some ability to save and retrieve data. In order to do it, we need some additional packages.

Setting up our application to use a database

We've already added one package in the previous part of the article, so this step should be easy now. We'll be using the most popular Elixir ORM, Ecto SQL, and we also need a database driver package – we'll use Postgrex to interface with a simple Postgres database.

In short, we need to add both of them to our mix.exs and run the mix deps.get task to download all our dependencies.

defp deps do
  [
    {:nostrum, git: "https://github.com/Kraigie/nostrum.git"},
    {:ecto_sql, "~> 3.10"},
    {:postgrex, ">= 0.0.0"}
  ]
end

Once this is done, we should run the mix ecto.gen.repo -r CatFacts.Repo command to initialize our Repo. This command also provides two additional bits of code – one to add in lib/cat_facts/application.ex so that the supervisor knows to run and handle the database connection:

def start(_type, _args) do
  children = [
    CatFacts.Repo,
    CatFacts.BotConsumer
  ]

  # See https://hexdocs.pm/elixir/Supervisor.html
  # for other strategies and supported options
  opts = [strategy: :one_for_one, name: CatFacts.Supervisor]
  Supervisor.start_link(children, opts)
end

and another in the config/config.exs so that our application knows what Repo it should use. Obviously you should provide proper values here for the user and password for your local Postgres server.

config :cat_facts, CatFacts.Repo,
  database: "cat_facts_dev",
  username: "user",
  password: "password",
  hostname: "localhost"

config :cat_facts, ecto_repos: [CatFacts.Repo]

Now we should be able to execute another console command - mix ecto.create - to create our database.

mix ecto.create
Compiling 4 files (.ex)
Generated cat_facts app
The database for CatFacts.Repo has been created

A database itself isn't very useful though. We have to take it a step further and introduce another concept: Contexts.

Contexts and schemas

Contexts are more of a Phoenix (the Elixir's equivalent to Ruby on Rails) concept than strictly Elixir's ones, but they're a good design pattern, so we can use them even without it – there aren't any Phoenix specific packages we should import in order to do so.

A context in itself is just an Elixir module that groups specific functionality – talks to a database or remote APIs in order to do some specific things. A typical example of a context would be Users, which would have all the specifics on how to create and edit user records, and so on.

Here, we're dealing with Facts, so let's name our context that. We'll make a new file - facts.ex in the lib/cat_facts directory.

defmodule CatFacts.Facts do
  import Ecto.Query, warn: false
  alias CatFacts.Repo

  def get_random_guild_fact(guild_id) do

  end

  def create_guild_fact(guild_id, content) do

  end
end

In this example, we will be only inserting new facts into the database without editing or removing them, so we only have two functions in our context. One of them is to create a new fact tied to a Discord server (they call servers "guilds" in their documentation, so that's what we'll call them inside our code too), and another to get a random fact from the database.

We now need a schema and a database table to hold our facts. A schema is what we would call in Rails a model, except we don't tend to put functions in it. Those should always go into the relevant context.

Creating those is pretty similar to what we're used in Rails, but since we don't have the Phoenix components in this project, we don't have the luxury of the task automatically creating all the files for us.

Running mix ecto.gen.migration add_facts in the console will create an empty migration file in the priv/repo/migrations/ directory. We should flesh it out with a table definition like below:

defmodule CatFacts.Repo.Migrations.AddFacts do
  use Ecto.Migration

  def change do
    create table(:facts) do
      add :guild_id, :bigint
      add :content, :text

      timestamps()
    end
  end
end

If you ever made a Rails migration, none of this should be a mystery - the types and the DSL are very similar, and the timestamps() function does exactly what we would expect, adding the created_at and updated_at datetime fields. The migration will automatically add an id column and make a sequence as well.

We can now run mix ecto.migrate to update our database.

Now that we have a table to hold our facts in, we can finally make a schema. First we should create a subdirectory facts in the lib/cat_facts directory and make a file called fact.ex in it. This will represent our single Fact record from the table we just made

defmodule CatFacts.Facts.Fact do
  use Ecto.Schema
  import Ecto.Changeset

  schema "facts" do
    field :guild_id, :integer
    field :content, :string

    timestamps()
  end

  @doc false
  def changeset(config, attrs) do
    config
    |> cast(attrs, [:guild_id, :content])
    |> validate_required([:guild_id, :content])
  end
end

This is all that the Fact schema will have. In Elixir, they're little more than simple representations of database records. As you can see above, in the schema macro we're declaring which database table and fields we want to be using. Another function is a changeset, which handles assigning changes to the database and validation. Right now both fields are required, but we could also validate other things here – like the length of the content, and so on.

Now we should get back to our Facts context and refine it.

Working with databases

We need to go back to our context file in lib/cat_facts/facts.ex. So far it's pretty empty, so we'll begin by adding an alias to our context, so that our code is shorter and more readable.

alias CatFacts.Facts.Fact

Now that we can simply use Fact to refer to our schema, let's start with inserting data:

def create_guild_fact(guild_id, content) do
  %Fact{}
  |> Fact.changeset(%{guild_id: guild_id, content: content})
  |> Repo.insert()
end

Here is where we can see how the pipes are making our code very readable.

We start with an empty struct representing our schema, %Fact{}. This struct gets passed to a Fact.changeset function together with our new attributes: Discord's server ID and the text of our message. The changed struct is then passed to Repo.insert(), which handles the actual interaction with our database.

Next we need a way to retrieve a random record. Ecto by itself doesn't have a random function, but Postgres does - and we can get a random record by ordering the records by their ID in a random way and just getting the first record. And that's exactly what we're going to do here:

def get_random_guild_fact(guild_id) do
  query =
    from f in Fact,
    where: f.guild_id == ^guild_id,
    order_by: fragment("RANDOM()"),
    limit: 1

  Repo.one(query)
end

Here we're building a simple Ecto SQL query – from our table defined by the Fact schema, we're taking records where the guild_id is equal to the passed argument (we need to write it as ^guild_id as it is an external variable to the query itself), which we're ordering randomly and limiting to only 1.

Then this prepared query is passed to Repo.onefunction, which will return the single record requested or nil.

We can quickly turn on the iex -S mix console and test our code:

iex -S mix
iex(1)> CatFacts.Facts.create_guild_fact(1, "test")

14:35:31.391 [debug] QUERY OK db=3.9ms decode=1.3ms queue=1.0ms idle=757.7ms
INSERT INTO "facts" ("guild_id","content","inserted_at","updated_at") VALUES ($1,$2,$3,$4) RETURNING "id" [1, "test", ~N[2023-09-06 12:35:31], ~N[2023-09-06 12:35:31]]
{:ok,
 %CatFacts.Facts.Fact{
   __meta__: #Ecto.Schema.Metadata<:loaded, "facts">,
   id: 1,
   guild_id: 1,
   content: "test",
   inserted_at: ~N[2023-09-06 12:35:31],
   updated_at: ~N[2023-09-06 12:35:31]
 }}

iex(2)> CatFacts.Facts.get_random_guild_fact(1)

14:36:05.721 [debug] QUERY OK source="facts" db=2.4ms idle=1718.8ms
SELECT f0."id", f0."guild_id", f0."content", f0."inserted_at", f0."updated_at" FROM "facts" AS f0 WHERE (f0."guild_id" = $1) ORDER BY RANDOM() LIMIT 1 [1]
%CatFacts.Facts.Fact{
  __meta__: #Ecto.Schema.Metadata<:loaded, "facts">,
  id: 1,
  guild_id: 1,
  content: "test",
  inserted_at: ~N[2023-09-06 12:35:31],
  updated_at: ~N[2023-09-06 12:35:31]
}

Looks like we're ready for the final step, which is handling the slash commands in Discord, and then using the code we just wrote.

Putting the bot together

Earlier in this article, we've initialized the command, but we also need a handle_event function to respond to it. But first, again – some new aliases.

alias Nostrum.Struct.Interaction
alias CatFacts.Facts

And now we can handle the incoming slash command. Those are send by Discord API to us and Nostrum converts them to events with an event type of :INTERACTION_CREATE, so that's what our function will be matching for:

def handle_event({:INTERACTION_CREATE, %Interaction{guild_id: guild_id, data: %{name: "fact", options: [%{value: content}]}} = interaction, _ws_state}) do
  Facts.create_guild_fact(guild_id, content)

  response = %{
    type: 4, # respond with a message
    data: %{
      content: "Your cat fact has been added to this server's list of cat facts.",
      flags: 64 # ephemeral message flag
    }
  }
  Api.create_interaction_response(interaction, response)
end

Again, let's walk through this code. First, we pattern match on the correct type of event and get the interesting data. That is which server it was issued in as guild_id, and what is the value of the command's argument as content - out of it. The whole interaction is also preserved in the interaction variable, since we'll be using it to respond.

Facts.create_guild_fact(guild_id, content)

This is the function from our Facts context we wrote above. We simply pass the variables into it to save our fact into the database.

response = %{
  type: 4, # respond with a message
  data: %{
    content: "Your cat fact has been added to this server's list of cat facts.",
    flags: 64 # ephemeral message flag
  }
}

Next we prepare the response map, which will be automatically converted by Nostrum to JSON and sent to the Discord API. Again, we see the inevitable magical numbers which we can check in the Discord's documentation – we want to respond with a text message, so we set the response type to 4, and in the data we set the message flags to 64, which represents an ephemeral message.

The whole interaction on Discord will look like this for the admin who uses the command:

The user observing this channel will only see the regular messages – without any bot’s interference:


Api.create_interaction_response(interaction, response)

And finally, we send the prepared response to the Discord API.

Now, all we need to do is modify our handle_event response so that a !catfact command in the channel will respond with a random fact. Right now the function looks like this – we just return a static string.

def handle_event({:MESSAGE_CREATE, msg, _state}) do
  command =
    msg.content
    |> String.split(" ", parts: 2, trim: true)
    |> List.first

  case command do
    "!catfact" ->
      Api.create_message(msg.channel_id, "This will be a Cat Fact.")
    _ ->
      :ignore
  end
end

Let's change it to use a private function instead, since it'll be more readable. We need to pass the whole msg struct here, because we need to get the Discord server id from it.

def handle_event({:MESSAGE_CREATE, msg, _state}) do
  command =
    msg.content
    |> String.split(" ", parts: 2, trim: true)
    |> List.first

  case command do
    "!catfact" ->
      respond_with_catfact(msg)
    _ ->
      :ignore
  end
end

And the function itself:

defp respond_with_catfact(msg) do
  response = "This will be a Cat Fact."

  Api.create_message(msg.channel_id, response)
end

We want to get a random fact instead of a static text, so let's change this responsedeclaration. As we remember from earlier, our Facts.get_random_guild_fact function returned a Repo.one, so it returns either a %CatFacts.Facts.Fact struct or a nil. This makes it very easy to write a case statement that will handle both cases:

defp respond_with_catfact(msg) do
  response =
    case Facts.get_random_guild_fact(msg.guild_id) do
      %CatFacts.Facts.Fact{content: content} ->
        content
      nil ->
        "I don't know any facts yet."
    end

  Api.create_message(msg.channel_id, response)
end

Api.create_message(msg.channel_id, response)
end

And that's it! Let's restart our server with iex -S mix again (using CTRL-C twice to quit it if it's still running) and let's test it out.

Now that the two versions work correctly, we are able to add new facts with our command. With that, our Discord bot example finally fulfills both tasks we set out to do, so the project is finally complete.

Next up – testing

Or is it? When working with Rails, we usually expect that our code will be automatically tested – either by RSpec or Minitest, and here, we haven't written a single line of test code. Doesn't it seem a bit lacking to leave our project in such a state?

Fortunately, the Elixir's Ruby inspirations also are present in this area, so it has a very good testing suite, complete with mocks and factories. We'll be dealing with that in the third part of this article.

On-demand webinar: Twilio implementation in GreenWay

Check how GreenWay improved customer engagement by implementing Twilio Studio with the assistance of our team.

On-demand webinar: Twilio implementation in GreenWay

Latest blog posts

See more

Ready to talk about your project?

1.

Tell us more

Fill out a quick form describing your needs. You can always add details later on and we’ll reply within a day!

2.

Strategic Planning

We go through recommended tools, technologies and frameworks that best fit the challenges you face.

3.

Workshop Kickoff

Once we arrange the formalities, you can meet your Polcode team members and we’ll begin developing your next project.