Slackbot On The Edge

While there are some examples of how to setup a slackbot that runs on
Lambdas, there wasn’t much available for running in an edge
environment.

The Edge Runtime has a few limitations (or differences) - not the
least of which is that they often are not using the Node APIs. Rather
they will often use v8 directly and make available the Fetch API.

When using the app directory, API routes will run in this reduced
environment which left me having to implement a new Slack Bolt
Receiver.

The example below takes into account this difference by using the
next-connect library’s support for NextJS’s edge
runtime.

The receiver

The Bolt library allows you to create a custom receiver.
We can store the Bolt app object in the init call and we use that to
process the request. The way it typically works is that the receiver
does the work of interpreting the request and packaging the responses.
This is particularly helpful here because the edge router, as mentioned
earlier, uses the Fetch API and not the Node
https
APIs.

In the solution below we use the fantastic next-connect
library to setup our handlers in the edge runtime.

First, we instatiate an edge
router

1// SETUP THE ROUTER - note taht we use NextConnect's EdgeRouter//  to handle the routing for us.  This uses the fetch API to//  handle requests and responses.this.router = createEdgeRouter<Request, RequestContext>()
2// ADD ALL THE ENDPOINT HANDLERS - We pass on all the routes//  that are going to be handled by the Bolt app to the routerObject.values(endpoints).forEach((endpoint) => {
3    this.router.post(endpoint, this.requestHandler.bind(this))
4})

Then we create a request handler that will do the following:

Handle URL url_verification

1if (body?.type === 'url_verification') {
2    return new Response(
3        JSON.stringify({ challenge: body?.challenge }),        { status: 200 }
4    )
5}

Handle signature validation

1if (!this.validateSignature(req)) {
2    return new Response('', { status: 401 })
3}

(see below for implementation of validateSignature)

Process incoming events

1await this.bolt?.processEvent(event)

Accept and send
acknlowdgement responses

1if (storedResponse !== undefined) {
2    this.logger.debug('stored response sent')
3    return new Response(storedResponse, {
4        status: 200,        headers: {
5            'content-type': 'application/json',        },    })
6}
7return new Response('', { status: 200 })

Everything Put Together

