Building A Slack Bot With Elixir Part 2
In the first part of this tutorial, we covered the basics of defining, running, and testing a web server in Elixir using Plug and Cowboy. In this part of the tutorial, we'll build on that groundwork to create a Slack bot that fetches and sends out weather forecasts.
Receiving a Slack message
For this tutorial, we'll be using Slack's incoming and outgoing webhook APIs to handle our message traffic. Note that Slack also provides a more feature rich bot users API, but we'll be using the simpler solution so we can focus more on implementing the Elixir side and less on the particulars of the Slack API.
Setting Up The Hooks
To begin with, you'll need to register a new incoming and outgoing webhook on Slack's custom integrations page. You'll notice that the outgoing webhook requires a target URL; this is the address Slack will send requests to, which, unsurprisingly, must be publicly accessible.
While you could set up an application on a server you control or a PaaS provider such as Heroku, it'll be much easier to iterate and make changes to your app if you use a localhost tunnel such as ngrok or localtunnel during development. If you use ngrok, you can set up a tunnel to port 4000 (which our app runs on) by running ngrok http 4000
. Ngrok will then provide you with a URL that you can set as your outgoing webhook target.
I'd also recommend defining a trigger word such as "forecast", "weatherbot", or "wb". Otherwise its very easy to accidentally get your bot stuck in a loop of triggering itself with its own responses.
Receiving A Webhook
To verify our Slack integration is set up properly, let's define a "/webhook" route that'll just return a 200 OK and a simple response message.
Add a POST route to router.ex with the following definition:
post "/webhook" do
send_resp(conn, 200, ~s({"text":"ok"}))
end
If everything is set up correctly, you should get a response of "ok" when you enter the trigger word in Slack.
Predicting the Weather
To fetch our weather predictions, we'll be using the DarkSky API, which has a free tier allowing for up to 1000 requests per day. If you're following along, go ahead and sign up for an account and make a note of the secret key you're given.
Some More Dependencies
We'll need a few new dependencies to interact with the Dark Sky API; HTTPoison for making HTTP requests, and Poison for parsing JSON responses (as you can see, Elixir follows in Ruby's footsteps regarding odd but thematically appropriate library names). Refer to the READMEs for these projects, or the previous post in this series for instructions on adding these libraries to your Elixir app.
Keeping It Secret
If you're planning on saving this project via github or another publicly accessible VCS solution you'll want to make sure you don't commit your Dark Sky API key, or the incoming Slack webhook you've set up. An easy way to accomplish this without complicating your application configuration is to create a config.secret.exs
file in your config
directory and add it to your .gitignore
. You can then instruct your application to load it by adding import_config "config.secret.exs"
to your main config.exs
file.
For example, my config.secret.exs
looks like
use Mix.Config
config :weatherbot, incoming_slack_webhook: "<WEBHOOK URL>"
config :weatherbot, darksky_key: "DARKSKY KEY"
Making The Request
To organize our functions for interacting with the Dark Sky API, let's create a new module at lib/weatherbot/weather_fetcher.ex
with the following definition:
defmodule Weatherbot.WeatherFetcher do
@darksky_url "https://api.forecast.io/forecast/#{Application.get_env(:weatherbot, :darksky_key)}/43.074458,-89.380778"
def get_forecast do
HTTPoison.get!(@darksky_url).body
|> Poison.Parser.parse!
end
def hourly_forecast do
get_forecast
|> Map.get("hourly")
|> Map.get("summary")
end
def daily_forecast do
get_forecast
|> Map.get("daily")
|> Map.get("summary")
end
def daily_and_hourly_forecasts do
~s"""
Hourly
#{hourly_forecast}
Daily
#{daily_forecast}
"""
end
end
You can test this new module by opening an iex prompt with iex -S mix
and calling Weatherbot.WeatherFetcher.daily_and_hourly_forecasts
.
Lets break down the component parts of this new function. First, we're fetching the Dark Sky key via a call to Application.get_env(:weatherbot, :darksky_key)
on the second line. Also, unless you're really interested in receiving weather forecasts for Bendyworks HQ, you'll want to change the lat/long numbers at the end of the URL.
To fetch the forecast data, we're making a call via HTTPoison.get!
to the API endpoint, and passing the response body to Poison.Parser.parse!
(in case you're unfamiliar with the convention, a !
in Elixir generally means a function is "unsafe", in that it throws exceptions in some circumstances).
We take the parsed response, now represented as a map, and extract the daily and hourly summaries from it. These are then combined via a heredoc sigil (~s"""
) to build up the response.
Responding to Slack
We'll create another new module at lib/weatherbot/slack_sender.ex
for sending our forecasts to Slack.
defmodule Weatherbot.SlackSender do
def post_to_slack(encoded_msg) do
HTTPoison.post(Application.get_env(:weatherbot, :incoming_slack_webhook), encoded_msg)
end
def sendmsg(msg) do
Poison.encode!(%{
"username" => "forecast-bot",
"icon_emoji" => ":cloud:",
"text" => msg
})
|> post_to_slack
end
end
This module is nearly the reverse of the one we wrote to receive outgoing webhooks; it encodes a JSON payload with Poison.encode!
and POSTs it to the :incoming_slack_webhook
url we've defined in config.secret.exs
.
As with the Weatherbot.WeatherFetcher
module, we can test this new functionality out by opening a new iex prompt (or calling recompile
in an existing one), and running Weatherbot.WeatherFetcher.daily_and_hourly_forecasts |> Weatherbot.SlackSender.sendmsg
.
Putting it all together
We now have a route in our router to receive outgoing webhooks, a module to fetch weather forecasts from the Dark Sky API, and another module to send the forecasts back in to Slack. To wire it all together, all we need is to make a slight modification to the behavior of our /webhook
route. Although Slack no longer requires a timely response to outgoing webhook requests, we can still leverage Elixir's excellent concurrency facilities to be friendlier, more responsive api consumers:
post "/webhook" do
spawn fn ->
Weatherbot.WeatherFetcher.daily_and_hourly_forecasts
|> Weatherbot.SlackSender.sendmsg
end
send_resp(conn, 200, ~s({"text":"ok"}))
end
This will spawn a new Elixir process to manage fetching the forecast and sending it to slack, which will allow the current process to immediately respond to the original webhook request.
Our weather forecasting Slack bot is now ready to go! If we run the application (mix run --no-halt
in case you've forgotten), we should be able to get a forecast by entering the outgoing webhook trigger word in Slack.
Next Steps
We now have a fully functional weather forecasting Slack bot, but there's a lot more we could do to build on this simple app. If you're interested in extending what we've built so far, here are a few ideas:
- Using ExVCR to write robust, network independent unit tests
- Taking a zip code or address argument and geocoding it to lat/long
- Using a different
icon_emoji
param depending on weather conditions - Delivering the Area Forecast Discussion, which requires some web page scraping and parsing (If you're coming from Ruby and are familiar with Nokogiri, Floki is a good Elixir equivalent)
- Implementing more complex behavior such as being able to ask "will it rain today" or "do I need a jacket?"