Create a serverless Slack chatbot with API Gateway, Lambda, and DynamoDB

Slack, a messaging tool with similarities to IRC, basically killed email in our office (for which I am forever grateful). Not only does Slack organize group conversations, but it provides extra functionality via slash commands. You can use commands like /remind me to call the client in 20 minutes  to set a reminder in 20 minutes, or /shrug works on my machine  when you want to punt a bug. This kind of natural language parsing for commands is an intriguing interface, seamlessly integrating bots into your team.

Our team regularly buys lunch in group orders. One person picks it up and usually pays for it. To keep track of who owed money, we used a virtual chip system.  It initially began with a spreadsheet posted on Slack. When we owed someone, we manually updated the table. This technique worked well but was error-prone. It also didn’t scale since only one person could access the sheet at a time.

The “iowe” chatbot enhances the virtual chip system by providing a Slack slash command. Now, we can use commands like these:

  • /iowe @rob 8
  • /iowe tally
  • /iowe $10 to @dan for a gringas from the taco truck

AWS makes it simple and inexpensive to create a slash command using a serverless architecture.

This tutorial walks through the steps necessary to create the iowe chatbot using DynamoDB, API Gateway, and Lambda as shown in Figure 1.

serverless Slack chatbot architecture

Figure 1 iowe serverless Slack chatbot architecture. Diagram created with Cloudcraft.

This is a step-by-step walkthrough including fully working code. The tutorial can be completed in less than an hour, but there are two requirements:

  • You need an AWS account
  • You must be an administrator on a Slack team.

Check out the conclusion for some per cost month estimates and a short analysis of the benefits of this serverless approach.

Now let’s dive into the tutorial! Log into your AWS Console and follow along.

Step 1: Create a Table in DynamoDB

DynamoDB is a NoSql database. It is also inexpensive. Lambdas are ephemeral, and need a data store to persist data. RDS is relatively expensive and editing files on S3 is not concurrency-friendly.  DynamoDB does everything we need for this app at ~$0.59/month.

The iowe Slack chatbot requires one table with two attributes: the Slack username and an owed amount.

To create the table:

  1. Navigate to the DynamoDB service and the “Create Table” button.
  2. Set the table name to “IOweSlack” and set the Primary key to “username.”
  3. Don’t change the other fields from their defaults.
  4. Uncheck the “Use default settings” box in the Table Settings section.
  5. Set the “Read capacity units” and “Write capacity units” to 1.
  6. Click the “Create” button and see your new table being created on the DynamoDB Tables list. It should take less than a minute to finish creation.

The DynamoDB table setup is now complete. Since the app will only use a minimum of space and throughput on DynamoDB, we are able to store our data on the cheap.

Step 2: Create an IAM service role

A Lambda’s basic execution role only gives it access to CloudWatch for logging. The Lambda needs permission to communicate with DynamoDB. We will accomplish this by creating an IAM service role and assigning it to the Lambda.

To create the service role, first navigate to the IAM (Identity Access Management) service and follow these instructions:

  1. Select Roles from the left navigation.
  2. Click “Create New Role”.
  3. Set the Role Name to “iowe_lambda_role”.
  4. Click “Next Step” to progress to role type selection.
  5. In “AWS Service Roles” select AWS Lambda.
  6. On the “Attach Policy” screen, select both “AWSLambdaBasicExecutionRole” and “AmazonDynamoDBFullAccess”.
  7. Click “Create Role”

The role is now created that can communicate and modify DynamoDB. However, it would be best for security if we locked down access to just the one table in DynamoDB. To specify the table, you will need to edit the role configuration by hand.  If you’d like to do this, retrieve the ARN for your DynamoDB table and attach a new role policy to our newly created role with the following permissions:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1478271683000",
            "Effect": "Allow",
            "Action": [
                "dynamodb:BatchGetItem",
                "dynamodb:BatchWriteItem",
                "dynamodb:DescribeTable",
                "dynamodb:GetItem",
                "dynamodb:GetRecords",
                "dynamodb:ListTables",
                "dynamodb:PutItem",
                "dynamodb:Query",
                "dynamodb:Scan",
                "dynamodb:UpdateItem",
                "dynamodb:UpdateTable"
            ],
            "Resource": [
                "THE ARN YOU COPIED FROM THE DYNAMODB TABLE"
            ]
        }
    ]
}

Now that the service role is set up, let’s prep the Slack side of things.

Step 3: Set up a slash command on Slack

