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

Slack Tutorial Part 4 - Building a first Slack command


Building a first slack command 🎉

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.
  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.
from django.urls import pathfrom . import commandsurlpatterns = [    path('commands/teamwork-latest/', commands.teamwork_latest),]
  1. Last, but not least, create a file for commands and a command itself.
from django.http import JsonResponsefrom django.views.decorators.csrf import csrf_exempt@csrf_exemptdef teamwork_latest(request):    return JsonResponse({        "blocks": [            {                "type": "section",                "text": {                    "type": "plain_text",                    "text": "Hello World :tada:.",                    "emoji": True                }            }        ]    })

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%208.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%204.0h%5Cn%20Description%3A%20Writing%20React%20components%20and%20making%20Storybook%20cleanup%22%7D%7D%5D
  1. 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.
from typing import Listdef get_teamwork_latest_blocks(request) -> List:  []
from django.test import RequestFactory, TestCasefrom core.models import Userfrom projects.models import Project, ProjectReportfrom .slack_commands import get_teamwork_latest_blocksclass SlackCommandsTests(TestCase):    maxDiff = None    def setUp(self):        self.request_factory = RequestFactory()        self.request_factory.defaults['SERVER_NAME'] = 'scrumie.com'        user = User.objects.create(username='Test User')        project_1 = Project.objects.create(name='Example Project')        project_2 = Project.objects.create(name='React Project')        ProjectReport.objects.create(            project=project_1,            description="I've written unit-tests, because it makes the whole development process faster :)",            hours=8.0,            user=user,        )        ProjectReport.objects.create(            project=project_2,            description="Writing React components and making Storybook cleanup",            hours=4.0,            user=user,        )    def test_simple_teamwork_latest_command(self):        expected_blocks = [            {                "type": "section",                "text": {                    "type": "mrkdwn",                    "text": "*Project*: Example Project\n *Reported Hours*: _8.0h_\n *Description*: I've written unit-tests, because it makes the whole development process faster :)"                }            },            {                "type": "divider"            },            {                "type": "section",                "text": {                    "type": "mrkdwn",                    "text": "*Project*: React Project\n *Reported Hours*: _4.0h_\n *Description*: Writing React components and making Storybook cleanup"                }            }        ]        request = self.request_factory        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 ...
from itertools import chainfrom typing import Listfrom projects.models import ProjectReportdef get_teamwork_latest_blocks(request, max_items=5) -> List:  reports = ProjectReport.objects.all().order_by('id')[:max_items]  if reports:    return list(chain.from_iterable(      (        {          "type": "section",          "text": {            "type": "mrkdwn",            "text": f"*Project*: {report.project.name}\n *Reported Hours*: _{report.hours}h_\n *Description*: " +                    report.description          }        },        {          "type": "divider",        }      ) for report in reports    ))[:-1]  else:    return [      {        "type": "section",        "text":{          "type": "mrkdwn",          "text": "No Project Reports"        }      }    ]
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.
from django.http import JsonResponsefrom django.views.decorators.csrf import csrf_exemptfrom slack_app.blocks.slack_commands import get_teamwork_latest_blocks@csrf_exemptdef teamwork_latest(request):    return JsonResponse({        "blocks": get_teamwork_latest_blocks(request)    })
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.
@csrf_exemptdef teamwork_latest(request):    return JsonResponse({        "blocks": get_teamwork_latest_blocks(request),        "text": "See recent work reports :page_with_curl:"    })
🎉 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