Build a Slack bot with Node.js on Cloud Run

1. Overview

5f529fb87abc11c9.png

In this codelab, you'll learn how to build a Slack bot using the Botkit toolkit and run it on Google Cloud. You'll be able to interact with the bot in a live Slack channel.

What you'll learn

  • How to create a bot custom integration in Slack
  • How to secure your Slack secrets with Secret Manager
  • How to deploy a Slack bot on Cloud Run, a fully managed compute platform that automatically scales your stateless containers

What you'll need

How will you use this tutorial?

Read it through only Read it and complete the exercises

How would you rate your experience with Google Cloud?

Novice Intermediate Proficient

2. Setup and requirements

Self-paced environment setup

  1. Sign-in to the Google Cloud Console and create a new project or reuse an existing one. If you don't already have a Gmail or Google Workspace account, you must create one.

98e4187c97cf2e0e.png

37d264871000675d.png

c20a9642aaa18d11.png

  • The Project name is the display name for this project's participants. It is a character string not used by Google APIs. You can always update it.
  • The Project ID is unique across all Google Cloud projects and is immutable (cannot be changed after it has been set). The Cloud Console auto-generates a unique string; usually you don't care what it is. In most codelabs, you'll need to reference your Project ID (typically identified as PROJECT_ID). If you don't like the generated ID, you might generate another random one. Alternatively, you can try your own, and see if it's available. It can't be changed after this step and remains for the duration of the project.
  • For your information, there is a third value, a Project Number, which some APIs use. Learn more about all three of these values in the documentation.
  1. Next, you'll need to enable billing in the Cloud Console to use Cloud resources/APIs. Running through this codelab won't cost much, if anything at all. To shut down resources to avoid incurring billing beyond this tutorial, you can delete the resources you created or delete the project. New Google Cloud users are eligible for the $300 USD Free Trial program.

Start Cloud Shell

While Google Cloud can be operated remotely from your laptop, in this tutorial you will be using Cloud Shell, a command line environment running in the Cloud.

Activate Cloud Shell

  1. From the Cloud Console, click Activate Cloud Shell d1264ca30785e435.png.

84688aa223b1c3a2.png

If this is your first time starting Cloud Shell, you're presented with an intermediate screen describing what it is. If you were presented with an intermediate screen, click Continue.

d95252b003979716.png

It should only take a few moments to provision and connect to Cloud Shell.

7833d5e1c5d18f54.png

This virtual machine is loaded with all the development tools needed. It offers a persistent 5 GB home directory and runs in Google Cloud, greatly enhancing network performance and authentication. Much, if not all, of your work in this codelab can be done with a browser.

Once connected to Cloud Shell, you should see that you are authenticated and that the project is set to your project ID.

  1. Run the following command in Cloud Shell to confirm that you are authenticated:
gcloud auth list

Command output

 Credentialed Accounts
ACTIVE  ACCOUNT
*       <my_account>@<my_domain.com>

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. Run the following command in Cloud Shell to confirm that the gcloud command knows about your project:
gcloud config list project

Command output

[core]
project = <PROJECT_ID>

If it is not, you can set it with this command:

gcloud config set project <PROJECT_ID>

Command output

Updated property [core/project].

3. Enable the APIs

From Cloud Shell, enable the Artifact Registry, Cloud Build, Cloud Run, and Secret Manager APIs:

gcloud services enable \
  artifactregistry.googleapis.com \
  cloudbuild.googleapis.com \
  run.googleapis.com \
  secretmanager.googleapis.com

This outputs a success message similar to this one:

Operation "operations/..." finished successfully.

Now, you're ready to prepare and deploy your application...

4. Create a Slack workspace

You will need a Slack workspace where you are allowed to create custom integrations. You can create a workspace for free if you do not already have one that you wish to use for this tutorial.

aa1f0fda82263bf8.png

5. Create a Slack bot user

A bot user can listen to messages on Slack, post messages, and upload files. In this codelab, you will create a bot to post a simple greeting message.

Create a new Slack app

  • Go to the Slack apps management page.
  • Click the Create new app button in the upper-right corner.
  • Give the app a name, such as "Kittenbot".
  • Choose the Slack team where you want it installed.

