Premise

The struggle of deciding where to go for lunch is real, especially at Five & Done. Fueled by the collective frustration, I built this app to streamline the lunch decision process. It seamlessly integrates with our existing Google Sheet of favorite restaurants, allowing us to get recommendations, avoid repeats, and even add new discoveries – all within Slack.

Building a Slack app that communicates with Google Sheets seemed easy at first. Then, I ran into a timeout issue. The problem was that Slack expects a response to its request within 3 seconds (3000 milliseconds) and the request to Google Sheets simply took longer than that. To address this, I needed to add API Gateway in front of the Lambda function. This allows for a quick response to Slack, letting it know that the request was heard and processing is underway. Tutorials got me 90% there, but the last 10% was tricky. Hopefully, this walkthrough on how I connected Slack, Lambdas, and Sheets can help you as well.

Side note - DB: I used Google Sheets as my database because that's what we started with. You can, of course, use any database you'd like, as this tutorial focuses on connecting a Lambda to Slack and not to Sheets.

Side note - Focus: This guide focuses on lunch recommendations, but I can't assume everyone has the same problem. Fortunately, the solution can still be applied to multiple cases. However, if you'd like a similar problem to get started, here's a dummy lunch list.

Set up: Slack App

First, I started a Slack app and created a new slash command called `/lunch-lad`. My expected syntax is `/lunch-lad [command]`.

Set up: Lambda Functions

Next, I created two Lambda functions:

  1. Sort and Parse: This function grabs the response URL, used to respond to Slack, and also identifies the command type, which will be crucial for Step Functions later.
exports.handler = async (event) => {
 // ~ Parses the body of a Slack request, decoding the URL-encoded key-value pairs.
 function parseSlackBody(body) {
   const keyValuePairs = body.split("&");
   const parsedBody = {};


   for (const keyValuePair of keyValuePairs) {
     const [key, value] = keyValuePair.split("=");
     const decodedValue = decodeURIComponent(value);
     parsedBody[key] = decodedValue;
   }
   return parsedBody;
 }


 const parsedBody = parseSlackBody(event.data);


 const { response_url, text } = parsedBody;


 let fields = {};


 switch (text) {
   case "recommend":
     fields = {
       response_url,
	type: text,
     };
     break;
 }
 return fields;
};
  1. Recommendation: This function reads the lunch table in your Google Sheet and returns a random recommendation, excluding recent visits. You can configure messages to be ephemeral (visible only to the user) by excluding `in_channel`.
const { google } = require("googleapis");
const axios = require("axios");


const SPREADSHEET_ID = "SHEET_ID";
const SHEET_RANGE = "SHEET NAME!A:A"; // Adjust range based on your data


exports.handler = async (event) => {
 let status = "success";
 async function getRestaurants() {
   const sheets = google.sheets({
     version: "v4",
     auth: "AUTH", // Switch out your own auth
   });


   try {
     const response = await sheets.spreadsheets.values.get({
       spreadsheetId: SPREADSHEET_ID,
       range: SHEET_RANGE,
     });
     const restaurants = response.data.values || [];


     let filteredRestaurants = restaurants.filter((item) => item.length > 0);


     const oneMonthAgo = new Date();
     oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
     const oneMonthAgoTime = oneMonthAgo.getTime();
     filteredRestaurants = filteredRestaurants.filter((item) => {
       if (!item[4]) {
         return true;
       }
       return new Date(item[4]).getTime() < oneMonthAgoTime;
     });


     return filteredRestaurants;
   } catch (error) {
     console.error("Error fetching restaurants:", error);
     status = "failed";
     return [];
   }
 }


 async function recommendLunch() {
   const restaurants = await getRestaurants();
   if (!restaurants.length) {
     return "Sorry, no restaurant recommendations found!";
   }
   const randomIndex = Math.floor(Math.random() * restaurants.length);
   const restaurant = restaurants[randomIndex];


   return `Today\'s lunch recommendation is ${restaurant[0]} (${
     restaurant[1]
   }). ${restaurant[4] === "" ? `` : `Last eaten at ${restaurant[4]}`}`;
 }


 const recommendation = await recommendLunch();


 const params = {
   method: "POST",
   url: event.response_url,
   headers: { "Content-Type": "application/json" },
   data: {
     response_type: "in_channel",
     text: recommendation,
   },
 };


 await axios(params)
   .then(() => {
     console.log("SUCCESS!");
   })
   .catch((err) => {
     console.error(err);
     status = "failed";
   });
 return status;
};

Step Functions

