Building a first slack command 🎉
Setup a Slack application
-
Go to https://api.slack.com/apps and create your app.
-
Go to your app on api.slack.com and create
SLACK_CLIENT_ID,SLACK_CLIENT_SECRETandSLACK_SIGNING_SECRETwith a correspondent values insettings.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 aDATABASE_URL.
SLACK_CLIENT_ID = '5130182099.926289676294'
SLACK_CLIENT_SECRET = os.getenv('SLACK_CLIENT_SECRET')
SLACK_SIGNING_SECRET = os.getenv('SLACK_SIGNING_SECRET')
- Install Python Slack bindings. You'll need them later on.
pipenv install slackclient
-
Click on Slash Commands > Create New Command
-
We will create command
/teamwork-latestthat 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 8000it 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.
- Let's create a Slack application
pipenv run ./manage.py startapp slack_app and add slack_app to
INSTALLED_APPS
-
And route all URLs prefixed with
/slackto our slack app by addingpath('slack/', include('slack_app.urls'))tourlpatternsinteamwork/urls.py -
Configure slack routing. Create
urls.pyinslack_appfolder.
1 2 3 4 5 6 7from django.urls import path from . import commands urlpatterns = [ path('commands/teamwork-latest/', commands.teamwork_latest), ]
- Last, but not least, create a file for commands and a command itself.
slack/commands.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt @csrf_exempt def 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 💪
-
In
slack_appapplication, create ablocksfolder where we will store all methods responsible for generating Slack blocks. Insideblocks, let's createslack_commands.pyandslack_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
1 2 3 4from typing import List def get_teamwork_latest_blocks(request) -> List: []
tests_slack_commands.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 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 57from django.test import RequestFactory, TestCase from core.models import User from projects.models import Project, ProjectReport from .slack_commands import get_teamwork_latest_blocks class 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 testto react to file changes.
The test should fail until you implement the method. Take the challenge and do it yourself or ...
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 35from itertools import chain from typing import List from projects.models import ProjectReport def 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.
- What if user has no reports?
- What if there is huge number of it? (Slack limits the number of blocks to 50)
- 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.
1 2 3 4 5 6 7 8 9 10 11from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from slack_app.blocks.slack_commands import get_teamwork_latest_blocks @csrf_exempt def 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.
1 2 3 4 5 6@csrf_exempt def 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.



