Blog

Building Your First Slack Bot with Block Kit – A Step-by-Step Guide

23 Apr, 2024
Xebia Background Header Wave

Recently at Xebia, a new office location opened up in Amsterdam. At this location there is only a limited number of parking spots, and we want to avoid colleagues arriving at the office with their car without an available spot to park. That’s why I took on the task to build a Slack bot that could facilitate viewing, cancelling, and booking spots in the garage.

In this article I would like to show you what I learned while building that Slack bot. I build the Slack Bot using Block Kit. Block Kit is the clean and consistent UI framework for Slack apps. In this blog post you’ll learn how to create a Slack Bot with Block Kit in these five steps:

  1. Setting up a Slack app
  2. Creating a Handler that Handles Slack Commands and Interactivity
  3. Create a Slack UI Layout using the Block Kit Builder
  4. Display your Block Kit Layout
  5. Adding Interactivity to your Block Kit Layout

After reading this blog post, you should have the understanding to build your own Slack bot with Block Kit.

1. Setting up a Slack app

First, you need to create a new Slack App. You can create a new Slack App here. Choose ‘Create New App’ > ‘From scratch’ and provide an App Name and the Slack workspace you want to develop this app in.

Setting up a Slack app from the Slack API web portal

Setting up a Slack app from the Slack API web portal.

After you created your Slack App, navigate to the ‘Basic Information’ tab of your app and click ‘Install to Workspace’. This enables you to test your app in your workspace, and it will allow you to generate the API tokens that you’ll need later.

2. Creating a Handler that Handles Slack Commands and Interactivity

Next, you are going to create a handler for your bot. You will also need to configure Slack to invoke this handler whenever a command is sent to the bot or any interactivity with the bot occurs. In this blog post a single Lambda function is used to handle both incoming commands and incoming interactivity.

Slack to Lambda

Slack API reaching out to AWS Lambda.

2.1 Creating your Handler using an AWS Lambda Function

In this example I am going to use a Node.js AWS Lambda function to host the handler. You can also use other programming languages or hosting environments, as long as you have a public HTTP endpoint that can be reached by Slack.

To create the AWS Lambda function, navigate to AWS Lambda in the AWS Console. Then, click on ‘Create function’, give your function an appropriate name and select the Node.js 20.x runtime. Then click ‘Create Function’.

Note: To provision your resources in AWS we advise the use of Infrastructure as Code (IaC) tools, such as Terraform, Cloud Formation, or AWS CDK. These tools eliminate manual resource creation and enforces consistency.

Slack requires your handler to be reachable publicly using HTTP. Within AWS Lambda you can achieve this by setting up a URL for you Lambda function.

  1. In your Lambda Function in the AWS Console open the ‘Configuration’ tab
  2. On the left, click on ‘Function URL’ and click on ‘Create function URL’
  3. Select ‘None’ for the auth type. Authentication of your bot will be handled within the handler itself.
  4. And click ‘Save’. Now your function URL is created. It should look something like this: https://abcdefghijklmnopqrstuvwxyz0123.lambda-url.eu-west-1.on.aws/
  5. Copy the function URL. You’ll need this in the following step.

2.2 Configuring Slack App to Invoke your Handler

Next you need to configure Slack to invoke your handler (URL endpoint) whenever a command is sent to the bot or any interactivity with the bot occurs. You can configure you Slack App here.

Let’s start by setting up the command handler. In the menu on the left, navigate to the ‘Slash Commands’ section. Then, click on ‘Create New Command’. Give your command an appropriate name (such as /my-bot) and in the ‘Request URL’ use the URL endpoint of your handler.

In Slack, when you now post /my-bot in any channel or private chat, your bot should respond. For now, it will respond with the “Hello from Lambda!” message. The response of the bot will only be visible to you.

The Hello, World of the Slack Bot

The “Hello, World” of the Slack Bot.

Currently, the UI is none interactive. It is just a piece of text. Once you start interacting with the UI of your bot, Slack will send out interactivity events. Let’s also configure your Slack App to send these interactivity events to your handler.

Find your Slack App. Then, navigate to the ‘Interactivity & Shortcuts’ section in the menu on the left. In the top right corner of that section, enable interactivity. Finally, provide the URL endpoint of your handler as the ‘Request URL’.

