DEV Community

Kazuhiro "Kaz" Sera
Kazuhiro "Kaz" Sera

Posted on • Edited on

Running Slack App on Cloudflare Workers

Recently, as a weekend hobby project, I created a Slack app development framework for Cloudflare Workers and Vercel Edge Functions.

In this article, I will explain the basics of Slack app development and how to use the following libraries:

If you are already familiar with Slack app development and want to try it out right away, it might be faster to read the documentation by following the links from the README.

"Slack App" development basics

Let me explain a little about the basics of "Slack app".

A "Slack app" is a mechanism for adding features such as bots that can converse in a Slack workspace, workflows that respond to events that occur within Slack (e.g., someone joining a channel), slash commands, shortcuts, etc.

There are two ways to communicate between this "Slack app" and Slack's API server:

  1. Request URL: Set up a public endpoint in advance on the app configuration page at https://api.slack.com/apps, receive event data from Slack, and respond with HTTP responses or Web API calls.
  2. Socket Mode: Receive event data from Slack via a WebSocket connection and respond with WebSocket messages or Web API calls.

The latter, Socket Mode, is useful for local development or when it is not possible to expose a public URL. In this article, I will guide you on how to develop a Slack app by setting the URL issued when deployed to Cloudflare Workers as the Request URL.

Digression: New platform feature for Workflow Builder

By the way, since the beginning of this year (2023), a new app development mechanism for extending the Workflow Builder, called the next-generation automation platform, has been added to the Slack platform.

In the Workflow Builder, there is one starting point called a trigger, and small processes called steps are connected in series to form a workflow. The next-generation platform allows you to create your own steps.

While it has become easier to automate within Slack, there are unsupported features while they're available with existing app development. For example, as of 2023, you cannot add slash commands to the entire workspace or set a home tab for users. Also, note that this next-generation platform feature is only available in paid Slack workspaces, and usage charges apply for workflow executions that exceed the free tier. For more details, please read the series of articles I wrote a few months ago: https://dev.to/seratch/series/21161

Wanna go without any libraries?

You might think, "if all I need to do with Cloudflare Workers' fetch function is receiving request data, at most calling Slack's Web API or other services, and returning an HTTP response, why not just implement it without bothering to use a library?"

Of course, you could implement all the things all yourself. However, considering the following points, it's hard to deny that implementing everything yourself every time can be quite a hassle.

Request verification

The Request URL of a Slack app is a URL that is publicly available on the internet, and the source IP address of Slack's requests is not always guaranteed to be the same. Therefore, there is a risk that a malicious third party could mimic the payload sent from Slack and make requests.

As a countermeasure against this, every HTTP request from Slack includes headers called x-slack-signature and x-slack-request-timestamp. It is recommended that the Slack app side verifies the x-slack-signature string using the Signing Secret shared only with Slack, and also checks that the x-slack-request-timestamp is not an old date and time. For more details, please read the official documentation.

To implement this as code that runs on Cloudflare Workers, you first need to read the request body as plain text and then implement the specified verification logic. Here is an example of such an implementation:

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // Request#text() on Cloudflare Workers prints warning
    const blobRequestBody = await request.blob();
    const rawBody: string = await blobRequestBody.text();
    if (await verifySlackRequest(env.SLACK_SIGNING_SECRET, request.headers, rawBody)) {
      // Valid request pattern
    } else {
      // Invalid signature or timestamp
      return new Response("invalid signature", { status: 401 });
    }
  },
};

async function verifySlackRequest(signingSecret: string, requsetHeaders: Headers, requestBody: string) {
  const timestampHeader = requsetHeaders.get("x-slack-request-timestamp");
  if (!timestampHeader) {
    return false;
  }
  const fiveMinutesAgoSeconds = Math.floor(Date.now() / 1000) - 60 * 5;
  if (Number.parseInt(timestampHeader) < fiveMinutesAgoSeconds) {
    return false;
  }
  const signatureHeader = requsetHeaders.get("x-slack-signature");
  if (!signatureHeader) {
    return false;
  }
  const textEncoder = new TextEncoder();
  return await crypto.subtle.verify(
    "HMAC",
    await crypto.subtle.importKey("raw", textEncoder.encode(signingSecret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]),
    fromHexStringToBytes(signatureHeader.substring(3)), textEncoder.encode(`v0:${timestampHeader}:${requestBody}`)
  );
}

