Start building your own chatbot now >

In this tutorial, we’re going to build a notifier bot. Sending notifications is a delicate topic, especially when the user doesn’t specifically ask for them. It can make your bot seems a bit pushy and intrusive, and in the worst case lead to the user leaving the conversation.

But when used smartly, sending notifications is a great tool to keep your users engaged in the conversation with your bot. Many use cases can benefit greatly from that. Some examples:

  • a ticket-booking bot, which sends you a message if a ticket is available on the trip you absolutely need to take
  • a reminder bot, a la Slackbot, super-useful to make sure you don’t forget anything
  • a bot representing a singer or a brand, able to inform users that new content has been released

At Recast.AI, we wanted a simple tool to check regularly if our other chatbots were up, and to warn us as soon as possible if they were not, no matter the reason. Sounds like a good use case for a notifier chatbot, doesn’t it? Of course, there are some great customisable tools around here that can deal with this sort of things, but we wanted something simple to use and setup to monitor ephemeral chatbots.

This tutorial doesn’t focus on the training part of bot building, but more on coding the logic. If you want to learn how to build a rock solid conversation flow, check out this tutorial first.

At Recast.AI, we build the majority of our chatbot in Node.JS, but in this tutorial we’re going to use Elixir. It’s a relatively recent functional programming language, running on the BEAM, the Erlang VM. With a Ruby-like syntax focused on expressiveness, it’s a real pleasure to use!

Among its advantages, we can cite:

  • fault-tolerance and resiliency, with tools like Supervision tree
  • concurrency, everything is a lightweight process that you can run in great number
  • scalability, ease to split an app in nodes

The code is available on github here and the Recast.AI bot here. Let’s begin!

1 – Bot training

First, let’s begin with a short training to get everything running. We want the bot to check if web apps are up and running, and to warn us if it’s not the case. Let’s create our three most important intents:

  • watch: triggered when the user asks the bot to watch an endpoint
  • stop_watch: triggered when the user asks the bot to stop monitoring an endpoint
  • list: triggered when the user wants to know which endpoints the bot is monitoring

Those are the heart of our bot, it’s what he’s made for. They will trigger logic in the code of the bot when detected. We want our bot to be polite, so we’re also adding basic intents: greetings and goodbye. You can add as much small talk to your bot as you would like to give him more personality or a more human feel.

You can either create and train those intents from scratch, or fork what we’ve already built here. Feel free to improve your bot’s training by adding more expressions in the intents. It’s a good practice to have around ~30 expressions / intents.

Some examples of sentences per intent:

Vary the grammatical construction of your sentences as much as possible, as it’s key for a good training. You can get some insights on your bot training in the Monitor > Training analytics tab.

Now our bot is trained to understand the intent behind users inputs. That’s good, but not enough. We also need to detect and extract information contained in the sentences.

For example, in “Can you ping https://api.recast.ai and warn me if it’s not up”, we want to know that the intent behind the sentence is watch, but also that the endpoint our bot has to ping is “https://api.recast.ai”.

Fortunately, Recast.AI automatically detects a list of gold entities, which among others includes URL (the full list is available here). That means we don’t have to train our bot to recognise them, it’s already able to!

2 – Conversation flow

Now that we have trained our bot, let’s build our conversation flow so he knows when to answer, what to answer, and what information he needs to obtain from the user to continue the conversation. In the end, it should look like this.

If you have created other intents, don’t forget to add them here or your bot won’t use them. You can achieve this by clicking on the “+” in the toolbar. Now open watch and stop_watch actions, and add the URL notion in both of them, as shown below.

 

Feel free to change the replies to give your bot the personality you want. You can leave the replies empty for watch, stop_watch and list when those actions are done, as the logic and answers will be handled in the code. Be sure to set all the other replies though, or your bot will not answer every time it receives a message.

We haven’t gone too deep into what the Bot Builder has to offer, so if you want a more comprehensive tour check out this tutorial.

Congrats, your bot training is now complete! Let’s dive in the code.

3 – Bot code

First and foremost, start by cloning the repository on github here.

$> git clone https://github.com/jhoudan/vigile
$> cd vigile

If Elixir is not installed on your machine yet, you can find all the information here.

The architecture should like that:

The config folder contains, as you could expect, our config file. Create a file in the folder called dev.exs containing the language of your bot, your Recast.AI request token (you can find it in the settings of your bot on the platform), and the interval between each ping your bot will perform on the same endpoint.

