Initial scaffold: Bedrock-backed Slack DM bot
- 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
This commit is contained in:
commit
f7e63e50c9
18 changed files with 2613 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
*.js
|
||||||
|
!jest.config.js
|
||||||
|
*.d.ts
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# CDK asset staging directory
|
||||||
|
.cdk.staging
|
||||||
|
cdk.out
|
||||||
|
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Lambda build outputs
|
||||||
|
lambda/**/dist/
|
||||||
|
lambda/**/node_modules/
|
||||||
|
|
||||||
|
# Knowledge base source documents (may be sensitive)
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Claude Code project settings
|
||||||
|
.claude/
|
||||||
61
TODO.txt
Normal file
61
TODO.txt
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
SEAHAVEN SLACK BOT — REMAINING TASKS
|
||||||
|
======================================
|
||||||
|
|
||||||
|
DEPLOY (in progress)
|
||||||
|
---------------------
|
||||||
|
[ ] npx cdk deploy — completes the full stack
|
||||||
|
- AOSS collection + vector index (created automatically by @cdklabs construct)
|
||||||
|
- Bedrock Knowledge Base + S3 docs bucket
|
||||||
|
- Bedrock Agent (Claude Sonnet + QBO + Google Maps action groups)
|
||||||
|
- Slack webhook Lambda + processor Lambda
|
||||||
|
- API Gateway → bot.seahaven.com
|
||||||
|
- DynamoDB conversation table
|
||||||
|
|
||||||
|
|
||||||
|
AFTER DEPLOY
|
||||||
|
------------
|
||||||
|
[ ] 1. Upload knowledge base documents to S3
|
||||||
|
- Bucket name printed in stack outputs as "KBDocsBucketName"
|
||||||
|
- Drop in: SA8000 compliance docs, SOPs, employee handbook (PDF/DOCX/TXT)
|
||||||
|
|
||||||
|
[ ] 2. Trigger first KB sync
|
||||||
|
aws bedrock-agent start-ingestion-job \
|
||||||
|
--knowledge-base-id <KB_ID from stack outputs> \
|
||||||
|
--data-source-id <DS_ID> \
|
||||||
|
--region us-east-1
|
||||||
|
|
||||||
|
[ ] 3. Configure Slack app Event Subscriptions
|
||||||
|
- Slack app dashboard → Event Subscriptions → enable
|
||||||
|
- Request URL: https://bot.seahaven.com/slack/events
|
||||||
|
(also printed in stack outputs as "SlackWebhookUrl")
|
||||||
|
- Subscribe to bot event: message.im
|
||||||
|
- Save changes
|
||||||
|
|
||||||
|
[ ] 4. Verify Slack app scopes are saved and reinstall if prompted
|
||||||
|
- Required scopes: chat:write, im:history
|
||||||
|
- App Home → Messages Tab enabled
|
||||||
|
|
||||||
|
[ ] 5. Test the bot
|
||||||
|
- DM the bot in Slack
|
||||||
|
- Try: "find me a plumber" → should hit QBO first
|
||||||
|
- Try: a policy question → should hit KB
|
||||||
|
- Check DynamoDB table for logged conversations
|
||||||
|
|
||||||
|
|
||||||
|
FUTURE / FOLLOW-UP
|
||||||
|
------------------
|
||||||
|
[ ] QBO refresh token rotation
|
||||||
|
- QBO refresh tokens expire after 100 days of inactivity
|
||||||
|
- Need to add a Lambda or scheduled job to refresh and update
|
||||||
|
the seahaven/qbo/oauth secret automatically
|
||||||
|
|
||||||
|
[ ] Enable Bedrock model access (if not already done)
|
||||||
|
- AWS Console → Bedrock → Model access
|
||||||
|
- Enable: Claude 3.5 Sonnet v2, Amazon Titan Embed Text V2
|
||||||
|
|
||||||
|
|
||||||
|
SECRETS ALREADY STORED (Secrets Manager)
|
||||||
|
-----------------------------------------
|
||||||
|
[x] seahaven/slack/credentials { botToken, signingSecret }
|
||||||
|
[x] seahaven/qbo/oauth { clientId, clientSecret, refreshToken, realmId }
|
||||||
|
[x] seahaven/google/maps-api-key { apiKey }
|
||||||
14
bin/seahaven-slack-bot.ts
Normal file
14
bin/seahaven-slack-bot.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import * as cdk from 'aws-cdk-lib';
|
||||||
|
import { SeahavenSlackBotStack } from '../lib/seahaven-slack-bot-stack';
|
||||||
|
|
||||||
|
const app = new cdk.App();
|
||||||
|
|
||||||
|
new SeahavenSlackBotStack(app, 'SeahavenSlackBotStack', {
|
||||||
|
env: {
|
||||||
|
account: '328440206208',
|
||||||
|
region: 'us-east-1',
|
||||||
|
},
|
||||||
|
stackName: 'seahaven-slack-bot',
|
||||||
|
description: 'Sea Haven Industries Slack DM bot powered by AWS Bedrock',
|
||||||
|
});
|
||||||
6
cdk.context.json
Normal file
6
cdk.context.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"hosted-zone:account=328440206208:domainName=seahaven.com:region=us-east-1": {
|
||||||
|
"Id": "/hostedzone/Z06652411XKH89KTZD3XA",
|
||||||
|
"Name": "seahaven.com."
|
||||||
|
}
|
||||||
|
}
|
||||||
24
cdk.json
Normal file
24
cdk.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"app": "npx ts-node --prefer-ts-exts bin/seahaven-slack-bot.ts",
|
||||||
|
"watch": {
|
||||||
|
"include": ["**"],
|
||||||
|
"exclude": [
|
||||||
|
"README.md",
|
||||||
|
"cdk*.json",
|
||||||
|
"**/*.d.ts",
|
||||||
|
"**/*.js",
|
||||||
|
"tsconfig.json",
|
||||||
|
".git",
|
||||||
|
"node_modules",
|
||||||
|
"docs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
|
||||||
|
"@aws-cdk/core:checkSecretUsage": true,
|
||||||
|
"@aws-cdk/aws-iam:minimizePolicies": true,
|
||||||
|
"@aws-cdk/core:enablePartitionLiterals": true,
|
||||||
|
|
||||||
|
"wildcardCertArn": "arn:aws:acm:us-east-1:328440206208:certificate/a66c0994-90d4-410a-a1d9-5595c2a3fae3"
|
||||||
|
}
|
||||||
|
}
|
||||||
160
lambda/maps-lookup/index.ts
Normal file
160
lambda/maps-lookup/index.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
|
||||||
|
|
||||||
|
const secretsClient = new SecretsManagerClient({});
|
||||||
|
|
||||||
|
// ── Bedrock action group event / response types ───────────────────────────────
|
||||||
|
|
||||||
|
interface ActionParameter {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BedrockActionEvent {
|
||||||
|
messageVersion: string;
|
||||||
|
agent: { name: string; id: string; alias: string; version: string };
|
||||||
|
inputText: string;
|
||||||
|
sessionId: string;
|
||||||
|
actionGroup: string;
|
||||||
|
function: string;
|
||||||
|
parameters?: ActionParameter[];
|
||||||
|
sessionAttributes: Record<string, string>;
|
||||||
|
promptSessionAttributes: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BedrockActionResponse {
|
||||||
|
messageVersion: string;
|
||||||
|
response: {
|
||||||
|
actionGroup: string;
|
||||||
|
function: string;
|
||||||
|
functionResponse: {
|
||||||
|
responseBody: { TEXT: { body: string } };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeResponse(event: BedrockActionEvent, body: string): BedrockActionResponse {
|
||||||
|
return {
|
||||||
|
messageVersion: '1.0',
|
||||||
|
response: {
|
||||||
|
actionGroup: event.actionGroup,
|
||||||
|
function: event.function,
|
||||||
|
functionResponse: { responseBody: { TEXT: { body } } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Google Maps Places API (New) types ────────────────────────────────────────
|
||||||
|
|
||||||
|
interface MapsSecret {
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaceResult {
|
||||||
|
displayName?: { text: string };
|
||||||
|
formattedAddress?: string;
|
||||||
|
nationalPhoneNumber?: string;
|
||||||
|
websiteUri?: string;
|
||||||
|
rating?: number;
|
||||||
|
userRatingCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlacesResponse {
|
||||||
|
places?: PlaceResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedSecret: MapsSecret | undefined;
|
||||||
|
|
||||||
|
async function getMapsSecret(): Promise<MapsSecret> {
|
||||||
|
if (!cachedSecret) {
|
||||||
|
const res = await secretsClient.send(
|
||||||
|
new GetSecretValueCommand({ SecretId: process.env.MAPS_SECRET_ARN! }),
|
||||||
|
);
|
||||||
|
cachedSecret = JSON.parse(res.SecretString!) as MapsSecret;
|
||||||
|
}
|
||||||
|
return cachedSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchPlaces(
|
||||||
|
apiKey: string,
|
||||||
|
trade: string,
|
||||||
|
location: string,
|
||||||
|
): Promise<PlaceResult[]> {
|
||||||
|
const res = await fetch('https://places.googleapis.com/v1/places:searchText', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Goog-Api-Key': apiKey,
|
||||||
|
'X-Goog-FieldMask': [
|
||||||
|
'places.displayName',
|
||||||
|
'places.formattedAddress',
|
||||||
|
'places.nationalPhoneNumber',
|
||||||
|
'places.websiteUri',
|
||||||
|
'places.rating',
|
||||||
|
'places.userRatingCount',
|
||||||
|
].join(','),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
textQuery: `${trade} near ${location}`,
|
||||||
|
maxResultCount: 5,
|
||||||
|
// Bias toward business results
|
||||||
|
includedType: 'contractor',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Google Maps API error: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as PlacesResponse;
|
||||||
|
return data.places ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPlaces(places: PlaceResult[], trade: string, location: string): string {
|
||||||
|
if (places.length === 0) {
|
||||||
|
return `No ${trade} vendors found near ${location} on Google Maps.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header =
|
||||||
|
`⚠️ These are NEW vendors found via Google Maps — not yet vetted or in QuickBooks.\n` +
|
||||||
|
`Verify credentials before engaging.\n\n`;
|
||||||
|
|
||||||
|
const results = places
|
||||||
|
.map((p, i) => {
|
||||||
|
const lines: string[] = [`${i + 1}. ${p.displayName?.text ?? 'Unknown'}`];
|
||||||
|
if (p.formattedAddress) lines.push(` Address: ${p.formattedAddress}`);
|
||||||
|
if (p.nationalPhoneNumber) lines.push(` Phone: ${p.nationalPhoneNumber}`);
|
||||||
|
if (p.websiteUri) lines.push(` Website: ${p.websiteUri}`);
|
||||||
|
if (p.rating !== undefined) {
|
||||||
|
lines.push(` Rating: ${p.rating}/5 (${p.userRatingCount ?? 0} reviews)`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
})
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
return header + results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handler ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const handler = async (event: BedrockActionEvent): Promise<BedrockActionResponse> => {
|
||||||
|
const params = Object.fromEntries(
|
||||||
|
(event.parameters ?? []).map((p) => [p.name, p.value]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const trade = params.trade?.trim();
|
||||||
|
const location = params.location?.trim();
|
||||||
|
|
||||||
|
if (!trade || !location) {
|
||||||
|
return makeResponse(event, 'Both trade and location are required for a Google Maps search.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const secret = await getMapsSecret();
|
||||||
|
const places = await searchPlaces(secret.apiKey, trade, location);
|
||||||
|
return makeResponse(event, formatPlaces(places, trade, location));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Google Maps lookup error:', err);
|
||||||
|
return makeResponse(event, `Google Maps search failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
193
lambda/qbo-lookup/index.ts
Normal file
193
lambda/qbo-lookup/index.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
|
||||||
|
|
||||||
|
const secretsClient = new SecretsManagerClient({});
|
||||||
|
|
||||||
|
// ── Bedrock action group event / response types ───────────────────────────────
|
||||||
|
|
||||||
|
interface ActionParameter {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BedrockActionEvent {
|
||||||
|
messageVersion: string;
|
||||||
|
agent: { name: string; id: string; alias: string; version: string };
|
||||||
|
inputText: string;
|
||||||
|
sessionId: string;
|
||||||
|
actionGroup: string;
|
||||||
|
function: string;
|
||||||
|
parameters?: ActionParameter[];
|
||||||
|
sessionAttributes: Record<string, string>;
|
||||||
|
promptSessionAttributes: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BedrockActionResponse {
|
||||||
|
messageVersion: string;
|
||||||
|
response: {
|
||||||
|
actionGroup: string;
|
||||||
|
function: string;
|
||||||
|
functionResponse: {
|
||||||
|
responseBody: { TEXT: { body: string } };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeResponse(event: BedrockActionEvent, body: string): BedrockActionResponse {
|
||||||
|
return {
|
||||||
|
messageVersion: '1.0',
|
||||||
|
response: {
|
||||||
|
actionGroup: event.actionGroup,
|
||||||
|
function: event.function,
|
||||||
|
functionResponse: { responseBody: { TEXT: { body } } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QBO types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface QBOSecret {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
refreshToken: string;
|
||||||
|
realmId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QBOVendor {
|
||||||
|
Id: string;
|
||||||
|
DisplayName: string;
|
||||||
|
CompanyName?: string;
|
||||||
|
PrimaryPhone?: { FreeFormNumber: string };
|
||||||
|
PrimaryEmailAddr?: { Address: string };
|
||||||
|
Notes?: string;
|
||||||
|
Balance?: number;
|
||||||
|
MetaData: { CreateTime: string; LastUpdatedTime: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedSecret: QBOSecret | undefined;
|
||||||
|
|
||||||
|
async function getQBOSecret(): Promise<QBOSecret> {
|
||||||
|
if (!cachedSecret) {
|
||||||
|
const res = await secretsClient.send(
|
||||||
|
new GetSecretValueCommand({ SecretId: process.env.QBO_SECRET_ARN! }),
|
||||||
|
);
|
||||||
|
cachedSecret = JSON.parse(res.SecretString!) as QBOSecret;
|
||||||
|
}
|
||||||
|
return cachedSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken(secret: QBOSecret): Promise<string> {
|
||||||
|
const credentials = Buffer.from(`${secret.clientId}:${secret.clientSecret}`).toString('base64');
|
||||||
|
|
||||||
|
const res = await fetch('https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${credentials}`,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: secret.refreshToken,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`QBO token refresh failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as { access_token: string };
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVendorQuery(trade?: string, name?: string): string {
|
||||||
|
const clauses: string[] = [];
|
||||||
|
|
||||||
|
// Sanitize inputs to prevent SOQL injection (single quotes escaped)
|
||||||
|
const sanitize = (s: string) => s.replace(/'/g, "''").substring(0, 100);
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
clauses.push(`DisplayName LIKE '%${sanitize(name)}%'`);
|
||||||
|
}
|
||||||
|
if (trade) {
|
||||||
|
const t = sanitize(trade);
|
||||||
|
clauses.push(`DisplayName LIKE '%${t}%'`);
|
||||||
|
clauses.push(`Notes LIKE '%${t}%'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = clauses.length > 0 ? `WHERE ${clauses.join(' OR ')}` : '';
|
||||||
|
return `SELECT * FROM Vendor ${where} MAXRESULTS 10`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryVendors(
|
||||||
|
accessToken: string,
|
||||||
|
realmId: string,
|
||||||
|
trade?: string,
|
||||||
|
name?: string,
|
||||||
|
): Promise<QBOVendor[]> {
|
||||||
|
const sql = buildVendorQuery(trade, name);
|
||||||
|
const url = `https://quickbooks.api.intuit.com/v3/company/${realmId}/query?query=${encodeURIComponent(sql)}&minorversion=65`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`QBO query failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
QueryResponse: { Vendor?: QBOVendor[]; totalCount?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
return data.QueryResponse.Vendor ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVendors(vendors: QBOVendor[]): string {
|
||||||
|
if (vendors.length === 0) {
|
||||||
|
return 'No matching vendors found in QuickBooks.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return vendors
|
||||||
|
.map((v) => {
|
||||||
|
const lines: string[] = [`Vendor: ${v.DisplayName}`];
|
||||||
|
if (v.CompanyName && v.CompanyName !== v.DisplayName) {
|
||||||
|
lines.push(` Company: ${v.CompanyName}`);
|
||||||
|
}
|
||||||
|
if (v.PrimaryPhone) lines.push(` Phone: ${v.PrimaryPhone.FreeFormNumber}`);
|
||||||
|
if (v.PrimaryEmailAddr) lines.push(` Email: ${v.PrimaryEmailAddr.Address}`);
|
||||||
|
if (v.Notes) lines.push(` Notes: ${v.Notes}`);
|
||||||
|
if (v.Balance !== undefined) lines.push(` Outstanding balance: $${v.Balance.toFixed(2)}`);
|
||||||
|
lines.push(` Last updated: ${new Date(v.MetaData.LastUpdatedTime).toLocaleDateString()}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
})
|
||||||
|
.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handler ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const handler = async (event: BedrockActionEvent): Promise<BedrockActionResponse> => {
|
||||||
|
const params = Object.fromEntries(
|
||||||
|
(event.parameters ?? []).map((p) => [p.name, p.value]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const trade = params.trade?.trim() || undefined;
|
||||||
|
const name = params.name?.trim() || undefined;
|
||||||
|
|
||||||
|
if (!trade && !name) {
|
||||||
|
return makeResponse(event, 'Please provide at least one of: trade or name to search for.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const secret = await getQBOSecret();
|
||||||
|
const accessToken = await refreshAccessToken(secret);
|
||||||
|
const vendors = await queryVendors(accessToken, secret.realmId, trade, name);
|
||||||
|
return makeResponse(event, formatVendors(vendors));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('QBO lookup error:', err);
|
||||||
|
return makeResponse(event, `QuickBooks lookup failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
144
lambda/slack-processor/index.ts
Normal file
144
lambda/slack-processor/index.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
|
||||||
|
import {
|
||||||
|
BedrockAgentRuntimeClient,
|
||||||
|
InvokeAgentCommand,
|
||||||
|
} from '@aws-sdk/client-bedrock-agent-runtime';
|
||||||
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
||||||
|
import { DynamoDBDocumentClient, PutCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
|
||||||
|
|
||||||
|
const secretsClient = new SecretsManagerClient({});
|
||||||
|
const bedrockClient = new BedrockAgentRuntimeClient({ region: process.env.REGION! });
|
||||||
|
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
|
||||||
|
|
||||||
|
interface SlackCredentials {
|
||||||
|
botToken: string;
|
||||||
|
signingSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessorEvent {
|
||||||
|
userId: string;
|
||||||
|
channelId: string;
|
||||||
|
text: string;
|
||||||
|
ts: string;
|
||||||
|
threadTs?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the secret across warm invocations
|
||||||
|
let cachedCredentials: SlackCredentials | undefined;
|
||||||
|
|
||||||
|
async function getSlackCredentials(): Promise<SlackCredentials> {
|
||||||
|
if (!cachedCredentials) {
|
||||||
|
const res = await secretsClient.send(
|
||||||
|
new GetSecretValueCommand({ SecretId: process.env.SLACK_SECRET_ARN! }),
|
||||||
|
);
|
||||||
|
cachedCredentials = JSON.parse(res.SecretString!) as SlackCredentials;
|
||||||
|
}
|
||||||
|
return cachedCredentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postToSlack(
|
||||||
|
token: string,
|
||||||
|
channel: string,
|
||||||
|
text: string,
|
||||||
|
threadTs?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const body: Record<string, string> = { channel, text };
|
||||||
|
if (threadTs) body.thread_ts = threadTs;
|
||||||
|
|
||||||
|
const res = await fetch('https://slack.com/api/chat.postMessage', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Slack HTTP error ${res.status}`);
|
||||||
|
const data = (await res.json()) as { ok: boolean; error?: string };
|
||||||
|
if (!data.ok) throw new Error(`Slack API error: ${data.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse the most recent Bedrock session if there's been activity in the last 30 minutes.
|
||||||
|
// This enables multi-turn conversation without re-introducing context each message.
|
||||||
|
async function resolveSessionId(userId: string): Promise<string> {
|
||||||
|
const cutoff = new Date(Date.now() - 30 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
const result = await ddb.send(
|
||||||
|
new QueryCommand({
|
||||||
|
TableName: process.env.CONVERSATION_TABLE!,
|
||||||
|
KeyConditionExpression: 'userId = :uid AND #ts > :cutoff',
|
||||||
|
ExpressionAttributeNames: { '#ts': 'timestamp' },
|
||||||
|
ExpressionAttributeValues: { ':uid': userId, ':cutoff': cutoff },
|
||||||
|
Limit: 1,
|
||||||
|
ScanIndexForward: false,
|
||||||
|
ProjectionExpression: 'sessionId',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.Items?.length && result.Items[0].sessionId) {
|
||||||
|
return result.Items[0].sessionId as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${userId}-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeAgent(sessionId: string, inputText: string): Promise<string> {
|
||||||
|
const res = await bedrockClient.send(
|
||||||
|
new InvokeAgentCommand({
|
||||||
|
agentId: process.env.AGENT_ID!,
|
||||||
|
agentAliasId: process.env.AGENT_ALIAS_ID!,
|
||||||
|
sessionId,
|
||||||
|
inputText,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let answer = '';
|
||||||
|
if (res.completion) {
|
||||||
|
for await (const event of res.completion) {
|
||||||
|
if (event.chunk?.bytes) {
|
||||||
|
answer += Buffer.from(event.chunk.bytes).toString('utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return answer.trim() || 'I was unable to generate a response. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler = async (event: ProcessorEvent): Promise<void> => {
|
||||||
|
const { userId, channelId, text, ts, threadTs } = event;
|
||||||
|
|
||||||
|
const [credentials, sessionId] = await Promise.all([
|
||||||
|
getSlackCredentials(),
|
||||||
|
resolveSessionId(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let answer: string;
|
||||||
|
try {
|
||||||
|
answer = await invokeAgent(sessionId, text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Bedrock agent invocation failed:', err);
|
||||||
|
answer = 'Sorry, I encountered an error processing your request. Please try again in a moment.';
|
||||||
|
}
|
||||||
|
|
||||||
|
await postToSlack(credentials.botToken, channelId, answer, threadTs ?? ts);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const ttl = Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60; // 90-day TTL
|
||||||
|
|
||||||
|
await ddb.send(
|
||||||
|
new PutCommand({
|
||||||
|
TableName: process.env.CONVERSATION_TABLE!,
|
||||||
|
Item: {
|
||||||
|
userId,
|
||||||
|
timestamp: now,
|
||||||
|
sessionId,
|
||||||
|
question: text,
|
||||||
|
answer,
|
||||||
|
channelId,
|
||||||
|
messageTs: ts,
|
||||||
|
ttl,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
115
lambda/slack-webhook/index.ts
Normal file
115
lambda/slack-webhook/index.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
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' };
|
||||||
|
};
|
||||||
209
lib/constructs/bedrock-agent.ts
Normal file
209
lib/constructs/bedrock-agent.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
import * as cdk from 'aws-cdk-lib';
|
||||||
|
import { Construct } from 'constructs';
|
||||||
|
import * as iam from 'aws-cdk-lib/aws-iam';
|
||||||
|
import * as lambda from 'aws-cdk-lib/aws-lambda';
|
||||||
|
import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs';
|
||||||
|
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
|
||||||
|
import * as bedrock from 'aws-cdk-lib/aws-bedrock';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export interface BedrockAgentProps {
|
||||||
|
accountId: string;
|
||||||
|
region: string;
|
||||||
|
knowledgeBaseId: string;
|
||||||
|
knowledgeBaseArn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BedrockAgentConstruct extends Construct {
|
||||||
|
public readonly agent: bedrock.CfnAgent;
|
||||||
|
public readonly agentAlias: bedrock.CfnAgentAlias;
|
||||||
|
public readonly qboLambda: lambdaNodejs.NodejsFunction;
|
||||||
|
public readonly mapsLambda: lambdaNodejs.NodejsFunction;
|
||||||
|
|
||||||
|
// Foundation model used for orchestration
|
||||||
|
private static readonly MODEL_ID = 'anthropic.claude-3-5-sonnet-20241022-v2:0';
|
||||||
|
|
||||||
|
constructor(scope: Construct, id: string, props: BedrockAgentProps) {
|
||||||
|
super(scope, id);
|
||||||
|
|
||||||
|
// Reference secrets created manually in Secrets Manager (see README for structure)
|
||||||
|
const qboSecret = secretsmanager.Secret.fromSecretNameV2(
|
||||||
|
this, 'QBOSecret', 'seahaven/qbo/oauth',
|
||||||
|
);
|
||||||
|
const mapsSecret = secretsmanager.Secret.fromSecretNameV2(
|
||||||
|
this, 'MapsSecret', 'seahaven/google/maps-api-key',
|
||||||
|
);
|
||||||
|
|
||||||
|
const bundling: lambdaNodejs.BundlingOptions = {
|
||||||
|
externalModules: ['@aws-sdk/*'],
|
||||||
|
minify: true,
|
||||||
|
sourceMap: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── QBO vendor lookup action group Lambda ─────────────────────────────────
|
||||||
|
this.qboLambda = new lambdaNodejs.NodejsFunction(this, 'QBOLookupFn', {
|
||||||
|
functionName: 'seahaven-qbo-lookup',
|
||||||
|
entry: path.join(__dirname, '../../lambda/qbo-lookup/index.ts'),
|
||||||
|
handler: 'handler',
|
||||||
|
runtime: lambda.Runtime.NODEJS_22_X,
|
||||||
|
timeout: cdk.Duration.seconds(30),
|
||||||
|
memorySize: 256,
|
||||||
|
environment: { QBO_SECRET_ARN: qboSecret.secretArn },
|
||||||
|
bundling,
|
||||||
|
});
|
||||||
|
qboSecret.grantRead(this.qboLambda);
|
||||||
|
|
||||||
|
// ── Google Maps lookup action group Lambda ────────────────────────────────
|
||||||
|
this.mapsLambda = new lambdaNodejs.NodejsFunction(this, 'MapsLookupFn', {
|
||||||
|
functionName: 'seahaven-maps-lookup',
|
||||||
|
entry: path.join(__dirname, '../../lambda/maps-lookup/index.ts'),
|
||||||
|
handler: 'handler',
|
||||||
|
runtime: lambda.Runtime.NODEJS_22_X,
|
||||||
|
timeout: cdk.Duration.seconds(30),
|
||||||
|
memorySize: 256,
|
||||||
|
environment: { MAPS_SECRET_ARN: mapsSecret.secretArn },
|
||||||
|
bundling,
|
||||||
|
});
|
||||||
|
mapsSecret.grantRead(this.mapsLambda);
|
||||||
|
|
||||||
|
// ── Bedrock Agent execution role ──────────────────────────────────────────
|
||||||
|
const agentRole = new iam.Role(this, 'AgentRole', {
|
||||||
|
roleName: 'AmazonBedrockExecutionRoleForAgents_seahaven',
|
||||||
|
assumedBy: new iam.ServicePrincipal('bedrock.amazonaws.com', {
|
||||||
|
conditions: {
|
||||||
|
StringEquals: { 'aws:SourceAccount': props.accountId },
|
||||||
|
ArnLike: {
|
||||||
|
'aws:SourceArn': `arn:aws:bedrock:${props.region}:${props.accountId}:agent/*`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
agentRole.addToPolicy(new iam.PolicyStatement({
|
||||||
|
actions: ['bedrock:InvokeModel'],
|
||||||
|
resources: [
|
||||||
|
`arn:aws:bedrock:${props.region}::foundation-model/${BedrockAgentConstruct.MODEL_ID}`,
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
agentRole.addToPolicy(new iam.PolicyStatement({
|
||||||
|
actions: ['bedrock:Retrieve'],
|
||||||
|
resources: [props.knowledgeBaseArn],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Allow Bedrock to invoke the action group Lambdas
|
||||||
|
this.qboLambda.addPermission('BedrockInvokeQBO', {
|
||||||
|
principal: new iam.ServicePrincipal('bedrock.amazonaws.com'),
|
||||||
|
sourceAccount: props.accountId,
|
||||||
|
});
|
||||||
|
this.mapsLambda.addPermission('BedrockInvokeMaps', {
|
||||||
|
principal: new iam.ServicePrincipal('bedrock.amazonaws.com'),
|
||||||
|
sourceAccount: props.accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Agent instruction (system prompt) ────────────────────────────────────
|
||||||
|
const instruction = `You are the Sea Haven Industries internal assistant, accessible to employees via Slack direct message. Sea Haven is a facility services company.
|
||||||
|
|
||||||
|
You help employees with:
|
||||||
|
1. Finding vendors and contractors for facility work
|
||||||
|
2. Company policies, SOPs, and SA8000 social accountability compliance questions
|
||||||
|
3. Employee handbook questions
|
||||||
|
|
||||||
|
## Vendor Query Rules — STRICTLY follow this priority order:
|
||||||
|
|
||||||
|
Step 1: ALWAYS call QBO_Lookup.search_vendors first. This searches our QuickBooks Online account for existing, vetted vendors we already have a relationship with.
|
||||||
|
|
||||||
|
Step 2: If QBO_Lookup returns no suitable match, search the knowledge base for approved vendor documentation or lists.
|
||||||
|
|
||||||
|
Step 3: ONLY if both QBO and the knowledge base return nothing suitable should you call Google_Maps_Lookup.search_nearby_vendors to find new options.
|
||||||
|
|
||||||
|
When presenting vendor results:
|
||||||
|
- QBO vendors: include name, trade/specialty, phone, email, and last updated date
|
||||||
|
- Knowledge base vendors: cite the source document
|
||||||
|
- Google Maps vendors: clearly label these as NEW (not yet vetted), include name, address, phone, and rating
|
||||||
|
|
||||||
|
## General Questions:
|
||||||
|
Use the knowledge base for policy, SOP, SA8000 compliance, and handbook questions. Cite the specific document or section when possible. If the information is not in the knowledge base, say so clearly — do not guess.
|
||||||
|
|
||||||
|
Keep responses concise, professional, and actionable.`;
|
||||||
|
|
||||||
|
// ── CfnAgent ──────────────────────────────────────────────────────────────
|
||||||
|
this.agent = new bedrock.CfnAgent(this, 'Agent', {
|
||||||
|
agentName: 'seahaven-assistant',
|
||||||
|
description: 'Sea Haven Industries internal Slack assistant',
|
||||||
|
agentResourceRoleArn: agentRole.roleArn,
|
||||||
|
foundationModel: BedrockAgentConstruct.MODEL_ID,
|
||||||
|
instruction,
|
||||||
|
idleSessionTtlInSeconds: 1800, // 30 min — matches the processor's session window
|
||||||
|
knowledgeBases: [
|
||||||
|
{
|
||||||
|
knowledgeBaseId: props.knowledgeBaseId,
|
||||||
|
description: 'Sea Haven internal documents: SOPs, SA8000 compliance docs, employee handbook, approved vendor lists',
|
||||||
|
knowledgeBaseState: 'ENABLED',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actionGroups: [
|
||||||
|
{
|
||||||
|
actionGroupName: 'QBO_Lookup',
|
||||||
|
description: 'Search QuickBooks Online for existing Sea Haven vendors by trade or name',
|
||||||
|
actionGroupState: 'ENABLED',
|
||||||
|
actionGroupExecutor: { lambda: this.qboLambda.functionArn },
|
||||||
|
functionSchema: {
|
||||||
|
functions: [
|
||||||
|
{
|
||||||
|
name: 'search_vendors',
|
||||||
|
description: 'Search QuickBooks Online for existing vendors by trade category or company name. Returns contact info and outstanding balance.',
|
||||||
|
parameters: {
|
||||||
|
trade: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Trade or service type to search for (e.g., "plumbing", "electrical", "HVAC", "janitorial", "landscaping")',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Vendor company name or partial name to search for',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actionGroupName: 'Google_Maps_Lookup',
|
||||||
|
description: 'Search Google Maps Places for vendors near a location. Use ONLY when QBO and the knowledge base have no suitable vendor.',
|
||||||
|
actionGroupState: 'ENABLED',
|
||||||
|
actionGroupExecutor: { lambda: this.mapsLambda.functionArn },
|
||||||
|
functionSchema: {
|
||||||
|
functions: [
|
||||||
|
{
|
||||||
|
name: 'search_nearby_vendors',
|
||||||
|
description: 'Search Google Maps Places for local vendors and contractors by trade type and location.',
|
||||||
|
parameters: {
|
||||||
|
trade: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Trade or service type to search for (e.g., "plumbing contractor", "electrician")',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'City, address, or area to search near (e.g., "Seattle WA", "Chicago IL")',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Agent alias (stable ARN for invocations) ──────────────────────────────
|
||||||
|
// Creating the alias also triggers agent preparation in CloudFormation.
|
||||||
|
this.agentAlias = new bedrock.CfnAgentAlias(this, 'AgentAlias', {
|
||||||
|
agentId: this.agent.attrAgentId,
|
||||||
|
agentAliasName: 'live',
|
||||||
|
description: 'Production alias — seahaven-assistant',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
31
lib/constructs/conversation-log.ts
Normal file
31
lib/constructs/conversation-log.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import * as cdk from 'aws-cdk-lib';
|
||||||
|
import { Construct } from 'constructs';
|
||||||
|
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
|
||||||
|
|
||||||
|
export class ConversationLogConstruct extends Construct {
|
||||||
|
public readonly table: dynamodb.Table;
|
||||||
|
|
||||||
|
constructor(scope: Construct, id: string) {
|
||||||
|
super(scope, id);
|
||||||
|
|
||||||
|
this.table = new dynamodb.Table(this, 'Table', {
|
||||||
|
tableName: 'seahaven-conversations',
|
||||||
|
// PK: Slack user ID (e.g. U0123456789)
|
||||||
|
partitionKey: { name: 'userId', type: dynamodb.AttributeType.STRING },
|
||||||
|
// SK: ISO-8601 timestamp — enables range queries for session lookup
|
||||||
|
sortKey: { name: 'timestamp', type: dynamodb.AttributeType.STRING },
|
||||||
|
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
||||||
|
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
||||||
|
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
|
||||||
|
// TTL attribute — records are automatically expired after 90 days
|
||||||
|
timeToLiveAttribute: 'ttl',
|
||||||
|
});
|
||||||
|
|
||||||
|
// GSI for looking up all turns in a given Bedrock session (for context retrieval)
|
||||||
|
this.table.addGlobalSecondaryIndex({
|
||||||
|
indexName: 'sessionId-index',
|
||||||
|
partitionKey: { name: 'sessionId', type: dynamodb.AttributeType.STRING },
|
||||||
|
sortKey: { name: 'timestamp', type: dynamodb.AttributeType.STRING },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
51
lib/constructs/knowledge-base.ts
Normal file
51
lib/constructs/knowledge-base.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import * as cdk from 'aws-cdk-lib';
|
||||||
|
import { Construct } from 'constructs';
|
||||||
|
import * as s3 from 'aws-cdk-lib/aws-s3';
|
||||||
|
import { bedrock } from '@cdklabs/generative-ai-cdk-constructs';
|
||||||
|
|
||||||
|
export interface KnowledgeBaseProps {
|
||||||
|
accountId: string;
|
||||||
|
region: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KnowledgeBaseConstruct extends Construct {
|
||||||
|
public readonly knowledgeBase: bedrock.VectorKnowledgeBase;
|
||||||
|
public readonly dataSource: bedrock.S3DataSource;
|
||||||
|
public readonly docsBucket: s3.Bucket;
|
||||||
|
|
||||||
|
constructor(scope: Construct, id: string, props: KnowledgeBaseProps) {
|
||||||
|
super(scope, id);
|
||||||
|
|
||||||
|
// S3 bucket for SA8000 docs, SOPs, and employee handbook
|
||||||
|
this.docsBucket = new s3.Bucket(this, 'DocsBucket', {
|
||||||
|
bucketName: `seahaven-kb-docs-${props.accountId}`,
|
||||||
|
encryption: s3.BucketEncryption.S3_MANAGED,
|
||||||
|
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
||||||
|
versioned: true,
|
||||||
|
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
||||||
|
});
|
||||||
|
|
||||||
|
// VectorKnowledgeBase from @cdklabs/generative-ai-cdk-constructs handles:
|
||||||
|
// - AOSS collection with correct encryption/network/access policies
|
||||||
|
// - IAM execution role
|
||||||
|
// - kNN vector index creation via built-in custom resource
|
||||||
|
// - Bedrock Knowledge Base creation once the index is ready
|
||||||
|
this.knowledgeBase = new bedrock.VectorKnowledgeBase(this, 'KnowledgeBase', {
|
||||||
|
embeddingsModel: bedrock.BedrockFoundationModel.TITAN_EMBED_TEXT_V2_1024,
|
||||||
|
name: 'seahaven-kb',
|
||||||
|
description: 'SA8000 compliance docs, SOPs, and employee handbook',
|
||||||
|
instruction: 'Use this knowledge base to answer questions about Sea Haven Industries company policies, SOPs, SA8000 social accountability compliance requirements, approved vendor lists, and the employee handbook.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// S3 data source — chunking configured via ChunkingStrategy.fixedSize()
|
||||||
|
this.dataSource = new bedrock.S3DataSource(this, 'S3DataSource', {
|
||||||
|
bucket: this.docsBucket,
|
||||||
|
knowledgeBase: this.knowledgeBase,
|
||||||
|
dataSourceName: 'seahaven-s3-docs',
|
||||||
|
chunkingStrategy: bedrock.ChunkingStrategy.fixedSize({
|
||||||
|
maxTokens: 512,
|
||||||
|
overlapPercentage: 20,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
144
lib/constructs/slack-handler.ts
Normal file
144
lib/constructs/slack-handler.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
60
lib/seahaven-slack-bot-stack.ts
Normal file
60
lib/seahaven-slack-bot-stack.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import * as cdk from 'aws-cdk-lib';
|
||||||
|
import { Construct } from 'constructs';
|
||||||
|
import { ConversationLogConstruct } from './constructs/conversation-log';
|
||||||
|
import { KnowledgeBaseConstruct } from './constructs/knowledge-base';
|
||||||
|
import { BedrockAgentConstruct } from './constructs/bedrock-agent';
|
||||||
|
import { SlackHandlerConstruct } from './constructs/slack-handler';
|
||||||
|
|
||||||
|
export class SeahavenSlackBotStack extends cdk.Stack {
|
||||||
|
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
|
||||||
|
super(scope, id, props);
|
||||||
|
|
||||||
|
// Wildcard cert ARN (*.seahaven.com) — set in cdk.json or via --context
|
||||||
|
const wildcardCertArn = this.node.tryGetContext('wildcardCertArn') as string | undefined;
|
||||||
|
if (!wildcardCertArn || wildcardCertArn.includes('CHANGE-ME')) {
|
||||||
|
throw new Error(
|
||||||
|
'Set wildcardCertArn in cdk.json or pass --context wildcardCertArn=arn:aws:acm:...\n' +
|
||||||
|
'Run: aws acm list-certificates --region us-east-1 to find your cert ARN.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conversation history (DynamoDB) ───────────────────────────────────────
|
||||||
|
const conversationLog = new ConversationLogConstruct(this, 'ConversationLog');
|
||||||
|
|
||||||
|
// ── Bedrock Knowledge Base (OpenSearch Serverless + S3) ───────────────────
|
||||||
|
const knowledgeBase = new KnowledgeBaseConstruct(this, 'KnowledgeBase', {
|
||||||
|
accountId: this.account,
|
||||||
|
region: this.region,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Bedrock Agent (Claude Sonnet + QBO + Google Maps action groups) ───────
|
||||||
|
const bedrockAgent = new BedrockAgentConstruct(this, 'BedrockAgent', {
|
||||||
|
accountId: this.account,
|
||||||
|
region: this.region,
|
||||||
|
knowledgeBaseId: knowledgeBase.knowledgeBase.knowledgeBaseId,
|
||||||
|
knowledgeBaseArn: knowledgeBase.knowledgeBase.knowledgeBaseArn,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Slack webhook handler (API Gateway + Lambda) ───────────────────────────
|
||||||
|
new SlackHandlerConstruct(this, 'SlackHandler', {
|
||||||
|
accountId: this.account,
|
||||||
|
region: this.region,
|
||||||
|
agentId: bedrockAgent.agent.attrAgentId,
|
||||||
|
agentAliasId: bedrockAgent.agentAlias.attrAgentAliasId,
|
||||||
|
conversationTable: conversationLog.table,
|
||||||
|
wildcardCertArn,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Stack outputs ─────────────────────────────────────────────────────────
|
||||||
|
new cdk.CfnOutput(this, 'KBDocsBucketName', {
|
||||||
|
value: knowledgeBase.docsBucket.bucketName,
|
||||||
|
description: 'Upload SA8000 docs, SOPs, and employee handbook here to populate the KB',
|
||||||
|
});
|
||||||
|
|
||||||
|
new cdk.CfnOutput(this, 'AgentId', {
|
||||||
|
value: bedrockAgent.agent.attrAgentId,
|
||||||
|
description: 'Bedrock Agent ID',
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
1246
package-lock.json
generated
Normal file
1246
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
package.json
Normal file
26
package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "seahaven-slack-bot",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"watch": "tsc -w",
|
||||||
|
"cdk": "cdk",
|
||||||
|
"deploy": "cdk deploy --all",
|
||||||
|
"diff": "cdk diff",
|
||||||
|
"synth": "cdk synth"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/aws-lambda": "^8.10.149",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"aws-cdk": "^2.180.0",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "~5.7.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@cdklabs/generative-ai-cdk-constructs": "^0.1.0",
|
||||||
|
"aws-cdk-lib": "^2.180.0",
|
||||||
|
"constructs": "^10.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
87
scripts/create-aoss-index.ts
Normal file
87
scripts/create-aoss-index.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* Creates the kNN vector index in the OpenSearch Serverless collection.
|
||||||
|
*
|
||||||
|
* Run this ONCE after `cdk deploy` creates the AOSS collection but BEFORE
|
||||||
|
* triggering a Bedrock Knowledge Base sync/ingestion job.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx ts-node scripts/create-aoss-index.ts <collection-endpoint>
|
||||||
|
*
|
||||||
|
* The collection endpoint is printed as a stack output after deploy:
|
||||||
|
* npx cdk deploy --outputs-file outputs.json
|
||||||
|
* cat outputs.json
|
||||||
|
*
|
||||||
|
* Requirements:
|
||||||
|
* npm install --save-dev @opensearch-project/opensearch aws4 ts-node typescript
|
||||||
|
* AWS credentials must have aoss:APIAccessAll on the collection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Client } from '@opensearch-project/opensearch';
|
||||||
|
import { AwsSigv4Signer } from '@opensearch-project/opensearch/aws';
|
||||||
|
import { defaultProvider } from '@aws-sdk/credential-provider-node';
|
||||||
|
|
||||||
|
const INDEX_NAME = 'seahaven-kb-index';
|
||||||
|
|
||||||
|
// Field names must match exactly what's configured in knowledge-base.ts
|
||||||
|
const INDEX_BODY = {
|
||||||
|
settings: {
|
||||||
|
'index.knn': true,
|
||||||
|
},
|
||||||
|
mappings: {
|
||||||
|
properties: {
|
||||||
|
// Titan Embed Text v2 with 1024 dimensions
|
||||||
|
'bedrock-knowledge-base-default-vector': {
|
||||||
|
type: 'knn_vector',
|
||||||
|
dimension: 1024,
|
||||||
|
method: {
|
||||||
|
name: 'hnsw',
|
||||||
|
space_type: 'l2',
|
||||||
|
engine: 'faiss',
|
||||||
|
parameters: { ef_construction: 512, m: 16 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AMAZON_BEDROCK_TEXT_CHUNK: { type: 'text' },
|
||||||
|
AMAZON_BEDROCK_METADATA: { type: 'text', index: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const endpoint = process.argv[2];
|
||||||
|
if (!endpoint) {
|
||||||
|
console.error('Usage: npx ts-node scripts/create-aoss-index.ts <collection-endpoint>');
|
||||||
|
console.error('Example: npx ts-node scripts/create-aoss-index.ts https://abc123.us-east-1.aoss.amazonaws.com');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
...AwsSigv4Signer({
|
||||||
|
region: 'us-east-1',
|
||||||
|
service: 'aoss',
|
||||||
|
getCredentials: defaultProvider(),
|
||||||
|
}),
|
||||||
|
node: endpoint,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Creating index "${INDEX_NAME}" on ${endpoint} …`);
|
||||||
|
|
||||||
|
const exists = await client.indices.exists({ index: INDEX_NAME });
|
||||||
|
if (exists.statusCode === 200) {
|
||||||
|
console.log(`Index "${INDEX_NAME}" already exists — nothing to do.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await client.indices.create({
|
||||||
|
index: INDEX_NAME,
|
||||||
|
body: INDEX_BODY,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Index created:', JSON.stringify(res.body, null, 2));
|
||||||
|
console.log('\nNext step: trigger a sync from the Bedrock console or run:');
|
||||||
|
console.log(' aws bedrock-agent start-ingestion-job --knowledge-base-id <KB_ID> --data-source-id <DS_ID>');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Failed to create index:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist", "lambda", "scripts"]
|
||||||
|
}
|
||||||
Reference in a new issue