Create a bot user

  • Go to App Home on the left-side panel under Features

414213b184fcc992.png

  • Assign a scope to your bot token by clicking Review Scopes to Add
  • Scroll down to Bot Token Scopes and click Add an OAuth Scope. Select chat:write to "Send messages as Kittenbot"

74a6fa87c64c2b23.png

  • Scroll up and click the Install App to your Workspace button.
  • This will install the app to your team, add the bot user you just created, and generate a bot token.
  • When prompted, click "Allow" to authorize the bot to chat in your workspace.

Enable messages and commands

  • Scroll down to Show Tabs and make sure both options are enabled:

5ca52f7abbdc15c.png

Get the client signing secret

  • Go to Basic Information under Settings.
  • Scroll down to Signing Secret, click Show, and then copy the secret to your clipboard:

74cfd6616fa71dc4.png

  • Save the secret in an environment variable:
CLIENT_SIGNING_SECRET=PASTE_THE_SIGNING_SECRET

Get the bot token

  • Go to OAuth & Permissions under Features.
  • Click the Copy button to copy the Bot User OAuth Access Token text into your clipboard.

6f5a18069471101.png

  • Save the bot token in an environment variable:
BOT_TOKEN=PASTE_THE_BOT_TOKEN

Don't worry. You can come back to this configuration page from the apps management page if you need to get these tokens again.

6. Secure your secrets

We want to ensure that your bot token and your client signing secret are stored securely. Hard-coding them in source code makes it likely to accidentally expose these secrets by publishing them to version control or embedding them in a docker image.

Secret Manager provides a secure and convenient method for storing API keys, passwords, certificates, and other sensitive data. Secret Manager provides a central place and single source of truth to manage, access, and audit secrets across Google Cloud.

Create your secrets

Save your client signing secret and bot token with the following commands:

  • Client signing secret
echo -n $CLIENT_SIGNING_SECRET | gcloud secrets create client-signing-secret \
  --replication-policy automatic \
  --data-file -
  • Bot token
echo -n $BOT_TOKEN | gcloud secrets create bot-token \
  --replication-policy automatic \
  --data-file -

Access your Secrets

Let's confirm that your secrets were created properly and your permissions are working. Access your secrets with the following commands:

echo $(gcloud secrets versions access 1 --secret client-signing-secret)
echo $(gcloud secrets versions access 1 --secret bot-token)

You can also view and manage your secrets in the Google Cloud console.

7. Get the sample code

In Cloud Shell on the command-line, run the following command to clone the GitHub repository:

git clone https://github.com/googlecodelabs/cloud-slack-bot.git

Change directory into cloud-slack-bot/start.

cd cloud-slack-bot/start

Understanding the Code

Open up the kittenbot.js file with your preferred command line editor (nano, vim, emacs...) or with the following command to directly open the current folder in Cloud Shell Editor:

cloudshell workspace .

The kittenbot code has two main functions. One is to retrieve the secrets, and the other is to run the bot.

First, we import the dependencies:

kittenbot.js

const { Botkit } = require('botkit');
const {
  SlackAdapter,
  SlackEventMiddleware,
} = require('botbuilder-adapter-slack');
const { SecretManagerServiceClient } = require('@google-cloud/secret-manager');

The SlackAdapter and SlackEventMiddleware are packages that extend Botkit and allow the bot to easily translate messages to and from the Slack API. The Secret Manager client will allow you to access the secrets you saved in an earlier step.

Next we have our function for retrieving the secrets:

/**
 * Returns the secret string from Google Cloud Secret Manager
 * @param {string} name The name of the secret.
 * @return {Promise<string>} The string value of the secret.
 */
async function accessSecretVersion(name) {
  const client = new SecretManagerServiceClient();
  const projectId = process.env.PROJECT_ID;
  const [version] = await client.accessSecretVersion({
    name: `projects/${projectId}/secrets/${name}/versions/1`,
  });

  // Extract the payload as a string.
  const payload = version.payload.data.toString('utf8');

  return payload;
}

