Flask app - processing Slack requests¶
Now that we can send the menu to the user, we are going to learn how to store the data. Slack communicates with our app via interactive actions (actions, dialogs, message buttons, or message menus) via HTTP POST to URL we set in slack application settings.
But first let’s write some code.
Flask app¶
We are going to write our code inside standup_bot/action_app.py
It will be a simple application POST processing requests via API Gateway triggering slack-responses
Lambda function.
21 22 23 24 25 26 27 28 | functions:
# Lambda function with Flask application to handle Slack communication
slack-responses:
handler: wsgi_handler.handler
events:
- http:
path: actions
method: post
|
To serve the flask app from within AWS Lambda we are going to use serverless-wsgi
plugin.
(Plugin is able to install itself, but you can do it as well: sls plugin install -n serverless-wsgi
)
55 56 57 58 | plugins:
- serverless-python-requirements
- serverless-pseudo-parameters
- serverless-wsgi
|
Our application will deal with multiple structures which you can look at inside the folder example-data
- Requests from slack are sent to API Gateway, which triggers our function and
passes
event
similar to what you can see insideexample-data/apigw-block_action.json
- Then
serverless-wsgi
and Flask transforms this event into Flask Request - We parse the request body and determine the request type:
block_actions
ordialog_submission
Let’s break down the standup_bot/action_app.py
file.
1 2 3 4 5 6 7 8 9 10 11 12 | """
Main flask app file used to receive incoming http requests from Slack.
"""
import logging
from urllib import parse
from flask import Flask, request, json, make_response
from slack import WebClient
from standup_bot.config import QUESTIONS, SLACK_TOKEN
from standup_bot.models import Report
from standup_bot.msg_templates import dialog_questions
|
We are going to need
urllib.parse
to help us with url decoding.Then we import Flask items we are going to need
And Slack web client from python-slackclient
Next set of imports comes from our cookiecutter template
QUESTIONS
- is a dict of questions we are going to ask.{ "question1" : "Question text?" }
SLACK_TOKEN
- so we can respond back to the userdialog_questions
- is a Slack dialog containingQUESTIONS
We simply iteratively buildelements
and so our result dialog looks similar to this1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
{ "callback_id": "standup.action.answers", "elements": [ { "hint": "What did you work on yesterday?", "label": "What did you work on yesterday?", "name": "question0", "optional": true, "placeholder": "What did you work on yesterday?", "type": "textarea" }, { "hint": "What is your plan for today?", "label": "What is your plan for today?", "name": "question1", "optional": true, "placeholder": "What is your plan for today?", "type": "textarea" }, { "hint": "Any impediments?", "label": "Any impediments?", "name": "question2", "optional": true, "placeholder": "Any impediments?", "type": "textarea" } ], "state": "{\"container\": {\"channel_id\": \"DFK2PDRPT\", \"is_ephemeral\": false, \"message_ts\": \"1558433489.000600\", \"type\": \"message\"}, \"report_id\": \"19700101\"}", "title": "Daily standup questions." }
Report
- is our DynamoDB database model created with pynamodb package.It is possible to access DynamoDB directly via boto3 however pynamodb friendlier API.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
"""Dynamo db models.""" from pynamodb.attributes import MapAttribute, UnicodeAttribute from pynamodb.models import Model from standup_bot.config import TABLE_NAME, AWS_REGION class Report(Model): """ Standup report model. """ class Meta: table_name = TABLE_NAME region = AWS_REGION report_id = UnicodeAttribute(hash_key=True) report_user_id = UnicodeAttribute(range_key=True) user_id = UnicodeAttribute() user_name = UnicodeAttribute() display_name = UnicodeAttribute() icon_url = UnicodeAttribute() answers = MapAttribute()
In our next step, we initialize our SlackClient (SC
) and Flask app.
When a Slack user use an interactive (click button, submit dialog)
we configure our Slack application to send a HTTP POST request
to our AWS API Gateway URL <APIGWID>.execute-api.<AWS-region>.amazonaws.com/actions
.
And define a route endpoint /actions
.
14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | # synchronous slack client
SC = WebClient(SLACK_TOKEN)
app = Flask(__name__)
app.config["SECRET_KEY"] = "you-will-never-guess"
app.logger.setLevel(logging.INFO)
@app.route("/actions", methods=["POST"])
def actions():
"""
Endpoint /actions to process actions and dialogs.
This method receives a request from API gateway.
Example request: example-data/apigw-block_action.json
We need to decode body and decide if requests is one of:
- block_actions -> will trigger process_block_actions()
- dialog_submission -> will trigger process_dialogs()
Returns
-------
flask.Response
"""
|
Inside function actions
we are going to perform following operations:
Parse and unquote request body received from AWS API Gateway.
Received request is a URL encoded string which contains a prefix
payload
.See line 119 inside a file
example-data/apigw-block_action.json
."body":"payload=%7B%22type%22%3A%22block_actions%22%2C%22team%...
To find out how does the parsed action body looks like check file:
sls_app/example-data/block_action.json
Determine Slack action type
block_actions
ordialog_submission
Process the action
Respond back to Slack (user)
22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | @app.route("/actions", methods=["POST"])
def actions():
"""
Endpoint /actions to process actions and dialogs.
This method receives a request from API gateway.
Example request: example-data/apigw-block_action.json
We need to decode body and decide if requests is one of:
- block_actions -> will trigger process_block_actions()
- dialog_submission -> will trigger process_dialogs()
Returns
-------
flask.Response
"""
prefix = "payload="
data = request.get_data(as_text=True)[len(prefix):]
app.logger.info("req_data %s", data)
# app.logger.info("dd %s", data[len(prefix):])
# action body
slack_req_body = json.loads(parse.unquote_plus(data))
app.logger.info("Action body: %s", slack_req_body)
slack_req_type = slack_req_body.get("type")
action = {"block_actions": process_block_actions, "dialog_submission": process_dialogs}
response = action[slack_req_type](slack_req_body)
app.logger.info("Response to Action: %s : %s", response, response.get_data())
return response
|
Now we need to write appropriate functions to process our action types.
Our first function will be process_block_actions
which triggered when user clicks on a button from the menu.
Button triggers a Slack block action.
We will need to create simple logic inside our function process_block_actions
,
to process each action type correctly.
So far we will deal with 2 types:
standup.action.open_dialog
- When clicked on Open Dialog buttonstandup.action.skip_today
- The idea is to signal that user decided to skip today’s meeting. Implementation of this is left for you as a challenge.
Following example is a JSON (dict
) data structure we get
after successful parsing of slack_request
in previous method actions()
.
Message itself contains a lot of data, however we will focus on highlighted parts.
- L15
container
- contains data we are going to need to uniquely identify the Slack dialog we create later - L21
trigger_id
- is required to trigger the correct dialog - L26
message
- contains original message with the menu - L72
actions
- contains result value of user’s action (click Open Dialog button)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | {
"type": "block_actions",
"team": {
"id": "TFE4ZTB3L",
"domain": "jendaworkspace"
},
"user": {
"id": "UFE4ZTC8J",
"username": "1oglop1",
"name": "1oglop1",
"team_id": "TFE4ZTB3L"
},
"api_app_id": "AFM36S3CN",
"token": "uJLNkNPcUwaEzPceiCEFb9wC",
"container": {
"type": "message",
"message_ts": "1555663294.000200",
"channel_id": "DFK2PDRPT",
"is_ephemeral": false
},
"trigger_id": "601888012770.524169929122.23393cd981d2028028794396a1e104bf",
"channel": {
"id": "DFK2PDRPT",
"name": "directmessage"
},
"message": {
"type": "message",
"subtype": "bot_message",
"text": "Daily menu",
"ts": "1555663294.000200",
"username": "jan-standup-bot",
"bot_id": "BFLN2CMST",
"blocks": [
{
"type": "section",
"block_id": "dgz+G",
"text": {
"type": "mrkdwn",
"text": "Hello, it is time report on daily standup.",
"verbatim": false
}
},
{
"type": "actions",
"block_id": "act",
"elements": [
{
"type": "button",
"action_id": "standup.action.open_dialog",
"text": {
"type": "plain_text",
"text": "Open Report",
"emoji": true
},
"style": "primary",
"value": "19700101"
},
{
"type": "button",
"action_id": "standup.action.skip_today",
"text": {
"type": "plain_text",
"text": "Skip today",
"emoji": true
}
}
]
}
]
},
"response_url": "https://hooks.slack.com/actions/TFE4ZTB3L/606986906385/Fc2QQKdICRgBSKiWXVBAS5Qp",
"actions": [
{
"action_id": "standup.action.open_dialog",
"block_id": "act",
"text": {
"type": "plain_text",
"text": "Open Report",
"emoji": true
},
"value": "19700101",
"type": "button",
"style": "primary",
"action_ts": "1555663307.531487"
}
]
}
|
With the information above we can proceed with implementation of process_block_actions
.
The logic is following:
- Take the 1st action value from
actions
array - Create a
state_data
to match the dialog with a user. - Determine the action type
- Fill dialog object with
questions
and other data - Send a request back to Slack to open a dialog
- And respond 200 and empty body if successful
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | def process_block_actions(slack_request: dict):
"""
Slack Action processor.
Here we are going to process decoded slack request "block actions"
https://api.slack.com/reference/messaging/blocks#actions
Example request: example-data/block_action.json
We will present user with 2 buttons.
1. Open dialog - which contains standup questions
2. Skip today - to let user pass the meeting
Returns
-------
flask.Response
Empty response 200 signifies success.
"""
action = slack_request["actions"][0]
state_data = {"container": slack_request["container"], "report_id": action["value"]}
if action["action_id"] == "standup.action.open_dialog":
questions = dialog_questions(json.dumps(state_data), QUESTIONS)
app.logger.info(questions)
slack_response = SC.dialog_open(
dialog=questions, trigger_id=slack_request["trigger_id"]
)
app.logger.info("Dialog Open: %s", slack_response)
return make_response()
if action["action_id"] == "standup.action.skip_today":
# you can try to implement this yourself
pass
return make_response("Unable to process action", 400)
|
If impatient and would like to try out your partially implemented app, go ahead to second slack setup and deployment then come back!
Okay, we have successfully showed dialog to the user, now it’s time to collect the data.
When user submits the dialog Slack sends a HTTP POST request to our endpoint /actions.
But this time the slack_req_type
type is dialog_submission
.
Dialog submission message contains data about the user and values for the answers.
Note that questions are only represented with IDsquestion1
, …
The important part is a callback_id
field which helps us to identify dialog type.
We then use the data previously stored in state
to identify the user.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | {
"type": "dialog_submission",
"token": "uJLNkNPcUwaEzPceiCEFb9wC",
"action_ts": "1558430192.474181",
"team": {
"id": "TFE4ZTB3L",
"domain": "jendaworkspace"
},
"user": {
"id": "UFE4ZTC8J",
"name": "1oglop1"
},
"channel": {
"id": "DFK2PDRPT",
"name": "directmessage"
},
"submission": {
"question0": "2",
"question1": "2",
"question2": "2"
},
"callback_id": "standup.action.answers",
"response_url": "https://hooks.slack.com/app/TFE4ZTB3L/641451583301/S10UQ9nERHjT2EbfxZNFgS7F",
"state": "{\"container\": {\"channel_id\": \"DFK2PDRPT\", \"is_ephemeral\": false, \"message_ts\": \"1558430180.000400\", \"type\": \"message\"}, \"report_id\": \"19700101\"}"
}
|
Slack does not have implemented automated updates(answers) to actions. This means when user submits the dialog, the menu stays unchanged.
But we would like to inform the user about successful submission therefore we will do the trick and update the previous message using the timestamp to following:
For this purpose we are going to implement a function process_dialogs
as follows:
Examine
callback_id
to identify dialog typeParse the
state_data
from string todict
Get detailed information about the user from Slack
Create a
report
object represented by our database modelReport
To uniquely identify the report for simple a simple query, our main identifier will be
report_id
. It’s a simple execution timestamp, which comes from a scheduled event, converted intoYYYYMMDD
string with suffix of a slack user IDUFE4ZTC8J
(assuming our meeting is once a day).Example
time: 2016-12-30T18:44:49Z user_id: UFE4ZTC8J
becomes:
20161230_UFE4ZTC8J
This way we can later query the database for a given day to get all reports.
Save data in the database
Inform user via updating a chat message.
Send empty response 200
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | def process_dialogs(slack_dialog: dict):
"""
Process Slack dialogs.
Here we are going to collect data from dialog
example dialog submission: example-data/dialog_submission.json
Returns
-------
flask.Response
Successful dialog submission requires empty response 200.
"""
if slack_dialog["callback_id"] == "standup.action.answers":
# add one field to answers
state_data = json.loads(slack_dialog["state"])
# prepare DB record
# get more user data
user_info = SC.users_info(
user=slack_dialog["user"]["id"]
)
app.logger.info("UserInfo: %s", user_info)
display_name = (
user_info["user"]["profile"]["display_name"]
or user_info["user"]["profile"]["real_name"]
or slack_dialog["user"]["name"]
)
user_report = Report(
state_data["report_id"],
f'{state_data["report_id"]}_{slack_dialog["user"]["id"]}',
user_name=slack_dialog["user"]["name"],
user_id=slack_dialog["user"]["id"],
answers=slack_dialog["submission"],
display_name=display_name,
icon_url=user_info["user"]["profile"]["image_48"],
)
# Write to database.
user_report.save()
# Respond to user
SC.chat_update(
channel=state_data["container"]["channel_id"],
ts=state_data["container"]["message_ts"],
text="Thank you for your submission.",
blocks=[],
as_user=True, # reason specified in slack docs
)
app.logger.info("Adding new answer: %s", user_report._get_json())
return make_response()
return make_response("Unable to process dialog", 400)
|
This concludes 2/3 of our application, you can now try to deploy and play around. To deploy check out the second slack setup and deployment page!
If you are not deploying or you have finished the investigation, please proceed to the last part sending the report from all users