Build a Rails App with Slack Part 4: Receive Modal Submission
Welcome to the fourth 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 === YOU ARE HERE ===
- 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. Part 3 covered how to implement a slash command that responds with a modal form, allowing the user to enter feedback for the retrospective.
Now in Part 4, we will learn how to handle the modal submission, save the user's feedback in the database, and reply back with a DM to the user letting them know their input has been received. The interaction looks like this:
In Part 3 we learned how to build this modal form in response to the /retro-feedback
slash command:
After submitting the form, the app responds with a direct message (DM) to the user confirming their feedback has been received:
Comment Model
Before implementing the Slack portion of this, we need to ensure there's a place in the database to save the user's retrospective comments. In Part 2 of this series, we introduced the Retrospective model, with title
and status
attributes:
# == Schema Information
#
# Table name: retrospectives
#
# id :bigint not null, primary key
# status :enum default("open"), not null
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_retrospectives_on_title (title) UNIQUE
#
class Retrospective < ApplicationRecord
enum status: {
open: "open",
closed: "closed"
}
# ...
end
To save the user's feedback, let's add a Comment
model that belongs_to
a Retrospective
. It has a text
column to store the content, some columns to store the Slack user information, and a boolean
to indicate if this comment should be anonymous. Here is the migration:
class CreateComments < ActiveRecord::Migration[7.0]
def change
create_table :comments do |t|
t.text :content, null: false
t.boolean :anonymous, null: false, default: false
t.string :slack_user_id
t.string :slack_username
t.references :retrospective, null: false, foreign_key: true
t.timestamps
end
end
end
We also need to know what kind of comment this is, i.e. whether this is something the team should keep on doing, stop doing, or try something new for next time. This can be modelled with a Postgres enum:
class AddCategoryToComments < ActiveRecord::Migration[7.0]
def up
execute <<-SQL.squish
CREATE TYPE comment_category AS ENUM ('keep', 'stop', 'try');
SQL
add_column :comments, :category, :comment_category, default: "keep", null: false
end
def down
remove_column :comments, :category
execute <<-SQL.squish
DROP TYPE comment_category;
SQL
end
end
After running the migrations with bin/rails db:migrate
, the resulting Comment
model is:
# == Schema Information
#
# Table name: comments
#
# id :bigint not null, primary key
# anonymous :boolean default(FALSE), not null
# category :enum default("keep"), not null
# content :text not null
# slack_username :string
# slack_user_id :string
# created_at :datetime not null
# updated_at :datetime not null
# retrospective_id :bigint not null
#
# Indexes
#
# index_comments_on_retrospective_id (retrospective_id)
#
# Foreign Keys
#
# fk_rails_... (retrospective_id => retrospectives.id)
#
class Comment < ApplicationRecord
belongs_to :retrospective
enum category: {
keep: "keep",
stop: "stop",
try: "try"
}
end
The retrospective model is also updated to indicate it has_many
comments. The dependent: :destroy
option is used to ensure that when a Retrospective
record is deleted, all associated Comment
records belonging to that retrospective are also deleted. This prevents orphaned records by ensuring that comments tied to a specific retrospective are removed when the retrospective is no longer needed:
# == Schema Information
#
# Table name: retrospectives
#
# id :bigint not null, primary key
# status :enum default("open"), not null
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_retrospectives_on_title (title) UNIQUE
#
class Retrospective < ApplicationRecord
has_many :comments, dependent: :destroy
# ...
end
Anonymous Enforcement
There's one more thing to handle in the Comment
model. The requirements of this app are that if the user selects the Anonymous checkbox when filling out the feedback form:
Then their Slack user id and name should not be persisted in the database. Otherwise if the feedback is not anonymous (i.e. user left the Anonymous checkbox unchecked), then their Slack user id and name should be persisted in the database. This will be used later when the team wants to discuss the retrospective feedback and the app displays all the feedback that has been submitted:
To enforce this rule, a combination of presence
and absence
options can be passed to the ActiveRecord validates
macro, together with conditional options:
class Comment < ApplicationRecord
validates :slack_user_id,
absence: { message: "must be empty when anonymous is true" },
if: :anonymous
validates :slack_username,
absence: { message: "must be empty when anonymous is true" },
if: :anonymous
validates :slack_user_id,
presence: { message: "must be provided when anonymous is false" },
unless: :anonymous
validates :slack_username,
presence: { message: "must be provided when anonymous is false" },
unless: :anonymous
# ...
end
However, the above validation rules will only be performed at the application level. Someone with direct database access would still be able to insert invalid data. To ensure data integrity at the database level, a CHECK CONSTRAINT can be added to the table using the Rails migration method add_check_constraint
:
class AddCheckConstraintForSlackInfoInComments < ActiveRecord::Migration[7.0]
def change
# If a comment is anonymous, then the slack info fields should be null.
# If a comment is not anonymous, then the slack info fields should be populated.
add_check_constraint(
:comments,
"(anonymous AND slack_user_id IS NULL AND slack_username IS NULL)
OR
(NOT anonymous AND slack_user_id IS NOT NULL AND slack_username IS NOT NULL)",
name: "check_slack_info_if_not_anonymous"
)
end
end
Now that the Comment
model is implemented, we can move on to handling the Slack form submission.
Configure Slack Interactivity
When the user clicks the Submit button on the modal form we generated, Slack will send an interaction payload to the request_url
that is configured as part of the Interactivity Settings of the app. We haven't configured this yet so let's go ahead and do that now.
Navigate to Your Apps in Slack, select the "Retro Pulse" application, then select "Interactivity & Shortcuts" from the Features section:
Enable the interactivity toggle:
Fill in your ngrok forwarding address in the Request URL field, and then /api/slack/action
. This is the URL that Slack will POST a message to when the user submits the feedback modal. Ngrok was setup in Part 1 of this series.
As soon as you enter a valid URL, it will be saved automatically.
Receive Form Submission in Rails
Now that the Slack app has been configured to POST the form submission to the Rails app (via Ngrok), we need to write a handler to receive this payload. In Part 2 of this series, we learned how to use the slack-ruby-bot-server-events gem to setup a command handler to receive a slash command. Now we'll do something similar, but for receiving the form submission. The slack-ruby-bot-server-events
gem calls these Actions.
Starting from the root of the project, create the following directory and files:
# The `bot` directory was created in Part 2 of this series
touch bot/actions.rb
mkdir bot/actions
touch bot/actions/view_submission.rb
You should have a directory structure that looks like this. Note that the bot
directory is a sibling to the Rails app
directory, and the slash_commands
were created in Parts 2 and 3 of this series:
.
├── app
└── bot
├── actions
│ └── view_submission.rb
├── actions.rb
├── slash_commands
│ ├── retro_feedback.rb
│ └── retro_open.rb
└── slash_commands.rb
Add the following in bot/actions.rb
to load all the actions, there's only one for now:
# bot/actions.rb
require_relative "actions/view_submission"
Fill in the implementation for the view_submission
action handler. For now, it will only log out the payload it received:
# bot/actions/view_submission.rb
SlackRubyBotServer::Events.configure do |config|
# Essentially this is saying to the SlackRubyBotServer,
# If a "view_submission" interaction is received from Slack,
# then execute this block.
config.on :action, "view_submission" do |action|
payload = action[:payload]
action.logger.info "=== ACTION: payload = #{payload}"
# Return `nil`, otherwise the slack-ruby-bot-server-events gem
# replies to the channel with a message "true"
nil
end
end
Update config.ru
file in the root of the Rails app to load the action handlers in the bot
directory. This will ensure the the Slack bot code is loaded when Rails starts:
# This file is used by Rack-based servers to start the application.
require_relative "config/environment"
# This line was added in Part 2 of this series
require_relative "bot/slash_commands"
# === NEW: Load Slack action handlers ===
require_relative "bot/actions"
# We added this line previously in Part 1 of this series:
SlackRubyBotServer::App.instance.prepare!
run Rails.application
Rails.application.load_server
To see the action handler working, restart the Rails server bin/dev
. Then in any Slack workspace that has the Retro Pulse app installed, enter the /retro-feedback
slash command to launch the feedback modal. Fill it in by selecting any Category, enter some test text in the Comment, and check off the Anonymous option:
Then click the Submit button in Slack, and watch the Rails server output. It will show something like this:
Started POST "/api/slack/action" for 34.201.19.177 at 2024-02-06 07:10:16 -0500
INFO -- : === ACTION: payload = {"type"=>"view_submission", "team"=>{"id"=>"your-team-id", ...}
What's going on:
The above output from the Rails server shows that a POST to /api/slack/action
is being processed. Recall when we configured Slack interactivity earlier, we told Slack to post payloads to this url. Slack is actually posting to the ngrok url, which forwards to the Rails server running on localhost:3000
.
To view the "raw" request sent by Slack, open a browser at http://127.0.0.1:4040
. This is the web interface exposed by ngrok which shows all HTTP requests and responses that were received by ngrok and responded to by the Rails app. For example, the form we just submitted was posted to ngrok as a url-encoded form with the following HTTP headers and body:
POST /api/slack/action HTTP/1.1
Host: 05f9-70-51-246-153.ngrok-free.app
User-Agent: Slackbot 1.0 (+https://api.slack.com/robots)
Content-Length: 4137
Accept: application/json,*/*
Accept-Encoding: gzip,deflate
Content-Type: application/x-www-form-urlencoded
X-Forwarded-For: 32.423.15.766
X-Forwarded-Host: 12e4-203-0-113-42.ngrok-free.app
X-Forwarded-Proto: https
X-Slack-Request-Timestamp: 1707220000
X-Slack-Signature: v0=88a...
payload=%7B%22type%22%3A%22view_submission%...
The POST /api/slack/action
is handled by the routing provided by the slack-ruby-bot-server-events gem, which takes care of a lot of the boilerplate including providing a controller to parse the raw url-encoded form body, and logic to verify the X-Slack-Signature
HTTP header.
Then the slack-ruby-bot-server-events
gem's controller for /api/slack/action
parses out the specific action name, which it finds in the type
section of the payload. From this example, the type is view_submission
. The gem will then run all callbacks that are registered for that action.
Since we added config.on :action, "view_submission"
in view_submission.rb
, this is how the slack-ruby-bot-server-events
gem knows it should run our custom logic. The raw url-encoded form data received from Slack has been converted into a hash that's available as action[:payload]
.
Saving Feedback
Now that the communication between Slack and Rails is working to receive the form submission, the next step is to parse out the contents of the payload to save the user's retrospective feedback.
The payload contains all the field names and corresponding values submitted by the user for the custom modal we built in Part 3, along with additional information such as the Slack team, user, trigger ID, and API application ID. Here is a condensed version of the payload for the test we submitted earlier:
{"type"=>"view_submission",
"team"=>{"id"=>"T0-your-team-id", "domain"=>"your-slack-domain"},
"user"=>{"id"=>"U0-your-slack-user-id", "username"=>"your.slack.user.name"},
"api_app_id"=>"A0-your-slack-app-id",
"token"=>"za...",
"trigger_id"=>"659...",
"view"=>
{
"type"=>"modal",
"callback_id"=>"feedback_form",
"blocks"=>[... ],
"state"=>
{"values"=>
{"category_block"=>
{"category_select"=>
{"type"=>"static_select",
"selected_option"=>
{"text"=>{"type"=>"plain_text", "text"=>"Something we should keep doing"},
"value"=>"keep"}}},
"comment_block"=>
{"comment_input"=>
{"type"=>"plain_text_input", "value"=>"This is a test of the modal submission action handler in Rails"}},
"anonymous_block"=>
{"anonymous_checkbox"=>
{"type"=>"checkboxes",
"selected_options"=>[{"text"=>{"type"=>"plain_text", "text"=>"Yes"}, "value"=>"true"}]}}}},
},
}
The payload indicates that it is of type view_submission
and contains some information about the Slack team
and user
.
The view
section contains a blocks
section, which is a repetition of the blocks we defined earlier in Part 3 when creating the form, which follows the Slack Block Kit format. We can disregard this section.
The view
section also contains a state
section. Here is where we find the actual values the user filled in the modal form. For example, the category option the user selected is available in payload["view"]["state"]["values"]["category_block"]["category_select"]["selected_option"]["value"]
.
The attributes of the payload need to be parsed out for instantiating and saving a Comment
model in the Rails app. I find it helpful to create a mapping table before writing the code, to be confident that all the data is available:
Comment Model | Slack Payload Attribute |
---|---|
category | ["view"]["state"]["values"]["category_block"]["category_select"]["selected_option"]["value"] |
content | ["view"]["state"]["values"]["comment_block"]["comment_input"]["value"] |
slack_user_id | ["user"]["id"] |
slack_username | ["user"]["username"] |
anonymous | ["view"]["state"]["values"]["anonymous_block"]["anonymous_checkbox"]["selected_options"].present? |
To determine whether the user checked off the Anonymous option in the form, we have to check whether the selected_options
of the checkbox portion of the payload exist. If yes, it means the user checked this option, otherwise, there will be no selected_options
at all.
Now that we know how to parse the Slack payload to extract what's needed to build a Comment model, we can come back to the view_submission
action handler, and implement the logic to create a new Comment:
# bot/actions/view_submission.rb
SlackRubyBotServer::Events.configure do |config|
config.on :action, "view_submission" do |action|
payload = action[:payload]
action.logger.info "=== ACTION: payload = #{payload}"
anonymous = payload["view"]["state"]["values"]["anonymous_block"]["anonymous_checkbox"]["selected_options"].present?
# This comment will be associated with the one and only open Retrospective
Comment.create!(
retrospective: Retrospective.find_by(status: Retrospective.statuses[:open]),
content: payload["view"]["state"]["values"]["comment_block"]["comment_input"]["value"],
anonymous:,
category: payload["view"]["state"]["values"]["category_block"]["category_select"]["selected_option"]["value"],
slack_user_id: anonymous ? nil : payload["user"]["id"],
slack_username: anonymous ? nil : payload["user"]["username"]
)
# Return `nil`, otherwise the slack-ruby-bot-server-events gem
# replies to the channel with a message "true"
nil
end
end
To make a better user experience, the app should reply with a DM (direct message) to the user that submitted the feedback, confirming their feedback has been saved. The postMessage API provided by Slack can be used for this. This was used in Part 2 of this series to send a message to the channel that a retrospective was opened. This time, instead of replying to the channel, the app should reply only to the user. This is accomplished by specifying the Slack user id as the channel
argument.
Here is the modified view_submission handler, with a DM back to the user:
SlackRubyBotServer::Events.configure do |config|
config.on :action, "view_submission" do |action|
payload = action[:payload]
action.logger.info "=== ACTION: payload = #{payload}"
anonymous = payload["view"]["state"]["values"]["anonymous_block"]["anonymous_checkbox"]["selected_options"].present?
Comment.create!(
retrospective: Retrospective.find_by(status: Retrospective.statuses[:open]),
content: payload["view"]["state"]["values"]["comment_block"]["comment_input"]["value"],
anonymous:,
category: payload["view"]["state"]["values"]["category_block"]["category_select"]["selected_option"]["value"],
slack_user_id: anonymous ? nil : payload["user"]["id"],
slack_username: anonymous ? nil : payload["user"]["username"]
)
# Instantiate a slack_client for this team
team_id = payload["team"]["id"]
team = Team.find_by(team_id:)
slack_client = Slack::Web::Client.new(token: team.token)
# Send DM to user
slack_client.chat_postMessage(
channel: payload["user"]["id"],
text: "Thank you, your feedback has been saved."
)
# Return `nil`, otherwise the slack-ruby-bot-server-events gem
# replies to the channel with a message "true"
nil
end
end
After restarting the Rails server bin/dev
, and submitting the form again from Slack /retro-feedback
, you should see the following in the Rails server output, indicating that a new Comment has been saved with the payload values. I've added some annotations:
=== Log the action payload
INFO -- : === ACTION: payload = {"type"=>"view_submission", "team"=>{"id"=>"T0...}
=== Find the open retrospective to associate the new Comment with:
Retrospective Load (1.6ms) SELECT "retrospectives".*
FROM "retrospectives"
WHERE "retrospectives"."status" = $1
ORDER BY "retrospectives"."id" ASC LIMIT $2
[["status", "open"], ["LIMIT", 1]]
== Insert a new Comment record in the database
== Since Anonymous checkbox was selected, slack info fields are nil
TRANSACTION (1.8ms) BEGIN
Comment Create (5.6ms)
INSERT INTO "comments"
("content", "anonymous", "retrospective_id", "created_at", "updated_at", "category", "slack_user_id", "slack_username")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING "id"
[
["content", "This is a test of the modal submission action handler in Rails"],
["anonymous", true],
["retrospective_id", 31],
["created_at", "2024-02-09 12:53:38.577358"],
["updated_at", "2024-02-09 12:53:38.577358"],
["category", "keep"],
["slack_user_id", nil],
["slack_username", nil]]
TRANSACTION (2.1ms) COMMIT
And in your Slack workspace, you should see a DM from the Retro Pulse app.
Refactor
While the current solution works, there are some problems with it:
- It's getting too long.
- There's no error handling. For example, the Comment may fail to be saved due to validation rules.
- Having all the business logic directly in the action handler makes it impossible to test.
- Having all the form parsing together with the business logic makes the code hard to read.
These problems can be solved in the same way as was done in Part2 of this series, by introducing an interactor named SaveRetrospectiveFeedback
. To keep the interactor clean, we'll also introduce a SlackFormParser
module for extracting the information needed from the Slack view_submission form.
Here is the SlackFormParser
module
# lib/slack_form_parser.rb
module SlackFormParser
module_function
def parse_user_info(payload)
{
user_id: payload["user"]["id"],
slack_user_id: payload["user"]["id"],
slack_username: payload["user"]["username"]
}
end
def parse_feedback_info(payload)
view_state = payload["view"]["state"]
{
category: view_state["values"]["category_block"]["category_select"]["selected_option"]["value"],
comment: view_state["values"]["comment_block"]["comment_input"]["value"],
anonymous: view_state["values"]["anonymous_block"]["anonymous_checkbox"]["selected_options"].present?
}
end
end
Here is the SaveRetrospectiveFeedback
interactor:
- It's provided the form
payload
andslack_client
viacontext
. - It includes the
SlackFormParser
for extracting the attributes for the Comment model. - It then attempts to create and save a new Comment from the parsed form information.
- If it succeeds, it DMs the user a success message, including the feedback category.
- Otherwise, it DMs the user a failure message, with the reason the Comment could not be saved.
- Finally if anything unexpected happens, it fails the context and logs the error.
class SaveRetrospectiveFeedback
include Interactor
include ActionView::Helpers::SanitizeHelper
include SlackFormParser
def call
parse_payload
save_comment
send_feedback_confirmation
rescue StandardError => e
log_error(e)
context.fail!
end
private
def parse_payload
@user_info = parse_user_info(context.payload)
@feedback_info = parse_feedback_info(context.payload)
end
def save_comment
retrospective = Retrospective.find_by(status: Retrospective.statuses[:open])
comment = Comment.new(
content: @feedback_info[:comment],
anonymous: @feedback_info[:anonymous],
category: @feedback_info[:category],
retrospective:,
**slack_fields
)
@save_message = if comment.save
"Thank you, your `#{@feedback_info[:category]}` feedback has been submitted."
else
"Could not save your `#{@feedback_info[:category]}` feedback: #{comment.errors.full_messages}"
end
end
def slack_fields
if @feedback_info[:anonymous]
{ slack_user_id: nil, slack_username: nil }
else
{ slack_user_id: @user_info[:slack_user_id], slack_username: @user_info[:slack_username] }
end
end
def send_feedback_confirmation
context.slack_client.chat_postMessage(
channel: @user_info[:user_id],
text: @save_message
)
end
def log_error(error)
error_message = "Error in SaveRetrospectiveFeedback: #{error.message}"
backtrace = error.backtrace.join("\n")
Rails.logger.error("#{error_message}\n#{backtrace}")
end
end
The view_submission action handler can be simplified because most of the logic has moved to the interactor:
# bot/actions/view_submission.rb
SlackRubyBotServer::Events.configure do |config|
config.on :action, "view_submission" do |action|
payload = action[:payload]
team_id = payload["team"]["id"]
team = Team.find_by(team_id:)
slack_client = Slack::Web::Client.new(token: team.token)
# If app is receiving multiple different form submissions, check callback_id and handle accordingly
callback_id = payload["view"]["callback_id"]
action.logger.info "=== ACTION: Team: #{team.name}, callback_id: #{callback_id}"
SaveRetrospectiveFeedback.call(payload:, slack_client:)
nil
end
end
About callback_id: The callback_id
attribute in the payload was initially populated in Part 3 of this series when we specified the modal payload. It's value was set to feedback_form
as follows:
modal_payload = {
trigger_id: trigger_id,
view: {
type: "modal",
callback_id: "feedback_form",
# ...
}
# ...
}
Populating a callback_id
is useful for an application that generates many different types of forms, in which case, it would set a unique callback_id
per form. Slack will send all form submissions to the same URL, which we configured earlier in the interactivity section. Then the application would need the ability to distinguish which kind of form is being submitted, and delegate to an appropriate handler. In this case, Retro Pulse is a simple app with only one form so strictly speaking, callback_id
isn't necessary, but it doesn't hurt to have it, then the app is ready for adding more forms in the future.
Next Steps
In this part of the series on building a Slack app with Rails, we've learned how to receive a form submission from Slack, parse it to extract the data the user filled in, save it to the database, and reply with a DM to the Slack user confirming their feedback was saved. At this point, we almost have a complete application working. Read on to the last part in this series, where we'll build a block kit response to a slash command, to support the team in discussing all the retrospective feedback that has been submitted.