This function returns the string values of the secrets needed to authenticate the bot.

The next function initializes the bot:

/**
 * Function to initialize kittenbot.
 */
async function kittenbotInit() {
  const adapter = new SlackAdapter({
    clientSigningSecret: await accessSecretVersion('client-signing-secret'),
    botToken: await accessSecretVersion('bot-token'),
  });

  adapter.use(new SlackEventMiddleware());

  const controller = new Botkit({
    webhook_uri: '/api/messages',
    adapter: adapter,
  });

  controller.ready(() => {
    controller.hears(
      ['hello', 'hi', 'hey'],
      ['message', 'direct_message'],
      async (bot, message) => {
        await bot.reply(message, 'Meow. :smile_cat:');
      }
    );
  });
}

The first part of the function configures the SlackAdapter with the secrets, and then specifies an endpoint for receiving messages. Then, once the controller is on, the bot will reply to any message containing "hello", "hi", or "hey" with "Meow. 😺".

Check out these specific parts in the app manifest:

package.json

{
  // ...
  "scripts": {
    "start": "node kittenbot.js",
    // ...
  },
  "engines": {
    "node": "16"
  },
  // ...
}

You can deploy a Node.js app directly from source with Cloud Run. The following will happen under the hood:

  • Cloud Run calls Cloud Build to build a container image (see Deploying from source code).
  • If a Dockerfile is present in the source code directory, Cloud Build uses it to build a container image.
  • Since it is not, Cloud Build calls Buildpacks to analyze the source and auto-generate a production-ready image.
  • Buildpacks detects the package.json manifest and builds a Node.js image.
  • The scripts.start field determines how the app is started.
  • The engines.node field determines the Node.js version of the container base image.
  • At deployment time, known security fixes are automatically applied.

You're ready to deploy the app!

8. Deploy the app

The Slack Events API uses webhooks to send outgoing messages about events. When you configure the Slack App, you'll have to provide a publicly accessible URL for the Slack API to ping.

Cloud Run is a good solution for hosting webhook targets. It allows you to use any language or runtime that you like and it provides concurrency, meaning your application will be able to handle much higher volume.

Retrieve your project ID

Define the PROJECT_ID environment variable:

PROJECT_ID=$(gcloud config get-value core/project)

Define your Cloud Run region

Cloud Run is regional, which means the infrastructure that runs your Cloud Run service is located in a specific region and is managed by Google to be redundantly available across all the zones within that region. Define the region you'll use for your deployment, for example:

REGION="us-central1"

Update the permissions

To be able to access secrets from Secret Manager, the Cloud Run service account needs to be granted the role roles/secretmanager.secretAccessor.

First, save the default service account into an environment variable:

SERVICE_ACCOUNT=$(gcloud iam service-accounts list \
  --format "value(email)" \
  --filter "displayName:Compute Engine default service account")

Confirm you have the email address saved:

echo $SERVICE_ACCOUNT

The service account has the following format: PROJECT_NUMBER-compute@developer.gserviceaccount.com.

Once you have the email address, enable the role for the service account:

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member serviceAccount:$SERVICE_ACCOUNT \
  --role roles/secretmanager.secretAccessor

Deploy the app

A Cloud Run service exposes a unique endpoint and automatically scales the underlying infrastructure to handle incoming requests.

Deploy the app to Cloud Run:

gcloud run deploy kittenbot \
  --source . \
  --platform managed \
  --region $REGION \
  --set-env-vars PROJECT_ID=$PROJECT_ID \
  --allow-unauthenticated
  • This creates a service called kittenbot.
  • The --source option uses the current folder to build the application with Cloud Build. Cloud Build automatically detects the presence of the package.json file.
  • You can alternatively define a default region with this command: gcloud config set run/region $REGION
  • You can also make Cloud Run managed by default with this command: gcloud config set run/platform managed
  • The --set-env-vars option sets the service environment variables.
  • The --allow-unauthenticated option makes the service publicly available.

The first time, you'll get a prompt to create an Artifact Registry repository. Tap Enter to validate:

