├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── PowerDialer-ListLoad ├── lambda_function.py └── requirements.txt ├── PowerDialer-ProcessContactEvents ├── lambda_function.py └── requirements.txt ├── PowerDialer-SaveResults ├── lambda_function.py └── requirements.txt ├── PowerDialer-connectStatus ├── lambda_function.py └── requirements.txt ├── PowerDialer-dial ├── lambda_function.py └── requirements.txt ├── PowerDialer-getAvailAgents ├── lambda_function.py └── requirements.txt ├── PowerDialer-getConfig ├── lambda_function.py └── requirements.txt ├── PowerDialer-getContacts ├── lambda_function.py └── requirements.txt ├── PowerDialer-layer ├── __init__.py ├── powerdialer.py └── requirements.txt ├── PowerDialer-queueContacts ├── lambda_function.py └── requirements.txt ├── PowerDialer-setDisposition ├── lambda_function.py └── requirements.txt ├── PowerDialer-updateProfile ├── lambda_function.py └── requirements.txt ├── README.md ├── images ├── DialerOnConnect-Architecture.jpg └── DialerOnConnect-Workflow.jpg ├── sample-files ├── View-Dialer-DispositionCodes └── sample-load.csv ├── statemachine ├── PowerDialer-ListLoad.asl.json └── PowerDialer-control.asl.json └── template.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /PowerDialer-ListLoad/lambda_function.py: -------------------------------------------------------------------------------- 1 | ##Add S3 file contacts to queue 2 | import json 3 | import os 4 | import boto3 5 | import datetime 6 | from botocore.exceptions import ClientError 7 | from powerdialer import get_config 8 | import re 9 | 10 | 11 | sfn = boto3.client('stepfunctions') 12 | 13 | 14 | SQS_URL = os.environ['SQS_URL'] 15 | DIALER_DEPLOYMENT= os.environ['DIALER_DEPLOYMENT'] 16 | SFN_ARN = os.environ['SFN_ARN'] 17 | CUSTOMER_PROFILES_DOMAIN = os.environ['CUSTOMER_PROFILES_DOMAIN'] 18 | NO_CALL_STATUS = os.environ['NO_CALL_STATUS'].split(",") 19 | VALIDATE_PROFILE = os.environ['VALIDATE_PROFILE'] 20 | 21 | 22 | 23 | def lambda_handler(event, context): 24 | print(event) 25 | countrycode = get_config('countrycode', DIALER_DEPLOYMENT) 26 | s3fileName=event['BatchInput']['bucket'] 27 | 28 | items = [] 29 | for item_data in event['Items']: 30 | item = flatten_keys(item_data) 31 | #try: 32 | 33 | if (VALIDATE_PROFILE): 34 | disposition = check_valid_disposition(countrycode+item['Address'],CUSTOMER_PROFILES_DOMAIN) 35 | if(disposition): 36 | print("Accepting calls") 37 | items.append(pack_entry(item,countrycode,s3fileName)) 38 | else: 39 | print("No call flag") 40 | items.append(pack_entry(item,countrycode,s3fileName)) 41 | else: 42 | print("No validation, queuing") 43 | items.append(pack_entry(item,countrycode,s3fileName)) 44 | 45 | #except Exception as e: 46 | # print("Failed checking profile") 47 | # print(e) 48 | 49 | response = queue_contacts(items,SQS_URL) 50 | 51 | 52 | return { 53 | 'statusCode': 200, 54 | 'queudContacts':response 55 | } 56 | 57 | def get_template(template): 58 | try: 59 | response = pinpointClient.get_voice_template( 60 | TemplateName=template 61 | ) 62 | except Exception as e: 63 | print("Error retrieving template") 64 | print(e) 65 | return False 66 | else: 67 | return response['VoiceTemplateResponse']['Body'] 68 | 69 | 70 | def check_valid_disposition(phoneNumber,customerProfileDomain): 71 | cpclient = boto3.client('customer-profiles') 72 | 73 | cp = cpclient.search_profiles(DomainName=customerProfileDomain,KeyName='_phone',Values=['+'+phoneNumber]) 74 | 75 | if(len(cp['Items']) and 'Attributes' in cp['Items'][0] and 'callDisposition' in cp['Items'][0]['Attributes']): 76 | print(cp['Items'][0]['Attributes']['callDisposition']) 77 | if(cp['Items'][0]['Attributes']['callDisposition'] not in NO_CALL_STATUS): 78 | return True 79 | else: 80 | return False 81 | else: 82 | print("No call disposition assigned") 83 | return True 84 | 85 | def queue_contacts(entries,sqs_url): 86 | sqs = boto3.client('sqs') 87 | try: 88 | response = sqs.send_message_batch( 89 | QueueUrl=sqs_url, 90 | Entries=entries 91 | ) 92 | except ClientError as e: 93 | print(e.response['Error']) 94 | return False 95 | else: 96 | return len(response['Successful']) 97 | 98 | 99 | 100 | def pack_entry(item,countrycode,s3fileName): 101 | attributes={ 102 | 'campaignId': datetime.datetime.now().isoformat(), 103 | 'applicationId': 'S3FileImported', 104 | 'campaignName': s3fileName 105 | } 106 | 107 | attributes.update(item) 108 | return { 109 | 'Id': item['UserId'], 110 | 'MessageBody': '+' + countrycode + item['Address'], 111 | 'MessageAttributes': { 112 | 'custID': { 113 | 'DataType': 'String', 114 | 'StringValue': item['UserId'] 115 | }, 116 | 'phone': { 117 | 'DataType': 'String', 118 | 'StringValue': '+' + countrycode + item['Address'] 119 | }, 120 | 'attributes': { 121 | 'DataType': 'String', 122 | 'StringValue': json.dumps(attributes) 123 | } 124 | } 125 | } 126 | 127 | def flatten_keys(item_data): 128 | transformed_data = {} 129 | for key, value in item_data.items(): 130 | if '.' in key: 131 | new_key = key.split('.')[-1] 132 | transformed_data[new_key] = value 133 | else: 134 | transformed_data[key] = value 135 | return transformed_data -------------------------------------------------------------------------------- /PowerDialer-ListLoad/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-power-dialer/dd6706b1020937883121cf436b9e2835c624fbe9/PowerDialer-ListLoad/requirements.txt -------------------------------------------------------------------------------- /PowerDialer-ProcessContactEvents/lambda_function.py: -------------------------------------------------------------------------------- 1 | #Process ContactsEvents 2 | import json 3 | import base64 4 | import boto3 5 | import os 6 | from powerdialer import get_token, sendSuccessToken, remove_contactId 7 | from boto3.dynamodb.conditions import Key 8 | 9 | ACTIVE_DIALING = os.environ['ACTIVE_DIALING'] 10 | 11 | 12 | def lambda_handler(event, context): 13 | print(event) 14 | if(event['detail'].get('eventType') =='DISCONNECTED'): 15 | contactId = str(event['detail']['contactId']) 16 | token = get_token(contactId,ACTIVE_DIALING) 17 | print ("ContactId:" + contactId) 18 | 19 | if(token!= None): 20 | print("Sending Token") 21 | print("Token:" + token) 22 | try: 23 | sendresult = sendSuccessToken(token,contactId) 24 | #remove_contactId(contactId,ACTIVE_DIALING) 25 | except Exception as e: 26 | print (e) 27 | else: 28 | 29 | print(sendresult) 30 | else: 31 | print("No token") 32 | 33 | return -------------------------------------------------------------------------------- /PowerDialer-ProcessContactEvents/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-power-dialer/dd6706b1020937883121cf436b9e2835c624fbe9/PowerDialer-ProcessContactEvents/requirements.txt -------------------------------------------------------------------------------- /PowerDialer-SaveResults/lambda_function.py: -------------------------------------------------------------------------------- 1 | ##Dialing Results Load to S3 2 | import json 3 | import boto3 4 | import os 5 | import csv 6 | 7 | from powerdialer import get_config 8 | from boto3.dynamodb.conditions import Key 9 | 10 | def lambda_handler(event, context): 11 | s3_client = boto3.client('s3') 12 | dynamodb = boto3.client('dynamodb') 13 | print(event) 14 | DIALER_DEPLOYMENT = os.environ['DIALER_DEPLOYMENT'] 15 | dialerList = get_config('table-dialerlist', DIALER_DEPLOYMENT) 16 | bucket = get_config('ResultsBucket', DIALER_DEPLOYMENT) 17 | 18 | response = dynamodb.scan( 19 | TableName=dialerList, 20 | Select='ALL_ATTRIBUTES') 21 | data = response['Items'] 22 | 23 | while 'LastEvaluatedKey' in response: 24 | response = dynamodb.scan( 25 | TableName=dialerList, 26 | Select='ALL_ATTRIBUTES', 27 | ExclusiveStartKey=response['LastEvaluatedKey']) 28 | data.extend(response['Items']) 29 | 30 | prettyData = [] 31 | for item in data: 32 | prettyData.append(remove_types(item)) 33 | print(prettyData) 34 | 35 | datajson = json.dumps(prettyData, ensure_ascii=False) 36 | response = s3_client.put_object(Body=datajson, 37 | Bucket=bucket, 38 | Key='results/dialingResults.json', 39 | ACL="bucket-owner-full-control") 40 | return {'Status':'OK'} 41 | 42 | def remove_types(ddbjson): 43 | result = {} 44 | for key, value in ddbjson.items(): 45 | if isinstance(value, dict): 46 | if len(value) == 1: 47 | new_key, new_value = list(value.items())[0] 48 | if new_key == 'BOOL': 49 | result[key] = new_value 50 | elif new_key == 'N' and new_value.isdigit(): 51 | result[key] = int(new_value) 52 | elif new_key == 'S': 53 | result[key] = new_value 54 | else: 55 | result.update(remove_types(value)) 56 | else: 57 | result.update(remove_types(value)) 58 | else: 59 | result[key] = value 60 | return result -------------------------------------------------------------------------------- /PowerDialer-SaveResults/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-power-dialer/dd6706b1020937883121cf436b9e2835c624fbe9/PowerDialer-SaveResults/requirements.txt -------------------------------------------------------------------------------- /PowerDialer-connectStatus/lambda_function.py: -------------------------------------------------------------------------------- 1 | #Get Connect Status 2 | import json 3 | import boto3 4 | import botocore 5 | import os 6 | import datetime 7 | import pytz 8 | import random 9 | import time 10 | 11 | connect_client = boto3.client('connect') 12 | ssm=boto3.client('ssm') 13 | 14 | def lambda_handler(event, context): 15 | 16 | connect_id=event['params']['connectid'] 17 | queueid=event['params']['queue'] 18 | response = queue_status(connect_id, queueid) 19 | response['availableAgents']=get_available_agents(connect_id,queueid) 20 | print(response) 21 | return response 22 | 23 | 24 | def queue_status(instanceId, queueId): 25 | retry_count = 0 26 | hours={} 27 | queue={} 28 | response={} 29 | while retry_count < 3: 30 | try: 31 | queue = connect_client.describe_queue(InstanceId=instanceId,QueueId=queueId) 32 | 33 | except botocore.exceptions.ClientError as error: 34 | print(error) 35 | if error.response['Error']['Code'] == 'TooManyRequestsException': 36 | print("TooManyRequestsException, waiting.") 37 | retry_count += 1 38 | delay = exponential_backoff(retry_count) 39 | time.sleep(delay) 40 | continue 41 | else: 42 | raise error 43 | finally: 44 | break 45 | 46 | if(queue['Queue']['Status']=='ENABLED'): 47 | response['queueEnabled']='True' 48 | else: 49 | response['queueEnabled']= "False" 50 | 51 | retry_count = 0 52 | while retry_count < 3: 53 | try: 54 | hours = connect_client.describe_hours_of_operation(InstanceId=instanceId,HoursOfOperationId=queue['Queue']['HoursOfOperationId']) 55 | except botocore.exceptions.ClientError as error: 56 | print(error) 57 | if error.response['Error']['Code'] == 'TooManyRequestsException': 58 | print("TooManyRequestsException, waiting.") 59 | retry_count += 1 60 | delay = exponential_backoff(retry_count) 61 | time.sleep(delay) 62 | else: 63 | raise error 64 | finally: 65 | break 66 | 67 | timezone = pytz.timezone(hours['HoursOfOperation']['TimeZone']) 68 | today = datetime.datetime.now(timezone).strftime('%A').upper() 69 | current_time = datetime.datetime.now(timezone).time() 70 | 71 | 72 | for entry in hours['HoursOfOperation']['Config']: 73 | if entry['Day'] == today: 74 | start_time = datetime.time(entry['StartTime']['Hours'], entry['StartTime']['Minutes']) 75 | end_time = datetime.time(entry['EndTime']['Hours'], entry['EndTime']['Minutes']) 76 | if start_time <= current_time < end_time or start_time == end_time: 77 | response['workingHours'] = "True" 78 | break 79 | else: 80 | response['workingHours'] = "False" 81 | return response 82 | 83 | def exponential_backoff(retry_count, base_delay=1, max_delay=32): 84 | delay = min(base_delay * (2 ** retry_count), max_delay) 85 | jitter = random.uniform(0, 0.1) 86 | return delay + jitter 87 | 88 | def get_available_agents(connectid,queue): 89 | 90 | connect_client = boto3.client('connect') 91 | response = connect_client.get_current_metric_data( 92 | InstanceId=connectid, 93 | Filters={ 94 | 'Queues': [ 95 | queue, 96 | ], 97 | 'Channels': [ 98 | 'VOICE', 99 | ] 100 | }, 101 | CurrentMetrics=[ 102 | { 103 | 'Name': 'AGENTS_ONLINE', 104 | 'Unit': 'COUNT' 105 | }, 106 | ], 107 | ) 108 | #print("Available Agents Metrics :" + str(response['MetricResults'])) 109 | 110 | if(response['MetricResults']): 111 | availAgents = int(response['MetricResults'][0]['Collections'][0]['Value']) 112 | else: 113 | availAgents =0 114 | return availAgents -------------------------------------------------------------------------------- /PowerDialer-connectStatus/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-power-dialer/dd6706b1020937883121cf436b9e2835c624fbe9/PowerDialer-connectStatus/requirements.txt -------------------------------------------------------------------------------- /PowerDialer-dial/lambda_function.py: -------------------------------------------------------------------------------- 1 | ##dialer 2 | import json 3 | import boto3 4 | import os 5 | from datetime import datetime 6 | from powerdialer import place_call, updateActiveDialing, save_results, get_token, exponential_backoff 7 | from boto3.dynamodb.conditions import Key 8 | 9 | 10 | def lambda_handler(event, context): 11 | print(event) 12 | 13 | ACTIVE_DIALING_TABLE = os.environ['ACTIVE_DIALING_TABLE'] 14 | DIALER_DEPLOYMENT = os.environ['DIALER_DEPLOYMENT'] 15 | RESULTS_FIREHOSE_NAME = os.environ['RESULTS_FIREHOSE_NAME'] 16 | 17 | contactFlow = event['params']['contactflow'] 18 | connectID = event['params']['connectid'] 19 | queue = event['params']['queue'] 20 | task_token = event['TaskToken'] 21 | phone = event['contacts']['phone'] 22 | attributes = event['contacts']['attributes'] 23 | attributes['UserId'] = event['contacts']['custID'] 24 | 25 | response = place_call(phone, contactFlow, connectID, queue,attributes) 26 | 27 | if(response): 28 | print("Valid response - Updating TOKEN") 29 | contactId = response['ContactId'] 30 | 31 | token = get_token(contactId,ACTIVE_DIALING_TABLE) 32 | if(token!= None): 33 | print("Existing previous call attempt") 34 | send_task_success(task_token) 35 | results = {'CampaignStep':'Dialing','phone':phone,'CallConnected':True,'contactId':contactId} 36 | else: 37 | print("Completed new call") 38 | validNumber= True 39 | updateActiveDialing(contactId, task_token, phone,ACTIVE_DIALING_TABLE) 40 | results = {'CampaignStep':'Dialing','phone':phone,'CallConnected':True,'contactId':contactId} 41 | else: 42 | print("Invalid response - Clearing TASK") 43 | validNumber=False 44 | contactId = "NoContact" 45 | send_task_success(task_token) 46 | results = {'CampaignStep':'Dialing','phone':phone,'CallConnected':False,'contactId':contactId} 47 | 48 | save_results(results,DIALER_DEPLOYMENT,RESULTS_FIREHOSE_NAME) 49 | return {'validNumber':validNumber, 'contactId': contactId } 50 | 51 | def send_task_success(task_token): 52 | sfn = boto3.client('stepfunctions') 53 | response = sfn.send_task_success( 54 | taskToken=task_token, 55 | output='{"Payload": {"callAttempt":"callAttemptFailed", "contactId":"NoContact"},"validNumber":"False"}' 56 | ) -------------------------------------------------------------------------------- /PowerDialer-dial/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-power-dialer/dd6706b1020937883121cf436b9e2835c624fbe9/PowerDialer-dial/requirements.txt -------------------------------------------------------------------------------- /PowerDialer-getAvailAgents/lambda_function.py: -------------------------------------------------------------------------------- 1 | #Get Available Agents in Queue 2 | import json 3 | import boto3 4 | import os 5 | 6 | 7 | def lambda_handler(event, context): 8 | print(event) 9 | 10 | CONNECT_INSTANCE_ID=event['params']['connectid'] 11 | CONNECT_QUEUE_ID=event['params']['queue'] 12 | 13 | connect_client = boto3.client('connect') 14 | response = connect_client.get_current_metric_data( 15 | InstanceId=CONNECT_INSTANCE_ID, 16 | Filters={ 17 | 'Queues': [ 18 | CONNECT_QUEUE_ID, 19 | ], 20 | 'Channels': [ 21 | 'VOICE', 22 | ] 23 | }, 24 | CurrentMetrics=[ 25 | { 26 | 'Name': 'AGENTS_AVAILABLE', 27 | 'Unit': 'COUNT' 28 | }, 29 | ], 30 | ) 31 | print("Available Agents Metriics :" + str(response['MetricResults'])) 32 | 33 | if(response['MetricResults']):availAgents = int(response['MetricResults'][0]['Collections'][0]['Value']) 34 | else: availAgents =0 35 | return {"availAgents":availAgents} 36 | -------------------------------------------------------------------------------- /PowerDialer-getAvailAgents/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-power-dialer/dd6706b1020937883121cf436b9e2835c624fbe9/PowerDialer-getAvailAgents/requirements.txt -------------------------------------------------------------------------------- /PowerDialer-getConfig/lambda_function.py: -------------------------------------------------------------------------------- 1 | ##getConfig Function 2 | import json 3 | import boto3 4 | import os 5 | 6 | ssm=boto3.client('ssm') 7 | 8 | def lambda_handler(event, context): 9 | DIALER_DEPLOYMENT = os.environ['DIALER_DEPLOYMENT'] 10 | config=get_parameters(DIALER_DEPLOYMENT) 11 | config['concurrentCalls'] = min(int(get_available_agents(config['connectid'],config['queue'])),int(config['concurrentCalls'])) 12 | config['dialerThreads']= [0]*int(config['concurrentCalls']) 13 | return config 14 | 15 | def get_parameters(deployment): 16 | 17 | config={} 18 | next_token = None 19 | 20 | try: 21 | while True: 22 | if next_token: 23 | ssmresponse = ssm.get_parameters_by_path(Path='/connect/dialer/'+deployment+'/',NextToken=next_token) 24 | else: 25 | ssmresponse = ssm.get_parameters_by_path(Path='/connect/dialer/'+deployment+'/') 26 | for parameter in ssmresponse['Parameters']: 27 | if(parameter['Value'].isnumeric()): 28 | config[parameter['Name'].split("/")[-1]]=int(parameter['Value']) 29 | else: 30 | config[parameter['Name'].split("/")[-1]]=parameter['Value'] 31 | next_token = ssmresponse.get('NextToken') 32 | 33 | if not next_token: 34 | break 35 | 36 | except: 37 | print("Error getting config") 38 | return None 39 | else: 40 | return config 41 | 42 | 43 | def get_available_agents(connectid,queue): 44 | 45 | connect_client = boto3.client('connect') 46 | response = connect_client.get_current_metric_data( 47 | InstanceId=connectid, 48 | Filters={ 49 | 'Queues': [ 50 | queue, 51 | ], 52 | 'Channels': [ 53 | 'VOICE', 54 | ] 55 | }, 56 | CurrentMetrics=[ 57 | { 58 | 'Name': 'AGENTS_ONLINE', 59 | 'Unit': 'COUNT' 60 | }, 61 | ], 62 | ) 63 | #print("Available Agents Metrics :" + str(response['MetricResults'])) 64 | 65 | if(response['MetricResults']): 66 | availAgents = int(response['MetricResults'][0]['Collections'][0]['Value']) 67 | else: 68 | availAgents =0 69 | return availAgents -------------------------------------------------------------------------------- /PowerDialer-getConfig/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-power-dialer/dd6706b1020937883121cf436b9e2835c624fbe9/PowerDialer-getConfig/requirements.txt -------------------------------------------------------------------------------- /PowerDialer-getContacts/lambda_function.py: -------------------------------------------------------------------------------- 1 | ##getContacts Function 2 | import json 3 | import boto3 4 | import os 5 | from powerdialer import delete_contact 6 | from boto3.dynamodb.conditions import Key 7 | from botocore.exceptions import ClientError 8 | 9 | def lambda_handler(event, context): 10 | print(event) 11 | DIALER_DEPLOYMENT = os.environ['DIALER_DEPLOYMENT'] 12 | SQS_URL = os.environ['SQS_URL'] 13 | PRIORITY_SQS_URL = os.environ['PRIORITY_SQS_URL'] 14 | availAgents = int(event['availAgents']) 15 | contacts = [] 16 | endOfList = "False" 17 | if(availAgents>10): 18 | messages = [] 19 | for i in range(round(availAgents/10)): 20 | msgblock = get_contact(availAgents,SQS_URL) 21 | messages.append(msgblock) 22 | else: 23 | if(check_for_contacts(PRIORITY_SQS_URL)): 24 | messages = get_contact(availAgents,PRIORITY_SQS_URL) 25 | else: 26 | messages = get_contact(availAgents,SQS_URL) 27 | messages = get_contact(availAgents,SQS_URL) 28 | 29 | if messages is not None: 30 | for message in messages: 31 | print("Received: " + message['custID']) 32 | contacts.append(dict([('custID',message['custID']),('phone',message['phone']),('attributes',message['attributes'])])) 33 | delete_contact(message['ReceiptHandle'],SQS_URL) 34 | else: 35 | print("No additional items") 36 | endOfList = "True" 37 | 38 | contactResponse = dict([("EndOfList",endOfList),("contacts",contacts)]) 39 | 40 | print(contactResponse) 41 | return contactResponse 42 | 43 | 44 | def get_contact(quantity,sqs_url): 45 | sqs = boto3.client('sqs') 46 | try: 47 | response = sqs.receive_message( 48 | QueueUrl=sqs_url, 49 | MaxNumberOfMessages=quantity, 50 | MessageAttributeNames=[ 51 | 'All' 52 | ], 53 | VisibilityTimeout=10, 54 | WaitTimeSeconds=5 55 | ) 56 | except: 57 | return None 58 | else: 59 | messages=[] 60 | if 'Messages' in response: 61 | for message in response['Messages']: 62 | msg = { 63 | 'ReceiptHandle':message['ReceiptHandle'], 64 | 'phone': message['MessageAttributes']['phone']['StringValue'], #message['Body'], 65 | 'custID': message['MessageAttributes']['custID']['StringValue'], 66 | 'attributes': json.loads(message['MessageAttributes']['attributes']['StringValue']) 67 | } 68 | messages.append(msg) 69 | return messages 70 | else: 71 | return None 72 | 73 | def check_for_contacts(sqs_url): 74 | sqs = boto3.client('sqs') 75 | try: 76 | response = client.get_queue_attributes(QueueUrl=sqs_url,AttributeNames=['ApproximateNumberOfMessages']) 77 | except: 78 | return None 79 | else: 80 | return response['Attributes']['ApproximateNumberOfMessages'] 81 | 82 | -------------------------------------------------------------------------------- /PowerDialer-getContacts/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-power-dialer/dd6706b1020937883121cf436b9e2835c624fbe9/PowerDialer-getContacts/requirements.txt -------------------------------------------------------------------------------- /PowerDialer-layer/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["upload_dial_record", "place_call", "updateActiveDialing","get_config","scan_config","update_dial_list","update_config","get_callee","get_total_records","get_token","remove_contactId","sendSuccessToken","queue_contact","save_results","delete_contact","exponential_backoff","get_call_preferences","update_call_attributes"] -------------------------------------------------------------------------------- /PowerDialer-layer/powerdialer.py: -------------------------------------------------------------------------------- 1 | ##dialer 2 | import json 3 | import boto3 4 | import time 5 | import random 6 | import os 7 | from datetime import datetime 8 | from boto3.dynamodb.conditions import Key 9 | from botocore.exceptions import ClientError 10 | 11 | 12 | 13 | def delete_contact(receipt,sqs_url): 14 | sqs = boto3.client('sqs') 15 | try: 16 | sqs.delete_message( 17 | QueueUrl=sqs_url, 18 | ReceiptHandle=receipt 19 | ) 20 | except ClientError as e: 21 | print(e.response['Error']) 22 | return False 23 | else: 24 | return True 25 | 26 | 27 | def save_results(data,partition,streamName): 28 | firehose = boto3.client('firehose') 29 | try: 30 | response = firehose.put_record( 31 | DeliveryStreamName=streamName, 32 | Record={'Data': json.dumps(data)} 33 | ) 34 | 35 | except ClientError as e: 36 | print(e.response['Error']) 37 | return False 38 | else: 39 | return response 40 | 41 | 42 | def queue_contact(custID,phone,attributes,sqs_url): 43 | sqs = boto3.client('sqs') 44 | try: 45 | response = sqs.send_message( 46 | QueueUrl=sqs_url, 47 | MessageAttributes={ 48 | 'custID': { 49 | 'DataType': 'String', 50 | 'StringValue': custID 51 | }, 52 | 'phone': { 53 | 'DataType': 'String', 54 | 'StringValue': phone 55 | }, 56 | 'attributes': { 57 | 'DataType': 'String', 58 | 'StringValue': json.dumps(attributes) 59 | } 60 | }, 61 | MessageBody=( 62 | phone 63 | ) 64 | ) 65 | except ClientError as e: 66 | print(e.response['Error']) 67 | return False 68 | else: 69 | return response['MessageId'] 70 | 71 | def upload_dial_record(dialIndex,custID,phone,attributes, table): 72 | dynamodb = boto3.resource('dynamodb') 73 | table = dynamodb.Table(table) 74 | 75 | try: 76 | response = table.update_item( 77 | Key={ 78 | 'seqID': dialIndex 79 | }, 80 | UpdateExpression='SET #item = :newState, #item2 = :newState2,#item3 = :newState3,#item4 = :newState4,#item5 = :newState5,#item6 = :newState6', 81 | ExpressionAttributeNames={ 82 | '#item': 'custID', 83 | '#item2': 'phone', 84 | '#item3': 'attributes', 85 | '#item4': 'callAttempted', 86 | '#item5': 'invalidNumber', 87 | '#item6': 'successfulConnection' 88 | }, 89 | ExpressionAttributeValues={ 90 | ':newState': custID, 91 | ':newState2': phone, 92 | ':newState3': attributes, 93 | ':newState4': False, 94 | ':newState5': False, 95 | ':newState6': False 96 | }, 97 | ReturnValues="UPDATED_NEW") 98 | 99 | print (response) 100 | except Exception as e: 101 | print (e) 102 | else: 103 | return response 104 | 105 | def place_call(phoneNumber, contactFlow,connectID,queue,attributes): 106 | connect_client = boto3.client('connect') 107 | retry_count = 0 108 | response = False 109 | while retry_count < 4: 110 | try: 111 | if(len(attributes)>0): 112 | response = connect_client.start_outbound_voice_contact( 113 | DestinationPhoneNumber=phoneNumber, 114 | ContactFlowId=contactFlow, 115 | InstanceId=connectID, 116 | QueueId=queue, 117 | Attributes=attributes, 118 | ClientToken=attributes['campaignId']+'-'+phoneNumber, 119 | ) 120 | else: 121 | response = connect_client.start_outbound_voice_contact( 122 | DestinationPhoneNumber=phoneNumber, 123 | ContactFlowId=contactFlow, 124 | InstanceId=connectID, 125 | ClientToken=attributes['campaignId']+'-'+phoneNumber, 126 | QueueId=queue 127 | ) 128 | except ClientError as error: 129 | print(error) 130 | if error.response['Error']['Code'] == 'TooManyRequestsException': 131 | print("TooManyRequestsException, waiting.") 132 | retry_count += 1 133 | delay = exponential_backoff(retry_count) 134 | time.sleep(delay) 135 | continue 136 | else: 137 | response = False 138 | finally: 139 | break 140 | 141 | return response 142 | 143 | def exponential_backoff(retry_count, base_delay=1, max_delay=32): 144 | delay = min(base_delay * (2 ** retry_count), max_delay) 145 | jitter = random.uniform(0, 0.1) 146 | return delay + jitter 147 | 148 | 149 | def updateActiveDialing(contactId, token, phone, table): 150 | dynamodb = boto3.resource('dynamodb') 151 | table = dynamodb.Table(table) 152 | timestamp = str(datetime.now()) 153 | timetolive = 24*60*60 + int(time.time()) 154 | 155 | try: 156 | response = table.update_item( 157 | Key={ 158 | 'contactId': contactId 159 | }, 160 | UpdateExpression='SET #v1 = :val1, #v2 =:val2,#v3=:val3,#v4=:val4', 161 | ExpressionAttributeNames={ 162 | '#v1': 'token', 163 | '#v2': 'phone', 164 | '#v3': 'timestamp', 165 | '#v4': 'TimeToLive', 166 | }, 167 | ExpressionAttributeValues={ 168 | ':val1': token, 169 | ':val2': phone, 170 | ':val3': timestamp, 171 | ':val4': timetolive 172 | 173 | }, 174 | ReturnValues="UPDATED_NEW" 175 | ) 176 | 177 | except Exception as e: 178 | print (e) 179 | 180 | else: 181 | return response 182 | 183 | def get_config(parameter,deployment): 184 | try: 185 | ssm=boto3.client('ssm') 186 | ssmresponse = ssm.get_parameter( 187 | Name='/connect/dialer/'+deployment+'/'+parameter, 188 | ) 189 | except: 190 | return None 191 | else: 192 | return ssmresponse['Parameter']['Value'] 193 | 194 | def update_dial_list(dialIndex, dialAttribute, dialValue, table): 195 | dynamodb = boto3.resource('dynamodb') 196 | table = dynamodb.Table(table) 197 | 198 | try: 199 | response = table.update_item( 200 | Key={ 201 | 'seqID': dialIndex 202 | }, 203 | UpdateExpression='SET #item = :newState', 204 | ExpressionAttributeNames={ 205 | '#item': dialAttribute 206 | }, 207 | ExpressionAttributeValues={ 208 | ':newState': dialValue 209 | }, 210 | ReturnValues="UPDATED_NEW") 211 | print (response) 212 | except Exception as e: 213 | print (e) 214 | else: 215 | return response 216 | 217 | def update_config(parameter,value,deployment): 218 | ssm=boto3.client('ssm') 219 | try: 220 | ssmresponse = ssm.put_parameter(Name='/connect/dialer/'+deployment+'/'+parameter,Value=value,Overwrite=True) 221 | except: 222 | return False 223 | else: 224 | return True 225 | 226 | def get_callee(index, table): 227 | dynamodb = boto3.resource('dynamodb') 228 | 229 | table = dynamodb.Table(table) 230 | try: 231 | response = table.query( 232 | KeyConditionExpression=Key('seqID').eq(index) 233 | ) 234 | except: 235 | return False 236 | else: 237 | return response['Items'][0] 238 | 239 | def get_total_records(table): 240 | 241 | client = boto3.client('dynamodb') 242 | response = client.describe_table(TableName=table) 243 | return(response['Table']['ItemCount']) 244 | 245 | def get_token(id, table): 246 | dynamodb = boto3.resource('dynamodb') 247 | 248 | table = dynamodb.Table(table) 249 | response = table.query( 250 | KeyConditionExpression=Key('contactId').eq(id) 251 | ) 252 | if (response['Count']): token = response['Items'][0]['token'] 253 | else: token = None 254 | return token 255 | 256 | def remove_contactId(id,table): 257 | dynamodb = boto3.resource('dynamodb') 258 | table = dynamodb.Table(table) 259 | 260 | try: 261 | response = table.delete_item( 262 | Key={ 263 | 'contactId': id 264 | } 265 | ) 266 | except Exception as e: 267 | print (e) 268 | else: 269 | return response 270 | 271 | def sendSuccessToken(token,id): 272 | sfn = boto3.client('stepfunctions') 273 | response = sfn.send_task_success( 274 | taskToken=token, 275 | output='{"Payload": {"callAttempt":"callAttemptFailed", "contactId":"' + str(id) + '","validNumber":"True"}}' 276 | ) 277 | return response 278 | 279 | def update_call_attributes(customer_id,attributes,customerProfileDomain): 280 | cpclient = boto3.client('customer-profiles') 281 | 282 | try: 283 | cp = cpclient.search_profiles(DomainName=customerProfileDomain,KeyName='_phone',Values=[phoneNumber]) 284 | if(len(cp['Items'])): 285 | response = cpclient.update_profile( 286 | DomainName=customerProfileDomain, 287 | ProfileId=cp['Items'][0]['ProfileId'], 288 | Attributes=attributes 289 | ) 290 | print(f'Perfil del cliente actualizado correctamente: {response}') 291 | return response 292 | 293 | except ClientError as e: 294 | print(f'Error updating profile: {e}') 295 | return False 296 | 297 | 298 | def get_call_preferences(phoneNumber,customerProfileDomain): 299 | cpclient = boto3.client('customer-profiles') 300 | try: 301 | cp = cpclient.search_profiles(DomainName=customerProfileDomain,KeyName='_phone',Values=['+'+phoneNumber]) 302 | except ClientError as e: 303 | print(f'Error searching profile: {e}') 304 | return None 305 | else: 306 | print(cp['Items']) 307 | if(len(cp['Items'])): 308 | return cp['Items'][0].get('Attributes',None) 309 | else: 310 | return None -------------------------------------------------------------------------------- /PowerDialer-layer/requirements.txt: -------------------------------------------------------------------------------- 1 | pytz -------------------------------------------------------------------------------- /PowerDialer-queueContacts/lambda_function.py: -------------------------------------------------------------------------------- 1 | ##Add Pinpoint contacts to queue 2 | import json 3 | import os 4 | import boto3 5 | import datetime 6 | from botocore.exceptions import ClientError 7 | import re 8 | from powerdialer import queue_contact,update_config,get_config, get_call_preferences 9 | 10 | pinpointClient = boto3.client('pinpoint') 11 | sfn = boto3.client('stepfunctions') 12 | 13 | 14 | SQS_URL = os.environ['SQS_URL'] 15 | DIALER_DEPLOYMENT= os.environ['DIALER_DEPLOYMENT'] 16 | SFN_ARN = os.environ['SFN_ARN'] 17 | CUSTOMER_PROFILES_DOMAIN = os.environ['CUSTOMER_PROFILES_DOMAIN'] 18 | NO_CALL_STATUS = os.environ['NO_CALL_STATUS'].split(",") 19 | VALIDATE_PROFILE = os.environ['VALIDATE_PROFILE'] 20 | countrycode = get_config('countrycode', DIALER_DEPLOYMENT) 21 | 22 | 23 | def lambda_handler(event, context): 24 | print(event) 25 | print(NO_CALL_STATUS) 26 | endpoints=event['Endpoints'] 27 | count=0 28 | skipped=0 29 | errors=0 30 | custom_events_batch = {} 31 | ApplicationId= event['ApplicationId'] 32 | CampaignId= event['CampaignId'] 33 | for key in endpoints.keys(): 34 | 35 | user = event['Endpoints'][key] 36 | campaignDetails = get_campaign_details(event['ApplicationId'],event['CampaignId']) 37 | attributes={ 38 | 'campaignId': event['CampaignId'], 39 | 'applicationId': event['ApplicationId'], 40 | 'campaignName': campaignDetails['CampaignName'], 41 | 'segmentName': campaignDetails['SegmentName'], 42 | 'campaingStartTime': campaignDetails['StartTime'], 43 | 'endpointId':key 44 | } 45 | 46 | if ('Data' in event): 47 | templateName = event['Data'] 48 | templateMessage = get_template(templateName) 49 | else: 50 | templateMessage=False 51 | 52 | if(templateMessage): 53 | body = get_message(templateMessage,user) 54 | attributes['prompt']=body 55 | data = get_endpoint_data(endpoints[key]) 56 | if('attributes' in data): 57 | attributes.update(data['attributes']) 58 | 59 | try: 60 | print("Querying profile",'+'+countrycode+data['phone'],data['custID'],attributes) 61 | if (VALIDATE_PROFILE): 62 | callPreferences = get_call_preferences(countrycode+data['phone'],CUSTOMER_PROFILES_DOMAIN) 63 | print("Evaluating callPreferences",callPreferences) 64 | if(callPreferences): 65 | if(callPreferences.get('callDisposition','False') not in NO_CALL_STATUS): 66 | print("Validated call preferences") 67 | queue_contact(data['custID'],'+'+countrycode+data['phone'],attributes,SQS_URL) 68 | count+=1 69 | else: 70 | print("Not queueing:",callPreferences) 71 | skipped+=1 72 | else: 73 | print("No validation, queuing") 74 | queue_contact(data['custID'],'+'+countrycode+data['phone'],attributes,SQS_URL) 75 | else: 76 | queue_contact(data['custID'],'+'+countrycode+data['phone'],attributes,SQS_URL) 77 | except Exception as e: 78 | print("Failed to queue") 79 | print(e) 80 | errors+=1 81 | 82 | else: 83 | print("Template returned blank") 84 | 85 | 86 | if(count): 87 | print("Contacts added to queue, validating dialer status.") 88 | dialerStatus = get_config('activeDialer', DIALER_DEPLOYMENT) 89 | sfStatus = int(check_sf_executions(SFN_ARN)) 90 | print('dialerStatus',dialerStatus) 91 | print('sfStatus',sfStatus) 92 | 93 | if (dialerStatus == "False" and sfStatus==0): 94 | print("Dialer inactive, starting.") 95 | result=launchDialer(SFN_ARN,ApplicationId,CampaignId) 96 | if(result): 97 | print("SF started") 98 | else: 99 | print("SF already started") 100 | else: 101 | print("SF already started") 102 | 103 | return { 104 | 'statusCode': 200, 105 | 'queuedContacts': count, 106 | 'errorContacts': errors 107 | } 108 | 109 | def get_template(template): 110 | try: 111 | response = pinpointClient.get_voice_template( 112 | TemplateName=template 113 | ) 114 | except Exception as e: 115 | print("Error retrieving template") 116 | print(e) 117 | return False 118 | else: 119 | return response['VoiceTemplateResponse']['Body'] 120 | 121 | def get_endpoint_data(endpoint): 122 | userDetails={ 123 | 'phone': endpoint.get('Address',None), 124 | 'attributes':{} 125 | } 126 | 127 | if ('UserId' in endpoint['User']): 128 | userDetails['custID'] = endpoint['User'].get('UserId',None) 129 | 130 | 131 | if ('UserAttributes' in endpoint['User'] and endpoint['User']['UserAttributes']): 132 | for attribkey in endpoint['User']['UserAttributes'].keys(): 133 | if len(endpoint['User']['UserAttributes'][attribkey])>0: 134 | userDetails['attributes'][attribkey] = endpoint['User']['UserAttributes'][attribkey][0] 135 | else: 136 | userDetails['attributes'][attribkey] = 'None' 137 | 138 | if (userDetails['phone']): 139 | return(userDetails) 140 | else: 141 | return None 142 | 143 | def check_sf_executions(sf_arn): 144 | response = sfn.list_executions( 145 | stateMachineArn=sf_arn, 146 | statusFilter='RUNNING' 147 | ) 148 | 149 | return(len(response['executions'])) 150 | 151 | def launchDialer(sfnArn,ApplicationId,CampaignId): 152 | 153 | inputData={ 154 | 'ApplicationId':ApplicationId, 155 | 'CampaignId' : CampaignId 156 | } 157 | try: 158 | response = sfn.start_execution( 159 | stateMachineArn=sfnArn, 160 | name=ApplicationId+CampaignId, 161 | input = json.dumps(inputData) 162 | ) 163 | except Exception as e: 164 | print("SF not launched") 165 | print(e) 166 | return False 167 | return response 168 | 169 | def get_campaign_details(applicationid,campaignid): 170 | campaignResponse = pinpointClient.get_campaign( 171 | ApplicationId=applicationid, 172 | CampaignId=campaignid 173 | ) 174 | segmentResponse = pinpointClient.get_segment( 175 | ApplicationId=applicationid, 176 | SegmentId=campaignResponse['CampaignResponse']['SegmentId'] 177 | ) 178 | campaignDetails = { 179 | "Creation": campaignResponse['CampaignResponse']['CreationDate'], 180 | "CampaignName": campaignResponse['CampaignResponse']['Name'], 181 | "StartTime" : campaignResponse['CampaignResponse']['Schedule']['StartTime'], 182 | "SegmentId":campaignResponse['CampaignResponse']['SegmentId'], 183 | "Timezone":campaignResponse['CampaignResponse']['Schedule']['Timezone'], 184 | "SegmentName":segmentResponse['SegmentResponse']['Name'] 185 | } 186 | 187 | return campaignDetails 188 | 189 | def get_message(text, data): 190 | def replace(match): 191 | key = match.group()[2:-2] 192 | value = get_value(key, data) 193 | return str(value) 194 | 195 | def get_value(key, data): 196 | keys = key.split(".") 197 | value = data 198 | for k in keys: 199 | if isinstance(value, dict): 200 | value = value.get(k) 201 | if isinstance(value, str): 202 | value = value.strip("'") 203 | elif isinstance(value, list): 204 | value = " ".join(value) 205 | elif isinstance(value, list): 206 | value = value[0] 207 | if isinstance(value, str): 208 | value = value.strip("'") 209 | elif isinstance(value, list): 210 | value = " ".join(value) 211 | else: 212 | return None 213 | return value 214 | 215 | pattern = r'\{\{.*?\}\}' 216 | replaced_text = re.sub(pattern, replace, text) 217 | return replaced_text 218 | 219 | def validate_endpoint(endpoint,countrycode,isocountrycode): 220 | try: 221 | response = pinpointClient.phone_number_validate( 222 | NumberValidateRequest={ 223 | 'IsoCountryCode': isocountrycode, 224 | 'PhoneNumber': countrycode+endpoint 225 | }) 226 | except ClientError as e: 227 | print(e.response['Error']) 228 | return False 229 | else: 230 | return response['NumberValidateResponse'] 231 | 232 | 233 | def create_success_custom_event(endpoint_id, campaign_id, message): 234 | custom_event = { 235 | 'Endpoint': {}, 236 | 'Events': {} 237 | } 238 | custom_event['Events']['voice_%s_%s' % (endpoint_id, campaign_id)] = { 239 | 'EventType': 'queuing.success', 240 | 'Timestamp': datetime.datetime.now().isoformat(), 241 | 'Attributes': { 242 | 'campaign_id': campaign_id, 243 | 'message': (message[:195] + '...') if len(message) > 195 else message 244 | } 245 | } 246 | return custom_event 247 | 248 | def create_failure_custom_event(endpoint_id, campaign_id, e): 249 | error = repr(e) 250 | custom_event = { 251 | 'Endpoint': {}, 252 | 'Events': {} 253 | } 254 | custom_event['Events']['voice_%s_%s' % (endpoint_id, campaign_id)] = { 255 | 'EventType': 'queuing.failure', 256 | 'Timestamp': datetime.datetime.now().isoformat(), 257 | 'Attributes': { 258 | 'campaign_id': campaign_id, 259 | 'error': (error[:195] + '...') if len(error) > 195 else error 260 | } 261 | } 262 | return custom_event 263 | 264 | def send_results(application_id,events_batch): 265 | put_events_result = pinpointClient.put_events( 266 | ApplicationId=application_id, 267 | EventsRequest={ 268 | 'BatchItem': events_batch 269 | } 270 | ) 271 | print(put_events_result) 272 | def pause_campaign(application_id,campaign_id): 273 | try: 274 | response = pinpointClient.update_campaign( 275 | ApplicationId=application_id, 276 | CampaignId=campaign_id, 277 | WriteCampaignRequest={'IsPaused': True}) 278 | except ClientError as e: 279 | print(e.response['Error']) 280 | return False 281 | else: 282 | return response 283 | 284 | def get_call_preferences(phoneNumber,customerProfileDomain): 285 | cpclient = boto3.client('customer-profiles') 286 | try: 287 | cp = cpclient.search_profiles(DomainName=customerProfileDomain,KeyName='_phone',Values=['+'+phoneNumber]) 288 | except ClientError as e: 289 | print(f'Error searching profile: {e}') 290 | return None 291 | else: 292 | print(cp['Items']) 293 | if(len(cp['Items'])): 294 | return cp['Items'][0].get('Attributes',None) 295 | else: 296 | return None -------------------------------------------------------------------------------- /PowerDialer-queueContacts/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-power-dialer/dd6706b1020937883121cf436b9e2835c624fbe9/PowerDialer-queueContacts/requirements.txt -------------------------------------------------------------------------------- /PowerDialer-setDisposition/lambda_function.py: -------------------------------------------------------------------------------- 1 | ##setDisposition Function 2 | import json 3 | import boto3 4 | import os 5 | from powerdialer import update_dial_list, save_results 6 | 7 | connect=boto3.client('connect') 8 | DIALER_DEPLOYMENT = os.environ['DIALER_DEPLOYMENT'] 9 | RESULTS_FIREHOSE_NAME = os.environ['RESULTS_FIREHOSE_NAME'] 10 | 11 | def lambda_handler(event, context): 12 | print(event) 13 | contactId = event['Details']['ContactData']['Attributes'].get('contactId',False) 14 | phone=event['Details']['ContactData'].get('CustomerEndpoint',False) 15 | 16 | results = {'CampaignStep':'CallCompleted','phone':phone,'contactId':contactId} 17 | 18 | if('Attributes' in event['Details']['ContactData'] and len(event['Details']['ContactData']['Attributes'])>0): 19 | for attkey in event['Details']['ContactData']['Attributes'].keys(): 20 | results.update({attkey:event['Details']['ContactData']['Attributes'][attkey]}) 21 | 22 | save_results(results,DIALER_DEPLOYMENT,RESULTS_FIREHOSE_NAME) 23 | 24 | return { 25 | 'Saved': True 26 | } -------------------------------------------------------------------------------- /PowerDialer-setDisposition/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-power-dialer/dd6706b1020937883121cf436b9e2835c624fbe9/PowerDialer-setDisposition/requirements.txt -------------------------------------------------------------------------------- /PowerDialer-updateProfile/lambda_function.py: -------------------------------------------------------------------------------- 1 | ##updateProfile, requires attributes {'Key':'Value'} 2 | import json 3 | import boto3 4 | import os 5 | from powerdialer import get_call_preferences,update_call_attributes 6 | from botocore.exceptions import ClientError 7 | 8 | CUSTOMER_PROFILES_DOMAIN = os.environ('CUSTOMER_PROFILES_DOMAIN') 9 | 10 | def lambda_handler(event, context): 11 | print(event) 12 | 13 | DIALER_DEPLOYMENT = os.environ['DIALER_DEPLOYMENT'] 14 | contactPhone = str(event['Details']['ContactData']['CustomerEndpoint']) 15 | attributes= event['Details']['Parameters'].get('attributes',False) 16 | if attributes: 17 | print(update_call_attributes(contactPhone,attributes,CUSTOMER_PROFILES_DOMAIN)) 18 | return { 19 | 'statusCode': 200, 20 | 'body': json.dumps('Hello from Lambda!') 21 | } -------------------------------------------------------------------------------- /PowerDialer-updateProfile/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-power-dialer/dd6706b1020937883121cf436b9e2835c624fbe9/PowerDialer-updateProfile/requirements.txt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amazon Connect Power Dialer 2 | 04/21/24: Simplified dialing mechanism based on a single Step Function. Added attribute handling from Pinpoint list. Added ClientToken on start_outbound_voice_call to remove potential duplicate calls per campaign. 3 | 4 | This project contains source code and supporting files for a serverless dialer to be used on top of an Amazon Connect instance. 5 | 6 | The basic operation of the solution is based on the principle of a Power Dialer: New calls are placed once agents complete previous calls. Since calls are placed automatically, the inefficiencies and error-prone nature of manual dialing are mitigated; yet, since calls are initiated until agents become available, you can maintain the warm nature of person to person contacts (no wait time for end users when being contacted). 7 | The general workflow for this solution is as follows: 8 | 9 | ![Workflow](/images/DialerOnConnect-Workflow.jpg "Workflow") 10 | 11 | 1. This is based on Amazon Pinpoint, segments must be based on files including: ChannelType,Address,User.UserId. Additional attributes should be mapped under User.UserAttributes.XXX. 12 | 2. A message template must be configured as part of Amazon Pinpoint. Attributes from the associated segment can be configured. 13 | 3. A campaign is launched based on the custom channel, pointint to the queueContacts Lambda function. 14 | 4. Contacts are queue on the SQS queue. The parameter concurrentCalls determines the number of simultaneous threads to initiate calling (this number should map expected agents). The dialer pulls contacts from the queue based on call concurrency. 15 | 5. Calls are launched in parallel based on the number of concurrentCalls. 16 | 6. Successful call connections are put in the configured queue. 17 | 7. Agent and contact activity are monitored so calls are initiated once Agents wrap up the active calls. 18 | 8. System parameters are included under the /connect/dialer// path. 19 | 20 | The solution leverages Amazon Connect API start outbound voice API to place calls, triggered by AWS Lambda Functions and orchestrated with Step Functions. Lambda Functions are also used to pull configuration, read contacts from the dialing list table and monitor Amazon Connect events on a Amazon Kinesis Stream (from both Contact Trace Records and Agent Events). Finally, Amazon DynamoDB and Amazon Simple Storage Service are used for dialing list, configuration parameters storage and input/output space for dialing lists and results. 21 | Roles for the Lambda Functions and Step Functions State Machines are defined in AWS Identity and Access Management to limit access to specific resources. 22 | 23 | ![Architecture](/images/DialerOnConnect-Architecture.jpg "Architecture") 24 | 25 | ## Deployed resources 26 | 27 | The project includes a cloud formation template with a Serverless Application Model (SAM) transform to deploy resources as follows: 28 | 29 | ### Amazon S3 30 | - Resultsbucket: Storage for campaign results. 31 | 32 | ### AWS Lambda functions 33 | 34 | - dial: Places calls using Amazon Connect boto3's start_outbound_voice_contact method. 35 | - getAvailAgents: Gets agents available in the associated queue. 36 | - getConfig: Gets the configuration parameters from a DynamoDB table. 37 | - getContacts: Gets contact phone numbers to dial from a DynamoDB table. 38 | - queueContacts: Loads dialing list from the Amazon Pinpoint campaign. 39 | - ProcessContactEvents: Processes Contact Events to determine when would the next call should be placed. 40 | - SetDisposition: Process agent input based on a step by step guide contact flow to categorize calls. 41 | - SaveResults: Generates a CSV file based from the dialing attempts. 42 | 43 | 44 | ### Step Functions 45 | 46 | - DialerControlSF: Provides the general structure for the dialer. Pulls initial configuration and invokes parallel dialer executions to perform the dialing process. 47 | 48 | ### DynamoDB tables 49 | - ActiveDialing: ContactIds to dialed contact relationship for contacts being dialed. 50 | 51 | ### System Manager Paramater Store Parameters 52 | Configuration information is stored as parameters in System Manager Parameter Store. The following parameters require configuration to match Connect configuration. 53 | - /connect/dialer/XXXX/connectid. Connect Instance Id (not ARN). 54 | - /connect/dialer/XXXX/contactflow. Contact Flow Id (not ARN). 55 | - /connect/dialer/XXXX/queue. Amazon Connect Outbound Queue (not ARN). 56 | 57 | - /connect/dialer/XXXX/ResultsBucket. Output bucket for results. Populated at set up time. 58 | - /connect/dialer/XXXX/activeDialer. On/Off switch for dialer opperation. Managed by State machines. 59 | - /connect/dialer/XXXX/dialIndex. index position within list. 60 | - /connect/dialer/XXXX/table-activedialing. Active numbers processing calls. 61 | - /connect/dialer/XXXX/table-dialerlist. Complete dialing list. 62 | - /connect/dialer/XXXX/totalRecords. Current number of records to be called. 63 | 64 | ### IAM roles 65 | - ControlSFRole: Dialer Control Step Functions state Machine IAM role. 66 | - ThreadSFRole: Dialer Thread Step Functions state Machine IAM role. 67 | - PowerDialerLambdaRole: Lambda Functions IAM role. 68 | 69 | 70 | ## Prerequisites. 71 | 1. Amazon Connect Instance already set up. 72 | 2. AWS Console Access with administrator account. 73 | 3. Cloud9 IDE or AWS and SAM tools installed and properly configured with administrator credentials. 74 | 4. Configured project for Amazon Pinpoint. 75 | 5. Message templates and built segments in Pinpoint. 76 | 77 | ## Deploy the solution 78 | 1. Clone this repo. 79 | 80 | `git clone https://github.com/aws-samples/amazon-connect-power-dialer` 81 | 82 | 2. Build the solution with SAM. 83 | 84 | `sam build` 85 | 86 | if you get an error message about requirements you can try using containers. 87 | 88 | `sam build -u` 89 | 90 | 91 | 3. Deploy the solution. 92 | 93 | `sam deploy -g` 94 | 95 | SAM will ask for the name of the application (use "PowerDialer" or something similar) as all resources will be grouped under it;Connect parameters, concurrent calls and targeted country (Phone number digits and the 2 letter ISO country code); Region and a confirmation prompt before deploying resources, enter y. 96 | SAM can save this information if you plan un doing changes, answer Y when prompted and accept the default environment and file name for the configuration. 97 | 98 | 99 | ## Configure Amazon Connect Agent Events and Contact Trace Records. 100 | 101 | 102 | ## Get Amazon Connect Configuration 103 | 104 | As part of the configuration, you will need the deployed Amazon Connect Instance ID (referenced as connectid on the configuration parameters of this solution), a contact flow used for Outbound calling (referenced as contactflow on the configuration parameters of this solution) and the queue ID (referenced as queue): 105 | 106 | 1. Navigate to the Amazon Connect console. 107 | 2. Click on Access URL. This will open the Amazon Connect interface. 108 | 3. From the left panel, open the routing menu (it has an arrow splitting in three as an icon). 109 | 4. Click on contact flow. Select the Default outbound contact flow. Click on "show additional flow information". 110 | 5. Copy the associated ARN. You will need 2 items from that string the instance id and the contact flow id. The instance ID is the string following the first "/" after the word instance and up to the following "/".The contact flow ID is the string separated by the "/" following "contact-flow". Make note of this 2 strings (do not copy "/"). 111 | 6. Navigate back to routing and pick queues. Select the queue you'll be using and click on show additional queue information. 112 | 7. Make note of the string separated by a "/" after the word queue on the ARN. Make sure you do not copy "/". 113 | 8. Make a test call to make sure you are able to start outbound calls. Log an agent on the Amazon CCP and have it set their status to Available. 114 | 9. From a command line terminal where you have already configured AWS Cli with administrator credentials, type: 115 | 116 | `aws connect start-outbound-voice-contact --destination-phone-number --contact-flow-id --instance-id --queue-id ` 117 | 118 | Make sure you replace the values for contactflow, phone numberm, queue and instance id. A call should be placed and put in queue. 119 | 120 | ## Configure Dialer Parameters 121 | This parameters are configured at solution deployment, modify only to change the initial configuration. 122 | 1. Navigate to the System Manager - Parameter Store console. 123 | 2. Modify the values for the following items. Note this items are case sensitive. 124 | 125 | | parameter | currentValue | 126 | |----------|:-------------:| 127 | | /connect/dialer/XXXX/connectid | Connect instance ID | 128 | | /connect/dialer/XXXX/contactflow |contactflow ID| 129 | |/connect/dialer/XXXX/queue|Id of the Connect queue to be used| 130 | 131 | ## Setting Disposition Codes 132 | As part of the deployment, a setDisposition Code function is created. This function will take contactId and attributes set on the dial phase to update the result on the dialing list table. 133 | The step by step guide contact flow (file , available on the sample files allows for a sample option to generate status and invoke the setDisposition Lambda function to tag associated contacts. 134 | 135 | ### Deploy agent guide flow. 136 | 1. From the AWS Services Console, browse to the Amazon Connect service and add the setDisposition Lambda function in Flows->Lambda. 137 | 1. In the Amazon Connect administrator interface create a new contact flow and import the View-Dialer-DispositionCodes sample file. Validate all boxes are configured correctly, save and publish the flow. 138 | 1. Make a note of the contact flow id on the ARN for this contact flow. 139 | 1. Add a Set Contact Attributes block on the ContactFlow used for outbound calls, specify a user defined parameter for DefaultFlowForAgentUI and specify the contact flow id from the previous step. 140 | 141 | ## Campaign scheduling 142 | As an alternative orchestration mechanism, an Eventbridge rule is created. 143 | An EventBridge rule is created as part of the deployment in the disabled state. From Eventbridge console browse to rules and select the CampaignLaunchSchedule rule. 144 | 145 | 1. Click on Edit. 146 | 2. Specify the required launch times for this campaign. 147 | 3. Keep the selected target DialerControlSF-XXXX. 148 | 4. Save changes and make sure to change the status of the rule to enabled. 149 | 150 | ## Operation 151 | The solution relies on Step Functions as the main orchestation point and Lambda Functions as the processing units. To start a campaign, launch a campaign on Amazon Pinpoint with a custom channel and the queueContacts lambda function as target. The solution will create queue contacts and launch the dialer control state machine. 152 | 153 | ### Loading a dialing list. 154 | Alternatively to Pinpoint, to load a list, simply upload a CSV file (example is provided on file [sample-file](/sample-files/sample-load.csv "sample-file") ) to the input bucket. An automated event will trigger the processing Lambda Function to upload the records. Be aware large files might consume a lot of time and might timeout on the Labda Function. 155 | 156 | 157 | #### Uploading the file 158 | 1. Generate a CSV file with the same structure as the example. 159 | 3. Go to cloudformation and select the stack created for this application (it will have the same name as you specified as application name). 160 | 4. Browse to the resources tab. 161 | 5. Click on the link for the iobucket name, a new window will open showing the bucket. 162 | 6. Click on the upload file and uplaod the file you created. 163 | 164 | ### Launching a dialing job. 165 | The process to start the dialing job is through the Power Dialer Control Step Function, initiate an execution to launch the dialing job or by means of scheduling recurring campaign. 166 | 167 | 1. Navigate to the StepFunctions console. 168 | 2. Pick the Control Step Machine, it should have a name similar to: "DialerControlSF-XXXXXXXX". 169 | 3. Click Start Execution. A "Start Execution Window" will open, click Start Execution once again. This will start the dialing job, placing calls and adding them to the queue. Once completed, a report will be downloaded from the DynamoDB table to the s3 iobucket. 170 | 171 | ### Stopping a dialing job 172 | To stop the dialing job gracefully, got to Systems Manager Parameter Store and change the parameter /connect/dialer/XXXX/activeDialer to False. 173 | 174 | ### Initiating a new dialing job 175 | The dialing process marks each contact attempt on the dialing table as the way to keep track on the process, by the end of the dialing process all contacts on the table are marked as "attempted".Contacts need to be repopulated (by loading a new file or marking the specific contacts callAttempt parameter as False). 176 | 177 | ### About the inner workings. 178 | 1. The state machine will iterate over the dialing list, pulling contacts as agents become available. Bear in mind the Agent event stream processing function expects agents to become available before placing a new call. Also, it takes a couple of seconds once the agent sets its status to "Available" before the event is published. 179 | 2. The StepFunctions machine will invoke dialing workers based on the number of agents in Available State by the time the process starts. This dialing workers fetch contacts from the dialing list, place calls and wait for agents status changes to iterate over a new contact. There's a relationship of 1:1 of workers to the number of agents. 180 | 3. Once a dialing worker reaches the end of the list, the activeDialer paramter is set to false in the dialer configuration table. This stops all subsequent fetching attempt and finalized the dialing process. You can manually set the activeDialer attribute to false to stop the dialing process. 181 | 182 | ## Resource deletion 183 | 1. Remove any folders from the iobucket. You can browse to the S3 bucket (click on CloudFormation iobucket link on the resources tab for this stack), select all objects and click on delete. 184 | 2. Back on the cloudformation console, select the stack and click on Delete and confirm it by pressing Delete Stack. 185 | -------------------------------------------------------------------------------- /images/DialerOnConnect-Architecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-power-dialer/dd6706b1020937883121cf436b9e2835c624fbe9/images/DialerOnConnect-Architecture.jpg -------------------------------------------------------------------------------- /images/DialerOnConnect-Workflow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-power-dialer/dd6706b1020937883121cf436b9e2835c624fbe9/images/DialerOnConnect-Workflow.jpg -------------------------------------------------------------------------------- /sample-files/View-Dialer-DispositionCodes: -------------------------------------------------------------------------------- 1 | {"Version":"2019-10-30","StartAction":"288442fc-00c5-469a-bdd0-ff9f955d8963","Metadata":{"entryPointPosition":{"x":40,"y":40},"ActionMetadata":{"590553fb-1da5-49a3-869e-6f9ae41edb07":{"position":{"x":1148,"y":161.6},"parameters":{"LambdaFunctionARN":{"displayName":"power-dialer-sb-SetDispositionCode-OlUhjKcktIGr"}},"dynamicMetadata":{}},"288442fc-00c5-469a-bdd0-ff9f955d8963":{"position":{"x":137.6,"y":107.2}},"24d443fb-b5e0-49f2-9020-76f6f9d42f31":{"position":{"x":520.8,"y":461.6}},"a7e61ad6-8ba5-482b-b5a4-2824857b5e01":{"position":{"x":1420.8,"y":188},"parameters":{"ViewResource":{"Id":{"displayName":"Confirmation"}},"InvocationTimeLimitSeconds":{"unit":1},"ViewData":{"Next":{"useJson":true}}}},"0f20dfb0-07fa-400d-a7f0-615de6110c07":{"position":{"x":1815.2,"y":464.8}},"44e047b8-b24d-4878-835f-46ef05e92bab":{"position":{"x":898.4,"y":175.2},"dynamicParams":[]},"6480b731-daa3-4fec-9508-4ac50b0b057b":{"position":{"x":521.6,"y":144.8},"parameters":{"ViewResource":{"Id":{"displayName":"List"}},"InvocationTimeLimitSeconds":{"unit":60},"ViewData":{"AttributeBar":{"useJson":true},"Items":{"useJson":true}}}}},"name":"View-Dialer-DispositionCodes","description":"","type":"contactFlow","status":"published","hash":{}},"Actions":[{"Parameters":{"LambdaFunctionARN":"arn:aws:lambda:us-west-2:758580162203:function:power-dialer-sb-SetDispositionCode-OlUhjKcktIGr","InvocationTimeLimitSeconds":"3","ResponseValidation":{"ResponseType":"STRING_MAP"}},"Identifier":"590553fb-1da5-49a3-869e-6f9ae41edb07","Type":"InvokeLambdaFunction","Transitions":{"NextAction":"a7e61ad6-8ba5-482b-b5a4-2824857b5e01","Errors":[{"NextAction":"0f20dfb0-07fa-400d-a7f0-615de6110c07","ErrorType":"NoMatchingError"}]}},{"Parameters":{"FlowLoggingBehavior":"Enabled"},"Identifier":"288442fc-00c5-469a-bdd0-ff9f955d8963","Type":"UpdateFlowLoggingBehavior","Transitions":{"NextAction":"6480b731-daa3-4fec-9508-4ac50b0b057b"}},{"Parameters":{"LoopCount":"10"},"Identifier":"24d443fb-b5e0-49f2-9020-76f6f9d42f31","Type":"Loop","Transitions":{"NextAction":"0f20dfb0-07fa-400d-a7f0-615de6110c07","Conditions":[{"NextAction":"6480b731-daa3-4fec-9508-4ac50b0b057b","Condition":{"Operator":"Equals","Operands":["ContinueLooping"]}},{"NextAction":"0f20dfb0-07fa-400d-a7f0-615de6110c07","Condition":{"Operator":"Equals","Operands":["DoneLooping"]}}]}},{"Parameters":{"ViewResource":{"Id":"confirmation","Version":"Latest"},"InvocationTimeLimitSeconds":"30","ViewData":{"Heading":"Llamada tipificada","Next":{"Label":"Cerrar"},"SubHeading":"Este contacto ha sido categorizado correctamente."}},"Identifier":"a7e61ad6-8ba5-482b-b5a4-2824857b5e01","Type":"ShowView","Transitions":{"NextAction":"0f20dfb0-07fa-400d-a7f0-615de6110c07","Conditions":[{"NextAction":"0f20dfb0-07fa-400d-a7f0-615de6110c07","Condition":{"Operator":"Equals","Operands":["Next"]}}],"Errors":[{"NextAction":"0f20dfb0-07fa-400d-a7f0-615de6110c07","ErrorType":"NoMatchingCondition"},{"NextAction":"0f20dfb0-07fa-400d-a7f0-615de6110c07","ErrorType":"NoMatchingError"},{"NextAction":"6480b731-daa3-4fec-9508-4ac50b0b057b","ErrorType":"TimeLimitExceeded"}]}},{"Parameters":{},"Identifier":"0f20dfb0-07fa-400d-a7f0-615de6110c07","Type":"DisconnectParticipant","Transitions":{}},{"Parameters":{"Attributes":{"dispositionCode":"$.Views.ViewResultData.Id"}},"Identifier":"44e047b8-b24d-4878-835f-46ef05e92bab","Type":"UpdateContactAttributes","Transitions":{"NextAction":"590553fb-1da5-49a3-869e-6f9ae41edb07","Errors":[{"NextAction":"0f20dfb0-07fa-400d-a7f0-615de6110c07","ErrorType":"NoMatchingError"}]}},{"Parameters":{"ViewResource":{"Id":"list","Version":"Latest"},"InvocationTimeLimitSeconds":"600","ViewData":{"AttributeBar":[{"Label":"Full Name","Value":"$.Attributes.name"},{"Label":"Account Number","Value":"$.Attributes.userid","Copyable":true},{"Label":"Contact Reason","Value":"$.Attributes.reason"}],"Heading":"\"¿Cuál fue el resultado de esta llamada?\"","Items":[{"Heading":"Contacto no exitoso","Description":"Llamada no conecta con usuario","Id":"no-exitoso","Icon":"Credit Card Front"},{"Heading":"Usuario ofrece fecha de pago","Description":"Usuario se compromete a pagar.","Id":"pago-comprometido","Icon":"Crane"},{"Heading":"Sin fecha compromiso","Description":"El usuario no se compromete a pagar.","Id":"pago-no-comprometido","Icon":"Dollar Sign"},{"Heading":"Numero equivocado","Description":"El teléfono ya no pertenece al usuario.","Id":"telefono-desactualizado","Icon":"Bulldozer"}]}},"Identifier":"6480b731-daa3-4fec-9508-4ac50b0b057b","Type":"ShowView","Transitions":{"NextAction":"0f20dfb0-07fa-400d-a7f0-615de6110c07","Conditions":[{"NextAction":"44e047b8-b24d-4878-835f-46ef05e92bab","Condition":{"Operator":"Equals","Operands":["ActionSelected"]}},{"NextAction":"24d443fb-b5e0-49f2-9020-76f6f9d42f31","Condition":{"Operator":"Equals","Operands":["Back"]}}],"Errors":[{"NextAction":"0f20dfb0-07fa-400d-a7f0-615de6110c07","ErrorType":"NoMatchingCondition"},{"NextAction":"0f20dfb0-07fa-400d-a7f0-615de6110c07","ErrorType":"NoMatchingError"},{"NextAction":"0f20dfb0-07fa-400d-a7f0-615de6110c07","ErrorType":"TimeLimitExceeded"}]}}]} -------------------------------------------------------------------------------- /sample-files/sample-load.csv: -------------------------------------------------------------------------------- 1 | custID,phone 2 | A0001,+15550100 3 | A0001,+15550101 4 | A0002,+15550102 5 | A0003,+15550103 6 | A0004,+15550104 7 | A0005,+15550105 -------------------------------------------------------------------------------- /statemachine/PowerDialer-ListLoad.asl.json: -------------------------------------------------------------------------------- 1 | { 2 | "Comment": "Load CSV file from S3", 3 | "StartAt": "ContactQueuing", 4 | "States": { 5 | "ContactQueuing": { 6 | "Type": "Map", 7 | "ItemProcessor": { 8 | "ProcessorConfig": { 9 | "Mode": "DISTRIBUTED", 10 | "ExecutionType": "EXPRESS" 11 | }, 12 | "StartAt": "Validate-QueueContacts", 13 | "States": { 14 | "Validate-QueueContacts": { 15 | "Type": "Task", 16 | "Resource": "arn:aws:states:::lambda:invoke", 17 | "OutputPath": "$.Payload", 18 | "Parameters": { 19 | "FunctionName": "${PowerDialers3ListLoadArn}", 20 | "Payload.$": "$" 21 | }, 22 | "Retry": [ 23 | { 24 | "ErrorEquals": [ 25 | "Lambda.ServiceException", 26 | "Lambda.AWSLambdaException", 27 | "Lambda.SdkClientException", 28 | "Lambda.TooManyRequestsException" 29 | ], 30 | "IntervalSeconds": 2, 31 | "MaxAttempts": 6, 32 | "BackoffRate": 2 33 | } 34 | ], 35 | "End": true 36 | } 37 | } 38 | }, 39 | "ItemReader": { 40 | "Resource": "arn:aws:states:::s3:getObject", 41 | "ReaderConfig": { 42 | "InputType": "CSV", 43 | "CSVHeaderLocation": "FIRST_ROW" 44 | }, 45 | "Parameters": { 46 | "Bucket.$": "$.bucket", 47 | "Key.$": "$.filename" 48 | } 49 | }, 50 | "MaxConcurrency": 1000, 51 | "Label": "ContactQueuing", 52 | "End": true, 53 | "ItemBatcher": { 54 | "MaxItemsPerBatch": 10, 55 | "BatchInput": { 56 | "bucket.$": "$.filename" 57 | } 58 | } 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /statemachine/PowerDialer-control.asl.json: -------------------------------------------------------------------------------- 1 | { 2 | "Comment": "Dialer controller", 3 | "StartAt": "ActivateDialer", 4 | "States": { 5 | "ActivateDialer": { 6 | "Comment": "Activate dialer", 7 | "Type": "Task", 8 | "Resource": "arn:aws:states:::aws-sdk:ssm:putParameter", 9 | "Parameters": { 10 | "Name": "${ParameterDialerStatus}", 11 | "Overwrite": true, 12 | "Value": "True" 13 | }, 14 | "Next": "GetConfig", 15 | "ResultPath": null 16 | }, 17 | "GetConfig": { 18 | "Comment": "Get configuration parameters from Parameters", 19 | "Type": "Task", 20 | "Resource": "arn:aws:states:::lambda:invoke", 21 | "Catch": [ 22 | { 23 | "ErrorEquals": [ 24 | "States.ALL" 25 | ], 26 | "Next": "deactivateDialerDueError" 27 | } 28 | ], 29 | "Parameters": { 30 | "FunctionName": "${PowerDialergetConfigArn}", 31 | "Payload": { 32 | "Input.$": "$" 33 | } 34 | }, 35 | "Next": "Dialer-Monitor", 36 | "ResultPath": "$.params", 37 | "ResultSelector": { 38 | "totalRecords.$": "$.Payload.totalRecords", 39 | "table-activedialing.$": "$.Payload.table-activedialing", 40 | "contactflow.$": "$.Payload.contactflow", 41 | "connectid.$": "$.Payload.connectid", 42 | "queue.$": "$.Payload.queue", 43 | "concurrentCalls.$": "$.Payload.concurrentCalls", 44 | "dialerThreads.$": "$.Payload.dialerThreads", 45 | "timeOut.$": "$.Payload.timeOut" 46 | } 47 | }, 48 | "Dialer-Monitor": { 49 | "Type": "Parallel", 50 | "Next": "GetConcurrencyChange", 51 | "Branches": [ 52 | { 53 | "StartAt": "Dialer", 54 | "States": { 55 | "Dialer": { 56 | "Type": "Map", 57 | "Catch": [ 58 | { 59 | "ErrorEquals": [ 60 | "States.ALL" 61 | ], 62 | "Next": "deactivateDialer-MapError" 63 | } 64 | ], 65 | "ResultPath": null, 66 | "ItemsPath": "$.params.dialerThreads", 67 | "InputPath": "$", 68 | "Parameters": { 69 | "params.$": "$.params" 70 | }, 71 | "Iterator": { 72 | "StartAt": "GetDialerStatus", 73 | "States": { 74 | "GetDialerStatus": { 75 | "Type": "Task", 76 | "Parameters": { 77 | "Name": "${ParameterDialerStatus}" 78 | }, 79 | "Resource": "arn:aws:states:::aws-sdk:ssm:getParameter", 80 | "Next": "isDialerActive", 81 | "ResultSelector": { 82 | "value.$": "$.Parameter.Value" 83 | }, 84 | "ResultPath": "$.params.activeDialer" 85 | }, 86 | "getContacts": { 87 | "Type": "Task", 88 | "Resource": "arn:aws:states:::lambda:invoke", 89 | "Parameters": { 90 | "Payload": { 91 | "params.$": "$.params", 92 | "availAgents": 1 93 | }, 94 | "FunctionName": "${PowerDialergetContactsArn}" 95 | }, 96 | "ResultSelector": { 97 | "entries.$": "$.Payload.contacts", 98 | "EndOfList.$": "$.Payload.EndOfList" 99 | }, 100 | "ResultPath": "$.contacts", 101 | "Next": "isListEmpty", 102 | "Catch": [ 103 | { 104 | "ErrorEquals": [ 105 | "States.ALL" 106 | ], 107 | "Next": "Fail" 108 | } 109 | ] 110 | }, 111 | "isListEmpty": { 112 | "Type": "Choice", 113 | "Choices": [ 114 | { 115 | "Variable": "$.contacts.EndOfList", 116 | "StringEquals": "False", 117 | "Next": "Dial" 118 | } 119 | ], 120 | "Default": "Success" 121 | }, 122 | "Fail": { 123 | "Type": "Fail" 124 | }, 125 | "Dial": { 126 | "Type": "Task", 127 | "Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken", 128 | "Parameters": { 129 | "FunctionName": "${PowerDialerdialArn}", 130 | "Payload": { 131 | "params.$": "$.params", 132 | "contacts.$": "$.contacts.entries[0]", 133 | "TaskToken.$": "$$.Task.Token" 134 | } 135 | }, 136 | "Next": "GetDialerStatus", 137 | "TimeoutSeconds": 1800, 138 | "ResultPath": null, 139 | "Catch": [ 140 | { 141 | "ErrorEquals": [ 142 | "States.Timeout" 143 | ], 144 | "Comment": "TimeOut", 145 | "Next": "GetDialerStatus", 146 | "ResultPath": null 147 | }, 148 | { 149 | "ErrorEquals": [ 150 | "States.ALL" 151 | ], 152 | "Next": "Fail", 153 | "ResultPath": null 154 | } 155 | ] 156 | }, 157 | "isDialerActive": { 158 | "Type": "Choice", 159 | "Choices": [ 160 | { 161 | "And": [ 162 | { 163 | "Variable": "$.params.activeDialer.value", 164 | "StringEquals": "True" 165 | } 166 | ], 167 | "Next": "getContacts" 168 | } 169 | ], 170 | "Default": "Success" 171 | }, 172 | "Success": { 173 | "Type": "Succeed" 174 | } 175 | }, 176 | "ProcessorConfig": { 177 | "Mode": "DISTRIBUTED", 178 | "ExecutionType": "STANDARD" 179 | } 180 | }, 181 | "Label": "Dialer", 182 | "MaxConcurrency": 1000, 183 | "ToleratedFailurePercentage": 50, 184 | "Next": "deactivateDialer" 185 | }, 186 | "deactivateDialer": { 187 | "Comment": "Deactivate dialer", 188 | "Type": "Task", 189 | "Resource": "arn:aws:states:::aws-sdk:ssm:putParameter", 190 | "Parameters": { 191 | "Name": "${ParameterDialerStatus}", 192 | "Overwrite":true, 193 | "Value": "False" 194 | }, 195 | "End": true 196 | }, 197 | "deactivateDialer-MapError": { 198 | "Comment": "Deactivate dialer due a problem", 199 | "Type": "Task", 200 | "Resource": "arn:aws:states:::aws-sdk:ssm:putParameter", 201 | "Parameters": { 202 | "Name": "${ParameterDialerStatus}", 203 | "Overwrite":true, 204 | "Value": "False" 205 | }, 206 | "End": true 207 | } 208 | } 209 | }, 210 | { 211 | "StartAt": "GetDialerStatus-Monitor", 212 | "States": { 213 | "GetDialerStatus-Monitor": { 214 | "Type": "Task", 215 | "Parameters": { 216 | "Name": "${ParameterDialerStatus}" 217 | }, 218 | "Resource": "arn:aws:states:::aws-sdk:ssm:getParameter", 219 | "ResultSelector": { 220 | "value.$": "$.Parameter.Value" 221 | }, 222 | "ResultPath": "$.params.activeDialer", 223 | "Next": "GetConnectStatus" 224 | }, 225 | "GetConnectStatus": { 226 | "Type": "Task", 227 | "Resource": "arn:aws:states:::lambda:invoke", 228 | "Parameters": { 229 | "Payload": { 230 | "params.$": "$.params" 231 | }, 232 | "FunctionName": "${PowerDialergetConnectStatusArn}" 233 | }, 234 | "Retry": [ 235 | { 236 | "ErrorEquals": [ 237 | "Lambda.ServiceException", 238 | "Lambda.AWSLambdaException", 239 | "Lambda.SdkClientException", 240 | "Lambda.TooManyRequestsException" 241 | ], 242 | "IntervalSeconds": 1, 243 | "MaxAttempts": 3, 244 | "BackoffRate": 2 245 | } 246 | ], 247 | "ResultPath": "$.connect", 248 | "ResultSelector": { 249 | "queueEnabled.$": "$.Payload.queueEnabled", 250 | "workingHours.$": "$.Payload.workingHours", 251 | "availableAgents.$": "$.Payload.availableAgents" 252 | }, 253 | "Catch": [ 254 | { 255 | "ErrorEquals": ["States.ALL"], 256 | "Next": "ThreadDeactivateDialer" 257 | } 258 | ], 259 | "Next": "isDialerActive-Monitor" 260 | }, 261 | "isDialerActive-Monitor": { 262 | "Type": "Choice", 263 | "Choices": [ 264 | { 265 | "And": [ 266 | { 267 | "Variable": "$.params.activeDialer.value", 268 | "StringEquals": "True" 269 | }, 270 | { 271 | "Variable": "$.connect.queueEnabled", 272 | "StringEquals": "True" 273 | }, 274 | { 275 | "Variable": "$.connect.workingHours", 276 | "StringEquals": "True" 277 | } 278 | ], 279 | "Next": "concurrentAgentsChanged" 280 | } 281 | ], 282 | "Default": "ThreadDeactivateDialer" 283 | }, 284 | "setConcurrencyChange": { 285 | "Type": "Task", 286 | "Parameters": { 287 | "Name": "${ParameterConcurrencyChange}", 288 | "Overwrite": true, 289 | "Value": "True" 290 | }, 291 | "Resource": "arn:aws:states:::aws-sdk:ssm:putParameter", 292 | "Comment": "Set concurrencyChange parameter to true to adjust ", 293 | "Next": "ThreadDeactivateDialer" 294 | }, 295 | "ThreadDeactivateDialer": { 296 | "Type": "Task", 297 | "Parameters": { 298 | "Name": "${ParameterDialerStatus}", 299 | "Overwrite": true, 300 | "Value": "False" 301 | }, 302 | "Resource": "arn:aws:states:::aws-sdk:ssm:putParameter", 303 | "Comment": "Set activeDialer parameter to false to finish dialing process.", 304 | "End": true 305 | }, 306 | "concurrentAgentsChanged": { 307 | "Type": "Choice", 308 | "Choices": [ 309 | { 310 | "Variable": "$.connect.availableAgents", 311 | "NumericEqualsPath": "$.params.concurrentCalls", 312 | "Next": "Wait" 313 | } 314 | ], 315 | "Default": "setConcurrencyChange" 316 | }, 317 | "Wait": { 318 | "Type": "Wait", 319 | "Seconds": 60, 320 | "Next": "GetDialerStatus-Monitor" 321 | } 322 | } 323 | } 324 | ], 325 | "ResultPath": null 326 | }, 327 | "GetConcurrencyChange": { 328 | "Type": "Task", 329 | "Parameters": { 330 | "Name": "${ParameterConcurrencyChange}" 331 | }, 332 | "Resource": "arn:aws:states:::aws-sdk:ssm:getParameter", 333 | "ResultSelector": { 334 | "value.$": "$.Parameter.Value" 335 | }, 336 | "ResultPath": "$.params.concurrencyChange", 337 | "Next": "Choice" 338 | }, 339 | "Choice": { 340 | "Type": "Choice", 341 | "Choices": [ 342 | { 343 | "Variable": "$.params.concurrencyChange.value", 344 | "StringEquals": "True", 345 | "Next": "clearConcurrencyChange" 346 | } 347 | ], 348 | "Default": "dialerFinished" 349 | }, 350 | "clearConcurrencyChange": { 351 | "Type": "Task", 352 | "Parameters": { 353 | "Name": "${ParameterConcurrencyChange}", 354 | "Overwrite": true, 355 | "Value": "False" 356 | }, 357 | "Resource": "arn:aws:states:::aws-sdk:ssm:putParameter", 358 | "Comment": "Set concurrencyChange parameter to true to adjust ", 359 | "Next": "ActivateDialer" 360 | }, 361 | "deactivateDialerDueError": { 362 | "Comment": "Deactivate dialer due a problem", 363 | "Type": "Task", 364 | "Resource": "arn:aws:states:::aws-sdk:ssm:putParameter", 365 | "Parameters": { 366 | "Name": "${ParameterDialerStatus}", 367 | "Overwrite": true, 368 | "Value": "False" 369 | }, 370 | "Next": "dialerError" 371 | }, 372 | "dialerFinished": { 373 | "Type": "Succeed" 374 | }, 375 | "dialerError": { 376 | "Type": "Fail" 377 | } 378 | } 379 | } -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: > 4 | Power Dialer for Amazon Connect Application. 5 | 6 | Globals: 7 | Function: 8 | Timeout: 60 9 | MemorySize: 128 10 | Runtime: python3.9 11 | 12 | Parameters: 13 | ConnectInstanceId: 14 | Type: String 15 | AllowedPattern: "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" 16 | Description: Amazon Connect Instance ID to use for the outbound call 17 | ConnectContactFlowId: 18 | Type: String 19 | AllowedPattern: "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" 20 | Description: Amazon Connect Contact Flow ID to use for the outbound call 21 | ConnectQueueId: 22 | Type: String 23 | AllowedPattern: "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" 24 | Description: Amazon Connect Queue ID to use for the outbound call 25 | ValidateProfile: 26 | Type: String 27 | Default: 'True' 28 | AllowedValues: ["True", "False"] 29 | Description: Determines whether the function will validate the profile exists or not. 30 | CustomerProfilesDomainName: 31 | Type: String 32 | Description: Amazon Connect Customer Profiles Domain Name 33 | CountryCode: 34 | Type: Number 35 | Default: 52 36 | Description: Country code of destination country 37 | ISOCountryCode: 38 | Type: String 39 | Default: MX 40 | MaxLength: 2 41 | Description: 2 letter code for country code 42 | ConcurrentCalls: 43 | Type: Number 44 | Default: 1 45 | Description: Number of calls to be called simultaneously. 46 | CallTimeOut: 47 | Type: Number 48 | Default: 3600 49 | Description: Timeout in seconds for each call 50 | NoCallStatusList: 51 | Type: CommaDelimitedList 52 | Default: Sin interes,No llamar,Renovación previa 53 | Description: Status list for which no call will be made 54 | 55 | Resources: 56 | dialinglistbucket: 57 | Type: AWS::S3::Bucket 58 | Properties: 59 | BucketName: !Join ['-', [!Ref AWS::StackName,'input-bucket', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 60 | NotificationConfiguration: 61 | EventBridgeConfiguration: 62 | EventBridgeEnabled: True 63 | DeletionPolicy: Delete 64 | 65 | 66 | resultsbucket: 67 | Type: AWS::S3::Bucket 68 | Properties: 69 | BucketName: !Join ['-', [!Ref AWS::StackName,'output-bucket', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 70 | DeletionPolicy: Delete 71 | 72 | ActiveDialing: 73 | Type: AWS::DynamoDB::Table 74 | Properties: 75 | AttributeDefinitions: 76 | - 77 | AttributeName: "contactId" 78 | AttributeType: "S" 79 | 80 | KeySchema: 81 | - 82 | AttributeName: "contactId" 83 | KeyType: "HASH" 84 | 85 | BillingMode: "PAY_PER_REQUEST" 86 | 87 | PointInTimeRecoverySpecification: 88 | PointInTimeRecoveryEnabled: True 89 | SSESpecification: 90 | SSEEnabled: True 91 | TimeToLiveSpecification: 92 | AttributeName: "TimeToLive" 93 | Enabled: True 94 | 95 | ControlSFRole: 96 | Type: AWS::IAM::Role 97 | Properties: 98 | AssumeRolePolicyDocument: 99 | Version: 2012-10-17 100 | Statement: 101 | - 102 | Effect: Allow 103 | Principal: 104 | Service: 105 | - states.amazonaws.com 106 | Action: sts:AssumeRole 107 | Policies: 108 | - 109 | PolicyName: LogAccess 110 | PolicyDocument: 111 | Version: 2012-10-17 112 | Statement: 113 | - 114 | Effect: Allow 115 | Action: 116 | - 'logs:CreateLogGroup' 117 | - 'logs:CreateLogDelivery' 118 | - 'logs:CreateLogStream' 119 | - 'logs:PutLogEvents' 120 | - 'logs:GetLogEvents' 121 | Resource: 122 | - '*' 123 | 124 | - 125 | PolicyName: ConfigTableAccess 126 | PolicyDocument: 127 | Version: 2012-10-17 128 | Statement: 129 | - 130 | Effect: Allow 131 | Action: 132 | - 'dynamodb:PutItem' 133 | - 'dynamodb:DeleteItem' 134 | - 'dynamodb:GetItem' 135 | - 'dynamodb:Scan' 136 | - 'dynamodb:Query' 137 | - 'dynamodb:UpdateItem' 138 | Resource: 139 | - !GetAtt ActiveDialing.Arn 140 | - 141 | PolicyName: ControlStateMachine 142 | PolicyDocument: 143 | Version: 2012-10-17 144 | Statement: 145 | - 146 | Effect: Allow 147 | Action: 148 | - states:DescribeExecution 149 | - states:StartExecution 150 | - states:StopExecution 151 | Resource: '*' 152 | - 153 | Effect: Allow 154 | Action: 155 | - events:PutTargets 156 | - events:PutRule 157 | - events:DescribeRule 158 | Resource: !Sub arn:aws:events:${AWS::Region}:${AWS::AccountId}:rule/StepFunctionsGetEventsForStepFunctionsExecutionRule 159 | - 160 | Effect: Allow 161 | Action: lambda:InvokeFunction 162 | Resource: 163 | - !GetAtt dial.Arn 164 | - !GetAtt getAvailAgents.Arn 165 | - !GetAtt getConfig.Arn 166 | - !GetAtt getContacts.Arn 167 | - !GetAtt getConnectStatus.Arn 168 | - !Sub 169 | - 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${listLoadName}' 170 | - listLoadName: !Join ['-', [!Ref AWS::StackName,'listload' ,!Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 171 | - 172 | PolicyName: ParameterAccess 173 | PolicyDocument: 174 | Version: 2012-10-17 175 | Statement: 176 | - 177 | Effect: Allow 178 | Action: 179 | - 'ssm:GetParametersByPath' 180 | - 'ssm:GetParameter' 181 | - 'ssm:PutParameter' 182 | Resource: 183 | - '*' 184 | - 185 | PolicyName: BucketAccess 186 | PolicyDocument: 187 | Version: 2012-10-17 188 | Statement: 189 | - 190 | Effect: Allow 191 | Action: 192 | - 's3:PutObject' 193 | - 's3:GetObject' 194 | - 's3:DeleteObject' 195 | - 's3:ListBucket' 196 | Resource: 197 | - !Sub 198 | - 'arn:aws:s3:::${dialingListBucket}' 199 | - dialingListBucket: !Join ['-', [!Ref AWS::StackName,'input-bucket', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 200 | - !Sub 201 | - 'arn:aws:s3:::${dialingListBucket}/*' 202 | - dialingListBucket: !Join ['-', [!Ref AWS::StackName,'input-bucket', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 203 | PowerDialerListLoadEventBridgeRole: 204 | Type: AWS::IAM::Role 205 | Properties: 206 | AssumeRolePolicyDocument: 207 | Version: 2012-10-17 208 | Statement: 209 | - 210 | Effect: Allow 211 | Principal: 212 | Service: 213 | - events.amazonaws.com 214 | Action: sts:AssumeRole 215 | Policies: 216 | - 217 | PolicyName: ControlStateMachine 218 | PolicyDocument: 219 | Version: 2012-10-17 220 | Statement: 221 | - 222 | Effect: Allow 223 | Action: 224 | - states:DescribeExecution 225 | - states:StartExecution 226 | - states:StopExecution 227 | - states:ListExecutions 228 | Resource: 229 | - !Sub 230 | - 'arn:aws:states:${AWS::Region}:${AWS::AccountId}:stateMachine:${ListLoadSFArn}' 231 | - ListLoadSFArn: !Join ['-', [!Ref AWS::StackName,'ListLoadSF', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 232 | 233 | 234 | 235 | PowerDialerLambdaRole: 236 | Type: AWS::IAM::Role 237 | Properties: 238 | AssumeRolePolicyDocument: 239 | Version: 2012-10-17 240 | Statement: 241 | - 242 | Effect: Allow 243 | Principal: 244 | Service: 245 | - lambda.amazonaws.com 246 | Action: sts:AssumeRole 247 | ManagedPolicyArns: 248 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 249 | - arn:aws:iam::aws:policy/service-role/AWSLambdaKinesisExecutionRole 250 | Policies: 251 | - 252 | PolicyName: BucketAccess 253 | PolicyDocument: 254 | Version: 2012-10-17 255 | Statement: 256 | - 257 | Effect: Allow 258 | Action: 259 | - 's3:PutObject' 260 | - 's3:GetObject' 261 | - 's3:DeleteObject' 262 | - 's3:ListBucket' 263 | Resource: 264 | - !Sub 265 | - 'arn:aws:s3:::${dialingListBucket}' 266 | - dialingListBucket: !Join ['-', [!Ref AWS::StackName,'input-bucket', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 267 | - !Sub 268 | - 'arn:aws:s3:::${dialingListBucket}/*' 269 | - dialingListBucket: !Join ['-', [!Ref AWS::StackName,'input-bucket', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 270 | - !Sub 271 | - 'arn:aws:s3:::${resultsbucket}' 272 | - dialingListBucket: !Join ['-', [!Ref AWS::StackName,'output-bucket', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 273 | - !Sub 274 | - 'arn:aws:s3:::${resultsbucket}/*' 275 | - dialingListBucket: !Join ['-', [!Ref AWS::StackName,'output-bucket', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 276 | 277 | - 278 | PolicyName: ControlStateMachine 279 | PolicyDocument: 280 | Version: 2012-10-17 281 | Statement: 282 | - 283 | Effect: Allow 284 | Action: 285 | - states:DescribeExecution 286 | - states:StartExecution 287 | - states:StopExecution 288 | - states:ListExecutions 289 | Resource: 290 | - !Sub 291 | - 'arn:aws:states:${AWS::Region}:${AWS::AccountId}:stateMachine:${DialerControlName}' 292 | - DialerControlName: !Join ['-', [!Ref AWS::StackName,'ControlSF', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 293 | 294 | - 295 | PolicyName: ConfigTableAccess 296 | PolicyDocument: 297 | Version: 2012-10-17 298 | Statement: 299 | - 300 | Effect: Allow 301 | Action: 302 | - 'dynamodb:PutItem' 303 | - 'dynamodb:DeleteItem' 304 | - 'dynamodb:GetItem' 305 | - 'dynamodb:Scan' 306 | - 'dynamodb:Query' 307 | - 'dynamodb:UpdateItem' 308 | Resource: 309 | - !GetAtt ActiveDialing.Arn 310 | 311 | - 312 | PolicyName: SendTaskToken 313 | PolicyDocument: 314 | Version: 2012-10-17 315 | Statement: 316 | - 317 | Effect: Allow 318 | Action: 319 | - 'states:SendTaskSuccess' 320 | - 'states:SendTaskFailure' 321 | - 'states:SendTaskHeartbeat' 322 | Resource: 323 | - '*' 324 | - 325 | PolicyName: ConnectPermissions 326 | PolicyDocument: 327 | Version: 2012-10-17 328 | Statement: 329 | - 330 | Effect: Allow 331 | Action: 332 | - 'connect:StartOutboundVoiceContact' 333 | - 'connect:DescribeHoursOfOperation' 334 | - 'connect:GetCurrentMetricData' 335 | - 'connect:DescribeQueue' 336 | - 'connect:CreateProfile' 337 | - 'profile:DeleteProfile' 338 | - 'profile:UpdateProfile' 339 | - 'profile:SearchProfiles' 340 | Resource: 341 | - '*' 342 | - 343 | PolicyName: ParameterAccess 344 | PolicyDocument: 345 | Version: 2012-10-17 346 | Statement: 347 | - 348 | Effect: Allow 349 | Action: 350 | - 'ssm:GetParametersByPath' 351 | - 'ssm:GetParameter' 352 | - 'ssm:PutParameter' 353 | Resource: 354 | - '*' 355 | - 356 | PolicyName: TableInitialize 357 | PolicyDocument: 358 | Version: 2012-10-17 359 | Statement: 360 | - 361 | Effect: Allow 362 | Action: 363 | - 'lambda:AddPermission' 364 | - 'lambda:RemovePermission' 365 | - 'events:PutRule' 366 | - 'events:DeleteRule' 367 | - 'events:PutTargets' 368 | - 'events:RemoveTargets' 369 | Resource: 370 | - '*' 371 | - 372 | PolicyName: EncryptionAcess 373 | PolicyDocument: 374 | Version: 2012-10-17 375 | Statement: 376 | - 377 | Effect: Allow 378 | Action: 379 | - 'kms:Decrypt' 380 | - 'kms:GenerateDataKey' 381 | Resource: 382 | - '*' 383 | - 384 | PolicyName: DataMovement 385 | PolicyDocument: 386 | Version: 2012-10-17 387 | Statement: 388 | - 389 | Effect: Allow 390 | Action: 391 | - 'firehose:*' 392 | - 'kinesis:*' 393 | - 'sqs:*' 394 | Resource: 395 | - '*' 396 | - 397 | PolicyName: PinpointAccess 398 | PolicyDocument: 399 | Version: 2012-10-17 400 | Statement: 401 | - 402 | Effect: Allow 403 | Action: 404 | - 'mobiletargeting:GetVoiceTemplate' 405 | - 'mobiletargeting:GetCampaign' 406 | - 'mobiletargeting:GetSegment' 407 | - 'mobiletargeting:PhoneNumberValidate' 408 | - 'mobiletargeting:PutEvents' 409 | Resource: 410 | - '*' 411 | 412 | CampaignLauncherRole: 413 | Type: AWS::IAM::Role 414 | Properties: 415 | AssumeRolePolicyDocument: 416 | Version: 2012-10-17 417 | Statement: 418 | - 419 | Effect: Allow 420 | Principal: 421 | Service: 422 | - events.amazonaws.com 423 | Action: sts:AssumeRole 424 | 425 | Policies: 426 | - 427 | PolicyName: ControlStateMachine 428 | PolicyDocument: 429 | Version: 2012-10-17 430 | Statement: 431 | - 432 | Effect: Allow 433 | Action: 434 | - states:DescribeExecution 435 | - states:StartExecution 436 | - states:StopExecution 437 | Resource: '*' 438 | - 439 | Effect: Allow 440 | Action: 441 | - events:PutTargets 442 | - events:PutRule 443 | - events:DescribeRule 444 | Resource: !Sub arn:aws:events:${AWS::Region}:${AWS::AccountId}:rule/StepFunctionsGetEventsForStepFunctionsExecutionRule 445 | KinesisFirehoseDeliveryRole: 446 | Type: AWS::IAM::Role 447 | Properties: 448 | AssumeRolePolicyDocument: 449 | Version: "2012-10-17" 450 | Statement: 451 | - Effect: Allow 452 | Principal: 453 | Service: 454 | - firehose.amazonaws.com 455 | - s3.amazonaws.com 456 | Action: sts:AssumeRole 457 | Path: / 458 | Policies: 459 | - PolicyName: deliveryToS3 460 | PolicyDocument: 461 | Version: "2012-10-17" 462 | Statement: 463 | - Sid: deliveryToS3 464 | Effect: Allow 465 | Action: 466 | - s3:AbortMultipartUpload 467 | - s3:GetBucketLocation 468 | - s3:GetObject 469 | - s3:ListBucket 470 | - s3:ListBucketMultipartUploads 471 | - s3:PutObject 472 | Resource: "*" 473 | 474 | DialerControlSF: 475 | Type: AWS::Serverless::StateMachine 476 | Properties: 477 | Name: !Join ['-', [!Ref AWS::StackName,'ControlSF', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 478 | Role: !GetAtt ControlSFRole.Arn 479 | DefinitionUri: statemachine/PowerDialer-control.asl.json 480 | DefinitionSubstitutions: 481 | PowerDialerdialArn: !GetAtt dial.Arn 482 | PowerDialergetAvailAgentsArn: !GetAtt getAvailAgents.Arn 483 | PowerDialergetConfigArn: !GetAtt getConfig.Arn 484 | PowerDialergetContactsArn: !GetAtt getContacts.Arn 485 | PowerDialerListLoadArn: !Sub 486 | - 'arn:aws:s3:::${listLoadARN}/*' 487 | - listLoadARN: !Join ['-', [!Ref AWS::StackName,'listload' ,!Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 488 | PowerDialergetConnectStatusArn: !GetAtt getConnectStatus.Arn 489 | ParameterIndex: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'dialIndex']] 490 | ParameterTotalRecords: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'totalRecords']] 491 | ParameterActiveDialing: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'table-activedialing']] 492 | ParameterDialerStatus: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'activeDialer']] 493 | ParameterConcurrencyChange: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'concurrencyChange']] 494 | 495 | ListLoadSF: 496 | Type: AWS::Serverless::StateMachine 497 | Properties: 498 | Name: !Join ['-', [!Ref AWS::StackName,'ListLoadSF', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 499 | Role: !GetAtt ControlSFRole.Arn 500 | DefinitionUri: statemachine/PowerDialer-ListLoad.asl.json 501 | DefinitionSubstitutions: 502 | PowerDialers3ListLoadArn: !GetAtt ListLoad.Arn 503 | 504 | ResultsFirehose: 505 | Type: 'AWS::KinesisFirehose::DeliveryStream' 506 | Properties: 507 | DeliveryStreamType: DirectPut 508 | DeliveryStreamName: !Join ['-', [!Ref AWS::StackName,'dialer-results', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 509 | S3DestinationConfiguration: 510 | BucketARN: !GetAtt resultsbucket.Arn 511 | BufferingHints: 512 | IntervalInSeconds: '60' 513 | SizeInMBs: '10' 514 | CompressionFormat: UNCOMPRESSED 515 | RoleARN: !GetAtt 516 | - KinesisFirehoseDeliveryRole 517 | - Arn 518 | 519 | DialingListQueue: 520 | Type: AWS::SQS::Queue 521 | Properties: 522 | QueueName: !Join ['-', [!Ref AWS::StackName, !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 523 | VisibilityTimeout: 30 524 | DelaySeconds: 0 525 | MessageRetentionPeriod: 1209600 526 | 527 | PriorityDialingQueue: 528 | Type: AWS::SQS::Queue 529 | Properties: 530 | QueueName: !Join ['-', [!Ref AWS::StackName,'priority' ,!Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 531 | VisibilityTimeout: 30 532 | DelaySeconds: 0 533 | MessageRetentionPeriod: 1209600 534 | 535 | PowerDialer: 536 | Type: AWS::Serverless::LayerVersion 537 | Properties: 538 | ContentUri: PowerDialer-layer/ 539 | CompatibleRuntimes: 540 | - python3.9 541 | - python3.10 542 | - python3.11 543 | - python3.12 544 | Metadata: 545 | BuildMethod: python3.9 546 | 547 | 548 | dial: 549 | Type: AWS::Serverless::Function 550 | Properties: 551 | Role: !GetAtt PowerDialerLambdaRole.Arn 552 | CodeUri: PowerDialer-dial/ 553 | Handler: lambda_function.lambda_handler 554 | Layers: 555 | - !Ref PowerDialer 556 | Environment: 557 | Variables: 558 | DIALER_DEPLOYMENT: !Ref AWS::StackName 559 | ACTIVE_DIALING_TABLE: !Ref ActiveDialing 560 | RESULTS_FIREHOSE_NAME: !Ref ResultsFirehose 561 | 562 | updateProfile: 563 | Type: AWS::Serverless::Function 564 | Properties: 565 | Role: !GetAtt PowerDialerLambdaRole.Arn 566 | CodeUri: PowerDialer-updateProfile/ 567 | Handler: lambda_function.lambda_handler 568 | Layers: 569 | - !Ref PowerDialer 570 | Environment: 571 | Variables: 572 | DIALER_DEPLOYMENT: !Ref AWS::StackName 573 | CUSTOMER_PROFILES_DOMAIN: !Ref CustomerProfilesDomainName 574 | 575 | getAvailAgents: 576 | Type: AWS::Serverless::Function 577 | Properties: 578 | Role: !GetAtt PowerDialerLambdaRole.Arn 579 | CodeUri: PowerDialer-getAvailAgents/ 580 | Handler: lambda_function.lambda_handler 581 | 582 | getConnectStatus: 583 | Type: AWS::Serverless::Function 584 | Properties: 585 | Role: !GetAtt PowerDialerLambdaRole.Arn 586 | CodeUri: PowerDialer-connectStatus/ 587 | Handler: lambda_function.lambda_handler 588 | Layers: 589 | - !Ref PowerDialer 590 | 591 | getConfig: 592 | Type: AWS::Serverless::Function 593 | Properties: 594 | Role: !GetAtt PowerDialerLambdaRole.Arn 595 | CodeUri: PowerDialer-getConfig/ 596 | Handler: lambda_function.lambda_handler 597 | Layers: 598 | - !Ref PowerDialer 599 | Environment: 600 | Variables: 601 | DIALER_DEPLOYMENT: !Ref AWS::StackName 602 | 603 | getContacts: 604 | Type: AWS::Serverless::Function 605 | Properties: 606 | Role: !GetAtt PowerDialerLambdaRole.Arn 607 | CodeUri: PowerDialer-getContacts/ 608 | Handler: lambda_function.lambda_handler 609 | Layers: 610 | - !Ref PowerDialer 611 | Environment: 612 | Variables: 613 | DIALER_DEPLOYMENT: !Ref AWS::StackName 614 | SQS_URL: !Ref DialingListQueue 615 | PRIORITY_SQS_URL: !Ref PriorityDialingQueue 616 | 617 | queueContacts: 618 | Type: AWS::Serverless::Function 619 | Properties: 620 | Role: !GetAtt PowerDialerLambdaRole.Arn 621 | CodeUri: PowerDialer-queueContacts/ 622 | Handler: lambda_function.lambda_handler 623 | MemorySize: 512 624 | Timeout: 600 625 | Layers: 626 | - !Ref PowerDialer 627 | Environment: 628 | Variables: 629 | DIALER_DEPLOYMENT: !Ref AWS::StackName 630 | SQS_URL: !Ref DialingListQueue 631 | SFN_ARN: !Ref DialerControlSF 632 | CUSTOMER_PROFILES_DOMAIN: !Ref CustomerProfilesDomainName 633 | NO_CALL_STATUS: !Join [",", !Ref NoCallStatusList] 634 | VALIDATE_PROFILE: !Ref ValidateProfile 635 | 636 | pinpointLambdaPermission: 637 | Type: AWS::Lambda::Permission 638 | Properties: 639 | Action: 'lambda:InvokeFunction' 640 | FunctionName: !Ref queueContacts 641 | Principal: !Sub pinpoint.${AWS::Region}.amazonaws.com 642 | SourceArn: !Sub arn:aws:mobiletargeting:${AWS::Region}:${AWS::AccountId}:apps/* 643 | 644 | ListLoad: 645 | Type: AWS::Serverless::Function 646 | Properties: 647 | FunctionName: !Join ['-', [!Ref AWS::StackName,'listload' ,!Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 648 | Role: !GetAtt PowerDialerLambdaRole.Arn 649 | CodeUri: PowerDialer-ListLoad/ 650 | Handler: lambda_function.lambda_handler 651 | MemorySize: 512 652 | Timeout: 600 653 | Layers: 654 | - !Ref PowerDialer 655 | Environment: 656 | Variables: 657 | DIALER_DEPLOYMENT: !Ref AWS::StackName 658 | SQS_URL: !Ref DialingListQueue 659 | SFN_ARN: !Ref DialerControlSF 660 | CUSTOMER_PROFILES_DOMAIN: !Ref CustomerProfilesDomainName 661 | NO_CALL_STATUS: !Join [",", !Ref NoCallStatusList] 662 | VALIDATE_PROFILE: !Ref ValidateProfile 663 | 664 | ListLoadTrigger: 665 | Type: AWS::Events::Rule 666 | Properties: 667 | Description: Campaign launch trigger 668 | EventPattern: 669 | source: 670 | - "aws.s3" 671 | detail-type: 672 | - 'Object Created' 673 | detail: 674 | bucket: 675 | name: 676 | - !Ref dialinglistbucket 677 | Name: !Join ['-', [!Ref AWS::StackName,'ListLoadTrigger' ,!Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 678 | Targets: 679 | - Arn: !Ref ListLoadSF 680 | Id: 'CampaingLauncher' 681 | RoleArn: !GetAtt PowerDialerListLoadEventBridgeRole.Arn 682 | InputTransformer: 683 | InputPathsMap: 684 | "bucket" : "$.detail.bucket.name" 685 | "filename" : "$.detail.object.key" 686 | InputTemplate: '{"bucket" : , "filename" : }' 687 | 688 | contactSource: 689 | Type: AWS::SSM::Parameter 690 | Properties: 691 | Name: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'contact-source']] 692 | Type: String 693 | Value: 's3' 694 | 695 | 696 | parameterIndex: 697 | Type: AWS::SSM::Parameter 698 | Properties: 699 | Name: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'dialIndex']] 700 | Type: String 701 | Value: '0' 702 | 703 | parameterDialerStatus: 704 | Type: AWS::SSM::Parameter 705 | Properties: 706 | Name: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'activeDialer']] 707 | Type: String 708 | Value: 'False' 709 | 710 | parameterTotalRecords: 711 | Type: AWS::SSM::Parameter 712 | Properties: 713 | Name: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'totalRecords']] 714 | Type: String 715 | Value: '0' 716 | 717 | parameterActiveDialing: 718 | Type: AWS::SSM::Parameter 719 | Properties: 720 | Name: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'table-activedialing']] 721 | Type: String 722 | Value: !Ref ActiveDialing 723 | 724 | parameterContactFlow: 725 | Type: AWS::SSM::Parameter 726 | Properties: 727 | Name: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'contactflow']] 728 | Type: String 729 | Value: !Ref ConnectContactFlowId 730 | 731 | parameterOutputBucket: 732 | Type: AWS::SSM::Parameter 733 | Properties: 734 | Name: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'ResultsBucket']] 735 | Type: String 736 | Value: !Ref resultsbucket 737 | 738 | parameterConnectId: 739 | Type: AWS::SSM::Parameter 740 | Properties: 741 | Name: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'connectid']] 742 | Type: String 743 | Value: !Ref ConnectInstanceId 744 | parameterQueue: 745 | Type: AWS::SSM::Parameter 746 | Properties: 747 | Name: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'queue']] 748 | Type: String 749 | Value: !Ref ConnectQueueId 750 | parameterCountryCode: 751 | Type: AWS::SSM::Parameter 752 | Properties: 753 | Name: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'countrycode']] 754 | Type: String 755 | Value: !Ref CountryCode 756 | parameterISOCountryCode: 757 | Type: AWS::SSM::Parameter 758 | Properties: 759 | Name: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'isocountrycode']] 760 | Type: String 761 | Value: !Ref ISOCountryCode 762 | parameterconcurrentCalls: 763 | Type: AWS::SSM::Parameter 764 | Properties: 765 | Name: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'concurrentCalls']] 766 | Type: String 767 | Value: !Ref ConcurrentCalls 768 | parametertimeOut: 769 | Type: AWS::SSM::Parameter 770 | Properties: 771 | Name: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'timeOut']] 772 | Type: String 773 | Value: !Ref CallTimeOut 774 | parameterconcurrencyChange: 775 | Type: AWS::SSM::Parameter 776 | Properties: 777 | Name: !Join ['/', ['/connect/dialer', !Ref AWS::StackName,'concurrencyChange']] 778 | Type: String 779 | Value: False 780 | 781 | 782 | SetDispositionCode: 783 | Type: AWS::Serverless::Function 784 | Properties: 785 | Role: !GetAtt PowerDialerLambdaRole.Arn 786 | CodeUri: PowerDialer-setDisposition/ 787 | Handler: lambda_function.lambda_handler 788 | Layers: 789 | - !Ref PowerDialer 790 | Environment: 791 | Variables: 792 | DIALER_DEPLOYMENT: !Ref AWS::StackName 793 | RESULTS_FIREHOSE_NAME: !Ref ResultsFirehose 794 | 795 | ProcessContactEvents: 796 | Type: AWS::Serverless::Function 797 | Properties: 798 | Role: !GetAtt PowerDialerLambdaRole.Arn 799 | CodeUri: PowerDialer-ProcessContactEvents/ 800 | Handler: lambda_function.lambda_handler 801 | MemorySize: 512 802 | Timeout: 600 803 | Layers: 804 | - !Ref PowerDialer 805 | Environment: 806 | Variables: 807 | ACTIVE_DIALING: !Ref ActiveDialing 808 | 809 | ContactEventsRule: 810 | Type: AWS::Events::Rule 811 | Properties: 812 | Description: Amazon Connect contact disconnection events monitor 813 | EventPattern: {"source": ["aws.connect"],"detail-type": ["Amazon Connect Contact Event"],"detail": {"eventType": ["DISCONNECTED"]}} 814 | Name: !Join ['-', ['connect-events', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] 815 | State: ENABLED 816 | Targets: 817 | - 818 | Arn: !GetAtt ProcessContactEvents.Arn 819 | Id: 'ConnectContactDisconnects' 820 | 821 | EventBridgeLambdaPermission: 822 | Type: AWS::Lambda::Permission 823 | Properties: 824 | FunctionName: !GetAtt ProcessContactEvents.Arn 825 | Action: lambda:InvokeFunction 826 | Principal: events.amazonaws.com 827 | SourceArn: !GetAtt ContactEventsRule.Arn 828 | 829 | CampaignLaunchSchedule: 830 | Type: AWS::Events::Rule 831 | Properties: 832 | Description: "Initiate dialer campaign" 833 | ScheduleExpression: 'cron(0 14 ? * MON-SUN *)' 834 | State: 'DISABLED' 835 | Targets: 836 | - 837 | Id: "CampaignStarter" 838 | RoleArn: !GetAtt CampaignLauncherRole.Arn 839 | Arn: !GetAtt DialerControlSF.Arn 840 | 841 | Outputs: 842 | InputBucket: 843 | Description: "Bucket for List Loading. Use Pinpoint CSV template format" 844 | Value: !Ref dialinglistbucket 845 | ConnectConfigConnectId: 846 | Description: "Connect - Instance Id parameter" 847 | Value: !Ref parameterConnectId 848 | ConnectConfigQueue: 849 | Description: "Connect - Queue parameter" 850 | Value: !Ref parameterQueue 851 | ConnectConfigContactFlow: 852 | Description: "Connect - ContactFlow Id parameter" 853 | Value: !Ref parameterContactFlow --------------------------------------------------------------------------------