Now, any interactions with shortcuts, modals, or interactive components (such as buttons, select menus, and date pickers) will be sent as HTTP POST requests to your handler as well.

3. Create a Slack UI using the Block Kit Builder

Block Kit is the clean and consistent UI framework for Slack apps. You can use it to design visually rich and interactive messages by composing a layout from individual blocks. Blocks are visual components that can be stacked and arranged to create app layouts. Some components (such as buttons) inject interactivity in your layout. You can find an overview of the available blocks in the API reference of Block Kit blocks.

In the Block Kit Builder you can design your components. You can do this by drag and dropping blocks from the left-hand column directly into the preview. Sample JSON for the blocks will show on the right. Alternatively, you can directly edit the sample JSON to see those changes reflected in the preview.

Let’s create the following layout.

A layout displaying a counter in the Block Kit Builder.

A layout displaying a counter in the Block Kit Builder.

The layout represents a counter and consists of 2 blocks. One ‘section’ block that displays some text, including the username. And a ‘actions’ block that displays three button elements (-1, 0, 1).

Click here to see the JSON corresponding to that component. If you want to make some adjustment, copy and paste it into the Block Kit Builder.
{
  "blocks": [
    {
      "type":"section",
      "text": {
        "type":"mrkdwn",
        "text":"Hi, *Simon* -- Keep track of a counter."
      }
    },
    {
      "type":"actions",
      "block_id":"counter",
      "elements": [
        {
          "type":"button",
          "text": {
            "type":"plain_text",
            "text":"-1"
          },
          "value":"-1",
          "action_id":"decrease"
        },
        {
          "type":"button",
          "text": {
            "type":"plain_text",
            "text":"0"
          },
          "action_id":"reset"
        },
        {
          "type":"button",
          "text": {
            "type":"plain_text",
            "text":"+1"
          },
          "value":"1",
          "action_id":"increase"
        }
      ]
    }
  ]
}

Interactive blocks in your layout have additional details such as block_id, action_id and value. These are hidden from view and are only used when a user interacts with these blocks. The corresponding values of the block_id, action_id and value are sent along with the interactivity event whenever a user interacts with your app. This allows you to determine what action the user intended to achieve with its interaction.

Once you’re happy with your layout. Save the JSON, you will need it in the next step.

4. Display your Block Kit Layout

4.1 Updating the Handler

Now let’s display your Block Kit layout whenever a user invokes the /my-bot command. To do this you have to return the layout as JSON from the handler.

In a Node.js AWS Lambda function handler you can you use the following code for this.

const myLayout = '/* paste your layout here! */';

export const handler = async (event) => {
  return { statusCode: 200, body: JSON.stringify(myLayout) };
};

Now, try and run your /my-bot command again. You should see your layout appear in Slack.

4.2 Customizing the Response

Currently, no matter who invoked the bot, it will always display Hi, Simon, since that text is hard coded in the layout. The handler needs to update the layout it returns according to the user that invoked the Slack bot.

When a slash command is invoked, Slack sends an HTTP POST to the handler. This request contains a data payload describing information about the command, including who invoked it. Keep in mind that this data will be sent with a Content-Type header set as application/x-www-form-urlencoded. You can find details of some of the important fields you might see in this payload in the Slash Command Documentation.

One of the fields in the payload is the user_name. You can use the value of this field to update the layout returned by the handler.

4.2.a Extracting the username

In AWS Lambda, the event is passed as the first argument to the handler. In the code below you can see how the event body is extracted and decoded. Then, from this data the user_name is extracted and used to update the corresponding block in the layout.

const myLayout = '/* paste your layout here! */';

export const handler = async (event) => {
  // Decode the body of the event to be able to extract the information from Slack
  const data = new URLSearchParams(Buffer.from(event.body, 'base64').toString());

  // Extract the username and update the block
  const username = data.get("user_name");
  myLayout.blocks[0].text.text = `Hi, *${username}* -- Keep track of a counter.`;

  // Return the block
  return { statusCode: 200, body: JSON.stringify(myLayout) };
};

4.2.b Securing your handler

