diff --git a/python/README.md b/python/README.md index 4b70780d47..9ee386f415 100644 --- a/python/README.md +++ b/python/README.md @@ -49,28 +49,29 @@ $ cdk destroy ## Table of Contents -| Example | Description | -|---------|-------------| -| [api-cors-lambda](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/api-cors-lambda/) | Shows creation of Rest API (GW) with an /example GET endpoint, with CORS enabled | -| [application-load-balancer](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/application-load-balancer/) | Using an AutoScalingGroup with an Application Load Balancer | -| [appsync-graphql-dynamodb](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/appsync-graphql-dynamodb/) | Creating a single GraphQL API with an API Key, and four Resolvers doing CRUD operations over a single DynamoDB | -| [classic-load-balancer](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/classic-load-balancer/) | Using an AutoScalingGroup with a Classic Load Balancer | -| [custom-resource](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/custom-resource/) | Shows adding a Custom Resource to your CDK app | -| [dockerized-app](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/docker-app-with-asg-alb/) | Deploys a containerized app into 3 tiers with userdata in an autoscaling group | -| [ecs-cluster](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ecs/cluster/) | Provision an ECS Cluster with custom Autoscaling Group configuration | -| [ecs-load-balanced-service](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ecs/ecs-load-balanced-service/) | Starting a container fronted by a load balancer on ECS | -| [ecs-service-with-task-placement](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ecs/ecs-service-with-task-placement/) | Starting a container ECS with task placement specifications | -| [ecs-service-with-advanced-alb-config](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ecs/ecs-service-with-advanced-alb-config/) | Starting a container fronted by a load balancer on ECS with added load balancer configuration | -| [ecs-service-with-task-networking](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ecs/ecs-service-with-task-networking/) | Starting an ECS service with task networking, allowing ingress traffic to the task but blocking for the instance | -| [fargate-load-balanced-service](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ecs/fargate-load-balanced-service/) | Starting a container fronted by a load balancer on Fargate | -| [fargate-service-with-autoscaling](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ecs/fargate-service-with-autoscaling/) | Starting an ECS service of FARGATE launch type that auto scales based on average CPU Utilization | -| [lambda-cron](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/lambda-cron/) | Running a Lambda on a schedule | -| [lambda-layer](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/lambda-layer/) | Running a Lambda with a lambda layer | -| [lambda-s3-trigger](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/lambda-s3-trigger/) | S3 trigger for Lambda | -| [rds](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/rds/) | Creating a MySQL RDS database inside its dedicated VPC | -| [s3-object-lambda](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/s3-object-lambda/) | Creating an S3 Object Lambda and access point | -| [stepfunctions](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/stepfunctions/) | A simple StepFunctions workflow | -| [url-shortener](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/url-shortener) | Demo from the [Infrastructure ***is*** Code with the AWS CDK](https://youtu.be/ZWCvNFUN-sU) AWS Online Tech Talk | -| [ec2-instance](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ec2/instance/) | Create EC2 Instance in new VPC with Systems Manager enabled | -| [serverless-backend](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/serverless-backend/) | Create a serverless backend with API Gateway, Lambda, S3, DynamoDB, and Cognito | -| [vpc-ec2-local-zones](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/vpc-ec2-local-zones/) | Create a VPC with public and private subnets in AWS Local Zones | \ No newline at end of file +| Example | Description | +|------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| [api-cors-lambda](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/api-cors-lambda/) | Shows creation of Rest API (GW) with an /example GET endpoint, with CORS enabled | +| [application-load-balancer](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/application-load-balancer/) | Using an AutoScalingGroup with an Application Load Balancer | +| [appsync-graphql-dynamodb](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/appsync-graphql-dynamodb/) | Creating a single GraphQL API with an API Key, and four Resolvers doing CRUD operations over a single DynamoDB | +| [classic-load-balancer](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/classic-load-balancer/) | Using an AutoScalingGroup with a Classic Load Balancer | +| [custom-resource](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/custom-resource/) | Shows adding a Custom Resource to your CDK app | +| [dockerized-app](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/docker-app-with-asg-alb/) | Deploys a containerized app into 3 tiers with userdata in an autoscaling group | +| [ecs-cluster](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ecs/cluster/) | Provision an ECS Cluster with custom Autoscaling Group configuration | +| [ecs-load-balanced-service](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ecs/ecs-load-balanced-service/) | Starting a container fronted by a load balancer on ECS | +| [ecs-service-with-task-placement](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ecs/ecs-service-with-task-placement/) | Starting a container ECS with task placement specifications | +| [ecs-service-with-advanced-alb-config](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ecs/ecs-service-with-advanced-alb-config/) | Starting a container fronted by a load balancer on ECS with added load balancer configuration | +| [ecs-service-with-task-networking](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ecs/ecs-service-with-task-networking/) | Starting an ECS service with task networking, allowing ingress traffic to the task but blocking for the instance | +| [end-user-messaging-rest-frontend](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ecs/end-user-messaging-rest-frontend/) | REST Frontend to AWS End User Messaging with automatic WhatsApp message retries | +| [fargate-load-balanced-service](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ecs/fargate-load-balanced-service/) | Starting a container fronted by a load balancer on Fargate | +| [fargate-service-with-autoscaling](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ecs/fargate-service-with-autoscaling/) | Starting an ECS service of FARGATE launch type that auto scales based on average CPU Utilization | +| [lambda-cron](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/lambda-cron/) | Running a Lambda on a schedule | +| [lambda-layer](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/lambda-layer/) | Running a Lambda with a lambda layer | +| [lambda-s3-trigger](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/lambda-s3-trigger/) | S3 trigger for Lambda | +| [rds](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/rds/) | Creating a MySQL RDS database inside its dedicated VPC | +| [s3-object-lambda](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/s3-object-lambda/) | Creating an S3 Object Lambda and access point | +| [stepfunctions](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/stepfunctions/) | A simple StepFunctions workflow | +| [url-shortener](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/url-shortener) | Demo from the [Infrastructure ***is*** Code with the AWS CDK](https://youtu.be/ZWCvNFUN-sU) AWS Online Tech Talk | +| [ec2-instance](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/ec2/instance/) | Create EC2 Instance in new VPC with Systems Manager enabled | +| [serverless-backend](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/serverless-backend/) | Create a serverless backend with API Gateway, Lambda, S3, DynamoDB, and Cognito | +| [vpc-ec2-local-zones](https://github.com/aws-samples/aws-cdk-examples/tree/master/python/vpc-ec2-local-zones/) | Create a VPC with public and private subnets in AWS Local Zones | diff --git a/python/end-user-messaging-rest-frontend/.gitignore b/python/end-user-messaging-rest-frontend/.gitignore new file mode 100644 index 0000000000..8800910958 --- /dev/null +++ b/python/end-user-messaging-rest-frontend/.gitignore @@ -0,0 +1,155 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +.DS_Store + +# CDK +*.swp +package-lock.json +.pytest_cache +*.egg-info + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/python/end-user-messaging-rest-frontend/README.md b/python/end-user-messaging-rest-frontend/README.md new file mode 100644 index 0000000000..b21a2e7688 --- /dev/null +++ b/python/end-user-messaging-rest-frontend/README.md @@ -0,0 +1,182 @@ +# Introduction + +This repository implements an Infrastructure as Code (IaC), serverless stack that exposes a REST API for sending +SMS & WhatsApp messages to your customers while handling conversation windows with WhatsApp destinations +(more on this below). + +It uses AWS End User Messaging as its communications platform and logs message history and handles +WhatsApp user consent automatically in Amazon DynamoDB. + +# Requirements + +The code has been tested with Python 3.12 in macOS. You will also need: +* [AWS CDK](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) +* Python 3.12 +* Docker or Podman for compiling the Lambda images +* The requirements defined in [`requirements.txt`](requirements.txt) +* SMS-related requirements in AWS End User Messaging SMS: + - [Configuration set](https://docs.aws.amazon.com/sms-voice/latest/userguide/configuration-sets.html) + - [Phone number or sender ID](https://docs.aws.amazon.com/sms-voice/latest/userguide/phone-number-types.html). This is + referred to as the "originating entity" later in this document. +* WhatsApp-related requirements: + - A [WhatsApp Business Account](https://docs.aws.amazon.com/social-messaging/latest/userguide/managing-waba.html) + [configured in AWS End User Messaging Social](https://docs.aws.amazon.com/social-messaging/latest/userguide/managing-phone-numbers-add.html). + + If you only have configured a single WhatsApp phone number, the solution will use that for sending messages. + For other use cases and for efficiency purposes, you can specify the phone number to use when deploying the + solution. + + The Business Account in End User Messaging Social must be configured with a + [message and event destination](https://docs.aws.amazon.com/social-messaging/latest/userguide/managing-event-destinations.html) + pointing to an SNS topic that this solution will use for tracking SMS message delivery. + - A default WhatsApp template in English requesting that your users to connect with you. When you try to send a + WhatsApp message to a number that has not communicated with you in the last 24h, the solution will send this + template to the user and keep the message in a queue for 2h. If the customer replies to your template in this 2h + window your original message will be automatically sent to the user automatically. + +# Architecture + +The diagram below ilustrates the main components of the architecture of the solution and their dependencies. +There are two main flows of information: + +* The flow which sends the SMS/WhatsApp messages to the end users, automatically tracking user consent in a + specific DynamoDB table. +* The flow which receives the message notifications from either SNS or EventBridge and tracks their status in a + separate DynamoDB table. This flow also handles the case where the end user writes a WhatsApp message to the + phone associated with the WhatsApp Business Account and registers their consent receive free-text messages. + +![Application architecture](docs/architecture.png) + +# Sending messages + +## SMS + +Sending SMS messages is handled with AWS End User Messaging SMS and in order to be able to send SMS you will need to +register a Configuration set and a phone number or sender ID, as described above. + +The tracking of SMS delivery is performed by monitoring +[EventBridge events](https://docs.aws.amazon.com/sms-voice/latest/userguide/monitor-event-bridge.html) and follows +the principles described [below](#observability). + +## WhatsApp + +In WhatsApp you generally cannot send free-form messages to users unless they have contacted you in the previous 24 +hours. In order to contact new users, you must either have them send you a message or send them +[a Meta-approved template](https://developers.facebook.com/docs/whatsapp/message-templates/guidelines/) asking the +destination user to write back to you. When they do, you are allowed to send free-text messages to your users for the +next 24 hours. + +The communication flow for talking to your clients in WhatsApp is as follows: + +1. You send a pre-approved message template to your customer asking them to write back to you. This message should + explicitly ask the user to not answer anything if they do not want to be contacted. +2. If the user answers with any text you can start sending free-form messages for the next 24 hours. +3. After 24 hours the communication window closes and you have to send a new template to the customer in order to be + able to send free-form messages. + +This solution tracks user communications with your number by automatically sending a template as needed and only trying +to send free-form messages if the user has responded to the template. If the user does not respond 2 hours after the +template is sent to them, the initiating message is discarded. + +The following flow is executed when you send a request to the REST API endpoint to send a free-form message: + +```mermaid +flowchart TD + Client([Original free-form message]) -->|POST /v1/sendWhatsApp| API[API Gateway] + API -->|Validates & authorizes| Q["`WhatsApp Queue + (2h TTL)`"] + Q --> E{{User wrote to us in the last 24h?}} + E -->|Yes|L([Send free-form message]) + E -->|No|T{{Template sent?}} + T -->|Yes|W[Return message to queue] + T -->|No|ST[Send template] + ST -->W + W -->Q + + style E fill:#009,color:#ddd + style T fill:#009,color:#ddd +``` + +This handling is transparent to you, and you are only responsible for sending the initial request to send a free-form +message. + +Once you make the initial request to the API to send the message, you can track its status as described +[below](#observability). + +# Observability + +Metadata about the messages and whether they were delivered or not (but not the messages themselves) is stored in +a DynamoDB table for observability purposes. This data does not, however, contain destination phone numbers and has a +TTL of 1 year. These messages are identified by their AWS End User Messaging message ID and (sometimes) by their +WahtsApp message ID, but contain no other perrsonally identifiable information. + +An entry in the message tracking table will typically contain the following fields: + +* `type`: Message type (either `sms` or `whatsapp`) +* `eum_msg_id`: AWS End User Messaging message ID. This is a random unique id. +* `wa_msg_id`: Meta-provided WhatsApp Message ID. Only available for WhatsApp messages and only once + Meta server have processed the message send request. Contains `__UNKOWN__` for SMS messages or WhatsApp messages + that have not yet been processed by Meta. +* `delivery_history`: Map with the history of the ISO-formatted instants when the message transitioned states. +* `expiration_date`: The UTC timestamp when the memssage will expire. +* `latest_status`: The most recent delivery status for the message. +* `latest_update`: The UTC timestamp when the message delivery information was last updated. +* `registration_date`: The ISO-formatted instant when the message was registered. + +The status a message transverses through its lifecyle are: + +* `unknown`: Message status is unknown. Unused. +* `failed`: Message delivery has failed. Unused. +* `sent_for_delivery`: Message has been processed by this stack and sent to AWS End User Messaging for delivery. +* `sent`: Message has been sent to the user. Does not gguarantee that the user has received it. +* `delivered`: Message has been delivered to the user's terminal. Does not guarantee that the user has read it. Also, + SMS carriers might not provide us with this information so correctly delivered SMS messages might not be marked as + `delivered` in the table. +* `read`: [WhatsApp specific] The message has been shown to the user in the WhatsApp application. + +# Deployment sample + +```bash +# Run this only if using Podman instead of Docker +export CDK_DOCKER=podman +# Deploy the solution +cdk deploy \ + --parameters ConfigurationSetArn='${CONFIGURATION_SET_ARN}' \ + --parameters OriginatingEntity='${ORIGINATING_PHONE_ARN}' \ + --parameters WhatsAppNotificationTopicARN='${SNS_TOPIC_ARN}' \ + --parameters MessageType='TRANSACTIONAL' \ + --parameters WATemplate='${WHATSAPP_TEMPLATE_NAME}' \ + --parameters WAPhoneNumberARN='${WHATSAPP_PHONE_NUMBER_ARN}' +``` + +You will get several outputs if everything is correct, they're referenced in the step below as the following fields: +* `RestAPIAPIKey`: the ID of the Rest API key +* `RestAPISMSApiGateway`: the URL of the SMS send endpoint in API Gateway +* `RestAPIWhatsAppApiGateway`: the URL of the WhatsApp send endpoint in API Gateway + +# Message sending sample + +```bash +# Send a SMS message +curl -X POST -H "x-api-key: $(aws apigateway get-api-key --api-key ${RestAPIAPIKey} --include-value | jq -r .value)" -H "Content-Type: application/json" -d '{"destination_number": "${DESTINATION_NUMBER}", "message_body": "${MESSAGE_BODY}"}' ${RestAPISMSApiGateway} +# Send a WhatsApp message +curl -X POST -H "x-api-key: $(aws apigateway get-api-key --api-key ${RestAPIAPIKey} --include-value | jq -r .value)" -H "Content-Type: application/json" -d '{"destination_number": "${DESTINATION_NUMBER}", "message_body": "${MESSAGE_BODY}"}' ${RestAPIWhatsAppApiGateway} +``` + +# Future work + +More work is required to turn this code into a production sample. Some ideas for future improvement: + +* WhatsApp delivery error handling in particular should be improved. While the solution should handle 24h + WhatsApp communication windows automatically and re-sends the default template if needed, it does not + handle the case where delivery to WhatsApp phone numbers fails for whatever reason. + The logic for handling these failures can be found in the [`wa_status_handler`](lambda/wa_status_handler/main.py) + lambda code. +* Also, the WhatsApp sending logic only sends English templates. WhatsApp templates can be configured per-language, so + you will most likely want to make the template sending logic configurable per-language. + The handling code is located in the [`send_whatsapp`](lambda/send_whatsapp/main.py) lambda. +* In the WhatsApp flow, if the user answers to the template message more than 2h after the template has been sent (and + therefore the initiating free-form message has already been automatically discarded) no extra communication is sent, + which can be confusing for users. Extra work should be done to improve the UX for these cases (maybe by sending a + specific message explaining that the original message has expired?). +* The solution only supports sending basic message types. WhatsApp supports a + [wide variety of rich messages](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages). The + solution could be extended to support these different message types. diff --git a/python/end-user-messaging-rest-frontend/app.py b/python/end-user-messaging-rest-frontend/app.py new file mode 100644 index 0000000000..5ea39fc568 --- /dev/null +++ b/python/end-user-messaging-rest-frontend/app.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 + +import cdk_nag +import aws_cdk as cdk +from cdk.message_api import MessageAPI + + +app = cdk.App() +MessageAPI(app, 'MessagingRESTAPI') +cdk.Aspects.of(app).add(cdk_nag.AwsSolutionsChecks(verbose=True)) +app.synth() diff --git a/python/end-user-messaging-rest-frontend/cdk.json b/python/end-user-messaging-rest-frontend/cdk.json new file mode 100644 index 0000000000..fc14e6687a --- /dev/null +++ b/python/end-user-messaging-rest-frontend/cdk.json @@ -0,0 +1,78 @@ +{ + "app": "python app.py", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "requirements*.txt", + "source.bat", + "**/__init__.py", + "**/__pycache__", + "tests" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, + "@aws-cdk/aws-eks:nodegroupNameAttribute": true, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, + "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false, + "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, + "@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true, + "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true, + "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true, + "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true, + "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true, + "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true, + "@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true + } +} diff --git a/python/end-user-messaging-rest-frontend/cdk/__init__.py b/python/end-user-messaging-rest-frontend/cdk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/end-user-messaging-rest-frontend/cdk/message_api.py b/python/end-user-messaging-rest-frontend/cdk/message_api.py new file mode 100644 index 0000000000..7e929cf6b2 --- /dev/null +++ b/python/end-user-messaging-rest-frontend/cdk/message_api.py @@ -0,0 +1,86 @@ +from aws_cdk import (aws_iam as iam, + aws_sqs as sqs, + CfnParameter, + Duration, + RemovalPolicy, + Stack) +from constructs import Construct +from cdk.rest_api import RestAPI +from cdk.message_router import MessageRouter +from cdk.message_tracker import MessageTracker + + +class MessageAPI(Stack): + def __init__(self, scope: Construct, construct_id: str) -> None: + super().__init__(scope, construct_id) + + whatsapp_notification_topic_arn = CfnParameter(scope=self, + id='WhatsAppNotificationTopicARN', + type='String', + description='The name of the topic used as an event destination ' + 'in AWS End User Compute Social', + no_echo=True) + configuration_set_arn = CfnParameter(scope=self, + id='ConfigurationSetArn', + type='String', + description='ARN of the SES Configuration Set') + message_type = CfnParameter(scope=self, + id='MessageType', + type='String', + description='Message category to send', + allowed_values=['PROMOTIONAL', 'TRANSACTIONAL'], + default='TRANSACTIONAL') + origination_entity = CfnParameter(scope=self, + id='OriginatingEntity', + type='String', + description='Sender ID. Can be the phone number ID/ARN, ' + 'Sender ID/ARN or Pool ID/ARN') + default_whatsapp_phone_arn = CfnParameter(scope=self, + id='WAPhoneNumberARN', + type='String', + description='The ARN for the phone number configured in AWS End User ' + 'Messaging Social that will be used for sending WhatsApp ' + 'Messages. Leave empty to try to automatically detect ' + 'the phone number.', + default='') + default_whatsapp_template = CfnParameter(scope=self, + id='WATemplate', + type='String', + description='The name of the WhatsApp template to send if the ' + 'WhatsApp message recipient has not written to our ' + 'number in the last 24h') + + # Dead Letter Queue to be used by all other queues + dlq = sqs.Queue(scope=self, + id='DLQ', + removal_policy=RemovalPolicy.DESTROY, + retention_period=Duration.days(2)) + dlq.add_to_resource_policy(iam.PolicyStatement(effect=iam.Effect.DENY, + principals=[iam.AnyPrincipal()], + actions=['sqs:*'], + resources=[dlq.queue_arn], + conditions={'Bool': { + 'aws:SecureTransport': 'false'}})) + + # Message tracking queue, in charge of tracking message delivery status & user consent + message_tracker = MessageTracker(scope=self, + construct_id='MessageTracker', + notifications_topic_arn=whatsapp_notification_topic_arn.value_as_string, + dlq=dlq) + # Create the infrastructure used for sending the messages + message_router = MessageRouter(scope=self, + construct_id='MessageRouter', + wa_notification_handler_lambda=message_tracker.wa_status_handler_lambda, + consent_table=message_tracker.consent_table, + message_tracking_table=message_tracker.message_tracking_table, + configuration_set_arn=configuration_set_arn, + message_type=message_type, + origination_entity=origination_entity, + default_whatsapp_template=default_whatsapp_template, + default_whatsapp_phone_arn=default_whatsapp_phone_arn, + dlq=dlq) + # Rest API + rest_api = RestAPI(scope=self, + construct_id='RestAPI', + sms_queue=message_router.sms_queue, + whatsapp_queue=message_router.wa_queue) diff --git a/python/end-user-messaging-rest-frontend/cdk/message_router.py b/python/end-user-messaging-rest-frontend/cdk/message_router.py new file mode 100644 index 0000000000..46f49abbf5 --- /dev/null +++ b/python/end-user-messaging-rest-frontend/cdk/message_router.py @@ -0,0 +1,177 @@ +import boto3 +import platform +import cdk_nag +from aws_cdk import ( + aws_dynamodb as ddb, + aws_ecr_assets, + aws_iam as iam, + aws_lambda as lambda_, + aws_lambda_event_sources as event_sources, + aws_logs as logs, + aws_sqs as sqs, + CfnOutput, + CfnParameter, + Duration, + RemovalPolicy, + Stack +) +from constructs import Construct + + +class MessageRouter(Construct): + def __init__(self, + scope: Stack, + construct_id: str, + wa_notification_handler_lambda: lambda_.Function, + consent_table: ddb.TableV2, + message_tracking_table: ddb.TableV2, + configuration_set_arn: CfnParameter, + message_type: CfnParameter, + origination_entity: CfnParameter, + default_whatsapp_template: CfnParameter, + default_whatsapp_phone_arn: CfnParameter, + dlq: sqs.Queue) -> None: + super().__init__(scope, construct_id) + + # Create the queues where the REST API will put the requests for sending the messages + self.sms_queue = sqs.Queue(scope=self, + id='SMSMessagingQueue', + removal_policy=RemovalPolicy.DESTROY, + retention_period=Duration.hours(2), + dead_letter_queue=sqs.DeadLetterQueue(max_receive_count=2 * 3600 // 30, + queue=dlq)) + self.sms_queue.add_to_resource_policy(iam.PolicyStatement(effect=iam.Effect.DENY, + principals=[iam.AnyPrincipal()], + actions=['sqs:*'], + resources=[self.sms_queue.queue_arn], + conditions={'Bool': { + 'aws:SecureTransport': 'false'}})) + + self.wa_queue = sqs.Queue(scope=self, + id='WhatsAppMessagingQueue', + removal_policy=RemovalPolicy.DESTROY, + retention_period=Duration.hours(2), + dead_letter_queue=sqs.DeadLetterQueue(max_receive_count=2 * 3600 // 30, + queue=dlq)) + self.wa_queue.add_to_resource_policy(iam.PolicyStatement(effect=iam.Effect.DENY, + principals=[iam.AnyPrincipal()], + actions=['sqs:*'], + resources=[self.wa_queue.queue_arn], + conditions={'Bool': { + 'aws:SecureTransport': 'false'}})) + + # DynamoDB table to keep track of open communication windows + consent_table.grant_read_write_data(wa_notification_handler_lambda) + + # Create a lambda for sending the SMS & WhatsApp messages + # Determine the lambda platform architecture + if platform.machine() == 'arm64': + lambda_architecture = lambda_.Architecture.ARM_64 + lambda_platform = aws_ecr_assets.Platform.LINUX_ARM64 + else: + lambda_architecture = lambda_.Architecture.X86_64 + lambda_platform = aws_ecr_assets.Platform.LINUX_AMD64 + + base_lambda_policy = iam.ManagedPolicy.from_aws_managed_policy_name( + managed_policy_name='service-role/AWSLambdaBasicExecutionRole') + sms_sender_lambda_role = iam.Role(scope=self, + id='SQS2SMSLambdaRole', + assumed_by=iam.ServicePrincipal('lambda.amazonaws.com'), + managed_policies=[base_lambda_policy]) + sms_sender_lambda_role.add_to_policy(iam.PolicyStatement(sid='SMSMessagingStatement', + effect=iam.Effect.ALLOW, + resources=[origination_entity.value_as_string], + actions=['sms-voice:SendTextMessage'])) + self.sms_sender_lambda = lambda_.Function(scope=self, + id='SMSSender', + code=lambda_.Code.from_asset('lambda/send_sms'), + runtime=lambda_.Runtime.PYTHON_3_13, + handler='main.handler', + environment={'CONFIGURATION_SET': + configuration_set_arn.value_as_string, + 'MESSAGE_TYPE': message_type.value_as_string, + 'ORIGINATION_ENTITY': origination_entity.value_as_string, + 'MESSAGE_TRACKING_TABLE_NAME': + message_tracking_table.table_name}, + timeout=Duration.seconds(5), + memory_size=256, + role=sms_sender_lambda_role, + architecture=lambda_architecture, + log_group=logs.LogGroup(scope=self, + id='SMSSenderLogGroup', + retention=logs.RetentionDays.THREE_DAYS, + removal_policy=RemovalPolicy.DESTROY)) + self.sms_sender_lambda.add_event_source(event_sources.SqsEventSource(self.sms_queue)) + message_tracking_table.grant_read_write_data(self.sms_sender_lambda) + + # Lambda that sends the SQS messagges to WhatsApp + default_whatsapp_phone_arn = default_whatsapp_phone_arn.value_as_string + wa_sender_lambda_role = iam.Role(scope=self, + id='SQS2WhatsAppLambdaRole', + assumed_by=iam.ServicePrincipal('lambda.amazonaws.com'), + managed_policies=[base_lambda_policy]) + wa_sender_lambda_role.add_to_policy(iam.PolicyStatement(sid='SocialMessagingStatement', + effect=iam.Effect.ALLOW, + resources=[default_whatsapp_phone_arn + if len(default_whatsapp_phone_arn) > 0 + else '*'], + actions=['social-messaging:SendWhatsAppMessage'])) + image = lambda_.DockerImageCode.from_image_asset('lambda/send_whatsapp', + platform=lambda_platform) + self.wa_sender_lambda = lambda_.DockerImageFunction(scope=self, + id='WASender', + code=image, + environment={'CONSENT_TABLE_NAME': + consent_table.table_name, + 'MESSAGE_TRACKING_TABLE_NAME': + message_tracking_table.table_name, + 'WA_TEMPLATE_NAME': + default_whatsapp_template.value_as_string, + 'WHATSAPP_PHONE_ID': + default_whatsapp_phone_arn}, + timeout=Duration.seconds(5), + memory_size=256, + architecture=lambda_architecture, + role=wa_sender_lambda_role, + log_group=logs.LogGroup(scope=self, + id='WASenderLogGroup', + retention=logs.RetentionDays.THREE_DAYS, + removal_policy=RemovalPolicy.DESTROY)) + self.wa_sender_lambda.add_environment(key='WHATSAPP_PHONE_ID', value=default_whatsapp_phone_arn) + self.wa_sender_lambda.add_event_source(event_sources.SqsEventSource(self.wa_queue)) + message_tracking_table.grant_read_write_data(self.wa_sender_lambda) + consent_table.grant_read_write_data(self.wa_sender_lambda) + + # Stack outputs + CfnOutput(self, "WABAPhoneARN", + description="WhatsApp Business Applications configured phone number ARN", + value=default_whatsapp_phone_arn) + + # Add cdk-nag suppresions for lambdas using the base lambda policy + for path in ('/MessagingRESTAPI/MessageRouter/SQS2SMSLambdaRole/Resource', + '/MessagingRESTAPI/MessageRouter/SQS2WhatsAppLambdaRole/Resource'): + cdk_nag.NagSuppressions.add_resource_suppressions_by_path(stack=scope, + path=path, + suppressions=[ + { + "id": "AwsSolutions-IAM4", + "reason": 'Using the AWS Lambda base ' + 'policy as starting point for the ' + 'Lambda roles', + } + ]) + for path in ('/MessagingRESTAPI/MessageRouter/SQS2SMSLambdaRole/DefaultPolicy/Resource', + '/MessagingRESTAPI/MessageRouter/SQS2WhatsAppLambdaRole/DefaultPolicy/Resource'): + cdk_nag.NagSuppressions.add_resource_suppressions_by_path(stack=scope, + path=path, + suppressions=[ + { + "id": "AwsSolutions-IAM5", + "reason": 'Using the AWS Lambda base ' + 'policy as starting point for the ' + 'Lambda roles, as well as policies ' + 'created automatically by CDK when ' + 'granting read-write permissions ' + 'to the DynamoDB table', + } + ]) diff --git a/python/end-user-messaging-rest-frontend/cdk/message_tracker.py b/python/end-user-messaging-rest-frontend/cdk/message_tracker.py new file mode 100644 index 0000000000..489a7197ca --- /dev/null +++ b/python/end-user-messaging-rest-frontend/cdk/message_tracker.py @@ -0,0 +1,150 @@ +import platform +import cdk_nag +from aws_cdk import (aws_dynamodb as ddb, + aws_events as events, + aws_events_targets as event_targets, + aws_iam as iam, + aws_lambda as lambda_, + aws_lambda_event_sources as event_sources, + aws_sns as sns, + aws_sns_subscriptions as sns_subscriptions, + aws_logs as logs, + aws_sqs as sqs, + Duration, + RemovalPolicy, + Stack) +from constructs import Construct + + +class MessageTracker(Construct): + def __init__(self, scope: Stack, construct_id: str, notifications_topic_arn: str, dlq: sqs.Queue) -> None: + super().__init__(scope, construct_id) + + # Determine the lambda platform architecture + if platform.machine() == 'arm64': + lambda_architecture = lambda_.Architecture.ARM_64 + else: + lambda_architecture = lambda_.Architecture.X86_64 + + # WhatsApp messaging components, also create a suscription for SNS -> SQS routing + notifications_topic = sns.Topic.from_topic_arn(scope=self, + id='WhatsAppNotificationsTopic', + topic_arn=notifications_topic_arn) + wa_notifications_queue = sqs.Queue(scope=self, + id='WhatsAppNotificationQueue', + retention_period=Duration.hours(2), + dead_letter_queue=sqs.DeadLetterQueue(max_receive_count=10, + queue=dlq)) + wa_notifications_queue.add_to_resource_policy(iam.PolicyStatement(effect=iam.Effect.DENY, + principals=[iam.AnyPrincipal()], + actions=['sqs:*'], + resources=[wa_notifications_queue.queue_arn], + conditions={'Bool': { + 'aws:SecureTransport': 'false'}})) + notifications_topic.add_subscription(sns_subscriptions.SqsSubscription(queue=wa_notifications_queue, + raw_message_delivery=True)) + + # SMS messaging components, send EventBridge events to SQS + event_rule = events.Rule(scope=self, + id='SMSNotificationsRule', + enabled=True, + event_pattern={'source': ['aws.sms-voice'], + 'detail_type': ['Text Message Delivery Status Updated']}) + sms_notifications_queue = sqs.Queue(scope=self, + id='SMSNotificationQueue', + retention_period=Duration.hours(2), + dead_letter_queue=sqs.DeadLetterQueue(max_receive_count=10, + queue=dlq)) + sms_notifications_queue.add_to_resource_policy(iam.PolicyStatement(effect=iam.Effect.DENY, + principals=[iam.AnyPrincipal()], + actions=['sqs:*'], + resources=[ + sms_notifications_queue.queue_arn], + conditions={'Bool': { + 'aws:SecureTransport': 'false'}})) + event_rule.add_target(event_targets.SqsQueue(sms_notifications_queue)) + + # DynamoDB table to keep track of user consent to message them + self.consent_table = ddb.TableV2(scope=self, + id='WAConsentTable', + removal_policy=RemovalPolicy.DESTROY, + partition_key=ddb.Attribute(name='phone_id', + type=ddb.AttributeType.STRING), + time_to_live_attribute='expiration_date') + # DynamoDB table to keep track of WhatsApp mesage status + self.message_tracking_table = ddb.TableV2(scope=self, + id='MessageTrackingTable', + removal_policy=RemovalPolicy.DESTROY, + partition_key=ddb.Attribute(name='eum_msg_id', + type=ddb.AttributeType.STRING), + time_to_live_attribute='expiration_date') + self.message_tracking_table.add_global_secondary_index(index_name='WhatsAppMessageId', + partition_key=ddb.Attribute(name='wa_msg_id', + type=ddb.AttributeType.STRING), + projection_type=ddb.ProjectionType.KEYS_ONLY) + + # WhatsApp status change handling Lambda + self.wa_status_handler_lambda = lambda_.Function(scope=self, + id='WAMessageHandler', + code=lambda_.Code.from_asset('lambda/wa_status_handler', + exclude=['samples/']), + runtime=lambda_.Runtime.PYTHON_3_13, + handler='main.handler', + environment={'CONSENT_TABLE_NAME': + self.consent_table.table_name, + 'TRACKING_TABLE_NAME': + self.message_tracking_table.table_name}, + timeout=Duration.seconds(10), + memory_size=256, + architecture=lambda_architecture, + log_group=logs.LogGroup(scope=self, + id='WAMessageHandlerLogGroup', + retention=logs.RetentionDays.THREE_DAYS, + removal_policy=RemovalPolicy.DESTROY)) + self.wa_status_handler_lambda.add_event_source(event_sources.SqsEventSource(wa_notifications_queue)) + self.consent_table.grant_read_write_data(self.wa_status_handler_lambda) + self.message_tracking_table.grant_read_write_data(self.wa_status_handler_lambda) + + # SMS status change handling Lambda + self.sms_status_handler_lambda = lambda_.Function(scope=self, + id='SMSMessageHandler', + code=lambda_.Code.from_asset('lambda/sms_status_handler'), + runtime=lambda_.Runtime.PYTHON_3_13, + handler='main.handler', + environment={'TRACKING_TABLE_NAME': + self.message_tracking_table.table_name}, + timeout=Duration.seconds(10), + memory_size=256, + architecture=lambda_architecture, + log_group=logs.LogGroup(scope=self, + id='SMSMessageHandlerLogGroup', + retention=logs.RetentionDays.THREE_DAYS, + removal_policy=RemovalPolicy.DESTROY)) + self.sms_status_handler_lambda.add_event_source(event_sources.SqsEventSource(sms_notifications_queue)) + self.message_tracking_table.grant_read_write_data(self.sms_status_handler_lambda) + + # Add cdk-nag suppresions for lambdas using the base lambda policy + for path in ('/MessagingRESTAPI/MessageTracker/WAMessageHandler/ServiceRole/Resource', + '/MessagingRESTAPI/MessageTracker/SMSMessageHandler/ServiceRole/Resource'): + cdk_nag.NagSuppressions.add_resource_suppressions_by_path(stack=scope, + path=path, + suppressions=[ + { + "id": "AwsSolutions-IAM4", + "reason": 'Using the AWS Lambda base ' + 'policy as starting point for the ' + 'Lambda roles', + } + ]) + for path in ('/MessagingRESTAPI/MessageTracker/WAMessageHandler/ServiceRole/DefaultPolicy/Resource', + '/MessagingRESTAPI/MessageTracker/SMSMessageHandler/ServiceRole/DefaultPolicy/Resource'): + cdk_nag.NagSuppressions.add_resource_suppressions_by_path(stack=scope, + path=path, + suppressions=[ + { + "id": "AwsSolutions-IAM5", + "reason": 'Using the AWS Lambda base ' + 'policy as starting point for the ' + 'Lambda roles', + } + ]) diff --git a/python/end-user-messaging-rest-frontend/cdk/rest_api.py b/python/end-user-messaging-rest-frontend/cdk/rest_api.py new file mode 100644 index 0000000000..5cacd38d88 --- /dev/null +++ b/python/end-user-messaging-rest-frontend/cdk/rest_api.py @@ -0,0 +1,203 @@ +import cdk_nag +from aws_cdk import ( + aws_apigateway as apigateway, + aws_iam as iam, + aws_logs as logs, + aws_sqs as sqs, + CfnOutput, + RemovalPolicy, + Stack +) +from constructs import Construct + + +class RestAPI(Construct): + def __init__(self, + scope: Stack, + construct_id: str, + sms_queue: sqs.Queue, + whatsapp_queue: sqs.Queue) -> None: + super().__init__(scope, construct_id) + + # Create API Gateway + api_logs = logs.LogGroup(scope=self, + id='ApiGatewayLogs', + retention=logs.RetentionDays.ONE_WEEK, + removal_policy=RemovalPolicy.DESTROY) + api = apigateway.RestApi(scope=self, + id='ApiGateway', + rest_api_name="Messaging API", + deploy_options=apigateway.StageOptions( + access_log_destination=apigateway.LogGroupLogDestination(api_logs), + access_log_format=apigateway.AccessLogFormat.clf(), + logging_level=apigateway.MethodLoggingLevel.INFO), + default_cors_preflight_options=apigateway.CorsOptions( + allow_origins=['*'], + allow_methods=['POST', 'OPTIONS'], + allow_headers=['Content-Type', 'X-Amz-Date', 'Authorization', 'X-Api-Key', + 'X-Amz-Security-Token'] + ) + ) + + # Create API Gateway Role for SQS access + api_role = iam.Role(scope=self, + id='APIGatewayWhatsAppRole', + assumed_by=iam.ServicePrincipal("apigateway.amazonaws.com")) + api_role.add_to_policy(iam.PolicyStatement(actions=['sqs:SendMessage'], + resources=[sms_queue.queue_arn, + whatsapp_queue.queue_arn])) + + # Create v1 resource + v1_resource = api.root.add_resource('v1') + sms_resource = v1_resource.add_resource("sendSMS") + whatsapp_resource = v1_resource.add_resource("sendWhatsApp") + + # Create request validator + validator = api.add_request_validator(id='MessageRequestValidator', + validate_request_body=True, + validate_request_parameters=True) + + # Create model for message requests + message_model = api.add_model(id="MessageRequestModel", + content_type="application/json", + model_name="MessageRequest", + schema=apigateway.JsonSchema( + type=apigateway.JsonSchemaType.OBJECT, + required=["message_body", "destination_number"], + properties={ + "message_body": apigateway.JsonSchema( + type=apigateway.JsonSchemaType.STRING), + "destination_number": apigateway.JsonSchema( + type=apigateway.JsonSchemaType.STRING) + } + ) + ) + + # Add SMS POST method + sms_integration = apigateway.AwsIntegration( + service='sqs', + integration_http_method='POST', + path=f"{scope.account}/{sms_queue.queue_name}", + options=apigateway.IntegrationOptions( + credentials_role=api_role, + request_templates={ + "application/json": "Action=SendMessage&MessageBody=$util.urlEncode($input.body)" + }, + request_parameters={ + "integration.request.header.Content-Type": "'application/x-www-form-urlencoded'" + }, + integration_responses=[{ + "statusCode": "200", + "responseTemplates": { + "application/json": "" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'POST,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + }], + passthrough_behavior=apigateway.PassthroughBehavior.NEVER + ) + ) + + sms_resource.add_method( + "POST", + sms_integration, + api_key_required=True, + request_validator=validator, + request_models={"application/json": message_model}, + method_responses=[ + apigateway.MethodResponse( + status_code="200", + response_parameters={ + "method.response.header.Access-Control-Allow-Headers": True, + "method.response.header.Access-Control-Allow-Methods": True, + "method.response.header.Access-Control-Allow-Origin": True + } + ) + ] + ) + # Add WhatsApp POST method with similar configuration + whatsapp_integration = apigateway.AwsIntegration( + service="sqs", + integration_http_method="POST", + path=f"{scope.account}/{whatsapp_queue.queue_name}", + options=apigateway.IntegrationOptions( + credentials_role=api_role, + request_templates={ + "application/json": 'Action=SendMessage&MessageBody=$util.urlEncode($input.body)' + }, + request_parameters={ + "integration.request.header.Content-Type": "'application/x-www-form-urlencoded'" + }, + integration_responses=[{ + "statusCode": "200", + "responseTemplates": { + "application/json": "" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'POST,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + }], + passthrough_behavior=apigateway.PassthroughBehavior.NEVER + ) + ) + + whatsapp_resource.add_method( + "POST", + whatsapp_integration, + api_key_required=True, + request_validator=validator, + request_models={"application/json": message_model}, + method_responses=[ + apigateway.MethodResponse( + status_code="200", + response_parameters={ + "method.response.header.Access-Control-Allow-Headers": True, + "method.response.header.Access-Control-Allow-Methods": True, + "method.response.header.Access-Control-Allow-Origin": True + } + ) + ] + ) + + # Create Usage Plan & API Key + api_key = api.add_api_key(id='APIKey', + api_key_name='Message API Key V1', + description='CloudFormation API Key V1') + plan = api.add_usage_plan(id='APIUsagePlan', + name='SMS_WhatsApp_Plan', + description='Send SMS & Whatsapp Messages usage plan', + api_stages=[apigateway.UsagePlanPerApiStage(api=api, + stage=api.deployment_stage)]) + plan.add_api_key(api_key) + + # Add outputs + CfnOutput(self, "SMSApiGateway", + description="SMS End Point in API Gateway (POST)", + value=f"{api.url}v1/sendSMS") + CfnOutput(self, "WhatsAppApiGateway", + description="WhatsApp End Point in API Gateway (POST)", + value=f"{api.url}v1/sendWhatsApp") + CfnOutput(self, "APIKey", + description="API Key for the API Gateway", + value=api_key.key_id) + + # Finally, add cdk-nag suppresions for the POST endpoints in the API + for suppression in ('AwsSolutions-APIG4', 'AwsSolutions-COG4'): + for path in ('/MessagingRESTAPI/RestAPI/ApiGateway/Default/v1/sendSMS/POST/Resource', + '/MessagingRESTAPI/RestAPI/ApiGateway/Default/v1/sendWhatsApp/POST/Resource'): + cdk_nag.NagSuppressions.add_resource_suppressions_by_path(stack=scope, + path=path, + suppressions=[ + { + "id": suppression, + "reason": 'API authorization is used ' + 'for this backend method ' + 'where we do not leverage ' + 'the concept of users', + } + ]) diff --git a/python/end-user-messaging-rest-frontend/docs/architecture.png b/python/end-user-messaging-rest-frontend/docs/architecture.png new file mode 100644 index 0000000000..f8f7799cbe Binary files /dev/null and b/python/end-user-messaging-rest-frontend/docs/architecture.png differ diff --git a/python/end-user-messaging-rest-frontend/lambda/send_sms/main.py b/python/end-user-messaging-rest-frontend/lambda/send_sms/main.py new file mode 100644 index 0000000000..c71bdbdeb6 --- /dev/null +++ b/python/end-user-messaging-rest-frontend/lambda/send_sms/main.py @@ -0,0 +1,69 @@ +import os +import json +import boto3 +import logging +import botocore.exceptions +from datetime import datetime, timedelta, UTC + +dynamodb = boto3.resource('dynamodb') +message_type = os.environ.get('MESSAGE_TYPE') +sms_client = boto3.client('pinpoint-sms-voice-v2') +configuration_set = os.environ.get('CONFIGURATION_SET') +origination_entity = os.environ.get('ORIGINATION_ENTITY') +msg_tracking_table = dynamodb.Table(os.environ['MESSAGE_TRACKING_TABLE_NAME']) + + +def handler(event, context): + failed_msgs = [] + + for record in event.get('Records', []): + body = json.loads(record['body']) + + destination_number = body.get('destination_number', '').strip() + if len(destination_number) == 0: + logging.error('Could not send message with no destination number, skipping') + continue + + if not destination_number.startswith('+'): + destination_number = f'+{destination_number}' + + message_body = body.get('message_body', '').strip() + if len(message_body) == 0: + logging.error("Could not send message with empty body") + continue + + try: + response = sms_client.send_text_message(ConfigurationSetName=configuration_set, + DestinationPhoneNumber=destination_number, + DryRun=False, + MaxPrice="2.00", + MessageBody=message_body, + MessageType=message_type, + OriginationIdentity=origination_entity, + TimeToLive=120) + except sms_client.exceptions.ClientError as e: + logging.exception(e) + # Register the message processing as failed, so that the message + # is sent back to the queue + failed_msgs.append({'itemIdentifier': record['messageId']}) + continue + + now = datetime.now(tz=UTC) + ttl = int((now + timedelta(days=365)).timestamp()) + try: + msg_tracking_table.put_item(Item={'type': 'sms', + 'eum_msg_id': response['MessageId'], + 'wa_msg_id': '__UNKNOWN__', + 'latest_status': 'sent_for_delivery', + 'latest_update': int(now.timestamp()), + 'delivery_history': {now.isoformat(): 'sent_for_delivery'}, + 'expiration_date': ttl, + 'registration_date': now.isoformat()}) + except botocore.exceptions.ClientError as e: + # The message has been delivered, so we don't mark it as sent to avoid + # sending it again to the recipient + logging.error(f'Failed to register sent message with id {response["MessageId"]}') + logging.exception(e) + continue + + return {'batchItemFailures': failed_msgs} diff --git a/python/end-user-messaging-rest-frontend/lambda/send_whatsapp/.dockerignore b/python/end-user-messaging-rest-frontend/lambda/send_whatsapp/.dockerignore new file mode 100644 index 0000000000..6e19512a0e --- /dev/null +++ b/python/end-user-messaging-rest-frontend/lambda/send_whatsapp/.dockerignore @@ -0,0 +1,2 @@ +.dockerignore +Dockerfile diff --git a/python/end-user-messaging-rest-frontend/lambda/send_whatsapp/Dockerfile b/python/end-user-messaging-rest-frontend/lambda/send_whatsapp/Dockerfile new file mode 100644 index 0000000000..e981f35a91 --- /dev/null +++ b/python/end-user-messaging-rest-frontend/lambda/send_whatsapp/Dockerfile @@ -0,0 +1,15 @@ +FROM public.ecr.aws/lambda/python:3.12@sha256:92c88c1adc374b073b07b12bd4045497af7da68230d47c2b330423115c5850dc + +#checkov:skip=CKV_DOCKER_2:Do not define a healthcheck, since this Lambda will be executed on triggers +#checkov:skip=CKV_DOCKER_3:Lambda functions do not require a USER statement as per https://docs.aws.amazon.com/lambda/latest/dg/images-create.html + +# Install required dependencies +COPY requirements.txt requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the source code from the web image over here, that way we won't require +# installing composer in this image, too +COPY . . + +# Run the main script +CMD [ "main.handler" ] diff --git a/python/end-user-messaging-rest-frontend/lambda/send_whatsapp/main.py b/python/end-user-messaging-rest-frontend/lambda/send_whatsapp/main.py new file mode 100644 index 0000000000..e50408e876 --- /dev/null +++ b/python/end-user-messaging-rest-frontend/lambda/send_whatsapp/main.py @@ -0,0 +1,155 @@ +import os +import json +import boto3 +import logging +import botocore.exceptions +from datetime import datetime, timedelta, UTC + +whatsapp = boto3.client('socialmessaging') +dynamodb = boto3.resource('dynamodb') +consent_table = dynamodb.Table(os.environ['CONSENT_TABLE_NAME']) +msg_tracking_table = dynamodb.Table(os.environ['MESSAGE_TRACKING_TABLE_NAME']) +template_name = os.environ['WA_TEMPLATE_NAME'] + + +def get_waba_phone_number_arn() -> str: + """ + Try to automatically determine the sender phone number ARN + + The method will query the social messaging service. If there is only one account + linked with a single phone number, the code will use that. + """ + # If no phone id was provided, try to find it + whatsapp = boto3.client('socialmessaging') + try: + response = whatsapp.list_linked_whatsapp_business_accounts(maxResults=1) + if len(response['linkedAccounts']) != 1: + raise RuntimeError('Could not automatically determine the WhatsApp phone number and none was defined') + + waba_id = response['linkedAccounts'][0]['id'] + response = whatsapp.get_linked_whatsapp_business_account(id=waba_id) + if response['account']['registrationStatus'] != 'COMPLETE': + raise RuntimeError('Business account is not fully registered') + if len(response['account']['phoneNumbers']) != 1: + raise RuntimeError('Cannot determine automatically what WhatsApp phone number to use') + phone_arn = response['account']['phoneNumbers'][0]['arn'] + except whatsapp.exceptions.ClientError: + phone_arn = '' + + return phone_arn + + +def send_message(sender_id: str, whatsapp_message: dict) -> str | None: + """ + Function used for actually sending the WhatsApp mesasge with Social Messaging + """ + try: + # Convert the message to a JSON string and then to bytes (no Base64 encoding needed) + message_json = json.dumps(whatsapp_message).encode() + + # Send the WhatsApp message + response = whatsapp.send_whatsapp_message(originationPhoneNumberId=sender_id, + message=message_json, + metaApiVersion='v20.0') + return response.get('messageId') + + except botocore.exceptions.ClientError as e: + logging.exception(e) + return None + + +def generate_template(recipient_id: str, template_name: str) -> dict | None: + """ + Generate a dict payload representing a default template WhatsApp Text message + + If there is no need to send the template (because the communication window is still open) + `None` will be returned. + """ + # Send the template & update the window open time + return {"messaging_product": "whatsapp", + "to": f'{recipient_id}', + "type": "template", + "template": {"name": template_name, + "language": {"code": "es_ES"}}} + + +def generate_text(recipient_id: str, message: str) -> dict: + """ + Generate a dict payload representing a regular WhatsApp Text message + """ + whatsapp_message = {"messaging_product": "whatsapp", + "type": "text", + "preview_url": True, + "to": f"{recipient_id}", + "text": {"body": message}} + + return whatsapp_message + + +def with_recipient_consent(recipient_id: str) -> bool: + """ + Determine if we have the user's consent to send free-form messages + + We will only have this if the user has written to us in the last 24h, which triggers + a registration in the corresponding DynamoDB table + """ + response = consent_table.get_item(Key={'phone_id': recipient_id}) + return response.get('Item', {}).get('user_consents', False) + + +def consent_request_sent(recipient_id: str) -> bool: + """ + Determine if we have already sent the consent request + """ + response = consent_table.get_item(Key={'phone_id': recipient_id}) + return response.get('Item', {}).get('consent_requested', False) + + +def handler(event, _): + failed_msgs = [] + whatsapp_phone_id = os.environ['WHATSAPP_PHONE_ID'] + if len(whatsapp_phone_id) == 0: + whatsapp_phone_id = get_waba_phone_number_arn() + # Handle messages one by one (by the construct of the application we should only be getting one, though) + for record in event.get('Records', []): + payload = json.loads(record.get('body')) + recipient_id = payload.get('destination_number') + message = payload.get('message_body') + if recipient_id is None or message is None: + logging.warning(f'Skipping empty message in event.') + continue + + if not recipient_id.startswith('+'): + recipient_id = f'+{recipient_id}' + + # If the user has provided consent to be messaged, just send the message + now = datetime.now(tz=UTC) + if with_recipient_consent(recipient_id): + msg_id = send_message(sender_id=whatsapp_phone_id, + whatsapp_message=generate_text(recipient_id, message)) + status = 'sent_for_delivery' + ttl = int((now + timedelta(days=365)).timestamp()) + msg_tracking_table.put_item(Item={'type': 'whatsapp', + 'eum_msg_id': msg_id, + 'wa_msg_id': '__UNKNOWN__', + 'latest_status': status, + 'latest_update': int(now.timestamp()), + 'delivery_history': {now.isoformat(): 'sent_for_delivery'}, + 'expiration_date': ttl, + 'registration_date': now.isoformat()}) + else: + if not consent_request_sent(recipient_id): + send_message(sender_id=whatsapp_phone_id, + whatsapp_message=generate_template(recipient_id, template_name)) + ttl = int((now + timedelta(days=7)).timestamp()) + consent_table.put_item(Item={'phone_id': recipient_id, + 'consent_requested': True, + 'user_consents': False, + 'request_date': now.isoformat(), + 'expiration_date': ttl}) + else: + # Register the message processing as failed, so that the message + # is sent back to the queue + failed_msgs.append({'itemIdentifier': record['messageId']}) + + return {'batchItemFailures': failed_msgs} diff --git a/python/end-user-messaging-rest-frontend/lambda/send_whatsapp/requirements.txt b/python/end-user-messaging-rest-frontend/lambda/send_whatsapp/requirements.txt new file mode 100644 index 0000000000..6f1972d8d4 --- /dev/null +++ b/python/end-user-messaging-rest-frontend/lambda/send_whatsapp/requirements.txt @@ -0,0 +1,2 @@ +boto3~=1.35.54 +botocore~=1.35.75 diff --git a/python/end-user-messaging-rest-frontend/lambda/sms_status_handler/main.py b/python/end-user-messaging-rest-frontend/lambda/sms_status_handler/main.py new file mode 100644 index 0000000000..f897432a70 --- /dev/null +++ b/python/end-user-messaging-rest-frontend/lambda/sms_status_handler/main.py @@ -0,0 +1,85 @@ +import os +import json +import boto3 +import logging +import botocore.exceptions +from datetime import datetime, UTC +from boto3.dynamodb.conditions import Attr + +dynamodb = boto3.resource('dynamodb') +tracking_table = dynamodb.Table(os.environ['TRACKING_TABLE_NAME']) +msg_status_translation = {'TEXT_SUCCESSFUL': 'sent', + 'TEXT_DELIVERED': 'delivered'} +msg_status = {'unknown': -999, 'failed': -1, 'sent_for_delivery': 0, + 'sent': 1, 'delivered': 2} + + +def get_message_status(msg_id: str | None) -> dict | None: + response = tracking_table.get_item(Key={'eum_msg_id': msg_id}) + if 'Item' in response: + return response['Item'] + + return None + + +def update_message_status(msg_id: str | None, new_status: str, timestamp: datetime) -> None: + """ + Update the message history and status in the Message tracking DynamoDB table + """ + details = get_message_status(msg_id) + if details is None: + raise RuntimeError(f'Cannot find message with id {msg_id}, failing') + + # Update the record to the new status, checking consistency + for i in range(5): + latest_update = details['latest_update'] + if msg_status.get(new_status, -999) > msg_status.get(details['latest_status'], -1): + details['latest_status'] = new_status + details['latest_update'] = int(datetime.now(tz=UTC).timestamp()) + + if new_status not in details['delivery_history'].values(): + details['delivery_history'][timestamp.isoformat()] = new_status + + try: + # We can just update the existing element since we're not altering the key + tracking_table.put_item(Item=details, ConditionExpression=Attr('latest_update').eq(latest_update)) + return + except botocore.exceptions.ClientError as e: + logging.error(f'Failed to update the status of the message ({e}). This probably means that the message ' + f'status was updated separately. Retrying...') + details = get_message_status(msg_id) + + raise RuntimeError('Could not update the message status') + + +def handler(event, _): + """ + Handle a SMS status update notifications. + + Message delivery notifications are stored for traceability. We record the full history of the message for + several months. We only store metadata about the interactions, not the content of the communications or + phone numbers. + """ + batch_item_failures = [] + for record in event.get('Records', []): + # Fetch the End User Messaging-specific message ID + # We should only get it if this message is for a `accepted` status + notification = json.loads(record['body']) + details = notification.get('detail', {}) + msg_id = details.get('messageId') + if msg_id is None: + logging.error(f'Failing to parse message without message ID: {notification}') + continue + status = msg_status_translation[details['eventType']] + try: + timestamp = datetime.fromtimestamp(details['eventTimestamp'] / 1000) + except (ValueError, TypeError) as e: + logging.warning(f'Could not parse msg timestamp ({details["eventTimestamp"]}), using current time') + timestamp = datetime.now(tz=UTC) + + try: + update_message_status(msg_id, status, timestamp) + except RuntimeError: + batch_item_failures.append({"itemIdentifier": msg_id}) + + return {'batchItemFailures': batch_item_failures} diff --git a/python/end-user-messaging-rest-frontend/lambda/wa_status_handler/main.py b/python/end-user-messaging-rest-frontend/lambda/wa_status_handler/main.py new file mode 100644 index 0000000000..34bbc48006 --- /dev/null +++ b/python/end-user-messaging-rest-frontend/lambda/wa_status_handler/main.py @@ -0,0 +1,140 @@ +import os +import json +import boto3 +import logging +import botocore.exceptions +from datetime import datetime, timedelta, UTC +from boto3.dynamodb.conditions import Attr, Key + +dynamodb = boto3.resource('dynamodb') +consent_table = dynamodb.Table(os.environ['CONSENT_TABLE_NAME']) +tracking_table = dynamodb.Table(os.environ['TRACKING_TABLE_NAME']) +msg_status = {'unknown': -999, 'failed': -1, 'created': 0, 'sent_for_delivery': 1, + 'accepted': 2, 'sent': 3, 'delivered': 4, 'read': 5} + + +def get_message_status(msg_id: str | None, whatsapp_msg_id: str) -> dict | None: + # Try to get the current message ID status, in the table we + # might either the WhatsApp or the AWS msg id + response = tracking_table.query(IndexName='WhatsAppMessageId', + KeyConditionExpression=Key('wa_msg_id').eq(whatsapp_msg_id)) + if response.get('Count', 0) > 0: + msg_id = response['Items'][0]['eum_msg_id'] + + response = tracking_table.get_item(Key={'eum_msg_id': msg_id}) + if 'Item' in response: + return response['Item'] + + return None + + +def update_message_status(msg_id: str | None, whatsapp_msg_id: str, new_status: str, timestamp: datetime) -> None: + """ + Update the message history and status in the Message tracking DynamoDB table + """ + details = get_message_status(msg_id, whatsapp_msg_id) + if details is None: + raise RuntimeError(f'Cannot find message with id {whatsapp_msg_id}, failing') + if details['wa_msg_id'] == '__UNKNOWN__': + details['wa_msg_id'] = whatsapp_msg_id + + # Update the record to the new status, checking consistency + for i in range(5): + latest_update = details['latest_update'] + if msg_status.get(new_status, -999) > msg_status.get(details['latest_status'], -1): + details['latest_status'] = new_status + details['latest_update'] = int(datetime.now(tz=UTC).timestamp()) + + if new_status not in details['delivery_history'].values(): + details['delivery_history'][timestamp.isoformat()] = new_status + + try: + # We can just update the existing element since we're not altering the key + tracking_table.put_item(Item=details, ConditionExpression=Attr('latest_update').eq(latest_update)) + return + except botocore.exceptions.ClientError as e: + logging.error(f'Failed to update the status of the message ({e}). This probably means that the message ' + f'status was updated separately. Retrying...') + details = get_message_status(msg_id, whatsapp_msg_id) + + raise RuntimeError('Could not update the message status') + + +def register_consent(sender_id: str, start_time: datetime): + """ + Register the fact that the user messaged us, opening a 24h communications + window where we can send free-form messages + """ + ttl = int((start_time + timedelta(hours=23, minutes=50)).timestamp()) + consent_table.put_item(Item={'phone_id': sender_id, + 'user_consents': True, + 'expiration_date': ttl}) + + +def handler(event, _): + """ + Handle a WhatsApp WebHook event. + + WhatsApp sends these when the conversations we're part of have been updated in any way, such as: + - User has replied/reacted to a message + - Message status updates (message has been delivered/read/failed to deliver...) + + More info on how WhatsApp webhooks work at + https://developers.facebook.com/docs/whatsapp/cloud-api/guides/set-up-webhooks + + We want to react to these notifications in two ways: + - If the user has written to us, we have 24h through which we can message them with free text messages. + We track these events in the CONSENT_TABLE_NAME table, so that we know in other parts of the stack + that we can message the user normally. These entries are short-lived in the consent table and are removed + after ~24h), but do contain phone numbers. + - Message delivery notifications are stored for traceability. We record the full history of the message for + several months. We only store metadata about the interactions, not the content of the communications or + phone numbers. + - Other message types are simply ignored. + """ + batch_item_failures = [] + for record in event.get('Records', []): + # Fetch the End User Messaging-specific message ID + # We should only get it if this message is for a `accepted` status + notification = json.loads(record['body']) + msg_id = notification.get('messageId') + entry = json.loads(notification['whatsAppWebhookEntry']) + for change in entry.get('changes', []): + if 'value' not in change: + logging.debug(f'Skipping malformed message as there is no `values` key. Existing keys: {change.keys()}') + continue + if 'statuses' in change['value']: + # A message has changed its status, register it in the table + for update in change['value']['statuses']: + if 'id' not in update: + filtered_info = {k: v for k, v in update.items() if k != 'recipient_id'} + logging.error(f'Failed to parse message WhatsApp response, skipping: {filtered_info}') + continue + whatsapp_msg_id = update['id'] + status = update['status'] + try: + timestamp = datetime.fromtimestamp(int(update['timestamp'])) + except (ValueError, TypeError) as e: + logging.warning(f'Could not parse msg timestamp ({update["timestamp"]}), using current time') + timestamp = datetime.now(tz=UTC) + + try: + update_message_status(msg_id, whatsapp_msg_id, status, timestamp) + except RuntimeError: + batch_item_failures.append({"itemIdentifier": record['messageId']}) + elif 'messages' in change['value']: + # The user has written to us, thus opening a new 24h communication window + for msg in change['value']['messages']: + sender_id = msg['from'] + if not sender_id.startswith('+'): + sender_id = f'+{sender_id}' + try: + send_time = datetime.fromtimestamp(int(msg['timestamp'])) + except (ValueError, TypeError): + send_time = datetime.now(tz=UTC) + + register_consent(sender_id=sender_id, start_time=send_time) + else: + logging.info(f'Skipping unsupported message type: {change["value"].keys()}') + + return {'batchItemFailures': batch_item_failures} diff --git a/python/end-user-messaging-rest-frontend/requirements.txt b/python/end-user-messaging-rest-frontend/requirements.txt new file mode 100644 index 0000000000..f9f962098e --- /dev/null +++ b/python/end-user-messaging-rest-frontend/requirements.txt @@ -0,0 +1,4 @@ +aws-cdk-lib>=2.171.1 +boto3>=1.35.78 +cdk-nag>=2.34.21 +constructs>=10.0.0,<11.0.0