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 006dbf7c6b feat: Alex rollout — persona, Socket Mode, payments, channels, App Home, unanswered questions
- Rename bot to Alex with friendly/professional persona (Bedrock Agent instruction rewrite)
- Replace HTTP webhook Lambda with ECS Fargate Socket Mode service (persistent WebSocket)
- Add payment/invoice lookup via PaymentsDashboard table (vendor, invoice, check search)
- Switch from manual SiteAssignments to auto-populated verified-sites table
- Add channel support via app_mention events (threaded replies)
- Add App Home tab with Block Kit capabilities view
- Add unanswered questions logging to seahaven-unanswered-questions DynamoDB table
- Fix pre-existing compliance: add arm64 + 60-day log retention to all Lambdas
- Remove webhook Lambda and seed-sites script (both obsolete)
2026-04-30 16:38:54 -04:00

172 lines
6.5 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 logs from 'aws-cdk-lib/aws-logs';
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;
unansweredTable: dynamodb.Table;
wildcardCertArn: string;
vpc: ec2.IVpc;
lambdaSecurityGroup: ec2.ISecurityGroup;
}
export class SlackHandlerConstruct extends Construct {
public readonly processorLambda: lambdaNodejs.NodejsFunction;
public readonly appHomeLambda: lambdaNodejs.NodejsFunction;
public readonly api: apigatewayv2.HttpApi;
constructor(scope: Construct, id: string, props: SlackHandlerProps) {
super(scope, id);
const slackSecret = secretsmanager.Secret.fromSecretNameV2(
this, 'SlackSecret', 'seahaven/slack/credentials',
);
const bundling: lambdaNodejs.BundlingOptions = {
externalModules: ['@aws-sdk/*'],
minify: true,
sourceMap: false,
};
// ── Processor Lambda ──────────────────────────────────────────────────────
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,
architecture: lambda.Architecture.ARM_64,
logRetention: logs.RetentionDays.TWO_MONTHS,
timeout: cdk.Duration.minutes(5),
memorySize: 512,
environment: {
AGENT_ID: props.agentId,
AGENT_ALIAS_ID: props.agentAliasId,
CONVERSATION_TABLE: props.conversationTable.tableName,
UNANSWERED_TABLE: props.unansweredTable.tableName,
SLACK_SECRET_ARN: slackSecret.secretArn,
REGION: props.region,
},
bundling,
});
slackSecret.grantRead(this.processorLambda);
props.conversationTable.grantReadWriteData(this.processorLambda);
props.unansweredTable.grantWriteData(this.processorLambda);
this.processorLambda.addToRolePolicy(new iam.PolicyStatement({
actions: ['bedrock:InvokeAgent'],
resources: [
`arn:aws:bedrock:${props.region}:${props.accountId}:agent/${props.agentId}`,
`arn:aws:bedrock:${props.region}:${props.accountId}:agent-alias/${props.agentId}/*`,
],
}));
// ── App Home Lambda ───────────────────────────────────────────────────────
this.appHomeLambda = new lambdaNodejs.NodejsFunction(this, 'AppHomeFn', {
functionName: 'seahaven-app-home',
entry: path.join(__dirname, '../../lambda/app-home/index.ts'),
handler: 'handler',
runtime: lambda.Runtime.NODEJS_22_X,
architecture: lambda.Architecture.ARM_64,
logRetention: logs.RetentionDays.TWO_MONTHS,
timeout: cdk.Duration.seconds(10),
memorySize: 256,
environment: {
SLACK_SECRET_ARN: slackSecret.secretArn,
},
bundling,
});
slackSecret.grantRead(this.appHomeLambda);
// ── HTTP API (API Gateway v2) — QBO OAuth routes only ─────────────────────
this.api = new apigatewayv2.HttpApi(this, 'Api', {
apiName: 'seahaven-slack-webhook',
description: 'Sea Haven bot — QBO OAuth routes',
});
// ── QBO OAuth Lambda ─────────────────────────────────────────────────────
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,
architecture: lambda.Architecture.ARM_64,
logRetention: logs.RetentionDays.TWO_MONTHS,
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,
});
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,
),
),
});
}
}