Skip to content

Instantly share code, notes, and snippets.

@myfancypants
Created February 25, 2025 18:44
Show Gist options
  • Select an option

  • Save myfancypants/1aab56b0cc49ac5c6179f85cd789d889 to your computer and use it in GitHub Desktop.

Select an option

Save myfancypants/1aab56b0cc49ac5c6179f85cd789d889 to your computer and use it in GitHub Desktop.
Draft Slack Bolt Next.js Receiever
import querystring from "node:querystring";
import { ConsoleLogger, LogLevel, type Logger } from "@slack/logger";
import type App from "../App";
import { ReceiverMultipleAckError } from "../errors";
import type { Receiver, ReceiverEvent } from "../types/receiver";
import type { StringIndexed } from "../types/utilities";
import { isValidSlackRequest } from "./verify-request";
export interface NextJsReceiverOptions {
/**
* The Slack Signing secret to be used for request verification
*/
signingSecret: string;
/**
* The logger instance
*/
logger?: Logger;
/**
* The log level for the logger
*/
logLevel?: LogLevel;
/**
* Whether to verify request signatures
*/
signatureVerification?: boolean;
/**
* Function to extract custom properties from the request
*/
customPropertiesExtractor?: (request: Request) => StringIndexed;
/**
* Timeout for unhandled requests in milliseconds
*/
unhandledRequestTimeoutMillis?: number;
}
/**
* A Bolt receiver implementation for Next.js Edge Runtime
*/
export default class NextJsReceiver implements Receiver {
private signingSecret: string;
private bolt?: App;
private _logger: Logger;
private signatureVerification: boolean;
private customPropertiesExtractor: (request: Request) => StringIndexed;
private unhandledRequestTimeoutMillis: number;
get logger() {
return this._logger;
}
constructor({
signingSecret = "",
logger = undefined,
logLevel = LogLevel.INFO,
signatureVerification = true,
customPropertiesExtractor = () => ({}),
unhandledRequestTimeoutMillis = 3001,
}: NextJsReceiverOptions) {
this.signingSecret = signingSecret;
this.signatureVerification = signatureVerification;
this.unhandledRequestTimeoutMillis = unhandledRequestTimeoutMillis;
if (typeof logger !== "undefined") {
this._logger = logger;
} else {
this._logger = new ConsoleLogger();
this._logger.setLevel(logLevel);
}
this.customPropertiesExtractor = customPropertiesExtractor;
}
public init(app: App): void {
this.bolt = app;
}
public start(): Promise<(req: Request) => Promise<Response>> {
// Return a Promise that resolves to the handler function
return Promise.resolve(this.toHandler.bind(this));
}
public stop(): Promise<void> {
return Promise.resolve();
}
private async toHandler(req: Request): Promise<Response> {
// Only accept POST requests
if (req.method !== "POST") {
return Response.json({ error: "Method not allowed" }, { status: 405 });
}
const rawBody = await req.text();
const contentType = req.headers.get("content-type");
// Parse the request body
const body = this.parseRequestBody(rawBody, contentType);
// Handle SSL Check
if (body?.ssl_check) {
return Response.json({}, { status: 200 });
}
// Verify request signature if enabled
if (this.signatureVerification) {
const signature = req.headers.get("x-slack-signature") || "";
const timestamp = Number(
req.headers.get("x-slack-request-timestamp") || ""
);
const headers = {
"x-slack-signature": signature,
"x-slack-request-timestamp": timestamp,
};
if (!signature || !timestamp) {
return Response.json(
{ error: "Missing signature headers" },
{ status: 401 }
);
}
if (
!isValidSlackRequest({
signingSecret: this.signingSecret,
body: rawBody,
headers,
logger: this.logger,
})
) {
this.logger.warn(
`Invalid request signature detected (X-Slack-Signature: ${signature}, X-Slack-Request-Timestamp: ${timestamp})`
);
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
}
// Handle URL verification
if (body?.type === "url_verification") {
return Response.json({ challenge: body.challenge });
}
// Setup acknowledgment timeout
let isAcknowledged = false;
const noAckTimeoutId = setTimeout(() => {
if (!isAcknowledged) {
this.logger.error(
`An incoming event was not acknowledged within ${this.unhandledRequestTimeoutMillis}ms. Ensure that the ack() argument is called in a listener.`
);
}
}, this.unhandledRequestTimeoutMillis);
// Create response holder
let storedResponse: string | Record<string, unknown> | undefined;
// Create the receiver event
const event: ReceiverEvent = {
body,
ack: async (response?: string | Record<string, unknown>) => {
if (isAcknowledged) {
throw new ReceiverMultipleAckError();
}
isAcknowledged = true;
clearTimeout(noAckTimeoutId);
if (typeof response === "undefined" || response === null) {
storedResponse = "";
} else {
storedResponse = response;
}
},
retryNum: req.headers.get("x-slack-retry-num")
? Number(req.headers.get("x-slack-retry-num"))
: undefined,
retryReason: req.headers.get("x-slack-retry-reason") ?? undefined,
customProperties: this.customPropertiesExtractor(req),
};
try {
// Process the event
await this.bolt?.processEvent(event);
// Return the response if one was stored
if (storedResponse !== undefined) {
if (typeof storedResponse === "string") {
return Response.json({ response: storedResponse });
}
return Response.json(storedResponse as Record<string, unknown>);
}
// No response was stored, return empty 200
return Response.json({});
} catch (error) {
this.logger.error(
"An unhandled error occurred while processing the event"
);
this.logger.debug(
`Error details: ${error}, storedResponse: ${storedResponse}`
);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
private parseRequestBody(
stringBody: string,
contentType: string | null
): Record<string, unknown> {
if (contentType === "application/x-www-form-urlencoded") {
const parsedBody = querystring.parse(stringBody);
if (typeof parsedBody.payload === "string") {
return JSON.parse(parsedBody.payload);
}
return parsedBody as Record<string, unknown>;
}
if (contentType === "application/json") {
return JSON.parse(stringBody);
}
this.logger.warn(`Unexpected content-type detected: ${contentType}`);
try {
return JSON.parse(stringBody);
} catch (error) {
this.logger.error(
`Failed to parse body as JSON data for content-type: ${contentType}`
);
throw error;
}
}
}
import { App, NextJsReceiver } from '@slack/bolt';
const receiver = new NextJsReceiver({
signingSecret: process.env.SLACK_SIGNING_SECRET || '',
});
const app = new App({
receiver,
token: process.env.SLACK_BOT_TOKEN,
});
// Listens to incoming messages that contain "hello"
app.message('hello', async ({ message, say }) => {
// say() sends a message to the channel where the event was triggered
await say(`Hey there <@${message.user}>!`);
});
// Initialize the app
receiver.init(app);
// Get the handler function
const handler = await receiver.start();
// Export the POST handler
export async function POST(req: Request) {
try {
return handler(req);
} catch (error) {
console.error('Error in Slack event handler:', error);
return new Response('Internal Server Error', { status: 500 });
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment