🀄️

Vonage でのキューイング実装

はじめに

みなさん、こんにちは。KDDI ウェブコミュニケーションズで CPaaS のエバンジェリストをしている高橋です。
この記事では、Vonage の Voice 機能を使って、キューイングを実装する方法について解説します。

本記事の対象となる読者

  • Vonage Voice SDK に興味のある方
  • Vonage Voice SDK で開発を検討されている方
  • Vonage Voice SDK を使ったキューイングについて知りたい方
  • Twilio から Vonage へのマイグレーションを検討されている方

事前準備

本記事の内容を実装するためには、あらかじめ以下の準備が必要になります。

  • Vonage アカウントを保有していること
  • Vonage で電話番号を購入していること
  • 保留時に流す mp3 ファイルを用意していること(ネット上で「効果音 mp3」などと検索すると見つかると思います)

アカウントを持っていない方は、以下の記事を参考にアカウントを開設してください。

電話番号を持っていない方は、以下の記事を参考に電話番号を購入してください。

Vonage とは

Vonage_logo

Vonage は、米国ニュージャージー州に本社を置く、CPaaS(Communication Platform as a Service)企業です。
もともとは VoIP(Voice over IP)企業としてスタートしましたが、いくつかの企業買収を行うことで、コミュニケーションサービス全般をサポートすることができる企業に発展しました。現在はスウェーデンの大手通信機器会社エリクソンの傘下に入っています。

2024年2月14日より、株式会社KDDIウェブコミュニケーションズ(以後、KWC)が Vonage の再販事業を開始することとなりました。
KWC経由でアカウントを開設する場合、Vonageで直接開設したアカウントとは一部仕様が異なります。(※提供する機能面において違いはありません)

なお、本記事ではKWC経由でのアカウントを使って説明を行います。

Vonage Cloud Runtime

今回はサーバー環境として、Vonage Cloud Runtime(VCR)を利用します。
Vonage Cloud Runtime (VCR)は、Vonage が提供するクラウド型のアプリケーションサーバーです。
VCR上に構築されたアプリケーションはVonage上にホスティングされるため、皆さん側でAWSなどの外部サーバーを準備する必要がありません。
これにより、Vonage API を使ってすぐにPoCやテストが行えるほか、外部のAPIサービスと連携したアプリケーションを構築することもできます。
VCR については、以下の記事もぜひご覧ください。

キューイングとは

キューイングとは、通話中のコールを一時的にキューと呼ばれる領域に保管することをいいます。キューに入れられたコールは保留音が流れて、待ち状態になります。そのため、このような状態のことを「待ち呼」と呼ぶこともあります。
Twilio にはこのキューイングのための仕組みとして、<Enqueue><Leave><Dial><Queue>といった TwiML が用意されていました。

Queue

しかし、Vonage にはこのような NCCO は用意されていないため、キューイングを行うには独自の実装が必要となります。

実現方法

Vonage でキューイングを実装するには、NCCO の Conversation を利用するのがよいでしょう。
Conversation は本来、複数人で会議を行うための機能ですが、この Conversation には、会議のモデレーターが参加するまで他の参加者に音楽を聞かせておく機能が用意されています。そのため今回はこちらを使ってキューイングを実装していきたいと思います。

今回のシナリオは以下のようなフローになります。

Queue on Vonage

  1. 最初のコールが UserA から入ります(incoming)。
  2. Vonage が Webhook を、VCR で作成した /enqueue エンドポイントに送信します。
  3. /enqueueは NCCO を返却し、UserA のコールが Conversation に参加します。このとき、UserA は参加者として参加するため、モデレーターが入室するまで音楽が流れます。
  4. つぎに、/dialエンドポイントにアクセスします。
  5. Conversation に UserA が存在しているかを確認します。
  6. UserA の確認ができたら、UserB にアウトバウンドコールを行う Rest API を Vonage に対して発行します。このときに、NCCO を併せてリクエストすることで、UserBが応答したときに UserB のコールがConversationに参加します。UserB はモデレーターとして参加するため、参加時点で UserA との通話が確立します。

VCR のセットアップ

では最初に VCR 環境を作成します。
VCR を作成すると、自動的に Vonage アプリケーションも作成されます。

Vonage ダッシュボードにログイン

こちらのページから、Vonage ダッシュボードにログインをします。
Vonage 本家でアカウントを作成した場合は、こちらからログインしてください。

Code Hub

  • Code Hub タブに遷移します。
    Developer Page

テンプレートが表示されます。

  • 一覧からStarter Projectを見つけて、クリックします。

Starter Project

  • Starter Project の詳細ページに遷移します。

