Breathing Life into the Panoptikum Facebook Messenger Bot with Elixir and Phoenix
Aug 5, 2017
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.
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:
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 @hola_soy_milk