08 November 2020
by
Jan Vorčák's profile photo, Webscope.io
Ján Vorčák

Slack Tutorial Part 4 - Building a first Slack command

Slack

Building a first slack command 🎉

Banner

Do you need a reliable partner in tech for your next project?

Setup a Slack application

  1. Go to https://api.slack.com/apps and create your app.
  2. Go to your app on api.slack.com and create SLACK_CLIENT_ID, SLACK_CLIENT_SECRET and SLACK_SIGNING_SECRET with a correspondent values in settings.py. We will use this later on e.g. when using Incoming Web Hooks. Secret should be provided as ENV variable and shouldn't be part of the codebase. We will pass it to our process together with a DATABASE_URL.
1SLACK_CLIENT_ID = '5130182099.926289676294'
2SLACK_CLIENT_SECRET = os.getenv('SLACK_CLIENT_SECRET')
3SLACK_SIGNING_SECRET = os.getenv('SLACK_SIGNING_SECRET')
  1. Install Python Slack bindings. You'll need them later on.
pipenv install slackclient
  1. Click on Slash Commands > Create New Command
  2. We will create command /teamwork-latest that will show last 5 project reports in our app.
    You need to fill request URL at this point. However during development, you usually can't easily expose your localhost to the world so that Slack can access it. Here's where ngrok comes handy. It will create a public URL and map it to your localhost. Please install it and setup an account. By running ngrok http 8000 it will give you a public URL that will route to your local Django instance.
    In my case I'll fill out http://slack-tutorial-app.ngrok.io/slack/commands/teamwork-latest/
After you've created your command, install your Slack app into your Slack workspace. Once you run /teamwork-latest you should see the following error.
/teamwork-latest failed with the error "dispatch_failed"
That is because we haven't implemented the endpoint yet! So let's do it.
  1. Let's create a Slack application
pipenv run ./manage.py startapp slack_app and add slack_app to INSTALLED_APPS
  1. And route all URLs prefixed with /slack to our slack app by adding path('slack/', include('slack_app.urls')) to urlpatterns in teamwork/urls.py
  2. Configure slack routing. Create urls.py in slack_app folder.
1from django.urls import path
2
3from . import commands
4
5urlpatterns = [
6    path('commands/teamwork-latest/', commands.teamwork_latest),
7]
  1. Last, but not least, create a file for commands and a command itself.
slack/commands.py
1from django.http import JsonResponse
2from django.views.decorators.csrf import csrf_exempt
3
4
5@csrf_exempt
6def teamwork_latest(request):
7    return JsonResponse({
8        "blocks": [
9            {
10                "type": "section",
11                "text": {
12                    "type": "plain_text",
13                    "text": "Hello World :tada:.",
14                    "emoji": True
15                }
16            }
17        ]
18    })

Generating response

We've returned simple message, but you can build much more. Have a look at Slack's Block Kit Builder at what UI you can render as a response. The only disadvantage of it is that it's not Open Source :disappointed_relieved:.
However, in order to return something valuable, we need to generate the output based on the content in our db. So let's do it 🎉! ... by writing tests first 💪
  1. Let's create our expected output in Block Kit Builder e.g. https://api.slack.com/tools/block-kit-builder?mode=message&blocks=%5B%7B%22type%22%3A%22section%22%2C%22text%22%3A%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22Project%3A%20Example%20Project%5Cn%20Reported%20Hours%3A%20_8.0h_%5Cn%20Description%3A%20I%27ve%20written%20unit-tests%2C%20because%20it%20makes%20the%20whole%20development%20process%20faster%20%3A)%22%7D%7D%2C%7B%22type%22%3A%22divider%22%7D%2C%7B%22type%22%3A%22section%22%2C%22text%22%3A%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22Project%3A%20Important%20Project%5Cn%20Reported%20Hours%3A%20_4.0h_%5Cn%20Description%3A%20Writing%20React%20components%20and%20making%20Storybook%20cleanup%22%7D%7D%5D
  2. In slack_app application, create a blocks folder where we will store all methods responsible for generating Slack blocks. Inside blocks, let's create slack_commands.py and slack_commands_spec.py.
