This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
seahaven-slack-bot/lambda/slack-webhook/index.ts

116 lines
3.5 KiB
TypeScript
Raw Normal View History

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<SlackCredentials> {
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<APIGatewayProxyResultV2> => {
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<string, unknown>;
// 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<string, unknown>;
// 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' };
};