use Mix.Config

config :vigile,
  recast_token: "YOUR_REQUEST_TOKEN",
  language: "en",
  interval: 60_000

The mix.exs files contains a bunch of information on how to start the application, no changes required here. It also contains the dependencies of our project. Type mix deps.get in your terminal to install them.

All our code is contained in the lib/vigile folder. Let’s first open the application.ex file.

defmodule Vigile.Application do
 @moduledoc false

 use Application

 def start(_type, _args) do
  import Supervisor.Spec, warn: false

  children = [
   Plug.Adapters.Cowboy.child_spec(:http, Vigile.Bot, [], port: 4242),
   worker(Vigile.Database, []),
  ]

  opts = [strategy: :one_for_one, name: Vigile.Supervisor]
  Supervisor.start_link(children, opts)
 end
end

This code is called when our application is started. It declares a Supervisor with two children. For those of you who are not familiar with what a Supervisor is, the official documentation has a really comprehensive description here.

Basically, it’s a process in charge of monitoring other processes, called child processes. It’s one of the strength of Elixir / Erlang, it allows you to build supervision tree, a nice way to structure fault-tolerant and resilient applications. If, for instance, one of our child processes crashes, it won’t take our entire application down. The Supervisor will just restart this part of the application, according to the strategy you specified in the opts parameter.

As you can see, our supervisor has two children. The first one, Vigile.Bot, is the chatbot process, in charge of receiving, understanding, and sending back messages. The other one, Vigile.Database, keeps track of the endpoints the bot has to monitor, and sends notifications if one of them doesn’t send back a 2XX HTTP response when pinged.

3.1 – Bot Section

Let’s start by the recast.ex file. It contains two functions that interact with the Recast.AI API, the foundation of our chatbot.

The first one, converse_text, is called when we want to analyse a user input. We get back information like the intent detected, the entities extracted and enriched, or the pre-defined replies we’ve set in the Bot Builder on the platform earlier. Everything we need to determine what logic we have to do, and what replies our bot will send back.

We use the second one, send_replies, to send back answers to the user discussing with the bot using the Bot Connector API. It takes an array of messages, so you can send as much as you like at once. You can choose to send rich format of messages, like cards or lists. More details on the format can be found here.

Both of them work the same way: first we build the request body, then we make the HTTPs call and handle the response we get back (either a success or an error).

defmodule Vigile.RecastAI do
  @moduledoc false

  @connect_url "https://api.recast.ai/connect/v1"
  @converse_url "https://api.recast.ai/v2/converse"

  @language Application.get_env(:vigile, :language)
  @token Application.get_env(:vigile, :recast_token)

  def converse_text(text, conversation_token \\ nil) do
    body = {:form,
      [{"text", text},
       {"language", @language},
       {"conversation_token", conversation_token}
    ]}

    with {:ok, %{body: body, status_code: 200}} <- HTTPoison.post(@converse_url, body, headers()),
         {:ok, %{"results" => results}} <- Poison.Parser.parse(body) do
      {:ok, results}
    else
      {:ok, %HTTPoison.Response{}} -> {:error, :auth_error}
      {:error, %HTTPoison.Error{}} -> {:error, :request_error}
      _ -> {:error, :parsing_error}
    end
 end

 def send_replies(messages, conversation_id) do
   route = "#{@connect_url}/conversations/#{conversation_id}/messages"

   with {:ok, messages} <- Poison.encode(messages),
        body <- {:form, [{"messages", messages}]},
        {:ok, %{status_code: 201}} <- HTTPoison.post(route, body, headers()) do
     {:ok, "Message sent with success"}
   else
      {:error, reason} -> {:error, reason}
      err -> {:error, err}
   end
 end

  # Helpers
  defp headers, do: [{"Authorization", "Token #{@token}"}]

end

Now open action.ex. The function handle_action takes a response from converse_text, and returns the reply to send back to the user.

We start by extracting the current action, aka where we are in the conversation flow. If there’s a current action (meaning, we have an action slug), the action is done (we have all the information we expect) and the current action is in the list of the actions we want to handle specifically in our code, we try to call the function associated to it. If it fails, we return a default reply defined at the end of the file.
If there’s no action detected, or if the action detected isn’t done yet, we send back the reply we’ve filled in the Bot Builder on the platform earlier.
If something has gone wrong earlier when calling the Recast.AI API, the second declaration of handle_action is called, meaning that our bot will never let a user in the dark if something bad happened.