function fromHexStringToBytes(hexString: string) {
  const bytes = new Uint8Array(hexString.length / 2);
  for (let idx = 0; idx < hexString.length; idx += 2) {
    bytes[idx / 2] = parseInt(hexString.substring(idx, idx + 2), 16);
  }
  return bytes.buffer;
}
Enter fullscreen mode Exit fullscreen mode

Various formats of payloads

Due to historical reasons, the format of the request body sent from Slack varies depending on the feature. For the Events API, it's Content-Type: application/json, but for other features, it's application/x-www-form-urlencoded, and the format of the contents differs between slash commands and other features. To accommodate all patterns, you need to implement something like this: https://github.com/seratch/slack-edge/blob/main/src/request/request-parser.ts

Various data patterns

Slack's developer platform supports a wide variety of events. And for each event, there are quite a few edge cases and diverse data patterns when you delve into the details. For example, even channel messages, which are the most basic data, can be just text or can include rich text and interactive components using blocks from Block Kit. When files, emails, etc. are attached, they are also added as attachments.

Also, this is a point for improvement in Slack's developer support, but there is a lack of comprehensive documentation on payloads (So sorry!).

For this reason, using a library like the one I developed this time and receiving support from TypeScript types, this area can be much easier.

Why don't you use Bolt?

Many of you may be aware of Bolt for JavaScript, an npm package that serves as a framework for developing Slack apps. It's an official full-stack Slack app development framework developed by the Slack Developer Relations team (of which I am a member) to free Slack app developers from the hassles I mentioned above.

However, unfortunately, Bolt for JS depends on axios and other Node.js APIs, and cannot be used directly with Cloudflare Workers. Although its name is for JavaScript, it's more of a framework for Node.js.

Also, while Bolt for JS is designed to be extended with an interface called Receiver, this aspect does not mesh well with the specification of processing requests as Cloudflare Workers' fetch function.

This also becomes a constraint when you want to run it as part of a web application using modern frameworks like Next.js or Nest.js. Since I often get questions about this, I explored possible ways on the weekends and eventually created a library to circumvent the constraints of the Receiver. At the moment, there are no plans to transfer this Bolt HTTP Runner into the 1st party SDKs. However, I intend to continue maintaining it as much as I can as an individual, so if you really want to try running it on Next.js or something similar, please use or fork this library.

I digressed a bit. As you can see, it's surprisingly difficult (or was) to properly create a Slack app on Cloudflare Workers. For example, this app that implements integration with ChatGPT is doing all the things I mentioned above on its own.

When I was playing with Cloudflare Workers on the weekend, I thought, "I want to make it easier to create Slack apps," and found the time to create the slack-cloudflare-workers and slack-edge libraries.

Initially, I put all the implementation into slack-cloudflare-workers, but along the way, I realized that most of it could also be used with Vercel Edge Functions (and presumably other similar runtimes), so I extracted that part as slack-edge and left only the OAuth flow-related implementation (such as managing installation information) using KV in slack-cloudflare-workers.

