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/lib/constructs/slack-handler.ts
Adam Moussa d1b91ae661 Read OAuth client credentials from Secrets Manager at runtime
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.
2026-04-13 20:22:21 -04:00

187 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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',
});
}
}