14
27

Laravel × Twilioでコールセンターの構築

Last updated at Posted at 2021-05-24

#自己紹介
普段私は、
一番得意な機械学習(深層学習)をしたり、
Python/Django でWebアプリを開発したり、
TypeScript/Vue or React でフロントエンドの開発をしたり、
PHP/Laravel でWebアプリを開発したり、
さまざまなことを行っています。

趣味で休みの日にGo言語で色々作成しているのですが、型のある世界は素敵だなと昨今感じています。
今最もやりたいことは、Goで大規模なWebアプリケーションを作成したい。

企業案件やご連絡等ございましたらお気軽に下記よりご連絡いただければと思います。
kohei0801nagamatsu@gmail.com

コールセンター作成に至った経緯

コールセンターと契約するにしろ費用が高いし、従業員に電話の受付として配置するのも非効率。

ならば独自のプラットフォームで、柔軟な電話対応ができるようなアプリケーションを作成してみようとチャレンジしました。

開発期間は半日です
思ったより難しくなかった

##開発環境

PHP 7.3.28
Laravel 5.8.38

ソースコード管理はGitHubで行っておりますが、社内で作成したものになりますので、
リポジトリの共有は控えさせていただきます。

##Twilio
コールセンターを構築するにあたって最低限の下記機能が必要だと判断しました。

  1. 電話の受信
  2. ボタンを押して転送先を切り替える。
      (例) ~の方は 1番をそれ以外の方は2番を入力してください
  3. 指定の電話番号へ転送
    今回TwilioのApiを使用いたしました。

###主に使用した2つのTwilio SDKメソッド

robotVoice.php
<?php
require_once './vendor/autoload.php';
use Twilio\TwiML\VoiceResponse;

$response = new VoiceResponse();
$response->dial('電話番号');
$response->say('Goodbye');

echo $response;

まず理解しておかないといけないことがなぜechoしているのかということだ。あくまで正確なTwilioの動きではなく、ニュアンスのみで簡単に説明する。
robotVoice.phpのecho結果は、下記のxmlが生成される。
プログラムを読み込んだあとにxmlを吐き出し、それをTwilioのシステムが自動で読み上げる。

result.xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
    <Dial>電話番号</Dial>
    <Say>Goodbye</Say>
</Response>

音声を聞いてみたらわかるが女性のロボットが感情もなく読み上げている、、、少し寂しい。
#####なら人間の声に変えちゃおう!!
人間の声で再生するサンプルhumanVoice.php

humanVoice.php
<?php
require_once './vendor/autoload.php';
use Twilio\TwiML\VoiceResponse;

$response = new VoiceResponse();
$response->dial('電話番号');
$response->play('https://api.twilio.com/cowbell.mp3', ['loop' => 10]);

echo $response;

それではxmlをみてみましょう!
タグがSayからPlayに変わってる!
Playタグに囲まれたurlに音声メッセージを入れれば読み上げてくれるのか…。
play関数の第2引数は、音声メッセージを読み上げる回数を表しているそうです。
その他の引数はドキュメントを見てください。

result.xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
    <Dial>電話番号</Dial>
    <Play loop="10">https://api.twilio.com/cowbell.mp3</Play>
</Response>

##実際のロジックを描いていく
下記sample.phpでやっていることは、
電話がかかってきた際に一番最初に応答するメッセージ、
「お電話ありがとうございます。株式会社DYM〜です、オペレーターにお繋ぎする前に何点かご質問させていただきます。最初の質問です、○○○の方は1をそれ以外の方は2を入力してください。」
そしてキーパッドからの入力を待つ。
gather関数を用いるとキーパッドからの入力を受け付ける。
ボイスメッセージは事前に録音しておいて、Laravelのstorageディレクトリにシンボリックリンクを貼って保存している。

sample.php

class SampleAutoApplyController extends Controller
   $response = new VoiceResponse();
   // twilioで購入した電話番号
   $ourTwilioNumber = $request["Called"];

   // welcomeメッセージ
   $telFrom = $request->From;
   $telFrom = str_replace("+81", "0", $telFrom);
   Log::info($telFrom);
   // welcome message
   if (empty($_POST["Digits"])) {
   $firstQuestion = asset('storage/voice/welcome_message.mp3');
   $this->welcomeMessage($response, $firstQuestion, $telFrom);
   return;
   }

   /**
   * welcomeMessage
   *
   * @param [type] $response
   * @param [type] $voiceUrl
   * @param [type] $telFrom
   * @return void
   */
   private function welcomeMessage($response, $voiceUrl, $telFrom)
   {
      EloquentModel::create([
          "tel" => $telFrom,
          "qualification" => null,
          "job_change" => null,
          "age" => null,
       ]);
       $gather = $response->gather(['numDigits' => 1, 'timeout' => 30]);
       $gather->play($voiceUrl, ["loop" => 3]);
       echo $response;
    }

