├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .gitignore ├── .npmignore ├── Diagram.png ├── LICENSE.md ├── README.md ├── bin └── aws-serverless-twitter-bot.ts ├── cdk.json ├── lib ├── constructs │ ├── analysis │ │ ├── download-images.lambda.ts │ │ └── download-images.ts │ ├── analytics │ │ ├── transform-records.lambda.ts │ │ └── transform-records.ts │ ├── egress │ │ ├── tweet-construct.lambda.ts │ │ └── tweet-construct.ts │ ├── ingress │ │ ├── twitter-construct.lambda.ts │ │ └── twitter-construct.ts │ └── responding │ │ ├── chat-bot-fulfilment.lambda.ts │ │ ├── chat-bot-fulfilment.ts │ │ ├── chat-bot.lambda.ts │ │ ├── chat-bot.ts │ │ ├── process-images.lambda.ts │ │ └── process-images.ts └── stacks │ ├── alerting-stack.ts │ ├── analysis-stack.ts │ ├── analytics-stack.ts │ ├── egress-stack.ts │ ├── ingress-stack.ts │ ├── plumbing-stack.ts │ └── responding-stack.ts ├── package-lock.json ├── package.json └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | cdk.out 3 | *.js 4 | *.d.ts -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "extends": [ 6 | "airbnb-typescript", 7 | "plugin:import/recommended", 8 | "plugin:import/typescript", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "parserOptions": { 13 | "project": ["tsconfig.json"] 14 | }, 15 | "rules": { 16 | "react/jsx-filename-extension": "off", 17 | "import/no-unresolved": "off" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.js text eol=lf 4 | *.ts text eol=lf 5 | *.json text eol=lf 6 | *.md text eol=lf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.d.ts 3 | node_modules 4 | 5 | # CDK asset staging directory 6 | .cdk.staging 7 | cdk.out 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /Diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/makit/aws-serverless-twitter-bot/d175ec57c3e318c9866ba99d70332a6357746f81/Diagram.png -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Martyn Kilbryde 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS Serverless Event Driven Twitter Bot 2 | An AWS Cloud Native application using CDK (Written in TypeScript) that defines a Serverless Event Driven application for interacting with Twitter and utilising Machine Learning / AI as a Service. 3 | 4 | ## Overview 5 | ![System Diagram](Diagram.png) 6 | 7 | First point to realise for this application, is that it could be simplified down to a few lambdas without Events - and for this single use-case it would work fine. The premise here is to demonstrate many different concepts and a Serverless Event Driven architecture that works for simple applications as well as for the Enterprise. 8 | 9 | This whole application is defined here within CDK, apart from the Ad-Hoc Athena queries, some examples can be seen below. 10 | 11 | ### Serverless 12 | 13 | The application only costs money when it is processing a message from Twitter, otherwise it is sitting in a standby state - with the only cost being the minor cost of S3 and CloudWatch log persistence. More can be seen in the Cost section below. 14 | 15 | ### Event Driven 16 | It is Event Driven because it is reactive based on the webhooks coming from Twitter, as soon as a message is received then AWS will take care of provisioning the resources, as defined in this application, to process it and then shut down again. This works whether it's a single message or a burst of 100's of messages. 17 | 18 | ## Ingress 19 | The Ingress Microservice at the top of the diagram takes care of accepting the messages from Twitter, this includes the authentication (Using API Keys stored in Secrets Manager). Each message is sent to a Lambda using a Proxy integration - this takes care of Authentication and then sending the payload into EventBridge. *Note, the messages could be sent directly to EventBridge from API Gateway, but a Lambda Authoriser would be needed anyway - so using a Lambda Proxy Integration keeps this simple*. 20 | 21 | The raw message is sent to EventBridge with the type of message set as the `detail-type` - Twitter lists all of the types that could be sent to the endpoint in it's [documentation](https://developer.twitter.com/en/docs/twitter-api/enterprise/account-activity-api/guides/account-activity-data-objects). 22 | 23 | A simple [Anti-Corruption Layer](https://deviq.com/domain-driven-design/anti-corruption-layer) also publishes a simplified Domain event of `MESSAGE_RECEIVED`. 24 | 25 | ## Plumbing 26 | The Plumbing Microservice consists of the Event Hub within EventBridge utilised for effectively linking all the Microservices. It also is configured with an Event Archive to allow replaying of events if required. 27 | 28 | A catch-all rule exists to push every event into CloudWatch logs for two purposes: 29 | * Debugging 30 | * Metrics 31 | 32 | A Metric Filter is deployed to the CloudWatch group for each type of message which generates a customer Metric that can then be used to visualise the events flowing through the system. 33 | 34 | ## Analysis 35 | The Analysis Microservice deploys a rule into the Plumbing Event Hub to catch all `MESSAGE_RECEIVED` events - this starts an execution on a StepFunction to allow orchestration of multiple services: 36 | * The downloading of any media into a local bucket and then parallel processing of these through different Rekognition endpoints to look for Labels, Text, Faces, etc. 37 | * The parallel processing of text through different Comprehend endpoints to gather insights into the text. 38 | * Pushing the result of all the analysis insights back to the Event Hub with a `MESSAGE_ANALYSED` event. 39 | 40 | ## Alerting 41 | The Alerting Microservice deploys a rule into the Plumbing Event Hub to catch all `MESSAGE_ANALYSED` events which have found text with a Sentiment of NEGATIVE. This targets an SNS Topic that can be sub-scribed to from Email addresses, Phone Numbers, etc to get alerts when Negative messages are received. It could integrate into Slack and other notification systems. 42 | 43 | ## Responding 44 | The Responding Microservice deploys two rules into the Plumbing Event Hub to catch all `MESSAGE_ANALYSED` events. One catches those with Images in them, and one catches those with no images, only text. 45 | 46 | ### Images 47 | The Image rule executes a Lambda which pulls the image from S3 (downloaded earlier) and uses the Rekognition insights to add Celebrity names onto the image next to the faces - and then generates a command Event onto the Plumbing Event Bus with the list of celebrities (or message saying none found) called `SEND_TWEET`. 48 | 49 | This can also blur faces of none-celebrities, highlight certain objects, etc. 50 | 51 | ### Text 52 | The Text rule executes a Lambda which calls Amazon Lex - an AI Conversational Bot which was built/defined in CDK. Lex utilises a separate Lambda to fulfilment any highlighted topics (Jokes/Facts) and returns the response. A `SEND_TWEET` command is generated and pushed to the Plumbing Event Bus. 53 | 54 | ## Egress 55 | The Egress Microservice deploys a rule into the Plumbing Event Hub to catch all `SEND_TWEET` commands and executes a Lambda which pulls API credentials from Secrets Manager, pulls any images from S3 and then calls the Twitter API to create a reply tweet. *If it wasn't for the Images, then this could be done with a EventBridge API Destination in theory*. 56 | 57 | ## Analytics 58 | The Analytics Microservice deploys a rule into the Plumbing Event Hub to catch all messages. This targets Kinesis Data Firehose, which is configured to use a Lambda for transformation (simply adds a newline after each message so they can be parsed later) and then store in S3 as a Data Lake - it has a 1 minute buffer configured so is not real-time. 59 | 60 | A Glue Table has also been defined for `MESSAGE_ANALYSED` that can be used by Athena - Glue could also be configured to crawl the S3 bucket and build the tables automatically, but for the purposes of the reporting required here a single static table (based on an Internal event that is known) makes most sense. 61 | 62 | Athena can be used against the bucket, and using the Glue table to run SQL queries about the messages received and all the analysis data (Such as find all messages that are Positive, or all messages that contained an image with a car in it). 63 | 64 | ## Utilised AWS Services 65 | * AWS Glue 66 | * AWS Identity and Access Management (IAM) 67 | * AWS Lambda 68 | * AWS Secrets Manager 69 | * AWS Step Functions 70 | * Amazon API Gateway 71 | * Amazon Athena 72 | * Amazon CloudWatch 73 | * Amazon Comprehend 74 | * Amazon EventBridge 75 | * Amazon Kinesis Data Firehose 76 | * Amazon Lex 77 | * Amazon Rekognition 78 | * Amazon S3 79 | * Amazon Simple Notification Service (SNS) 80 | 81 | ## Building and Deploying 82 | 83 | ### CDK Deploy 84 | If not already setup for CDK then you will need: 85 | * [AWS CLI](https://aws.amazon.com/cli/) installed and your workstation [configured](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) to the correct account: `aws configure` 86 | * [Node & NPM](https://nodejs.org/en/about/releases/) installed 87 | * CDK installed globally: `npm install -g aws-cdk` 88 | * This can be verified by running `cdk --version` 89 | 90 | Within the root of this application you should be able to then run a `npm install` to restore the dependencies. 91 | 92 | Once installed, then you can run `cdk deploy --all --context twitterAccountId=999999` to build and deploy all stacks to your default AWS account/region. **Fill in your own account ID here**. For other CDK commands then check [documention](https://docs.aws.amazon.com/cdk/v2/guide/cli.html). 93 | 94 | The API Gateway URL should be output to the console as part of the deployment, but may be hard to find in the output - it will look something like: 95 | `IngressStack.APIGateway = https://99dd9d9dd.execute-api.eu-west-1.amazonaws.com/prod/` 96 | 97 | If you cannot find it, then navigate to [API Gateway](https://eu-west-1.console.aws.amazon.com/apigateway/main/apis) in your console and you should have an API called `ingress-api` - if you navigate to this and then Stages and `prod` you can see the url there. 98 | 99 | ### Twitter Developer Account 100 | The application is reactive to webhooks from Twitter utilising the [Account Activity API](https://developer.twitter.com/en/docs/twitter-api/premium/account-activity-api/overview). For this a Developer Account is needed. 101 | 102 | 1. Sign up for a [Twitter Developer Account](https://developer.twitter.com/en/apply-for-access) 103 | 2. Apply for [Elevated Access](https://developer.twitter.com/en/portal/products/elevated) 104 | 3. Create an [Application](https://developer.twitter.com/en/portal/projects-and-apps) and grab all the API Keys/Secrets, Auth Tokens, etc. 105 | 4. Follow the `Twitter Secrets` below to add these details to your AWS account. 106 | 5. Create a Dev Environment for that application to use the Account Activity API. 107 | 6. Register a Webhook with the `https://api.twitter.com/1.1/account_activity/all/{{environment}}/webhooks.json?url={{your_api_gateway_url}}/prod/twitter` API. More details in the[Twitter API Doc](https://developer.twitter.com/en/docs/twitter-api/premium/account-activity-api/api-reference). 108 | 7. Register a [subscription for the account](https://developer.twitter.com/en/docs/twitter-api/premium/account-activity-api/api-reference/aaa-premium#post-account-activity-all-env-name-subscriptions). More details in the [Twitter API Doc](https://developer.twitter.com/en/docs/twitter-api/premium/account-activity-api/api-reference). 109 | 110 | Once a webhook is registered then an API call will be made to the API Gateway to verify, this can be seen in the logs for the `IngressStack-TwitterActivitylambda` lambda for debugging. 111 | 112 | ### Twitter Secrets 113 | Create a Secret in Secret Manager manually in the correct AWS account and region with the name `TwitterSecret` and value of the below. (In the UI this is added as a Key/value pair or plaintext of the raw JSON like below): 114 | 115 | ``` 116 | { 117 | ApiKey: 'TODO', 118 | ApiSecret: 'TODO', 119 | AccessToken: 'TODO, 120 | AccessTokenSecret: 'TODO' 121 | } 122 | ``` 123 | 124 | ## Athena 125 | You can use the below ad-hoc queries in Athena by selecting the `messages-data-lake` Glue table created as part of this CDK app. Some sample searches below: 126 | 127 | ### Select all Celebrities 128 | ``` 129 | SELECT time, detail.author, celebrityfaces.name 130 | FROM "analysed-messages-table" 131 | CROSS JOIN UNNEST(detail.analysis.images) as t(images) 132 | CROSS JOIN UNNEST(images.analysis.celebrityfaces) as t(celebrityfaces) 133 | WHERE "detail-type" = 'MESSAGE_ANALYSED' 134 | ORDER BY time DESC 135 | ``` 136 | 137 | ### Select all Image Labels 138 | ``` 139 | SELECT time, detail.author, detail.text, labels.name 140 | FROM "analysed-messages-table" 141 | CROSS JOIN UNNEST(detail.analysis.images) as t(images) 142 | CROSS JOIN UNNEST(images.analysis.labels) as t(labels) 143 | WHERE "detail-type" = 'MESSAGE_ANALYSED' 144 | ORDER BY time DESC 145 | ``` 146 | 147 | ### Select all Positive Text 148 | ``` 149 | SELECT time, detail.author, detail.analysis.textsentiment, detail.text 150 | FROM "analysed-messages-table" 151 | CROSS JOIN UNNEST(detail.analysis.images) as t(images) 152 | WHERE "detail-type" = 'MESSAGE_ANALYSED' AND detail.analysis.textsentiment='POSITIVE' 153 | ORDER BY time DESC 154 | ``` 155 | 156 | ## Cost 157 | The cost of processing can be broken down into a few areas: 158 | * AI (Rekognition, Lex, Comprehend) 159 | * Code/Infrastructural (StepFunction, Lambda, EventBridge, API Gateway, SNS) 160 | * Storage (S3, Cloudwatch, Secrets Manager) 161 | 162 | ### AI 163 | This is the most expensive part, especially since the Step Function in this application is currently running the text and images through multiple APIs and not using all the results - obviously in a production ready system that could be handling millions of requests then this should be optimised. As is though, to deal with 1 million requests (50/50 on text and image), it would cost about £1500. This works out as £0.0015 per message - or about 15p for 100 messages. 164 | 165 | ### Code/Infrastructural 166 | The Code/Infrastructural is the second most expensive part, but mainly because of using a Standard Step Function - which as mentioned above is not optimised as it calls the AI services for everything - adding extra steps, and in production would be better suited to Express step functions for each specific use-case potentially. Even so, this works out at around £130 to handle 1 million messages, this works out as £0.00013 per message, less than 1p for 100 messages. 167 | 168 | Interestingly Lambda would be less than a few pounds to handle 1 million messages with the use-case in this application. This works out as a ridiculously small number per message, or even per 100 messages. 169 | 170 | EventBridge would be less than £1 top handle 1 million messages. Firehose would be less than £0.30. 171 | 172 | ### Storage 173 | Storage is cheap on AWS, so to store a lot of history in the Data Lake and in CloudWatch then this would probably work out as up to around £5 (assuming 1 million messages). With more realistic numbers this would be pence. 174 | 175 | ### Overall 176 | So overall it would be about £1700 to handle 1 million messages with the current design. Or £0.0017 per message - £0.17 for 100 messages. -------------------------------------------------------------------------------- /bin/aws-serverless-twitter-bot.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import * as cdk from 'aws-cdk-lib'; 4 | import { PlumbingStack } from '../lib/stacks/plumbing-stack'; 5 | import { IngressStack } from '../lib/stacks/ingress-stack'; 6 | import { AnalysisStack } from '../lib/stacks/analysis-stack'; 7 | import { AlertingStack } from '../lib/stacks/alerting-stack'; 8 | import { AnalyticsStack } from '../lib/stacks/analytics-stack'; 9 | import { RespondingStack } from '../lib/stacks/responding-stack'; 10 | import { EgressStack } from '../lib/stacks/egress-stack'; 11 | 12 | const app = new cdk.App(); 13 | 14 | // Read from context 15 | const twitterIdOfAccount = app.node.tryGetContext('twitterAccountId'); 16 | 17 | if (!twitterIdOfAccount) { 18 | throw new Error('Missing twitterAccountId context param'); 19 | } 20 | 21 | const plumbingStack = new PlumbingStack(app, 'PlumbingStack', {}); 22 | 23 | new IngressStack(app, 'IngressStack', { plumbingEventBus: plumbingStack.eventBus, twitterIdOfAccount }); 24 | 25 | const analysisStack = new AnalysisStack(app, 'AnalysisStack', { plumbingEventBus: plumbingStack.eventBus }); 26 | 27 | new AlertingStack(app, 'AlertingStack', { plumbingEventBus: plumbingStack.eventBus }); 28 | 29 | new AnalyticsStack(app, 'AnalyticsStack', { plumbingEventBus: plumbingStack.eventBus }); 30 | 31 | new RespondingStack(app, 'RespondingStack', { plumbingEventBus: plumbingStack.eventBus, analysisBucket: analysisStack.analyseBucket }); 32 | 33 | new EgressStack(app, 'EgressStack', { plumbingEventBus: plumbingStack.eventBus, analysisBucket: analysisStack.analyseBucket }); -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/aws-serverless-twitter-bot.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 21 | "@aws-cdk/core:stackRelativeExports": true, 22 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 23 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 24 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 25 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 26 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 27 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 28 | "@aws-cdk/core:checkSecretUsage": true, 29 | "@aws-cdk/aws-iam:minimizePolicies": true, 30 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 31 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 32 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 33 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 34 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 35 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 36 | "@aws-cdk/core:enablePartitionLiterals": true, 37 | "@aws-cdk/core:target-partitions": [ 38 | "aws", 39 | "aws-cn" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/constructs/analysis/download-images.lambda.ts: -------------------------------------------------------------------------------- 1 | import * as aws from 'aws-sdk'; 2 | import * as crypto from 'crypto'; 3 | import * as https from 'https'; 4 | import * as stream from 'stream'; 5 | import * as path from 'path'; 6 | 7 | interface DownloadImagesEvent { 8 | imageUrls: string[] 9 | } 10 | 11 | interface DownloadImagesResponse { 12 | Images: DownloadImagesImage[] 13 | } 14 | 15 | interface DownloadImagesImage { 16 | Key: string 17 | } 18 | 19 | class DownloadImages { 20 | private readonly _bucket: string; 21 | 22 | private readonly _s3: aws.S3; 23 | 24 | constructor() { 25 | const { Bucket } = process.env; 26 | 27 | if (!Bucket) { 28 | throw new Error('Missing environment variables'); 29 | } 30 | 31 | this._bucket = Bucket; 32 | this._s3 = new aws.S3(); 33 | 34 | console.info('Initialised'); 35 | } 36 | 37 | handler = async (event: DownloadImagesEvent): Promise => { 38 | console.info('Received Event:', JSON.stringify(event, null, 2)); 39 | 40 | const promises = event.imageUrls.map(e => this.handleSingleImage(e)); 41 | 42 | console.info('Waiting for all promises to complete'); 43 | const results = await Promise.allSettled(promises); 44 | console.info('All promises completed', JSON.stringify(results, null, 2)); 45 | 46 | const fulfilledResults = (results.filter(c=>c.status === 'fulfilled') as PromiseFulfilledResult[]); 47 | return { 48 | Images: fulfilledResults.map(k => { return { Key: k.value }; }), 49 | }; 50 | }; 51 | 52 | handleSingleImage = async (url: string): Promise => { 53 | 54 | const passthroughStream = new stream.PassThrough(); 55 | https.get(url, resp => resp.pipe(passthroughStream)); 56 | 57 | const date = new Date(); 58 | const key = `${date.getFullYear()}/${date.getMonth()}/${date.getDay()}/${crypto.randomUUID()}${path.extname(url)}`; 59 | 60 | console.info('Putting image into bucket', url, key); 61 | 62 | const result = await this._s3.upload({ 63 | Bucket: this._bucket, 64 | Key: key, 65 | Body: passthroughStream, 66 | }).promise(); 67 | 68 | console.info('Image put into bucket', url, key, result); 69 | 70 | return key; 71 | }; 72 | } 73 | 74 | // Initialise class outside of the handler so context is reused. 75 | const downloadImages = new DownloadImages(); 76 | 77 | // The handler simply executes the object handler 78 | export const handler = async (event: DownloadImagesEvent): Promise => downloadImages.handler(event); -------------------------------------------------------------------------------- /lib/constructs/analysis/download-images.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs'; 2 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 3 | import * as lambdanode from 'aws-cdk-lib/aws-lambda-nodejs'; 4 | import * as iam from 'aws-cdk-lib/aws-iam'; 5 | import * as s3 from 'aws-cdk-lib/aws-s3'; 6 | 7 | export interface DownloadImagesConstructProps { 8 | bucket: s3.IBucket 9 | } 10 | 11 | /** 12 | * Construct for a lambda (and associated role) to download images from given URLs into S3 and return the list 13 | */ 14 | export default class DownloadImagesConstruct extends Construct { 15 | 16 | public readonly lambda : lambda.IFunction; 17 | 18 | constructor(scope: Construct, id: string, props: DownloadImagesConstructProps) { 19 | super(scope, id); 20 | 21 | const role = new iam.Role(this, 'LambdaRole', { 22 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 23 | }); 24 | role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')); 25 | role.addToPolicy( 26 | new iam.PolicyStatement({ 27 | resources: [`${props.bucket.bucketArn}/*`], 28 | actions: ['s3:PutObject'], 29 | }), 30 | ); 31 | 32 | this.lambda = new lambdanode.NodejsFunction(this, 'lambda', { 33 | runtime: lambda.Runtime.NODEJS_16_X, 34 | architecture: lambda.Architecture.ARM_64, 35 | environment: { 36 | Bucket: props.bucket.bucketName, 37 | NODE_OPTIONS: '--enable-source-maps', 38 | }, 39 | bundling: { 40 | minify: true, 41 | sourceMap: true, 42 | }, 43 | role, 44 | }); 45 | } 46 | } -------------------------------------------------------------------------------- /lib/constructs/analytics/transform-records.lambda.ts: -------------------------------------------------------------------------------- 1 | import * as firehose from 'aws-lambda/trigger/kinesis-firehose-transformation'; 2 | 3 | class TransformRecords { 4 | handler = async (event: firehose.FirehoseTransformationEvent): Promise => { 5 | console.info('Received Event:', JSON.stringify(event, null, 2)); 6 | 7 | const records: firehose.FirehoseTransformationResultRecord[] = []; 8 | 9 | for (const record of event.records) { 10 | const transformed = this.transformRecord(record); 11 | records.push(transformed); 12 | } 13 | 14 | return { records }; 15 | }; 16 | 17 | transformRecord = (record: firehose.FirehoseTransformationEventRecord): firehose.FirehoseTransformationResultRecord => { 18 | try { 19 | const payloadStr = Buffer.from(record.data, 'base64').toString(); 20 | return { 21 | recordId: record.recordId, 22 | result: 'Ok', 23 | // Ensure that '\n' is appended to the record's JSON string so Kinesis puts JSON on different lines for Athena 24 | data: Buffer.from(payloadStr + '\n').toString('base64'), 25 | }; 26 | } catch (error) { 27 | console.error('Error processing record', record, error); 28 | return { 29 | recordId: record.recordId, 30 | result: 'Dropped', 31 | data: Buffer.from('').toString('base64'), 32 | }; 33 | } 34 | }; 35 | } 36 | 37 | // Initialise class outside of the handler so context is reused. 38 | const transformRecords = new TransformRecords(); 39 | 40 | // The handler simply executes the object handler 41 | export const handler = async (event: firehose.FirehoseTransformationEvent): Promise => transformRecords.handler(event); -------------------------------------------------------------------------------- /lib/constructs/analytics/transform-records.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs'; 2 | import * as cdk from 'aws-cdk-lib'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import * as lambdanode from 'aws-cdk-lib/aws-lambda-nodejs'; 5 | 6 | /** 7 | * Construct for a lambda (and associated role) to transform records going through Kinesis into S3 so Athena can query them. 8 | */ 9 | export default class TransformRecordsConstruct extends Construct { 10 | 11 | public readonly lambda : lambda.IFunction; 12 | 13 | constructor(scope: Construct, id: string) { 14 | super(scope, id); 15 | 16 | this.lambda = new lambdanode.NodejsFunction(this, 'lambda', { 17 | runtime: lambda.Runtime.NODEJS_16_X, 18 | architecture: lambda.Architecture.ARM_64, 19 | environment: { 20 | NODE_OPTIONS: '--enable-source-maps', 21 | }, 22 | bundling: { 23 | minify: true, 24 | sourceMap: true, 25 | }, 26 | timeout: cdk.Duration.minutes(1), 27 | }); 28 | } 29 | } -------------------------------------------------------------------------------- /lib/constructs/egress/tweet-construct.lambda.ts: -------------------------------------------------------------------------------- 1 | import * as aws from 'aws-sdk'; 2 | import { TwitterClient } from 'twitter-api-client'; 3 | import { EventBridgeEvent } from 'aws-lambda'; 4 | 5 | type MessageAnalysedDetailType = 'SEND_TWEET'; 6 | 7 | export interface TwitterApiDetails { 8 | ApiSecret: string, 9 | ApiKey: string, 10 | AccessToken: string, 11 | AccessTokenSecret: string, 12 | } 13 | 14 | interface MessageAnalysedDetail { 15 | Text: string, 16 | ReplyToUserId: string, 17 | ReplyToTweetId: string, 18 | ImageKey: string, 19 | } 20 | 21 | class Tweet { 22 | private readonly _secretArn: string; 23 | 24 | private readonly _bucket: string; 25 | 26 | private readonly _secretsManager: aws.SecretsManager; 27 | 28 | private readonly _s3: aws.S3; 29 | 30 | // We will lazy load the client for efficiency across reused instances 31 | private _twitterClient: TwitterClient; 32 | 33 | constructor() { 34 | const { SecretArn, Bucket } = process.env; 35 | 36 | if (!SecretArn || !Bucket) { 37 | throw new Error('Missing environment variables'); 38 | } 39 | 40 | this._secretArn = SecretArn; 41 | this._bucket = Bucket; 42 | this._secretsManager = new aws.SecretsManager(); 43 | this._s3 = new aws.S3(); 44 | 45 | console.info('Initialised'); 46 | } 47 | 48 | handler = async (event: EventBridgeEvent): Promise => { 49 | console.info('Received Event:', JSON.stringify(event, null, 2)); 50 | 51 | try { 52 | if (!this._twitterClient) { 53 | const secretValue = await this._secretsManager.getSecretValue({ SecretId: this._secretArn }).promise(); 54 | const secret = JSON.parse(secretValue.SecretString ?? '') as TwitterApiDetails; 55 | 56 | this._twitterClient = new TwitterClient({ 57 | apiKey: secret.ApiKey, 58 | apiSecret: secret.ApiSecret, 59 | accessToken: secret.AccessToken, 60 | accessTokenSecret: secret.AccessTokenSecret, 61 | }); 62 | } 63 | 64 | let mediaId = undefined; 65 | 66 | if (event.detail.ImageKey) { 67 | 68 | const response = await this._s3.getObject({ 69 | Bucket: this._bucket, 70 | Key: event.detail.ImageKey, 71 | }).promise(); 72 | 73 | const uploadedMedia = await this._twitterClient.media.mediaUpload({ 74 | media_data: response.Body?.toString('base64'), 75 | }); 76 | 77 | mediaId = uploadedMedia.media_id_string; 78 | } 79 | 80 | const response = await this._twitterClient.tweetsV2.createTweet({ 81 | reply: { 82 | in_reply_to_tweet_id: event.detail.ReplyToTweetId.toString(), 83 | }, 84 | text: event.detail.Text, 85 | media: mediaId === undefined ? undefined : { 86 | media_ids: [mediaId], 87 | }, 88 | }); 89 | 90 | console.info('Tweet Response:', JSON.stringify(response, null, 2)); 91 | } catch (error) { 92 | console.error(JSON.stringify(error, null, 2)); 93 | throw error; 94 | } 95 | 96 | return true; 97 | }; 98 | } 99 | 100 | // Initialise class outside of the handler so context is reused. 101 | const tweet = new Tweet(); 102 | 103 | // The handler simply executes the object handler 104 | export const handler = async (event: EventBridgeEvent): Promise => tweet.handler(event); -------------------------------------------------------------------------------- /lib/constructs/egress/tweet-construct.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs'; 2 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 3 | import * as lambdanode from 'aws-cdk-lib/aws-lambda-nodejs'; 4 | import * as iam from 'aws-cdk-lib/aws-iam'; 5 | import * as s3 from 'aws-cdk-lib/aws-s3'; 6 | import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; 7 | import * as events from 'aws-cdk-lib/aws-events'; 8 | 9 | export interface TweetConstructProps { 10 | twitterSecret: secretsmanager.ISecret 11 | plumbingEventBus: events.IEventBus, 12 | bucket: s3.IBucket, 13 | } 14 | 15 | /** 16 | * Construct for the Twitter Tweet Activity lambda and associated IAM role 17 | * @see {@link https://developer.twitter.com/en/docs/twitter-api/tweets/manage-tweets/api-reference/post-tweets} 18 | */ 19 | export default class TweetConstruct extends Construct { 20 | 21 | public readonly lambda : lambda.IFunction; 22 | 23 | constructor(scope: Construct, id: string, props: TweetConstructProps) { 24 | super(scope, id); 25 | 26 | const role = new iam.Role(this, 'LambdaRole', { 27 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 28 | }); 29 | role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')); 30 | role.addToPolicy( 31 | new iam.PolicyStatement({ 32 | resources: [`${props.twitterSecret.secretArn}-*`], 33 | actions: ['secretsmanager:GetSecretValue'], 34 | }), 35 | ); 36 | role.addToPolicy( 37 | new iam.PolicyStatement({ 38 | resources: [`${props.bucket.bucketArn}/*`], 39 | actions: ['s3:GetObject'], 40 | }), 41 | ); 42 | 43 | this.lambda = new lambdanode.NodejsFunction(this, 'lambda', { 44 | runtime: lambda.Runtime.NODEJS_16_X, 45 | architecture: lambda.Architecture.ARM_64, 46 | environment: { 47 | Bucket: props.bucket.bucketName, 48 | SecretArn: props.twitterSecret.secretArn, 49 | NODE_OPTIONS: '--enable-source-maps', 50 | }, 51 | bundling: { 52 | minify: true, 53 | sourceMap: true, 54 | }, 55 | role, 56 | }); 57 | } 58 | } -------------------------------------------------------------------------------- /lib/constructs/ingress/twitter-construct.lambda.ts: -------------------------------------------------------------------------------- 1 | import * as aws from 'aws-sdk'; 2 | import * as crypto from 'crypto'; 3 | import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; 4 | 5 | export interface TwitterApiDetails { 6 | ApiSecret: string, 7 | ApiKey: string, 8 | AccessToken: string, 9 | AccessTokenSecret: string, 10 | } 11 | 12 | export interface TwitterActivityPayload { 13 | for_user_id: string, 14 | is_blocked_by: string, 15 | source: string, 16 | target: string, 17 | tweet_create_events: TwitterTweetCreated[], 18 | favorite_events: never[], 19 | follow_events: never[], 20 | unfollow_events: never[], 21 | block_events: never[], 22 | unblock_events: never[], 23 | mute_events: never[], 24 | unmute_events: never[], 25 | user_event: never[], 26 | direct_message_events: never[], 27 | direct_message_indicate_typing_events: never[], 28 | direct_message_mark_read_events: never[], 29 | tweet_delete_events: never[], 30 | } 31 | 32 | export interface TwitterUser { 33 | id_str: string, 34 | name: string, 35 | screen_name: string, 36 | } 37 | 38 | export interface TwitterExtendedTweet { 39 | full_text: string 40 | } 41 | 42 | export interface TwitterMedia { 43 | media_url_https: string 44 | } 45 | 46 | export interface TwitterEntities { 47 | media: TwitterMedia[] 48 | } 49 | 50 | export interface TwitterTweetCreated { 51 | id_str: string, 52 | user: TwitterUser, 53 | entities: TwitterEntities, 54 | text: string, 55 | truncated: boolean, 56 | extended_tweet: TwitterExtendedTweet 57 | } 58 | 59 | interface Dictionary { 60 | [Key: string]: T; 61 | } 62 | 63 | class Twitter { 64 | private readonly _secretArn: string; 65 | 66 | private readonly _eventBusName: string; 67 | 68 | private readonly _twitterIdOfAccount: string; 69 | 70 | private readonly _eventBridge: aws.EventBridge; 71 | 72 | private readonly _secretsManager: aws.SecretsManager; 73 | 74 | // We will lazy load the secret for efficiency across reused instances 75 | private _secret: TwitterApiDetails; 76 | 77 | constructor() { 78 | const { SecretArn, EventBusName, TwitterIdOfAccount } = process.env; 79 | 80 | if (!SecretArn || !EventBusName || !TwitterIdOfAccount) { 81 | throw new Error('Missing environment variables'); 82 | } 83 | 84 | this._secretArn = SecretArn; 85 | this._eventBusName = EventBusName; 86 | this._twitterIdOfAccount = TwitterIdOfAccount; 87 | this._eventBridge = new aws.EventBridge(); 88 | this._secretsManager = new aws.SecretsManager(); 89 | 90 | console.info('Initialised'); 91 | } 92 | 93 | handler = async (event: APIGatewayProxyEvent): Promise => { 94 | console.info('Received Event:', JSON.stringify(event, null, 2)); 95 | 96 | try { 97 | if (!this._secret) { 98 | const secretValue = await this._secretsManager.getSecretValue({ SecretId: this._secretArn }).promise(); 99 | this._secret = JSON.parse(secretValue.SecretString ?? ''); 100 | } 101 | 102 | if (event.requestContext.httpMethod === 'GET') { 103 | return await this.handleCrc(event); 104 | } 105 | 106 | if (event.requestContext.httpMethod === 'POST') { 107 | return await this.handleActivity(event); 108 | } 109 | 110 | return { 111 | body: 'Invalid METHOD', 112 | statusCode: 400, 113 | }; 114 | } catch (error) { 115 | console.log('ERROR', error); 116 | return { 117 | body: 'Error', 118 | statusCode: 500, 119 | }; 120 | } 121 | }; 122 | 123 | handleCrc = async (event: APIGatewayProxyEvent): Promise => { 124 | const givenCrcToken = event?.queryStringParameters?.crc_token; 125 | if (!givenCrcToken) { 126 | console.error('No crc_token given'); 127 | return { 128 | body: 'No crc_token given', 129 | statusCode: 400, 130 | }; 131 | } 132 | 133 | const bodyResponse = JSON.stringify({ 134 | response_token: `sha256=${this.generateHmac(givenCrcToken)}`, 135 | }); 136 | 137 | console.info('Generating HMAC for provided token', bodyResponse); 138 | 139 | return { 140 | body: bodyResponse, 141 | statusCode: 200, 142 | }; 143 | }; 144 | 145 | handleActivity = async (event: APIGatewayProxyEvent): Promise => { 146 | 147 | if (!event.body) { 148 | console.error('No body given'); 149 | return { 150 | body: 'No body', 151 | statusCode: 400, 152 | }; 153 | } 154 | 155 | const providedSignature = (event.headers['X-Twitter-Webhooks-Signature'] ?? '').substring(7); 156 | const generatedSignature = this.generateHmac(event?.body); 157 | if (!crypto.timingSafeEqual(Buffer.from(generatedSignature), Buffer.from(providedSignature))) { 158 | console.error('Provided signature does not match.'); 159 | return { 160 | body: 'Invalid signature', 161 | statusCode: 401, 162 | }; 163 | } 164 | 165 | const payload = JSON.parse(event?.body) as TwitterActivityPayload; 166 | const generatedEvents = this.generateEvents(payload); 167 | 168 | console.info('Pushing to event bridge', JSON.stringify(generatedEvents, null, 2)); 169 | 170 | const putResponse = await this._eventBridge.putEvents({ 171 | Entries: this.generateEvents(payload), 172 | }).promise(); 173 | 174 | console.log('Pushed to EventBridge', JSON.stringify(putResponse, null, 2)); 175 | 176 | return { 177 | body: 'Accepted', 178 | statusCode: 200, 179 | }; 180 | }; 181 | 182 | generateEvents = (payload: TwitterActivityPayload): aws.EventBridge.PutEventsRequestEntryList => { 183 | const events: aws.EventBridge.PutEventsRequestEntryList = []; 184 | 185 | const types: Dictionary = { 186 | 'tweet_create_events' : 'TWEETED', 187 | 'favorite_events': 'FAVOURITED', 188 | 'follow_events': 'FOLLOWED', 189 | 'unfollow_events': 'UNFOLLOWED', 190 | 'block_events': 'BLOCKED', 191 | 'unblock_events': 'UNBLOCKED', 192 | 'mute_events': 'MUTED', 193 | 'unmute_events': 'UNMUTED', 194 | 'user_event': 'PERMISSION_REVOKED', 195 | 'direct_message_events': 'DM_RECEIVED', 196 | 'direct_message_indicate_typing_events': 'DM_TYPING', 197 | 'direct_message_mark_read_events': 'DM_MARKED_READ', 198 | 'tweet_delete_events': 'DELETED', 199 | }; 200 | 201 | for (const type in types) { 202 | const eventsOfType = (payload as never)[type]; 203 | if (eventsOfType) { 204 | const typeName: string = types[type]; 205 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 206 | (payload as any)[type].forEach((e: never) => events.push(this.generateSingleEvent(typeName, e))); 207 | } 208 | } 209 | 210 | // Generic Message Received Event 211 | if (payload.tweet_create_events) { 212 | payload.tweet_create_events.forEach((e) => { 213 | // If it wasn't sent by the twitter account we are receiving events for 214 | if (e.user.id_str !== this._twitterIdOfAccount) { 215 | events.push(this.generateMessageReceivedEvent(e)); 216 | } 217 | }); 218 | } 219 | 220 | return events; 221 | }; 222 | 223 | generateSingleEvent = (type: string, detail: never): aws.EventBridge.PutEventsRequestEntry => { 224 | return { 225 | Detail: JSON.stringify(detail), 226 | DetailType: `TWITTER_${type}`, // Strip _events 227 | EventBusName: this._eventBusName, 228 | Source: 'TWITTER', 229 | }; 230 | }; 231 | 232 | /** 233 | * Generate an internal payload for all received messages, this is an Anti-corruption Layer Event so that downstream 234 | * aren't dependent on the JSON Twitter sends. 235 | * @param payload The raw payload for an individual tweet_create_events event 236 | * @returns an entry to be pushed to EventBridge 237 | */ 238 | generateMessageReceivedEvent = (payload: TwitterTweetCreated): aws.EventBridge.PutEventsRequestEntry => { 239 | return { 240 | Detail: JSON.stringify({ 241 | Text: payload.truncated ? payload.extended_tweet.full_text : payload.text, 242 | ImageUrls: payload.entities?.media ? payload.entities.media.map(e => e.media_url_https) : undefined, 243 | Author: payload.user.screen_name, 244 | Twitter: { 245 | // Need these for replying 246 | TweetId: payload.id_str, 247 | UserId: payload.user.id_str, 248 | }, 249 | }), 250 | DetailType: 'MESSAGE_RECEIVED', 251 | EventBusName: this._eventBusName, 252 | Source: 'TWITTER', 253 | }; 254 | }; 255 | 256 | generateHmac = (payload: string): string => { 257 | return crypto.createHmac('sha256', this._secret.ApiSecret).update(payload).digest('base64'); 258 | }; 259 | } 260 | 261 | // Initialise class outside of the handler so context is reused. 262 | const twitter = new Twitter(); 263 | 264 | // The handler simply executes the object handler 265 | export const handler = async (event: APIGatewayProxyEvent): Promise => twitter.handler(event); -------------------------------------------------------------------------------- /lib/constructs/ingress/twitter-construct.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs'; 2 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 3 | import * as lambdanode from 'aws-cdk-lib/aws-lambda-nodejs'; 4 | import * as iam from 'aws-cdk-lib/aws-iam'; 5 | import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; 6 | import * as events from 'aws-cdk-lib/aws-events'; 7 | 8 | export interface TwitterConstructProps { 9 | twitterSecret: secretsmanager.ISecret 10 | plumbingEventBus: events.IEventBus, 11 | twitterIdOfAccount: number 12 | } 13 | 14 | /** 15 | * Construct for the Twitter Twitter Activity lambda and associated IAM role 16 | * @see {@link https://developer.twitter.com/en/docs/twitter-api/enterprise/account-activity-api/guides/account-activity-data-objects} 17 | */ 18 | export default class TwitterConstruct extends Construct { 19 | 20 | public readonly lambda : lambda.IFunction; 21 | 22 | constructor(scope: Construct, id: string, props: TwitterConstructProps) { 23 | super(scope, id); 24 | 25 | const role = new iam.Role(this, 'LambdaRole', { 26 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 27 | }); 28 | role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')); 29 | role.addToPolicy( 30 | new iam.PolicyStatement({ 31 | resources: [`${props.twitterSecret.secretArn}-*`], 32 | actions: ['secretsmanager:GetSecretValue'], 33 | }), 34 | ); 35 | role.addToPolicy( 36 | new iam.PolicyStatement({ 37 | resources: [props.plumbingEventBus.eventBusArn], 38 | actions: ['events:PutEvents'], 39 | }), 40 | ); 41 | 42 | this.lambda = new lambdanode.NodejsFunction(this, 'lambda', { 43 | runtime: lambda.Runtime.NODEJS_16_X, 44 | architecture: lambda.Architecture.ARM_64, 45 | environment: { 46 | SecretArn: props.twitterSecret.secretArn, 47 | EventBusName: props.plumbingEventBus.eventBusName, 48 | TwitterIdOfAccount: props.twitterIdOfAccount.toString(), 49 | NODE_OPTIONS: '--enable-source-maps', 50 | }, 51 | bundling: { 52 | minify: true, 53 | sourceMap: true, 54 | }, 55 | role, 56 | }); 57 | } 58 | } -------------------------------------------------------------------------------- /lib/constructs/responding/chat-bot-fulfilment.lambda.ts: -------------------------------------------------------------------------------- 1 | import { LexV2Event, LexV2Result } from 'aws-lambda'; 2 | import axios from 'axios'; 3 | 4 | class ChatBot { 5 | handler = async (event: LexV2Event): Promise => { 6 | console.info('Received Event:', JSON.stringify(event, null, 2)); 7 | 8 | const content = await (event.sessionState.intent.name === 'Fact' ? this.getFact() : this.getDadJoke()); 9 | return { 10 | sessionState: { 11 | intent: { 12 | name: event.sessionState.intent.name, 13 | state: 'Fulfilled', 14 | }, 15 | dialogAction: { 16 | type: 'Close', 17 | }, 18 | }, 19 | messages: [ 20 | { 21 | contentType: 'PlainText', 22 | content, 23 | }, 24 | ], 25 | }; 26 | }; 27 | 28 | getDadJoke = async (): Promise => { 29 | try { 30 | const response = await axios.get('https://icanhazdadjoke.com/', { 31 | headers: { 32 | Accept: 'application/json', 33 | 'User-Agent': 'axios 0.21.1', 34 | }, 35 | timeout: 700, 36 | }); 37 | console.info('Received dad joke: ', response.data); 38 | return `${response.data.joke} #joke`; 39 | } catch (err) { 40 | console.error('Error getting joke: ', err); 41 | 42 | // Backup with random joke from list 43 | const jokes = [ 44 | "Some people say that comedians who tell one too many light bulb jokes soon burn out, but they don't know watt they are talking about. They're not that bright.", 45 | 'Why was the big cat disqualified from the race? Because it was a cheetah.', 46 | 'You know what they say about cliffhangers...', 47 | 'Why does Superman get invited to dinners? Because he is a Supperhero.', 48 | "I've just been reading a book about anti-gravity, it's impossible to put down!", 49 | 'I started a new business making yachts in my attic this year...the sails are going through the roof', 50 | "What happens to a frog's car when it breaks down? It gets toad.", 51 | "The Swiss must've been pretty confident in their chances of victory if they included a corkscrew in their army knife.", 52 | "My wife told me to rub the herbs on the meat for better flavor. That's sage advice.", 53 | ]; 54 | return jokes[Math.floor(Math.random() * jokes.length)]; 55 | } 56 | }; 57 | 58 | getFact = async (): Promise => { 59 | try { 60 | const response = await axios.get('https://uselessfacts.jsph.pl/random.json?language=en', { 61 | headers: { 62 | Accept: 'application/json', 63 | 'User-Agent': 'axios 0.21.1', 64 | }, 65 | timeout: 700, 66 | }); 67 | console.info('Received fact: ', response.data); 68 | return response.data.text; 69 | } catch (err) { 70 | console.error('Error getting fact: ', err); 71 | const facts = [ 72 | "The dot over the small letter 'i' is called a tittle.", 73 | 'The plastic tips of shoelaces are called aglets.', 74 | 'Arnold Schonberg suffered from triskaidecaphobia, the fear of the number 13. He died at 13 minutes from midnight on Friday the 13th.', 75 | 'Giraffes have no vocal cords.', 76 | '70% of all boats sold are used for fishing.', 77 | 'It is illegal to hunt camels in the state of Arizona', 78 | 'More human twins are being born now than ever before.', 79 | "A narwhal's tusk reveals its past living conditions.", 80 | 'The first person convicted of speeding was going eight mph.', 81 | 'The world wastes about 1 billion metric tons of food each year.', 82 | ]; 83 | return facts[Math.floor(Math.random() * facts.length)]; 84 | } 85 | }; 86 | } 87 | 88 | // Initialise class outside of the handler so context is reused. 89 | const chatBot = new ChatBot(); 90 | 91 | // The handler simply executes the object handler 92 | export const handler = async (event: LexV2Event): Promise => chatBot.handler(event); -------------------------------------------------------------------------------- /lib/constructs/responding/chat-bot-fulfilment.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs'; 2 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 3 | import * as iam from 'aws-cdk-lib/aws-iam'; 4 | import * as lambdanode from 'aws-cdk-lib/aws-lambda-nodejs'; 5 | 6 | /** 7 | * Construct for a lambda (and associated role) for fulfilment of topics with Lex. 8 | */ 9 | export default class ChatBotFulfilmentConstruct extends Construct { 10 | 11 | public readonly lambda : lambda.IFunction; 12 | 13 | constructor(scope: Construct, id: string) { 14 | super(scope, id); 15 | 16 | this.lambda = new lambdanode.NodejsFunction(this, 'lambda', { 17 | runtime: lambda.Runtime.NODEJS_16_X, 18 | architecture: lambda.Architecture.ARM_64, 19 | environment: { 20 | NODE_OPTIONS: '--enable-source-maps', 21 | }, 22 | bundling: { 23 | minify: true, 24 | sourceMap: true, 25 | }, 26 | }); 27 | 28 | // Allow the lambda to be executed by Lex 29 | this.lambda.addPermission('LexCanExecute', { 30 | principal: new iam.ServicePrincipal('lexv2.amazonaws.com'), 31 | }); 32 | } 33 | } -------------------------------------------------------------------------------- /lib/constructs/responding/chat-bot.lambda.ts: -------------------------------------------------------------------------------- 1 | import * as aws from 'aws-sdk'; 2 | import { EventBridgeEvent } from 'aws-lambda'; 3 | 4 | type MessageAnalysedDetailType = 'MESSAGE_ANALYSED'; 5 | 6 | interface MessageAnalysedDetail { 7 | Author: string, 8 | Text: string, 9 | Twitter: TwitterDetail, 10 | } 11 | 12 | interface TwitterDetail { 13 | UserId: string, 14 | TweetId: string, 15 | } 16 | 17 | 18 | class ChatBot { 19 | private readonly _eventBusName: string; 20 | 21 | private readonly _botId: string; 22 | 23 | private readonly _botAliasId: string; 24 | 25 | private readonly _botLocaleId: string; 26 | 27 | private readonly _lex: aws.LexRuntimeV2; 28 | 29 | private readonly _eventBridge: aws.EventBridge; 30 | 31 | constructor() { 32 | const { BotId, BotAliasId, BotLocaleId, EventBusName } = process.env; 33 | 34 | if (!BotId || !BotAliasId || !BotLocaleId || !EventBusName) { 35 | throw new Error('Missing environment variables'); 36 | } 37 | 38 | this._botId = BotId; 39 | this._botAliasId = BotAliasId; 40 | this._botLocaleId = BotLocaleId; 41 | this._eventBusName = EventBusName; 42 | this._lex = new aws.LexRuntimeV2(); 43 | this._eventBridge = new aws.EventBridge(); 44 | 45 | console.info('Initialised'); 46 | } 47 | 48 | handler = async (event: EventBridgeEvent): Promise => { 49 | console.info('Received Event:', JSON.stringify(event, null, 2)); 50 | 51 | // Can't handle if no text. 52 | if (!event.detail.Text) { 53 | return false; 54 | } 55 | 56 | const response = await this._lex.recognizeText({ 57 | botId: this._botId, 58 | botAliasId: this._botAliasId, 59 | localeId: this._botLocaleId, 60 | sessionId: event.detail.Author, 61 | text: event.detail.Text.replace('@\S+', '').trim(), // Remove the username because Lex isn't trained on that 62 | }).promise(); 63 | 64 | console.info('Response:', JSON.stringify(response, null, 2)); 65 | 66 | if (response && response.messages && response.messages.length > 0 && response.messages[0].content) { 67 | const respondEvent = this.generateEvent(`@${event.detail.Author} ${response.messages[0].content}`, event.detail.Twitter); 68 | 69 | console.info('Pushing to event bridge', JSON.stringify(respondEvent, null, 2)); 70 | 71 | const putResponse = await this._eventBridge.putEvents({ 72 | Entries: [respondEvent], 73 | }).promise(); 74 | 75 | console.log('Pushed to EventBridge', JSON.stringify(putResponse, null, 2)); 76 | } 77 | 78 | return true; 79 | }; 80 | 81 | generateEvent = (message: string, detail: TwitterDetail): aws.EventBridge.PutEventsRequestEntry => { 82 | return { 83 | Detail: JSON.stringify({ 84 | Text: message, 85 | ReplyToUserId: detail.UserId, 86 | ReplyToTweetId: detail.TweetId, 87 | }), 88 | DetailType: 'SEND_TWEET', 89 | EventBusName: this._eventBusName, 90 | Source: 'BOT', 91 | }; 92 | }; 93 | } 94 | 95 | // Initialise class outside of the handler so context is reused. 96 | const chatBot = new ChatBot(); 97 | 98 | // The handler simply executes the object handler 99 | export const handler = async (event: EventBridgeEvent): Promise => chatBot.handler(event); -------------------------------------------------------------------------------- /lib/constructs/responding/chat-bot.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs'; 2 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 3 | import * as events from 'aws-cdk-lib/aws-events'; 4 | import * as lambdanode from 'aws-cdk-lib/aws-lambda-nodejs'; 5 | import * as iam from 'aws-cdk-lib/aws-iam'; 6 | 7 | export interface ChatBotConstructProps { 8 | botId: string, 9 | botAliasId: string, 10 | botLocaleId: string, 11 | plumbingEventBus: events.IEventBus, 12 | } 13 | 14 | /** 15 | * Construct for a lambda (and associated role) for interacting with Lex. 16 | */ 17 | export default class ChatBotConstruct extends Construct { 18 | 19 | public readonly lambda : lambda.IFunction; 20 | 21 | constructor(scope: Construct, id: string, props: ChatBotConstructProps) { 22 | super(scope, id); 23 | 24 | const role = new iam.Role(this, 'LambdaRole', { 25 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 26 | }); 27 | role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')); 28 | role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonLexFullAccess')); 29 | role.addToPolicy( 30 | new iam.PolicyStatement({ 31 | resources: [props.plumbingEventBus.eventBusArn], 32 | actions: ['events:PutEvents'], 33 | }), 34 | ); 35 | 36 | this.lambda = new lambdanode.NodejsFunction(this, 'lambda', { 37 | runtime: lambda.Runtime.NODEJS_16_X, 38 | architecture: lambda.Architecture.ARM_64, 39 | environment: { 40 | BotId: props.botId, 41 | BotAliasId: props.botAliasId, 42 | BotLocaleId: props.botLocaleId, 43 | EventBusName: props.plumbingEventBus.eventBusName, 44 | NODE_OPTIONS: '--enable-source-maps', 45 | }, 46 | bundling: { 47 | minify: true, 48 | sourceMap: true, 49 | }, 50 | role, 51 | }); 52 | } 53 | } -------------------------------------------------------------------------------- /lib/constructs/responding/process-images.lambda.ts: -------------------------------------------------------------------------------- 1 | import * as aws from 'aws-sdk'; 2 | import { EventBridgeEvent } from 'aws-lambda'; 3 | import gm from 'gm'; 4 | 5 | type MessageAnalysedDetailType = 'MESSAGE_ANALYSED'; 6 | 7 | interface MessageAnalysedDetail { 8 | Author: string, 9 | Text: string, 10 | Twitter: TwitterDetail, 11 | Analysis: TwitterAnalysis, 12 | } 13 | 14 | interface TwitterDetail { 15 | UserId: string, 16 | TweetId: string, 17 | } 18 | 19 | interface TwitterAnalysis { 20 | Images: TwitterImage[], 21 | } 22 | 23 | interface TwitterImage { 24 | Key: string, 25 | Analysis: TwitterImageAnalysis, 26 | } 27 | 28 | interface TwitterImageAnalysis { 29 | CelebrityFaces: TwitterFaceDetection[], 30 | UnrecognizedFaces: TwitterFace[], 31 | Labels: never[], 32 | TextDetections: never[] 33 | } 34 | 35 | interface TwitterFaceDetection { 36 | Face: TwitterFace, 37 | Name: string, 38 | Urls: string[], 39 | } 40 | 41 | interface TwitterFace { 42 | BoundingBox: BoundingBox, 43 | Confidence: number, 44 | } 45 | 46 | interface BoundingBox { 47 | Height: number, 48 | Left: number, 49 | Top: number, 50 | Width: number, 51 | } 52 | 53 | class ProcessImages { 54 | private readonly _eventBusName: string; 55 | 56 | private readonly _bucket: string; 57 | 58 | private readonly _s3: aws.S3; 59 | 60 | private readonly _eventBridge: aws.EventBridge; 61 | 62 | constructor() { 63 | const { Bucket, EventBusName } = process.env; 64 | 65 | if (!Bucket || !EventBusName) { 66 | throw new Error('Missing environment variables'); 67 | } 68 | 69 | this._bucket = Bucket; 70 | this._eventBusName = EventBusName; 71 | this._s3 = new aws.S3(); 72 | this._eventBridge = new aws.EventBridge(); 73 | 74 | console.info('Initialised'); 75 | } 76 | 77 | handler = async (event: EventBridgeEvent): Promise => { 78 | console.info('Received Event:', JSON.stringify(event, null, 2)); 79 | 80 | // Can't handle if no images 81 | if (event.detail.Analysis.Images.length <= 0) { 82 | return false; 83 | } 84 | 85 | // We will only process the first one for simplicity 86 | const imageToAnalyse = event.detail.Analysis.Images[0]; 87 | 88 | const manipulatedImage = await this.processImage(imageToAnalyse); 89 | 90 | console.log('Image has been manipulated'); 91 | 92 | // Overwrite the image 93 | const response = await this._s3.putObject({ 94 | Bucket: this._bucket, 95 | Key: imageToAnalyse.Key, 96 | Body: manipulatedImage, 97 | }).promise(); 98 | 99 | console.log('Overwritten image', response); 100 | 101 | const celebList = imageToAnalyse.Analysis.CelebrityFaces.map(f => f.Name); 102 | const celebs = celebList.length > 0 ? `I recognised: ${celebList.join(',')}` : 'Sorry I recognised no celebrities!'; 103 | 104 | if (response) { 105 | const respondEvent = this.generateEvent( 106 | `@${event.detail.Author} ${celebs}`, 107 | event.detail.Twitter, 108 | celebList.length > 0 ? imageToAnalyse.Key : undefined); 109 | 110 | console.info('Pushing to event bridge', JSON.stringify(respondEvent, null, 2)); 111 | 112 | const putResponse = await this._eventBridge.putEvents({ 113 | Entries: [respondEvent], 114 | }).promise(); 115 | 116 | console.log('Pushed to EventBridge', JSON.stringify(putResponse, null, 2)); 117 | } 118 | 119 | return true; 120 | }; 121 | 122 | processImage = async (imageSpec: TwitterImage) : Promise => { 123 | console.info('Downloading', imageSpec.Key); 124 | 125 | const response = await this._s3.getObject({ 126 | Bucket: this._bucket, 127 | Key: imageSpec.Key, 128 | }).promise(); 129 | 130 | if (!response.Body) { 131 | throw new Error('Cannot find image'); 132 | } 133 | 134 | return new Promise( function (resolve, reject) { 135 | try { 136 | 137 | const img = gm(response.Body as Buffer); 138 | 139 | img.size(function (err: Error | null, value: gm.Dimensions) { 140 | if (err) { 141 | reject(err); 142 | } else { 143 | console.info('Processing celebrity faces', imageSpec.Key); 144 | for (const celebFace of imageSpec.Analysis.CelebrityFaces) { 145 | 146 | const x = (celebFace.Face.BoundingBox.Left * value.width) + ((celebFace.Face.BoundingBox.Width * value.width) / 2); 147 | const y = celebFace.Face.BoundingBox.Top * value.height; 148 | 149 | console.log('Drawing text', x, y); 150 | 151 | img.stroke('red', 1).fontSize(18).drawText(x, y, celebFace.Name); 152 | } 153 | 154 | // One option is to blur the unknowns 155 | // console.info('Processing Unrecognized faces', imageSpec.Key); 156 | // for(const unknownFace of imageSpec.Analysis.UnrecognizedFaces) { 157 | // img.region( 158 | // unknownFace.BoundingBox.Width * value.width, 159 | // unknownFace.BoundingBox.Height * value.height, 160 | // unknownFace.BoundingBox.Left * value.width, 161 | // unknownFace.BoundingBox.Top * value.height).blur(20); 162 | // } 163 | 164 | img.toBuffer('JPG', function (error: Error | null, buffer: Buffer) { 165 | if (error) { 166 | reject(error); 167 | } else { 168 | resolve(buffer); 169 | } 170 | }); 171 | } 172 | }); 173 | } catch (error) { 174 | console.error('Failed doing image', error); 175 | reject(error); 176 | } 177 | }); 178 | }; 179 | 180 | generateEvent = (message: string, detail: TwitterDetail, imageKey?: string): aws.EventBridge.PutEventsRequestEntry => { 181 | return { 182 | Detail: JSON.stringify({ 183 | Text: message, 184 | ReplyToUserId: detail.UserId, 185 | ReplyToTweetId: detail.TweetId, 186 | ImageKey: imageKey, 187 | }), 188 | DetailType: 'SEND_TWEET', 189 | EventBusName: this._eventBusName, 190 | Source: 'BOT', 191 | }; 192 | }; 193 | } 194 | 195 | // Initialise class outside of the handler so context is reused. 196 | const processImages = new ProcessImages(); 197 | 198 | // The handler simply executes the object handler 199 | export const handler = async (event: EventBridgeEvent): Promise => processImages.handler(event); -------------------------------------------------------------------------------- /lib/constructs/responding/process-images.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs'; 2 | import * as cdk from 'aws-cdk-lib'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import * as lambdanode from 'aws-cdk-lib/aws-lambda-nodejs'; 5 | import * as iam from 'aws-cdk-lib/aws-iam'; 6 | import * as s3 from 'aws-cdk-lib/aws-s3'; 7 | import * as events from 'aws-cdk-lib/aws-events'; 8 | 9 | export interface ProcessImagesConstructProps { 10 | bucket: s3.IBucket, 11 | plumbingEventBus: events.IEventBus, 12 | } 13 | 14 | /** 15 | * Construct for a lambda (and associated role) to process an analysed image for responding. 16 | */ 17 | export default class ProcessImagesConstruct extends Construct { 18 | 19 | public readonly lambda : lambda.IFunction; 20 | 21 | constructor(scope: Construct, id: string, props: ProcessImagesConstructProps) { 22 | super(scope, id); 23 | 24 | const role = new iam.Role(this, 'LambdaRole', { 25 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 26 | }); 27 | role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')); 28 | role.addToPolicy( 29 | new iam.PolicyStatement({ 30 | resources: [`${props.bucket.bucketArn}/*`], 31 | actions: ['s3:GetObject', 's3:PutObject'], 32 | }), 33 | ); 34 | role.addToPolicy( 35 | new iam.PolicyStatement({ 36 | resources: [props.plumbingEventBus.eventBusArn], 37 | actions: ['events:PutEvents'], 38 | }), 39 | ); 40 | 41 | // From https://github.com/rpidanny/gm-lambda-layer 42 | const layer = lambda.LayerVersion.fromLayerVersionArn(this, 'GMLayer', 'arn:aws:lambda:eu-west-1:175033217214:layer:graphicsmagick:2'); 43 | 44 | this.lambda = new lambdanode.NodejsFunction(this, 'lambda', { 45 | runtime: lambda.Runtime.NODEJS_16_X, 46 | architecture: lambda.Architecture.X86_64, // Can't use ARM for ImageMagick 47 | layers: [layer], 48 | environment: { 49 | Bucket: props.bucket.bucketName, 50 | EventBusName: props.plumbingEventBus.eventBusName, 51 | NODE_OPTIONS: '--enable-source-maps', 52 | }, 53 | bundling: { 54 | minify: true, 55 | sourceMap: true, 56 | }, 57 | timeout: cdk.Duration.seconds(5), 58 | memorySize: 256, 59 | role, 60 | }); 61 | } 62 | } -------------------------------------------------------------------------------- /lib/stacks/alerting-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import * as events from 'aws-cdk-lib/aws-events'; 4 | import * as targets from 'aws-cdk-lib/aws-events-targets'; 5 | import * as sns from 'aws-cdk-lib/aws-sns'; 6 | 7 | export interface AlertingStackProps extends cdk.StackProps { 8 | plumbingEventBus: events.IEventBus 9 | } 10 | 11 | /** 12 | * Stack that subscribes to events from event bridge for sending alerts about things that need to be seen. 13 | */ 14 | export class AlertingStack extends cdk.Stack { 15 | 16 | public readonly _eventBus: events.IEventBus; 17 | 18 | constructor(scope: Construct, id: string, props: AlertingStackProps) { 19 | super(scope, id, props); 20 | 21 | this._eventBus = props.plumbingEventBus; 22 | 23 | // People can subscribe phone numbers and emails to this to get alerts 24 | const alertTopic = new sns.Topic(this, 'MessageAlert'); 25 | 26 | this.createNegativeMessagesRule(alertTopic); 27 | } 28 | 29 | /** 30 | * Add a rule to catch all negative sentiment messages and send an alert to the Alert topic for subscribers. 31 | * @param alertTopic - Topic to send the events to 32 | */ 33 | private createNegativeMessagesRule(alertTopic: cdk.aws_sns.Topic) { 34 | const negativeMessagesRule = new events.Rule(this, 'NegativeMessages', { 35 | eventPattern: { 36 | detailType: ['MESSAGE_ANALYSED'], 37 | detail: { 38 | Analysis: { 39 | TextSentiment: ['NEGATIVE'], 40 | }, 41 | }, 42 | }, 43 | eventBus: this._eventBus, 44 | }); 45 | 46 | negativeMessagesRule.addTarget(new targets.SnsTopic(alertTopic, { 47 | message: events.RuleTargetInput.fromText( 48 | `You have received a Negative Message: ${events.EventField.fromPath('$.detail.Text')} - ${events.EventField.fromPath('$.detail.Author')}`), 49 | })); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/stacks/analysis-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import * as events from 'aws-cdk-lib/aws-events'; 4 | import * as s3 from 'aws-cdk-lib/aws-s3'; 5 | import * as iam from 'aws-cdk-lib/aws-iam'; 6 | import * as targets from 'aws-cdk-lib/aws-events-targets'; 7 | import * as stepfunctions from 'aws-cdk-lib/aws-stepfunctions'; 8 | import * as tasks from 'aws-cdk-lib/aws-stepfunctions-tasks'; 9 | import DownloadImagesConstruct from '../constructs/analysis/download-images'; 10 | import { JsonPath } from 'aws-cdk-lib/aws-stepfunctions'; 11 | 12 | export interface AnalysisStackProps extends cdk.StackProps { 13 | plumbingEventBus: events.IEventBus 14 | } 15 | 16 | /** 17 | * Stack that handles analysing received messages. 18 | */ 19 | export class AnalysisStack extends cdk.Stack { 20 | 21 | public readonly analyseBucket: s3.Bucket; 22 | 23 | private readonly _downloadImagesConstruct: DownloadImagesConstruct; 24 | 25 | private readonly _plumbingEventBus: events.IEventBus; 26 | 27 | constructor(scope: Construct, id: string, props: AnalysisStackProps) { 28 | super(scope, id, props); 29 | 30 | this._plumbingEventBus = props.plumbingEventBus; 31 | 32 | // Stores images that are downloaded for analysis - don't need persistence here so expire items after 1 day. 33 | this.analyseBucket = new s3.Bucket(this, 'AnalysisMedia', { 34 | removalPolicy: cdk.RemovalPolicy.DESTROY, 35 | autoDeleteObjects: true, 36 | lifecycleRules: [{ 37 | expiration: cdk.Duration.days(1), 38 | }], 39 | }); 40 | 41 | this._downloadImagesConstruct = new DownloadImagesConstruct(this, 'Download Images', { 42 | bucket: this.analyseBucket, 43 | }); 44 | 45 | const analyseMessageStateMachine = this.buildStepFunction(); 46 | 47 | const analyseIncomingMessageRule = new events.Rule(this, 'AnalyseIncomingMessageRule', { 48 | eventPattern: { 49 | detailType: ['MESSAGE_RECEIVED'], 50 | }, 51 | eventBus: props.plumbingEventBus, 52 | }); 53 | analyseIncomingMessageRule.addTarget(new targets.SfnStateMachine(analyseMessageStateMachine)); 54 | } 55 | 56 | /** 57 | * Will build the main analysis stepfunction that will orchestrate the calls to the different AI services. 58 | * @returns The build State Machine. 59 | */ 60 | private buildStepFunction(): stepfunctions.StateMachine { 61 | const detectEntities = new tasks.CallAwsService(this, 'Detect Entities', { 62 | service: 'comprehend', 63 | action: 'detectEntities', 64 | iamResources: ['*'], 65 | parameters: { 66 | 'Text': stepfunctions.JsonPath.stringAt('$.Text'), 67 | 'LanguageCode': 'en', 68 | }, 69 | }); 70 | 71 | const detectSentiment = new tasks.CallAwsService(this, 'Detect Sentiment', { 72 | service: 'comprehend', 73 | action: 'detectSentiment', 74 | iamResources: ['*'], 75 | parameters: { 76 | 'Text': stepfunctions.JsonPath.stringAt('$.Text'), 77 | 'LanguageCode': 'en', 78 | }, 79 | }); 80 | 81 | const analyseText = new stepfunctions.Parallel(this, 'Analyse Text', { 82 | resultSelector: { 83 | Entities: stepfunctions.JsonPath.stringAt('$[0].Entities'), 84 | Sentiment: stepfunctions.JsonPath.stringAt('$[1].Sentiment'), 85 | }, 86 | }); 87 | analyseText.branch(detectEntities); 88 | analyseText.branch(detectSentiment); 89 | 90 | const containImage = new stepfunctions.Choice(this, 'Contain Image(s)?'); 91 | const containsImageCondition = stepfunctions.Condition.isPresent('$.ImageUrls'); 92 | const noImage = new stepfunctions.Pass(this, 'No Images', { 93 | outputPath: JsonPath.DISCARD, 94 | }); 95 | 96 | const downloadImagesToS3 = new tasks.LambdaInvoke(this, 'Download Images to S3', { 97 | lambdaFunction: this._downloadImagesConstruct.lambda, 98 | payload: stepfunctions.TaskInput.fromObject({ 99 | imageUrls: stepfunctions.JsonPath.listAt('$.ImageUrls'), 100 | }), 101 | outputPath: '$.Payload', 102 | }); 103 | 104 | const detectLabels = new tasks.CallAwsService(this, 'Detect Labels', { 105 | service: 'rekognition', 106 | action: 'detectLabels', 107 | iamResources: ['*'], 108 | parameters: { 109 | 'Image': { 110 | 'S3Object': { 111 | 'Bucket': this.analyseBucket.bucketName, 112 | 'Name': stepfunctions.JsonPath.stringAt('$.Key'), 113 | }, 114 | }, 115 | }, 116 | resultSelector: { 117 | Labels: stepfunctions.JsonPath.stringAt('$.Labels'), 118 | }, 119 | }); 120 | 121 | const detectText = new tasks.CallAwsService(this, 'Detect Text', { 122 | service: 'rekognition', 123 | action: 'detectText', 124 | iamResources: ['*'], 125 | parameters: { 126 | 'Image': { 127 | 'S3Object': { 128 | 'Bucket': this.analyseBucket.bucketName, 129 | 'Name': stepfunctions.JsonPath.stringAt('$.Key'), 130 | }, 131 | }, 132 | }, 133 | resultSelector: { 134 | TextDetections: stepfunctions.JsonPath.stringAt('$.TextDetections'), 135 | }, 136 | }); 137 | 138 | const recognizeCelebrities = new tasks.CallAwsService(this, 'Recognize Celebrities', { 139 | service: 'rekognition', 140 | action: 'recognizeCelebrities', 141 | iamResources: ['*'], 142 | parameters: { 143 | 'Image': { 144 | 'S3Object': { 145 | 'Bucket': this.analyseBucket.bucketName, 146 | 'Name': stepfunctions.JsonPath.stringAt('$.Key'), 147 | }, 148 | }, 149 | }, 150 | }); 151 | 152 | const mapImage = new stepfunctions.Map(this, 'Map through Images', { 153 | itemsPath: stepfunctions.JsonPath.stringAt('$.Images'), 154 | }); 155 | 156 | const parallelImages = new stepfunctions.Parallel(this, 'Analyse Image', { 157 | resultSelector: { 158 | Labels: stepfunctions.JsonPath.stringAt('$[0].Labels'), 159 | TextDetections: stepfunctions.JsonPath.stringAt('$[1].TextDetections'), 160 | CelebrityFaces: stepfunctions.JsonPath.stringAt('$[2].CelebrityFaces'), 161 | UnrecognizedFaces: stepfunctions.JsonPath.stringAt('$[2].UnrecognizedFaces'), 162 | }, 163 | resultPath: '$.Analysis', 164 | }); 165 | parallelImages.branch(detectLabels); 166 | parallelImages.branch(detectText); 167 | parallelImages.branch(recognizeCelebrities); 168 | 169 | const pushResultingEvent = new tasks.CallAwsService(this, 'Push Result', { 170 | service: 'eventbridge', 171 | action: 'putEvents', 172 | iamResources: ['*'], 173 | parameters: { 174 | 'Entries': [ 175 | { 176 | 'Detail': { 177 | 'Text': stepfunctions.JsonPath.stringAt('$$.Execution.Input.detail.Text'), 178 | 'Author': stepfunctions.JsonPath.stringAt('$$.Execution.Input.detail.Author'), 179 | 'Analysis': stepfunctions.JsonPath.objectAt('$'), 180 | 'Twitter': { 181 | 'TweetId': stepfunctions.JsonPath.stringAt('$$.Execution.Input.detail.Twitter.TweetId'), 182 | 'UserId': stepfunctions.JsonPath.stringAt('$$.Execution.Input.detail.Twitter.UserId'), 183 | }, 184 | }, 185 | 'DetailType': 'MESSAGE_ANALYSED', 186 | 'EventBusName': this._plumbingEventBus.eventBusName, 187 | 'Source': stepfunctions.JsonPath.stringAt('$$.Execution.Input.source'), 188 | }, 189 | ], 190 | }, 191 | }); 192 | 193 | const pushFailEvent = new stepfunctions.Pass(this, 'Push Fail Result'); 194 | 195 | const parallelTextAndImages = new stepfunctions.Parallel(this, 'Analyse Text and Images', { 196 | inputPath: '$.detail', 197 | resultSelector: { 198 | TextEntities: stepfunctions.JsonPath.stringAt('$[0].Entities'), 199 | TextSentiment: stepfunctions.JsonPath.stringAt('$[0].Sentiment'), 200 | Images: stepfunctions.JsonPath.stringAt('$[1]'), 201 | }, 202 | }); 203 | parallelTextAndImages.branch(analyseText); 204 | parallelTextAndImages.branch(containImage 205 | .when(containsImageCondition, downloadImagesToS3 206 | .next(mapImage 207 | .iterator(parallelImages))) 208 | .otherwise(noImage)); 209 | 210 | const definition = parallelTextAndImages 211 | .addCatch(pushFailEvent) 212 | .next(pushResultingEvent); 213 | 214 | const sf = new stepfunctions.StateMachine(this, 'AnalyseTweet', { 215 | definition, 216 | 217 | }); 218 | 219 | // Allow rekognition to get the images - running via the Step Function role 220 | sf.addToRolePolicy(new iam.PolicyStatement({ 221 | resources: [`${this.analyseBucket.bucketArn}/*`], 222 | actions: ['s3:GetObject'], 223 | })); 224 | 225 | // CDK doesn't do the permissions correctly... 226 | sf.addToRolePolicy(new iam.PolicyStatement({ 227 | resources: [this._plumbingEventBus.eventBusArn], 228 | actions: ['events:PutEvents'], 229 | })); 230 | 231 | return sf; 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /lib/stacks/analytics-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import * as events from 'aws-cdk-lib/aws-events'; 4 | import * as s3 from 'aws-cdk-lib/aws-s3'; 5 | import * as targets from 'aws-cdk-lib/aws-events-targets'; 6 | import * as firehose from 'aws-cdk-lib/aws-kinesisfirehose'; 7 | import * as glue from '@aws-cdk/aws-glue-alpha'; 8 | import * as firehose_alpha from '@aws-cdk/aws-kinesisfirehose-alpha'; 9 | import * as firehosedestinations from '@aws-cdk/aws-kinesisfirehose-destinations-alpha'; 10 | import TransformRecordsConstruct from '../constructs/analytics/transform-records'; 11 | 12 | export interface AnalyticsStackProps extends cdk.StackProps { 13 | plumbingEventBus: events.IEventBus 14 | } 15 | 16 | /** 17 | * Stack that allows analytics over the messages received and analysed. 18 | */ 19 | export class AnalyticsStack extends cdk.Stack { 20 | 21 | public readonly _eventBus: events.IEventBus; 22 | 23 | constructor(scope: Construct, id: string, props: AnalyticsStackProps) { 24 | super(scope, id, props); 25 | 26 | this._eventBus = props.plumbingEventBus; 27 | 28 | const dataLakeBucket = new s3.Bucket(this, 'DataLake'); 29 | 30 | // Used to transform each record with a newline so stored in S3 for querying ok 31 | const transformRecords = new TransformRecordsConstruct(this, 'TransformRecords'); 32 | 33 | const processor = new firehose_alpha.LambdaFunctionProcessor(transformRecords.lambda, { 34 | bufferInterval: cdk.Duration.seconds(60), 35 | bufferSize: cdk.Size.mebibytes(1), 36 | retries: 1, 37 | }); 38 | 39 | const kinesis = new firehose_alpha.DeliveryStream(this, 'DeliveryStream', { 40 | destinations: [new firehosedestinations.S3Bucket(dataLakeBucket, { 41 | bufferingInterval: cdk.Duration.seconds(60), 42 | processor, 43 | })], 44 | }); 45 | 46 | const dataLakeRule = new events.Rule(this, 'DataLakeRule', { 47 | eventPattern: { 48 | account: [cdk.Aws.ACCOUNT_ID], 49 | }, 50 | eventBus: this._eventBus, 51 | }); 52 | 53 | dataLakeRule.addTarget(new targets.KinesisFirehoseStream(kinesis.node.defaultChild as firehose.CfnDeliveryStream, { 54 | })); 55 | 56 | const database = new glue.Database(this, 'MessagesDataLake', { 57 | databaseName: 'messages-data-lake', 58 | }); 59 | 60 | this.createGlueTableForAnalysedMessage(database, dataLakeBucket); 61 | } 62 | 63 | /** 64 | * Create a Glue table for the Analysed Message type, could also use a crawler to find the schema automatically but we 65 | * know the schema so this is safer. If we want to search all the different types of TWITTER event schemas then a crawler 66 | * probably makes more sense. 67 | * @param database The Glue DB to add the table to. 68 | * @param dataLakeBucket The Bucket containing the data, to create the table for. 69 | */ 70 | private createGlueTableForAnalysedMessage(database: glue.Database, dataLakeBucket: cdk.aws_s3.Bucket) { 71 | new glue.Table(this, 'AnalysedMessagesTable', { 72 | database, 73 | storedAsSubDirectories: true, // Kinesis stores the data in sub-directories 74 | tableName: 'analysed-messages-table', 75 | bucket: dataLakeBucket, 76 | columns: [ 77 | { 78 | name: 'detail-type', 79 | type: glue.Schema.STRING, 80 | }, 81 | { 82 | name: 'source', 83 | type: glue.Schema.STRING, 84 | }, 85 | { 86 | name: 'time', 87 | type: glue.Schema.TIMESTAMP, 88 | }, 89 | { 90 | name: 'detail', 91 | type: glue.Schema.struct([ 92 | { 93 | name: 'Author', 94 | type: glue.Schema.STRING, 95 | }, 96 | { 97 | name: 'Text', 98 | type: glue.Schema.STRING, 99 | }, 100 | { 101 | name: 'Analysis', 102 | type: glue.Schema.struct([ 103 | { 104 | name: 'TextSentiment', 105 | type: glue.Schema.STRING, 106 | }, 107 | { 108 | name: 'Images', 109 | type: glue.Schema.array(glue.Schema.struct([ 110 | { 111 | name: 'Analysis', 112 | type: glue.Schema.struct([ 113 | { 114 | name: 'CelebrityFaces', 115 | type: glue.Schema.array(glue.Schema.struct([ 116 | { 117 | name: 'Name', 118 | type: glue.Schema.STRING, 119 | }, 120 | { 121 | name: 'Face', 122 | type: glue.Schema.struct([ 123 | { 124 | name: 'Emotions', 125 | type: glue.Schema.array(glue.Schema.struct([ 126 | { 127 | name: 'Confidence', 128 | type: glue.Schema.DOUBLE, 129 | }, 130 | { 131 | name: 'Type', 132 | type: glue.Schema.STRING, 133 | }, 134 | ])), 135 | }, 136 | ]), 137 | }, 138 | ])), 139 | }, 140 | { 141 | name: 'UnrecognizedFaces', 142 | type: glue.Schema.array(glue.Schema.struct([ 143 | { 144 | name: 'Emotions', 145 | type: glue.Schema.array(glue.Schema.struct([ 146 | { 147 | name: 'Confidence', 148 | type: glue.Schema.DOUBLE, 149 | }, 150 | { 151 | name: 'Type', 152 | type: glue.Schema.STRING, 153 | }, 154 | ])), 155 | }, 156 | ])), 157 | }, 158 | { 159 | name: 'Labels', 160 | type: glue.Schema.array(glue.Schema.struct([ 161 | { 162 | name: 'Confidence', 163 | type: glue.Schema.DOUBLE, 164 | }, 165 | { 166 | name: 'Name', 167 | type: glue.Schema.STRING, 168 | }, 169 | ])), 170 | }, 171 | ]), 172 | }, 173 | ])), 174 | }, 175 | ]), 176 | }, 177 | ]), 178 | }, 179 | ], 180 | dataFormat: glue.DataFormat.JSON, 181 | }); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /lib/stacks/egress-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import * as events from 'aws-cdk-lib/aws-events'; 4 | import * as s3 from 'aws-cdk-lib/aws-s3'; 5 | import * as targets from 'aws-cdk-lib/aws-events-targets'; 6 | import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; 7 | import TweetConstruct from '../constructs/egress/tweet-construct'; 8 | 9 | export interface EgressStackProps extends cdk.StackProps { 10 | plumbingEventBus: events.IEventBus, 11 | analysisBucket: s3.IBucket, 12 | } 13 | 14 | /** 15 | * Stack that subscribes to events from event bridge for sending messages back to the source, such as a Twitter reply. 16 | */ 17 | export class EgressStack extends cdk.Stack { 18 | constructor(scope: Construct, id: string, props: EgressStackProps) { 19 | super(scope, id, props); 20 | 21 | // The secret is manually added, see README for more details, but this is the AWS Recommended way to 22 | // ensure it is not stored in Git or shared around as environment variables/parameters. 23 | const twitterSecret = secretsmanager.Secret.fromSecretAttributes(this, 'TwitterSecret', { 24 | secretPartialArn: `arn:aws:secretsmanager:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:secret:TwitterSecret`, 25 | }); 26 | 27 | const tweetConstruct = new TweetConstruct(this, 'TweetConstruct', { 28 | twitterSecret, 29 | plumbingEventBus: props.plumbingEventBus, 30 | bucket: props.analysisBucket, 31 | }); 32 | 33 | const sendTweetRule = new events.Rule(this, 'SendTweetRule', { 34 | eventPattern: { 35 | detailType: ['SEND_TWEET'], 36 | }, 37 | eventBus: props.plumbingEventBus, 38 | }); 39 | sendTweetRule.addTarget(new targets.LambdaFunction(tweetConstruct.lambda)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/stacks/ingress-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import * as apigateway from 'aws-cdk-lib/aws-apigateway'; 4 | import * as events from 'aws-cdk-lib/aws-events'; 5 | import TwitterConstruct from '../constructs/ingress/twitter-construct'; 6 | import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; 7 | 8 | export interface IngressStackProps extends cdk.StackProps { 9 | plumbingEventBus: events.IEventBus, 10 | twitterIdOfAccount: number 11 | } 12 | 13 | /** 14 | * Stack that handles the ingress of data and outputs into the plumbling event bus. 15 | */ 16 | export class IngressStack extends cdk.Stack { 17 | constructor(scope: Construct, id: string, props: IngressStackProps) { 18 | super(scope, id, props); 19 | 20 | // The secret is manually added, see README for more details, but this is the AWS Recommended way to 21 | // ensure it is not stored in Git or shared around as environment variables/parameters. 22 | const twitterSecret = secretsmanager.Secret.fromSecretAttributes(this, 'TwitterSecret', { 23 | secretPartialArn: `arn:aws:secretsmanager:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:secret:TwitterSecret`, 24 | }); 25 | 26 | // This contains the lambda that we will proxy to from API Gateway 27 | const construct = new TwitterConstruct(this, 'TwitterActivity', { 28 | twitterSecret, 29 | plumbingEventBus: props.plumbingEventBus, 30 | twitterIdOfAccount: props.twitterIdOfAccount, 31 | }); 32 | 33 | // Proxy to false so we can define the API model 34 | const api = new apigateway.LambdaRestApi(this, 'ingress-api', { 35 | handler: construct.lambda, 36 | proxy: false, 37 | }); 38 | 39 | const twitterEndpoint = api.root.addResource('twitter'); 40 | twitterEndpoint.addMethod('GET'); 41 | twitterEndpoint.addMethod('POST'); 42 | 43 | // Create an Output for information 44 | new cdk.CfnOutput(this, 'APIGateway', { 45 | value: api.url, 46 | description: 'The ingress API endpoint for the Twitter Bot', 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/stacks/plumbing-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import * as logs from 'aws-cdk-lib/aws-logs'; 4 | import * as events from 'aws-cdk-lib/aws-events'; 5 | import * as targets from 'aws-cdk-lib/aws-events-targets'; 6 | 7 | /** 8 | * Stack that handles the event bus that plumbs all the components together. 9 | */ 10 | export class PlumbingStack extends cdk.Stack { 11 | 12 | public readonly eventBus: events.IEventBus; 13 | 14 | constructor(scope: Construct, id: string, props: cdk.StackProps) { 15 | super(scope, id, props); 16 | 17 | this.eventBus = new events.EventBus(this, 'bus', { 18 | eventBusName: 'Plumbing', 19 | }); 20 | 21 | this.addEventArchiveForReplayAbility(); 22 | 23 | this.addCatchAllRuleToDebugLog(); 24 | } 25 | 26 | /** 27 | * Archive all the events so we can do replay if needed. 28 | */ 29 | private addEventArchiveForReplayAbility() { 30 | this.eventBus.archive('PlumbingArchive', { 31 | eventPattern: { 32 | account: [cdk.Aws.ACCOUNT_ID], 33 | }, 34 | retention: cdk.Duration.days(7), 35 | }); 36 | } 37 | 38 | /** 39 | * Debug logs - keep for a week and don't let them hang around if the stack is deleted. 40 | */ 41 | private addCatchAllRuleToDebugLog() { 42 | const catchAllLogGroup = new logs.LogGroup(this, 'PlumbingEvents', { 43 | retention: logs.RetentionDays.ONE_MONTH, 44 | removalPolicy: cdk.RemovalPolicy.DESTROY, 45 | }); 46 | 47 | this.createMetricFilter(catchAllLogGroup, 'MESSAGE_RECEIVED'); 48 | this.createMetricFilter(catchAllLogGroup, 'MESSAGE_ANALYSED'); 49 | this.createMetricFilter(catchAllLogGroup, 'TWITTER_FAVOURITED'); 50 | this.createMetricFilter(catchAllLogGroup, 'TWITTER_FOLLOWED'); 51 | this.createMetricFilter(catchAllLogGroup, 'TWITTER_UNFOLLOWED'); 52 | this.createMetricFilter(catchAllLogGroup, 'TWITTER_DM_RECEIVED'); 53 | this.createMetricFilter(catchAllLogGroup, 'TWITTER_DELETED'); 54 | this.createMetricFilter(catchAllLogGroup, 'SEND_TWEET'); 55 | 56 | const catchAllRule = new events.Rule(this, 'CatchAllRule', { 57 | eventPattern: { 58 | account: [cdk.Aws.ACCOUNT_ID], 59 | }, 60 | eventBus: this.eventBus, 61 | }); 62 | catchAllRule.addTarget(new targets.CloudWatchLogGroup(catchAllLogGroup)); 63 | } 64 | 65 | /** 66 | * Creates a single metric filter for the given detail type.. 67 | * @param catchAllLogGroup The log group that is capturing all messages through the application. 68 | * @param metricName The name of the detail-type and therefore metric to create. 69 | */ 70 | private createMetricFilter(catchAllLogGroup: cdk.aws_logs.LogGroup, metricName: string) { 71 | new logs.MetricFilter(this, `MetricFilter${metricName}`, { 72 | logGroup: catchAllLogGroup, 73 | metricNamespace: 'ServerlessMessageAnalyser', 74 | metricName, 75 | filterPattern: logs.FilterPattern.stringValue('$.detail-type', '=', metricName), 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /lib/stacks/responding-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import * as events from 'aws-cdk-lib/aws-events'; 4 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 5 | import * as lex from 'aws-cdk-lib/aws-lex'; 6 | import * as iam from 'aws-cdk-lib/aws-iam'; 7 | import * as s3 from 'aws-cdk-lib/aws-s3'; 8 | import * as targets from 'aws-cdk-lib/aws-events-targets'; 9 | import ChatBotConstruct from '../constructs/responding/chat-bot'; 10 | import ChatBotFulfilmentConstruct from '../constructs/responding/chat-bot-fulfilment'; 11 | import ProcessImagesConstruct from '../constructs/responding/process-images'; 12 | 13 | export interface RespondingStackProps extends cdk.StackProps { 14 | plumbingEventBus: events.IEventBus, 15 | analysisBucket: s3.IBucket, 16 | } 17 | 18 | /** 19 | * Stack that subscribes to events from event bridge for generating responses from the messages. 20 | */ 21 | export class RespondingStack extends cdk.Stack { 22 | 23 | private readonly localeId = 'en_GB'; 24 | 25 | public readonly _eventBus: events.IEventBus; 26 | 27 | constructor(scope: Construct, id: string, props: RespondingStackProps) { 28 | super(scope, id, props); 29 | 30 | this._eventBus = props.plumbingEventBus; 31 | 32 | const textLambda = this.createTextResponseResources(); 33 | 34 | // Match on anything that doesn't have images, they will be dealt with my a different lambda 35 | const analyseIncomingMessageRule = new events.Rule(this, 'RespondTextRule', { 36 | eventPattern: { 37 | detailType: ['MESSAGE_ANALYSED'], 38 | detail: { 39 | Analysis: { 40 | Images: { 41 | Key: [ { 'exists': false } ], 42 | }, 43 | }, 44 | }, 45 | }, 46 | eventBus: props.plumbingEventBus, 47 | }); 48 | analyseIncomingMessageRule.addTarget(new targets.LambdaFunction(textLambda)); 49 | 50 | 51 | const processImages = new ProcessImagesConstruct(this, 'ProcessImages', { 52 | plumbingEventBus: props.plumbingEventBus, 53 | bucket: props.analysisBucket, 54 | }); 55 | 56 | // Match on anything that has at least one image 57 | const analyseImageRule = new events.Rule(this, 'RespondImageRule', { 58 | eventPattern: { 59 | detailType: ['MESSAGE_ANALYSED'], 60 | detail: { 61 | Analysis: { 62 | Images: { 63 | Key: [ { 'exists': true } ], 64 | }, 65 | }, 66 | }, 67 | }, 68 | eventBus: props.plumbingEventBus, 69 | }); 70 | analyseImageRule.addTarget(new targets.LambdaFunction(processImages.lambda)); 71 | } 72 | 73 | /** 74 | * Create the Lex bot and lambdas that Lex will use/or be called from. 75 | * @returns The lambda that calls the Lex bot. 76 | */ 77 | private createTextResponseResources(): lambda.IFunction { 78 | const role = new iam.Role(this, 'BotRole', { 79 | assumedBy: new iam.ServicePrincipal('lexv2.amazonaws.com'), 80 | }); 81 | 82 | const chatBotFulfilmentConstruct = new ChatBotFulfilmentConstruct(this, 'ChatBotFulfilment'); 83 | 84 | const jokeIntent: lex.CfnBot.IntentProperty = { 85 | name: 'Joke', 86 | sampleUtterances: [ 87 | { 88 | utterance: 'please tell me a joke', 89 | }, 90 | { 91 | utterance: 'tell me a joke', 92 | }, 93 | { 94 | utterance: 'I would like to hear a joke', 95 | }, 96 | { 97 | utterance: 'make me laugh', 98 | }, 99 | { 100 | utterance: 'have you got a joke', 101 | }, 102 | { 103 | utterance: 'tell me something funny', 104 | }, 105 | ], 106 | fulfillmentCodeHook: { 107 | enabled: true, 108 | }, 109 | }; 110 | 111 | const factIntent: lex.CfnBot.IntentProperty = { 112 | name: 'Fact', 113 | sampleUtterances: [ 114 | { 115 | utterance: 'please tell me a fact', 116 | }, 117 | { 118 | utterance: 'tell me a fact', 119 | }, 120 | { 121 | utterance: 'I would like to hear a fact', 122 | }, 123 | { 124 | utterance: 'tell me something interesting', 125 | }, 126 | { 127 | utterance: 'I would like to hear something interesting', 128 | }, 129 | ], 130 | fulfillmentCodeHook: { 131 | enabled: true, 132 | }, 133 | }; 134 | 135 | const fallbackIntent: lex.CfnBot.IntentProperty = { 136 | name: 'FallbackIntent', 137 | parentIntentSignature: 'AMAZON.FallbackIntent', 138 | intentClosingSetting: { 139 | isActive: true, 140 | closingResponse: { 141 | messageGroupsList: [ 142 | { 143 | message: { 144 | plainTextMessage: { 145 | value: 'You can ask me for a joke, fact or send a single image and I will identify the celebrities', 146 | }, 147 | }, 148 | }, 149 | ], 150 | }, 151 | }, 152 | }; 153 | 154 | const bot = new lex.CfnBot(this, 'RespondBot', { 155 | name: 'MessageResponderBot', 156 | idleSessionTtlInSeconds: 300, 157 | roleArn: role.roleArn, 158 | dataPrivacy: { 159 | ChildDirected: false, 160 | }, 161 | autoBuildBotLocales: true, 162 | botLocales: [ 163 | { 164 | localeId: this.localeId, 165 | nluConfidenceThreshold: 0.6, 166 | intents: [jokeIntent, factIntent, fallbackIntent], 167 | }, 168 | ], 169 | }); 170 | 171 | const botVersion = new lex.CfnBotVersion(this, 'RespondBotVers', { 172 | botId: bot.ref, 173 | botVersionLocaleSpecification: [{ 174 | botVersionLocaleDetails: { 175 | sourceBotVersion: 'DRAFT', 176 | }, 177 | localeId: this.localeId, 178 | }, 179 | ], 180 | }); 181 | 182 | const botAlias = new lex.CfnBotAlias(this, 'RespondBotAlias', { 183 | botAliasName: 'BiscuitCake', 184 | botVersion: botVersion.attrBotVersion, 185 | botId: bot.ref, 186 | botAliasLocaleSettings: [ 187 | { 188 | localeId: this.localeId, 189 | botAliasLocaleSetting: { 190 | enabled: true, 191 | codeHookSpecification: { 192 | lambdaCodeHook: { 193 | codeHookInterfaceVersion: '1.0', 194 | lambdaArn: chatBotFulfilmentConstruct.lambda.functionArn, 195 | }, 196 | }, 197 | }, 198 | }, 199 | ], 200 | }); 201 | 202 | const chatBotConstruct = new ChatBotConstruct(this, 'ChatBot', { 203 | botId: bot.ref, 204 | botAliasId: botAlias.attrBotAliasId, 205 | botLocaleId: this.localeId, 206 | plumbingEventBus: this._eventBus, 207 | }); 208 | 209 | return chatBotConstruct.lambda; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-serverless-twitter-bot", 3 | "description": "CDK Application for a Serverless Event Driven Twitter Bot Utilising AI Services", 4 | "version": "1.0.0", 5 | "author": { 6 | "name": "Martyn Kilbryde", 7 | "url": "https://makit.net/" 8 | }, 9 | "bin": { 10 | "aws-serverless-twitter-bot": "bin/aws-serverless-twitter-bot.js" 11 | }, 12 | "scripts": { 13 | "build": "tsc", 14 | "watch": "tsc -w", 15 | "cdk": "cdk", 16 | "lint": "eslint --ext .ts .", 17 | "lint-fix": "eslint --fix --ext .ts ." 18 | }, 19 | "devDependencies": { 20 | "@types/gm": "^1.18.12", 21 | "@types/node": "16.11.58", 22 | "@types/prettier": "2.6.0", 23 | "aws-cdk": "2.41.0", 24 | "ts-node": "^10.9.1", 25 | "typescript": "~3.9.7" 26 | }, 27 | "dependencies": { 28 | "@aws-cdk/aws-glue-alpha": "2.41.0-alpha.0", 29 | "@aws-cdk/aws-kinesisfirehose-alpha": "2.41.0-alpha.0", 30 | "@aws-cdk/aws-kinesisfirehose-destinations-alpha": "2.41.0-alpha.0", 31 | "@types/aws-lambda": "^8.10.102", 32 | "aws-cdk-lib": "2.41.0", 33 | "aws-sdk": "^2.1209.0", 34 | "axios": "^0.27.2", 35 | "constructs": "^10.0.0", 36 | "eslint": "^8.23.1", 37 | "eslint-config-airbnb-typescript": "^17.0.0", 38 | "eslint-plugin-import": "^2.26.0", 39 | "gm": "^1.23.1", 40 | "source-map-support": "^0.5.21", 41 | "twitter-api-client": "^1.6.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020" 7 | ], 8 | "declaration": true, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "noUnusedLocals": false, 15 | "noUnusedParameters": false, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": false, 18 | "inlineSourceMap": true, 19 | "inlineSources": true, 20 | "experimentalDecorators": true, 21 | "strictPropertyInitialization": false, 22 | "esModuleInterop": true, 23 | "typeRoots": [ 24 | "./node_modules/@types" 25 | ] 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "cdk.out" 30 | ] 31 | } 32 | --------------------------------------------------------------------------------