Slack Workspace With Custom Command Using Lambda

We have decided to make our Slack channel more attractive. On our team, most of the IM work-related correspondence is done on other channels (WhatsApp, Jabber), while our goal was to migrate these interactions to Slack. How were we to foster this change?

Slack allows the creation of external applications, which is a huge benefit. We harnessed the feature of custom commands to make Slack more amusing, and a bit of humor always gives motivation.

This article describes the steps to connect Slack custom commands to an AWS Lambda function, fetch data from Trello boards, and eventually serve it back to Slack as a response.

1. Create an AWS Lambda Function

I assume you know how to build and configure an AWS Lambda function. You can also read one of my previous articles: How to start with AWS Lambda function or Changing API Gateway parameters. This time, I used Python for the Lambda function.

After defining a basic Hello World function, we shall expose it via API Gateway (GET and POST). Although Slack calls are only based on POST, it is useful to access the function via GET requests too.

#build the response
def respond(err, res=None):
    if err:
        logger.error(err)
        return res+". Error: "+str(err)
    else:
        return res
# The main Lambda function method
def lambda_handler(event, context):
    try:
        logger.info('start '+str(event))
        return respond(None, 'I received a call' )
    except Exception as ex:
        return respond(ex, "Ooopss.. We're not perfect")

But before invoking this primary Lambda function, we need to change the response format.

Changing the Response Format

The text in Slack is based on Markdown format (read more here); Therefore, the returned string should be plain text and not JSON, which is the default returning format for the Lambda function. In the API Gateway, under the Integration Response (of both GET and POST), set the Mapping Template to text/plain and define the output:

#set($inputRoot = $input.path('$'))
$inputRoot

The screenshot below exemplifies this definition:

The final step would be to deploy the API Gateway and test it (if you’re unsure how to, you can refer to my articles or use AWS documentation).

Wait a Minute!

Before continuing, it’s necessary to configure the logging level of our API Gateway. It will be much easier to analyze errors and visualize the whole flow. In the example below, the stage name is “prod;” I configured the log level to INFO and to log the complete request/response data.

Configure logs

Now, let’s create a Slack Command that invokes this Lambda function.

2. Create a Slack Command

I’ll run through it briefly, but there’s an excellent tutorial that explains how to do it step-by-step.

First, create a new application.

Create a new application

  1. Under the Basic Information section, you will find the App Credentials. It has a verification token that identifies this app externally. This token will be used later, so keep it to yourself!
  2. At the bottom of the Basic Information section lies the Display Information, where you can be creative by adding a logo and a short description for your new application.
  3. The Slash Commands definition appears under the Features section.
  4. Fill in the details and set the URL to be the API Gateway URL, which we created before. You can create more than one command with the same URL, as the command name is a parameter (see the next chapter).
Fill in the details and set the URL to be the API Gateway URL

Remember — any change to the custom command is applied only after reinstalling the application:

Reinstall application

3. Connecting Slack to Our Lambda Function

Now, let’s handle the request part.

Slack sends parameters in the body of the POST request; the ampersand character is the delimiter. Here’s a censored example:

token=xuYpPjBh&
team_id=19FV3&
team_domain=domainname&
channel_id=D03EEPFS36E&
channel_name=directmessage&
user_id=U02SWEY2NUQ&
user_name=lior.k.sh
&command=%2Fchuck&
text=&
api_app_id=A03EEMBNRD0&
is_enterprise_install=false&
response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands&
trigger_id=35119694466d24a6

Let’s review the main parameters in this request:

  • token: The Slack token that identifies your application (keep it secured!)
  • user_name: Identifies the caller ID
  • command: The command that triggered the call; in this example, it’s “/chuck” since we love Chuck Norris jokes
  • text: The text after the command; if there is no text after the command, this parameter is empty

Facing Our First Error

If we try to run the command as is, Slack will probably reply with an error This is an annoying response since it reveals nothing about the underlying problem. When I tried to run the custom command “/chuckThe response was:

/chuck failed with the error “dispatch_failed”

Slackbot error message

Well, this was not very informative. Wisely, there are logs. Remember we configure the API Gateway log level? This is the time to dive into these logs!

The response is error 400, which means Bad Request. The log message indicates the transformation of the request body to JSON has failed:
“Execution failed: Could not parse request body into json: Could not parse payload into json”.

Log message indicating the transformation of the request body to JSON has failedOur Lambda expects the payload format (aka the body) to be JSON; however, it receives a string delimited with an ampersand (&). Therefore, there’s a parsing error (Bad Request 400).

