Building a Slack bot with Sinatra

Posted in engineering

I’m going to build a Slack bot which triggers by keyword, performs some action and sends a response back to Slack. During the work I will keep two rules in mind:

  1. Bot code should be as simple as possible;
  2. Bot should be extensible by plugins.

The bot will be built with Sinatra and deployed to Heroku. No database or background worker is needed.

The basics

Let’s create bot application structure:

slackbot
├── Gemfile
├── slackbot.rb
├── plugins
│   └── sample_plugin.rb
└── config.ru

That’s it. All the functionality will be added into slackbot.rb and plugins will be stored in /plugins directory. Now let’s add the following code into Gemfile:

source 'https://rubygems.org'
ruby '2.3.1'
gem 'sinatra'

Yup, for now we need only Sinatra gem, but you can add any gem you need if you’re going to integrate the bot with other services. Don’t forget to run bundle install after you update Gemfile.

Now add the following lines into config.ru:

%w(sinatra json).each { |lib| require(lib) }
require './slackbot'
run Sinatra::Application

This Ruby script will run our application. The first line loads all the dependencies (for this basic app we need just Sinatra and JSON library). The second line loads our bot code and the third line runs the application.

Bot code

Let’s take a look on the bot’s code from slackbot.rb:

$plugins = {}

def plugin(regexp, &block)
  $plugins[regexp] = block
end

def parse(input, params)
  plugin = $plugins.find { |regexp, _| regexp =~ input }
  plugin ? plugin.last.call(*$~.captures, params) : {}
end

before { content_type(:json) }

after { body(body.to_json) }

post '/' do
  input = params[:text].sub(params[:trigger_word], '').strip
  { text: parse(input, params) }
end

get('/debug') { { text: parse(params[:text], params) } } if development?

Dir[File.join(File.dirname(__FILE__), 'plugins', '*.rb')].each do |file|
  require(file)
end

It starts from defining a global variable1 $plugins which will store the plugins data as a hash.

Next is plugin method which stores plugin code into the variable. Having this we can define plugins in such way:

plugin /^Weather in (\w)$/ do |city, params|
  # fetches and returns weather data for the given city
end

If you’re familiar with Cucumber BDD framework you will find it’s step definitions similar to these plugin definitions.

The only line in plugin method stores plugin’s code as a hash value where key is regular expression which triggers the plugin:

$plugins[/some_regexp_to_match/] = -> (p) { code_to_be_invoked }

Now parse method. It takes two parameters and the first of them is user’s input sent from Slack. In this method app tries to match user’s input with any of the keys in $plugins. If anything matches it will invoke the matched plugin’s code passing all the matched data along with Slack input to it.

As the app responds only with JSON data we can specify the correct content type in before filter2:

before { content_type(:json) }

Also to avoid calling #to_json for any data app returns we define after filter which will convert any response3 to JSON:

after { body(body.to_json) }

Now we can define our app routes. Slack will send data to the root URL via POST method so we define the correct route:

post '/' do
  ...
end

As our app receives Outgoing Web hook from Slack triggered by some word, we should remove it from the input. For this we simply replace it with empty string by calling #sub. Then we call parse method and return a Hash which will be converted to JSON.

I’d like to have another route to test plugin responses locally so I define it but only in development mode4:

get('/debug') { { text: parse(params[:text], params) } } if development?

The last thing app does in slackbot.rb is loading all plugin files from ./plugins directory.

Sample plugin

Let’s create a sample plugin:

command /echo (\w+)/ do |value, params|
  "Echoed with #{value} for #{params[:user_name]}"
end

It will simply echo the user’s input. Put the code into plugins/sample_plugin.rb file.

Testing

Now Slack bot is ready and it’s time to test it. Start the application by executing rackup:

~/Development/slackbot$ rackup
[2016-05-26 12:59:01] INFO  WEBrick 1.3.1
[2016-05-26 12:59:01] INFO  ruby 2.3.1 (2016-04-26) [x86_64-darwin15]
[2016-05-26 12:59:01] INFO  WEBrick::HTTPServer#start: pid=10544 port=9292

Ok, we can open the browser now. As we added a debug route to test chat plugins, we just open http://localhost:9292/debug?text=echo&20message. You should see the response:

Deployment

Now the app is ready to be deployed to Heroku. The simplest way is executing the following commands:

gem install heroku
heroku create <app_name>
heroku deploy

You will need to sign in with your Heroku credentials and then your app will be deployed to http://app_name.herokuapp.com.

Slack

The final step is configuring Slack to communicate with your newly deployed app. Create a new Outgoing WebHook with:

That’s it! It’s time to test Slack integration:

  1. There are numerous of discussions about how bad global variables are, but for our case (remember, the code should be as simple as it’s possible) using globals is enough. 

  2. Filters 

  3. Setting Body, Status Code and Headers 

  4. Environments