def handle_action({:ok, %{"replies" => replies} = result}) do
  action_slug = Kernel.get_in(result, ["action", "slug"])
  action_done = Kernel.get_in(result, ["action", "done"])

  case {action_slug, action_done, replies} do
    {slug, true, _r} when slug in @actions ->
      try do
        apply(__MODULE__, String.to_atom(slug), [result])
      rescue
        _ -> error_reply()
      end
    {_s, _d, [head | _tail]} -> [%{type: "text", content: head}]
      _ -> error_reply()
    end
 end
 def handle_action({:error, _err}), do: error_reply()

The second part of the file contains the functions associated to the actions we want to handle.

The watch and stop_watch functions are quite similar: they extract the url from the information detected in the user input and call a method of our module in charge of the database (more details on this later), one to store a new url to watch, the other to suppress it and stop monitoring it.

The list function doesn’t need an url: it directly calls our database, and returns the list of endpoints monitored for the current user.

The “|>” (pipe) operator is one of my favourite features of Elixir: it allows you to chain your functions, passing the result of one to another as the first parameter.

def watch(%{"entities" => entities, "conversation_token" => id}) do
  url = get_url(entities)
  Database.insert_url(id, url)
  [%{type: "text", content: "I will watch #{url} for you."}]
end

def stop_watch(%{"entities" => entities, "conversation_token" => id}) do
  url = get_url(entities)
  case Database.delete_url(id, url) do
    {:error, :not_found} -> [%{type: "text", content: "You didn't ask me to watch #{url}"}]
    {:ok, _res} -> [%{type: "text", content: "I stopped watching #{url}"}]
  end
end

def list(%{"conversation_token" => id}) do
  urls = id
  |> Database.get_user_urls
  |> Enum.map(fn {_id, url} -> "- #{url}" end)
  |> Enum.join("\n")

  case urls do
    "" -> [%{type: "text", content: "I don't watch any url for you right now."}]
    urls -> [%{type: "text", content: "Here's the list of the urls I'm currently watching for you:\n#{urls}"}]
  end
end

Note: the function names have to match the actions you’ve created in the Bot Builder. If you’ve named them differently, you need to update them also here. In the same logic, if you want to handle other actions specifically in the code, their name has to match the functions here.

Now let’s explore bot.ex. This file contains the API’s routes of our bot. It uses Plug, “a specification and conveniences for composable modules between web applications.”. It’s quite similar to Rake, for those of you who are rubyist, or like a succession of Express middleware for the Node.JS aficionados. The most interesting part is the declaration of the “/“ route.

post "/" do
  message = Map.get(conn.body_params, "message")
  conversation_id = Map.get(message, "conversation")

  message
  |> Kernel.get_in(["attachment", "content"])
  |> RecastAI.converse_text(conversation_id)
  |> Action.handle_action()
  |> RecastAI.send_replies(conversation_id)

  send_resp(conn, 200, "Roger that")
end

This route is called by the Bot Connector when a new message is received. In my case, I’ve connected the bot to Slack, but you are free to connect it to your favourite channel, like Messenger, Twitch or Cisco Spark.
When a new message is received, we extract the user’s message and the id of the conversation. We send the content of the message to the Recast.AI API via our converse_text function seen earlier, chain the result to handle_action to perform some logic according to the position of the user in the conversation flow, and pass the result directly to send_replies to send it back to the user, which will receive it directly on the channel he’ve sent a message from.

And that’s it for the chatbot part! There are a few helpers here and there, but there are really simple and the Elixir expressiveness make them easy to understand. If you have any questions though, don’t hesitate to ask them in the comments.

3.2 Notification section

The notification section is composed of two files. Let’s start with checker.ex. It contains a single function, check_url. It takes an url and a conversation id as parameters and make a GET request on the url. If it receives a status code other than 2XX, it sends a message containing some details of what happened to the user that asked to be notified.

def check_url(url, id) do
  case HTTPoison.get(url) do
    {:ok, %HTTPoison.Response{"status_code": code}} when code >= 200 and code <= 299 ->
      :ok
    {:ok, %HTTPoison.Response{"status_code": code, "body": body}} ->
      [%{type: "text", content: "Something went wrong while pinging #{url}"},
       %{type: "text", content: "Code: #{code}\nResult: #{body}"}]
      |> RecastAI.send_replies(id)
      :error
    err ->
      [%{type: "text", content: "Something went wrong while checking #{url}!"}]
      |> RecastAI.send_replies(id)
      :error
  end
