[Amazon Connect] 営業時間外に着信したビジネスチャンスを失わないように留守番電話機能をつけてみた

273件のシェア(すこし話題の記事)

1 はじめに

AIソリューション部の平内(SIN)です。

Amazon Connect(以下、Connect)では、昨年末、顧客との通話をリアルタイムでストリームに流す機能が追加されました。

Amazon Connect にリアルタイム顧客音声ストリーム機能を追加

今回は、この機能を利用して、サーバレスで留守番電話を作ってみました。営業時間外に着信した場合、留守電話となってメッセージを受け取り、翌営業日に、担当者が確認するイメージです。

2 構成

構成は以下のとおりです。

  1. Connectのコンタクトフローは、営業時間外には、 留守番電話のメッセージを流して、通話をKinesis Video Streamsに保存します。
  2. 通話終了時に、Streamsに保存したデータの諸元(発信者番号、ストリーム名、フラグメント番号など)をLambdaを利用してS3に保存します。
  3. S3の諸元データが保存されると、その情報を元に、Kinesis Video Streamsから当該データ(RAWデータ)を取得し、WAVファイルに変換し、S3に保存します。
  4. 管理者は、ブラウザから保存されたWAVファイルを再生します。

3 問い合わせフロー

営業時間外の問い合わせフローは、次のようになっています。

問い合わせフローでは、メディアストリーミングの開始ブロックを配置すると、そこからKinesis Video Streamsへのデータ送信が始まり、メディアストリーミングの終了ブロックで、送信は止まります。 今回は、この間に、13秒のブレイクを置いたプロンプト再生を置くことで、メッセージを受け取れるよにしました。

なお、ストリーム機能を利用するには、インスタンスのデータストレージ内にあるライブメディアストリーミングを有効化する必要がありますが、詳しくは、下記のブログをご参照下さい。


[アップデート]Amazon Connectにリアルタイム顧客音声ストリーム機能が追加されました

4 諸元の保存

