Introduction
At ventx, we like to keep ourselves busy with bite-sized exercises in between projects - this one is about building a serverless Slack app with
Our app is a basic polling tool: users can initiate polls with either a slash command like /poll "What do you want to eat today?" "Pizza" "Tacos" "Burger"
or with an app_mention (These come with the added benefit of playing nice with Slack’s built in reminder feature.) like @PollApp "Skip today's daily?" "Yes" "No"
. Users may then post their votes while the poll’s results are visible to channel members.
Prerequisites
Before we get started, make sure that you have:
- a Slack workspace set up
- access to an AWS account
- already provisioned a hosted zone with Route53 in the above account
- installed and configured
- checked out the code that is available on Github
Slack App
Our app uses Slack Bolt a Python framework to build Slack apps in a flash with the latest platform features so that we do not have to bother with getting into the intricate details of how authentication with Slack or listening and responding to events really works.
The first thing we need to do is retrieve our app’s Signing Secret and Bot User OAuth Token from a Secrets Manager secret. (We’ll get to where you can find these later.) Next is to go about setting up the App - making sure to set process_before_response
as per the documentation’s advice for FaaS (Function-as-a-Service) environments such as AWS Lambda - and register our
- slash command (for when our app is being invoked via a slash command),
- event listener (in case the app is being invoked via
app_mention
) and - action listener (that is being called when a user clicks on a vote button).
Note that while we immediately acknowledge requests with immediate_ack()
, we do not run business logic right away but process lazily.
# ...
def immediate_ack(ack):
# immediately acknowledge request
ack()
def init_poll(body, respond):
# do something on slash command or app_mention
def on_vote(body, respond, action):
# do something on vote action
# instantiate app
app = App(
token=slack_secrets['SLACK_BOT_TOKEN'],
signing_secret=slack_secrets['SLACK_SIGNING_SECRET'],
process_before_response=True
)
# register listeners
app.command(command)(ack=immediate_ack, lazy=[init_poll])
app.event(app_mention)(ack=immediate_ack, lazy=[init_poll])
app.action(action_vote)(ack=immediate_ack, lazy=[on_vote])
# lambda handler
def handler(event, context):
slack_handler = SlackRequestHandler(app=app)
return slack_handler.handle(event, context)
When a user initiates a poll, we
- generate a unique
poll_id
, - initalize a corresponding
poll_data
object, that contains title, options and votes, - store both in our DynamoDB poll table and
- return an interactive message to the given channel using Slack’s UI framework Block Kit.
We use the vote button’s value attribute to hold both the poll’s unique id and the respective option’s index.
# ...
def init_poll(body, respond, say, client):
# parse the given command: "<TITLE>" "<OPTION_A>" "<OPTION_B>" ...
command = re.findall('"([^"]*)"', body['text']
if 'command' in body else body['event']['text'])
if not len(command) > 1:
# respond with usage hint
usage(body, respond, client)
else:
# create and persist poll-data
item = {
'id': str(uuid.uuid4()),
'version': 0,
'data': {
'title': command[0],
'options': list(map(lambda x: {'title': x, 'votes': []}, command[1:]))
}
}
poll_table.put_item(Item=item)
# respond with interactive poll message
if 'response_url' in body:
# slash-command response via respond/response_url
respond(
response_type='in_channel',
blocks=get_poll_blocks(
poll_id=item['id'],
poll_data=item['data'])
)
else:
# app_mention response via say/chat.postMessage
say(
response_type='in_channel',
blocks=get_poll_blocks(
poll_id=item['id'],
poll_data=item['data'])
)
def get_poll_blocks(poll_id: str, poll_data: dict):
# message title
blocks = [
{
'type': 'section',
'text': {
'type': 'mrkdwn',
'text': f"*{poll_data['title']}*"
}
}
]
for option_id, option in enumerate(poll_data['options']):
blocks.append({
# option title and votes
'type': 'section',
'text': {
'type': 'mrkdwn',
'text': f"{option_id + 1}. {option['title']}\n {', '.join(list(map(lambda x: '<@' + x + '>', option['votes'])))}"
},
# option vote button with number of votes
'accessory': {
'type': 'button',
'text': {
'type': 'plain_text',
'text': f"Vote ({len(option['votes'])})"
},
'value': f"{option_id}_{poll_id}", # option id and poll id
'action_id': action_vote
}
})
return blocks
# ...
Finally when a user votes, we
- extract the previously mentioned
poll_id
andoption_id
, - fetch the respective item from our DynamoDB poll table,
- update the
poll_data
object and update the above item to keep track the user’s vote and - redraw the Slack message using blocks once again.
You may have picked up on a try-catch-block inside a for-loop: this is our attempt at preventing concurrency mess-ups using the Optimistic locking with version number strategy.
With optimistic locking, each item has an attribute that acts as a version number. If you retrieve an item from a table, the application records the version number of that item. You can update the item, but only if the version number on the server side has not changed. If there is a version mismatch, it means that someone else has modified the item before you did. The update attempt fails, because you have a stale version of the item. If this happens, you simply try again by retrieving the item and then trying to update it. Optimistic locking prevents you from accidentally overwriting changes that were made by others. It also prevents others from accidentally overwriting your changes.
Down below you can see this strategy in action:
For a maximum number of 3 attempts, we’ll fetch the item from the table and perform the update if and only if the version we’ve obtained is still equal to the version of the item when the update is executed. If the put fails with ConditionalCheckFailedException
, we retry - if it does not, we’re good.
# ...
def on_vote(body, respond, action):
# extract option id and poll id from the action button's value attribute
option_id = int(action['value'].split('_')[0])
poll_id = action['value'].split('_')[1]
for i in range(3):
try:
# fetch poll-data for the given poll id
response = poll_table.get_item(
TableName=poll_table_name,
Key={
'id': poll_id
}
)
if 'Item' in response:
current_version = response['Item']['version']
# update poll-data to reflect this vote
poll_data = vote(
poll_data=response['Item']['data'],
user_id=body['user']['id'],
option_id=option_id)
item = {
'id': poll_id,
'data': poll_data,
'version': current_version + 1
}
# persist updated poll-data, if we have the latest item version
poll_table.put_item(
Item=item,
ConditionExpression='version = :CURRENT_VERSION',
ExpressionAttributeValues={
':CURRENT_VERSION': current_version}
)
# update interactive poll message
respond(
blocks=get_poll_blocks(
poll_id=poll_id,
poll_data=poll_data)
)
break
except ClientError as error:
if error.response['Error']['Code'] != 'ConditionalCheckFailedException':
raise error
# else:
# retry in case the item we've retrieved is stale by now
def vote(poll_data: dict, user_id: str, option_id: int):
if user_id in poll_data['options'][option_id]['votes']:
# unvote
poll_data['options'][option_id]['votes'] = list(
filter(lambda x: x != user_id, poll_data['options'][option_id]['votes']))
else:
# vote
poll_data['options'][option_id]['votes'].append(user_id)
return poll_data
# ...
Infrastructure
For our serverless API we need
- an API Gateway to front the previously discussed Lambda function together with a Route53 record and an ACM certificate,
- the Lambda function itself together with a Slack Bolt library layer we build with Docker,
- a DynamoDB table where we maintain
poll_data
withpoll_id
as its primary/hash key, - a Secrets Manager secret that holds our Slack app’s credentials and
- a couple of IAM roles.
Since we use Terraform to provision our infrastructure, creating all of the above is matter of
- updating
terraform.tfvars
’saws_region
with our desired AWS target regionaws_profile
with the AWS profile we’re usingzone_name
with the name of an already existing hosted zone, where the app’s record and certificate validation records will be created
- and running
terraform init
andterraform apply
.
You may have to wait for the DNS record to propagate for a little while, use nslookup by running the output of the terraform output -raw check_url_command
command.
Installing the Slack App to your Workspace
With the necessary infrastructure in place we can install the app to a Slack workspace using an app manifest file:
- go to https://api.slack.com/apps
- click on Create New App
- click on From an app manifest
- select your desired workspace and click on Next
- run
terraform output -raw app_manifest
in the project’s root folder and copy the output - select YAML, paste above’s app manifest and click on Next
- click on Create
- navigate to Settings > Basic Information, click on Install to Workspace in the Install your app section and confirm by clicking on Allow in the following dialog
Next we put our app’s Signing Secret and Bot User OAuth Token into the secret we previously mentioned so that the Lambda function can authenticate with Slack:
- go to https://api.slack.com/apps
- click on Your Apps and select our app
- navigate to Settings > Basic Information and copy the Signing Secret’s value
- navigate to Features > OAuth & Permissions and copy the Bot User OAuth Token’s value
- run
terraform output -raw set_slack_secret_command
, copy the output command, replace<SLACK_BOT_TOKEN>
and<SLACK_SIGNING_SECRET>
with above’s Signing Secret and Bot User OAuth Token so that the result looks like this
aws secretsmanager put-secret-value \
--secret-id pollapp_slack_secrets \
--secret-string "{\"SLACK_BOT_TOKEN\":\"<SLACK_BOT_TOKEN>\",\"SLACK_SIGNING_SECRET\":\"<SLACK_SIGNING_SECRET>\"}\" \
--region us-east-1 \
--profile default
- run the above command
- navigate to Features > App Manifest where you may find an info box stating that the URL isn’t verified and click Click here to verify
Finally test that everything is working by initiating a poll with something like /poll "My polling app works" "Yes" "No"
in your Slack workspace.
Cleanup
To delete your Slack app
- go to https://api.slack.com/apps
- click on Your Apps and select our app
- navigate to Settings > Basic Information and click on Delete App
To delete all AWS resources
- run
terraform destroy