Setting up a custom slash command on Slack requires administrator access to a Slack team. The app we are creating could be published as one of the Slack listed apps, but it would take extra steps and consideration not provided in this tutorial.

To setup a slash command on Slack, follow these instructions:

  1. Navigate to https://<your-team-domain>.slack.com/apps
  2. Select “Slash Commands”.
  3. Enter a name for your command (iowe) and click “Add Slash Command Integration”.
  4. Copy the token string from the integration settings — you will need it when configuring the Lambda in the next section.
  5. You will need to set another value after creating the API Gateway/Lambda so keep the tab open.

The slash command is mostly setup, but it is missing the required http endpoint for our app. We will create that endpoint via API Gateway/Lambda in the next step.

Step 4: Create the API Gateway/Lambda from a Blueprint

The serverless way to provide an endpoint is to use API Gateway which triggers a Lambda backend.  The Lambda can access other AWS Services (i.e., DynamoDB) and form a response. The API Gateway/Lambda pairing is so common that AWS provides blueprints that set up API Gateway for you. Additionally, there is a blueprint specifically for responding to a slash command from Slack. All we need to do is provide some Lambda code and set some configuration values.

As far as the code goes, since the Lambda servers provide the AWS SDK we do not need to include any third party libraries in the app.  That means we can do our code editing inline, which is strangely rewarding.

Regarding cost, API Gateway costs $3.50/mo for every million requests and Lambdas are free for the first million executions. After a million, they are very inexpensive.

To create API Gateway and Lambda at the same time using a blueprint, first navigate to the Lambda service then follow these steps:

  1. Click the “Create a Lambda function” button.
  2. Choose the “slack-echo-command-python” blueprint. You may not know (or like) python, but I will provide code that needs no editing.
  3. On the Configure Triggers screen, set Security to ‘Open’ and choose your own values for the name and deployment stage.
  4. Click “Next”.
  5. On the Configure function screen, choose a name and description for the Lambda.
  6. View the blueprint-provided template code. The comments at the top of the code provide similar instructions to this tutorial, plus instructions for using extra encryption via KMS. This app does not include that encryption because I think it is overkill for our purposes. Plus, it adds ~$1/mo to the app running cost. But please do try the encryption helpers out by following those AWS tutorials.
  7. Remember the token string from the Slack Team Settings from the slash command configuration? Change the name of the “kmsEncryptedToken” environment variable to “EXPECTED_TOKEN” and paste the token string into the value.
  8. Create another Environment variable named “TABLE_NAME” and set its value to the name of the DynamoDB table you created in the first step of this tutorial.
  9. Note: The Lambda defaults to ‘us-east-1’, if you are in a different region, add an environment variable named “REGION_NAME” and set the value to your desired region.
  10. Replace the blueprint code with the following code:
    import boto3
    import json
    import logging
    import re
    import os
    
    from base64 import b64decode
    from urlparse import parse_qs
    from decimal import Decimal
    from urllib2 import Request, urlopen, URLError, HTTPError
    
    expected_token = os.environ['EXPECTED_TOKEN']
    table_name = os.environ['TABLE_NAME']
    region_name = os.getenv('REGION_NAME', 'us-east-1')
    
    logger = logging.getLogger()
    logger.setLevel(logging.INFO)
    dynamo = boto3.client('dynamodb', region_name=region_name)
    
    def respond(err, res=None):
        return {
            'statusCode': '400' if err else '200',
            'body': err.message if err else json.dumps(res),
            'headers': {
                'Content-Type': 'application/json',
            },
        }
    
    
    def parse_user_and_amount(text):
        user = None
        p_user = re.compile('@\S+')
        match = p_user.search(text)
        if match:
            user = match.group(0).replace('@', '')
        
        amount = None    
        p_amount = re.compile('(-?\$?(\d{1,3}(,\d{3})+|\d+)(\.\d\d)?)')
        match = p_amount.search(text)
        if match:
            amount = Decimal(match.group(1).replace(',', '').replace('$', ''))
            
        return user,amount
    
    
    def save_to_db(user, owed_user, owed_amount):
        # subtract owed_amount from user
        dynamo.update_item(TableName=table_name,
            Key={'username':{'S':user}},
            AttributeUpdates= {
                'chips':{
                    'Action': 'ADD',
                    'Value': {'N': str(-owed_amount)}
                }
            }
        )
        
        #add owed_amount to owed_user
        dynamo.update_item(TableName=table_name,
            Key={'username': {'S':owed_user}},
            AttributeUpdates= {
                'chips':{
                    'Action': 'ADD',
                    'Value': {'N': str(owed_amount)}
                }
            }
        )
    
    
    def get_tally():
        res = dynamo.scan(TableName=table_name)
        
        logger.info(res)
        
        tally_dict = {}
        for item in res['Items']:
            tally_dict[item['username']['S']] = item['chips']['N']
        
        tally_msg = "```"
        for user, chips in sorted(tally_dict.items()):
            tally_msg += "{:<10}:{:>5}\n".format(user, chips)
        tally_msg += "```"
        return tally_msg
    
    
    def lambda_handler(event, context):
        
        # keep alive ping will keep the lambda warm
        if 'keep_alive_ping' in event:
            logger.info("ping")
            return
        
        params = parse_qs(event['body'])
        token = params['token'][0]
        if token != expected_token:
            logger.error("Request token (%s) does not match expected", token)
            return respond(Exception('Invalid request token'))
    
        user = params['user_name'][0]
        command = params['command'][0]
        channel = params['channel_name'][0]
        command_text = params['text'][0]
        response_url = params['response_url'][0]
        
        owed_user, owed_amount = parse_user_and_amount(command_text)
        
        if command_text == 'list' or command_text == 'tally':
            response_msg = get_tally()
        elif owed_user is None or owed_amount is None:
            response_msg = "Make sure to include a username with '@' and an amount, or use `list` to get the current chip count"
        elif owed_amount < Decimal(0):
            response_msg = "You can't owe someone negative. Get them to owe you to erase debts."
        elif owed_user == user:
            response_msg = "You can resolve your own debts to yourself. No chips changed!"
        else:
            save_to_db(user, owed_user, owed_amount)
            response_msg = "success! %s lost %s chips while %s gained %s chips" % (user, owed_amount, owed_user, owed_amount)
            logger.info(response_msg)
    
    
        return respond(None, {
                'response_type': 'in_channel',
                'text': response_msg
            })

     

  11. Remember the service role created in Step 2 of this tutorial? Set the Role for this Lambda to that role.
  12. Click “Next”
  13. Click “Create function”, which could take up to a minute to process.
  14. You should see an API Gateway URL displayed as a trigger for the Lambda.  Copy that value into the Slack Team Settings URL field.
  15. Set the other fields in Slack how you see fit (I like the money bag emoji for this bot)
  16. Click “Save Integration” on Slack and everything is now all set up!

