import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda'; import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda'; import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; import { createHmac, timingSafeEqual } from 'crypto'; const lambdaClient = new LambdaClient({}); const secretsClient = new SecretsManagerClient({}); interface SlackCredentials { botToken: string; signingSecret: string; } // Cache the secret within the Lambda execution environment lifetime let cachedSecret: SlackCredentials | undefined; async function getSlackCredentials(): Promise { if (!cachedSecret) { const res = await secretsClient.send( new GetSecretValueCommand({ SecretId: process.env.SLACK_SECRET_ARN! }), ); cachedSecret = JSON.parse(res.SecretString!) as SlackCredentials; } return cachedSecret; } function verifySignature( signingSecret: string, timestamp: string, rawBody: string, signature: string, ): boolean { // Reject requests older than 5 minutes (replay attack prevention) const ageSeconds = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10)); if (ageSeconds > 300) return false; const baseString = `v0:${timestamp}:${rawBody}`; const expected = `v0=${createHmac('sha256', signingSecret).update(baseString).digest('hex')}`; try { return timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(signature, 'utf8')); } catch { return false; } } export const handler = async ( event: APIGatewayProxyEventV2, ): Promise => { const rawBody = event.body ?? ''; const timestamp = event.headers['x-slack-request-timestamp'] ?? ''; const signature = event.headers['x-slack-signature'] ?? ''; const credentials = await getSlackCredentials(); if (!verifySignature(credentials.signingSecret, timestamp, rawBody, signature)) { return { statusCode: 401, body: 'Invalid signature' }; } const payload = JSON.parse(rawBody) as Record; // Slack sends this when you first register the event URL if (payload.type === 'url_verification') { return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challenge: payload.challenge }), }; } if (payload.type !== 'event_callback') { return { statusCode: 200, body: 'OK' }; } const slackEvent = payload.event as Record; // DM only — channel_type === 'im' if (slackEvent.channel_type !== 'im') { return { statusCode: 200, body: 'OK' }; } // Ignore bot messages and message edits/deletions to prevent loops if ( slackEvent.bot_id || slackEvent.subtype === 'bot_message' || slackEvent.subtype === 'message_changed' || slackEvent.subtype === 'message_deleted' ) { return { statusCode: 200, body: 'OK' }; } if (slackEvent.type !== 'message') { return { statusCode: 200, body: 'OK' }; } // Fire-and-forget — processor handles the slow Bedrock call await lambdaClient.send( new InvokeCommand({ FunctionName: process.env.PROCESSOR_FUNCTION_NAME!, InvocationType: 'Event', Payload: Buffer.from( JSON.stringify({ userId: slackEvent.user, channelId: slackEvent.channel, text: slackEvent.text, ts: slackEvent.ts, threadTs: slackEvent.thread_ts, }), ), }), ); // Slack requires a 200 within 3 seconds return { statusCode: 200, body: 'OK' }; };