"""Lambda publish-message function module.""" import base64 import json import time from segment import analytics import boto3 from boto3.dynamodb.types import TypeDeserializer, TypeSerializer from lambdacommon.common_config import logger import sentry_sdk from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration import config from src.common import messages from src.models import ows_notifications, ows_users from src.utils import dynamo_utils, message_utils if config.secrets_manager_client: sentry_sdk.init( config.secrets_manager_client.get_cred('SENTRY_DSN'), integrations=[AwsLambdaIntegration()] ) logger.info(f'Initializing with Sentry {config.secrets_manager_client is not None}') logger.info(f'Initializing with BUFFER_POLLER_ID => {config.BUFFER_POLLER_ID}') logger.info(f'Initializing with GETSTREAM_ID => {config.GETSTREAM_ID}') analytics.write_key = config.SEGMENT_WRITE_KEY sns_client = boto3.client('sns', region_name=config.AWS_REGION) dynamo_client = boto3.client('dynamodb', region_name=config.AWS_REGION) ses_client = boto3.client('ses', region_name=config.AWS_REGION) S = TypeSerializer() D = TypeDeserializer() def handler(event, context): """Lambda entry point. Args: event (dict): messages from SQS queue context (object): AWS Lambda Context object https://docs.aws.amazon.com/lambda/latest/dg/python-context.html Returns: None """ try: logger.info(f'lambda notification event: {event}') for raw_record in event.get('Records', []): sender_id = raw_record['attributes']['SenderId'] # process buffer full of events if sender_id == config.BUFFER_POLLER_ID or sender_id.startswith(config.BUFFER_POLLER_ID + ':'): # noqa:E501 buffer_id = raw_record['body'] event_type, profile_id, profile_type = dynamo_utils.decompress_buffer_id(buffer_id) events = clear_buffer(event_type, profile_id, profile_type) # filter, format, and send notifications send_notifications( profile_id, profile_type, events ) # aggregate or process event from GetStream.io elif sender_id == config.GETSTREAM_ID: try: record_str = base64.b64decode( raw_record['body']).decode('utf-8') except UnicodeDecodeError: logger.info(f"decode error for: {raw_record['body']}") continue for record_json in json.loads(record_str): logger.info(record_json) feed_items = record_json['new'] if not feed_items: continue # Get profile ID from feed name feed_name = record_json['feed'].split(':')[1] feed_profile_type, feed_profile_id = feed_name.split('_') # filter out and add to buffer aggregate events single_items = [] for feed_item in feed_items: # format raw message details from JSON string contents feed_item['object'] = json.loads(feed_item['object']) event_type = feed_item['verb'] aggregate_delay = _aggregation_time(event_type) if aggregate_delay > 0: buffer_id = add_event( feed_item, event_type, feed_profile_id, feed_profile_type, aggregate_delay) logger.info( f'Buffer ID {buffer_id} added. Skipping..') else: single_items.append(feed_item) # filter, format, and send notifications send_notifications( feed_profile_id, feed_profile_type, single_items, record_json.get('app_id') ) # unknown event source, raise exception else: raise Exception(f'Unexpected sender_id "{sender_id}"') except Exception as e: logger.exception(str(e)) raise e def _aggregation_time(event_type): """Return aggregation time in seconds. Args: event_type (str): key in EVENT_TYPES config Returns: int """ return config.EVENT_TYPES[event_type]['aggregate'] def send_notifications(profile_id, profile_type, events, app_id=''): """Process events into notifications. Args: profile_id (int): profile identifier to receive notifications profile_type (str): profile type to receive notifications events (list): discrete events to process into notifications Returns: None """ if not events: return # get user information in order to filter events and enrich notification identity_id, _, brand = process_identity_response( profile_id, profile_type, app_id) if not identity_id: return notification_settings = ows_notifications.get_notification_settings( # noqa:E501 profile_id, profile_type) user_data = { 'notification_settings': notification_settings, 'profile_id': profile_id, 'profile_type': profile_type, 'identity_id': identity_id, 'brand': brand, } # process events according to their type for event_type, event_config in config.EVENT_TYPES.items(): batch_events = [ x for x in events if x['verb'] == event_type ] if not batch_events: continue # filter according to user subscription settings batch_events = message_utils.events_with_notifications_enabled( # noqa:E501 batch_events, user_data['notification_settings'], event_config['feed_type'], 'push_notifications') if not batch_events: continue # generate notifications message_func = getattr(message_utils, event_config['message_func']) messages_list = message_func(batch_events, _, **user_data) # send notifications for message, metadata in messages_list: publish_to_sns(message, metadata, identity_id) def clear_buffer(event_type, profile_id, profile_type): """Delete and return buffer contents.""" buffer_id = S.serialize( dynamo_utils.compress_buffer_id(event_type, profile_id, profile_type)) batch = dynamo_client.delete_item( TableName=config.DYNAMO_TABLE, Key={ 'buffer_id': buffer_id }, ReturnValues='ALL_OLD' ) if 'Attributes' in batch: return D.deserialize(batch['Attributes']['events']) return [] def add_event(event_obj, event_type, profile_id, profile_type, delay): """Add event to dynamodb buffer table.""" buffer_id = S.serialize( dynamo_utils.compress_buffer_id(event_type, profile_id, profile_type)) result = dynamo_client.update_item( TableName=config.DYNAMO_TABLE, Key={ 'buffer_id': buffer_id }, AttributeUpdates={ 'events': { 'Value': S.serialize( [ event_obj ] ), 'Action': 'ADD' } }, ReturnValues='ALL_OLD' ) if 'Attributes' not in result: dynamo_client.update_item( TableName=config.DYNAMO_TABLE, Key={ 'buffer_id': buffer_id }, AttributeUpdates={ 'process_after': { 'Value': S.serialize(int(time.time() + delay)), 'Action': 'PUT' }, } ) return buffer_id def process_identity_response( feed_profile_id, feed_profile_type, app_id=''): """Process response from ows-users.""" identity_response = ows_users.get_identity( feed_profile_id, feed_profile_type) if identity_response.status_code == 404: logger.info( f'404 identity response for {feed_profile_type} {feed_profile_id}. Skipping...') # noqa:E501 return None, None, None identity_response = identity_response.json() identity_id = identity_response['id'] identity_email = identity_response.get('email', '') identity_lc = identity_response.get('localization', '') analytics.identify(identity_id, { 'auth0UserId': identity_response.get('auth0_user_id'), 'email': identity_email, 'profileId': feed_profile_id, 'profileType': feed_profile_type, 'localization': identity_lc, 'app_id': app_id }) return ( identity_id, messages.get_translations(identity_lc), identity_response.get('default_brand', config.ORCHARD_BRAND) ) def publish_to_sns(message, metadata, identity_id): """Publish message to sns topic.""" topic_name = get_topic_name(metadata, identity_id) logger.info(f'Publishing message: {message} to SNS Topic ARN: {topic_name}') profile_id = metadata['data'].get('profileId') profile_type = metadata['data'].get('profileType') profile_info = f'Profile:{profile_id} {profile_type}' try: publish_result = sns_client.publish( TopicArn=f'{config.SNS_ARN_PREFIX}:{topic_name}', MessageStructure='json', Message=message ) # Catch only not found exception which indicates the topic does not exist, log and skip. # Raise other exceptions. except sns_client.exceptions.NotFoundException: logger.warning(f'Topic not found for {profile_info}. SNS Topic ARN: {topic_name}.') else: logger.info(f'Successfully published message to SNS for {profile_info}: {publish_result}') analytics.track( identity_id, 'Push Notification Sent', { 'notification_data': metadata['data'], 'sns_publish_id': publish_result['MessageId'], 'sns_topic_name': topic_name, 'campaign': { 'medium': 'Push', 'name': metadata['data'].get('type', '').upper(), 'source': 'orchard-notifications', } }) def send_email_to_ses( email_address, email_subject, html_body, text_body): """Send email to ses.""" return ses_client.send_email( Destination={ 'ToAddresses': email_address, }, Message={ 'Body': { 'Html': { 'Charset': 'UTF-8', 'Data': html_body, }, 'Text': { 'Charset': 'UTF-8', 'Data': text_body, } }, 'Subject': { 'Charset': 'UTF-8', 'Data': email_subject, }, }, Source=config.ORCHARD_EMAIL_ADDRESS ) def get_topic_name(metadata: dict, identity_id: str) -> str: """Return topic name for given identity. :param metadata: metadata dict :param identity_id: identity id """ brand = metadata['data'].get('brand') brand_prefix = f'-{brand}-' if brand in [config.AWAL_BRAND, config.SME_BRAND] else '-' return f'{config.ENVIRONMENT}{brand_prefix}push-notifications-{identity_id}'