#4 Discord bot & stackable queries
Discover the Phoenix Series
#1・Handle HTML & API Authentication
#2・Automatic Deployment
#3・Pimp my Phoenix
#4・Discord Bot & Stackable Queries
Before jumping right into this new article, note that I am currently using Phoenix 1.5.10 for this series and that all articles are going to get an update soon to match the current state (1.6.x) of Phoenix : I am talking about the new authentication logic generation, assets managements, config, etc. Stay tuned!
For this 4th article, I want to show you how easy it is to create a Discord bot using Nostrum. We will create this bot on top of TastyRecipes, the project we are developping since we started this series! Next, let's talk about something that I like to call "stackable queries", I use it to simplify the way I query data using Ecto.
Nostrum is an Elixir library wrapping Discord API. You probably can do your own wrapper but I will use this one because I am way too lazy for this.
Setup Discord consumer
First of all, add Nostrum to your dependencies :
defp deps do
[
...
{:nostrum, "~> 0.4"},
{:cowlib, "~> 2.11", hex: :remedy_cowlib, override: true}
]
end
To avoid dependencies resolution errors, you will need to specify override for cowlib. It seems that this problem is well known and will be resolved in the next major update.
Create the consumer that will handle messages coming from your Discord server:
defmodule TastyRecipes.BotConsumer do
use Nostrum.Consumer
alias Nostrum.Api
def start_link do
Consumer.start_link(__MODULE__)
end
def handle_event({:MESSAGE_CREATE, msg, _ws_state}) do
case msg.content do
"!hello" ->
Api.create_message(msg.channel_id, "world!")
_ ->
:ignore
end
end
# Default event handler, if you don't include this, your consumer WILL crash if
# you don't have a method definition for each event type.
def handle_event(_event) do
:noop
end
end
Note that we use pattern matching to handle events. Since this is just a quick demonstration of Nostrum workflow, we will just process create_message
events.
For the next part, you will need to create an application on the Discord developer portal. Once it is done, you will find a token inside the Bot section of your app. Use this token to update the Nostrum configuration:
...
config :nostrum,
token: "ODg3NzQxHdfRTTA2MjAyNjc2.YUIkFw.Ao0Im5olpyOM4LlOOiIiuGCcVhI"
Let's add our consumer to the supervisor's children so it is started alongside Phoenix:
...
def start(_type, _args) do
children = [
# Start the Ecto repository
TastyRecipes.Repo,
# Start the Telemetry supervisor
TastyRecipesWeb.Telemetry,
# Start the PubSub system
{Phoenix.PubSub, name: TastyRecipes.PubSub},
# Start the Endpoint (http/https)
TastyRecipesWeb.Endpoint,
# Start a worker by calling: TastyRecipes.Worker.start_link(arg)
# {TastyRecipes.Worker, arg}
TastyRecipes.BotConsumer
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: TastyRecipes.Supervisor]
Supervisor.start_link(children, opts)
end
...
We should now be ready to make our first tests ! For this, you need to add your bot to your Discord server. You can create the invitation link that will do the job. It should looks like:
https://discord.com/api/oauth2/authorize?client_id=123456789012345678&permissions=2048&scope=bot%20applications.commands
You just need to replace the client_id
value with your application ID. Permissions are set to 2048
which means that when clicking the link, you will be asked to authorize the bot to send messages on your server. In our case, this configuration is enough but you can update it as you may need more permissions.
When your bot has arrived, try to communicate with him! Run your Phoenix server mix do deps.get, phx.server
and try to type !hello
in your discord server :
But... Is that all?
Stackable queries
Alright, now that we can chitchat with our bot, maybe we can update it a little bit so it will do something useful! Let's think about it. Here is a short list of what we can do :
- Retrieve number of recipes available
- Retrieve given user's last recipe
- List all user's recipes containing a specfic word in its name
First, let's update handle_event/1
logic so it will handle mutliple commands:
def handle_event({:MESSAGE_CREATE, msg, _ws_state}) do
command = List.first(String.split(msg.content))
case command do
"!count" -> count_recipes(msg)
"!last_recipe" -> last_recipe(msg)
"!recipes" -> recipes_matching(msg)
_ -> :ignore
end
end
Printing the number of available recipes looks pretty simple, here is something we can do :
def count_recipes(msg) do
count = length(Recipes.list_recipes())
Api.create_message(
msg.channel_id,
"There is actually #{count} #{Inflex.inflect("recipe", count)} on the website!"
)
end
Note: I like to use Inflex to pluralize values and word inflections in general.
Next, when looking for a specific user's last recipe, things spice a lit bit :
def last_recipe(msg) do
# Extract email from message
try do
args = List.to_tuple(String.split(msg.content))
email = elem(args, 1)
# Get the last recipe, nil if no result found
recipe =
Recipe
|> join(:left, [r], u in User, on: r.owner == u.id)
|> where([r, u], u.email == ^email)
|> order_by([r, u], desc: r.inserted_at)
|> limit(1)
|> Repo.all()
|> List.first()
last_recipe_message(msg.channel_id, recipe, email)
rescue
ArgumentError ->
Api.create_message(
msg.channel_id,
":warning: That's not how you should use it..."
)
end
end
def last_recipe_message(channel_id, %Recipe{} = recipe, email) do
show_url = Routes.html_recipe_show_url(Endpoint, :show, recipe.id)
Api.create_message(
channel_id,
":white_check_mark: Last recipe for **#{email}** is **#{recipe.name}**!\nCheck it there : #{show_url}"
)
end
def last_recipe_message(channel_id, nil = _recipe, _email) do
Api.create_message(
channel_id,
":question: No recipe found for this user, sorry."
)
end
Using user's email, our function last_recipe/1
will try to find his most recent recipe. Pattern matching is then used to send a response depending of the outcome.
Another one to find recipes that contain a string in their name and belongs to a given user:
def recipes_matching(msg) do
try do
args = List.to_tuple(String.split(msg.content))
email = elem(args, 1)
search = elem(args, 2)
recipes =
Recipe
|> join(:left, [r], u in User, on: r.owner == u.id)
|> where([r, u], u.email == ^email and ilike(r.name, ^"%#{search}%"))
|> order_by([r, u], desc: r.inserted_at)
|> Repo.all()
recipes_matching_message(msg.channel_id, recipes, email, search)
rescue
ArgumentError ->
Api.create_message(
msg.channel_id,
":warning: That's not how you should use it..."
)
end
end
def recipes_matching_message(channel_id, recipes, email, search) do
message = ":white_check_mark: Recipes containing \"**#{search}**\" for **#{email}** found:\n\n"
list = for recipe <- recipes do
"・ " <>
recipe.name <>
" - " <>
Routes.html_recipe_show_url(Endpoint, :show, recipe.id) <>
"\n"
end
Api.create_message(
channel_id,
"#{message} #{list}"
)
end
def recipes_matching_message(channel_id, [] = _recipes, _email, _search) do
Api.create_message(
channel_id,
":question: No recipe found, sorry."
)
end
This is pretty much the same thing that for finding last recipe with some little updates.
Lets dry this queries
If you take a closer look at the queries, you can see that we kind of repeat ourself when we look for a specific user email, for example. Now imagine that instead of having three commands, we had a hundred ones to manage, where we do the exact same where
check. For easier management, it might be useful and more readable for this piece of code to be in a single place :
def with_owner_email(query \\ Recipe, email) do
query
|> join(:left, [r], u in User, on: r.owner == u.id)
|> where([r, u], u.email == ^email)
end
Let's do the same thing for recipe's name matching :
def name_contains(query \\ Recipe, search) do
query
|> where([r], ilike(r.name, ^"%#{search}%"))
end
This is way more convenient ! You will not tear your hair out if for some reasons you have to update some part of the query, rename fields or update your database schema. The bonus is that you can "stack" (or chain or compose, call it as you prefer) your queries in a simple way :
# This query from recipes_matching/1
recipes =
Recipe
|> join(:left, [r], u in User, on: r.owner == u.id)
|> where([r, u], u.email == ^email and ilike(r.name, ^"%#{search}%"))
|> order_by([r, u], desc: r.inserted_at)
|> Repo.all()
# Becomes
recipes =
Recipe
|> with_owner_email(email)
|> name_contains(search)
|> order_by([r, u], desc: r.inserted_at)
|> Repo.all()
Don't you think this is better? This will take some times to adapt your code and create every functions needed to query your data but what a gain of time once everything is setup!
Note that you can chain as many queries as you want but you will need to integrate a little trick to handle multiple joins.
I hope you liked today's article! See you soon for more.
If you have a problem and no one else can help. Maybe you can hire the Kalvad-Team.