While attending the super-fun Vienna BEAMers study group, where we learn all things Elixir and Erlang, I’ve been making tiny contributions to the Panoptikum project, which creates a podcast-oriented social network. It’s been a great opportunity to contribute to a project at the same time as learning a completely new language.

The bot should work as follows: You send a message to the bot, and it should reply with some podcast recommendations.

Chatbot

Having attended the Lemmings summer incubator and learning to make chatbots, I ended up to talking to Panoptikum’s lead maintainer, Stefan about chatbots and he had the idea to make a chatbot for Panoptikum. This sounded like a splendid idea, so I jumped right in, with Stefan’s helping hand!

You can find the code for the bot in the following files: Controller, View, Module.

The Setup

The bot is currently set up as follows:

  • It’s integrated into the Panoptikum Phoenix app. This allows us to share code with the app and have it live on the same Phoenix instance.
  • No existing bot framework is being used. I did this so I could a) familiarize myself with the Facebook Messenger API, as well as b) avoid introducing dependencies to the app.
  • The behind-the-scenes search is accomplished using Elasticsearch. This is already integrated into Panoptikum, so I was able to use the existing API call.

Once the Facebook side of things was set up (this guide helped me get started) we could start with the bot!

Part 0: Running the bot locally and online at the same time!

Having to upload a change to our bot to a server every time we want to quickly try something can be exhausting. It is, however, possible to put your locally running server online using a tool called ngrok.

Once you’ve installed ngrok, you can use it with the command line to link up a remote URL to your local server by setting the port. For example, for a Phoenix server, you’d enter the following:

$ ngrok http 4000

Running the above command will start up an ngrok process on your terminal, with a URL you can use to access your server from anywhere, even, say, Facebook!

Part 1: The webhook

For verification, Facebook requires us to return a challenge number they send us via a JSON response. This requires a few short steps to set up. First, a route for Facebook to contact us. In web/router.ex:

pipeline :bot do
  plug :accepts, ["json"]
end

scope "/bot", Pan do
  pipe_through :bot

  get "/webhook", BotController, :webhook
end

We’ll set up our bot endpoint to accept JSON data, and we’ll have a BotController handle our requests. Let’s set that up under web/controllers/bot_controller.ex:

defmodule Pan.BotController do
  use Pan.Web, :controller

  def webhook(conn, %{ "hub.challenge" => challenge } ) do
    challenge = challenge
                |> String.to_integer()
    render conn, "webhook.json", challenge: challenge
  end
end

We’ve now declared the view through which will pass that challenge code. Let’s set that response up in web/views/bot_view.ex:

defmodule Pan.BotView do
use Pan.Web, :view

  def render("webhook.json", %{ challenge: challenge}) do
    challenge
  end
end

Looks straightforward enough! We take the challenge code provided by Facebook and return it right back.

Let’s test it

Fire up ngrok with the following command:

$ ngrok http 4000

You’ll find the URL to copy over to Facebook Developers:

Facebook Developers

If you’re wondering what the “Verify Token” is, it’s for us! We can use it to verify that our webhook request is coming from Facebook and Facebook alone. You might’ve noticed we don’t use it for the purpose of this example, and that’s fine. Facebook will require you to enter one, though, so go ahead and enter anything.

Once you’ve done this, go ahead and click on “Verify and Save”. All things done correctly, we’re good to go!

But you’re probably concerned, and rightly so: our bot doesn’t do anything.

Part 3: Receiving messages

So here we go! We’ll add a route to our web/router.ex to respond when a user sends a message to our chatbot. We’ll quickly add a line to our bot scope:

scope "/bot", Pan do
  pipe_through :bot

  get "/webhook", BotController, :webhook
  post "/webhook", BotController, :message
end

You’ll notice that Facebook sends a POST HTTP request to our bot. We’ll route that into our message function on our BotController. For now, let’s have it receive the message and let Facebook know that we got it:

  def message(conn, _params) do
    conn
    |> send_resp(200, "ok")
  end

Providing you’ve taken care of wiring up your page to the Facebook app, we can test. Let’s! If you’re running ngrok already, you can send a message to your bot.

… And nothing happens.

Or does it?

The bad news is that our bot doesn’t reply. That’s okay, we didn’t code it to do so. However, if we look back on our server running in the terminal, things look good! Our message is being received.

Part 4: The Bot Logic

To repeat: The bot works by responding to any message the user sends it by replying with podcasts matching the message.

Let’s add a pattern-matched method to respond to a user’s message:

  def message(conn, %{"entry" => [%{"messaging" => [%{"message" => %{"text" => message}, "sender" => %{"id" => sender_id}}]}]}) do
    Pan.Bot.whitelist_urls()
    Pan.Bot.respond_to_message(message, sender_id)
    conn
    |> send_resp(200, "ok")
  end

We’re pattern matching the user’s message and their ID out of the map received. The second line seems like a bot would normally do. It responds to the user’s message.

The question is: Why are we whitelisting URLs? What’s that about?

Facebook requires us to whitelist the URLs to which the images we send it are coming from. In our case, this is https://panoptikum.io.

