Build a Rails App with Slack Part 5: Slash Command With Block Kit Response
Welcome to the fifth installment of this multi-part series on building a Slack application with Rails. This series will guide you through the process of creating a Slack application with Rails and is structured as follows:
- Part 1: Rails new, Slack, and OAuth
- Part 2: Slack Slash Command with Text Response
- Part 3: Slack Slash Command with Modal Response
- Part 4: Slack Action Modal Submission
- Part 5: Slack Slash Command with Block Kit Response === YOU ARE HERE ===
Feel free to jump to a specific part of interest using the links above or follow along sequentially. You can also checkout the source code on Github for the application we'll be building.
This post assumes the reader has at least a beginner level familiarity with Ruby on Rails. It's also assumed the reader has used Slack as an end user with basic interactions such as joining channels, sending messages, and participating in conversations.
Part 1 of this series introduced Retro Pulse, a Slack app built with Rails for agile teams to manage their retrospectives with Slack. Part 2 explained how to implement a Slack slash command to open a retrospective and return a markdown text response to the same Slack channel that initiated the request. Part 3 covered how to implement a slash command that responds with a modal form, allowing the user to enter feedback for the retrospective. In Part 4, we learned how to handle the modal form submission to save the user's feedback in the database, and reply back with a direct message to let the user know their feedback was saved.
Now in Part 5, we'll be implementing the last part of the application, which is to display the feedback when the team is ready to have their retrospective meeting. The interaction looks like this:
Given that the team has started their retrospective meeting, someone who is screen sharing their Slack workspace enters the /retro-discuss
slash command. This command accepts one argument to specify the category: keep, stop, or try:
Usually the meeting starts with a discussion of what the team should keep on doing, in this case, the slash command would be entered as /retro-discuss keep
:
The app responds with all the comments that have been collected in that category. It also displays a header and a count of how many comments were entered. Below each comment section, it shows the date the comment was entered on and either the Slack username or "anonymous" if the person who entered this comment had selected to remain anonymous:
Finally, when the retrospective meeting is over, it can be closed with another slash command:
Which the app responds to with a confirmation message that the retrospective has been closed:
Closing the retrospective is a very similar interaction to opening a retrospective, that was already covered in Part 2 of this series, so it won't be covered here.
Add Slash Command in Slack
The first thing we need to do to implement this interaction is to configure another slash command in the Retro Pulse app. Navigate to Your Apps on Slack, select the "Retro Pulse" app you created in Part 1 of this series, and then select "Slash Commands" from the Features section:
Then click on the "Create New Command" button, and fill in the form as follows:
Command: /retro-discuss
. This is what the user will type into a Slack message to initiate an interaction with the Retro Pulse Rails app.
Request URL: For example: https://12e4-203-0-113-42.ngrok-free.app/api/slack/command
. This is where Slack will send an HTTP POST request when the user submits this slash command from Slack. The hostname is your ngrok forwarding address that you got from starting ngrok in Part 1 of this series. The route /api/slack/command
is defined in the slack-ruby-bot-server gem that's included in the Retro Pulse Rails app.
Short Description: Discuss retrospective feedback
. This will be displayed as the user types in the slash command.
Usage Hint: keep stop try
. Since this particular slash command requires a parameter, which will be used to retrieve the comments for that category, the usage hint is also shown to the user as they type in the slash command.
Escape Channels: Leave this unchecked. Turning this on will modify the parameters sent with a command by a user such as wrapping URLs in angle brackets and translating channel or user mentions into their correlated IDs. It's not necessary for this app. See the Slack documentation if your app needs this option.
After filling out the slash command form, click the "Save" button which appears all the way at the bottom right hand corner.
Receive Slash Command in Rails
In Part 2 of this series, we learned how to add a handler to receive slash commands using the slack-ruby-bot-server-events gem. Let's add another one to handle the /retro-discuss
command.
Create a new file in the bot/slash_commands
directory that will get triggered whenever a user submits the /retro-discuss
command. The command
object exposed by the slack-ruby-bot-server-events
gem contains many of the request parameters sent by Slack including the team_id
, channel_id
, and text
that user entered.
This first attempt retrieves the comments by category for the open retrospective, concatenates their content
attributes, and sends back a simple text response to the channel:
# bot/slash_commands/retro_discuss.rb
SlackRubyBotServer::Events.configure do |config|
# Essentially this is saying to the SlackRubyBotServer,
# If a "/retro-discuss" slash command is received from Slack,
# then execute this block.
config.on :command, "/retro-discuss" do |command|
# Use `command[:team_id]` from request parameters sent to us
# by Slack to find the Team model persisted in the database
team = Team.find_by(team_id: command[:team_id])
# This is the Slack channel we need to respond back to
channel_id = command[:channel_id]
# If user entered /retro-discuss keep
# in Slack, then command_text will be: keep
command_text = command[:text]
command.logger.info "=== COMMAND: retro-discuss, Team: #{team.name}, Channel: #{channel_id}, Text: #{command_text}"
# Instantiate a slack client with the team token
# so we can communicate back to the channel
slack_client = Slack::Web::Client.new(token: team.token)
# Find the one and only open Retrospective
retrospective = Retrospective.find_by(status: Retrospective.statuses[:open])
# Retrieve the comments for the open Retrospective, for this category
category = command_text
comments = retrospective.comments.where(category: category)
# Concatenate all the comments.content into a single comma separated text
comments_text = comments.map(&:content).join(", ")
# Reply to the channel with all the comments text
slack_client.chat_postMessage(
channel: channel_id,
text: comments_text
)
# Return `nil`, otherwise the slack-ruby-bot-server-events gem
# replies to the channel with a message "true"
nil
end
end
The Comment
model was introduced in Part 4 of this series, have a quick read there if you need a refresher of what it looks like. The Retrospective
model was introduced in Part 2.
Use require_relative
to load this new command in the bot/slash_commands.rb
file. We created this file in Part 2 when introducing slash commands:
# bot/slash_commands.rb
# This line was added in Part 2 of this series
require_relative "slash_commands/retro_open"
# This line was added in Part 3 of this series
require_relative "slash_commands/retro_feedback"
# === NEW ===
require_relative "slash_commands/retro_discuss"
This file is loaded by config.ru
to ensure that all the Slack handlers are loaded when Rails starts.
After restarting the Rails server bin/dev
, and entering /retro-discuss keep
in a Slack workspace that has the Retro Pulse app installed, it will respond with something like this. In the example below, there are two comments, each having several sentences, separated by a comma:
Technically this works, but it's difficult to determine where one comment ends and the next begins. We'd also like to see who posted it and when. The next section covers how to make the response more visually appealing.
Using Block Kit to Format the Response
Part 3 of this series introduced Slack's Block Kit for building the interactive modal to collect user feedback. Block Kit can also be used to compose a message that is sent via Slack's chat_postMessage API.
We've already been using the chat_postMessage
API throughout this series to send simple messages. For example, in Part 2 it was used to reply to the channel that a retrospective had been created:
slack_client = Slack::Web::Client.new(token: team.token)
slack_client.chat_postMessage(
channel: channel_id,
mrkdwn: true,
text: ":memo: Opened retro `#{retrospective.title}`"
)
Which looks like this:
Instead of text
, the chat_postMessage
API can accept a blocks
attribute, which is a JSON array. Each element in the array must be a valid block. If all this sounds a little abstract, an example should help to clear things up.
Section and Divider
In the version of bot/slash_commands/retro_discuss.rb
shown below, the text: comments_text
has been removed and instead an array of blocks is built. For each comment, there a section
block is created for rendering the content of the comment, followed by a divider
block to visually distinguish one comment from the other:
# bot/slash_commands/retro_discuss.rb
SlackRubyBotServer::Events.configure do |config|
# ...
# Retrieve the comments for the open Retrospective, for this category
category = command_text
comments = retrospective.comments.where(category: category)
# Initialize an array of comment blocks
comments_blocks = []
# Add the comment content
comments.each do |comment|
comments_blocks << {
type: "section",
text: {
type: "mrkdwn",
text: comment.content
}
}
# Add a divider after each comment
comments_blocks << { type: "divider" }
end
# Send `blocks` instead of `text`
slack_client.chat_postMessage(
channel: channel_id,
blocks: comments_blocks
)
end
Notice that each block must have a type
attribute. See the Slack documentation on Reference Blocks for the full list of block types and how to use them.
Restarting the Rails server bin/dev
and entering /retro-discuss keep
again in the Slack workspace will result in this response:
This time the response is easier to read because each comment is rendered in its own section
block, which is similar to a <div>
in HTML, and followed by a divider
block, which is similar to an <hr>
element in HTML.
Contextual Information
We'd also like to see the user that posted the comment and when it was posted. Since this information is not as visually important as the content of the comment itself, it can go in a context block, which is used to display contextual information.
The context
block functions as a "parent" block. It accepts a list of "children" elements
, each of which must be a valid block. So we'll have one element to show the user information, and another element to show the date. The emoji: true
attribute is set on the elements so we can render emoji's as well. This helps with the visual cues to indicate what kind of information this is.
Here is the updated version of the code that adds contextual information about each comment. Note the check for "anonymous" comment when rendering the user name. For the posted date, the comment model's created_at
timestamp is formatted to display in YYYY-MM-DD
format:
# bot/slash_commands/retro_discuss.rb
SlackRubyBotServer::Events.configure do |config|
# ...
# Retrieve the comments for the open Retrospective, for this category
category = command_text
comments = retrospective.comments.where(category: category)
# Initialize an array of comment blocks
comments_blocks = []
comments.each do |comment|
# Add the comment content
comments_blocks << {
type: "section",
text: {
type: "mrkdwn",
text: comment.content
}
}
# Add the user and date as contextual info
comments_blocks << {
type: "context",
elements: [
{
type: "plain_text",
emoji: true,
text: ":bust_in_silhouette: #{comment.anonymous ? 'anonymous' : comment.slack_username}"
},
{
type: "plain_text",
emoji: true,
text: ":calendar: #{comment.created_at.strftime('%Y-%m-%d')}"
}
]
}
# Add a divider after each comment
comments_blocks << { type: "divider" }
end
slack_client.chat_postMessage(
channel: channel_id,
blocks: comments_blocks
)
end
The above code results in the following response to the /retro-discuss keep
slash command:
This is looking much better.
Header
The last thing to add is a header showing the category of the feedback, and a smaller sub-section just below the header to display how many comments were found for that category. The category is defined as an enum in the Comment
model. Rather than just displaying the word "keep" or "stop", a header
method can be added to convert it to an phrase for display as follows:
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :retrospective
enum category: {
keep: "keep",
stop: "stop",
try: "try"
}
# === NEW: Convert category enum to phrase
def self.header(category)
case category.to_sym
when :keep
"What we should keep on doing"
when :stop
"What we should stop doing"
when :try
"Something to try for next time"
else
"Unknown category"
end
end
end
Now the Comment.header()
method can be used in building the header in the /retro_discuss
handler as follows:
# bot/slash_commands/retro_discuss.rb
SlackRubyBotServer::Events.configure do |config|
# ...
# Retrieve the comments for the open Retrospective, for this category
category = command_text
comments = retrospective.comments.where(category: category)
# Initialize an array of comment blocks
comments_blocks = []
# Add the header block
comments_blocks << {
type: "header",
text: {
type: "plain_text",
text: Comment.header(category)
}
}
# Add a sub-header as a section
# Use markdown to bold the number of comments
comments_blocks << {
type: "section",
text: {
type: "mrkdwn",
text: "Found *#{comments.size}* comments in this category:"
}
}
# Add a divider to separate the sub-header from the comments
comments_blocks << { type: "divider" }
comments.each do |comment|
# comment content...
end
slack_client.chat_postMessage(
channel: channel_id,
blocks: comments_blocks
)
end
Note that the text
attribute of the section
type can set to mrkdwn
. In this case the comments.size
value is wrapped in *
so that it renders as bold in the Slack response.
Putting this all together, the app responds with a fully formatted list of comments as shown below:
Refactor
While the current version of the code in the /retro-discuss
handler works, there are several issues:
- It's too long, triggering the Rubocop
Metrics/BlockLength
rule. - The verbose syntax of the Slack blocks mixed in with the business logic makes it difficult to understand what this code is actually doing.
- There's no validation - for example, what if the user types in
/retro-discuss foo
or doesn't provide a category at all? In this case the app should reply with a helpful message letting the user know to provide a valid category.
This will be resolved in a similar way to what was done in Part 2 of this series, which is to introduce an interactor DiscussRetrospective
to handle the validation and business logic. We'll also introduce a SlackCommentBuilder
module for the responsibility of building the Slack blocks array.
Starting with the SlackCommentBuilder
module. This contains smaller methods to build each type of block and some methods to put them all together:
# lib/slack_comment_builder.rb
module SlackCommentBuilder
module_function
def build_header_block(comments, category_display)
[
build_header(category_display),
build_section("Found *#{comments.size}* comments in this category:"),
build_divider
]
end
def build_comment_blocks(comments)
comments.flat_map do |comment|
[
build_comment_content(comment),
build_comment_context(comment),
build_divider
]
end
end
def build_header(category_display)
{
type: "header",
text: {
type: "plain_text",
text: category_display
}
}
end
def build_section(text)
{
type: "section",
text: {
type: "mrkdwn",
text:
}
}
end
def build_comment_content(comment)
build_section(comment.content)
end
def build_comment_context(comment)
{
type: "context",
elements: [
build_context_element(":bust_in_silhouette: #{comment.user_info}"),
build_context_element(":calendar: #{comment.created_at.strftime('%Y-%m-%d')}")
]
}
end
def build_context_element(text)
{
type: "plain_text",
emoji: true,
text:
}
end
def build_divider
{ type: "divider" }
end
end
And here is the DiscussRetrospective
interactor:
class DiscussRetrospective
include Interactor
include SlackCommentBuilder
def call
retrospective = Retrospective.find_by(status: Retrospective.statuses[:open])
return no_open_retrospective_message if retrospective.nil?
category = extract_valid_category
return invalid_category_message unless category
comments = retrospective.comments_by_category(category: context.category)
post_message(comments)
rescue StandardError => e
log_error(e)
context.fail!
end
private
def no_open_retrospective_message
message = "There is no open retrospective. Please run `/retro-open` to open one."
send_error_message(message)
end
def extract_valid_category
category = context.category&.to_sym
return category if Comment.categories.key?(category)
nil
end
def invalid_category_message
valid_categories = Comment.categories.keys.map(&:to_s).join(", ")
message = "Invalid discussion category. Please provide a valid category (#{valid_categories})."
send_error_message(message)
end
def post_message(comments)
blocks = build_header_block(comments, Comment.header(context.category))
blocks += build_comment_blocks(comments)
send_message(blocks)
end
def send_message(blocks)
context.slack_client.chat_postMessage(
channel: context.channel_id,
text: "fallback TBD",
blocks:
)
end
def send_error_message(text)
warning_icon = ":warning:"
context.slack_client.chat_postMessage(
channel: context.channel_id,
text: "#{warning_icon} #{text}"
)
end
def log_error(error)
error_message = "Error in DiscussRetrospective: #{error.message}"
backtrace = error.backtrace.join("\n")
Rails.logger.error("#{error_message}\n#{backtrace}")
end
end
What's going on:
- The inputs
category
,channel_id
, andslack_client
are provided via the context object, which contains everything the interactor needs to do its work. - Validation is performed on
category
, if itsnil
or an unknown category, an error message is sent back to the channel. - Given that
category
is valid, the comments for this category are retrieved from the openRetrospective
- The
comments
are then passed to the methods ofSlackCommentBuilder
to build the blocks array. - The resulting
blocks
are sent back to the channel.
Finally, the /retro-discuss
handler is updated, removing all the previous business logic, and instead it delegates to the DiscussRetrospective
interactor, passing it the inputs it needs:
# bot/slash_commands/retro_discuss.rb
SlackRubyBotServer::Events.configure do |config|
# Essentially this is saying to the SlackRubyBotServer,
# If a "/retro-discuss" slash command is received from Slack,
# then execute this block.
config.on :command, "/retro-discuss" do |command|
# Use `command[:team_id]` from request parameters sent to us
# by Slack to find the Team model persisted in the database
team = Team.find_by(team_id: command[:team_id])
# Instantiate a slack client with the team token
# so we can communicate back to the channel
slack_client = Slack::Web::Client.new(token: team.token)
# This is the Slack channel we need to respond back to
channel_id = command[:channel_id]
# If user entered /retro-discuss keep
# in Slack, then command_text will be: keep
command_text = command[:text]
command.logger.info "=== COMMAND: retro-discuss, Team: #{team.name}, Channel: #{channel_id}, Text: #{command_text}"
# Delegate to the interactor passing in `command_text` as the category, and channel_id and slack_client
DiscussRetrospective.call(category: command_text, channel_id:, slack_client:)
# Return `nil`, otherwise the slack-ruby-bot-server-events gem
# replies to the channel with a message "true"
nil
end
end
At this point, we have a functioning Slack application built with Rails. There's just a few more topics to cover before wrapping up.
Debugging Blocks
A common issue that can occur when working with the block kit UI is that you accidentally put together the blocks in an invalid way or specify some attribute value that's not allowed. For example, suppose in SlackCommentBuilder
module, we had specified a type: "button"
instead of type: "plain_text"
for the context element:
# lib/slack_comment_builder.rb
module SlackCommentBuilder
def build_context_element(text)
{
# type: "plain_text",
type: "button",
emoji: true,
text:
}
end
end
In this case, when the message is sent to Slack using the slack_client
:
# app/interactors/discuss_retrospective.rb
class DiscussRetrospective
# ...
def send_message(blocks)
context.slack_client.chat_postMessage(
channel: "some_channel_id",
text: "some_fallback_text",
blocks: blocks # Invalid due to bug in SlackCommentBuilder
)
end
end
Slack will return an invalid blocks
error. When working with the slack-ruby-bot-server-events gem, it uses the slack-ruby-client gem, which in turn uses faraday to communicate with the Slack API via HTTP. Here's what this error looks like in the Rails server output:
Error in DiscussRetrospective: invalid_blocks
slack-ruby-client-2.2.0/lib/slack/web/faraday/response/raise_error.rb:19:in `on_complete'
faraday-2.7.11/lib/faraday/middleware.rb:18:in `block in call'
...
retro-pulse/app/interactors/discuss_retrospective.rb:54:in `post_message'
retro-pulse/app/interactors/discuss_retrospective.rb:25:in `call'
Unfortunately, the error doesn't include specify what is wrong with the blocks. To resolve these kinds of errors, add a debug statement that outputs the json value of the blocks, just before the blocks get sent to Slack:
# app/interactors/discuss_retrospective.rb
class DiscussRetrospective
def send_message(blocks)
Rails.logger.debug { "=== BLOCKS: #{blocks.to_json}" }
context.slack_client.chat_postMessage(
channel: "some_channel_id",
text: "some_fallback_text",
blocks: blocks
)
end
end
When this code runs, the Rails server output will include a debug line like this:
=== BLOCKS: [{"type":"header","text":{"type":"plain_text","text":"What we should keep on doing"}}...]
Highlight and copy the entire blocks array from the Rails server output: [...]
.
Then open a browser tab to Slack's Block Kit Builder. You need to be signed in to a Slack workspace to use this feature. This renders a split screen view where on the right hand side you can enter any blocks JSON, and on the left hand side it will show you the rendered result. If invalid blocks JSON is entered, then Slack will show a red X mark beside the invalid line. Hovering over it will display what's wrong with this line.
For example, pasting in the blocks JSON from the buggy version of our code and hovering over the red X shows that button
is in invalid type for the context
block. The message helpfully lists the valid values which are: "image", "plain_text", "mrkdwn"
:
App Manifest
Throughout this series, we've been using Slack's web UI to build up the application. This has involved opening a browser tab to Your Slack Apps (need to be signed in to Slack workspace), then using the Settings and Features sections to tell Slack what this app supports. For example, to enable receiving a modal form submission in Part 4, we had to enable interactivity by filling out the URL that Slack should POST to:
That URL is an ngrok address that's forwarding requests from Slack to the Rails app running on localhost. We setup ngrok in Part 1 of this series. An issue to be aware of with the free ngrok version is that every time you restart it, it assigns a new url. That means going back into the Slack app configuration and updating all url's such as the OAuth redirect, interactivity, and slash commands sections. This is tedious and error prone.
To save yourself this manual effort, there is an easier way. Under the Features section of your Slack app, select "App Manifest". This provides a text representation of the Slack app in either YAML or JSON format. Click on the JSON format, it will look something like this:
Notice all the url's and Slack client id are in this file.
Make a copy of the JSON contents, and paste it into a new file in the Rails project root named app_manifest_template.json
. Update this file, making the following replacements:
- Update all occurrences of the ngrok IP address with
SERVER_HOST_NAME
- Update all occurrences of your slack client id with
SLACK_CLIENT_ID
Here's an example of app_manifest_template.json from the complete source of the Retro Pulse application on Github.
SERVER_HOST_NAME
and SLACK_CLIENT_ID
were configured as environment variables in Part 1 of this series.
Add the following rake task to generate the real manifest file from the template, replacing the environment variables with their actual values from the .env
file:
# lib/tasks/app_manifest.rake
namespace :manifest do
desc 'Generate and update app_manifest.json from app_manifest_template.json'
task generate: :environment do
require 'json'
require 'dotenv'
# Load environment variables from .env file in the project root directory
Dotenv.load(File.join(Rails.root, '.env'))
# Read the template JSON file from the project root
template_file = File.read(File.join(Rails.root, 'app_manifest_template.json'))
# Perform global replacements for SERVER_HOST_NAME and SLACK_CLIENT_ID
updated_content = template_file
.gsub('SERVER_HOST_NAME', ENV['SERVER_HOST_NAME'])
.gsub('SLACK_CLIENT_ID', ENV['SLACK_CLIENT_ID'])
# Write the updated JSON to app_manifest.json in the project root
File.open(File.join(Rails.root, 'app_manifest.json'), 'w') do |file|
file.write(updated_content)
end
puts 'app_manifest.json has been generated and updated.'
end
end
This will generate the real manifest file app_manifest.json
. Make sure this is in .gitignore
as it's a generated file and contains a secret.
Now, every time you restart ngrok, you'll do the following:
- Update the new ngrok IP address in
.env
- Run the rake task:
bundle exec rake manifest:generate
- Copy the contents of
app_manifest.json
into the Your Slack App Features: Manifest section, and save the changes.
Then you can restart the Rails server with bin/dev
and it will receive requests from Slack via the new ngrok forwarding address.
Deployment
A full discussion of how to deploy Rails apps to production is out of scope for this post. But here's a few things to note for this app.
Up until now, the Rails app has been running on a laptop, with ngrok forwarding traffic from Slack to localhost
. For production use, you'll need to deploy your Rails application to a publicly accessible host, eg: https://yourcompany-retropulse.com
. Then update all the url's in the Slack app to point to where the Rails app is running. You'll also need to make sure all the Slack environment variables (SLACK_CLIENT_ID
, SLACK_CLIENT_SECRET
, SLACK_SIGNING_SECRET
, and SLACK_VERIFICATION_TOKEN
) are populated in production.
By default, the Slack app is not available for public distribution. The Retro Pulse app described in this series is intended for just one team so this is fine, essentially it would only be installed in your workspace, which is the same one you've been developing it under.
However, if you have a different kind of application and want to distribute it more widely, it requires activating public distribution. To do this, select the "Manage Distribution" option under Settings when logged into Your Slack App:
Then Make sure all the required steps are checked off as shown below, then click on Activate Public Distribution (only if you want any company/team/workspace to be able to install the app):
Conclusion
Congratulations on making it to the end of this series on building a Slack application with Rails! I hope you've found it helpful. Throughout this series, we've covered many aspects of Slack integration, from OAuth authentication to implementing slash commands and interactive modals using Block Kit. In this final installment, we explored how to display feedback collected during retrospective meetings, bringing our app to completion.
There are many further enhancements that could be made. For example, to support public distribution and multiple teams, the Retrospective model could be associated to the Team model. Then all searches for an open retrospective would have to be scoped by team_id
. It might also be nice to let users list the feedback they've submitted so far and allow them to edit it. For further exploration and reference, here are some useful resources:
- Retro Pulse GitHub Repository
- Slack Ruby Client Gem
- Slack Ruby Bot Server Gem
- Slack Ruby Bot Server Events Gem
- Slack OAuth Documentation
- ngrok - Secure Tunnels to Localhost
- Your Slack Apps (requires login)
- Interactor Gem
- Slack Modals Documentation
- Slack Block Kit Documentation
- Slack Interactivity Documentation
- Slack API Methods