And while this library was originally a hobby project aimed solely at running on edge functions, as I progressed with the implementation, I was able to complete it as something that solved the challenges that Bolt for JS had been facing for a long time:

  • Streamlined Slack Web API client
    • Improved portability: Eliminated all dependencies on axios and other 3rd party modules as well as Node.js, implemented to depend only on the Fetch API
    • Implemented more precise TypeScript typing for API response data (especially the types of Block Kit components are much easier to use)
    • Support for Web APIs that are only available on the Next Generation Platform
    • Simplified code by completely removing support for deprecated APIs
  • Native support for async code execution to avoid 3-second timeouts
  • Resolved issues with TypeScript support in Bolt for JS
    • Changed the behavior and payload type of app.message listener to be more intuitive
    • By removing support for deprecated features such as old-style dialogs and button click events in attachments, the type of app.action listener arguments is more clearly defined
    • Designed to enable developers to more easily avoid the error saying you've called ack() method several times when matching multiple listeners
    • More strictly defined the types of returned values from listeners
  • Added features missing in Bolt for JS
    • Added middleware mechanism that runs before the authorize function
    • Added support for "Sign in with Slack" (OpenID Connect) feature
  • Added Socket Mode for local development without other dependency libraries (this is still experimental)
  • Added supports for emerging JavaScript/TypeScript runtimes such as Deno and Bun

For those who are not familiar with Slack app development, it may seem like a lot of things that aren't quite understandable, but the point is, "It's not just that it works on Cloudflare Workers, it's also improved in many ways over Bolt for JS." If you're going to create a Slack app with TypeScript in 2023, I think it's not an exaggeration to say that this is the easiest library to use. As I mentioned earlier, it's designed to work with Deno and Bun as well, so you can use it even if you want to run your Deno/Bun app on a container service (e.g., k8s, Amazon ECS), not just on edge functions.

Ideally, I'd like to incorporate the insights gained from this development into Bolt for JS, but most of them would result in breaking changes, and as the DevRel team, most of the members are focusing on the next generation platform, so the improvements I could add to the 1st party ones are still very limited. I hope to be able to do it little by little.

Getting Stared

The introduction might be quite long. Now let's finally create a Slack app. Most of the things are already written in the library's README, but I'll go over them with more details.

Install the wragnler CLI and create a project

Install wrangler, the CLI for developing Cloudflare Workers apps, and create a project.

npm install -g wrangler@latest
npx wrangler generate my-slack-app
Enter fullscreen mode Exit fullscreen mode

Proceed with the default specification set to Yes. The console should output something like this.

$ npx wrangler generate my-slack-app
 ⛅️ wrangler 3.2.0
------------------
Using npm as package manager.
▲ [WARNING] The `init` command is no longer supported. Please use `npm create cloudflare@2 -- my-slack-app` instead.

  The `init` command will be removed in a future version.


✨ Created my-slack-app/wrangler.toml
✔ Would you like to use git to manage this Worker? … yes
✨ Initialized git repository at my-slack-app
✔ No package.json found. Would you like to create one? … yes
✨ Created my-slack-app/package.json
✔ Would you like to use TypeScript? … yes
✨ Created my-slack-app/tsconfig.json
✔ Would you like to create a Worker at my-slack-app/src/index.ts? › Fetch handler
✨ Created my-slack-app/src/index.ts
✔ Would you like us to write your first test with Vitest? … yes
✨ Created my-slack-app/src/index.test.ts
npm WARN deprecated rollup-plugin-inject@3.0.2: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.
npm WARN deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead

added 160 packages, and audited 161 packages in 48s

29 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
✨ Installed @cloudflare/workers-types, typescript, and vitest into devDependencies

To start developing your Worker, run `cd my-slack-app && npm start`
To start testing your Worker, run `npm test`
To publish your Worker to the Internet, run `npm run deploy`
Enter fullscreen mode Exit fullscreen mode

Then, we'll add the library I developed, slack-cloudflare-workers:

cd my-slack-app
npm i slack-cloudflare-workers@latest
Enter fullscreen mode Exit fullscreen mode

It's still the default sample code, but if you run npm start, you should see the following console output.

$ npm start

> my-slack-app@0.0.0 start
> wrangler dev

 ⛅️ wrangler 3.2.0
------------------
wrangler dev now uses local mode by default, powered by 🔥 Miniflare and 👷 workerd.
To run an edge preview session for your Worker, use wrangler dev --remote
⎔ Starting local server...
[mf:wrn] The latest compatibility date supported by the installed Cloudflare Workers Runtime is "2023-07-10",
but you've requested "2023-07-15". Falling back to "2023-07-10"...
[mf:inf] Ready on http://127.0.0.1:8787/
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ [b] open a browser, [d] open Devtools, [l] turn off local mode, [c] clear console, [x] to exit                                           │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Enter fullscreen mode Exit fullscreen mode