You might have also noticed we’re using a Bot module. This is a module extracted with the purpose of holding the bot’s logic. This code was placed in lib/pan/bot.ex. Let’s first begin with the whitelist_urls() method:

  defmodule Pan.Bot do
    use Pan.Web, :controller
    alias Pan.Podcast

    def whitelist_urls do
      body = %{
        setting_type: "domain_whitelisting",
        whitelisted_domains: [Application.get_env(:pan, :bot)[:host], "https://panoptikum.io/"],
        domain_action_type: "add"
      }
      |> Poison.encode!
      facebook_request_url("thread_settings", access_token_params())
      |> HTTPoison.post(body, ["Content-Type": "application/json"], stream_to: self())
    end

    defp facebook_request_url(path, params) do
      "https://graph.facebook.com/v2.6/me/#{path}?#{params}"
    end

    defp access_token_params do
      %{
        access_token: Application.get_env(:pan, :bot)[:fb_access_token]
      }
      |> URI.encode_query()
    end
  end

We use Poison to encode our maps into JSON, and HTTPoison to send facebook our whitelisted URLs, along with the access token we got from Facebook Developers’ website.

Next, the message response. In lib/pan/bot.ex:

  def respond_to_message(message, sender_id) do
    data = %{
      recipient: %{
        id: sender_id
      },
      message: message_response(podcasts_from_query(message))
    }
    |> Poison.encode!

    facebook_request_url("messages", access_token_params())
    |> HTTPoison.post(data, ["Content-Type": "application/json"], stream_to: self())
  end

The higher level logic takes places in the respond_to_message function. Like before, we encode our message response with the access token and make a POST request to Facebook. So far so good!

The two missing functions are message_response and podcasts_from_query. Let’s work on the latter first. Again, in lib/pan/bot.ex:

  defp podcasts_from_query(message) do
    query = [index: "/panoptikum_" <> Application.get_env(:pan, :environment),
     search: [size: 5, from: 0,
      query: [
        function_score: [
          query: [match: [_all: [query: message]]],
          boost_mode: "multiply",
          functions: [
            %{filter: [term: ["_type": "categories"]], weight: 0},
            %{filter: [term: ["_type": "podcasts"]], weight: 1},
            %{filter: [term: ["_type": "personas"]], weight: 0},
            %{filter: [term: ["_type": "episodes"]], weight: 0},
            %{filter: [term: ["_type": "users"]], weight: 0}]]]]]

    {:ok, 200, %{hits: hits, took: _took}} = Tirexs.Query.create_resource(query)

    podcast_ids = Enum.map(hits.hits, fn(hit) -> hit._id end)

    from(p in Podcast, where: p.id in ^podcast_ids, preload: :episodes)
    |> Pan.Repo.all
  end

Panoptikum uses Elasticsearch for searching. We can piggyback on the existing query structure to get IDs for podcasts, using the elixir Tirexs package. The function then returns the set of podcasts that match the query from the user.

Next up! The message_response method:

  defp message_response([]) do
    %{
      text: "Sorry! I couldn't find any podcasts with that. How about \"Serial\"?"
    }
  end

  defp message_response(podcasts) do
    %{
      attachment: %{
        type: "template",
        payload: %{
          template_type: "generic",
          elements: Enum.map(podcasts, &(podcast_json(&1)))
        }
      }
    }
  end

Here, we use pattern matching to catch a query for which there are no podcasts in Panoptikum. The second function is where it gets interesting, however. We’re using generic templates to display each podcast in a carousel-of-sorts.

Finally, we need to implement the podcast_json function for each podcast:

  defp podcast_json(podcast) do
    [episode | _rest] = podcast.episodes
    host = Application.get_env(:pan, :bot)[:host]
    data = %{
      title: podcast.title,
      subtitle: podcast.description,
      default_action: %{
        type: "web_url",
        url: host <> podcast_frontend_path(Pan.Endpoint, :show, podcast),
        messenger_extensions: true,
        webview_height_ratio: "tall",
        fallback_url: host <> podcast_frontend_path(Pan.Endpoint, :show, podcast)
      },
      buttons: [
        %{
          type: "web_url",
          url: host <> podcast_frontend_path(Pan.Endpoint, :show, podcast),
          title: "👉 Panoptikum"
        },
        %{
          type: "web_url",
          url: podcast.website,
          title: "🌎 Podcast website"
        },
        %{
          type: "web_url",
          url: host <> episode_frontend_path(Pan.Endpoint, :player, episode),
          messenger_extensions: true,
          webview_height_ratio: "tall",
          title: "🎧 Latest episode"
        }
      ]
    }
    case podcast.image_url && URI.parse(podcast.image_url).scheme do
      nil -> data
      _ ->
        Map.put_new(data, :image_url, podcast.image_url)
    end
  end

This will turn each podcast into a generic template that won’t crash if the image doesn’t exist or is bogus (this happens when processing hundreds upon hundreds of podcast feeds (I might even have the order of magnitude wrong!)).

And there you have it! You can go ahead and test.

Epilogue: What else could the bot do?

Throwing out some ideas:

  • Subscribing to a podcast: Users can be notified when a new episode of a podcast comes out.
  • Querying for podcast episodes, genres, speakers, etc.
  • Suggestions for podcasts.

I’m sure there’s more!

Want to contribute to Panoptikum? Check out the project on Github. The lead maintainer Stefan is super helpful and welcoming.

Buy me a coffee