Blog What we do Support Community
Login Sign up

Building a serverless Slack bot using Cloudflare Workers

by Rita Kozlov.

Our Workers platform can be used for a ton of useful purposes: for A/B (multivariate) testing, storage bucket authentication, coalescing responses from multiple APIs, and more. But Workers can also be put to use beyond "HTTP middleware": a Worker can effectively be a web application in its own right. Given the rise of 'chatbots', we can also build a Slack app using Cloudflare Workers, with no servers required (well, at least not yours!).

workers_slack_bot2-1

What are we Building?

We're going to build a Slack bot (as an external webhook) for fetching the latest stock prices.

This Worker could also be adapted to fetch open issues from GitHub's API; to discover what movie to watch after work; anything with a REST API you can make query against.

Nevertheless, our "stock prices bot":

  • Uses the Alpha Vantage API to fetch stock prices
  • Caches a map of the top equities to their public identifiers, so you can request /stocks MSFT as a shorthand.
  • Leverages Cloudflare's cache to minimize the need to hit the API on every invocation, whilst still serving recent price data.

Using the cache allows you to improve your bot's response times across all invocations of your Worker. It's also polite to reduce redundant calls to an API (less you get rate limited!) where possible, so it's win-win.

Prerequisites

In order to get started, you'll need:

  • A Cloudflare account, with Workers enabled (see note)
  • Some basic programming experience.
  • An existing Slack workspace. If you don't have one set up, follow Slack's helpful guide to get one started.

Note: You can enable Workers via the "Workers" app in the Cloudflare dashboard.

Creating our Worker

We'll get our Worker up and running first, and test it outside of Slack before wiring it up. Our Worker needs to:

  1. Handle the incoming webhook (a HTTP POST request) from Slack, including authenticating it is actually from Slack.
  2. Parsing the requested symbol from the user's message (the webhook body).
  3. Making a request to the Alpha vantage API, as well as handling any errors that arise (invalid symbol, API unreachable, etc).
  4. Building our response, and sending that back to Slack within 3s (the timeout).

We'll step through each requirement and its associated code, deploy the Worker to a route, and then connect it to Slack.

Handling the Webhook

Like all Cloudflare Workers, we need to add a hook for the fetch event and attach the entry point to our Worker. Our slackWebhookHandler function will then be responsible for triggering the rest of our logic and returning a Response to Slack's request.

// SLACK_TOKEN is used to authenticate requests are from Slack.
// Keep this value secret.
const SLACK_TOKEN = "SLACKTOKENGOESHERE"
const BOT_NAME = "Stock-bot 🤖"
const ALPHA_VANTAGE_KEY = ""


let jsonHeaders = new Headers([["Content-Type", "application/json"]])

addEventListener("fetch", event => {
  event.respondWith(slackWebhookHandler(event.request))
})

/**
 * simpleResponse generates a simple JSON response
 * with the given status code and message.
 *
 * @param {Number} statusCode
 * @param {String} message
 */
function simpleResponse(statusCode, message) {
  let resp = {
    message: message,
    status: statusCode
  }

  return new Response(JSON.stringify(resp), {
    headers: jsonHeaders,
    status: statusCode
  })
}

/**
 * slackWebhookHandler handles an incoming Slack
 * webhook and generates a response.
 * @param {Request} request
 */
async function slackWebhookHandler(request) {
  // As per: https://api.slack.com/slash-commands
  // - Slash commands are outgoing webhooks (POST requests)
  // - Slack authenticates via a verification token.
  // - The webhook payload is provided as POST form data
  
  if (request.method !== "POST") {
    return simpleResponse(
      200,
      `Hi, I'm ${BOT_NAME}, a Slack bot for fetching the latest stock prices`
    )
  }

  try {
    let formData = await request.formData()
    if (formData.get("token") !== SLACK_TOKEN) {
      return simpleResponse(403, "invalid Slack verification token")
    }
    
    let parsed = parseMessage(formData)

  
    let reply = await stockRequest(parsed.stock)
    let line = `Current price (*${parsed.stock}*): 💵 USD $${reply.USD} (Last updated on ${reply.updated}).`

    return slackResponse(line)
  } catch (e) {
    return simpleResponse(
      200,
      `Sorry, I had an issue retrieving anything for that stock: ${e}`
    )
  }
}


Our handler is fairly straightforward:

  1. If the incoming request was not a POST request (i.e. what the Slack webhook is), we return some useful information.
  2. For POST requests, we check that the token provided in the POST form data matches ours: this is how we validate the webhook is coming from Slack itself.
  3. We then parse the user message, make a request to fetch the latest price, and construct our response.
  4. If anything fails along the way, we return an error back to the user.

