Rails and Slack: Creating a Slash Command App

Back in my Campfire days, I remember being blown away when Github released Hubot. Our team tinkered around with our install for days – hoping to make Hubot do our bidding. It was a great little tool (and still is!), but I’ve recently fallen in love with Slack’s “Slash Commands”. They’re a handy way to send commands to a service and get a response. And the best part? You can write one in Ruby!

The Slack Webhook

Slash commands work in a simple manner: you start a command with a forward slash, then the command name. For this basic example, we’ll ask for weather for a particular zip code:

/weather 83864

The entire command, along with any additional text, is sent as a webhook to your application.

token=gIkuvaNzQIHg97ATvDxqgjtO
team_id=T0001
team_domain=example
channel_id=C2147483705
channel_name=test
user_id=U2147483697
user_name=Steve
command=/weather
text=83864
response_url=https://hooks.slack.com/commands/1234/5678

The token is a secret setup when you configure the command inside your Slack instance. You’ll be checking this to ensure the command is actually from Slack.

The response_url is where we’ll send our data once it’s ready.

Receiving the Command

The webhook from Slack comes as a POST request, so we’ll create a Rails controller with a create method:

class CommandsController < ApplicationController

  def create
    return render json: {}, status: 403 unless valid_slack_token?
    CommandWorker.perform_async(command_params.to_h)
    render json: { response_type: "in_channel" }, status: :created
  end

  private

    def valid_slack_token?
      params[:token] == ENV["SLACK_SLASH_COMMAND_TOKEN"]
    end

    # Only allow a trusted parameter "white list" through.
    def command_params
      params.permit(:text, :token, :user_id, :response_url)
    end

end

We’re doing a few simple things here:

  1. We immediately return a 403 (forbidden) unless the Slack token matches. We do that with the private method valid_slack_token? which compares the token in the params with the one Slack gave us (and is stored in an ENV var).
  2. Next, we’ll send off our entire hash of params to a Sidekiq background job. (I had some trouble serializing the params hash in Rails 5, so I’m using .to_h to do this for me) We’re using a background job here since we’ll be communicating with a 3rd party API. That way, we can return a response quickly to Slack and avoid a timeout.
  3. Finally, we return a 201 (created) and send back some JSON.

The { response_type: "in_channel" } JSON tells slack to leave the original command (/weather 83864) in the Slack chat history. If you’re sending something back that only the user will see privately, you’ll want to return { response_type: "ephemeral" } at this step.

Returning Data

Next, we’ll have our Sidekiq worker fetch the data and send it all back using the response_url that Slack sent us in the params.

To fetch the Weather data, I’m using Weather Underground. I’m going to create a basic class to encapsulate some of this behavior.

class Weather

  attr_reader :zip

  def initialize(zip)
    @zip = zip
  end

  def city
    data["full"]
  end

  def temperature
    data["temperature_string"]
  end

  def icon_url
    data["icon_url"]
  end


  private

    def data
      @data ||= HTTParty.get("https://api.wunderground.com/api/#{ENV['WUNDERGROUND_KEY']}/conditions/q/#{zip}.json")["current_observation"]["display_location"]
    end
end

And then, the Sidekiq worker which uses this class:

class CommandWorker
  include Sidekiq::Worker
  sidekiq_options :retry => false

  def perform(params)
    weather = Weather.new(params[:text])

    message = {
      text: "The temperature in #{weather.city} is #{weather.temperature}",
      response_type: "in_channel"
    }

    HTTParty.post(params[:response_url], { body: message.to_json, headers: {
        "Content-Type" => "application/json"
      }
    })

  end
end

Here’s what’s happening:

  1. Our worker starts up with the params it was sent by Slack.
  2. We initialize a Weather object and pass in the text from the message (zip code)
  3. The Weather instance fetches the data from the API, then exposes it back to the CommandWorker to be used in generating the text.
  4. We post back our text string to Slack, using the response_url value it sent us in the first step.

image

Attachments

To make our message a little prettier, we can send along the image URL as an “attachment” to Slack. This requires simply modifying our JSON a bit like this:

{
    "attachments": [
        {
            "title": "Weather for Sandpoint, ID",
            "title_link": "https://www.wunderground.com/US/ID/Sandpoint.html",
            "text": "Optional text that appears within the attachment",
            "fields": [
                {
                    "title": "Temp",
                    "value": "68F",
                    "short": false
                },
        {
          "title": "Wind",
          "value": "16mph",
          "short": false
        }
            ],
            "image_url": "https://icons.wxug.com/i/c/k/clear.gif"
        }
    ]
}

image

Slack provides a nice demo tool of message formatting. It’s very handy when getting your messages to look just right.

Extending

Next, we may want to send different types of commands to the same app. For instance, I could specify what I want from the weather app, like this:

/weather forecast:tomorrow 83864
/weather temp:f 83864

To do so, you may choose to separate out pieces of the app into separate modules or sub-classes, then dispatch them from one place. In an app I’m working on at Heroku, we pass commands like this:

/slack-app-name task:subtask command

…where slack-app-name is one of a few different Slash Commands apps. In order to properly parse the task, subtask, and command, we have a Processor module:

module Commands
  module Processor
    extend self

    def init(params)
      task = task(params).titleize
      "Commands::#{task}".constantize.new(params)
    end

    # Given `task:sub message`, return task
    def task(params)
      params[:text].scan(/\w+/).first
    end

    # Given `task:sub message`, return subtask
    def subtask(params)
      params[:text].scan(/\w+/)[1]
    end

    # Given `task:sub message`, return message
    def message(params)
      params[:text][/\w+:\w+\s(.*)/, 1]
    end
  end
end

We use this to initialize the correct class to handle the command:

class CommandWorker
  include Sidekiq::Worker
  sidekiq_options :retry => false # job will be discarded immediately if failed

  def perform(params)
    Commands::Processor.init(params).run
  end
end

Now we can send something like /weather forecast:tomorrow 83864 and have a class Commands::Forecast process it by looking at subtask (tomorrow) and message (83864). By doing so we can extend and expand our app to process multiple different tasks.

Conclusion

I hope this gives you a glimpse into what’s possible with Slash Commands! Happy coding, Rubyist!

Hi there, I'm Jon.

Writer. Musician. Adventurer. Nerd.

Purveyor of GIFs and dad jokes.