Deploying from source requires an Artifact Registry Docker repository to store
built containers. A repository named [cloud-run-source-deploy] in region [REGION]
will be created.

Do you want to continue (Y/n)?

This launches the upload of your source code to the Artifact Registry repository and the build of your container image:

Building using Dockerfile and deploying container ...
* Building and deploying new service... Building Container.
  OK Creating Container Repository...
  OK Uploading sources...
  * Building Container... Logs are available at ...

Then, wait a moment until the build and deployment are complete. On success, the command line displays the service URL:

...
OK Building and deploying new service... Done.
  OK Creating Container Repository...
  OK Uploading sources...
  OK Building Container... Logs are available at ...
  OK Creating Revision... Creating Service.
  OK Routing traffic...
  OK Setting IAM Policy...
Done.
Service [SERVICE]... has been deployed and is serving 100 percent of traffic.
Service URL: https://SERVICE-PROJECTHASH-REGIONID.a.run.app

You can get the service URL with this command:

SERVICE_URL=$( \
  gcloud run services describe kittenbot \
  --platform managed \
  --region $REGION \
  --format "value(status.url)" \
)
echo $SERVICE_URL

The URL has the following format:

https://kittenbot-PROJECTHASH-REGIONID.a.run.app

This URL will be the base used to enable the Slack Events API. Copy it into your clipboard to use in the next step.

Your service is now live and publicly available! Go to the Cloud Run console for more information. fee46ea7c8483d56.png

You can see when the last revision was created, how much traffic it's receiving, and look at the logs. If we click into the logs, we can see that the Botkit controller is on and ready to receive messages.

Now let's start sending messages from our Slack channel!

9. Enable Slack events

As we saw earlier, our kittenbot code specifies a relative endpoint for our webhook target.

kittenbot.js

 const controller = new Botkit({
    webhook_uri: '/api/messages',
    adapter: adapter,
  });

This means, our full URL will be the base part from the Cloud Run service, plus /api/messages.

Enable Events

In the apps management page, go to the Events Subscriptions section on the sidebar, and toggle Enable Events on. Input your service URL:

PASTE_THE_SERVICE_URL/api/messages

5179a99339839999.png

Depending on how fast you type the URL in, it might try to verify before you're finished. If it fails, click "Retry."

Subscribe

Subscribe to all the message bot events.

1e8f200390908a9b.png

Click Save Changes at the bottom of the page. You will be prompted to Reinstall Your App. Go through the prompts and click Allow.

At this point, your bot is fully integrated! Messages in the workspace will trigger Slack to send messages to your Cloud Run service, which will in turn respond with a simple greeting.

10. Test your bot

Send a direct message to Kittenbot:

1f442dd7fd7b5773.png

Add kittenbot to your channel by entering "@kittenbot" and then clicking "Invite Them.":

9788d2167ce47167.png

Now everyone in your channel can interact with Kittenbot!

9c0d1d7907a51767.png

Each message in Slack triggers an event and sends an HTTP POST message to our Cloud Run service. If you take a look at the Cloud Run service logs, you'll see that each message corresponds to a POST entry in the log.

1ff0c2347bf464e8.png

The kittenbot responds to each message with "Meow. 😺".

11. Bonus - Update your bot

This optional section should take a few minutes. Feel free to skip directly to Cleanup.

Conversational Threads

We'd like for the bot to do more than just say "meow". But how do you deploy a new version of something running on Cloud Run?

Change directory into cloud-slack-bot/extra-credit:

cd ../extra-credit/

Open the current folder in Cloud Shell Editor:

cloudshell workspace .

Botkit offers the ability to handle conversations. With these, the bot can request more information and react to messages beyond a one word reply.

Define the dialog

First, see how conversational functions are defined at the end of the file:

// ...
const maxCats = 20;
const catEmojis = [
  ':smile_cat:',
  ':smiley_cat:',
  ':joy_cat:',
  ':heart_eyes_cat:',
  ':smirk_cat:',
  ':kissing_cat:',
  ':scream_cat:',
  ':crying_cat_face:',
  ':pouting_cat:',
  ':cat:',
  ':cat2:',
  ':leopard:',
  ':lion_face:',
  ':tiger:',
  ':tiger2:',
];

