AWS Contact Center

Building a state-aware workflow with Amazon Connect and AWS Step Functions

AWS Step Functions enables you to orchestrate multiple services in a seamless workflow in coordination with Amazon Connect and Amazon SNS. In this post, I walk you through how to use Step Functions to coordinate customer workflows.

To put the problem and solution in concrete terms, consider the following use case:

Example Corp. is a property management company that also offers round-the-clock support to their homeowners. When a homeowner calls for support on a home-related issue (for example, “My smart doorbell stopped working”), the customer service agent creates a “New” ticket.

AWS Step Function assigns the ticket to a contractor and notifies the contractor of the assignment. As the contractor starts to work the case, they then change the ticket state to “Work in Progress.” The ticket is marked “Complete” after the work is complete. The system notifies the homeowner via SMS each time the ticket changes state.

The solution uses the following AWS services:

  1. AWS Lambda
  2. Amazon DynamoDB
  3. Amazon Connect
  4. AWS Step Functions
  5. Amazon SNS
  6. Amazon CloudWatch

Overview

At a high level, the solution works as follows:

  1. A customer calls a toll-free number, which delivers the caller to a Connect contact flow.
  2. The Connect contact flow collects issue details from the customer and passes control to a Lambda function (#1).
  3. Lambda function #1 receives the phone number from Connect, looks up the customer’s details, and creates a ticket. The ticket opens with the status: “NEW.” I used DynamoDB as a ticketing solution. In practice, a CRM assumes this role, and Lambda function #1 communicates with the CRM using exposed APIs.
  4. Lambda function #1 invokes a Step Functions state machine. The state machine enters a pre-configured wait time, after which it keeps polling the ticket at a set interval to note any change in status. The state machine accomplishes this polling using a Lambda function (#2).
  5. After the ticket status changes (for example, changing from “NEW” to “WORK IN PROGRESS”), the state machine calls a Lambda function (#3) to send a notification to the customer. The state machine terminates after this step.
  6. The following diagram illustrates this workflow:

 

 

Prerequisites

Setup prerequisites are as follows:

  • An active AWS account with the following permissions:
    • Create and modify Lambda functions.
    • Create and modify Step Function state machines.
    • Create and update DynamoDB tables.
    • A Connect instance configured for inbound and outbound calls. Make sure to claim a phone number after configuring the instance. For more information, see Getting Started with Amazon Connect.

The following IAM policies are required to prepare the environment:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "*"
        }
    ]
}
  • Policy to call Step Functions: (Policy name = PolicyStepFunctionAccess) To learn how to create a custom IAM policy enabling Lambda to call state machines, pass parameters, and obtain results from them, wee Creating IAM policies. The policy used here is as follows:
{
    “Version”: “2012-10-17”,
    “Statement”: [
        {
            “Sid”: “VisualEditor0”,
            “Effect”: “Allow,”
            “Action”: [
                “states:DescribeActivity”,
                “states:DescribeStateMachine”,
                “states:ListExecutions”,
                “states:UpdateStateMachine”,
                “states:DeleteStateMachine”,
                “states:StopExecution”,
                “states:DescribeStateMachineForExecution”,
                “states:SendTaskSuccess”,
                “states:SendTaskFailure”,
                “states:DescribeExecution”,
                “states:GetExecutionHistory”,
                “states:DeleteActivity”,
                “states:StartExecution”,
                “states:SendTaskHeartbeat”,
                “states:GetActivityTask”,
                “states:ListTagsForResource”
            ],
            “Resource”: “*”
        }
    ]
}
  • Policy to interact with Connect: (Policy name = AWSConnect-GrantOutboundPermission) To learn how to create a custom IAM policy enabling the Lambda function to invoke Connect, see Creating IAM policies. The JSON policy is as follows:
{
    “Version”: “2012-10-17”,
    “Statement”: [
        {
            “Sid”: “VisualEditor0”,
            “Effect”: “Allow”,
            “Action”: “connect:StartOutboundVoiceContact”,
            “Resource”: “arn:aws:connect::*:instance/<InsertAmazonConnectInstanceName>/contact/*”
        }
    ]
}
  • Policy to interact with DynamoDB: (Policy name = DDBTblSpecificReadWriteAccess) To learn how to create a policy that has access to read and write to a specific table, see Amazon DynamoDB. Replace “MyTable” with TicketInfo.
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "ListAndDescribe",
            "Effect": "Allow",
            "Action": [
                "dynamodb:List*",
                "dynamodb:DescribeReservedCapacity*",
                "dynamodb:DescribeLimits",
                "dynamodb:DescribeTimeToLive"
            ],
            "Resource": "*"
        },
        {
            "Sid": "SpecificTable",
            "Effect": "Allow",
            "Action": [
                "dynamodb:BatchGet*",
                "dynamodb:DescribeStream",
                "dynamodb:DescribeTable",
                "dynamodb:Get*",
                "dynamodb:Query",
                "dynamodb:Scan",
                "dynamodb:BatchWrite*",
                "dynamodb:CreateTable",
                "dynamodb:Delete*",
                "dynamodb:Update*",
                "dynamodb:PutItem"
            ],
            "Resource": "arn:aws:dynamodb:*:*:table/ TicketInfo"
        }
    ]
}
  • The JSON for the policy to send SNS messages (Policy name = PolicySendSNS) is as follows:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "sns:Publish",
            "Resource": "*"
        }
    ]
}
  • Step Functions IAM policy: Step Functions roles are configured similarly to Lambda policies, from the Step Function console. For more information, see Creating IAM policy for AWS Step Functions. Use the pre-existing policy “AWSLambdaExecute” for this state machine. The JSON is as follows:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:*"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:PutObject"
            ],
            "Resource": "arn:aws:s3:::*"
        }
    ]
}

 

Setup

As part of this post, you first create a DynamoDB table that stores a customer’s name and phone number. Next, create the Lambda functions and the state machine.

  • Create a DynamoDB table: (Table name = TicketInfo): To learn how to use the DynamoDB console and create a DynamoDB table called TicketInfo, see Step 1: Create Example Tables. Configure the table as follows:
    1. Attribute: “PhoneNumber”
      1. Key Type: Primary Key / Partition
      2. Key Attribute Type: String
    2. Attribute: “CustomerName”
      1. Key Type: Sort
      2. Key Attribute Type: String
    3. Table Region: US-East-1
  • Create Lambda Function #1: SendSMS, written in Python 3.7 (name = SendSMS). To walk through creating a Lambda function using Python, see AWS Lambda Function Handler in Python.
import logging
import boto3

# Initialize logger and set log level
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# Initialize SNS client 
session = boto3.Session(region_name="us-east-1")
sns_client = session.client('sns')


def lambda_handler(event, context):
    strMessage = 'CaseID:' + event["CaseStatus"]["CaseID"] + ' - Status:' + event["CaseStatus"]["TicketStatus"]
    response = sns_client.publish(PhoneNumber=event["ani"],Message=strMessage)
    logger.info(response)
    return 'OK'

Test input parameters for this Lambda function:

{
  "ani": "+12485550199",
  "statusCode": 200,
  "CaseStatus":
  {
      "CaseID": "63602",
      "TicketStatus": "COMPLETE"
  }
}

A successful test results in an SMS message.

  • IAM policies for this Lambda function:
    • AWSLambdaBasicExecutionRole
    • PolicySendSNS

 

  • Lambda function #2: Check the status of a case from DynamoDB (Python 3.7) (name = DDBCheckCaseStatus).
import json
import random
import boto3
from boto3 import resource
from boto3.dynamodb.conditions import Key

# The boto3 dynamoDB resource
dynamodb_resource   = resource('dynamodb')
table = dynamodb_resource.Table('TicketInfo')

RETURN_ERRORS       = ['Object Not Found', 'Exception']
OBJECT_NOT_FOUND    = 0
RETURN_ERROR        = 999
RETURN_SUCCESS      = 200

def lambda_handler(event, context):
    # unpack parameters
    caseID      = event["CaseID"]
    theANI      = event["ani"]
    status      = RETURN_ERROR

    #
    #   Read the first name
    #
    try:
        aTicket = queryTable('PhoneNumber', theANI)
        nCount = aTicket['Count']
        if OBJECT_NOT_FOUND!= nCount:
            caseStatus = aTicket['Items'][0]['TicketInfo']['CurrentStatus']

            if caseID == str(aTicket['Items'][0]['TicketInfo']['ID']):
                status = RETURN_SUCCESS
            else :
                status = RETURN_ERROR

    except Exception as ex:
        status = RETURN_ERROR
        firstName = RETURN_ERRORS[1] + ' : '+ str(ex)

    return {
        'statusCode': status,
        'CaseID': caseID,
        'TicketStatus': caseStatus
    }


def queryTable(filter_key, filter_value):
    response = ''
    try:
        if filter_key and filter_value:
            response = table.query(KeyConditionExpression=Key(filter_key).eq(filter_value))
    except Exception as ex:
        raise ex
    
    return response

 

The test input parameters for this function are as follows:

{
  "CaseID": "96650",
  "ani": “+12485550199”
}

At this point, this test should return “null,” as there is no record in the DB with this info.

  • IAM policies for this Lambda Function:
    • AWSLambdaBasicExecutionRole
    • DDBTblSpecificReadWriteAccess
    • PolicyStepFunctionAccess

 

import json
import random
import boto3
import datetime
from boto3 import resource
from boto3.dynamodb.conditions import Key


# The boto3 DynamoDB resource
dynamodb_resource   = resource('dynamodb')
table = dynamodb_resource.Table('TicketInfo')

RETURN_ERRORS       = ['Object Not Found', 'Exception']
OBJECT_NOT_FOUND    = 0
RETURN_ERROR        = 999
RETURN_SUCCESS      = 200

def lambda_handler(event, context):
    # unpack parameters
    details     = event["Details"]
    parameters  = details["Parameters"]
    theANI      = parameters["ani"]
    status      = RETURN_ERROR
    updateStatus= RETURN_ERROR
    firstName   = RETURN_ERRORS[OBJECT_NOT_FOUND]
    ticketNumber = 0

    #
    #   Read the first name
    #
    try:
        if OBJECT_NOT_FOUND!= queryTable('PhoneNumber', theANI)['Count']:
            firstName = (queryTable('PhoneNumber', theANI))['Items'][0]['CustomerName']
            status = RETURN_SUCCESS

    except Exception as ex:
        status = RETURN_ERROR
        firstName = RETURN_ERRORS[1] + ' : '+ str(ex)
        

    #
    # Update that row with a ticket number
    #
    try:
        if RETURN_SUCCESS == status:
            ticketNumber = random.randint(10000,99999)
            updateStatus = createTicket(theANI, firstName, ticketNumber)
    except Exception as ex:    
        status = RETURN_ERROR
        firstName = RETURN_ERRORS[1] + ' : '+ str(ex)

    #
    # Send SMS from step function
    #
    strDt = '%.1f' % (datetime.datetime.now().timestamp())
    sfClient = boto3.client('stepfunctions')
    response = sfClient.start_execution(
                        stateMachineArn='arn:aws:states:us-east-1:298213240728:stateMachine:CheckTicketStatus',
                        name=strDt,
                        input= json.dumps(
                                {
                                    "WaitTime": 10,
                                    "CaseID": str(ticketNumber),
                                    "ani":theANI
                                }
                            )   
                        )

    return {
        'statusCode': status
        ,'CustomerName': firstName
        ,'TicketNumber': str(ticketNumber)
        ,'STFName': strDt
    }


def queryTable(filter_key, filter_value):

    response = ''
    try:
        if filter_key and filter_value:
            response = table.query(KeyConditionExpression=Key(filter_key).eq(filter_value))
    except Exception as ex:
        raise ex
    
    return response
    
    
def createTicket(phoneNumber, firstName, ticketNumber):
    response = ''
    
    try:
        response = table.put_item(
           Item={
                'PhoneNumber': phoneNumber,
                'CustomerName': firstName,
                'TicketInfo': {
                    'ID':ticketNumber,
                    'CurrentStatus': "NEW"
                }
            }
        )    
    except Exception as ex:
        raise ex
    
    return response

Test Input parameters for this Lambda Function:

{
  "Details": {
    "Parameters": {
      "ani": “+12485550199”
    }
  }
}

 

The test won’t work until after state machine creation.

  • IAM policies for this Lambda Function:
    • AWSLambdaBasicExecutionRole
    • DDBTblSpecificReadWriteAccess
    • PolicyStepFunctionAccess

 

Make a note of the Python API for instantiating a state machine in the earlier code. I highlight some specifics in the following:

response = sfClient.start_execution(
                        stateMachineArn=’<ARN>’,
                        name='<Unique Identifier>',
                        input= json.dumps(
                                {
                                    "WaitTime": 10,
                                    "CaseID": str(ticketNumber),
                                    "ani":theANI
                                }
                            )   
                        )

For more information about the calling convention, see the Step Function API ReferenceThe “name” of the execution must be unique for your AWS account, Region, and state machine for 90 days. I recommend using date/time stamps for this field.

 

  • State machine (Name = CheckTicketStatus): For an overview of Step Function state machines and how to create them, see Getting Started.
{
  "Comment": "Amazon Step Fn to monitors job status.",
  "StartAt": "Wait X Seconds",
  "States": {
    "Wait X Seconds": {
      "Type": "Wait",
      "SecondsPath": "$.WaitTime",
      "Next": "Get Job Status"
    },
    "Get Job Status": {
      "Type": "Task",
      "Resource":
      "arn:aws:lambda:us-east-1:XXXXXXXXXXXX:function:DDBCheckCaseStatus",
      "ResultPath": "$.CaseStatus",
      "Next": "Job Complete?"
    },
    "Job Complete?": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.CaseStatus.TicketStatus",
          "StringEquals": "NEW",
          "Next": "Ticket Not Updated"
        },
        {
          "Variable": "$.CaseStatus.TicketStatus",
          "StringEquals": "WORK IN PROGRESS",
          "Next": "Get Final Job Status"
        },
        {
          "Variable": "$.CaseStatus.TicketStatus",
          "StringEquals": "COMPLETE",
          "Next": "Get Final Job Status"
        }
      ],
      "Default": "Wait X Seconds"
    },
    "Ticket Not Updated": {
      "Type": "Wait",
      "SecondsPath": "$.WaitTime",
      "Next": "Get Job Status"
    },
    "Get Final Job Status": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:XXXXXXXXXXXX:function:SendSMS",
      "End": true
    }
  }
}

A state machine graphic workflow follows. This workflow is generated by AWS, based on the JSON code shown above. AWS doesn’t expose a way to edit the graphic directly.

 

  • Let me stress three callouts regarding Step Functions parameter passing. Each state within a step function can perform the following steps:
    • Take an input from a previous state’s output.
    • Use parameters that allow that state to execute.
    • Dump output within a JSON structure for the next state.

Consider the following state:

"Wait X Seconds": {
"Type": "Wait",
"SecondsPath": "$.WaitTime",
"Next": "Get Job Status"
},

This “Wait” state takes in “$.WaitTime” as input. When specified as JSON, the input takes the following form:

{
    "WaitTime": 10,
    "CaseID": "97643",
    "ani": “+12485550199”
}

Next, consider the state: “Get Job Status.” This state is a “Task” state that calls the Lambda function DDBCheckCaseStatus.

"Get Job Status": {
"Type": "Task",
"Resource":"arn:aws:lambda:us-east-1:XXXXXXXXXXXX:function:DDBCheckCaseStatus",
"ResultPath": "$.CaseStatus",
"Next": "Job Complete?"
 },

The Lambda function takes the ANI (“ani”) from the previous state as an input. You see the following output from the Lambda function:

{
"statusCode": 200,
"CaseID": "96650",
"TicketStatus": "NEW"
}

The “Get Job Status” state writes this result inside a “CaseStatus” JSON structure, as shown here:

"CaseStatus": {
      "statusCode": 200,
      "CaseID": "97643",
      "TicketStatus": "NEW"
    }

This result inputs to the “Choice” block – this block allows the state function to select a path based on the output from the Lambda function. You have three choices available in the state function, as shown in the following code example:

"Choices": [
{
"Variable": "$.CaseStatus.TicketStatus",
"StringEquals": "NEW",
"Next": "Ticket Not Updated"
},

The decision is made based on the value in the “Variable” element. The value [in this case] is retrieved from “$.CaseStatus.TicketStatus” [Value = “NEW”].

  • Configuring the Amazon Connect instance: Configure the Amazon Connect instance before creating a contact flow. Also, whitelist all Lambda functions needed for a contact flow in the configuration section. For more information, see Amazon Connect Instances.
  • Use Amazon Connect workflow to call the Lambda function DDBCreateTicket: See the contact flow used for this post attached here. For more information, see Amazon Connect Contact Flows and the Getting started with Amazon Connect YouTube tutorial.
  • Amazon Connect Lambda invocation: For more information, see Using Lambda Functions with Amazon Connect. The following figure shows how the Lambda function parameters pass to the Lambda function DDBCreateTicket and deploy on Amazon Connect.

Execution

  • Call the toll-free number configured to execute the Connect call flow.
  • The call flow calls Lambda function #1, which receives the phone number from Connect, looks up the customer’s details, and creates a ticket. The ticket opens with the status: “NEW.” (NOTE: I used DynamoDB as a ticketing solution. In practice, a CRM assumes this role, and Lambda Function #1 communicates with the CRM via their exposed APIs)
{
  "CustomerName": "John Customer",
  "PhoneNumber": "+12485550199",
  "TicketInfo": {
    "CurrentStatus": "NEW",
    "ID": 89812
  }
}
  • Lambda Function #1 invokes a Step Function. The Step Function enters into a pre-configured wait time, after which, it keeps polling the ticket at a set interval to note any change in status. The state machine accomplishes this via a Lambda function (Lambda Function #2).
  • After the ticket status changes (for example, it changes from “NEW” to “WORK IN PROGRESS”) the Step Function calls a Lambda function (Lambda Function #3) to send out a notification to the customer. The Step Function terminates after this step. The SMS is sent out to the customer via the phone number they called originally (+12485550199, in this case). The DynamoDB entry now looks as follows:
{
  "CustomerName": "Naveen",
  "PhoneNumber": "+12547184206",
  "TicketInfo": {
    "CurrentStatus": "NEW",
    "ID": 89812
  }
}

Conclusion

As you read in this post, Step Functions provide a seamless way to build a state model and wait an arbitrary amount of time for a task to complete. Although I have focused on one use case, you can use Step Functions wherever you must proactively communicate the status of an issue to interested parties.