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 thenext-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}