Currently, anyone that knows your endpoint URL can invoke it with any payload they like. You can try this yourself: curl -X POST <your-handler-url> and see what happens.

For our simple example this isn’t much of a problem. However, if you start doing database calls or other business logic in your Slack handler, you probably don’t want anyone (except for Slack) to be able to invoke it.

Slack signs its requests using a secret unique to your app. With the help of signed secrets, your app can more confidently verify whether requests from Slack are authentic. You can find more information in the Verifying requests from Slack article.

To add this functionality to the handler, you can copy the code snippet below and paste it in the verify.mjs file of your handler.

File: verify.mjs (click to show file contents)
// create verify.mjs
import { createHmac } from 'crypto';

const verifyErrorPrefix = 'Failed to verify authenticity';

/**
 * Verifies the signature of an incoming request from Slack.
 * If the request is invalid, this method throws an exception with the error details.
 */
export function verifySlackRequest(options) {
  const requestTimestampSec = options.headers['x-slack-request-timestamp'];
  const signature = options.headers['x-slack-signature'];
  if (Number.isNaN(requestTimestampSec)) {
    throw new Error(
      `Failed to verify authenticity: header x-slack-request-timestamp did not have the expected type (${requestTimestampSec})`,
    );
  }

  // Calculate time-dependent values
  const nowMs = Date.now();
  const requestTimestampMaxDeltaMin = 5;
  const fiveMinutesAgoSec = Math.floor(nowMs / 1000) - 60 * requestTimestampMaxDeltaMin;

  // Enforce verification rules

  // Rule 1: Check staleness
  if (requestTimestampSec < fiveMinutesAgoSec) {
    throw new Error(`${verifyErrorPrefix}: x-slack-request-timestamp must differ from system time by no more than ${requestTimestampMaxDeltaMin
    } minutes or request is stale`);
  }

  // Rule 2: Check signature
  // Separate parts of signature
  const [signatureVersion, signatureHash] = signature.split('=');
  // Only handle known versions
  if (signatureVersion !== 'v0') {
    throw new Error(`${verifyErrorPrefix}: unknown signature version`);
  }
  // Compute our own signature hash
  const hmac = createHmac('sha256', options.signingSecret);
  hmac.update(`${signatureVersion}:${requestTimestampSec}:${options.body}`);
  const ourSignatureHash = hmac.digest('hex');
  if (!signatureHash || signatureHash !== ourSignatureHash) {
    throw new Error(`${verifyErrorPrefix}: signature mismatch`);
  }
}

/**
 * Verifies the signature of an incoming request from Slack.
 * If the request is invalid, this method returns false.
 */
export function isValidSlackRequest(options) {
  try {
    verifySlackRequest(options);
    return true;
  } catch (e) {
    console.warn(`Signature verification error: ${e}`);
  }
  return false;
}

Then, in your handler call the isValidSlackRequest method from the verify.mjs file. Don’t forget to add the import statement at the top of the file.

import { isValidSlackRequest } from './verify.mjs';

export const handler = async (event) => {
  // Validate the Slack signature <---- here
  const body = Buffer.from(event.body, 'base64').toString();
  if (!isValidSlackRequest({
    signingSecret: process.env.SLACK_SIGNING_SECRET,
    body,
    headers: event.headers,
  })) {
    return { statusCode: 403 };
  }

  // ... keep the rest of your handler the same ...
};

Finally, use the Signing Secret from your Slack Application details and use it to add an environment variable for your AWS Lambda with the following name SLACK_SIGNING_SECRET.

Your requests from Slack should now still work correctly (try running the command /my-app again). However, if you try to POST custom data directly to the endpoint URL you should get a Forbidden (HTTP 403) response.

5. Adding Interactivity to your Block Kit Layout

It is time to make the Slack app interactive by ensuring the handler processes interactions from the buttons. Interactivity events are handled differently from slash command events. First, the event information is passed as a JSON object in the payload field of the body. Secondly, to send back information to the user, you have to post data to the response_url, instead of returning it in the body of the response.

In the code below you can see how the event body is extracted and decoded. If this data contains a payload, that tells you that you’re handling interactivity. Instead of responding with the layout blocks, your handler can invoke the handleInteractivity method with the JSON decoded payload as shown in the code blow.

