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 acfe8185a9 Add QBO OAuth endpoints for QuickBooks app listing
Adds /qbo/connect, /qbo/callback, /qbo/disconnect, and /qbo/launch
routes to bot.seahaven.com for Intuit app store compliance. Also
updates qbo-lookup to persist rotated refresh tokens automatically.
2026-04-13 19:31:13 -04:00

183 lines
7.2 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 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;
}
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,
QBO_CLIENT_ID: '{{resolve:secretsmanager:seahaven/qbo/oauth:SecretString:clientId}}',
QBO_CLIENT_SECRET: '{{resolve:secretsmanager:seahaven/qbo/oauth:SecretString:clientSecret}}',
REDIRECT_URI: 'https://bot.seahaven.com/qbo/callback',
},
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',
});
}
}