If you access http://127.0.0.1:8787/ from your browser, you should see Hello World! there.

Slack app settings

Access https://api.slack.com/apps in your web browser. If you're not already logged into the Slack workspace you're planning to use for development in that browser, log in first.

There's a button in the upper right corner that says "Create New App". Click on it, select "From an app manifest", and create an app using the YAML configuration information after the video below (just set it as is for now, we'll change it later).

image

display_information:
  name: cf-worker-test-app
features:
  bot_user:
    display_name: cf-worker-test-app
    always_online: true
  shortcuts:
    - name: Hey Cloudflare Wokers!
      type: global
      callback_id: hey-cf-workers
      description: Say hi to CF Workers
  slash_commands:
    - command: /hey-cf-workers
      url: https://XXX.trycloudflare.com/
      description: Say hi to CF Workers
      usage_hint: Say hi to CF Workers
oauth_config:
  scopes:
    bot:
      - app_mentions:read
      - chat:write
      - chat:write.public
      - commands
settings:
  event_subscriptions:
    request_url: https://XXX.trycloudflare.com/
    bot_events:
      - app_mention
  interactivity:
    is_enabled: true
    request_url: https://XXX.trycloudflare.com/
    message_menu_options_url: https://XXX.trycloudflare.com/
Enter fullscreen mode Exit fullscreen mode

Once the app settings are created, you should see a button that says "Install to Workspace". Click on it and complete the installation to your Slack workspace.

image

Set env variables

Create a file named .dev.vars in the top directory of the project and set it with the following content.

# https://api.slack.com/apps/{your App ID}/general
# Settings > Basic Information > App Credentials > Signing Secret
SLACK_SIGNING_SECRET=....
# https://api.slack.com/apps/{your App ID}/install-on-team
# Settings > Install App > Bot User OAuth Token
SLACK_BOT_TOKEN=xoxb-...
SLACK_LOGGING_LEVEL=DEBUG
Enter fullscreen mode Exit fullscreen mode

As written in the comments,

  • SLACK_SIGNING_SECRET is in Settings > Basic Information > App Credentials > Signing Secret on the app management screen
  • SLACK_BOT_TOKEN is in Settings > Install App > Bot User OAuth Token on the app management screen

Please set these to the correct values and restart the npm start command. We will check if they are set correctly in the next step.

Now the minimum preparation is complete! Next, we will check the communication with Slack.

Check if the URL is reachable

Before we start writing code, let's check if the app we're about to develop can communicate correctly with Slack.

Edit src/index.ts

We'll flesh out the logic later, but for now, please paste the following minimum code into src/index.ts. It will no longer be consistent with src/index.test.ts, but let's hold off maintaining the tests for now (sorry! this article does not cover how to write tests).

import { SlackApp, SlackEdgeAppEnv } from "slack-cloudflare-workers";

export default {
  async fetch(
    request: Request,
    env: SlackEdgeAppEnv,
    ctx: ExecutionContext
  ): Promise<Response> {
    const app = new SlackApp({ env });

    return await app.run(request, ctx);
  },
};
Enter fullscreen mode Exit fullscreen mode

Configure Cloudflare Tunnel

As mentioned at the beginning, the Request URL needs to be a URL that is publicly available on the internet. The App Manifest YAML settings we set earlier had a placeholder of https://XXX.trycloudflare.com/. We will change this to the correct value.

Please install the command following the steps below to use Cloudflare Tunnel. For instructions other than macOS, please follow the official guide.

brew install cloudflare/cloudflare/cloudflared
cloudflared tunnel --url http://localhost:8787
Enter fullscreen mode Exit fullscreen mode

If it starts up correctly, the console should output something like the following.