While we were at it, we also built a couple of useful helper functions: simpleResponse, which is used for generating errors back to the client, and slackResponse (which we'll look at later) for generating responses in Slack's expected format.

The constants SLACK_TOKEN, BOT_NAME, and ALPHA_VANTAGE_KEYdon't need to be calculated on every request, and so we've made them global, outside our request handling logic.

Note: Caching (often called "memoizing") static data outside of our request handler in a Worker allows it to be re-used across requests, should the Worker instance itself be re-used. Although the performance gain in this case is negligible, it's good practice, and doesn't hinder our Workers' readability.

Parsing the User Message

Our next step is to parse the message sent in the POST request from Slack. This is where we capture the requested equity, ready to be passed to the Alpha Vantage API.

To receive the correct stock information from the API, we will parse the input we received from Slack, so that we can pass it to the API to collect information about the stock we are looking for.

/**
 * parseMessage parses the selected stock from the Slack message.
 *
 * @param {FormData} message - the message text
 * @return {Object} - an object containing the stock name.
 */
function parseMessage(message) {
  // 1. Parse the message (trim whitespace, uppercase)
  // 2. Return stock that we are looking for
  return {
    stock: message.get("text").trim().toUpperCase()
  }
}

Let's step through what we're doing:

  1. We pass in our FormData containing the user's message
  2. We clean it up (i.e. trim surrounding whitespace, convert to uppercase)

In the future, if we want to parse more values from the bot (maybe get the currency the user is interested in, or the date), we easily can add additional values to the object.

Now that we have the stock we are looking for, we can move on to making the API request!

Making the API Request

We want to make sure our bot doesn't have to make a request to the Alpha Vantage API unnecessarily: if we had thousands of users every minute, there's no need to fetch the (same) price every time. We can fetch it once (per Cloudflare PoP), store in the Cloudflare cache for a short period of time (say 1 minute), and serve users that cached copy. This is win-win: our bot responds more quickly and we're kinder to the API we're consuming).

For customers on the Enterprise plan, you may also use the cacheTtlByStatus functionality, which allows you to set different TTLs based on the response status. This way, if you get an error code, you may only cache it for 1 second, or not cache at all, so that subsequent requests (once the API has been updated) will not fail as well.

Given the requested stock, we'll make a HTTP request to the API, confirm we got an acceptable response (HTTP 200), and then return an object with the fields we need:


    let resp = await fetch(
      endpoint,
      { cf: { cacheTtl: 60} } // Cache our responses for 60s.
    )

The API output provides us with two things: the metadata and a line per time interval. For the purposes of our bot, we are going to keep the latest interval provided by the API and discard the rest. On future iterations, we may also call the monthly endpoint, and provide information on monthly highs and lows for comparison, but we will keep it simple for now.

We are going to be using the intradaily intervals API endpoint provided by Alpha Vantage. This will allow us to cache the response for each individual stock lookup, such that the next person to make a call to our bot may receive a cached version faster (and help us avoid getting rate limited by the API). Here, we will be choosing to optimize for having the latest data, rather than caching for longer periods of time.

You can see that we will be asking the API for a 1min interval.

curl -s "https://www.alphavantage.co/query?function=TIME_SERIES_INTRADAY&symbol=MSFT&interval=1min&apikey=KEY" | jq

A single output block looks like this:

{
      "1. open": "99.8950",
      "2. high": "99.8950",
      "3. low": "99.8300",
      "4. close": "99.8750",
      "5. volume": "34542"
    },

To only get the last 1 minute interval, we will grab the last value that is provided to us by the API, and get the currently open price for it.

/**
 * stockRequest makes a request to the Alpha Vantage API for the
 * given stock request.
 * Endpoint:  https://www.alphavantage.co/documentation/*
 * @param {string} stock - the stock to fetch the price for
 * @returns {Object} - an Object containing the stock, price in USD.
 */
async function stockRequest(stock) {
  let endpoint = new URL("https://www.alphavantage.co/query")

  endpoint.search = new URLSearchParams({"function" : "TIME_SERIES_INTRADAY" ,
    "interval" : "1min",
    "apikey": ALPHA_VANTAGE_KEY,
    "symbol": stock
  })


  try {
    let resp = await fetch(
      endpoint,
      { cf: { cacheTtl: 60} } // Cache our responses for 60s.
    )

    if (resp.status !== 200) {
      throw new Error(`bad status code from Alpha Vantage: HTTP ${resp.status}`)
    }
    
    let data = await resp.json()
    let timeSeries = data["Time Series (1min)"]

    // We want to use the last value (1 minute interval) that is provided by the API
    let timestamp = Object.keys(timeSeries)[1]
    let usd = timeSeries[timestamp]["1. open"]
    
    let reply = {
      stock: stock,
      USD: usd,
      updated: timestamp
    }
    
    return reply
  } catch (e) {
    throw new Error(`could not fetch the selected symbol: ${e}`)
  }
}