Configure the API Gateway to Parse the Request

Our next step should be converting the Slack request to JSON. The API Gateway allows intervening after receiving a request and before passing it to our Lambda function. It’s done in the POST method execution →IntegrationRequest →Mapping Template.

Let’s define a new mapping template; set the Content-Type to application/x-www-form-urlencoded.

You can copy and paste the following template. It’s a bit long, but you can read it through (see the comments ##).

There’s a bonus here: I concatenated a query string parameter named “action” besides the original POST body. It will be used to pass additional parameters via a query string. I promise you’ll see further on.

## convert HTML POST data or HTTP GET query string to JSON
 
## get the raw post data from the AWS built-in variable and give it a nicer name
## Part 1: get the body content 
#if ($context.httpMethod == "POST")
 #set($rawAPIData = $input.path('$')+"&action="+$input.params('action'))
#elseif ($context.httpMethod == "GET")
 #set($rawAPIData = $input.params().querystring)
 #set($rawAPIData = $rawAPIData.toString())
 #set($rawAPIDataLength = $rawAPIData.length() - 1)
 #set($rawAPIData = $rawAPIData.substring(1, $rawAPIDataLength))
 #set($rawAPIData = $rawAPIData.replace(", ", "&"))
#else
 #set($rawAPIData = "")
#end
 
## Part 2: extract the key-value pairs by parsing the &
## Check the number of "&" in the string; it tells us if there is more than one key value pair
#set($countAmpersands = $rawAPIData.length() - $rawAPIData.replace("&", "").length())
 
## if there are no "&" at all then we have only one key value pair.
## we append an ampersand to the string so that we can tokenise it the same way as multiple kv pairs.
## the "empty" kv pair to the right of the ampersand will be ignored anyway.
#if ($countAmpersands == 0)
 #set($rawPostData = $rawAPIData + "&")
#end
 
## now we tokenise using the ampersand(s)
#set($tokenisedAmpersand = $rawAPIData.split("&"))
 
## we set up a variable to hold the valid key value pairs
#set($tokenisedEquals = [])
 
## now we set up a loop to find the valid key value pairs, which must contain only one "="
#foreach( $kvPair in $tokenisedAmpersand )
 #set($countEquals = $kvPair.length() - $kvPair.replace("=", "").length())
 #if ($countEquals == 1)
  #set($kvTokenised = $kvPair.split("="))
  ## Check if the key-value pair has only key, without value.
  #set($isEmpty = $kvTokenised.size()==1)
  #if ($kvTokenised[0].length() > 0 && !$isEmpty)
   ## we found a valid key value pair. add it to the list.
   #set($devNull = $tokenisedEquals.add($kvPair))
  #end
 #end
#end
 
## Part 3: Go over all the key-value pairs and construct the JSON format
{
#foreach( $kvPair in $tokenisedEquals )
  ## finally we output the JSON for this pair and append a comma if this isn't the last pair
  #set($kvTokenised = $kvPair.split("="))
  ## Check if this is a pair; if yes, add it to the final JSON output "key":"value".
  #if($kvTokenised[1].length() > 0)
   "$util.urlDecode($kvTokenised[0])" : "$util.urlDecode($kvTokenised[1])"#if( $foreach.hasNext ),#end
  #end
#end
}

Eventually, the mapping template should look like this:

Map template screenshot

Running the Slash Command (Again)

Now, when running the Slash command, the logs show the request before the transformation (a string with &) and after the transformation (JSON format).

Logs showing request before and after transformation

Once passing this hurdle, the body is JSON-based, and we can alter our Lambda function to extract the body content.

To begin with, we need to filter out calls that were not made by our Slack application. It is a way to validate the caller; Any other caller shall throw an exception. Here’s how to get the Slack token from the payload (the POST body):

def lambda_handler(event, context):
    try:
        token = event['token']
        if os.environ['slacktoken'] != token:
            return respond(Exception('Invalid request was made'))
        else:
            return respond(None, 'I received a call' )
       # some more code....
  except Exception as ex:
        return respond(ex, "Ooopss.. We're not perfect")

In the code sample above, the actual token is saved in an environment variable. It can be encrypted with a KMS key to increase security, but it’s for another article.

At this point, we can run our Slash Command and receive a response.

Let’s add some beef to our Slash Commands.

4. Connecting to Other APIs

At this point, when the foundations are there, this Lambda service can connect any API and pass it to Slack.

As you already know, Chuck Norris is our star. The functions below show the implementation of fetching Chuck Norris jokes from API, which returns a random joke in JSON format:

def process_chuck():    
     return respond(None, "%s :joy:" % (getValueFromJson('http://api.icndb.com/jokes/random', 'value','joke')))
def getValueFromJson(url, key1, key2):    
    content = getURLResponseJson(url)
    if len(key2)>0:
        return content[key1][key2]
    return content[key1]
def getURLResponse(sUrl, header=None):
   if header==None:
       header={'Accept': 'application/json'}
   res = urllib.request.urlopen(urllib.request.Request(url=sUrl,
        headers=header,
        method='GET'),
        timeout=5)
   return res    
def getURLResponseJson(sUrl, header=None):
    res = getURLResponse(sUrl,header)
    contentStr = res.read()
    return json.loads(contentStr)

Here’s another example for calling an API that returns Dad jokes:

def process_joke(userId, command, channel, command_text):
    sUrl="https://dad-jokes.p.rapidapi.com/random/joke"
    header ={'Accept': 'application/json',
                     'X-RapidAPI-Key' :os.environ['dad']}
    content = getURLResponseJson(sUrl,header)

Jokes aside, that’s not enough. How about getting some work-related content?

5. Adding Trello to the Party

After having some fun, I wanted to share more work-related content. Since our Slack application doesn’t have a connection to our network environment, we chose to keep some information elsewhere. Trello is the perfect solution for that purpose; it is accessible anywhere and has an extensive API. The starting point is to define an application key and then generate a token.

You need to obtain the card ID before fetching its data directly. Trello has a hierarchy: Board →List Card. First, I fetched all the lists of our board. The board’s ID appears on the URL:

Board ID as appears on URL

After having this starting point, I drilled into the specific card by running some queries using Postman.

  • Get all the list on the board.
https://api.trello.com/1/boards/aZCi/lists?key=<myKey>&token=<myToken>

Get all the list on the board

  • Found my list. Now get all the cards in it.
https://api.trello.com/1/lists/<listId>/cards?key=<myKey>&token=<myToken>

  • Finally, I can get all the card’s data and all its attachments:
    Card data
Card attachment

With that, I saved some handy information on a Trello card and fetched it by the AWS Lambda service. Here are two code samples for fetching data from Trello cards:

  • Get Card (based on its CardID):
def trello_getCard(cardId):
    trelloAppKey=os.environ['trelloAppKey']
    sUrl="https://api.trello.com/1/cards/"+cardId+'?key='+trelloAppKey+'&token='+os.environ['trello']
    return getURLResponseJson(sUrl)

  • Get attachment
    This action requires two steps. First, get the public URL of the attachment (there can be more than one attachment to a given card), and then get the attachment itself. The second call requires passing the secrets in the header. The response is the content of the file.
def trello_getCardAttachment(cardId, index):
    trelloAppKey=os.environ['trelloAppKey']
    # get the attachement details to fetch its public URL 
    contentStr = trello_getCardAttachmentsDetails(cardId)
# read the attachemnt file path (to be accessed using OAuth)    
sUrl="https://api.trello.com/1/cards/"+cardId+'/attachments/'+contentStr[index]['id']+'?key='+trelloAppKey+'&token='+os.environ['trello']
        
    contentStr = getURLResponseJson(sUrl)
    sUrl = contentStr['url']
# create a header with Oauth key
    header ={'Accept': 'application/json',
                     'Authorization' : 'OAuth oauth_consumer_key="'+trelloAppKey+'",oauth_token="'+os.environ['trello']+'"'}
  
    res = getURLResponse(sUrl,header)
    return res

I wanted to make the Slack command call more generic, so I passed the Trello card ID as a query string parameter from the Slach Command request. Do you remember the “action” parameter that was added to the Mapping Template? That’s its purpose. It was added to the input for the Lambda function.

Action parameter

Finally: Engaging the Team

Here are the results of running three commands:

  • /joke returns a random Dad joke; it authenticates to an API and fetches a joke.
  • /chuckreturns a random Chuck Norris joke; it executes an API call with no authentication involved.
  • /inspirereturns a random inspirational quote taken from an attachment file of a Trello card.
Results from running the three commands

As you can see, I spiced up the text with some emojis.

Wrapping Up

The results? Well, this feature was launched only recently, but the team really liked these refreshing supplements for our Slack channels.

There are many APIs out there. Lastly, you can find my source code on GitHub.

Hope you found this article useful. Keep on coding!

.

Leave a Comment