Our function will accept a Django request object (so that we can later use it to build absolute urls) and expect a list of blocks. Let's create an interface so that we can create unit-tests.
slack_commands.py
1from typing import List
2
3def get_teamwork_latest_blocks(request) -> List:
4  []
tests_slack_commands.py
1from django.test import RequestFactory, TestCase
2
3from core.models import User
4from projects.models import Project, ProjectReport
5from .slack_commands import get_teamwork_latest_blocks
6
7
8class SlackCommandsTests(TestCase):
9    maxDiff = None
10
11    def setUp(self):
12        self.request_factory = RequestFactory()
13        self.request_factory.defaults['SERVER_NAME'] = 'scrumie.com'
14
15        user = User.objects.create(username='Test User')
16
17        project_1 = Project.objects.create(name='Example Project')
18        project_2 = Project.objects.create(name='React Project')
19
20        ProjectReport.objects.create(
21            project=project_1,
22            description="I've written unit-tests, because it makes the whole development process faster :)",
23            hours=8.0,
24            user=user,
25        )
26
27        ProjectReport.objects.create(
28            project=project_2,
29            description="Writing React components and making Storybook cleanup",
30            hours=4.0,
31            user=user,
32        )
33
34    def test_simple_teamwork_latest_command(self):
35        expected_blocks = [
36            {
37                "type": "section",
38                "text": {
39                    "type": "mrkdwn",
40                    "text": "*Project*: Example Project\n *Reported Hours*: _8.0h_\n *Description*: I've written unit-tests, because it makes the whole development process faster :)"
41                }
42            },
43            {
44                "type": "divider"
45            },
46            {
47                "type": "section",
48                "text": {
49                    "type": "mrkdwn",
50                    "text": "*Project*: React Project\n *Reported Hours*: _4.0h_\n *Description*: Writing React components and making Storybook cleanup"
51                }
52            }
53        ]
54
55        request = self.request_factory
56
57        self.assertEqual(expected_blocks, list(get_teamwork_latest_blocks(request)))
It's good practise to write a test that fails first and later fix the methods so that the test passes.
You can run tests with pipenv run ./manage.py test (this time without passing DATABASE_URL), we don't need to connect to our db for a test run.
Tip: You can use find . -name '*.py' | entr pipenv run ./manage.py test to react to file changes.
The test should fail until you implement the method. Take the challenge and do it yourself or ...
1from itertools import chain
2from typing import List
3
4from projects.models import ProjectReport
5
6
7def get_teamwork_latest_blocks(request, max_items=5) -> List:
8  reports = ProjectReport.objects.all().order_by('id')[:max_items]
9  if reports:
10    return list(chain.from_iterable(
11      (
12        {
13          "type": "section",
14          "text": {
15            "type": "mrkdwn",
16            "text": f"*Project*: {report.project.name}\n *Reported Hours*: _{report.hours}h_\n *Description*: " +
17                    report.description
18          }
19        },
20        {
21          "type": "divider",
22        }
23      ) for report in reports
24    ))[:-1]
25
26  else:
27    return [
28      {
29        "type": "section",
30        "text":{
31          "type": "mrkdwn",
32          "text": "No Project Reports"
33        }
34      }
35    ]
Now, please write more tests to try to cover all corner-cases.
  1. What if user has no reports?
  2. What if there is huge number of it? (Slack limits the number of blocks to 50)
  3. What if ...

Let's connect our block function to the actual view

This part is fairly easy, we just replace our mocked blocks with a function we just created.
1from django.http import JsonResponse
2from django.views.decorators.csrf import csrf_exempt
3
4from slack_app.blocks.slack_commands import get_teamwork_latest_blocks
5
6
7@csrf_exempt
8def teamwork_latest(request):
9    return JsonResponse({
10        "blocks": get_teamwork_latest_blocks(request)
11    })
If you now go to /admin and try to create some Project and Project Reports, write /teamwork-latest in Slack and you should see the following.

Text preview of a message

One last problem with our command is how it's going to be presented in a notifications. Type /teamwork-latest and quickly switch your window. On Mac you'll see the following. (I'm sorry, but I can't currently test this on other platforms).
To fix this, just add text property to our response.
1@csrf_exempt
2def teamwork_latest(request):
3    return JsonResponse({
4        "blocks": get_teamwork_latest_blocks(request),
5        "text": "See recent work reports :page_with_curl:"
6    })
🎉 Now you're ready to go ahead and implement your own business logic with Slack Commands.
Share post

Let’s stay connected

Do you want the latest and greatest from our blog straight to your inbox? Chuck us your email address and get informed.
You can unsubscribe any time. For more details, review our privacy policy