Try it out

Let’s see if it works. Open up Slack and type /iowe @dan 5  (if @dan does not exist in your team, use another name).

You should receive a message about chips being exchanged.

Then Type /iowe tally  to see a table of how much each person owes (negative) or is owed (positive).

The app is pretty loose with the parsing so you can create a log of what was actually paid for by writing commands like: /iowe @dan 5 for borrowing his stapler .

Unacceptable commands will be rejected with a hopefully helpful message.

Conclusion: That was easy

If there is a more frictionless way to create and host a Slack slash command integration so inexpensively, I’d like to know. If you’re on the free tier, this app costs nothing on AWS.  Otherwise, here’s the cost breakdown:

DynamoDB: $0.59

API Gateway: $3.50

Lambda: Free

Total: ~$4.09/mo

That is pretty inexpensive, especially if you’re already using API Gateway for other projects. In addition, the serverless architecture provides high availability and scalability by using the managed services. It also saves the time needed for server setup, configuration, and maintenance.

Another option of using serverless, you could use a single ec2 instance running the app with a Rest API and a database. For an on-demand t2.nano ec2 server, it would cost roughly $4.25/mo. Consider the cost of your time and the limiting factors of the power/memory available in a t2.nano instance.

One drawback to this serverless solution is that it locks you in to AWS. An app on a regular server could easily be installed on a server not in AWS. Transferring the serverless solution outside of AWS would require a rewrite.  The Serverless Framework is a good alternative if you’ll want to explore other cloud options like Microsoft Azure and IBM OpenWhisk.

Overall, the serverless Slack chatbot architecture works perfectly for the iowe bot (other than not working during the S3 outage event of 20170228). The blueprint makes setup a breeze and adding custom integrations to Slack feels powerful.

Thanks for reading my post. If you have any questions, feedback, or suggestion for future tutorial topics, please leave a comment below.

Like this post? Please share it using the share buttons to the left. Then join our mailing list below and follow us on Twitter – @thorntech – for future updates.