Starter Project get code

  • 画面中央に表示されている Get Code タブを選択します。
  • Create a new development environment ボタンを押します。

Set up your workspace

  • Region は、「AWS - Asia Pacific」を選択します。
  • Workspace name は、「Vonage Voice Queue」としておきます。
  • Continue ボタンを押します。

しばらくすると画面が遷移し、以下のようなコーディングページが表示されます。

VCR Init page

保留音の準備

事前に準備した着信音の mp3 ファイルを VCR 上に格納します。

hold-music.mp3

  • 左側のEXPLORERpublicフォルダ内に着信音(mp3ファイル)を保存してください(ドラッグアンドドロップでインポートできます)。
  • ファイル名は、hold-music.mp3という名前にしてください。

秘密鍵の準備

コーディングに入る前に、Vonageアプリケーション内で秘密鍵を作成していきます。

  • ブラウザで、Vonage ダッシュボードを開いているタブに移動します。
  • アプリケーションの一覧から今作成したVonage Voice Queueを選択します(表示されない場合はリロードしてください)。

Vonageアプリケーション

  • 画面右側にある編集ボタンを押します。

秘密鍵の生成

  • 公開鍵と秘密鍵を生成ボタンを押します。

  • private.keyが生成され、自動的にダウンロードされます。

保存

  • 画面右下の変更を保存ボタンを押します。

では次に、今作成した秘密鍵をVCRアプリケーションに取り込んでみましょう。

private.key

  • プロジェクトの直下に、privateというフォルダを新規に作成します。
  • 先ほど作成したprivate.keyを今作成したフォルダにインポートします(ドラッグアンドドロップでインポートできます)。

続いて、秘密鍵のパスを環境変数に指定します。また、Vonageで取得してある電話番号についても同じように環境変数に指定しておきます。

  • vcr.ymlを選択します。

  • project/nameを「vonage-voice-queue」に書き換えます。

  • environmentの内容を以下のように書き換えます。

  environment:
    - name: PRIVATE_KEY_PATH
      value: "./private/private.key"
    - name: VONAGE_NUMBER
      value: "8150XXXXXXXX" 

vcr.yml

以上で秘密鍵の準備は完了です。

サーバーサイドのコーディング

  • 左側のEXPLORERからindex.jsを選択して、現在の内容を以下のコードに置き換えてください。
index.js
import { Vonage } from "@vonage/server-sdk";
import { vcr } from "@vonage/vcr-sdk";
import express from 'express';
import fs from 'fs';

const app = express();
const port = process.env.VCR_PORT;

const vonage = new Vonage({
    applicationId: process.env.API_APPLICATION_ID,
    privateKey: process.env.PRIVATE_KEY_PATH
}, {
    restHost: 'https://api-ap-3.vonage.com',
    apiHost: 'https://api-ap-3.vonage.com'
})

const CONVERSATION_FILE = './conversation.json'

app.use(express.json());
app.use(express.static('public'));

app.get('/_/health', async (req, res) => {
    res.sendStatus(200);
});

app.post('/enqueue', async (req, res, next) => {
    console.log(`🐞 enqueue called. ${JSON.stringify(req.body, null, '\t')}`);
    // Write conversation file.
    try {
        const uuid = req.body.uuid || ''
        fs.writeFileSync(CONVERSATION_FILE, JSON.stringify({ uuid }));

        // Return NCCO that join the conversation.
        res.json([
            {
                action: 'conversation',
                name: uuid,
                startOnEnter: false,
                endOnExit: true,
                musicOnHoldUrl: [`${vcr.getAppUrl()}/hold-music.mp3`],
              },
          
        ]);
    } catch (error) {
        console.error(`👺 enqueue error. ${error.message}`);
        res.sendStatus(500);
    }
})

app.post('/event', async (req, res, next) => {
    console.log(`🐞 Event called. ${JSON.stringify(req.body, null, '\t')}`);
    res.sendStatus(200);
})

