- CDK stack for Sea Haven Industries internal Slack assistant - Bedrock Agent (Claude 3.5 Sonnet) with QBO + Google Maps action groups - VectorKnowledgeBase via @cdklabs/generative-ai-cdk-constructs (AOSS + S3) - Slack webhook/processor Lambdas with DM-only filtering - API Gateway HTTP API on bot.seahaven.com - DynamoDB conversation log with 90-day TTL - Secrets Manager references for Slack, QBO OAuth, and Google Maps
115 lines
3.5 KiB
TypeScript
115 lines
3.5 KiB
TypeScript
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' };
|
|
};
|