const myLayout = '/* paste your layout here! */';

const handleInteractivity = async (payload) => {
  // ... more later ...
};

export const handler = async (event) => {
  // Decode the body of the event to be able to extract the information from Slack
  const data = new URLSearchParams(Buffer.from(event.body, 'base64').toString());

  // Check if this is an interactivity event <----- here
  if (data.has("payload")) {
    await handleInteractivity(JSON.parse(data.get("payload")));
    return { statusCode: 200 };
  }

  // Extract the username and update the block
  const username = data.get("user_name");
  myLayout.blocks[0].text.text = `Hi, *${username}* -- Keep track of a counter.`;

  // Return the block
  return { statusCode: 200, body: JSON.stringify(myLayout) };
};

Within the handleInteractivity method you can construct a new layout. The new layout you construct will resemble the interactivity or change that was just sent. For example, you can update the values of the counter buttons, based on the payload value.

To update the layout after an interactivity you’ll have to use the response_url that is part of the payload. Any JSON you POST to this response_url determines the behaviour of this interactivity.

In the example below an updated layout is POST-ed to the response URL. Since the replace_original field is set to true on the layout, Slack will replace the original message that the user interacted with in Slack, with the new layout.

const handleInteractivity = async (payload) => {  
  // Create a clone of the layout to avoid updating the original layout 
  const layout = structuredClone(myLayout);

  // Ensure this layout replaces the existing message
  layout["replace_original"] = true;

  // Extract the username and update the block
  const username = payload["user"]["username"];
  layout.blocks[0].text.text = `Hi, *${username}* -- Keep track of a counter.`;

  // Find the value associated to this action and update the buttons
  const value = parseInt(payload["actions"][0].value, 10);
  layout.blocks[1].elements[0].value = (value - 1).toString();
  layout.blocks[1].elements[1].text.text = value.toString();
  layout.blocks[1].elements[1].value = value.toString();
  layout.blocks[1].elements[2].value = (value + 1).toString();

  // Post the layout to the response url
  await fetch(payload["response_url"], {
    method: 'POST',
    body: JSON.stringify(layout),
    headers: { 'Content-Type': 'application/json' }
  });
};

After you have updated your handler with the interactivity code, your app should look like this. Pressing the -1 and +1 buttons should increase and decrease the counter.

Counter is working!

Conclusion

You have seen how to create a new Slack app, create a handler for your bot, create an interactive layout using Block Kit, and have your handler provide the functionality of your interactive layout to your users.

It’s now up to you to create your own Slack app and have the handler do useful stuff for your organisation. Keep in mind that your handler can run anything, such as database queries or call APIs, so the possibilities of you Slack app are endless.

If you’re interested in more, then please take a look at Block Builder a Node.js library for building Slack Block Kit UIs and Bolt.js a framework to build Slack apps using JavaScript. These two libraries help you in building a more sophisticated Slack bot at scale.

Full Code Example

The full code of the example handler from this blog is available below. Keep in mind that there are two files index.mjs and verify.mjs and that you should add your environment variable for the SLACK_SIGNING_SECRET.

File: index.mjs (click to show)
// in index.mjs
import { isValidSlackRequest } from './verify.mjs';

const myLayout = {
  "blocks": [
    {
      "type":"section",
      "text": {
        "type":"mrkdwn",
        "text":"Keep track of a counter."
      }
    },
    {
      "type":"actions",
      "block_id":"counter",
      "elements": [
        {
          "type":"button",
          "text": {
            "type":"plain_text",
            "text":"-1"
          },
          "value":"-1",
          "action_id":"decrease"
        },
        {
          "type":"button",
          "text": {
            "type":"plain_text",
            "text":"0"
          },
          "action_id":"reset"
        },
        {
          "type":"button",
          "text": {
            "type":"plain_text",
            "text":"+1"
          },
          "value":"1",
          "action_id":"increase"
        }
      ]
    }
  ]
};

const handleInteractivity = async (payload) => {
  const layout = structuredClone(myLayout);

  // Ensure this layout replaces the existing message
  layout["replace_original"] = true;

  // Extract the username and update the block
  const username = payload["user"]["username"];
  layout.blocks[0].text.text = `Hi, *${username}* -- Keep track of a counter.`;

  // Find the value associated to this action and update the buttons
  const value = parseInt(payload["actions"][0].value, 10);
  layout.blocks[1].elements[0].value = (value - 1).toString();
  layout.blocks[1].elements[1].text.text = value.toString();
  layout.blocks[1].elements[1].value = value.toString();
  layout.blocks[1].elements[2].value = (value + 1).toString();

  // Post the layout to the response url
  const fetchDetails = [payload["response_url"], { method: 'POST', body: JSON.stringify(layout), headers: { 'Content-Type': 'application/json' } }];
  const response = await fetch(...fetchDetails);
  console.info('Responded!', response, fetchDetails);
};

export const handler = async (event) => {
  // Validate the Slack signature
  const body = Buffer.from(event.body, 'base64').toString();
  if (!isValidSlackRequest({
    signingSecret: process.env.SLACK_SIGNING_SECRET,
    body,
    headers: event.headers,
  })) {
    return { statusCode: 403 };
  }

  // Decode the body of the event to be able to extract the information from Slack
  const data = new URLSearchParams(body);

  // Check if this is an interactivity event
  if (data.has("payload")) {
    await handleInteractivity(JSON.parse(data.get("payload")));
    return { statusCode: 200 };
  }

  // Extract the username and update the block
  const username = data.get("user_name");
  myLayout.blocks[0].text.text = `Hi, *${username}* -- Keep track of a counter.`;

  // Return the block
  return { statusCode: 200, body: JSON.stringify(myLayout) };
};
File: verify.mjs (click to show)
// in verify.mjs
import { createHmac } from 'crypto';

const verifyErrorPrefix = 'Failed to verify authenticity';

/**
 * Verifies the signature of an incoming request from Slack.
 * If the request is invalid, this method throws an exception with the error details.
 */
export function verifySlackRequest(options) {
  const requestTimestampSec = options.headers['x-slack-request-timestamp'];
  const signature = options.headers['x-slack-signature'];
  if (Number.isNaN(requestTimestampSec)) {
    throw new Error(
      `Failed to verify authenticity: header x-slack-request-timestamp did not have the expected type (${requestTimestampSec})`,
    );
  }

  // Calculate time-dependent values
  const nowMs = Date.now();
  const requestTimestampMaxDeltaMin = 5;
  const fiveMinutesAgoSec = Math.floor(nowMs / 1000) - 60 * requestTimestampMaxDeltaMin;

  // Enforce verification rules

  // Rule 1: Check staleness
  if (requestTimestampSec < fiveMinutesAgoSec) {
    throw new Error(`${verifyErrorPrefix}: x-slack-request-timestamp must differ from system time by no more than ${requestTimestampMaxDeltaMin
    } minutes or request is stale`);
  }

  // Rule 2: Check signature
  // Separate parts of signature
  const [signatureVersion, signatureHash] = signature.split('=');
  // Only handle known versions
  if (signatureVersion !== 'v0') {
    throw new Error(`${verifyErrorPrefix}: unknown signature version`);
  }
  // Compute our own signature hash
  const hmac = createHmac('sha256', options.signingSecret);
  hmac.update(`${signatureVersion}:${requestTimestampSec}:${options.body}`);
  const ourSignatureHash = hmac.digest('hex');
  if (!signatureHash || signatureHash !== ourSignatureHash) {
    throw new Error(`${verifyErrorPrefix}: signature mismatch`);
  }
}

/**
 * Verifies the signature of an incoming request from Slack.
 * If the request is invalid, this method returns false.
 */
export function isValidSlackRequest(options) {
  try {
    verifySlackRequest(options);
    return true;
  } catch (e) {
    console.warn(`Signature verification error: ${e}`);
  }
  return false;
}
Simon Karman
Simon Karman is a cloud engineer and consultant that builds effective cloud solutions ranging from serverless applications, to apis, to CICD pipelines, and to infrastructure as code solutions in both AWS and GCP.
Questions?

Get in touch with us to learn more about the subject and related solutions

Explore related posts