What are Step Functions?

Step Functions is an AWS service that lets you define the flow of your code visually. Imagine a recipe with multiple steps. Step Functions helps you manage these steps in your code.

  1. I created a new State Machine in Step Functions and chose a blank template.
  1. In the Config tab, I named the machine “lunch-machine” and changed the type to Express. According to this video tutorial, the main reason I got 90% to the finish line (thanks, Magestic.cloud!), Express can do asynchronous and synchronous executions, and it’s cheaper than standard.
  1. I set the first step to call my sort and parse function by dragging the AWS Lambda Invoke block from the left sidebar and setting Function Name to target my `lunch-sort` function. I know the return will include a command type which I will listen for in the next step.
  1. Using a Choice Flow as the next step, I can sort which function will be called by checking if the variable `type`, returned in step one, is "recommend." Later, when other commands are added, more conditions can be added to the Choice Flow. I prefer this branching method since it’s visually organized, and lambda functions are created separately from each other, making it easy to edit and debug.
  1. Under the "recommend" rule in the Choice Flow, I added another AWS Lambda Invoke block to call the `lunch-recommendation` function. Later, I’ll add an error function to send a Slack message if the command isn't set, but for now, I set the flow to fail.

Side note: I like to label the State names as something easy to read at a glance to know what’s happening at each block level.

When you create the state machine, an IAM role will automatically be created alongside it. If you add new Lambdas, you’ll have to manually update the IAM's Lambda invoke policy to include those functions. Otherwise, you’ll get permission errors.

API Gateway: Part One - IAM Role

What is an API Gateway?

API Gateway is an AWS service that acts as a secure, centralized entry point for applications to interact with your services (like Lambdas).

  1. While Step Functions automatically create IAM roles, API Gateway does not. To grant API Gateway the necessary permissions, I manually created a new IAM role. I specified the role type as 'AWS Service' and designated 'API Gateway' as the service.
  1. I created the role with the name LunchGatewayRole, then added Step Function permissions to it.

API Gateway: Part Two - Set up

  1. In API Gateway, I created a new REST API and named it “LunchAPI”.
  1. Then, I created a new resource that will be invoked by the `/lunch-lad` slash command and pass on the payload to Step Functions to be parsed.
  1. Under the resource, I created a POST method with the following configuration:
  • method type = POST
  • integration type = AWS service
  • region = [same region as step function]
  • aws service = Step Functions
  • HTTP method = POST
  • action name = StartExecution
  • execution role = [IAM role ARN for Gateway]

API Gateway: Part Three - Integration Request

Here’s where the last 10% of work took the longest to complete. I needed to transform Slack’s HTTP POST request into a format that Step Functions understood. I knew from testing that Slack's slash command payload would come in as encoded content, but I could not get the payload to work with the mapping’s JSON. I finally figured out that the mapping template’s content type had to match the content type of Slack’s request, `application/x-www-form-urlencoded.` The payload also had to be changed so that it would pass in input. You can read more about the slash command here and more about creating mapping templates here.

TL;DR

  1. Edit the Integration Request's mapping template and set the content type to application/x-www-form-urlencoded.
  2. Set the template body with your State Machine ARN (replace the placeholder). The name is optional (for logs).
{
 "input": "{\"data\": $util.escapeJavaScript($input.json('$')).replaceAll("\\'","'")}",
 "name": "MyExecution",
 "stateMachineArn": "[REPLACE WITH YOUR STATE MACHINE ARN]"
}

API Gateway: Part Four - Integration Response

I also customized the response Slack gets, confirming that the request was received in the Integration Response tab. For now I kept it simple with a “Command received.” but you can find more information about confirmation receipts here

{
 "text": "Command received.",
}

Deploying the API and Wrapping Up

I deployed my API Gateway and created a new stage called dev. Then, copied the Invoke URL under the method (it includes the stage and resource path) for Slacks slash command settings.

Set Up Slack Integration

In my Lunch Lad app settings, I pasted the copied Invoke URL into the Request URL field and then installed the app to my workspace.

Done!

This tutorial covered a basic app with a single command, but other features could be added like:

  • Modals for more complex user interactions
  • Shortcuts for easier access to specific commands
  • Additional commands

Troubleshooting Tip:

Step Functions provides a visual log that helps pinpoint where errors occur in your workflow. This makes troubleshooting much easier!

Security Note:

Consider using environment variables like your State Machine ARN to store sensitive information. This improves security by keeping sensitive data out of your code.

I hope this comprehensive tutorial empowers you to build your own Slack app!