Build a Rails App with Slack Part 3: Slash Command with Modal Response
Welcome to the third 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 === YOU ARE HERE ===
- Part 4: Slack Action Modal Submission
- Part 5: Slack Slash Command with Block Kit Response
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. Now in Part 3, we will learn how to implement another slash command /retro-feedback
that will respond with a modal form, allowing the user to enter some feedback for the retrospective such as something the team should keep on doing, or stop doing, or something new to try.
The interaction starts with a user entering the /retro-feedback
slash command in a Slack workspace where the Retro Pulse app has been added:
After hitting Enter, the app responds with a modal:
The modal has a dropdown for the category of feedback:
After selecting a category, the user can enter a multi-line comment containing their feedback, optionally check the Anonymous option if they don't want their Slack username shown alongside their feedback, and Submit the form. For example:
We'll be looking at handling the form submission in the next post. This post is focused on generating the modal response.
Create Slash Command in Slack
The first step in implementing this is to 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-feedback
. 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 we included as part of our Rails app in part 1 of this series.
Short Description: Provide some feedback for what's going well, or what to stop doing, or try
. This will be displayed as the user types in the slash command.
Usage Hint: Leave blank.
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.
Then 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-feedback
command.
Start by adding a new file in the bot/slash_commands
directory named retro_feedback.rb
. The structure will be similar to the /retro-open
command handler we added in Part 2, except this time, there will be no command[:text]
because the /retro-feedback
command does not accept any arguments.
This command handler will make use of the trigger_id
that Slack sends in the request. The trigger_id
is a unique identifier generated by Slack when a user interacts with an interactive element (e.g., slash command, button click). It's used to open modals or perform other interactive actions in response to a user's command:
# bot/slash_commands/retro_feedback.rb
SlackRubyBotServer::Events.configure do |config|
# Essentially this is saying to the SlackRubyBotServer,
# If a "/retro-feedback" slash command is received from Slack,
# then execute this block.
config.on :command, "/retro-feedback" do |command|
# Use `command[:team_id]` from request parameters sent to us
# by Slack to find the Team model persisted in the database
team_id = command[:team_id]
team = Team.find_by(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]
# Use `command[:trigger_id]` from request parameters sent to us Slack
# This will be needed to generate the modal response.
trigger_id = command[:trigger_id]
command.logger.info "=== COMMAND: retro-feedback, Team: #{team.name}, Channel: #{channel_id}"
# == Do SOMETHING with trigger_id and slack_client ===
# Return `nil`, otherwise the slack-ruby-bot-server-events gem
# replies to the channel with a message "true"
nil
end
end
Also, use require_relative
to load this command in the bot/slash_commands.rb
file. We created this file in Part 2 for receiving slash commands:
# bot/slash_commands.rb
# This line was added in Part 2 of this series
require_relative "slash_commands/retro_open"
# === NEW ===
require_relative "slash_commands/retro_feedback"
This file is loaded by config.ru
to ensure that all the Slack handlers are loaded when Rails starts (which we also did in Part 2).
At this point, if you restart the Rails server, and then enter /retro-feedback
in any channel in a Slack workspace with the Retro Pulse app has been installed, you should see some info logging in the Rails server output displaying your team name and the channel id. Nothing will happen on the Slack side because the code isn't responding yet. The next section explains how to generate a modal response.
Respond with Example Modal
In order to send back a modal response from the slash command, we'll make use of Slack Modals and Block Kit.
Modal: The Slack app equivalent of an alert box, pop-up, or dialog box. Modals capture and maintain focus within Slack until the user submits or dismisses the modal.
Block Kit: A framework provided by Slack for building rich and interactive messages within the Slack platform. It allows developers to create visually appealing messages containing elements such as text, buttons, images, and input fields. Think of Block Kit as a set of building blocks for crafting dynamic user interfaces directly within Slack conversations.
This will make more sense with an example. In the following code snippet, the views_open method from the Slack API is used to trigger the opening of a modal in response to the /retro-feedback
command. This method accepts a hash that represents the modal contents. The modal includes a title "Example Modal", a submit button, a cancel button, and an input block for users to enter some text:
# bot/slash_commands/retro_feedback.rb
SlackRubyBotServer::Events.configure do |config|
config.on :command, "/retro-feedback" do |command|
team_id = command[:team_id]
team = Team.find_by(team_id:)
slack_client = Slack::Web::Client.new(token: team.token)
channel_id = command[:channel_id]
trigger_id = command[:trigger_id]
command.logger.info "=== COMMAND: retro-feedback, Team: #{team.name}, Channel: #{channel_id}"
modal_payload = {
trigger_id: trigger_id,
view: {
type: "modal",
callback_id: "feedback_form",
title: {
type: "plain_text",
text: "Example Modal",
emoji: true
},
submit: {
type: "plain_text",
text: "Submit",
emoji: true
},
close: {
type: "plain_text",
text: "Cancel",
emoji: true
},
blocks: [
{
type: "input",
block_id: "comment_block",
element: {
type: "plain_text_input",
action_id: "comment_input",
multiline: true,
placeholder: {
type: "plain_text",
text: "Enter some text"
}
},
label: {
type: "plain_text",
text: "Comment"
}
}
]
}
}
slack_client.views_open(modal_payload)
nil
end
end
Details:
trigger_id
was extracted from the Slack request sent to the Rails app when the user entered/retro-feedback
, we need to send it back to Slack as part of the request to open a modal so that Slack can "connect" the original user's request with this modal response.callback_id
is a unique identifier assigned to the modal, helping your Slack app distinguish different modals from one another when user submits them.title
will be displayed at the top of the modal.- The
submit
andclose
attributes are optional. Slack will always generate these buttons, but you can optionally define them in the payload to override the default text that is displayed on the buttons. - The
blocks
section is required. It contains an array of Slack block elements that make up the modal content. In this simple example, we only have a single element, a multi-line input with some placeholder text and a label. The Slack reference documentation covers all supported elements and options.
After restarting the Rails server bin/dev
, go to your Slack workspace, enter /retro-feedback
in any channel and hit Enter, you should be presented with a modal like this:
Respond with Retro Feedback Modal
Now that we understand the basics of generating a modal response, we're ready to start building the actual modal to collect feedback on the retrospective. Recall this is what we need to generate:
Some of this we've already seen how to do including generating the title, a multi-line text input, and the Cancel and Submit buttons. The new parts are the dropdown value for Category (keep, stop, and try), and the optional checkbox for submitting the feedback anonymously.
To generate a select input element with a static list of options, Slack provides the static_select type. This supports specifying a placeholder such as "Select category", and a list of options. Each option has display text and the value that will be submitted if user selects this option. Adding the category static_select
input type to our list of blocks looks like this:
# bot/slash_commands/retro_feedback.rb
SlackRubyBotServer::Events.configure do |config|
config.on :command, "/retro-feedback" do |command|
# extract params from command
modal_payload = {
trigger_id: trigger_id,
view: {
type: "modal",
callback_id: "feedback_form",
title: { ... },
submit: { ... },
close: { ... },
blocks: [
{ type: "input", block_id: "comment_block", ... },
{
type: "input",
block_id: "category_block",
element: {
type: "static_select",
action_id: "category_select",
placeholder: {
type: "plain_text",
text: "Select category"
},
options: [
{
text: {
type: "plain_text",
text: "Something we should keep doing"
},
value: "keep"
},
{
text: {
type: "plain_text",
text: "Something we should stop doing"
},
value: "stop"
},
{
text: {
type: "plain_text",
text: "Something to try"
},
value: "try"
}
]
},
label: {
type: "plain_text",
text: "Category"
}
}
]
}
}
slack_client.views_open(modal_payload)
nil
end
end
To complete the modal generation, we also need to add the Anonymous checkbox. This is for user's that don't want their Slack username displayed later when the retrospective feedback is being discussed. The Slack reference documentation explains how to use a checkbox element. It's type is checkboxes
, and requires an array of options. We'll also set optional: true
to tell Slack that this isn't required. Here's what the blocks
array looks like with the checkbox element added:
# bot/slash_commands/retro_feedback.rb
SlackRubyBotServer::Events.configure do |config|
config.on :command, "/retro-feedback" do |command|
# extract params from command...
modal_payload = {
trigger_id: trigger_id,
view: {
type: "modal",
callback_id: "feedback_form",
title: { ... },
submit: { ... },
close: { ... },
blocks: [
{ type: "input", block_id: "comment_block", ... },
{ type: "input", block_id: "category_block", ... },
{
type: "input",
block_id: "anonymous_block",
optional: true,
element: {
type: "checkboxes",
action_id: "anonymous_checkbox",
options: [
{
text: {
type: "plain_text",
text: "Yes"
},
value: "true"
}
]
},
label: {
type: "plain_text",
text: "Anonymous"
}
}
]
}
}
slack_client.views_open(modal_payload)
nil
end
end
Restarting the Rails server at this point, and then running /retro-feedback
in Slack will now generate the form with all the required inputs.
However, before declaring this task done, there's a problem. This code is way too long (if using Rubocop, the Metrics/BlockLength
violation will be indicated). Furthermore, having it in the command handler makes it impossible to test. This will be addressed in the next section.
Refactor
The issue of an overly long command handler can be addressed in a similar manner to what was done in Part 2 of this series, where the command handler was refactored by moving the business logic into an interactor named OpenRetrospective
. A brief discussion of how to organize business logic in Rails applications was also covered in Part 2.
We'll do something similar now by introducing an InitiateFeedbackForm
interactor, which will receive the trigger_id
and slack_client
from the command handler, build the modal response, and send it back to the slack channel. This will simplify the command handler to look like this:
# bot/slash_commands/retro_feedback.rb
SlackRubyBotServer::Events.configure do |config|
config.on :command, "/retro-feedback" do |command|
team_id = command[:team_id]
team = Team.find_by(team_id:)
slack_client = Slack::Web::Client.new(token: team.token)
channel_id = command[:channel_id]
trigger_id = command[:trigger_id]
command.logger.info "=== COMMAND: retro-feedback, Team: #{team.name}, Channel: #{channel_id}"
# Pass in the `trigger_id` and `slack_client` as context to the interactor
InitiateFeedbackForm.call(trigger_id:, slack_client:)
nil
end
end
The interactor is responsible for building the modal payload, and sending it back to the channel using the slack client.
# app/interactors/initiate_feedback_form.rb
class InitiateFeedbackForm
include Interactor
def call
modal_payload = build_modal_payload
context.slack_client.views_open(modal_payload)
rescue StandardError => e
error_message = "Error in InitiateFeedbackForm: #{error.message}"
backtrace = error.backtrace.join("\n")
Rails.logger.error("#{error_message}\n#{backtrace}")
context.fail!
end
private
# Use `trigger_id` from context passed in by the Slack command handler
def build_modal_payload
{
trigger_id: context.trigger_id,
view: {
type: "modal",
callback_id: "feedback_form",
title: { ... },
submit: { ... },
close: { ... },
blocks: [
{ type: "input", block_id: "comment_block", ... },
{ type: "input", block_id: "category_block", ... },
{ type: "input", block_id: "anonymous_block", ... }
]
}
}
end
end
While this eliminates the Rubocop warnings from the Slack command handler bot/slash_commands/retro_feedback.rb
, it introduces a new problem. The build_modal_payload
method of the InitiateFeedbackForm
is too long, which Rubocop will flag with the Metrics/MethodLength
rule. It looks condensed above because I've replaced many sections with ...
to easily fit it in a smaller view, but the actual payload is over 100 lines long!
One way to deal with this would be to break up the building of individual portions of the modal payload into smaller methods. For example, extract a method such as build_title_block
that only deals with the title logic:
# app/interactors/initiate_feedback_form.rb
class InitiateFeedbackForm
include Interactor
def call
# ...
end
private
def build_modal_payload
{
trigger_id: context.trigger_id,
view: {
type: "modal",
callback_id: "feedback_form",
title: build_title_block,
submit: { ... },
close: { ... },
blocks: [
{ type: "input", block_id: "comment_block", ... },
{ type: "input", block_id: "category_block", ... },
{ type: "input", block_id: "anonymous_block", ... }
]
}
}
end
def build_title_block
{
type: "plain_text",
text: "Retrospective Feedback",
emoji: true
}
end
end
This pattern could be carried on for every element of the modal. However, since the logic is so closely tied to the Slack API, it will be cleaner to extract Slack-specific form building logic into the lib
directory as a new module SlackFormBuilder
:
# lib/slack_form_builder.rb
module SlackFormBuilder
module_function
def build_title_block
{
type: "plain_text",
text: "Retrospective Feedback",
emoji: true
}
end
def build_submit_block
{
type: "plain_text",
text: "Submit",
emoji: true
}
end
def build_close_block
{
type: "plain_text",
text: "Cancel",
emoji: true
}
end
def build_category_block
{
type: "input",
block_id: "category_block",
element: build_static_select_element,
label: {
type: "plain_text",
text: "Category"
}
}
end
def build_static_select_element
{
type: "static_select",
action_id: "category_select",
placeholder: {
type: "plain_text",
text: "Select category"
},
options: [
build_option("Something we should keep doing", "keep"),
build_option("Something we should stop doing", "stop"),
build_option("Something to try", "try")
]
}
end
def build_option(text, value)
{
text: {
type: "plain_text",
text:
},
value:
}
end
def build_comment_block
{
type: "input",
block_id: "comment_block",
element: build_plain_text_input_element("Enter your feedback"),
label: {
type: "plain_text",
text: "Comment"
}
}
end
def build_plain_text_input_element(placeholder_text)
{
type: "plain_text_input",
action_id: "comment_input",
multiline: true,
placeholder: {
type: "plain_text",
text: placeholder_text
}
}
end
def build_anonymous_block
{
type: "input",
block_id: "anonymous_block",
optional: true,
element: build_checkboxes_element,
label: {
type: "plain_text",
text: "Anonymous"
}
}
end
def build_checkboxes_element
{
type: "checkboxes",
action_id: "anonymous_checkbox",
options: [
build_option("Yes", "true")
]
}
end
end
With the SlackFormBuilder
module in place that does all the work of building the Slack block kit form components, the InitiateFeedbackForm
interactor can be simplified to use it as follows:
class InitiateFeedbackForm
include Interactor
# Mixin the custom module so we can access the methods
# such as `build_title_block` as instance methods
include SlackFormBuilder
def call
modal_payload = build_modal_payload
context.slack_client.views_open(modal_payload)
rescue StandardError => e
error_message = "Error in InitiateFeedbackForm: #{error.message}"
backtrace = error.backtrace.join("\n")
Rails.logger.error("#{error_message}\n#{backtrace}")
context.fail!
end
private
# Make use of the SlackFormBuilder module methods
def build_modal_payload
{
trigger_id: context.trigger_id,
view: {
type: "modal",
callback_id: "feedback_form",
title: build_title_block,
submit: build_submit_block,
close: build_close_block,
blocks: [
build_category_block,
build_comment_block,
build_anonymous_block
]
}
}
end
end
Validation
One last thing the InitiateFeedbackForm
needs to do is verify that an open retrospective actually exists, and if not, it should notify the user with a direct message (DM) that made the request. There's no point in letting the user provide feedback if there's nowhere to save it to. The Retrospective model was created in Part 2 of this series.
In order to send a direct message, we'll need to know the Slack user that made the /retro-feedback
request. This can be extracted from the command
object exposed by the command handler, and passed on to the interactor context:
# bot/slash_commands/retro_feedback.rb
SlackRubyBotServer::Events.configure do |config|
config.on :command, "/retro-feedback" do |command|
team_id = command[:team_id]
team = Team.find_by(team_id:)
slack_client = Slack::Web::Client.new(token: team.token)
channel_id = command[:channel_id]
trigger_id = command[:trigger_id]
# === NEW: Extract the user_id ===
user_id = command[:user_id]
# === NEW: Also pass in user_id ===
InitiateFeedbackForm.call(trigger_id:, slack_client:, user_id:)
nil
end
end
Then the InitiateFeedbackForm
is modified to check for an open retrospective, and send the user an error message using the chat_postMessage API from the slack_client
:
class InitiateFeedbackForm
include Interactor
include SlackFormBuilder
def call
retrospective = Retrospective.find_by(status: Retrospective.statuses[:open])
return no_open_retrospective_message if retrospective.nil?
# ...
end
private
# ...
# Specifying `user_id` for the channel will send a direct message to the user
def no_open_retrospective_message
message = "There is no open retrospective. Please run `/retro-open` to open one."
warning_icon = ":warning:"
context.slack_client.chat_postMessage(
channel: context.user_id,
text: "#{warning_icon} #{message}"
)
end
end
With this change in place, if a user enters /retro-feedback
in a Slack workspace with the Retro Pulse app installed, and there is no open retrospective, they will get a notification from the app as shown below:
Next Steps
At this point, we've built the capability for a Slack user to enter a custom slash command /retro-feedback
, and have the app respond with a nicely formatted modal. The user can then select the category of the retrospective feedback (whether it's something the team should keep on doing, stop doing, or try for next time), enter their feedback in a multi-line input, and optionally check off if they wish to remain anonymous. In the next post in this series, we'll learn how to handle the form submission, save the user's feedback in the database, and send a direct message back to the user to confirm their feedback was received.