Skip to content

Instantly share code, notes, and snippets.

@munky69rock
Last active July 12, 2024 09:15
Show Gist options
  • Save munky69rock/a29834cb0dc11413762d060976ecaee4 to your computer and use it in GitHub Desktop.
Save munky69rock/a29834cb0dc11413762d060976ecaee4 to your computer and use it in GitHub Desktop.
Running Slack Bolt App with Next.js API Routes (not Route Handlers)
// 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);
}
// 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