We build an object representing our reply. We're also careful to handle any errors should we get a bad response back from the API: be it a non-HTTP 200 response, or a non-JSON response body. When relying on a third-party service/API, any assumptions you make about the format or correctness of a response that could cause an exception to be thrown if broken—such as calling resp.json() on HTML body—must be accounted for.

Additionally, note that subrequests will respect the SSL Mode you have for your entire zone. Thus, if the SSL mode is set to flexible, Cloudflare will try to connect to the API over port 80 and over HTTP, and the request will fail (you will see a 525 error).

Responding to Slack

Slack expects responses in two possible formats: a plain text string, or a simple JSON structure. Thus, we need to take our reply and build a response for Slack.

/**
 * slackResponse builds a message for Slack with the given text
 * and optional attachment text
 *
 * @param {string} text - the message text to return
 */
function slackResponse(text) {
  let content = {
    response_type: "in_channel",
    text: text,
    attachments: []
  }

  return new Response(JSON.stringify(content), {
    headers: jsonHeaders,
    status: 200
  })
}

The corresponding part of our slackWebhookHandler deals with taking our reply object and passing it to slackResponse -

    let reply = await stockRequest(parsed.stock)
    let line = `Current price (*${parsed.stock}*): 💵 USD $${reply.USD} (Last updated on ${reply.updated}).`

    return slackResponse(line)

This returns a response to Slack that looks like this:

{
  "response_type": "in_channel",
  "text": "Current price (*MSFT*): 💵 USD $101.8300 (Last updated on 2018-06-20 11:52:00).",
  "attachments": []
}

Configuring Slack & Testing Our Bot

With our bot ready, let's configure Slack to talk to it for our chosen slash-command. First, log into Slack and head to the app management dashboard.

You'll then want to click "Create an App" and fill in the fields, including nominating which workspace to attach it to:

Slack: Create App modal

We'll then want to set it up as a Slash Command:

Slack: Select Slash Command

Fill in the details: the request URL is the most important, and will reflect the route you've attached your Worker to. In our case, that's https://bots.example.com/stockbot/stocks

Slack: Create New Command

Fetch your App Credentials from the Basic Information tab: specifically, the Verification Token.

Slack: Fetch App Credentials

Paste that value into our Worker bot as the value of our SLACK_TOKEN variable:

// SLACK_TOKEN is used to authenticate requests are from Slack.
// Keep this value secret.
let SLACK_TOKEN = "PUTYOURTOKENHERE"

Before hooking our bot up to Slack, we can test to make sure it responds correctly. We can emulate a request from Slack by making a POST request with token and message text via curl -

# Replace with the hostname/route your Worker is running on
➜  ~  curl -X POST -F "token=SLACKTOKENGOESHERE" -F "text=MSFT" "https://bots.example.com/stockbot/stock"
{"response_type":"in_channel","text":"Current price (MSFT): 💵 USD $101.7300","attachments":[]}

A correct response should net us the expected reply. If we intentionally send an invalid token instead, our bot should reply accordingly:

➜  ~  curl -X POST https://bots.example.com/stockbot/stock
 -F "token=OBVIOUSLYINCORRECTTOKEN" -F "text=MSFT"
{"message":"invalid Slack verification token","status":403}%

... or an invalid symbol:

➜  ~  curl -X POST https://bots.example.com/stockbot/stock -F "token=SLACKTOKENGOESHERE" -F "text=BADSYMBOL"
{"message":"Sorry, I had an issue retrieving anything for that symbol: Error: could not fetch the selected symbol: Error: bad status code from Alpha Vantage: HTTP 404","status":200}%

If you're running into issues, make sure your token is correct (case-sensitive), and that the stock you're after exists on Alpha Vantage. Beyond that however, we can now install the app to our Workspace (Slack will ask you to authorize the bot):

Slack: Add to Workspace

We can now call our bot via the slash command we assigned to it!

stockbot

Wrap

With our Cloudflare Worker, we were able to put together a useful chat bot that responds quickly (within the 3 seconds Slack allows) thanks to Cloudflare's cache. We're also kind on the Alpha Vantage API, since we don't have to reach back out to it for a given symbol if we just fetched it recently.

We look forward to hearing what others have built using Workers!

Thank you

Thank you to Matt Silverlock for his contribution to this post, and Cloudflare.


If you have a worker you'd like to share, or want to check out workers from other Cloudflare users, visit the “Recipe Exchange” in the Workers section of the Cloudflare Community Forum.

comments powered by Disqus