1import crypto from 'crypto'import tsscmp from 'tsscmp'import { Logger, ConsoleLogger, LogLevel } from '@slack/logger'import { createEdgeRouter } from 'next-connect'import App from '@slack/bolt/dist/App'import {
2    ReceiverMultipleAckError,    ErrorCode,    CodedError,} from '@slack/bolt/dist/errors'import {
3    Receiver,    ReceiverEvent,} from '@slack/bolt/dist/types'import { StringIndexed } from '@slack/bolt/dist/types/helpers'import { RequestContext } from 'next/dist/server/base-server'import { EdgeRouter } from 'next-connect/dist/types/edge'/** * Returns the value of a header on a request, or undefined if it's not present. * @param request The incoming request * @param headerName The name of the header to retrieve * @returns The value of the header, or undefined if it's not present */function getRequestHeader(
4    request: Request,    headerName: string): string | undefined {
5    const headerValue = request.headers.get(headerName)
6    if (headerValue === null) {
7        return undefined    }
8    return headerValue
9}
10/** *  Returns the value of a header on a request, parsed as a number, or undefined if it's not present. * @param request The incoming request * @param headerName The name of the header to retrieve * @returns The value of the header, parsed as a number, or undefined if it's not present */function getRequestHeaderAsNumber(
11    request: Request,    headerName: string): number | undefined {
12    const headerValue = getRequestHeader(request, headerName)
13    if (headerValue === undefined) {
14        return undefined    }
15    return Number(headerValue)
16}
17export interface AppRouterBoltReceiverOptions {
18    signingSecret: string    logger?: Logger
19    logLevel?: LogLevel
20    endpoints?: string | { [endpointType: string]: string }
21    customPropertiesExtractor?: (request: Request) => StringIndexed
22}
23/** * Receives HTTP requests with Events, Slash Commands, and Actions. Works with * the Next App Router version of the NextJS framework.  Unlike other receivers, * this one is meant to handle requests and responses that use the Fetch API. */export class AppRouterBoltReceiver implements Receiver {
24    private bolt: App | undefined    private logger: Logger
25    private signingSecret: string    public router: EdgeRouter<Request, RequestContext>    private customPropertiesExtractor: (request: Request) => StringIndexed
26    public constructor({
27        logger = undefined,        logLevel = LogLevel.INFO,        signingSecret,        endpoints = {
28            events: '/api/slack/events',            commands: '/api/slack/commands',            actions: '/api/slack/actions',            options: '/api/slack/options',        },        customPropertiesExtractor = (_req) => ({}),    }: AppRouterBoltReceiverOptions) {
29        this.signingSecret = signingSecret
30        // SETUP THE LOGGER        if (typeof logger !== 'undefined') {
31            this.logger = logger
32        } else {
33            this.logger = new ConsoleLogger()
34            this.logger.setLevel(logLevel)
35        }
36        // SETUP THE ROUTER - note taht we use NextConnect's EdgeRouter        //  to handle the routing for us.  This uses the fetch API to        //  handle requests and responses.        this.router = createEdgeRouter<Request, RequestContext>()
37        // ADD ALL THE ENDPOINT HANDLERS - We pass on all the routes        //  that are going to be handled by the Bolt app to the router        Object.values(endpoints).forEach((endpoint) => {
38            this.router.post(endpoint, this.requestHandler.bind(this))
39        })
40        this.customPropertiesExtractor = customPropertiesExtractor
41    }
42    /**     *  Validates the signature of the request basedd on a stored signing secret.  It will     * return true if the signature is valid, false if it is not.  It will also check the     * timestamp of the request to ensure that it is not too old.     * @param req     * @returns     */    private async validateSignature(req: Request) {
43        const body = await req.clone().text()
44        const signature = getRequestHeader(req, 'X-Slack-Signature')
45        const requestTimestamp = getRequestHeaderAsNumber(req, 'X-Slack-Request-Timestamp')
46        if (!signature || !requestTimestamp) {
47            return false;        }
48        // Divide current date to match Slack ts format        // Subtract 5 minutes from current time        const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5;        if (requestTimestamp < fiveMinutesAgo) {
49            this.logger.info(`Request timed out: ${requestTimestamp})`)
50            return false        }
51        const hmac = crypto.createHmac('sha256', this.signingSecret);        const [version, hash] = signature.split('=');        hmac.update(`${version}:${requestTimestamp}:${body}`);        if (!tsscmp(hash, hmac.digest('hex'))) {
52            this.logger.info(`Invalid request signature detected (X-Slack-Signature: ${signature}, X-Slack-Request-Timestamp: ${requestTimestamp})`);
53            return false;        }
54        return true    }
55    /**     * Handles the parsing of the body of the request.  This is a bit more complex than     * it should be because Slack sends the body as a URL encoded form, but the body     * is actually a JSON object.  So we have to parse the body as a URL encoded form     * and then parse the payload property of the body as JSON.     *     * In some cases the body is just a JSON object, so we have to handle that as well.     *     * @param req     * @returns     */    private async parseRequestBody(req: Request): Promise<any> {
56        const rawBody = await req.clone().text()
57        const contentType = getRequestHeader(req, 'content-type')
58        if (contentType === 'application/x-www-form-urlencoded') {
59            const parsedBody = new URLSearchParams(rawBody)
60            const body: StringIndexed = {}
61            parsedBody.forEach((value, key) => {
62                body[key] = value
63            })
64            if (body.payload !== undefined) {
65                return JSON.parse(body.payload)
66            }
67            return body
68        }
69        if (contentType === 'application/json') {
70            return JSON.parse(rawBody)
71        }
72        return rawBody
73    }
74    /**     * This is the main request handler for the Bolt app.  It will handle all the     * incoming requests and route them to the appropriate handler.     *     * @param req     * @returns     */    private async requestHandler(req: Request): Promise<any> {
75        const body = await this.parseRequestBody(req)
76        if (body?.ssl_check) {
77            return new Response('', { status: 200 })
78        }
79        // VALIDATE SIGNATURE        if (body?.type === 'url_verification') {
80            return new Response(
81                JSON.stringify({ challenge: body?.challenge }),                { status: 200 }
82            )
83        }
84        // VALIDATE SIGNATURE        if (!this.validateSignature(req)) {
85            return new Response('', { status: 401 })
86        }
87        let isAcknowledged = false        setTimeout(() => {
88            if (!isAcknowledged) {
89                this.logger.error(
90                    'An incoming event was not acknowledged within 3 seconds. Ensure that the ack() argument is called in a listener.'                )
91            }
92        }, 3001)
93        let storedResponse
94        const event: ReceiverEvent = {
95            body,            ack: async (response: string): Promise<void> => {
96                this.logger.debug('ack() begin')
97                if (isAcknowledged) {
98                    throw new ReceiverMultipleAckError()
99                }
100                isAcknowledged = true                if (typeof response === 'undefined' || response === null) {
101                    storedResponse = ''                } else if (typeof response === 'string') {
102                    storedResponse = response
103                } else if (typeof response === 'object') {
104                    storedResponse = JSON.stringify(response)
105                }
106                this.logger.debug('ack() response sent')
107            },            retryNum: getRequestHeaderAsNumber(req, 'x-slack-retry-num'),            retryReason: getRequestHeader(req, 'x-slack-retry-reason'),            customProperties: this.customPropertiesExtractor(req),        }
108        try {
109            if (event.retryNum && event.retryReason === 'http_timeout') {
110                this.logger.debug('Ignoring retry due to http timeout')
111                return            }
112            await this.bolt?.processEvent(event)
113            if (storedResponse !== undefined) {
114                this.logger.debug('stored response sent')
115                return new Response(storedResponse, {
116                    status: 200,                    headers: {
117                        'content-type': 'application/json',                    },                })
118            }
119            return new Response('', { status: 200 })
120        } catch (err) {
121            const e = err as any            if ('code' in e) {
122                // CodedError has code: string                const errorCode = (err as CodedError).code                if (errorCode === ErrorCode.AuthorizationError) {
123                    // authorize function threw an exception, which means there is no valid installation data                    isAcknowledged = true                    return new Response(
124                        'No valid authorization found for this installation.',                        {
125                            status: 401,                        }
126                    )
127                }
128            }
129            return new Response((err as Error).message, { status: 500 })
130        }
131    }
132    /**     *  Initializes the Bolt app with the receiver.     * @param bolt     */    public init(bolt: App): void {
133        this.bolt = bolt
134    }
135    /**     * Starts the router.     * @returns     */    public start(): any {
136        return this.router    }
137    /**     * Stops the router.     * @returns     */    public stop(): Promise<void> {
138        return Promise.resolve()
139    }
140}