Removes CloudFormation dynamic references for clientId/clientSecret env vars. Credentials are now fetched from Secrets Manager at runtime so secret updates don't require a redeploy.
187 lines
7.3 KiB
TypeScript
187 lines
7.3 KiB
TypeScript
import * as cdk from 'aws-cdk-lib';
|
||
import { Construct } from 'constructs';
|
||
import * as lambda from 'aws-cdk-lib/aws-lambda';
|
||
import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs';
|
||
import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
|
||
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
|
||
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
|
||
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
||
import * as iam from 'aws-cdk-lib/aws-iam';
|
||
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
|
||
import * as route53 from 'aws-cdk-lib/aws-route53';
|
||
import * as route53Targets from 'aws-cdk-lib/aws-route53-targets';
|
||
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
|
||
import * as path from 'path';
|
||
|
||
export interface SlackHandlerProps {
|
||
accountId: string;
|
||
region: string;
|
||
agentId: string;
|
||
agentAliasId: string;
|
||
conversationTable: dynamodb.Table;
|
||
wildcardCertArn: string;
|
||
vpc: ec2.IVpc;
|
||
lambdaSecurityGroup: ec2.ISecurityGroup;
|
||
}
|
||
|
||
export class SlackHandlerConstruct extends Construct {
|
||
public readonly webhookLambda: lambdaNodejs.NodejsFunction;
|
||
public readonly processorLambda: lambdaNodejs.NodejsFunction;
|
||
public readonly api: apigatewayv2.HttpApi;
|
||
|
||
constructor(scope: Construct, id: string, props: SlackHandlerProps) {
|
||
super(scope, id);
|
||
|
||
// Slack credentials secret (created manually — see README for structure)
|
||
const slackSecret = secretsmanager.Secret.fromSecretNameV2(
|
||
this, 'SlackSecret', 'seahaven/slack/credentials',
|
||
);
|
||
|
||
// ── Processor Lambda ──────────────────────────────────────────────────────
|
||
// Async worker: calls Bedrock Agent, writes to DynamoDB, posts reply to Slack.
|
||
// Timeout is generous — agent invocations with multi-step tool use can take 2–3 min.
|
||
this.processorLambda = new lambdaNodejs.NodejsFunction(this, 'ProcessorFn', {
|
||
functionName: 'seahaven-slack-processor',
|
||
entry: path.join(__dirname, '../../lambda/slack-processor/index.ts'),
|
||
handler: 'handler',
|
||
runtime: lambda.Runtime.NODEJS_22_X,
|
||
timeout: cdk.Duration.minutes(5),
|
||
memorySize: 512,
|
||
environment: {
|
||
AGENT_ID: props.agentId,
|
||
AGENT_ALIAS_ID: props.agentAliasId,
|
||
CONVERSATION_TABLE: props.conversationTable.tableName,
|
||
SLACK_SECRET_ARN: slackSecret.secretArn,
|
||
REGION: props.region,
|
||
},
|
||
bundling: {
|
||
externalModules: ['@aws-sdk/*'],
|
||
minify: true,
|
||
sourceMap: false,
|
||
},
|
||
});
|
||
|
||
slackSecret.grantRead(this.processorLambda);
|
||
props.conversationTable.grantReadWriteData(this.processorLambda);
|
||
|
||
this.processorLambda.addToRolePolicy(new iam.PolicyStatement({
|
||
actions: ['bedrock:InvokeAgent'],
|
||
resources: [
|
||
// Wildcard on agent alias — agentAliasId is a CDK token resolved at synth
|
||
`arn:aws:bedrock:${props.region}:${props.accountId}:agent/${props.agentId}`,
|
||
`arn:aws:bedrock:${props.region}:${props.accountId}:agent-alias/${props.agentId}/*`,
|
||
],
|
||
}));
|
||
|
||
// ── Webhook Lambda ────────────────────────────────────────────────────────
|
||
// Synchronous: verifies Slack signature, returns 200 immediately, fires processor async.
|
||
this.webhookLambda = new lambdaNodejs.NodejsFunction(this, 'WebhookFn', {
|
||
functionName: 'seahaven-slack-webhook',
|
||
entry: path.join(__dirname, '../../lambda/slack-webhook/index.ts'),
|
||
handler: 'handler',
|
||
runtime: lambda.Runtime.NODEJS_22_X,
|
||
timeout: cdk.Duration.seconds(10),
|
||
memorySize: 256,
|
||
environment: {
|
||
PROCESSOR_FUNCTION_NAME: this.processorLambda.functionName,
|
||
SLACK_SECRET_ARN: slackSecret.secretArn,
|
||
},
|
||
bundling: {
|
||
externalModules: ['@aws-sdk/*'],
|
||
minify: true,
|
||
sourceMap: false,
|
||
},
|
||
});
|
||
|
||
slackSecret.grantRead(this.webhookLambda);
|
||
this.processorLambda.grantInvoke(this.webhookLambda);
|
||
|
||
// ── HTTP API (API Gateway v2) ──────────────────────────────────────────────
|
||
this.api = new apigatewayv2.HttpApi(this, 'Api', {
|
||
apiName: 'seahaven-slack-webhook',
|
||
description: 'Receives Slack event webhook calls for Sea Haven bot',
|
||
});
|
||
|
||
this.api.addRoutes({
|
||
path: '/slack/events',
|
||
methods: [apigatewayv2.HttpMethod.POST],
|
||
integration: new HttpLambdaIntegration('WebhookIntegration', this.webhookLambda),
|
||
});
|
||
|
||
// ── QBO OAuth Lambda ─────────────────────────────────────────────────────
|
||
// Handles /qbo/connect, /qbo/callback, /qbo/disconnect, /qbo/launch
|
||
const qboSecret = secretsmanager.Secret.fromSecretNameV2(
|
||
this, 'QBOSecret', 'seahaven/qbo/oauth',
|
||
);
|
||
|
||
const qboOAuthLambda = new lambdaNodejs.NodejsFunction(this, 'QBOOAuthFn', {
|
||
functionName: 'seahaven-qbo-oauth',
|
||
entry: path.join(__dirname, '../../lambda/qbo-oauth/index.ts'),
|
||
handler: 'handler',
|
||
runtime: lambda.Runtime.NODEJS_22_X,
|
||
timeout: cdk.Duration.seconds(15),
|
||
memorySize: 256,
|
||
environment: {
|
||
QBO_SECRET_ARN: qboSecret.secretArn,
|
||
REDIRECT_URI: 'https://bot.seahaven.com/qbo/callback',
|
||
},
|
||
vpc: props.vpc,
|
||
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
||
securityGroups: [props.lambdaSecurityGroup],
|
||
bundling: {
|
||
externalModules: ['@aws-sdk/*'],
|
||
minify: true,
|
||
sourceMap: false,
|
||
},
|
||
});
|
||
|
||
qboSecret.grantRead(qboOAuthLambda);
|
||
qboSecret.grantWrite(qboOAuthLambda);
|
||
|
||
const qboOAuthIntegration = new HttpLambdaIntegration('QBOOAuthIntegration', qboOAuthLambda);
|
||
|
||
for (const qboPath of ['/qbo/connect', '/qbo/callback', '/qbo/disconnect', '/qbo/launch']) {
|
||
this.api.addRoutes({
|
||
path: qboPath,
|
||
methods: [apigatewayv2.HttpMethod.GET],
|
||
integration: qboOAuthIntegration,
|
||
});
|
||
}
|
||
|
||
// ── Custom domain: bot.seahaven.com ───────────────────────────────────────
|
||
const certificate = acm.Certificate.fromCertificateArn(
|
||
this, 'WildcardCert', props.wildcardCertArn,
|
||
);
|
||
|
||
const hostedZone = route53.HostedZone.fromLookup(this, 'SeahavenZone', {
|
||
domainName: 'seahaven.com',
|
||
});
|
||
|
||
const customDomain = new apigatewayv2.DomainName(this, 'CustomDomain', {
|
||
domainName: 'bot.seahaven.com',
|
||
certificate,
|
||
});
|
||
|
||
new apigatewayv2.ApiMapping(this, 'ApiMapping', {
|
||
api: this.api,
|
||
domainName: customDomain,
|
||
stage: this.api.defaultStage!,
|
||
});
|
||
|
||
new route53.ARecord(this, 'BotDnsRecord', {
|
||
zone: hostedZone,
|
||
recordName: 'bot',
|
||
target: route53.RecordTarget.fromAlias(
|
||
new route53Targets.ApiGatewayv2DomainProperties(
|
||
customDomain.regionalDomainName,
|
||
customDomain.regionalHostedZoneId,
|
||
),
|
||
),
|
||
});
|
||
|
||
new cdk.CfnOutput(scope, 'SlackWebhookUrl', {
|
||
value: 'https://bot.seahaven.com/slack/events',
|
||
description: 'Paste this into Slack app → Event Subscriptions → Request URL',
|
||
});
|
||
}
|
||
}
|