それぞれ別のファイルを記事にするために1つのファイルにまとめたため見づらくなっているのは申し訳ないが、一つずる説明する、
Twilioの使用上,キーパッドの入力が行われて、echoすると再度twilio管理画面のWebhookに登録されているエンドポイントにrequestを行うことになる

Twilio自動応答システム
「お電話ありがとうございます。株式会社DYM〜です、オペレーターにお繋ぎする前に何点かご質問させていただきます。最初の質問です、○○○の方は1をそれ以外の方は2を入力してください。」

通話相手
1番をクリック

Twilio自動応答システム
1番をクリックしたことを確認
次の質問を読み上げる
「○○○の方は1をそれ以外の方は2を入力してください。」

通話相手
2番をクリック

続く

回答状況を全て確認した上で担当にお繋ぎするかどうかを判断する。

ここで問題発生
キーパッドからの入力を行うたびにエンドポイントにリクエストを行われるTwilioの仕様上、キーパッドの入力が行われるたびにプログラムが最初から実行される。
何が問題なのかと言うと、
「お電話ありがとうございます。株式会社DYM〜です、.......」がキーパッド入力の度に読み直され前に進まない…

ここで私はひらめいた、
通話相手の電話番号と回答状況をキーパッド入力があるたびに保存すれば良いじゃないか!!

Twilio自動応答システム
「お電話ありがとうございます。株式会社DYM〜です、オペレーターにお繋ぎする前に何点かご質問させていただきます。最初の質問です、○○○の方は1をそれ以外の方は2を入力してください。」

通話相手
1番をクリック

Twilio自動応答システム
質問内容とキーパッド入力をデータベースに保存
次の質問を読み上げる
「○○○の方は1をそれ以外の方は2を入力してください。」

通話相手
2番をクリック

質問内容とキーパッド入力をデータベースに保存
次の質問を読み上げる

続く

これで前進できる。
担当につなぐかどうかはDBにためたデータをもとに繋げば良い。

下記がコードになる