$ cloudflared tunnel --url http://localhost:8787
2023-07-15T07:13:03Z INF Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps
2023-07-15T07:13:03Z INF Requesting new quick Tunnel on trycloudflare.com...
2023-07-15T07:13:04Z INF +--------------------------------------------------------------------------------------------+
2023-07-15T07:13:04Z INF |  Your quick Tunnel has been created! Visit it at (it may take some time to be reachable):  |
2023-07-15T07:13:04Z INF |  https://system-guam-mpg-adequate.trycloudflare.com                                        |
2023-07-15T07:13:04Z INF +--------------------------------------------------------------------------------------------+
2023-07-15T07:13:04Z INF Cannot determine default configuration path. No file [config.yml config.yaml] in [~/.cloudflared ~/.cloudflare-warp ~/cloudflare-warp /etc/cloudflared /usr/local/etc/cloudflared]
2023-07-15T07:13:04Z INF Version 2023.7.0
2023-07-15T07:13:04Z INF GOOS: darwin, GOVersion: go1.19.3, GoArch: amd64
2023-07-15T07:13:04Z INF Settings: map[ha-connections:1 protocol:quic url:http://localhost:8787]
2023-07-15T07:13:04Z INF cloudflared will not automatically update when run from the shell. To enable auto-updates, run cloudflared as a service: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/run-tunnel/as-a-service/
2023-07-15T07:13:04Z INF Generated Connector ID: 663b9432-4d49-44ef-a1c6-c4d923206a17
2023-07-15T07:13:04Z INF Initial protocol quic
2023-07-15T07:13:04Z INF ICMP proxy will use 192.168.68.112 as source for IPv4
2023-07-15T07:13:04Z INF ICMP proxy will use fe80::1424:857f:f8a9:7f56 in zone en0 as source for IPv6
2023-07-15T07:13:04Z INF Created ICMP proxy listening on 192.168.68.112:0
2023-07-15T07:13:04Z INF Created ICMP proxy listening on [fe80::1424:857f:f8a9:7f56%en0]:0
2023-07-15T07:13:04Z INF Starting metrics server on 127.0.0.1:61426/metrics
2023-07-15T07:13:05Z INF Registered tunnel connection connIndex=0 connection=68158100-10f4-40f6-891f-d5deaa033092 event=0 ip=198.41.192.227 location=NRT protocol=quic
Enter fullscreen mode Exit fullscreen mode

In this case, https://system-guam-mpg-adequate.trycloudflare.com is the available public URL.

Please enter this URL into Request URL under Features > Event Subscriptions on the app configuration page. If .dev.vars is set correctly, it should display Verified as shown below.

image

Don't forget to press the "Save Changes" button at the bottom right to save the settings. Please set this same URL on the following screens:

  • Features > Interactivity & Shortcuts > Request URL
  • Features > Interactivity & Shortcuts > Select Menus > Options Load URL
  • Features > Slash Commands > Click the pencil icon for /hey-cf-workers > Request URL

Please be careful not to forget to press the save button on each screen.

Add functionalites to the app

We have the minimum preparations in place, but this app has no implementation yet.

Try running the added slash command /hey-cf-workers. You should get an error with the "dispatch_failed" code.

If you look at the console where you started the app with npm start, you should see output like the following.

*** Received request body ***
 {
  "token": "zzz",
  "team_id": "T03E94MJU",
  "team_domain": "seratch",
  "channel_id": "CHE2DUW5V",
  "channel_name": "dev",
  "user_id": "U03E94MK0",
  "user_name": "seratch",
  "command": "/hey-cf-workers",
  "text": "",
  "api_app_id": "A05H6MVTXA6",
  "is_enterprise_install": "false",
  "response_url": "https://hooks.slack.com/commands/T03E94MJU/xxx/yyy",
  "trigger_id": "111.222.xxx"
}
*** No listener found ***
{ .... }
[mf:inf] POST / 404 Not Found (263ms)
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ [b] open a browser, [d] open Devtools, [l] turn off local mode, [c] clear console, [x] to exit                                           │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Enter fullscreen mode Exit fullscreen mode

The very simple code to respond to /hey-cf-workers can be as follows.

const app = new SlackApp({ env });
app.command("/hey-cf-workers",
  // "ack" function must complete within 3 seconds
  async (_req) => {
    // This text can be sent as an ephemeral message
    return "What's up?";
  },
  // "lazy" function can do whatever you want to do asynchronously
  async (req) => {
    await req.context.respond({
      text: "Hey! This is an async response!"
    });
  }
);
return await app.run(request, ctx);
Enter fullscreen mode Exit fullscreen mode

If everything is working correctly, you should not get an error this time, and you should see two ephemeral messages displayed.

You might be wondering, "Why are we passing two functions to app.command() here?"

This mechanism of executing two functions, ack/lazy, was originally something I devised for Function-as-a-Service environments like AWS Lambda. Slack's event delivery requires 3rd party apps to respond within 3 seconds, but in AWS Lambda, once a function triggered by an HTTP request returns an HTTP response, the runtime can be terminated, so you can't perform time-consuming asynchronous processing after returning a response. As a solution for it, Bolt for Python's Lazy Listeners were designed to handle asynchronous processing in a separate AWS Lambda execution with the same input.

However, with Cloudflare Workers, there is already a mechanism called the Fetch event's waitUntil command, so there's no need for such a workaround, and the same thing can be achieved with a simple implementation. THIS IS AMAZING!!!

By the way, when it's clear that the lazy processing is not needed, you can omit the second function. However, if you go with this implementation, make sure that all processing finishes within 3 seconds. If that's always true, move the time-consuming processing to the second asynchronous execution function.

app.command("/hey-cf-workers", async () => {
  return "What's up?";
});
Enter fullscreen mode Exit fullscreen mode

Slash commands and Block Kit interations

Let's improve the above slash command example further by adding a message with a Block Kit button, and also add a process for when the button is pressed.

// Post a message with a button in response to a slash command invocation
app.command("/hey-cf-workers", async () => {
  return {
    response_type: "ephemeral",
    text: "Click this!",
    blocks: [
      {
        "type": "section",
        "block_id": "button",
        "text": { "type": "mrkdwn", "text": "Click this!" },
        "accessory": {
          "type": "button",
          "action_id": "button-action", // app.action will match this string
          "text": { "type": "plain_text", "text": "Button" },
          "value": "hidden value",
        }
      }
    ],
  }
});
app.action("button-action",
  async () => { }, // just acknowledge the request
  async ({ context, payload }) => {
    if (context.respond) {
      // context.respond sends a message using response_url
      await context.respond({ text: "You've clicked it!" });
    } else {
      await context.client.views.open({
        trigger_id: payload.trigger_id,
        view: {
          type: "modal",
          callback_id: "test-modal",
          title: { type: "plain_text", text: "Button click" },
          close: { type: "plain_text", text: "Close" },
          blocks: [
            {
              "type": "section",
              "text": { "type": "plain_text", "text": "You've clicked it!" }
            }
          ],
        },
      });
    }
  },
);
Enter fullscreen mode Exit fullscreen mode

This code overwrites the original message where the button was placed.

If you set the response_type to "in_channel", you can post it as a message that can be seen by other people in the channel. The context.respond is a utility that uses response_url to send a message. There is also an example of opening a modal that just conveys the same text in the else clause of the above code, so please try that as well if you like.

Shortcuts and modal data submissions

Next, I will introduce an example where a modal opens when a global shortcut is executed, and input validation is performed when data is sent. An example of a search function using external data is also included.

// From a global shortcut execution to modal data submission
app.shortcut("hey-cf-workers",
  async () => { }, // just acknowledge the request
  async ({ context, body }) => {
    await context.client.views.open({
      trigger_id: body.trigger_id,
      view: {
        type: "modal",
        callback_id: "test-modal", // app.view will match this string
        title: { type: "plain_text", text: "Test modal" },
        submit: { type: "plain_text", text: "Submit" },
        close: { type: "plain_text", text: "Cancel" },
        blocks: [
          {
            "type": "input",
            "block_id": "memo",
            "label": { "type": "plain_text", "text": "Memo" },
            "element": { "type": "plain_text_input", "multiline": true, "action_id": "input" },
          },
          {
            "type": "input",
            "block_id": "category",
            "label": { "type": "plain_text", "text": "Category" },
            "element": {
              "type": "external_select",
              "action_id": "category-search", // app.options will match this string
              'min_query_length': 1,
            },
          }
        ],
      },
    });
  }
);
// Respond to a keyword search using the external_select block element
// This listener must complete to return search results within 3 seconds
// Therefore, having lazy listeners is not supported
app.options("category-search", async ({ payload }) => {
  console.log(`Query: ${payload.value}`);
  // TODO: you will filter the options by the keyword
  return {
    options: [
      {
        "text": { "type": "plain_text", "text": "Work" },
        "value": "work"
      },
      {
        "text": { "type": "plain_text", "text": "Family" },
        "value": "family"
      },
      {
        "text": { "type": "plain_text", "text": "Running" },
        "value": "running"
      },
      {
        "text": { "type": "plain_text", "text": "Random thoughts" },
        "value": "random-thought"
      },
    ],
  };
});
// Handle modal data submissions
app.view("test-modal",
  async ({ payload }) => {
    // Do all the things within 3 seconds here
    const stateValues = payload.view.state.values;
    console.log(JSON.stringify(stateValues, null, 2));
    const memo = payload.view.state.values.memo.input.value!;
    if (memo.length <= 10) {
      // validation errors
      return {
        response_action: "errors",
        errors: { "memo": "The memo must be longer than 10 characters" }
      }
    }
    // Update the modal view as "completed"
    return {
      response_action: "update",
      view: {
        type: "modal",
        callback_id: "test-modal",
        title: { type: "plain_text", text: "Test modal" },
        close: { type: "plain_text", text: "Close" },
        blocks: [
          {
            "type": "section",
            "text": { "type": "plain_text", "text": "Thank you!" }
          }
        ],
      },
    };
    // If you just want to close the modal, no need to return anything here
  },
  async (req) => {
    // you can do async jobs here
  }
);
Enter fullscreen mode Exit fullscreen mode

Event delivery when someone mentions your app's bot

Lasty, here is an example of the Events API. It's the "app_mention" event, which notifies when the bot user of the app is mentioned.

// In the Events API, there are no requirements that must be responded to synchronously within 3 seconds, so you can pass only the lazy function.
app.event("app_mention", async ({ context }) => {
  await context.say({
    text: `Hey <@${context.userId}>, is there anything I can help you with?`
  });
});
Enter fullscreen mode Exit fullscreen mode

If you want to create an app that responds to other events, you can specify the events from this list in the app configuration pages and reinstall it in the workspace to be able to receive them.

The payload argument of the listener function is resolved to the data type of the specified event payload:

image

If there are any deficiencies or errors, please report them to this issue tracker and I will correct them. I appreciate your help!

Deploy to Cloudflare

That's it for the implementation of the features. Now, let's deploy this app to the Cloudflare Workers environment and see it in action.

wrangler deploy
Enter fullscreen mode Exit fullscreen mode

If the deployment is successful, the console will output something like this:

$ wrangler deploy
 ⛅️ wrangler 3.2.0
------------------
Total Upload: 131.16 KiB / gzip: 19.86 KiB
Uploaded my-slack-app (1.42 sec)
Published my-slack-app (3.77 sec)
  https://my-slack-app.YOURS.workers.dev
Current Deployment ID: f0d7708e-edde-4b29-9bc4-ac4db11fcdbd
Enter fullscreen mode Exit fullscreen mode

If you want to switch the app you just tested locally to production as is, it's a bit of a hassle, but you need to switch all four of the following settings that were previously set with the trycloudflare.com URL to https://my-slack-app.YOURS.workers.dev.

  • Features > Event Subscriptions > Request URL
  • Features > Interactivity & Shortcuts > Request URL
  • Features > Interactivity & Shortcuts > Select Menus > Options Load URL
  • Features > Slash Commands > Click the pencil icon for /hey-cf-workers > Request URL

If you plan to create a production app with a new separate configuration, it would be easier to replace the https://XXX.trycloudflare.com/ part of the App Manifest YAML configuration file with the issued production URL, and then create the app.

As the last step, tell the Signing Secret and Bot User OAuth Token set in .dev.vars to the production secrets. For this step as well, if you are creating a new production app separately, the values will be different, so please obtain them from the Slack app management screen after installing them in your workspace and specify those values.

wrangler secret put SLACK_SIGNING_SECRET
wrangler secret put SLACK_BOT_TOKEN
Enter fullscreen mode Exit fullscreen mode

The console outputs will look like this:

$ wrangler secret put SLACK_SIGNING_SECRET
 ⛅️ wrangler 3.2.0
------------------
✔ Enter a secret value: … ********************************
🌀 Creating the secret for the Worker "my-slack-app"
✨ Success! Uploaded secret SLACK_SIGNING_SECRET

$ wrangler secret put SLACK_BOT_TOKEN
 ⛅️ wrangler 3.2.0
------------------
✔ Enter a secret value: … ******************************************************
🌀 Creating the secret for the Worker "my-slack-app"
✨ Success! Uploaded secret SLACK_BOT_TOKEN
Enter fullscreen mode Exit fullscreen mode

Once all the settings are complete, please check to see if it works the same way when switched. Perhaps, the app running in the production environment can respond much more quickly.

More advanced topics

Let me briefly touch on some more advanced topics. I may publish more blog posts for these topics in the near future.

Enable the OAuth flow

The steps above are for creating the simplest type of app, one that only operates in a single workspace.

This article has gotten a bit long, so I'll explain the details in another article, but if you want the same app to work in other workspaces, you can provide your own OAuth flow for installation (so far, we've been installing using the installation flow provided by the Slack app management screen, right? This is about switching to your own), and by properly managing the installation information for multiple workspaces, you can distribute your app to other workspaces.