app.post('/dial', async (req, res, next) => {
    console.log(`🐞 dial called.`)
    try {
        // Check the parameter.
        const to = req.body.to || '';
        if (!to) throw new Error('Parameter <to> not exist');

        // Check the conversation.
        const conversationPool = fs.existsSync(CONVERSATION_FILE) ? JSON.parse(fs.readFileSync(CONVERSATION_FILE), 'utf8') : '';
        if (!conversationPool || !conversationPool.uuid) {
            throw new Error('conversation not exist.');
        }
        const conversationList = await vonage.conversations.getConversationPage({ pageSize: 10, order: 'desc'});
        let conversationExist = false;
        for await (const conversation of conversationList.conversations) {
            if (conversation.name === conversationPool.uuid) {
                const memberList = await vonage.conversations.getMemberPage(conversation.id, { pageSize: 10, order: 'desc'});
                if (memberList.members[0].state === 'JOINED') {
                    conversationExist = true;
                }
            }
        }

        // Outbound call start with NCCO when conversation existing only.
        if (conversationExist) {
            await vonage.voice.createOutboundCall({
                from: {
                    type: 'phone',
                    number: process.env.VONAGE_NUMBER
                },
                to: [{
                    type: 'phone',
                    number: `${to}`
                }],
                eventUrl: [`${vcr.getAppUrl()}/event`],
                eventMethod: 'POST',
                ncco: [{
                    action: 'conversation',
                    name: conversationPool.uuid,
                    startOnEnter: true,
                    endOnExit: true,
                }]
            });
        }
        res.sendStatus(200);
    } catch (error) {
        console.error(`👺 dial error. ${error.message}`)
        res.sendStatus(500);
    }
})

app.listen(port, () => {
    console.log(`App listening on port ${port}`)
});

では、プログラムの説明をしていきましょう。

enqueue エンドポイント

最初のユーザーが電話をしてきたときにコールを Conversation に入れる NCCO を返します。

/enqueue
app.post('/enqueue', async (req, res, next) => {
    console.log(`🐞 enqueue called. ${JSON.stringify(req.body, null, '\t')}`);
    // Write conversation file.
    try {
        const uuid = req.body.uuid || ''
        fs.writeFileSync(CONVERSATION_FILE, JSON.stringify({ uuid }));

        // Return NCCO that join the conversation.
        res.json([
            {
                action: 'conversation',
                name: uuid,
                startOnEnter: false,
                endOnExit: true,
                musicOnHoldUrl: [`${vcr.getAppUrl()}/hold-music.mp3`],
              },
          
        ]);
    } catch (error) {
        console.error(`👺 enqueue error. ${error.message}`);
        res.sendStatus(500);
    }
})

今回は Conversation の名前に着信した際に自動的に割り当てられる UUID を利用しています。これにより、着信ごとに異なる Conversation を生成できます。作成した Conversation の名前(UUID)は、後で利用したいために、プロジェクトの直下にconversation.jsonという名前で保存するようにしています。

返却する NCCO のポイントは、startOnEnterfalseを指定するところです。この指定により、このユーザーは参加者としてConversationに参加することになります。
よって、モデレーターが参加してくるまで、hold-music.mp3が再生されることになります。

endOnExit: trueを指定しておくことで、キューに入っているコールのいずれかが切断したときに通話が終了します。

dial エンドポイント

UserB に発信をするためのエンドポイントになります。最初に、UserA が Conversation にまだ存在しているかをチェックし、その後で Vonage Voice の RestAPI コールして発信を行います。
発信先の電話番号はtoパラメータとして受け取るようにしています。

/dial
app.post('/dial', async (req, res, next) => {
    console.log(`🐞 dial called.`)
    try {
        // Check the parameter.
        const to = req.body.to || '';
        if (!to) throw new Error('Parameter <to> not exist');

        // Check the conversation.
        const conversationPool = fs.existsSync(CONVERSATION_FILE) ? JSON.parse(fs.readFileSync(CONVERSATION_FILE), 'utf8') : '';
        if (!conversationPool || !conversationPool.uuid) {
            throw new Error('conversation not exist.');
        }
        const conversationList = await vonage.conversations.getConversationPage({ pageSize: 10, order: 'desc'});
        let conversationExist = false;
        for await (const conversation of conversationList.conversations) {
            if (conversation.name === conversationPool.uuid) {
                const memberList = await vonage.conversations.getMemberPage(conversation.id, { pageSize: 10, order: 'desc'});
                if (memberList.members[0].state === 'JOINED') {
                    conversationExist = true;
                }
            }
        }

        // Outbound call start with NCCO when conversation existing only.
        if (conversationExist) {
            await vonage.voice.createOutboundCall({
                from: {
                    type: 'phone',
                    number: process.env.VONAGE_NUMBER
                },
                to: [{
                    type: 'phone',
                    number: `${to}`
                }],
                eventUrl: [`${vcr.getAppUrl()}/event`],
                eventMethod: 'POST',
                ncco: [{
                    action: 'conversation',
                    name: conversationPool.uuid,
                    startOnEnter: true,
                    endOnExit: true,
                }]
            });
        }
        res.sendStatus(200);
    } catch (error) {
        console.error(`👺 dial error. ${error.message}`)
        res.sendStatus(500);
    }
})

