#4 Discord bot & stackable queries

Fourth article of a series about Phoenix and Elixir. This one will be focused on creating a Discord bot and making Ecto queries easy.

#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
mix.exs

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
lib/tasty_recipes/discord/bot_consumer.ex

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"
config/config.exs

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

...
lib/tasty_recipes/application.ex

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 :

Yay, we made it !

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
lib/tasty_recipes/discord/bot_consumer.ex

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
lib/tasty_recipes/discord/bot_consumer.ex
Sounds good !

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
lib/tasty_recipes/discord/bot_consumer.ex

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.

Fair enough!

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
lib/tasty_recipes/discord/bot_consumer.ex

This is pretty much the same thing that for finding last recipe with some little updates.

Looks nice, everything is working!

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
lib/tasty_recipes/recipes/recipe.ex

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
lib/tasty_recipes/recipes/recipe.ex

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()
lib/tasty_recipes/discord/bot_consumer.ex

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.