Spice up Your Slack Workspace with Custom Commands using AWS Lambda Function

Lior Shalom
DevTechBlogs
Published in
10 min readMay 21, 2022

So, 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. So, how 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.

TL;DR

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 Lamda function

I assume you know how to build and configure an AWS Lambda function; you can read one of my previous articles: How to start with AWS Lambda function, 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 helpful 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, 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.

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

2. Create a Slack Command

I’ll run through it briefly; there’s an excellent tutorial that explains how to do it step-by-step[1].

First, go to https://api.slack.com/apps and 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; 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).

Remember — any change to the custom command is applied only after reinstalling the 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.

Since the command is a parameter, our Lambda function can identify the invoking command although it has made a call to the same URL.

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 “/chuck,” the response was:

/chuck failed with the error “dispatch_failed”

Well, 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”

Our 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 ##). Basically, it runs over the string, split it based on the delimiter (&), decode it, and produce valid JSON format.
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 query string. I promise you’ll see in 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 that:

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).

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 increasing security, but it’s for another article (read more here).

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 return the results 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 (see reference [2]). The starting point is to define an application key and then generate a token (see references below [3]).

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:

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>
  • 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
Get Trello Card’s data
Get the attachments of a given card

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.

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.
  • /chuck — returns a random Chuck Norris joke; it executes an API call with no authentication involved.
  • /inspire — return a random inspirational quote taken from an attachment file of a Trello card

As you can see, I spiced the text with some emojis [4].

Wrapping Up

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

There are many APIs out there, see reference [5]. Lastly, you can find my source code on GitHub [6].

Hope you found this article useful. Keep on coding 💾!

— Lior

--

--

Lior Shalom
DevTechBlogs

Technical manager, software development manager, striving for continuous evolvement // Follow me on LinkedIn: https://www.linkedin.com/in/liorshalom/