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.
Elixir for Rubyists - Cat Facts in Discord (Part 2)
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.one
function, 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 response
declaration. 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.
Latest blog posts
Advanced Features and Capabilities of AI Chatbots: Elevate, Engage, Excel with AI in eCommerce
Nov 4, 2024 by Łukasz Ceran
Why Custom AI Chatbots are the Future of E-commerce
Oct 30, 2024 by Anna Bober
The Business Case for Custom Chatbots: Elevate, Engage, Excel with AI in eCommerce
Oct 23, 2024 by Łukasz Ceran
Ready to talk about your project?
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!
Strategic Planning
We go through recommended tools, technologies and frameworks that best fit the challenges you face.
Workshop Kickoff
Once we arrange the formalities, you can meet your Polcode team members and we’ll begin developing your next project.