Also, by implementing the OAuth flow, you can not only get bot permissions like this article does, but also receive permissions to view or operate some information from each user in the workspace, allowing you to change statuses, post messages, access search results, etc. on behalf of the user (if you install from the management screen without implementing the OAuth flow, you can only issue your own user token).

The slack-cloudflare-workers library already provides a feature to implement Slack's OAuth flow using Cloudflare's KV. For more details, please refer to this document and the code examples.

Sign in with Slack

The library also supports the feature of logging into external sites with a Slack account (compatible with OpenID Connect). There is currently no documentation for this feature, so I plan to first enhance the documentation and then introduce how to implement it in some form.

Run your app outside of edge functions

I mentioned this briefly in the middle of this article, but this slack-edge library is also compatible with Deno and Bun. Also, as of the time of this article's posting, it's still in the experimental stage, but it does support socket mode to some extent.

If you like this library and want to use it not only for edge functions but also for developing apps that run on container services, running it on Deno/Bun might be a good idea.

There are some sample codes here, so please refer to them.

Wrap up

"Will anyone read this to the end...?" This article has become quite long. Thank you so much for reading this far!

Cloudflare Workers are truly convenient. Especially, the Fetch event's waitUntil command is absolutely fantastic! I almost thought it was a feature designed specifically for Slack apps (joking). Also, the development experience is very smooth, and I think it can be used for various purposes, not limited to Slack apps.

As for slack-edge and slack-cloudflare-workers, they are completely my personal hobby projects. So, I'm not sure how much time I can devote to them, but I hope to continue improving them at my own pace. Currently, they only support KV, so I want to add support for other data stores as well. Please feel free to give me any questions or feedback. Also, if you create something interesting using these, I'd love to hear about it!

That's all for now!

Top comments (0)