/**
 * Function to concatenate cat emojis
 * @param {number} numCats Number of cat emojis.
 * @return {string} The string message of cat emojis.
 */
function makeCatMessage(numCats) {
  let catMessage = '';
  for (let i = 0; i < numCats; i++) {
    // Append a random cat from the list
    catMessage += catEmojis[Math.floor(Math.random() * catEmojis.length)];
  }
  return catMessage;
}

/**
 * Function to create the kitten conversation
 * @param {Object} controller The botkit controller.
 * @return {Object} The BotkitConversation object.
 */
function createKittenDialog(controller) {
  const convo = new BotkitConversation('kitten-delivery', controller);

  convo.ask('Does someone need a kitten delivery?', [
    {
      pattern: 'yes',
      handler: async (response, convo, bot) => {
        await convo.gotoThread('yes_kittens');
      },
    },
    {
      pattern: 'no',
      handler: async (response, convo, bot) => {
        await convo.gotoThread('no_kittens');
      },
    },
    {
      default: true,
      handler: async (response, convo, bot) => {
        await convo.gotoThread('default');
      },
    },
  ]);

  convo.addQuestion(
    'How many would you like?',
    [
      {
        pattern: '^[0-9]+?',
        handler: async (response, convo, bot, message) => {
          const numCats = parseInt(response);
          if (numCats > maxCats) {
            await convo.gotoThread('too_many');
          } else {
            convo.setVar('full_cat_message', makeCatMessage(numCats));
            await convo.gotoThread('cat_message');
          }
        },
      },
      {
        default: true,
        handler: async (response, convo, bot, message) => {
          if (response) {
            await convo.gotoThread('ask_again');
          } else {
            // The response '0' is interpreted as null
            await convo.gotoThread('zero_kittens');
          }
        },
      },
    ],
    'num_kittens',
    'yes_kittens'
  );

  // If numCats is too large, jump to start of the yes_kittens thread
  convo.addMessage(
    'Sorry, {{vars.num_kittens}} is too many cats. Pick a smaller number.',
    'too_many'
  );
  convo.addAction('yes_kittens', 'too_many');

  // If response is not a number, jump to start of the yes_kittens thread
  convo.addMessage("Sorry I didn't understand that", 'ask_again');
  convo.addAction('yes_kittens', 'ask_again');

  // If numCats is 0, send a dog instead
  convo.addMessage(
    {
      text:
        'Sorry to hear you want zero kittens. ' +
        'Here is a dog, instead. :dog:',
      attachments: [
        {
          fallback: 'Chihuahua Bubbles - https://youtu.be/s84dBopsIe4',
          text: '<https://youtu.be/s84dBopsIe4|' + 'Chihuahua Bubbles>!',
        },
      ],
    },
    'zero_kittens'
  );

  // Send cat message
  convo.addMessage('{{vars.full_cat_message}}', 'cat_message');

  convo.addMessage('Perhaps later.', 'no_kittens');

  return convo;
}

This new conversation directs the thread based on the responses. For example, if the user responds "no" to the kitten question, it jumps to the message labeled "no_kittens", which is the end of that conversational thread.

Adding the dialog to the controller

Now that the conversation is defined, see how to add it to the controller:

async function kittenbotInit() {
  // ...
  const controller = new Botkit({
    webhook_uri: '/api/messages',
    adapter: adapter,
  });

  // Add Kitten Dialog
  const convo = createKittenDialog(controller);
  controller.addDialog(convo);

  // Controller is ready
  controller.ready(() => {
    // ...
  });
}

Trigger the dialog

Now that dialog is available for the controller to use, see how the conversation gets started when the chatbot hears "kitten", "kittens", "cat", or "cats":

  // ...

  controller.ready(() => {
    controller.hears(
      ['hello', 'hi', 'hey'],
      ['message', 'direct_message'],
      async (bot, message) => {
        await bot.reply(message, 'Meow. :smile_cat:');
        return;
      }
    );

    // START: listen for cat emoji delivery
    controller.hears(
      ['cat', 'cats', 'kitten', 'kittens'],
      ['message', 'direct_message'],
      async (bot, message) => {
        // Don't respond to self
        if (message.bot_id !== message.user) {
          await bot.startConversationInChannel(message.channel, message.user);
          await bot.beginDialog('kitten-delivery');
          return;
        }
      }
    );
    // END: listen for cat emoji delivery

    // ...
  });

  // ...

