Last active
July 12, 2024 09:15
-
-
Save munky69rock/a29834cb0dc11413762d060976ecaee4 to your computer and use it in GitHub Desktop.
Running Slack Bolt App with Next.js API Routes (not Route Handlers)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// lib/slack/next-connext-receiver.ts | |
/* eslint-disable @typescript-eslint/no-explicit-any */ | |
import { | |
HTTPModuleFunctions as httpFunc, | |
HTTPResponseAck, | |
ReceiverAuthenticityError, | |
type AnyMiddlewareArgs, | |
type App, | |
type CodedError, | |
type Receiver, | |
type ReceiverDispatchErrorHandlerArgs, | |
type ReceiverEvent, | |
type ReceiverProcessEventErrorHandlerArgs, | |
type ReceiverUnhandledRequestHandlerArgs, | |
} from "@slack/bolt"; | |
import { verifyRedirectOpts } from "@slack/bolt/dist/receivers/verify-redirect-opts"; | |
import type { StringIndexed } from "@slack/bolt/dist/types/helpers"; | |
import { ConsoleLogger, Logger, LogLevel } from "@slack/logger"; | |
import { | |
CallbackOptions, | |
InstallPathOptions, | |
InstallProvider, | |
InstallProviderOptions, | |
InstallURLOptions, | |
} from "@slack/oauth"; | |
import type { NextApiRequest, NextApiResponse } from "next"; | |
import { createRouter, expressWrapper } from "next-connect"; | |
import crypto from "node:crypto"; | |
import querystring from "node:querystring"; | |
import rawBody from "raw-body"; | |
import tsscmp from "tsscmp"; | |
// next-connect | |
type Router = ReturnType<typeof createRouter<NextApiRequest, NextApiResponse>>; | |
type FunctionLike = (...args: any[]) => unknown; | |
type ValueOrPromise<T> = T | Promise<T>; | |
type NextHandler = () => ValueOrPromise<any>; | |
type Nextable<H extends FunctionLike> = ( | |
...args: [...Parameters<H>, NextHandler] | |
) => ValueOrPromise<any>; | |
type RequestHandler = Nextable< | |
(req: NextApiRequest, res: NextApiResponse) => void | |
>; | |
const respondToSslCheck: RequestHandler = (req, res, next) => { | |
if (req.body && req.body.ssl_check) { | |
res.send(undefined); | |
return; | |
} | |
next(); | |
}; | |
const respondToUrlVerification: RequestHandler = (req, res, next) => { | |
if (req.body && req.body.type && req.body.type === "url_verification") { | |
res.json({ challenge: req.body.challenge }); | |
return; | |
} | |
next(); | |
}; | |
// TODO: we throw away the key names for endpoints, so maybe we should use this interface. is it better for migrations? | |
// if that's the reason, let's document that with a comment. | |
export interface NextConnectReceiverOptions { | |
signingSecret: string | (() => PromiseLike<string>); | |
logger?: Logger; | |
logLevel?: LogLevel; | |
endpoints?: string | Record<string, string>; | |
signatureVerification?: boolean; | |
processBeforeResponse?: boolean; | |
clientId?: string; | |
clientSecret?: string; | |
stateSecret?: InstallProviderOptions["stateSecret"]; // required when using default stateStore | |
redirectUri?: string; | |
installationStore?: InstallProviderOptions["installationStore"]; // default MemoryInstallationStore | |
scopes?: InstallURLOptions["scopes"]; | |
installerOptions?: InstallerOptions; | |
router?: Router; | |
customPropertiesExtractor?: (request: NextApiRequest) => StringIndexed; | |
dispatchErrorHandler?: ( | |
args: ReceiverDispatchErrorHandlerArgs, | |
) => Promise<void>; | |
processEventErrorHandler?: ( | |
args: ReceiverProcessEventErrorHandlerArgs, | |
) => Promise<boolean>; | |
// NOTE: for the compatibility with HTTPResponseAck, this handler is not async | |
// If we receive requests to provide async version of this handler, | |
// we can add a different name function for it. | |
unhandledRequestHandler?: (args: ReceiverUnhandledRequestHandlerArgs) => void; | |
unhandledRequestTimeoutMillis?: number; | |
} | |
// Additional Installer Options | |
interface InstallerOptions { | |
stateStore?: InstallProviderOptions["stateStore"]; // default ClearStateStore | |
stateVerification?: InstallProviderOptions["stateVerification"]; // defaults true | |
legacyStateVerification?: InstallProviderOptions["legacyStateVerification"]; | |
stateCookieName?: InstallProviderOptions["stateCookieName"]; | |
stateCookieExpirationSeconds?: InstallProviderOptions["stateCookieExpirationSeconds"]; | |
authVersion?: InstallProviderOptions["authVersion"]; // default 'v2' | |
metadata?: InstallURLOptions["metadata"]; | |
installPath?: string; | |
directInstall?: InstallProviderOptions["directInstall"]; // see https://api.slack.com/start/distributing/directory#direct_install | |
renderHtmlForInstallPath?: InstallProviderOptions["renderHtmlForInstallPath"]; | |
redirectUriPath?: string; | |
installPathOptions?: InstallPathOptions; | |
callbackOptions?: CallbackOptions; | |
userScopes?: InstallURLOptions["userScopes"]; | |
clientOptions?: InstallProviderOptions["clientOptions"]; | |
authorizationUrl?: InstallProviderOptions["authorizationUrl"]; | |
} | |
/** | |
* Receives HTTP requests with Events, Slash Commands, and Actions | |
*/ | |
export default class NextConnectReceiver implements Receiver { | |
private bolt: App | undefined; | |
private logger: Logger; | |
private processBeforeResponse: boolean; | |
private signatureVerification: boolean; | |
public router: Router; | |
public installer: InstallProvider | undefined = undefined; | |
public installerOptions?: InstallerOptions; | |
private customPropertiesExtractor: (request: NextApiRequest) => StringIndexed; | |
private dispatchErrorHandler: ( | |
args: ReceiverDispatchErrorHandlerArgs, | |
) => Promise<void>; | |
private processEventErrorHandler: ( | |
args: ReceiverProcessEventErrorHandlerArgs, | |
) => Promise<boolean>; | |
private unhandledRequestHandler: ( | |
args: ReceiverUnhandledRequestHandlerArgs, | |
) => void; | |
private unhandledRequestTimeoutMillis: number; | |
public constructor({ | |
signingSecret = "", | |
logger = undefined, | |
logLevel = LogLevel.INFO, | |
endpoints = { events: "/slack/events" }, | |
processBeforeResponse = false, | |
signatureVerification = true, | |
clientId = undefined, | |
clientSecret = undefined, | |
stateSecret = undefined, | |
redirectUri = undefined, | |
installationStore = undefined, | |
scopes = undefined, | |
installerOptions = {}, | |
router = undefined, | |
customPropertiesExtractor = () => ({}), | |
dispatchErrorHandler = httpFunc.defaultAsyncDispatchErrorHandler, | |
processEventErrorHandler = httpFunc.defaultProcessEventErrorHandler, | |
unhandledRequestHandler = httpFunc.defaultUnhandledRequestHandler, | |
unhandledRequestTimeoutMillis = 3001, | |
}: NextConnectReceiverOptions) { | |
if (typeof logger !== "undefined") { | |
this.logger = logger; | |
} else { | |
this.logger = new ConsoleLogger(); | |
this.logger.setLevel(logLevel); | |
} | |
this.signatureVerification = signatureVerification; | |
const bodyParser = this.signatureVerification | |
? buildVerificationBodyParserMiddleware(this.logger, signingSecret) | |
: buildBodyParserMiddleware(this.logger); | |
const expressMiddleware: RequestHandler[] = [ | |
expressWrapper(bodyParser), | |
expressWrapper(respondToSslCheck), | |
expressWrapper(respondToUrlVerification), | |
expressWrapper(this.requestHandler.bind(this)), | |
]; | |
this.processBeforeResponse = processBeforeResponse; | |
const endpointList = | |
typeof endpoints === "string" ? [endpoints] : Object.values(endpoints); | |
this.router = router !== undefined ? router : createRouter(); | |
endpointList.forEach((endpoint) => { | |
this.router.post(endpoint, ...expressMiddleware); | |
}); | |
this.customPropertiesExtractor = customPropertiesExtractor; | |
this.dispatchErrorHandler = dispatchErrorHandler; | |
this.processEventErrorHandler = processEventErrorHandler; | |
this.unhandledRequestHandler = unhandledRequestHandler; | |
this.unhandledRequestTimeoutMillis = unhandledRequestTimeoutMillis; | |
// Verify redirect options if supplied, throws coded error if invalid | |
verifyRedirectOpts({ | |
redirectUri, | |
redirectUriPath: installerOptions.redirectUriPath, | |
}); | |
if ( | |
clientId !== undefined && | |
clientSecret !== undefined && | |
(installerOptions.stateVerification === false || // state store not needed | |
stateSecret !== undefined || | |
installerOptions.stateStore !== undefined) // user provided state store | |
) { | |
this.installer = new InstallProvider({ | |
clientId, | |
clientSecret, | |
stateSecret, | |
installationStore, | |
logLevel, | |
logger, // pass logger that was passed in constructor, not one created locally | |
directInstall: installerOptions.directInstall, | |
stateStore: installerOptions.stateStore, | |
stateVerification: installerOptions.stateVerification, | |
legacyStateVerification: installerOptions.legacyStateVerification, | |
stateCookieName: installerOptions.stateCookieName, | |
stateCookieExpirationSeconds: | |
installerOptions.stateCookieExpirationSeconds, | |
renderHtmlForInstallPath: installerOptions.renderHtmlForInstallPath, | |
authVersion: installerOptions.authVersion ?? "v2", | |
clientOptions: installerOptions.clientOptions, | |
authorizationUrl: installerOptions.authorizationUrl, | |
}); | |
} | |
// create install url options | |
const installUrlOptions = { | |
metadata: installerOptions.metadata, | |
scopes: scopes ?? [], | |
userScopes: installerOptions.userScopes, | |
redirectUri, | |
}; | |
// Add OAuth routes to receiver | |
if (this.installer !== undefined) { | |
const { installer } = this; | |
const redirectUriPath = | |
installerOptions.redirectUriPath === undefined | |
? "/slack/oauth_redirect" | |
: installerOptions.redirectUriPath; | |
const { callbackOptions, stateVerification } = installerOptions; | |
// this.router.use(redirectUriPath, async (req, res) => { | |
this.router.get(redirectUriPath, async (req, res) => { | |
try { | |
if (stateVerification === false) { | |
// when stateVerification is disabled pass install options directly to handler | |
// since they won't be encoded in the state param of the generated url | |
await installer.handleCallback( | |
req, | |
res, | |
callbackOptions, | |
installUrlOptions, | |
); | |
} else { | |
await installer.handleCallback(req, res, callbackOptions); | |
} | |
} catch (e) { | |
await this.dispatchErrorHandler({ | |
error: e as Error | CodedError, | |
logger: this.logger, | |
request: req, | |
response: res, | |
}); | |
} | |
}); | |
const installPath = | |
installerOptions.installPath === undefined | |
? "/slack/install" | |
: installerOptions.installPath; | |
const { installPathOptions } = installerOptions; | |
this.router.get(installPath, async (req, res) => { | |
try { | |
// try { | |
await installer.handleInstallPath( | |
req, | |
res, | |
installPathOptions, | |
installUrlOptions, | |
); | |
// } catch (error) { | |
// next(error); | |
// } | |
} catch (e) { | |
await this.dispatchErrorHandler({ | |
error: e as Error | CodedError, | |
logger: this.logger, | |
request: req, | |
response: res, | |
}); | |
} | |
}); | |
} | |
} | |
public async requestHandler( | |
req: NextApiRequest, | |
res: NextApiResponse, | |
): Promise<void> { | |
const ack = new HTTPResponseAck({ | |
logger: this.logger, | |
processBeforeResponse: this.processBeforeResponse, | |
unhandledRequestHandler: this.unhandledRequestHandler, | |
unhandledRequestTimeoutMillis: this.unhandledRequestTimeoutMillis, | |
httpRequest: req, | |
httpResponse: res, | |
}); | |
const event: ReceiverEvent = { | |
body: req.body, | |
ack: ack.bind(), | |
retryNum: httpFunc.extractRetryNumFromHTTPRequest(req), | |
retryReason: httpFunc.extractRetryReasonFromHTTPRequest(req), | |
customProperties: this.customPropertiesExtractor(req), | |
}; | |
try { | |
await this.bolt?.processEvent(event); | |
if (ack.storedResponse !== undefined) { | |
httpFunc.buildContentResponse(res, ack.storedResponse); | |
this.logger.debug("stored response sent"); | |
} | |
} catch (err) { | |
const acknowledgedByHandler = await this.processEventErrorHandler({ | |
error: err as Error | CodedError, | |
logger: this.logger, | |
request: req, | |
response: res, | |
storedResponse: ack.storedResponse, | |
}); | |
if (acknowledgedByHandler) { | |
// If the value is false, we don't touch the value as a race condition | |
// with ack() call may occur especially when processBeforeResponse: false | |
ack.ack(); | |
} | |
} | |
} | |
public init(bolt: App): void { | |
this.bolt = bolt; | |
} | |
public async start() { | |
// noop | |
} | |
public async stop() { | |
// noop | |
} | |
} | |
/** | |
* This request handler has two responsibilities: | |
* - Verify the request signature | |
* - Parse request.body and assign the successfully parsed object to it. | |
*/ | |
function buildVerificationBodyParserMiddleware( | |
logger: Logger, | |
signingSecret: string | (() => PromiseLike<string>), | |
): RequestHandler { | |
return async (req, res, next): Promise<void> => { | |
let stringBody: string; | |
// On some environments like GCP (Google Cloud Platform), | |
// req.body can be pre-parsed and be passed as req.rawBody here | |
const preparsedRawBody: any = (req as any).rawBody; | |
if (preparsedRawBody !== undefined) { | |
stringBody = preparsedRawBody.toString(); | |
} else { | |
// stringBody = (await rawBody(req)).toString(); | |
// Next.jsの場合、objectとしてparseされてわたってくる | |
stringBody = JSON.stringify(req.body); | |
} | |
// *** Parsing body *** | |
// As the verification passed, parse the body as an object and assign it to req.body | |
// Following middlewares can expect `req.body` is already a parsed one. | |
try { | |
// This handler parses `req.body` or `req.rawBody`(on Google Could Platform) | |
// and overwrites `req.body` with the parsed JS object. | |
req.body = verifySignatureAndParseBody( | |
typeof signingSecret === "string" | |
? signingSecret | |
: await signingSecret(), | |
stringBody, | |
req.headers, | |
); | |
} catch (error) { | |
if (error) { | |
if (error instanceof ReceiverAuthenticityError) { | |
logError(logger, "Request verification failed", error); | |
res.status(401).send(undefined); | |
return; | |
} | |
logError(logger, "Parsing request body failed", error); | |
res.status(400).send(undefined); | |
return; | |
} | |
} | |
next(); | |
}; | |
} | |
function logError(logger: Logger, message: string, error: any): void { | |
const logMessage = | |
"code" in error | |
? `${message} (code: ${error.code}, message: ${error.message})` | |
: `${message} (error: ${error})`; | |
logger.warn(logMessage); | |
} | |
function verifyRequestSignature( | |
signingSecret: string, | |
body: string, | |
signature: string | undefined, | |
requestTimestamp: string | undefined, | |
): void { | |
if (signature === undefined || requestTimestamp === undefined) { | |
throw new ReceiverAuthenticityError( | |
"Slack request signing verification failed. Some headers are missing.", | |
); | |
} | |
const ts = Number(requestTimestamp); | |
if (isNaN(ts)) { | |
throw new ReceiverAuthenticityError( | |
"Slack request signing verification failed. Timestamp is invalid.", | |
); | |
} | |
// Divide current date to match Slack ts format | |
// Subtract 5 minutes from current time | |
const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5; | |
if (ts < fiveMinutesAgo) { | |
throw new ReceiverAuthenticityError( | |
"Slack request signing verification failed. Timestamp is too old.", | |
); | |
} | |
const hmac = crypto.createHmac("sha256", signingSecret); | |
const [version, hash] = signature.split("="); | |
hmac.update(`${version}:${ts}:${body}`); | |
if (!hash || !tsscmp(hash, hmac.digest("hex"))) { | |
throw new ReceiverAuthenticityError( | |
"Slack request signing verification failed. Signature mismatch.", | |
); | |
} | |
} | |
/** | |
* This request handler has two responsibilities: | |
* - Verify the request signature | |
* - Parse `request.body` and assign the successfully parsed object to it. | |
*/ | |
function verifySignatureAndParseBody( | |
signingSecret: string, | |
body: string, | |
headers: Record<string, any>, | |
): AnyMiddlewareArgs["body"] { | |
// *** Request verification *** | |
const { | |
"x-slack-signature": signature, | |
"x-slack-request-timestamp": requestTimestamp, | |
"content-type": contentType, | |
} = headers; | |
verifyRequestSignature(signingSecret, body, signature, requestTimestamp); | |
return parseRequestBody(body, contentType); | |
} | |
function buildBodyParserMiddleware(logger: Logger): RequestHandler { | |
return async (req, res, next): Promise<void> => { | |
let stringBody: string; | |
// On some environments like GCP (Google Cloud Platform), | |
// req.body can be pre-parsed and be passed as req.rawBody here | |
const preparsedRawBody: any = (req as any).rawBody; | |
if (preparsedRawBody !== undefined) { | |
stringBody = preparsedRawBody.toString(); | |
} else { | |
stringBody = (await rawBody(req)).toString(); | |
} | |
try { | |
const { "content-type": contentType } = req.headers; | |
req.body = parseRequestBody(stringBody, contentType); | |
} catch (error) { | |
if (error) { | |
logError(logger, "Parsing request body failed", error); | |
res.status(400).send(undefined); | |
return; | |
} | |
} | |
next(); | |
}; | |
} | |
function parseRequestBody( | |
stringBody: string, | |
contentType: string | undefined, | |
): any { | |
if (contentType === "application/x-www-form-urlencoded") { | |
// TODO: querystring is deprecated since Node.js v17 | |
const parsedBody = querystring.parse(stringBody); | |
if (typeof parsedBody["payload"] === "string") { | |
return JSON.parse(parsedBody["payload"]); | |
} | |
return parsedBody; | |
} | |
return JSON.parse(stringBody); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// pages/api/slack/[[..route]].ts | |
/* eslint-disable @typescript-eslint/no-explicit-any */ | |
import NextConnectReceiver from "@/lib/slack/next-connect-receiver"; | |
import { PrismaClient } from "@prisma/client"; | |
import { PrismaInstallationStore } from "@seratch_/bolt-prisma"; | |
import { App } from "@slack/bolt"; | |
const signingSecret = process.env["SLACK_SIGNING_SECRET"]; | |
const clientId = process.env["SLACK_CLIENT_ID"]; | |
const clientSecret = process.env["SLACK_CLIENT_SECRET"]; | |
const stateSecret = process.env["SLACK_STATE_SECRET"] ?? "secret"; | |
if (!signingSecret || !clientId || !clientSecret) | |
throw new Error(`\ | |
SLACK_SIGNING_SECRET, SLACK_CLIENT_ID, and SLACK_CLIENT_SECRET must be defined in the environment`); | |
const prisma: PrismaClient = (global as any).prisma || new PrismaClient(); | |
if (process.env["NODE_ENV"] !== "production") (global as any).prisma = prisma; | |
const receiver = new NextConnectReceiver({ | |
clientId, | |
clientSecret, | |
signingSecret, | |
stateSecret, | |
// The `processBeforeResponse` option is required for all FaaS environments. | |
// It allows Bolt methods (e.g. `app.message`) to handle a Slack request | |
// before the Bolt framework responds to the request (e.g. `ack()`). This is | |
// important because FaaS immediately terminate handlers after the response. | |
processBeforeResponse: true, | |
endpoints: { | |
events: "/api/slack/events", | |
commands: "/api/slack/commands", | |
interactions: "/api/slack/interactions", | |
}, | |
scopes: ["chat:write", "chat:write.public", "app_mentions:read"], | |
installationStore: new PrismaInstallationStore({ | |
prismaTable: prisma.slackAppInstallation, | |
clientId, | |
}), | |
installerOptions: { | |
directInstall: true, | |
installPath: "/api/slack/install", | |
redirectUriPath: "/api/slack/oauth_redirect", | |
}, | |
}); | |
// Initializes your app with your bot token and the AWS Lambda ready receiver | |
const app = new App({ | |
receiver: receiver, | |
developerMode: false, | |
}); | |
app.event("app_mention", async ({ event, say }) => { | |
await say({ | |
text: `<@${event.user}> Hi there :wave:`, | |
blocks: [ | |
{ | |
type: "section", | |
text: { | |
type: "mrkdwn", | |
text: `<@${event.user}> Hi there :wave:`, | |
}, | |
}, | |
], | |
}); | |
}); | |
export default receiver.router.handler({ | |
onError: (err: any, _req, res) => { | |
res.status(err.statusCode || 500).end(err.message); | |
}, | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment