AWS Step FunctionsでConnectの通話記録をBacklogに自動起票する際、課題の担当者を対応エージェントに設定する

AWS Step FunctionsでConnectの通話記録をBacklogに自動起票する際、課題の担当者を対応エージェントに設定する

Clock Icon2025.04.24

はじめに

以前、Amazon Connectで受けた問い合わせを自動的にBacklogのチケットとして起票する仕組みを ブログにしました。2つ目の記事では、問い合わせレコードの重複配信対策方法をブログにしました。

  1. Amazon Connectの通話記録をStep Functionsで処理し、Backlogに自動起票させてみた
  2. Amazon Connectの通話記録をKinesis Data Streamsに配信時、Step Functionsでデータの重複対策を実装する

今回は、電話対応した際に自動起票されるチケットの担当者を、AWS Step Functionsで実際に対応したエージェントに設定する方法をご紹介します。

cm-hirai-screenshot 2025-04-22 11.27.13

担当者を実際に対応したエージェントにすることで、自動起票されたチケットにすぐ気づき、対応履歴を即座に記載できるため、チケット管理がしやすくなります。

なお、本記事は前回の記事で構築したリソースを前提としています。システムの全体構成は以下の通りです。

cm-hirai-screenshot 2025-04-21 14.31.55

ちなみに、Backlog APIでは、description内でメンションを指定できません。
メンションによる通知を行いたい場合は、担当者としてユーザーを指定する(assigneeId)、またはコメントで通知したいユーザーを指定する(notifiedUserId)方法を取ります。

Connectユーザーにタグを設定する

Amazon Connectのユーザーに、Backlogのユーザー名と一致するタグを設定します。このタグは後ほどStep Functionsのワークフロー内で参照され、Backlogユーザーを特定するために使用されます。

  • タグキー:backlog_user_name
  • タグ値:Backlogのユーザー名と完全に一致する値
    cm-hirai-screenshot 2025-04-21 14.35.56

ステートマシン修正

前回実装したステートマシンは、データの受信から処理、Backlogチケットの作成、チケットの重複作成対策までを行う構成でした。

cm-hirai-screenshot 2025-04-21 15.40.09

処理の流れは次のようになります。

  1. Amazon Connectで顧客との通話が行われる
  2. エージェントがアフターコールワークを完了すると、問い合わせレコードがAmazon Kinesis Data Streams(以下、KDS)にストリーミングされる
  3. EventBridge Pipesがそのデータを検知し、Step Functionsのステートマシンを起動
  4. Step Functionsが問い合わせデータを整形し、Backlog APIを呼び出して新規チケットを自動作成

今回は、この処理フローに以下の機能を追加します。

  1. KDS経由で受け取った情報から対応エージェントの情報を取得
  2. Connect DescribeUser APIを使用してエージェントのbacklog_user_nameタグの値を取得
  3. Backlogプロジェクトユーザー一覧APIを呼び出し、backlog_user_nameの値と一致するBacklogユーザーのIDを特定
  4. 課題の追加後、課題を更新するAPIを呼び出し、取得したユーザーIDを使って課題の担当者を設定

これにより、対応したエージェントが自動的にBacklog課題の担当者となります。

cm-hirai-screenshot 2025-04-21 15.38.34

https://developer.nulab.com/ja/docs/backlog/api/2/get-project-user-list/

https://developer.nulab.com/ja/docs/backlog/api/2/update-issue/

ステートマシン定義

以下がJSONata形式で記述した完全なステートマシン定義です。各環境に合わせてプレースホルダーを置き換えて使用してください。

{
  "Comment": "通話記録内容をBacklogチケットに起票する",
  "StartAt": "Base64Decode",
  "States": {
    "Base64Decode": {
      "Type": "Pass",
      "Next": "ChannelTypeCheck",
      "Assign": {
        "decodedData": "{% $base64decode($states.input.data) %}"
      }
    },
    "ChannelTypeCheck": {
      "Type": "Choice",
      "Choices": [
        {
          "Next": "RecordContactInDynamoDB",
          "Condition": "{% $parse($decodedData).Channel = \"VOICE\" %}",
          "Assign": {
            "data": "{% $parse($decodedData) %}"
          }
        }
      ],
      "Default": "NonVoiceChannelEnd"
    },
    "RecordContactInDynamoDB": {
      "Type": "Task",
      "Resource": "arn:aws:states:::dynamodb:putItem",
      "Arguments": {
        "TableName": "cm-hirai-connect-backlog-tracker",
        "Item": {
          "contact_id": {
            "S": "{% $data.ContactId %}"
          },
          "ttl": {
            "N": "{% $string($floor($toMillis($now()) / 1000 + 86400)) %}"
          }
        },
        "ConditionExpression": "attribute_not_exists(contact_id)"
      },
      "Next": "ProcessContactInParallel",
      "Catch": [
        {
          "ErrorEquals": [
            "DynamoDB.ConditionalCheckFailedException"
          ],
          "Next": "DuplicateContactEnd",
          "Comment": "既に同じコンタクトIDが存在する場合は重複処理と判断して終了する"
        },
        {
          "ErrorEquals": [
            "States.ALL"
          ],
          "Next": "Fail",
          "Comment": "その他のエラーが発生した場合は処理を失敗させる"
        }
      ]
    },
    "DuplicateContactEnd": {
      "Type": "Succeed",
      "Comment": "既に処理済みのコンタクトIDなので処理を終了"
    },
    "ProcessContactInParallel": {
      "Type": "Parallel",
      "Next": "CreateBacklogTicket",
      "Branches": [
        {
          "StartAt": "InquiryTypeCheck",
          "States": {
            "InquiryTypeCheck": {
              "Type": "Choice",
              "Choices": [
                {
                  "Next": "ExtractContactData",
                  "Condition": "{% $data.Attributes.inquiry_type = \"product\" %}",
                  "Assign": {
                    "categoryId": "<categoryId_product>"
                  }
                },
                {
                  "Next": "ExtractContactData",
                  "Condition": "{% $data.Attributes.inquiry_type = \"contract\" %}",
                  "Assign": {
                    "categoryId": "<categoryId_contract>"
                  }
                }
              ],
              "Default": "ExtractContactData",
              "Assign": {
                "categoryId": "<categoryId_other>"
              }
            },
            "ExtractContactData": {
              "Type": "Pass",
              "Assign": {
                "InitialContactId": "{% $data.ContactId %}",
                "InitiationTimestamp": "{% $data.InitiationTimestamp %}",
                "DisconnectTimestamp": "{% $data.DisconnectTimestamp %}",
                "CallerPhoneNumber": "{% $data.CustomerEndpoint.Address %}"
              },
              "Next": "FormatContactData"
            },
            "FormatContactData": {
              "Type": "Pass",
              "End": true,
              "Output": {
                "FormattedInitiationTimestamp": "{% $fromMillis($toMillis($InitiationTimestamp), '[Y0001]年[M]月[D]日 [H]時[m01]分[s01]秒', '+0900') %}",
                "FormattedDisconnectTimestamp": "{% $fromMillis($toMillis($DisconnectTimestamp), '[Y0001]年[M]月[D]日 [H]時[m01]分[s01]秒', '+0900') %}",
                "FormattedPhoneNumber": "{% $CallerPhoneNumber = 'anonymous' ? '非通知' : ($substring($CallerPhoneNumber, 0, 3) = '+81' ? '0' & $substring($CallerPhoneNumber, 3) : $CallerPhoneNumber) %}",
                "ContactDetailsURL": "{% 'https://<Connectドメイン>/connect/contact-trace-records/details/' & $InitialContactId & '?tz=Asia/Tokyo' %}",
                "categoryId": "{% $categoryId %}",
                "InitialContactId": "{% $InitialContactId %}"
              }
            }
          }
        },
        {
          "StartAt": "CheckAgentExists",
          "States": {
            "CheckAgentExists": {
              "Type": "Choice",
              "Choices": [
                {
                  "Next": "FinalizeAgentInfo",
                  "Condition": "{% $data.Agent = null %}",
                  "Assign": {
                    "connectUserTagName": "対応者はいません",
                    "matchedBacklogUserId": "false"
                  }
                }
              ],
              "Default": "RetrieveAgentInfo"
            },
            "RetrieveAgentInfo": {
              "Type": "Task",
              "Arguments": {
                "InstanceId": "{% $data.Tags.\"aws:connect:instanceId\" %}",
                "UserId": "{% $substringAfter($data.Agent.ARN, '/agent/') %}"
              },
              "Resource": "arn:aws:states:::aws-sdk:connect:describeUser",
              "Assign": {
                "connectUserTagName": "{% $states.result.User.Tags.backlog_user_name %}"
              },
              "Next": "FetchBacklogUsers",
              "Catch": [
                {
                  "ErrorEquals": [
                    "States.ALL"
                  ],
                  "Next": "FinalizeAgentInfo",
                  "Assign": {
                    "connectUserTagName": "対応者不明",
                    "matchedBacklogUserId": "false"
                  },
                  "Comment": "対応したエージェントにconnectUserTagName名が存在しない"
                }
              ]
            },
            "FetchBacklogUsers": {
              "Type": "Task",
              "Resource": "arn:aws:states:::http:invoke",
              "Arguments": {
                "ApiEndpoint": "https://xxx.backlog.jp/api/v2/projects/<projectId>/users",
                "InvocationConfig": {
                  "ConnectionArn": "<ContactDetailsURL>"
                },
                "Headers": {
                  "Content-Type": "application/json;charset=utf-8"
                },
                "Transform": {
                  "RequestBodyEncoding": "URL_ENCODED",
                  "RequestEncodingOptions": {
                    "ArrayFormat": "INDICES"
                  }
                },
                "Method": "GET"
              },
              "Retry": [
                {
                  "ErrorEquals": [
                    "States.ALL"
                  ],
                  "BackoffRate": 2,
                  "IntervalSeconds": 1,
                  "MaxAttempts": 3,
                  "JitterStrategy": "FULL"
                }
              ],
              "Catch": [
                {
                  "ErrorEquals": [
                    "States.QueryEvaluationError"
                  ],
                  "Comment": "connectUserTagName名とBacklogユーザー名が一致しない",
                  "Next": "FinalizeAgentInfo",
                  "Assign": {
                    "connectUserTagName": "対応者不明",
                    "matchedBacklogUserId": "false"
                  }
                }
              ],
              "Next": "FinalizeAgentInfo",
              "Assign": {
                "backlogUserList": "{% $states.result.ResponseBody %}",
                "matchedBacklogUserInfo": "{% $states.result.ResponseBody[name = $connectUserTagName] %}",
                "matchedBacklogUserId": "{% $states.result.ResponseBody[name = $connectUserTagName].id %}",
                "connectUserTagName": "{% $connectUserTagName %}"
              }
            },
            "FinalizeAgentInfo": {
              "Type": "Pass",
              "End": true,
              "Output": {
                "matchedBacklogUserId": "{% $matchedBacklogUserId %}",
                "connectUserTagName": "{% $connectUserTagName %}"
              }
            }
          }
        }
      ],
      "Assign": {
        "FormattedInitiationTimestamp": "{% $states.result[0].FormattedInitiationTimestamp %}",
        "FormattedDisconnectTimestamp": "{% $states.result[0].FormattedDisconnectTimestamp %}",
        "FormattedPhoneNumber": "{% $states.result[0].FormattedPhoneNumber %}",
        "InitialContactId": "{% $states.result[0].InitialContactId %}",
        "ContactDetailsURL": "{% $states.result[0].ContactDetailsURL %}",
        "categoryId": "{% $states.result[0].categoryId %}",
        "connectUserTagName": "{% $states.result[1].connectUserTagName %}",
        "matchedBacklogUserId": "{% $states.result[1].matchedBacklogUserId %}"
      }
    },
    "NonVoiceChannelEnd": {
      "Type": "Succeed"
    },
    "CreateBacklogTicket": {
      "Type": "Task",
      "Resource": "arn:aws:states:::http:invoke",
      "Arguments": {
        "ApiEndpoint": "https://xxx.backlog.jp/api/v2/issues",
        "InvocationConfig": {
          "ConnectionArn": "<ContactDetailsURL>"
        },
        "Headers": {
          "Content-Type": "application/x-www-form-urlencoded"
        },
        "Transform": {
          "RequestBodyEncoding": "URL_ENCODED",
          "RequestEncodingOptions": {
            "ArrayFormat": "INDICES"
          }
        },
        "Method": "POST",
        "RequestBody": {
          "projectId": "<projectId>",
          "summary": "通話記録",
          "description": "{% '|項目|内容|備考|\n|---|---|---|\n|通話開始時間|' & $FormattedInitiationTimestamp & '| |\n|通話終了時間|' & $FormattedDisconnectTimestamp & '| |\n|コンタクトID|' & $InitialContactId & '| [コンタクト詳細URL](' & $ContactDetailsURL & ')|\n|対応者|' & $connectUserTagName & '| |\n|発信者電話番号|' & $FormattedPhoneNumber & '| |' %}",
          "issueTypeId": "<issueTypeId>",
          "categoryId[]": "{% $categoryId %}",
          "priorityId": "3"
        }
      },
      "Next": "UpdateDynamoDBWithBacklogTicketId",
      "Retry": [
        {
          "ErrorEquals": [
            "States.ALL"
          ],
          "BackoffRate": 2,
          "IntervalSeconds": 1,
          "MaxAttempts": 3,
          "JitterStrategy": "FULL"
        }
      ],
      "Assign": {
        "backlogIssueKey": "{% $string($states.result.ResponseBody.issueKey) %}"
      }
    },
    "UpdateDynamoDBWithBacklogTicketId": {
      "Type": "Task",
      "Resource": "arn:aws:states:::dynamodb:updateItem",
      "Arguments": {
        "TableName": "cm-hirai-connect-backlog-tracker",
        "Key": {
          "contact_id": {
            "S": "{% $data.ContactId %}"
          }
        },
        "UpdateExpression": "SET backlog_issue_id = :backlogIssueKey",
        "ExpressionAttributeValues": {
          ":backlogIssueKey": {
            "S": "{% $backlogIssueKey %}"
          }
        }
      },
      "Catch": [
        {
          "ErrorEquals": [
            "States.ALL"
          ],
          "Next": "Fail"
        }
      ],
      "Next": "CheckAssigneeRequired"
    },
    "CheckAssigneeRequired": {
      "Type": "Choice",
      "Choices": [
        {
          "Next": "成功",
          "Condition": "{% $matchedBacklogUserId = \"false\" %}",
          "Comment": "対応者不明のため、課題の担当者は設定しない"
        }
      ],
      "Default": "AssignBacklogIssue"
    },
    "AssignBacklogIssue": {
      "Type": "Task",
      "Resource": "arn:aws:states:::http:invoke",
      "Arguments": {
        "ApiEndpoint": "{% 'https://xxx.backlog.jp/api/v2/issues/' & $issueId %}",
        "InvocationConfig": {
          "ConnectionArn": "<ContactDetailsURL>"
        },
        "Headers": {
          "Content-Type": "application/x-www-form-urlencoded"
        },
        "Transform": {
          "RequestBodyEncoding": "URL_ENCODED",
          "RequestEncodingOptions": {
            "ArrayFormat": "INDICES"
          }
        },
        "RequestBody": {
          "assigneeId": "{% $matchedBacklogUserId %}"
        },
        "Method": "PATCH"
      },
      "Retry": [
        {
          "ErrorEquals": [
            "States.ALL"
          ],
          "BackoffRate": 2,
          "IntervalSeconds": 1,
          "MaxAttempts": 3,
          "JitterStrategy": "FULL"
        }
      ],
      "Next": "成功"
    },
    "成功": {
      "Type": "Succeed"
    },
    "Fail": {
      "Type": "Fail"
    }
  },
  "QueryLanguage": "JSONata"
}

以下のプレースホルダーを実際の値に置き換えてください

  • <projectId>:BacklogのプロジェクトID
  • <xxx.backlog.jp>:Backlogドメイン
  • <issueTypeId>:Backlog「電話」種別のID
  • <categoryId_product>:Backlog「商品関連」カテゴリーのID
  • <categoryId_contract>:Backlog「契約関連」カテゴリーのID
  • <categoryId_other>:Backlog「その他」カテゴリーのID
  • <Connectドメイン>:Amazon ConnectインスタンスのURL
  • <ConnectionArn>:作成したEventBridge ConnectionのARN

実行時には、Connectのユーザーにタグにより、以下のような条件分岐で対応者情報が処理されます。

  • Connectのユーザーにタグが設定されていない場合:Backlogの対応者欄に「対応者不明」と表示され、担当者は設定されません
  • Connectのユーザーのタグ値がBacklogのユーザー名と一致しない場合:同じく「対応者不明」と表示され、担当者は設定されません
  • エージェントが対応する前に通話が終了した場合:「対応者はいません」と表示され、担当者は設定されません

いずれの場合も、Backlogチケットの担当者は設定されません。

取得可能なおおよそのユーザー数

Step Functionsは、最大256KBのペイロードサイズをサポートしています。この制限を超えると、データ処理時にエラーが発生します。

プロジェクトユーザー一覧からユーザー情報を取得する際、ユーザー数が多いと256KBの制限を超える可能性があります。制限を超えた場合、以下のようなエラーが発生します。

{
  "cause": "The state/task 'http' returned a result with a size exceeding the maximum number of bytes service limit.",
  "error": "States.DataLimitExceeded",
  "resource": "invoke",
  "resourceType": "http"
}

実際のテストでは、ユーザー一覧APIを使用した場合、約3,000ユーザーで1.4MB弱のデータサイズとなりました。
このことから推測すると、約500ユーザー程度であれば256KBの制限内に収まる可能性が高いです。
あくまで目安であり、ユーザーデータの内容によって実際のサイズは変動します。

https://developer.nulab.com/ja/docs/backlog/api/2/get-project-user-list/

ステートマシンのIAMポリシー

ステートマシンが必要なAPIを呼び出せるよう、適切なIAMポリシーを設定する必要があります。今回は以下の2つの権限が必要です。

  1. HTTP APIの呼び出し権限:Backlog APIを呼び出すために必要です。課題更新APIではPATCHメソッドを使用するため、既存のポリシーにPATCHメソッドを追加します。
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Sid": "InvokeHttpEndpoint1",
            "Action": [
                "states:InvokeHTTPEndpoint"
            ],
            "Resource": [
                "arn:aws:states:ap-northeast-1:111111111111:stateMachine:*"
            ],
            "Condition": {
                "StringEquals": {
                    "states:HTTPMethod": [
                        "POST",
                        "GET",
                        "PATCH"
                    ]
                }
            }
        }
    ]
}
  1. Connect API呼び出し権限:エージェント情報を取得するために、Amazon Connect APIへのアクセス権限が必要です。このため、ステートマシンの実行ロールにAmazonConnectReadOnlyAccessマネージドポリシーも追加で付与しました。

これらの権限を設定することで、ステートマシンは必要なすべてのAPIを呼び出すことができます。

動作確認

実際にシステムを動作させて、各ケースでの挙動を確認しました。

ケース1: 正常に担当者が設定される場合

電話対応後、エージェントがアフターコールワークを完了すると、Backlogにチケットが自動起票され、対応したエージェントが担当者として設定されました。

cm-hirai-screenshot 2025-04-22 11.27.13
ステートマシンの実行フローでは、すべてのステートが正常に完了しています。

cm-hirai-screenshot 2025-04-22 11.27.25

ケース2: タグ設定が不適切な場合

Connectのユーザーに以下のいずれかの問題がある場合、担当者設定が行われません。

  1. タグ「backlog_user_name」が設定されていない場合
    ステートマシンのRetrieveAgentInfoステートでエラーが発生し、Catchブロックが実行されます。

    "Catch": [
      {
        "ErrorEquals": ["States.ALL"],
        "Next": "FinalizeAgentInfo",
        "Assign": {
          "connectUserTagName": "対応者不明",
          "matchedBacklogUserId": "false"
        },
        "Comment": "対応したエージェントにconnectUserTagName名が存在しない"
      }
    ]
    
  2. タグの値がBacklogのユーザー名と一致しない場合
    FetchBacklogUsersステートで一致するユーザーが見つからず、Catchブロックが実行されます。

    "Catch": [
      {
        "ErrorEquals": ["States.QueryEvaluationError"],
        "Comment": "connectUserTagName名とBacklogユーザー名が一致しない",
        "Next": "FinalizeAgentInfo",
        "Assign": {
          "connectUserTagName": "対応者不明",
          "matchedBacklogUserId": "false"
        }
      }
    ]
    

いずれの場合も、Backlogチケットの対応者欄には「対応者不明」と表示され、matchedBacklogUserIdfalseとなるため、担当者設定のステートがスキップされます。

cm-hirai-screenshot 2025-04-22 11.27.01

どちらの場合もステートマシンの実行フローでは、CheckAssigneeRequiredステートで条件分岐が行われ、担当者設定のステートがスキップされています。

cm-hirai-screenshot 2025-04-22 11.26.44
タグの値がBacklogのユーザー名と一致しない場合の実行フロー

ケース3: エージェント未対応の場合

顧客が電話をかけたものの、エージェントが対応する前に通話が終了した場合、Agent情報が存在しないため、CheckAgentExistsステートで以下の条件分岐が実行されます。

"CheckAgentExists": {
  "Type": "Choice",
  "Choices": [
    {
      "Next": "FinalizeAgentInfo",
      "Condition": "{% $data.Agent = null %}",
      "Assign": {
        "connectUserTagName": "対応者はいません",
        "matchedBacklogUserId": "false"
      }
    }
  ],
  "Default": "RetrieveAgentInfo"
}

この条件に一致すると、connectUserTagNameに「対応者はいません」が設定され、matchedBacklogUserIdfalseとなります。その結果、Backlogチケットの対応者欄には「対応者はいません」と表示され、担当者設定のステートがスキップされます。

cm-hirai-screenshot 2025-04-22 11.27.34

ステートマシンの実行フローでは、CheckAssigneeRequiredステートで条件分岐が行われ、matchedBacklogUserIdfalseのため担当者設定のステートがスキップされています。

cm-hirai-screenshot 2025-04-22 11.27.48

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.