sample2.php
class SampleAutoApplyController extends Controller
   $response = new VoiceResponse();
   // twilioで購入した電話番号
   $ourTwilioNumber = $request["Called"];
   // 掛かってきた電話番号の取得
   $telFrom = $request->From;
   $telFrom = str_replace("+81", "0", $telFrom);
   Log::info($telFrom);
   // welcome message
   if (empty($_POST["Digits"])) {
      $firstQuestion = asset('storage/voice/welcome_message.mp3');
      $this->welcomeMessage($response, $firstQuestion, $telFrom);
      return;
   }
   $questionStatus =EloquentModel::where("tel", "=", $telFrom)
                                  ->get()
                                  ->toArray();
   $targetTelInDB = array_pop($questionStatus);
   Log::info($targetTelInDB);
   $ok = false;
   $shopBranchTell = '';
   //二番目の質問
   if ($targetTelInDB["qualification"] == null) {
      $secondQuestion = asset('storage/voice/re_job_question.mp3');
      // $this->question($response, "転職時期をお聞かせください。半年以内のかたは1を、半年以上先のかたは2を入力してください.");
      $this->question($response, $secondQuestion);
      return;
   //三番目の質問
   } else if ($targetTelInDB["job_change"] == null) {
      EloquentModel::where("id", "=", $targetTelInDB["id"])
                  ->update(["job_change" => $_POST["Digits"]]);
      $thirdQuestion = asset('storage/voice/age_question.mp3');
      $this->question($response, $thirdQuestion, true);
      return;
   //三番目の質問
   } else if ($targetTelInDB["age"] == null) {
      EloquentModel::where("id", "=", $targetTelInDB["id"])
                  ->update(["age" => $_POST["Digits"]]);

      // 支店の電話番号を配列から取得する
      $shopBranchTell = $this->getThePhoneNumberToCall($ourTwilioNumber);
      $ok = $this->connectCheck($telFrom);
   }
   // チェック
   if ($ok) {
      $endPoint = config('endpoint.end-point');
      $connect = asset('storage/voice/tantousya.mp3');
      $response->play($connect);
      $response->dial($shopBranchTell,
          [
             'callerId' => $ourTwilioNumber,
             'action' => $endPoint . 'dial_status',
             'method' => 'POST',
             'timeout' => "7"
          ]
      );
      echo $response;
      return;
   } else {
      $sorry = asset('storage/voice/sorry.mp3');
      $response->play($sorry);
      echo $response;
      return;
   }

   /**
   * welcomeMessage
   *
   * @param [type] $response
   * @param [type] $voiceUrl
   * @param [type] $telFrom
   * @return void
   */
   private function welcomeMessage($response, $voiceUrl, $telFrom)
   {
      EloquentModel::create([
          "tel" => $telFrom,
          "qualification" => null,
          "job_change" => null,
          "age" => null,
       ]);
       $gather = $response->gather(['numDigits' => 1, 'timeout' => 30]);
       $gather->play($voiceUrl, ["loop" => 3]);
       echo $response;
    }

    /**
     * secondQuestion
     *
     * @param [type] $response
     * @param [type] $voiceUrl
     * @return void
     */
    private function question($response, $voiceUrl, $ageInput = false)
    {
        $options = [
            'numDigits' => 1,
            'timeout' => 30,
        ];
        //年齢の時だけ #の入力で入力を終了できるようにオプションを設定
        if ($ageInput) {
            $options = [
                'timeout' => 30,
                'finishOnKey' => '#',
            ];
        }
        $gather = $response->gather($options);
        $gather->play($voiceUrl, ["loop" => 3]);
        echo $response;
    }

    /**
     * connectCheck
     *
     * @param [type] $telFrom
     * @return boolean
     */
    private function connectCheck($telFrom):bool
    {
        $flag = false;
        $questionStatus = HitowaStatus::where("tel", "=", $telFrom)
                                        ->get()
                                        ->toArray();
        $targetTelInDB = array_pop($questionStatus);
        if ($targetTelInDB["age"] >=18 &&
            $targetTelInDB["age"] <=64 &&
            $targetTelInDB["qualification"] == 1 &&
            $targetTelInDB["job_change"] == 1) {
            $flag = true;
        }
        return $flag;
    }

    /**
     * 支店の電話番号を配列から取得する
     *
     * @param string $ourTwilioNumber
     * @return string
     */
    private function getThePhoneNumberToCall(string $ourTwilioNumber):string
    {
        $linkingPhoneNumbers = [
            config('ourTwilioNumber.our-tokyo-tell') => config('phoneBook.shop-tokyo-tell'),
            config('ourTwilioNumber.our-hokkaido-tell') => config('phoneBook.shop-yokohama-tell'),
            config('ourTwilioNumber.our-nagoya-tell') => config('phoneBook.shop-nagoya-tell'),
            config('ourTwilioNumber.our-osaka-tell') => config('phoneBook.shop-osaka-tell'),
            config('ourTwilioNumber.our-kobe-tell') => config('phoneBook.shop-kobe-tell'),
            config('ourTwilioNumber.our-fukuoka-tell') => config('phoneBook.shop-fukuoka-tell'),
        ];
        $outTelTo = $linkingPhoneNumbers[$ourTwilioNumber];
        return $outTelTo;
    }

    /**
     * 電話番号から支店名を推測
     *
     * @param string $ourTwilioNumber
     * @return string
     */
    private function getTheAreaNameToCall(string $ourTwilioNumber):string
    {
        $linkingPhoneNumbers = [
            config('ourTwilioNumber.our-tokyo-tell') => "東京",
            config('ourTwilioNumber.our-hokkaido-tell') => "北海道",

            config('ourTwilioNumber.our-nagoya-tell') => "名古屋",
            config('ourTwilioNumber.our-osaka-tell') => "大阪",
            config('ourTwilioNumber.our-kobe-tell') => "神戸",
            config('ourTwilioNumber.our-fukuoka-tell') => "福岡",
        ];
        $areaName = $linkingPhoneNumbers[$ourTwilioNumber];
        return $areaName;
    }

今回記事を投稿するために、一つのコントローラーに纏めたのですが、
本来は別々のファイル(Service/〇〇〇〇.php)をコントローラーから処理を呼び出すだけなので、ロジックは書いていないです。
今回はqiitaように別々のファイルに分けたのを再現するのが面倒だったのでまとめさせていただきました。1つのコントローラーにまとめる方が面倒だったかもしれない笑
これで完璧な独自コールセンターの実装ができました。
電話番などの人件費を大幅に削減でき、余った人材を他の業務にあてれる。
なんて素敵なRPA(業務自動化)なんだ。
皆さんも良いTwilioライフを、RPAライフを!!

14
27
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
27