end

Now let’s switch to database.ex. This module is based on the GenServer behaviour, used to implement a client-server relation. Our module will keep track of a state, and will have an API so our other processes can communicate with it easily. If you want more details on how’s working a GenServer, check out the official documentation.

In our case, the database module keeps a DETStable (a disk-based redis-like storage engine) in its state, and provides three public functions our chatbot section can use to list, insert and delete the urls our bot has to ping.

# Database API

def get_user_urls(id), do: GenServer.call(@name, {:lookup, id})

def insert_url(id, url), do: GenServer.cast(@name, {:insert, id, url})

def delete_url(id, url), do: GenServer.call(@name, {:delete, id, url})

Those functions, in turns, calls three other functions declared at the end of the file that perform the actual logic and interactions with the DETS table.

def handle_cast({:insert, id, url}, table) do
  :dets.insert(table, {id, url})
  schedule_check(id, url)
  {:noreply, table}
end

def handle_call({:lookup, id}, _from, table) do
  {:reply, :dets.lookup(table, id), table}
end

def handle_call({:delete, id, url}, _from, table) do
  case :dets.match(table, {id, url}) do
    [] ->
      {:reply, {:error, :not_found}, table}
    _res ->
      urls = table
      |> :dets.lookup(id)
      |> Enum.filter(fn {_i, u} -> u != url end)

      :dets.delete(table, id)
      :dets.insert(table, urls)
      {:reply, {:ok, urls}, table}
  end
end

Next, there are two functions related to the GenServer behaviour. The start_link is mandatory, and called by our Supervisor to link the database module to our supervision tree. Now if it crashes, it will be automatically restarted, and won’t crash our entire application.

The init function is a callback automatically called once start_link has been executed. It’s there the DETS table is initiated and all the urls currently present in the database are fetched to launch the monitoring of each one.

def start_link do
  GenServer.start_link(__MODULE__, nil, name: @name)
end

def init(_state) do
  Logger.info "Database started"

  with {:ok, table} <- :dets.open_file(:users, [type: :bag]) do
    table
    |> :dets.match_object({:"$0", :"$1"})
    |> Enum.map(fn {i, u} -> schedule_check(i, u) end)

    {:ok, table}
  else
    err -> {:error, err}
  end
end

Now we are at the culmination of this tutorial, the actual notification sending! It’s handled by two functions.

The first, schedule_check, simply sends a message to the GenServer module every minute with a url to check. You can change the interval in your config if you want the bot to ping the endpoints more or less often.

The second, handle_info, is called to perform the actual check once the interval is done. It fetches the url in the database and, if the url hasn’t changed and still exists, call asynchronously the Checker module we’ve seen earlier and call schedule_check recursively.

defp schedule_check(id, url) do
  Logger.info "Goin to check #{url}, #{id}"
  Process.send_after(self(), {:check, id, url}, @interval)
end

def handle_info({:check, id, url}, table) do
  with urls <- :dets.lookup(table, id),
       {^id, ^url} <- Enum.find(urls, fn {i, u} -> i == id && u == url end) do
    Task.async(fn -> Checker.check_url(url, id) end)
    schedule_check(id, url)
  else
    _ -> Logger.info "Config has changed for #{url} for user #{id}. Monitoring stopped"
  end
  {:noreply, table}
end

Usage

Now you have a deeper understanding of the way the bot work, it’s time to launch it.
If you haven’t installed the dependencies, now is time to do it by typing mix deps.get in your terminal.
Once it’s done, type the following command to get your bot up and running.

$> mix run —no-halt

If everything goes well, you should have logs similar to:

Now if you want to test your bot, you need to connect it to a channel via the Bot Connector. You can do it in the Run > BotConnector tab on the platform. You also need to make your bot running locally available to the outside, by using ngrok for example.

And that’s it for our notifier chatbot! There are a lot of ways to improve it! We can for instance make it send a message if one of the web apps he’s monitoring goes up again or keep track of the response time and the uptime. Or even make it possible to the user to specify what response he expects from the url he wants to be monitored.

There are also a lot of other use cases to explore where using a chatbot able to send notifications can be a great plus. I would love to have a bot that can send me a notification if a train ticket if available when I plan my holidays! Feel free to share your ideas in comments.

Want to build your own conversational bot? Get started with Recast.AI !

Subscribe to our newsletter


There are currently no comments.