Update the app

Re-deploy the application to Cloud Run:

gcloud run deploy kittenbot \
  --source . \
  --platform managed \
  --region $REGION \
  --set-env-vars PROJECT_ID=$PROJECT_ID \
  --allow-unauthenticated

Try it out

eca12b3463850d52.png

Congratulations! You just updated a Slack bot running on Cloud Run to a new version.

Slash Commands

What if you don't want to have a conversation with the user? What if you'd prefer to simply trigger an action with one simple command?

Slack offers this functionality via Slash commands, which allow users to invoke your application by entering the command into the message box.

Enable Slack Slash Commands

  • Go to the Slash Commands section under Features on your Apps Management Page.
  • Click Create New Command.
  • Configure a /cats command with your kittenbot service URL. Remember to use the same endpoint you used to enable the Events API! This is your URL, plus '/api/messages'.

e34d393c14308f28.png

  • Follow the prompt to update your app and permissions.

Add Slash Commands to your Controller

See how a handler for slash commands was added inside the controller.ready function:

  // ...

  // Controller is ready
  controller.ready(() => {
    // ...

    // START: slash commands
    controller.on('slash_command', async (bot, message) => {
      const numCats = parseInt(message.text);
      const response = makeCatMessage(numCats);
      bot.httpBody({ text: response });
    });
    // END: slash commands
  });

  // ...

Try it out

Enter /cats plus a number to send the slash command. Eg: /cats 8

c67f6fe1ffcafec8.png

The bot will respond with 8 cats, only visible to you:

9c1b256987fd379a.png

12. Cleanup

Congratulations, you now have a Slack bot running on Cloud Run. Time for some cleaning of the resources used (to save on cost and to be a good cloud citizen).

Delete the project

You can delete the entire project, directly from Cloud Shell:

gcloud projects delete $PROJECT_ID

Alternatively, if you prefer to delete the different resources one by one, proceed to the next section.

Delete the deployment

gcloud run services delete kittenbot --region $REGION

Command output

Service [kittenbot] will be deleted.
Do you want to continue (Y/n)?  y
Deleted service [kittenbot].

Delete your client signing secret

gcloud secrets delete client-signing-secret

Command output

You are about to destroy the secret [client-signing-secret] and its
[1] version(s). This action cannot be reversed.
Do you want to continue (Y/n)?  y
Deleted secret [client-signing-secret].

Delete your bot token secret

gcloud secrets delete bot-token

Command output

You are about to destroy the secret [bot-token] and its [1]
version(s). This action cannot be reversed.
Do you want to continue (Y/n)?  y
Deleted secret [bot-token].

Delete the storage buckets

First, list the Google Cloud Storage buckets to get the bucket path:

gsutil ls

Command output

gs://[REGION.]artifacts.<PROJECT_ID>.appspot.com/
gs://<PROJECT_ID>_cloudbuild/

Now, delete the artifacts bucket:

gsutil rm -r gs://[REGION.]artifacts.${PROJECT_ID}.appspot.com/

Command output

Removing gs://[REGION.]artifacts.<PROJECT_ID>.appspot.com/...

Finally, delete the cloudbuild bucket:

gsutil rm -r gs://${PROJECT_ID}_cloudbuild/

Command output

Removing gs://<PROJECT_ID>_cloudbuild/...

13. Congratulations!

528302981979de90.png

You now know how to run a Slack bot on Cloud Run!

We've only scratched the surface of this technology and we encourage you to explore further with your own Cloud Run deployments.

What we've covered

  • Creating a bot custom integration in Slack
  • Securing your Slack secrets with Secret Manager
  • Deploying your Slack bot on Cloud Run

Next Steps

Learn More