Created
February 25, 2025 18:44
-
-
Save myfancypants/1aab56b0cc49ac5c6179f85cd789d889 to your computer and use it in GitHub Desktop.
Draft Slack Bolt Next.js Receiever
This file contains hidden or 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
| 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; | |
| } | |
| } | |
| } |
This file contains hidden or 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
| 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