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.

serverless.yml
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)

serverless.yml
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

  1. Requests from slack are sent to API Gateway, which triggers our function and passes event similar to what you can see inside example-data/apigw-block_action.json
  2. Then serverless-wsgi and Flask transforms this event into Flask Request
  3. We parse the request body and determine the request type: block_actions or dialog_submission

Let’s break down the standup_bot/action_app.py file.

action_app.py
 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 user

    • dialog_questions - is a Slack dialog containing QUESTIONS We simply iteratively build elements and so our result dialog looks similar to this

      example-data/sample_dialog.json
       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
      {
          "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.

    models.py
     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.

action_app.py
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:

  1. 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

  2. Determine Slack action type block_actions or dialog_submission

  3. Process the action

  4. Respond back to Slack (user)

action_app.py
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.

menu image

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 button
  • standup.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)
example-data/block_action.json
 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:

  1. Take the 1st action value from actions array
  2. Create a state_data to match the dialog with a user.
  3. Determine the action type
  4. Fill dialog object with questions and other data
  5. Send a request back to Slack to open a dialog
  6. 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 IDs question1, …

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.

example-data/block_action.json
 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.

menu image

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:

menu response image

For this purpose we are going to implement a function process_dialogs as follows:

  1. Examine callback_id to identify dialog type

  2. Parse the state_data from string to dict

  3. Get detailed information about the user from Slack

  4. Create a report object represented by our database model Report

    • 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 into YYYYMMDD string with suffix of a slack user ID UFE4ZTC8J (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.

  5. Save data in the database

  6. Inform user via updating a chat message.

  7. Send empty response 200

action_app.py
 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