UserA が Conversation に存在しているかを確認する方法は次のとおりです。

  • /enqueue エンドポイントで着信時に保存しておいたconversation.jsonを読み取ります。
  • そこに入っているuuidパラメータを使って、既存の conversation の中から該当する Conversation を探し出します。
  • 該当する Conversation が存在する場合は、さらにその中のメンバー一覧を取得します。
  • その中にステータスがJOINEDのものが存在すれば、UserA が存在することになります。

UserA の存在が確認できた場合は、引き続き Vonage Voice API を使って発信処理を行います。
この際に、一緒に NCCO を設定しておくことで、発信先(この場合は UserB)が応答したときに、その NCCO が自動的に実行されます。
NCCO には、UserA が入っている Conversation を指定しています。
先ほどと違うのは、startOnEnter: trueの部分です。この指定により該当コールはモデレーターとして参加することになり、参加した時点ですでに参加中の参加者と通話が成立します。

デプロイ

では実際にこれらのプログラムをデプロイしてみましょう。
その前に、package.jsonの設定を少し変更しておきます。

  • 左側のEXPLORERからpackage.jsonを開いて、以下の内容に変更します。
{
  "name": "vonage-voice-queue",
  "type": "module",
  "version": "1.0.0",
  "description": "Vonage Voice Queue",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "debug": "vcr debug"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@vonage/server-sdk": "^3.14.0",
    "@vonage/vcr-sdk": "1.2.0",
    "express": "4.19.2"
  }
}

作成したプログラムをサーバー上にデプロイします。

  • Terminalメニューの中のNew Terminalを選択します。

New Terminal

画面下部にターミナルウィンドウが開きます。

  • ターミナルウィンドウで、以下のコマンドを入力します。
npm install && vcr deploy

デプロイがスタートします。デプロイは1分程度かかります。

 ℹ️  creating new project for project "vonage-voice-queue"
 ✅ project "vonage-voice-queue" created: project_id="40d17e8d-9eea-463c-ae99-742136f6d99d"
working directory: /home/coder/project
 ℹ️  compressing directory: /home/coder/project
 ℹ️  compressed: size 9676923 byte(s) contents 5338 file(s)... done.
 ℹ️  uploading source code...
 ✅ source code uploaded
 ℹ️  creating package from source code...
 ℹ️  package id: e63e3a85-f09d-4dc9-b0f2-fcc705aa9dc6
 ℹ️  package build status is 'pending' - image will begin building soon...
 ℹ️  package build status is 'building' - image is being built...
 ✅ package build status is 'completed' - image is ready to be deployed!
 ✅ instance has been deployed!
      instance id: acc7c033-3781-4443-812c-96ea979b7571
      instance service name: neru-XXXXXXXX-vonage-voice-queue-dev
      instance host address: https://neru-XXXXXXXX-vonage-voice-queue-dev.apse1.runtime.vonage.cloud

上の例のように、instance has been deployed!が表示されればOKです。
最後に表示された host address: はこのあとで利用しますので、メモ帳などに記録しておきましょう。

Vonage アプリケーションの設定

では最後に、再び Vonage アプリケーションに対して、電話番号のアサインと Webhook の設定を行っていきましょう。

  • ブラウザで、Vonage ダッシュボードを開いているタブに移動します。
  • アプリケーションの一覧から今作成したVonage Voice Queueを選択します。

Vonageアプリケーション

  • 画面右側にある編集ボタンを押します。

音声ON

電話番号のリンク

  • 前の画面に戻ったら、電話番号一覧からこのアプリケーションで利用する番号の右側にあるリンクボタンを押します。

以上で設定はすべて完了です。

テスト

まずは、お手持ちの電話機から Vonage アプリケーションにリンクした電話番号に電話をかけます。
保留音が流れます。

次に、以下のコマンドを使って UserB に発信します。接続先のURLは、先程デプロイしたときに割り当てられたものに書き換えてください。
また、toパラメータには、 UserB の電話番号を指定してください。

curl -X POST --location 'https://neru-XXXXXXXX-debug-debug.apse1.runtime.vonage.cloud/dial' \
--header 'Content-Type: application/json' \
--data '{
    "to": "8190XXXXXXXX"
}'

指定したもう一台の電話機に電話がかかり、応答すると最初のコールとの通話が成立すれば成功です。
UserA が先に切断してしまった場合に、上記のコマンドを実行しても UserB には電話がかからないことも確認しておきましょう。

まとめ

この記事では、Vonage の NCCO を使ってキューイングを実装してみました。通話保留をするときも、今回のようにConversationを活用するとよいと思います。
Twilio のキューイングと違って、1つのキューに複数のコールを入れて FIFO でコールを抜き出すようなことは難しいため、1コールに対して個別の Conversation を作成し、別途 Conversation を管理しながら別のコールに割り当てていくような実装が必要になります。

KWCPLUS

Discussion