問い合わせフローからLambdaを呼び出すと、リクエストには、発信者番号やストリームの情報が含まれます。この内、ストリームへの保存完了後に、kinesis Video Streamsからデータを取得する際に必要なのは、StartFragmentNumberStreamARNに含まれているストリーム名です。

 {
    "Details": {
        "ContactData": {
        
            ・・・略・・・
            
            "CustomerEndpoint": {
                "Address": "+819068739557", <= 発信者番号
                ・・・略・・・
            },
            ・・・略・・・
            "MediaStreams": {
                "Customer": {
                    "Audio": {
                        "StartFragmentNumber": "xxxxx", <= 開始フラグメント番号
                        "StartTimestamp": "1555032992938", <=開始タイムスタンプ
                        "StopFragmentNumber": "xxxxx", <=終了フラグメント番号
                        "StopTimestamp": "1555033001125", <=終了タイムスタンプ
                        "StreamARN": "arn:aws:kinesisvideo:ap-northeast-1:xxxx:stream/voicemail-connect-xxxxx/xxx" <= StreamARN
                    }
                }
            },
            ・・・略・・・

いくつかの付加情報を含めて、諸元をS3に保存しているコードは、以下です。

const AWS = require("aws-sdk");
const region = 'ap-northeast-1';
const bucketName = 'voice-mail-stream-information';

exports.handler = async (event) => {
    console.log(JSON.stringify(event));
    
    // リクエストから必要データを取得する
    let streamInformation = {};
    
    const contactData = event.Details.ContactData;
    streamInformation.contactId = contactData.ContactId; // コンタクトID
    streamInformation.customerEndpoint = contactData.CustomerEndpoint.Address; // 発信者番号
    streamInformation.systemEndpoint = contactData.SystemEndpoint.Address; // 着信番号

    const audio = contactData.MediaStreams.Customer.Audio;
    streamInformation.startFragmentNumber = audio.StartFragmentNumber; // 録音データの開始フラグメント番号
    streamInformation.startTimestamp = audio.StartTimestamp; // 録音データの開始時間
    streamInformation.stopFragmentNumber = audio.StopFragmentNumber;// 録音データの終了フラグメント番号
    streamInformation.stopTimestamp = audio.StopTimestamp;// 録音データの終了時間
    streamInformation.streamARN = audio.StreamARN; // ストリームのARN
    
    // S3に日付文字列をキーといして保存する
    const s3 = new AWS.S3({region:region});

    const key = createKeyName();
    const body = JSON.stringify(streamInformation);
    const params = {
        Bucket: bucketName,
        Key: key,
        Body: body
    };
    await s3.putObject(params).promise();
    return {};
};


function createKeyName() {

    const date = new Date();    
    const year = date.getFullYear();
    const mon = (date.getMonth() + 1);
    const day = date.getDate();
    const hour = date.getHours();
    const min = date.getMinutes();
    const sec = date.getSeconds();

    const space = (n) => {
        return ('0' + (n)).slice(-2)
    }
    
    let result = year + '_';
    result += space(mon) + '_';
    result += space(day) + '_';
    result += space(hour) + '_';
    result += space(min) + '_';
    result += space(sec);
    return result;
}

5 データ取得とWAVへの変換

Kinesis Video Streamsからデータを取得しWAVファイルに変換してS3に保存しているメインコードは、以下のとおりです。


const AWS = require("aws-sdk");
const region = 'ap-northeast-1';
const bucketName = 'voice-mail-wav-file';

exports.handler = async (event) => {

    const s3 = new S3(AWS, region);

    for (let record of event.Records) {
        const key = record.s3.object.key;

        // S3から録音データに関する情報を取得
        const data = await s3.get(record.s3.bucket.name, key);
        const info = JSON.parse(data.Body);
        const streamName = info.streamARN.split('stream/')[1].split('/')[0];
        const fragmentNumber =  info.startFragmentNumber;
        
        // Kinesis Video Streamsから当該RAWデータの取得
        const raw = await getMedia(streamName, fragmentNumber);

        // RAWデータからWAVファイルを作成
        const wav = Converter.createWav(raw, 8000);

        // WAVファイルをS3に保存する
        let tagging = ''; // 付加情報をタグに追加する
        tagging += "customerEndpoint=" + info.customerEndpoint + '&';
        tagging += "systemEndpoint=" + info.systemEndpoint + '&';
        tagging += "startTimestamp=" + info.startTimestamp + '&';
        tagging += "stopTimestamp=" + info.stopTimestamp;
        await s3.put(bucketName, key + '.wav', Buffer.from(wav.buffer), tagging)

    }
    return {};
};

class S3 {
    constructor(AWS, region){
        this._s3 = new AWS.S3({region:region});
    }
    async get(bucketName, key){
        const params = {
            Bucket: bucketName,
            Key: key
        };
        return await this._s3.getObject(params).promise();
    }

    async put(bucketName, key, body, tagging) {
        const params = {
            Bucket: bucketName,
            Key: key,
            Body: body,
            Tagging: tagging
        };
        return  await this._s3.putObject(params).promise();
    }

}

Kinesis Video Streamsからデータを取得するには、getMedia()を使用します。なお、getMedia()は、予めgetDataEndpoint()で取得したエンドポインで初期化が必要です。

getMedia()は、ストリーム名とフラグメント番号を指定することで、当該データが取得できます。

取得したデータは、Matroska(MKV)のコンテナとなっていますので、ebmlにより、チャンクごとに分解して、コンテナ内のデータのみを取得しています。

const ebml = require('ebml');

async function getMedia(streamName, fragmentNumber) {
    // Endpointの取得
    const kinesisvideo = new AWS.KinesisVideo({region: region});
    var params = {
        APIName: "GET_MEDIA",
        StreamName: streamName
    };
    const end = await kinesisvideo.getDataEndpoint(params).promise();

    // RAWデータの取得
    const kinesisvideomedia = new AWS.KinesisVideoMedia({endpoint: end.DataEndpoint, region:region});
    var params = {
        StartSelector: { 
            StartSelectorType: "FRAGMENT_NUMBER",
            AfterFragmentNumber:fragmentNumber,
        },
        StreamName: streamName
    };
    const data = await kinesisvideomedia.getMedia(params).promise();
    const decoder = new ebml.Decoder();
    let chunks = [];
    decoder.on('data', chunk => {
        if(chunk[1].name == 'SimpleBlock'){
            chunks.push(chunk[1].data);
        }
    });
    decoder.write(data["Payload"]);
    
    // chunksの結合
    const margin = 4; // 各chunkの先頭4バイトを破棄する
    var sumLength = 0;
    chunks.forEach( chunk => {
        sumLength += chunk.byteLength - margin;
    })
    var sample = new Uint8Array(sumLength);
    var pos = 0;
    chunks.forEach(chunk => {
        let tmp = new Uint8Array(chunk.byteLength - margin);
        for(var e = 0; e < chunk.byteLength -  margin; e++){
            tmp[e] = chunk[e + margin];
        }
        sample.set(tmp, pos);
        pos += chunk.byteLength - margin;

    })
    return sample.buffer;
}

実は、Connectで保存された、MKVコンテナの音声データは、Single 16bit PCM 8000Hz のRAWデータであり 、バイトオーダーは、リトルインディアンとなっています。

このままでは、再生することが出来ないので、ヘッダを追加してWAVファイルに変換しています。


class Converter {
    // WAVファイルの生成
    static createWav(samples, sampleRate) {
        const len = samples.byteLength;
        const view = new DataView(new ArrayBuffer(44 + len));
        this._writeString(view, 0, 'RIFF');
        view.setUint32(4, 32 + len, true);
        this._writeString(view, 8, 'WAVE');
        this._writeString(view, 12, 'fmt ');
        view.setUint32(16, 16, true);
        view.setUint16(20, 1, true); // リニアPCM
        view.setUint16(22, 1, true); // モノラル
        view.setUint32(24, sampleRate, true); 
        view.setUint32(28, sampleRate * 2, true);
        view.setUint16(32, 2, true);
        view.setUint16(34, 16, true);
        this._writeString(view, 36, 'data');
        view.setUint32(40, len, true);
        let offset = 44;
        const srcView = new DataView(samples);
        for (var i = 0; i < len; i+=4, offset+=4) {
            view.setInt32(offset, srcView.getUint32(i));
        }
        return view;
    }
    
    static _writeString(view, offset, string) {
        for (var i = 0; i < string.length; i++) {
          view.setUint8(offset + i, string.charCodeAt(i));
        }
    }
}

6 録音データの一覧

管理者が、録音データを確認するためのページでは、CognitoのPoolIdでS3へのパーミッションを付与した、スクリプトになっています。なお、表示用のスクリプトを配置するS3にはCORSの設定が必要です。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Voice Mail Viewer</title>
    <script src="https://sdk.amazonaws.com/js/aws-sdk-2.283.1.min.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <link rel="stylesheet" type="text/css" href="style.css">
  </head>
  <body>
    <h1 id="time">留守番電話</h1>
    
    <audio id="sound" preload="auto">
    </audio>

    <table id="contents"/ border=1>
    <script>
        AWS.config.region = 'ap-northeast-1'; 
        AWS.config.credentials = new AWS.CognitoIdentityCredentials({
            IdentityPoolId: 'ap-northeast-1:xxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
        });
        const bucketName = 'voice-mail-wav-file';
        const s3 = new AWS.S3();

        async function refresh(){
            const list = await s3.listObjects({Bucket: bucketName}).promise();
            const keys = list.Contents.map( v => v.Key );
            let items = await Promise.all(keys.map(async (key)=>{
                
                // S3上のオブジェクトのタグ情報を取得する
                const tagging = await s3.getObjectTagging({Bucket: bucketName, Key: key }).promise();
                const customerEndpoint = tagging.TagSet.find((v => { return v.Key == "customerEndpoint"}))["Value"];
                const startTimestamp = Number(tagging.TagSet.find((v => { return v.Key == "startTimestamp"}))["Value"]);

                const item = {
                    customerEndpoint: endpoint,
                    startTimestamp: dateString(new Date(startTimestamp)), // yyyy/mm/dd hh:mm
                    key: key
                };
                return item;
            }));
            // 録音時間でソートする
            items = items.sort((a, b) => b.startTimestamp - a.startTimestamp);

            const table = $("#contents");
            const tr = table.append($("<tr></tr>"))
            tr.append("<th>着信時間</th>")
            tr.append("<th>発信番号</th>")
            tr.append("<th></th>")

            // 一覧の表示
            items.forEach( item => {
                const customerEndpoint = $("<td></td>").text(item.customerEndpoint);
                const startTimestamp = $("<td></td>").text(item.startTimestamp);
                const listenButton = $("<td><img src=listen.png></td>").click(()=> {listen(listenButton,item.key)});
                const tr = table.append($("<tr></tr>"))
                tr.append(startTimestamp)
                tr.append(customerEndpoint)
                tr.append(listenButton)
            });
        }

        refresh();

        // スピーカーのアイコンをクリックした際にWAVファイルを取得する
        async function listen(element, key) {
            var params = {
                Bucket: bucketName,
                Key: key
            };
            const data = await s3.getObject(params).promise();
            var blob = new Blob([data.Body], {type : 'audio/wav'});
            element.html('<audio src=' + URL.createObjectURL(blob) + ' controls></audio>');
        }

        function dateString(date) {
            const year = date.getFullYear();
            const mon = (date.getMonth() + 1);
            const day = date.getDate();
            const hour = date.getHours();
            const min = date.getMinutes();

            const space = (n) => {
                return ('0' + (n)).slice(-2)
            }

            let result = year + '/';
            result += space(mon) + '/';
            result += space(day) + ' ';
            result += space(hour) + ':';
            result += space(min);
            return result;
        }

    </script>
</body>
</html>

7 Cognito

最後に、CoginitoPoolIdへ付与したパーミションです。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetBucketTagging",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::voice-mail-wav-file"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObjectTagging",
                "s3:*Object"
            ],
            "Resource": [
                "arn:aws:s3:::voice-mail-wav-file/*"
            ]
        }
    ]
}

8 最後に

今回は、Connectの通話をストリームに流す機能を利用して、留守番電話を作成してみました。

Kinesis Video Streamsに保存されたRAWデータを、再生可能なファイルにデコードするのに、ちょっと手間取りましたが、全部、Lambdaだけで実装できたので、サーバレスな仕組みとして、簡単に再利用可能なのでは無いでしょうか。

アイコンは、下記のものを利用させて頂きました。
ICOON MONO


弊社ではAmazon Connectのキャンペーンを行なっております。

3月に引き続き、4月も「無料Amazon Connectハンズオンセミナー」を開催致します。導入を検討されておられる方は、是非、お申し込み下さい。

また音声を中心とした各種ソリューションの開発支援も行なっております。

コメントは受け付けていません。