Pythonサーバーレスフレームワーク Chalice と Twilio で簡易的な受付システムを作ってみる

## やりたいこと
受付システムとか書いてしまったけど、やりたいことは基本的に以下のクイックスタートの延長です。

[PYTHON クイックスタート: ブラウザーから通話を発信する](https://jp.twilio.com/docs/quickstart/python/client/outgoing-calls)

![employee-call.png](https://qiita-image-store.s3.amazonaws.com/0/121487/f2be25af-0cf0-62d9-ae76-8c4f97432390.png)

TwilioクライアントのWebRTC使って何かできないかなと考えてみたら、会社に良くある受付電話を、タブレットなどのWebブラウザを使って、更にサーバーレスに実装できないかなと思い書いてみました。

Twilio公式に、Flaskを使ったブラウザ通話のサンプルコードがあるのですが、twilio-pythonとtwilio.jsの情報がバージョンが若干古かったりして、そのままでは動かなかったので一応最新に近いコードで動作するようにしてみております。

内線電話を利用するといったものではなく、個人所有のスマホなどに直接発信します。メリットとしては、固定電話が必要無いのと、個人の電話番号を教える必要が無い、といったところでしょうか。また、サーバーレスでの実装なので、メンテフリーで安価になります。（通話料はかかってしまいますがね・・・）

## 環境
* ローカル環境 CentOS 7(Vagrant)
* Python 2.7.13
 * chalice 0.8.1
 * twilio 6.0.0

### 必要なもの
* AWSアカウント
 * AWS Lambda
 * AWS APIGateWay
 * AWS S3
* Twilioアカウント(課金済み)

今回はサクッとAWS Lambda + APIGateWayの環境を作りたかったため、[AWS Chalice](https://github.com/awslabs/chalice)を利用しました。

## 必要なライブラリのインストール
※ 予めvirtualenvなどでChalice用の仮想環境を作っておくことをお勧めします。
まずはChaliceをインストールします。

```
$ pip install chalice
```
次にAWS認証情報を設定します。boto3などを利用して既に設定している場合は、おそらく必要ありません。

```
$ mkdir ~/.aws
$ vim ~/.aws/credentials
```

```
[default]
aws_access_key_id=YOUR_ACCESS_KEY_HERE
aws_secret_access_key=YOUR_SECRET_ACCESS_KEY
region=YOUR_REGION (such as us-west-2, us-west-1, etc)
```

## Lambda関数の作成
Chaliceでプロジェクトを作成します。

```
$ chalice new-project EmployeeCaller
$ cd EmployeeCaller
```
上記コマンドを実行すると、ディレクトリ配下にファイルが生成されるので、そのファイルを編集していきます。

まずは、requirements.txtにDeployするライブラリを書いておきます。

```
$ vim requirements.txt
twilio==6.0.0
```

次にapp.pyを編集し、コードを入れます。

```python:app.py
import os, json
from urlparse import parse_qs
from chalice import Chalice, Response
from twilio.jwt.client import ClientCapabilityToken
from twilio.twiml.voice_response import VoiceResponse

app = Chalice(app_name='EmployeeCaller')

# 音声通話のEndpoint、TwiMLを生成する
@app.route('/voice', methods=['POST'], content_types=['application/x-www-form-urlencoded'], cors=True)
def voice():
    parsed = parse_qs(app.current_request.raw_body)
    dest_number = parsed.get('PhoneNumber', [])

    resp = VoiceResponse()

    resp.dial(dest_number[0], caller_id=os.environ['TWILIO_CALLER_ID'])
    return Response(body=str(resp), status_code=200, headers={'Content-Type': 'application/xml'})

# トークンを発行する
@app.route('/client', methods=['GET'])
def client():
    request = app.current_request

    account_sid = os.environ['TWILIO_ACCOUNT_SID']
    auth_token = os.environ['TWILIO_AUTH_TOKEN']
    application_sid = os.environ['TWILIO_TWIML_APP_SID']

    capability = ClientCapabilityToken(account_sid, auth_token)
    capability.allow_client_outgoing(application_sid)
    capability.allow_client_incoming(os.environ['DEFAULT_CLIENT'])
    token = capability.to_jwt()

    callback = request.query_params['callback']
    return str(callback) + "(" + json.dumps({"token": token}) + ")"
```

少しChaliceの補足をしておきますと、ChaliceはFlaskというマイクロフレームワークをベースに作られています。なのでルーティングなどはFlaskのドキュメントを見ると良いかと思います。

### ルーティングについて
まず以下のルーティングについてですが

```
@app.route('/client', methods=['GET'])
```
Twilioクライアントで通話するのに必要なCapabilityToken(ケーパビリティトークン)というものを発行してもらう必要があります。`/client`を叩くとこのトークンを生成し、jsonで返却。フロント側でこれを利用して発信を行う、という感じです。

トークンについて、詳しくは[TWILIO クライアント ケイパビリティ トークン](https://jp.twilio.com/docs/api/client/capability-tokens)に書かれています。

次に以下のルーティング

```
@app.route('/voice', methods=['POST'], content_types=['application/x-www-form-urlencoded'], cors=True)
```
この`/voice`にはTwilioサーバから接続先の電話番号などを含んだRequestが届くことになります。POSTで送信されるのでContent-Typeに`application/x-www-form-urlencoded`を指定する必要があります。

### 環境変数について
コード中のos.environ['---']の部分は環境変数を読み込む箇所で、Chaliceの設定ファイルに書くのですが、一度DeployしTwilioの設定を行った後に設定します。

### デプロイ
上記コードをデプロイします。プロジェクトディレクトリ内で以下のコマンドを実行してください。

```
$ chalice deploy
Updating IAM policy.
Updating lambda function...
Regen deployment package...
Sending changes to lambda.
API Gateway rest API already found.
Deploying to: dev
https://********.execute-api.ap-northeast-1.amazonaws.com/dev/
```
実行すると上記のような結果が出力されるはずです。一番最後に出力されたURLが、APIGateWayの呼び出し先URLで後ほど使うのでメモしておきます。

## TwilioでTwiML Appの作成
### Account SIDとAuth Token
Twilioにログインし、Console DashBoardの「ACCOUNT SID」と「AUTH TOKEN」をメモしておきます。

### TwiMLAPPの作成
「電話番号」→「ツール」から新しいTwiML APPを作成します。
フレンドリーネームに適宜App名を入力します。

「音声通話」の「Request URL」に先ほど`chalice deploy`をした際にメモしたURL＋`/voice`を入力し保存します。(`https://********.execute-api.ap-northeast-1.amazonaws.com/dev/voice`)

TwiML APPを作成すると一覧にAppが出てくるので、詳細を表示して「SID」(TwiMLApp SID)を確認しメモしておきます。

### 電話番号の購入
「電話番号」から「Buy a Number」で電話番号を購入。電話番号を控えておきます。

電話番号の購入方法は[以前書いた記事](http://qiita.com/hidesakai/items/20873fa354cb49911608#twilio%E3%81%A7%E9%9B%BB%E8%A9%B1%E7%95%AA%E5%8F%B7%E8%B3%BC%E5%85%A5%E8%AA%B2%E9%87%91)にありますので、わからなければそちらを参考にしてください。

## Lambda関数に環境変数を設定
TwiML APPを作成したら、chaliceプロジェクトに戻り、以下のファイルを編集します。

```
$ vim .chalice/config.json
```

config.jsonファイルに`enviroment_variables`という項目を追加します。先ほどメモしたSIDやTokenをここに設定していきます。これはAWS Lambda上での環境変数になります。

```
.....
"app_name": "EmployeeCaller",
// 以下の項目を追加
"environment_variables": {
    "TWILIO_ACCOUNT_SID": "*******************",
    "TWILIO_AUTH_TOKEN": "*******************",
    "TWILIO_TWIML_APP_SID": "*******************",
    "TWILIO_CALLER_ID": "+81********",
    "DEFAULT_CLIENT": "reception"
}
```

config.jsonを編集したら、再度Deployします。

```
$ chalice deploy
```
## 受付画面の作成、S3へアップロード
次に受付画面を作成します。
### 画面作成

```
$ vim employee.html
```

```html:employee_call.html
<html>
    <head>
        <script src="https://code.jquery.com/jquery-3.2.1.min.js" integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" crossorigin="anonymous"></script>
        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/css/bootstrap.min.css" integrity="sha384-rwoIResjU2yc3z8GV/NPeZWAv56rSmLldC3R/AZzGRnGxQQKnKkoFVhFQhNUwEyJ" crossorigin="anonymous">
        <script src="https://cdnjs.cloudflare.com/ajax/libs/tether/1.4.0/js/tether.min.js" integrity="sha384-DztdAPBWPRXSA/3eYEEUWrWCy7G5KFbe8fFjk5JAIxUYHKkDx6Qin1DkWx51bBrb" crossorigin="anonymous"></script>
        <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/js/bootstrap.min.js" integrity="sha384-vBWWzlZJ8ea9aCX4pEW3rVHjgjt7zpkNpZk+02D9phzyeVkE+jo0ieGizqPLForn" crossorigin="anonymous"></script>
        <script type="text/javascript" src="https://media.twiliocdn.com/sdk/js/client/v1.4/twilio.min.js"></script>
        <script type="text/javascript">
            Twilio.Device.ready(function (device) {
                console.log("Ready");
            });

            Twilio.Device.error(function (error) {
                console.log("Error: " + error.message);
            });

            Twilio.Device.connect(function (conn) {
                console.log("Successfully established call");
            });

            Twilio.Device.disconnect(function (conn) {
                console.log("Call ended");
                $('.employee-hangup').addClass('disabled').prop('disabled', true);
                $('.employee-call').removeClass('disabled').prop('disabled', false);
            });

            Twilio.Device.incoming(function (conn) {
                console.log("Incoming connection from " + conn.parameters.From);
                conn.accept();
            });

            function twilioReadyAsync(phoneNumber) {
                return new Promise(function(resolve){
                    (function ready(){
                        if (Twilio.Device.status() == 'ready') {
                            resolve({"PhoneNumber": phoneNumber});
                        }
                        setTimeout(ready, 1000);
                    })();
                });
            }

            $(function() {
                $('.employee-hangup').addClass('disabled').prop('disabled', true);

                $('.employee-call').click(function(){
                    var employeePhoneNumber = $(this).attr('data-phone-number');

                    $(this).next().removeClass('disabled').prop('disabled', false);
                    $('.employee-call').addClass('disabled').prop('disabled', true);

                    $.ajax({
                        url: 'https://******.execute-api.ap-northeast-1.amazonaws.com/dev/client',
                        dataType: 'jsonp',
                        jsonCallback: 'callback'
                    })
                    .done(function(data) {
                        Twilio.Device.setup(data.token);
                        twilioReadyAsync(employeePhoneNumber).then(Twilio.Device.connect);
                    });
                });

                $('.employee-hangup').click(function(){
                    Twilio.Device.disconnectAll();
                    $(this).addClass('disabled').prop('disabled', true);
                    $('.employee-call').removeClass('disabled').prop('disabled', false);
                });
            });

        </script>
        <style>
            .container {width: auto;}
        </style>
    </head>
    <body>
        <div class="container">
            <h1>受付</h1>
            <div class="card-deck">
                <div class="card text-center" id="employee-1">
                    <img class="card-img-top img-fluid" src="hidesakai.png" alt="Card image cap">
                    <div class="card-block">
                        <h4 class="card-title">hidesakai</h4>
                        <p class="card-text">
                        <p>Development: Engineer</p>
                        御用の際は内線にてご連絡ください。
                        </p>
                        <button class="btn btn-primary employee-call" data-phone-number="+8190-****-****">Call</button>
                        <button class="btn btn-danger employee-hangup">Hangup</button>
                    </div>
                </div>
                <div class="card text-center" id="employee-2">
                    <img class="card-img-top img-fluid" src="spam.jpg" alt="Card image cap">
                    <div class="card-block">
                        <h4 class="card-title">Spam さん</h4>
                        <p class="card-text">
                        <p>Design: Designer</p>
                        受付よりご連絡をお願いします。
                        </p>
                        <button class="btn btn-primary employee-call" data-phone-number="+8190-****-****">Call</button>
                        <button class="btn btn-danger employee-hangup">Hangup</button>
                    </div>
                </div>
                <div class="card text-center" id="employee-3">
                    <img class="card-img-top img-fluid" src="egg.png" alt="Card image cap">
                    <div class="card-block">
                        <h4 class="card-title">Egg さん</h4>
                        <p class="card-text">
                        <p>Sales: Marketer</p>
                        </p>
                        <button class="btn btn-primary employee-call" data-phone-number="+8190-****-****">Call</button>
                        <button class="btn btn-danger employee-hangup">Hangup</button>
                    </div>
                </div>
                <div class="card text-center" id="employee-4">
                    <div class="card-block">
                        <h4 class="card-title">受付</h4>
                        <p class="card-text">
                        こちらは受付となります。
                        </p>
                        <button class="btn btn-primary employee-call" data-phone-number="+8180-****-****">Call</button>
                        <button class="btn btn-danger employee-hangup">Hangup</button>
                    </div>
                </div>
            </div>
        </div>
    </body>
</html>
```
`$.ajax()`の`url: 'https://******.execute-api.ap-northeast-1.amazonaws.com/dev/client'`に先ほどchalice deployした際のURLを適宜入れます。

次に`data-phone-number`に国番号(+81)から始まる電話番号を入力します。

### S3へアップロード
S3に適当なbucketを作成し、htmlファイルをアップロードします。

## 画面から通話
S3へアップロードしたら、ページへアクセスしてCallボタンを押してみます。設定した電話番号へ送信できればOKです。

![スクリーンショット 2017-05-09 1.50.01.png](https://qiita-image-store.s3.amazonaws.com/0/121487/99436952-4e64-97f5-a1af-e4d5512709cf.png)


## 課題点
このままだとどこからでもアクセスできてしまって、いたずらできてしまうので、
* htmlファイルのアクセス制限
* APIGateWayへのアクセス制限
などの設定が必要になるかと思います。

あとはhtml上に直接電話番号を入れてしまっていますが、 DynamoDBなどLambdaからデータを取得するようにしたいところですね。