🀄

Vonage で複数人へ同時に通話を発信する(C#版)

に公開

はじめに

こんにちは。KDDI ウェブコミュニケーションズの西嵜(にしざき)です。

この記事では、Vonage を用いて、着信通話があった時に、複数人へ(ほぼ)同時に通話発信し、最初に応答した電話とのみ通話を接続する方法をご紹介します。コールセンターや障害対応の連絡など、「複数人へ同時に通話発信するが、実際に通話を接続するのは一人だけにしたい」というケースで利用できます。

本記事の対象となる読者

  • Vonage に興味のある方
  • Vonage を用いて、複数人へ(ほぼ)同時に通話発信する方法を学びたい方

注意事項

  • 「(ほぼ)同時に」とある通り、この記事でご紹介するプログラムで実行する通話発信は、厳密に言うと同時には実行されません。
    • Vonage の仕様として、「1 秒間に同時に通話発信できるのは 3 件まで (3 CPS = 3 Calls Per Second)」という制約があります。そのため、発信先が 4 件以上である場合、若干のタイムラグが発生する可能性があります。
  • Vonage に限定されない、電話における一般的な制約ですが、キャリアや電波などの状況によって、発信した通話が電話機に着電するタイミングが前後する可能性があります。
    • そのため、上述した 3CPS の 2 巡目に発信した通話の方が、1 巡目の通話よりも早く着電するケースもあります。

この記事のプログラムの概要

この記事でご紹介するプログラムは、以下のように動作します。

  • Vonage 電話番号に着信したら、
    • 発信者に対してメッセージを流したのち、Conversation (2 名以上で同時通話可能なルーム) に入室、待機させる。
    • 着信側の電話番号に対して、3 CPS で順次、通話を発信する。
  • 着信側が通話に応答したら、メッセージを流したのち、DTMF の入力を促す。
  • 着信側が DTMF として「1」を入力したら、
    • 「1」を入力した最初の着信側である場合は、発信者が入室している Conversation に入室させ、通話を開始する。
    • 「1」を入力した別の着信側が既にいる場合は、メッセージを流したのち、通話を切断する。

準備

この記事でご紹介する手順では、以下のものが必要となります。

  • Vonage アカウント
    • API キー(アカウントを開設すると、自動的に付与されます)
    • Vonage 電話番号
  • C#
    • 最新のバージョンを推奨します。
  • .NET
  • Vonage CLI
    • Vonage のさまざまな要素を操作するためのコマンドライン・インターフェースです。
  • ngrok
    • ローカル PC で作成したプログラムを外部に公開するために利用します。
  • 電話機 3 台以上
    • 今回は複数人へ同時に通話を発信するため、発信側 1 台と着信側に最低 2 台、合わせて 3 台以上の電話機が必要となります。

手順

C# による同時通話発信プログラム用の .NET プロジェクトの生成

プログラムのコードを実装する前に、プログラムを開発するための .NET プロジェクトを生成します。

# Linux/macOSなどの場合
dotnet new webapi -n VonageParallelCalls
cd VonageParallelCalls
ls -l Program.cs

# Windowsの場合
dotnet new webapi -n VonageParallelCalls
cd VonageParallelCalls
dir Program.cs

上記の Program.cs が、プログラムのメインファイルとなります。

C# による同時通話発信プログラムの実装

複数人への同時通話発信を実現するためのプログラムを、C# を用いて実装します。

先述の Program.cs ファイルを開き、既存のコードを全て削除してから、以下の C# のコードをコピー&ペーストし、上書き保存します。

Program.cs
using System.Text.Json;
using System.Text.Json.Nodes;
using Vonage;
using Vonage.Request;
using Vonage.Voice;
using Vonage.Voice.Nccos;
using Vonage.Voice.Nccos.Endpoints;

var port = "3000";
var urlMusicOnHold = "URL_MUSIC_ON_HOLD";

var applicationId = "VONAGE_APPLICATION_ID";
var privateKeyPath = "VONAGE_APPLICATION_PRIVATE_KEY_PATH";
var vonagePhoneNumber = "VONAGE_NUMBER";
var destinationNumbers = new string[] {
    "DESTINATION_NUMBER_1",
    "DESTINATION_NUMBER_2",
};

var builder = WebApplication.CreateBuilder();
builder.Services.AddLogging(logging =>
    {
        logging.ClearProviders();
        logging.AddConsole();
        logging.AddDebug();
    }
);
var app = builder.Build();

// Vonage電話番号に着信した際に実行されるエンドポイント。
app.MapPost("/api/voice/answer", async (HttpContext context, ILogger<Program> logger) =>
{
    var url = GetUrl(context);
    logger.LogInformation($"URL: {url}");

    try
    {
        var requestBody = await GetRequestBody(context);
        logger.LogInformation($"Request body: {requestBody}");
        var node = JsonNode.Parse(requestBody);

        var uuid = (string?)node?["uuid"];
        if (string.IsNullOrEmpty(uuid))
        {
            throw new Exception("UUID not found.");
        }
        var baseUrl = GetBaseUrl(context);
        var client = InitVonageClient(applicationId, privateKeyPath);

        await Task.Run(async () =>
        {
            try
            {
                // トークンファイルを生成する。いずれかの着信側が「1」を入力すると削除される。
                CreateToken(uuid);

                foreach (var phoneNumber in destinationNumbers)
                {
                    var callResponse = await CallToCalleeAsync(client, phoneNumber, baseUrl, uuid, logger);
                    logger.LogInformation($"Call UUID: {callResponse.Uuid}");
                }
                logger.LogInformation("Initiated calls to callees");
            }
            catch (Exception ex)
            {
                RemoveToken(uuid);
                logger.LogError(ex, $"Failed to initiate calls to callees: {ex.Message}");
                throw;
            }
        });

        var ncco = GetCallerNcco(uuid);
        logger.LogInformation($"NCCO: {ncco.ToString()}");
        return ResultsFromNCCO(ncco);
    }
    catch (Exception ex)
    {
        logger.LogError(ex, $"Error in endpoint {url}: {ex.Message}");
        return Results.Problem("Error processing voice call", statusCode: 500);
    }
});

// 着信側に通話を発信した際に実行されるエンドポイント。
app.MapGet("/api/voice/callee-answer/{uuid}", (HttpContext context, string uuid, ILogger<Program> logger) =>
{
    var url = GetUrl(context);
    logger.LogInformation($"URL: {url}");

    try
    {
        var baseUrl = GetBaseUrl(context);
        var dtmfCallbackUrl = $"{baseUrl}/api/voice/callee-dtmf/{uuid}";
        var ncco = GetCalleeNcco(dtmfCallbackUrl);
        logger.LogInformation($"NCCO: {ncco.ToString()}");
        return ResultsFromNCCO(ncco);
    }
    catch (Exception ex)
    {
        logger.LogError(ex, $"Error in endpoint {url}: {ex.Message}");
        return Results.Problem("Error processing callee answer", statusCode: 500);
    }
});

// 着信側がDTMFを入力した際に実行されるエンドポイント。
app.MapPost("/api/voice/callee-dtmf/{uuid}", async (HttpContext context, string uuid, ILogger<Program> logger) =>
{
    var url = GetUrl(context);
    logger.LogInformation($"URL: {url}");

    try
    {
        var requestBody = await GetRequestBody(context);
        logger.LogInformation($"Request body: {requestBody}");
        var node = JsonNode.Parse(requestBody);

        var dtmfDigit = (string?)node?["dtmf"]?["digits"];
        logger.LogInformation($"DTMF: {dtmfDigit}");

        if (string.IsNullOrEmpty(dtmfDigit) || dtmfDigit != "1")
        {
            var ncco = GetCalleeTimeoutNcco();
            logger.LogInformation($"NCCO: {ncco.ToString()}");
            return ResultsFromNCCO(ncco);
        }

        // トークンファイルを削除する。最初に「1」を入力した着信側のみ、削除に成功する。
        var isFirstResponder = RemoveToken(uuid);

        // 最初に「1」を入力した着信側は、発信者が待つConversationに入室する。
        if (isFirstResponder)
        {
            var ncco = GetCalleeAcceptNcco(uuid);
            logger.LogInformation($"NCCO: {ncco.ToString()}");
            return ResultsFromNCCO(ncco);
        }
        // それ以外の着信側は、通話が切断される。
        else
        {
            var ncco = GetCalleeRejectNcco();
            logger.LogInformation($"NCCO: {ncco.ToString()}");
            return ResultsFromNCCO(ncco);
        }
    }
    catch (Exception ex)
    {
        logger.LogError(ex, $"Error in endpoint {url}: {ex.Message}");
        return Results.Problem("Error processing DTMF input", statusCode: 500);
    }
});

// Vonage電話番号への着信通話について発生したイベントを受け取るエンドポイント。
app.MapPost("/api/voice/events", (HttpContext context, ILogger<Program> logger) =>
{
    var url = GetUrl(context);
    logger.LogInformation($"URL: {url}");

    return Results.Ok();
});

app.Run($"http://*:{port}");

string GetUrl(HttpContext context)
{
    var request = context.Request;
    var url = $"https://{request.Host}{request.Path}";
    return url;
}

string GetBaseUrl(HttpContext context)
{
    var request = context.Request;
    var url = $"https://{request.Host}";
    return url;
}

async Task<string> GetRequestBody(HttpContext context)
{
    var request = context.Request;
    var requestBody = await new StreamReader(request.Body).ReadToEndAsync();
    return requestBody;
}

IResult ResultsFromNCCO(Ncco ncco)
{
    var nccoString = ncco.ToString();
    var jsonObj = JsonSerializer.Deserialize<object>(nccoString);
    return Results.Json(jsonObj);
}

Ncco GetCallerNcco(string uuid)
{
    var conversationId = $"conversation.{uuid}";
    var talkAction = new TalkAction
    {
        Text = "こんにちは。しばらくお待ちください。",
        Language = "ja-JP",
    };
    var conversationAction = new ConversationAction
    {
        Name = conversationId,
        StartOnEnter = false,
        EndOnExit = true,
        MusicOnHoldUrl = new[] { urlMusicOnHold },
    };
    var ncco = new Ncco
    {
        talkAction,
        conversationAction,
    };
    return ncco;
}

Ncco GetCalleeNcco(string callbackUrl)
{
    var talkAction = new TalkAction
    {
        Text = "着信がありました。応答する場合は1を押してください。",
        Language = "ja-JP",
        BargeIn = true,
    };
    var inputAction = new MultiInputAction
    {
        EventUrl = new[] { callbackUrl },
        Dtmf = new DtmfSettings {
            MaxDigits = 1,
            TimeOut = 10,
        },
    };
    var ncco = new Ncco
    {
        talkAction,
        inputAction,
    };
    return ncco;
}

Ncco GetCalleeAcceptNcco(string uuid)
{
    var conversationId = $"conversation.{uuid}";
    var talkAction = new TalkAction
    {
        Text = "通話に接続します。しばらくお待ちください。",
        Language = "ja-JP",
    };
    var conversationAction = new ConversationAction
    {
        Name = conversationId,
        EndOnExit = true,
    };
    var ncco = new Ncco
    {
        talkAction,
        conversationAction,
    };
    return ncco;
}

Ncco GetCalleeRejectNcco()
{
    var talkAction = new TalkAction
    {
        Text = "別のオペレーターが先に応答しました。通話を終了します。",
        Language = "ja-JP",
    };
    var ncco = new Ncco
    {
        talkAction,
    };
    return ncco;
}

Ncco GetCalleeTimeoutNcco()
{
    var talkAction = new TalkAction
    {
        Text = "入力がありませんでしたので、通話を終了します。",
        Language = "ja-JP",
    };
    var ncco = new Ncco
    {
        talkAction,
    };
    return ncco;
}

VonageClient InitVonageClient(string applicationId, string privateKeyPath)
{
    var privateKey = File.ReadAllText(privateKeyPath);
    var credentials = Credentials.FromAppIdAndPrivateKey(applicationId, privateKey);
    var client = new VonageClient(credentials);
    return client;
}

async Task<CallResponse> CallToCalleeAsync(VonageClient client, string phoneNumber, string baseUrl, string uuid, ILogger<Program> logger)
{
    var answerUrl = $"{baseUrl}/api/voice/callee-answer/{uuid}";
    var eventUrl = $"{baseUrl}/api/voice/events";
    try
    {
        var command = new CallCommand
        {
            To = new Vonage.Voice.Nccos.Endpoints.Endpoint[]
            {
                new PhoneEndpoint
                {
                    Number = phoneNumber,
                },
            },
            From = new PhoneEndpoint
            {
                Number = vonagePhoneNumber,
            },
            AnswerUrl = new[] { answerUrl },
            EventUrl = new[] { eventUrl },
        };

        return await client.VoiceClient.CreateCallAsync(command);
    }
    catch (Exception ex)
    {
        logger.LogError(ex, $"Error in operation: {ex.Message}");
        throw;
    }
}

// トークンファイルを生成する。生成に成功するとtrueを返す。
bool CreateToken(string uuid)
{
    var path = $"./.token.{uuid}";
    File.Create(path);
    return true;
}

// トークンファイルを削除する。削除に成功するとtrueを返す。
bool RemoveToken(string uuid)
{
    var path = $"./.token.{uuid}";
    var existed = TokenExists(uuid);
    File.Delete(path);
    return existed;
}

// トークンファイルが存在するかどうかを判定する。存在すればtrueを返す。
bool TokenExists(string uuid)
{
    var path = $"./.token.{uuid}";
    return File.Exists(path);
}
  • URL_MUSIC_ON_HOLD の部分は、MP3 の URL に置き換えます。これは、発信者が Conversation に入室し、着信側と接続されるまでの間、電話機に流れる BGM となります。
  • VONAGE_NUMBER の部分は、取得された Vonage 電話番号に置き換えます。この番号は、通話が発信された際、着信側の電話機に発信元番号として表示されるものです。
  • DESTINATION_NUMBER_1 および DESTINATION_NUMBER_2 の部分は、着信側の電話番号に置き換えます。ここで指定された番号に対して、通話を発信します。
    • この配列の要素数を増やすことで、着信側の電話番号を増やすことができます。
  • 上記の電話番号については、MSISDN 形式で指定します。日本の番号であれば、先頭の 0 を除去し、代わりに国番号である 81 を加えます。たとえば、090AAAABBBB であれば、MSISDN 形式は 8190AAAABBBB となります。
  • VONAGE_APPLICATION_ID および VONAGE_APPLICATION_PRIVATE_KEY_PATH の部分については後述します。

C# 依存パッケージのインストール

今回は、C# 用の Vonage Server SDK をインストールします。

dotnet add package vonage

ngrok による URL の公開

上記のプログラムに Vonage からアクセスできるよう、ngrok を用いて URL を与えます。

ngrok http 3000

実行した際、「Forwarding」の項目に表示される URL が、ngrok によって外部に公開された URL なので、これをメモしておきます。またこの後は、Control-C などは入力せず、新しいターミナル画面を開きます。

Vonage CLI によるアプリケーションの構成

  • まだインストールしていない場合は、Vonage CLI をインストールします。
npm install -g @vonage/cli
  • Vonage CLI の初期設定が完了していない場合は、設定します。
    • API キーと API シークレットについては、Vonage ダッシュボードで確認してください。
vonage auth set --api-key (APIキー) --api-secret (APIシークレット)
  • Vonage CLI を用いて、Vonage アプリケーションを新たに作成します。
    • 実行した際に表示される「Application ID」は、後の手順で必要となるため、コピーしておいてください。
    • Vonage アプリケーションの作成とともに、アプリケーションの秘密鍵ファイル private.key も生成されているため、これを削除しないよう、ご注意ください。
vonage apps create "VonageParallelCalls"
  • Vonage CLI を用いて、作成した Vonage アプリケーションの音声通話機能を有効にします。
    • URL のドメイン部分(NGROK_HOSTNAME)は、先ほどコピーしておいたものを指定します。
vonage apps capabilities update (上記でコピーしたApplication ID) voice \
--voice-answer-url="https://NGROK_HOSTNAME/api/voice/answer" \
--voice-event-url="https://NGROK_HOSTNAME/api/voice/events" \
--voice-fallback-url="https://NGROK_HOSTNAME/api/voice/answer"
  • Vonage CLI を用いて、作成した Vonage アプリケーションに Vonage 電話番号(MSISDN 形式)を割り当てます。
    • 対象の Vonage 電話番号が別の Vonage アプリケーションに割り当てられている場合、エラーが発生するようです。この場合は、Vonage ダッシュボードから手動で割り当ててください。
vonage apps numbers link (上記でコピーしたApplication ID) (Vonage電話番号)
  • 最初に作成した C# プログラムのうち、以下の部分を置き換えます。
    • VONAGE_APPLICATION_ID: (上記でコピーしたApplication ID)
    • VONAGE_APPLICATION_PRIVATE_KEY_PATH: (上記で作成した秘密鍵ファイルへのパス)

C# プログラムの実行

作成した C# プログラムを、.NET 環境で実行します。

dotnet run

すると、以下の 4 つの URL で HTTP リクエストを待機します(そして、すでに起動済みの ngrok により、外部からのリクエストを受け付けます)。この後は、Control-C などは入力せず、ターミナルを開いたままにします。

通話の実行

  • 発信側の電話機から、Vonage 電話番号に対して通話を発信し、以下のように動作すれば成功です。
    • 発信側の電話機で「こんにちは。しばらくお待ちください。」という音声が流れ、続いて待ち受け音声が始まる。
    • DESTINATION_NUMBER_1 および DESTINATION_NUMBER_2 に指定した電話番号に、その Vonage 電話番号から着信する。
    • DESTINATION_NUMBER_1, DESTINATION_NUMBER_2 のいずれかが応答し、先にキー 1 を入力すると、「通話に接続します。しばらくお待ちください。」という音声が流れ、発信側との通話が開始される。
    • 後からキー 1 を入力した方に対しては、「別のオペレーターが先に応答しました。通話を終了します。」という音声が流れ、通話が切断される。
  • DESTINATION_NUMBER_1, DESTINATION_NUMBER_2 の双方がキー 1 を入力することなく通話を切断した場合は、発信者の電話機を手動で切断してください。
  • 通話発信の実行の流れ以外にも、dotnet run を実行しているターミナルに流れる JSON 文字列にも注目してください。これは、Vonage による Event webhook のリクエストで、通話において何らかのイベント(たとえば、着信側が応答した、など)に反応して HTTP リクエストを送信するものです。今回、着信側への発信も、この Event webhook からの情報を利用しています。

まとめ

  • Vonage を利用すると、Vonage 電話番号に対して着信があった時に、複数の外部の電話番号に対して通話を(ほぼ)同時に発信し、応答した通話を接続することができます。
  • 上記を実現するには、複数人での電話会議を実現する NCCO の Conversation action と、Voice API による通話発信を併用します。
    • Vonage 電話番号に着信した際、発信側を Conversation に入室させます。
    • 次に、Voice API で外部の電話番号に対して通話を発信します。この時、複数の電話番号に対して通話を発信することで、ほぼ同時に通話を発信することができます。
    • 通話発信に応答があったら、NCCO の Input Action を用いて、着信側の
      DTMF 入力を待ちます。そして、入力があったら、EventUrl に設定した URL で入力を処理し、新しい NCCO を返して通話を制御します。
  • Vonage での通話中、何かイベントが発生すると、Vonage から HTTP リクエストが実行されます。これを Event webhooks と呼びます。

参考ウェブサイト

KWCPLUS

Discussion