2020.02.03
TwilioとFirebaseを用いた動的な自動音声応答の作り方
今回のブログでは、実際にハンズオンでどのようなアプリを作ったのかご紹介いたします。
システム概要
このシステムでは電話の自動応答にTwilioを使います。
Twilio Studioを使うことによってソースコードを書くことなくGUIで自動応答のフローを実装することができます。
また、店舗情報のデータベースと店舗情報取得用APIをFirebaseで実装します。
これをTwilio側から呼び出すことによって店舗情報の提供を行います。FirebaseではFirestoreとFunctionsという機能を使います。

Firebase
データベース作成
まずは新しいプロジェクトを作成しておいてください。
新しいプロジェクトにデータベースを作成していきます。




これでデータベースは作れました。
作るテーブルのイメージ
まずはどのような形でデータを入力していくのかイメージしてもらうために下の全体図を見てください。

今回は、大まかなジャンル(maincategories)とジャンル内の詳細ジャンル(subcategories)の2段階でジャンル分けをします。では全体図のようにテーブルを作っていきます。
テーブル作成
Categories


numは電話をかけてジャンルを絞るときに押すダイヤルキーになるので同じ階層では数字が被らないようにします。


まず、numを1にしたmaincategories(揚げ物)の「コレクションを開始」を押します。


どのsubcategoriesも同じ名前、同じ数のフィールドを入力してください。
maincategoriesのときのnumと同じように数が被らないようにnumを設定する必要があります。


Stores
categoriesを入力した階層の「コレクションを開始」を押してコレクションに「stores」を入力して「次へ」を押します。

どの店舗も同じフィールドとタイプを入力してください。
フィールド | タイプ | 値 |
---|---|---|
SMSname | string | 送信するSMSに載せる店舗名 |
categories | array | 店舗に関わるsubcategories IDの配列 |
name | string | 音声で読ませる店舗名 ※きちんと読まれないことがあるので日本語にしておくことをお勧めします。 |
num | string | ダイヤルキーで押す数字 |
say_num | string | 「●●ならば1(いち)を」のように読む数字 |
link | string | SMSに載せるURL |





これで全てのデータ入力が完了です。
環境構築
今回はFirebaseのFunctionsという機能を使用してREST APIを作成します。
Functionsを使うためには、ローカル環境でプログラムを記述し、それをFirebaseにデプロイする必要があります。そのための環境構築を行います。
Node.jsのインストール
こちらから使用している環境に適切なNode.jsの推奨版をダウンロードしてインストールしてください。Linuxなどの場合はパッケージマネージャからインストールすることも出来ます。
Windowsではコマンドプロンプトを、Linuxなどではターミナルを開いて、次のコマンドでインストールの確認を行います。
$ node --version
バージョンが表示されたらインストール成功です。
Firebase CLIのインストール
次のコマンドでFirebase CLIをインストールします。Linuxなどでは管理者権限(sudoなど)で行ってください。
$ npm install -g firebase-tools
Firebaseへのログイン
まず次のコマンドでFirebaseにログインします。
$ firebase login
コマンドを実行すると(エラーレポートの送信について聞かれる場合もあります)、次のようにURLが表示されるので、ブラウザ等でアクセスしてください。
Visit this URL on any device to log in:
https://accounts.google.com/o/oauth2/auth?client_id=......
Waiting for authentication...
アクセス後は、表示内容に沿ってログイン、Googleアカウントへのアクセスリクエストの許可を行ってください。Firebase CLI Login Successfulというページが表示されたらログイン成功です。
Functionsの作成
Firebaseプロジェクトの初期化
任意の場所にプロジェクト用のフォルダを作成し、移動します。
$ mkdir myproject
$ cd myproject
次のコマンドでfirebaseプロジェクトを初期化します。
$ firebase init functions
実行後は使用するプロジェクトの設定が始まるので、矢印キーとエンターキーを使って以下の表のように選択を行ってください。
Q | A |
---|---|
Please select an option | Use an existing project |
Select a default Firebase project for this directory | 作成したプロジェクト |
What language would you like to use to write Cloud Functions? | TypeScript |
Do you want to use TSLint to catch probable bugs and enforce style? | Yes |
Do you want to install dependencies with npm now? | Yes |
コマンド実行後は以下のようにファイルが生成されます。今後は主にindex.tsを編集していきます。
myproject/
├── firebase.json
└── functions
├── node_modules
├── package-lock.json
├── package.json
├── src
│ └── index.ts
├── tsconfig.json
└── tslint.json
開発の方法
まずはindex.tsのサンプルコードをコメントアウトし、APIを試してみましょう。index.tsの内容を以下のように変更します。
このプログラムでは、http://functionsのURL/helloWorldにリクエストを送った際にHello from Firebase!という文字列を返すようになっています。
import * as functions from 'firebase-functions';
// Start writing Firebase Functions
// https://firebase.google.com/docs/functions/typescript
export const helloWorld = functions.https.onRequest((request, response) => {
response.send("Hello from Firebase!");
});
テストの方法
まずは動作するかを確認してみましょう。functionsフォルダに移動後、npm run serveを実行すると以下のような出力がされます。
$ npm run serve
functions: Emulator started at http://localhost:5000
functions: Watching "作業フォルダ/myproject/functions" for Cloud Functions...
functions[helloWorld]: http function initialized(http://localhost:5000/プロジェクトID/us-central1/helloWorld).
表示されたURL【http://localhost:5000/プロジェクトID/us-central1/helloWorld】にアクセスすることで動作を確認することができます。実際にアクセスしてHello from Firebase!と表示されれば成功です。
このようにして、デプロイを行う前にローカル環境で動作を確認することができます。
デプロイの方法
では、Firebase上にデプロイをしてみましょう。functionsフォルダに移動後、npm run deployを実行してください。次のように表示されれば成功です。
$ npm run deploy
Function URL (helloWorld): https://us-central1-プロジェクトID.cloudfunctions.net/helloWorld
Deploy complete!
表示されたURLにアクセスし、テストのときと同じように表示されれば成功です。
このようにして、APIをFirebase上にデプロイして開発を行います。
Expressの使用方法とFirestoreへのアクセス
ExpressとFirebase-adminのインストール
今回はExpressというフレームワークを使用してAPIを作成していきます。まずはExpressと、Firestoreにアクセスするのに必要なfirebase-adminをインストールします。functionsフォルダに移動後、次のコマンドでインストールを行います。
$ npm install --save express firebase-admin
Expressを用いたAPI
サンプルコードをExpressを用いて書き換えたものは以下のようになります。
この例ではhttp://apiまでのURL/api/helloWorldにアクセスすることでサンプルコードと同じ結果が得られます。
import * as functions from 'firebase-functions';
import * as express from 'express';
const app = express();
app.route('/helloWorld').get((req,res)=>{
res.send("Hello from Firebase!");
})
export const api = functions.https.onRequest(app);
クエリパラメータを渡す
APIにはクエリパラメータを使って値を渡すことができます。渡されたクエリパラメータはreq.queryで取得することが出来ます。今回の例では、http://apiまでのURL/api/test?testQuery=helloのようにアクセスすることで、testQueryに渡したhelloという値が表示されます。
app.route('/test')
.get( (req,res)=>{
res.send(req.query.testQuery);
})
JSONで結果を返す
今まではAPIの返却に文字列を返していましたが、通常はXML形式やJSON形式で結果を返却します。Expressではres.json()を使うことでオブジェクトをJSON形式で返すことが出来ます。
次の例ではJSON形式でデータを返しています。JSON形式には配列を含めることもできます。
app.route('/test')
.get( (req,res)=>{
let data = {
name:'tarou',
age:20,
favoriteThings:[
'sushi',
'apple',
'banana'
]
};
アクセスするとオブジェクトがjson形式で返されていることが分かります。JSON Viewerのような拡張機能を使用すると、JSONが整形されて表示されるのでより確認しやすくなります。また、APIテストツールなどを使うと楽にテスト出来るのでオススメです。
Firestoreへのアクセス
注意
今後はFirestoreのデータにアクセスをしていきますが、ローカル環境でデータへのアクセスを試すためには、Firestoreのエミュレータを起動しデータを構築する必要があります。今回は手順を容易にするために実際のFirestore上のデータを使用して動作を確認していきます。
そのため、動作を確認する際はデプロイの方法で記述したように、下記で行なってください。
$ npm run deploy
アクセスの方法
それではFirestoreにアクセスしていきましょう。
作成したデータへのアクセスは次のプログラムのように行います。
この例ではhttps://apiまでのURL/api/testにアクセスすることで、Firestoreのcategoriesコレクション内の情報がJSON形式で得られます。
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import * as express from 'express';
admin.initializeApp();
const db = admin.firestore();
const app = express();
app.route('/test')
.get(async (req,res)=>{
const resObjs:any[] = [];
//categoriesコレクションの取得
const categoriesSnapShot = await db.collection('/categories').get();
//コレクション内のドキュメントそれぞれに対して処理
categoriesSnapShot.forEach((doc)=>{
resObjs.push(
{
id:doc.id,
data:doc.data()
}
);
})
res.json({
resObjs
});
})
export const api = functions.https.onRequest(app);
プログラムの説明を行います。
はじめに、admin.firestore()でFirestoreクライアントを得ることができ、データへのアクセスが可能になります。
取得したFirestoreクライアントdbを使って、
- db.collection('collection name').get()
- db.collection('collection name').doc('document id').get()
- db.collection('collection name').doc('document id').collection('sub collection name').get()
というように階層に従って指定していきデータを取得することが可能です。
get()からはPromiseが返ってくるのでasync/awaitを使って処理が完了するまで待つようにしています。
コレクション内のドキュメントはdocsまたはforEachで参照することが出来ます。ドキュメント内のデータは.data()でオブジェクトとして取得することが出来ます。
APIの実装
実装するAPIの仕様
それでは実際にAPIを実装していきましょう。今回実装するAPIの仕様は以下の通りです。
パス | パラメータ | 動作 |
---|---|---|
/store | num : 店番号 | numに指定された番号の店情報を返却 ※numは必須 |
/random | category : カテゴリーID | パラメーター未指定時は全体の店舗からランダムで店情報を返却 category指定時は指定されたカテゴリの店舗からランダムで店情報を返却 |
/categories | mainCategory : メインカテゴリー番号 subCategory : サブカテゴリー番号 |
パラメーター未指定時はメインカテゴリーのリスト返却 mainCategory指定時はサブカテゴリーのリスト返却 mainCategory & subCategory指定時はカテゴリーのID返却 ※リスト返却時はTwilio読み上げ用の文章を返却 |
/store の実装
まずは最も簡単な/storeから実装しましょう。
今回作成するシステムは店番号が2桁固定で、Twilioで入力した数字は文字列として渡されるので、3などの1桁の数字のときは、03というような文字列が渡されます。それを考慮してFirestoreのデータ側を03というようなデータにしていたため、プログラム中では比較を容易に行うことが出来ます。
/storeの実装は次の通りです。https://apiまでのURL/api/store?num=02のようにアクセスすると、numが02の店情報を取得することができます。
app.route('/store')
.get(async (req,res)=>{
try{
//クエリパラメータにnumが渡されてなかったらエラー
if(req.query.num === undefined || req.query.num === '') throw Error('No number.');
const num = req.query.num;
const storeSnapShot = await db.collection('stores').where('num','==',num).get();
//該当する番号の店が見つからなければエラー
if(storeSnapShot.empty) throw new Error('Not found such a store.');
//番号から探したら一意に定まるはず
const store = storeSnapShot.docs[0].data();
//成功時はステータスコード200で店情報を返す
res.status(200).json(store);
}catch(err){
//エラー時はステータスコード404を返す
res.status(404).json({error:(err as Error).message});
}
})
例外処理にはtry/catchを使用しています。numラメータは必須のため、渡されてないときに例外を投げます。また、指定された店番号のお店が見つからない際にも例外を投げるようになっています。例外が投げられたときはステータスコード404と共にエラーメッセージを返すようにしています。
今回はコレクション取得の際にwhere関数を使うことで絞り込みを行っています。入力された店番号と一致するドキュメントに絞り込んでいるため、取得結果が空でない限り一意に定まるはずなので、docs[0].data()を返却しています。
/random の実装
では次に/randomを実装していきましょう。
/randomの実装は次の通りです。https://apiまでのURL/api/randomにアクセスすることで全店舗からランダムで店舗情報の提供を行います。また、https://apiまでのURL/api/random?category=カテゴリIDのようにアクセスすることで、そのカテゴリが指定されている店舗の中からランダムで店舗情報の提供を行います。
app.route('/random')
.get(async (req,res)=>{
try{
//ランダム対象の店情報を入れる配列
const stores:any[] = [];
//storeコレクションの取得
const storeSnapShot = await db.collection('stores').get();
if(req.query.category === undefined || req.query.category === ''){
//カテゴリが指定されていないときは全ての店舗情報を対象の配列に追加
storeSnapShot.forEach((doc)=>{
stores.push(doc.data());
})
}else{
//カテゴリが指定されているときは
//そのカテゴリが指定されている店舗情報のみを対象の配列に追加
const category = req.query.category as string;
storeSnapShot.forEach((doc)=>{
//カテゴリの配列をstringの配列として取得することで、includes関数によってチェック
const categories = doc.data().categories as string[];
if(categories.includes(category)){
stores.push(doc.data());
}
})
}
//対象の店舗が1つもない場合はエラー
if(stores.length === 0) throw Error('No stores.');
//ランダムな値を生成して1つの店舗情報を返却
const randomIndex = Math.floor(Math.random() * (stores.length));
res.status(200).json(stores[randomIndex]);
}catch(err){
res.status(404).json({error:(err as Error).message});
}
})
まず、ランダムに選ぶ際に使う対象のリストとなる配列を用意します。次にstoreコレクション全体を取得し、categoryが渡されていない場合は全ての店舗情報を、categoryが渡されている場合はそのカテゴリが指定されている店舗情報のみを、対象のリストに追加します。そのカテゴリが指定されているかの判断では、データのcategoriesを文字列の配列として扱い、includes関数を用いて判断しています。対象のリスト作成後に、まだリストのサイズが0の場合は対象の店舗が存在しないので例外を投げます。
最後にランダムな値を生成し、店舗情報をランダムに1つ返却しています。
/categories の実装
最後に/categoriesを実装しましょう。
まず、今回の関数の中でソート用に使用する比較関数を示します。数字の文字列を数字に変換してから比較を行う関数です。
//sort用の数字の文字列の比較関数
const cmpNumStr = (a:any,b:any)=>{
const num1:number = parseInt(a.num);
const num2:number = parseInt(b.num);
if(num1 === NaN || num2 === NaN) throw new Error('Numeric strings in the database cannot be converted.');
if(num1 < num2)return -1; if(num1 > num2)return 1;
return 0;
}
では/categoriesの実装に入りましょう。
実装は次のようになります。指定したクエリパラメータとそれぞれでの返却値を以下に示します。URLはhttps://apiまでのURL/api/categoriesです。
※データ入力で1桁の数字(3番のときは03ではなく3)を入力したように、1桁の数字を前提としていることに注意してください。
クエリパラメータ | 返却値 |
---|---|
なし | id : なし categories : メインカテゴリーの一覧 str : メインカテゴリー選択用読み上げ文字列 |
mainCategoryのみ (例 mainCategory=2) |
id : 指定したメインカテゴリーのID categories : サブカテゴリーの一覧 str : サブカテゴリー選択用読み上げ文字列 |
mainCategoryとsubCategory (例 mainCategory=2&subCategory=1) |
id : 指定したサブカテゴリーのID categories : なし str : なし |
app.route('/categories')
.get(async (req,res)=>{
try{
//返却用の変数
const resCategories:any[] = [];
let id = '';
let str = '';
//メインカテゴリーが指定されているかどうか
const hasMain :boolean = !(req.query.mainCategory === undefined || req.query.mainCategory === '');
//サブカテゴリーが指定されているかどうか
const hasSub :boolean = !(req.query.subCategory === undefined || req.query.subCategory === '');
if(!hasMain){
//メインカテゴリーが指定されていないときはメインカテゴリーの一覧を取得
const mainSnapShot = await db.collection('categories').get();
mainSnapShot.forEach((doc)=>{
const tmp = doc.data();
tmp.id = doc.id;
resCategories.push(tmp);
})
}else{
if(!hasSub){
//メインカテゴリーが指定されており、サブカテゴリーが指定されていないとき
//メインカテゴリーのIDを取得
const mainCategorySnapShot = await db.collection('categories').where('num','==',req.query.mainCategory).get();
if(mainCategorySnapShot.empty) throw new Error('Not found such a main Category');
const mainCategory = mainCategorySnapShot.docs[0].id;
id = mainCategory;
//サブカテゴリーの一覧を取得
const subSnapShot = await db.collection('categories').doc(mainCategory).collection('subcategories').get();
subSnapShot.forEach((doc)=>{
const tmp = doc.data();
tmp.id = doc.id;
resCategories.push(tmp);
})
}else{
//メインカテゴリーとサブカテゴリーの両方が指定されているとき
//サブカテゴリーのIDを取得するためにメインカテゴリーのIDを取得
const mainCategorySnapShot = await db.collection('categories').where('num','==',req.query.mainCategory).get();
if(mainCategorySnapShot.empty) throw new Error('Not found such a main category');
const mainCategory = mainCategorySnapShot.docs[0].id;
//サブカテゴリーのIDを取得
const subCategorySnapShot = await db.collection('categories').doc(mainCategory).collection('subcategories').where('num','==',req.query.subCategory).get();
if(subCategorySnapShot.empty) throw new Error('Not found such a sub category');
id = subCategorySnapShot.docs[0].id;
}
}
//Twilio読み上げ用に文字列を生成
if(resCategories.length){
//比較関数cmpNumStrを使って番号順にソート
resCategories.sort(cmpNumStr);
for(const i of resCategories){
str += `${i.name}を探したい方は${i.num}を、`;
}
str += '押してください。'
}
res.status(200).json({
'id':id,
'categories':resCategories,
'str':str
})
}catch(err){
res.status(404).json({error:(err as Error).message});
}
})
少し長い関数となっていますが難しいことはしていないので順番に見ていきましょう。
まず返却用に値を代入する変数を用意します。そして、mainCategoryとsubCategoryがそれぞれ渡されているかを表すhasMainとhasSubを用意します。これ以降はhasMainとhasSubの値によって次のように分岐をし、処理を行っています。
クエリパラメータの状態 | 処理 |
---|---|
何も渡されていない状態 hasMain : false hasSub : false |
1.メインカテゴリーの一覧を取得して resCategories に追加 |
mainCategoryのみ hasMain : true hasSub : false |
1.指定された番号のメインカテゴリーが存在するか確認。 2.指定されたメインカテゴリーのIDを取得し id に代入 3.指定されたメインカテゴリーに属するサブカテゴリーの一覧を取得し resCategories に追加 |
mainCategoryとsubCategory hasMain : true hasSub : true |
1.サブカテゴリーのIDを取得するために指定されたメインカテゴリーのIDを取得 2.指定されたサブカテゴリーのIDを取得し id に代入 |
その後は、resCategoriesに要素がある場合はTwilio側で読み上げるための文字列を生成する処理を行っています。
これでAPIの作成は終わりです。今後はこのAPIをTwilio側から呼び出して使用していきます。
Twilio
新規フローの作成
これから実際に電話をかけた時の会話フローを作成していきます。
まず、Twilioにログインします。
Twilioアカウントを持っている方はログイン、持っていない方は「無料アカウント作成」を押してアカウント作成しましょう。





フローではブロック1つ1つをWidgetsと呼び、Widgetsを組み合わせることで、プログラミングをせずに自動音声応答を作ることができ、 リファレンスを見ながらWidgetsをいろいろ書き換えてみると理解が深まります。

店舗検索フローの作成
まず、電話を受けた際の最初の応答とジャンルからお店を検索するか店舗番号から検索するか。をユーザーに問うフローを作ります。
openingで使われているWidgets(say/pay)は文章を書き込むとその文章を音声出力させるものです。
choice1で使われているWidgets(Gather Input On Call)は音声出力とともにダイヤルキーまたは音声でユーザーの入力を受け取れます。
split_1で使われているWidgets(Split Based On...)は入力された値から分岐させるものです。今回はchoice1の入力(Digits)を用いて分岐させます。

次に、ジャンルから検索するフローと店舗番号から検索するフローを作成します。
赤で囲まれた方がジャンルから検索するフローで、青で囲まれた方が店舗番号から検索するフローです。
赤側のgenre_http_1とgenre_http_2では、firebaseのAPIにHTTPリクエストを送り、genre_http_1でmaincategoriesの情報を取得し、genre_http_2でsubcategoriesの情報を取得しています。
青側のnum_http_1のWidgetsも、firebaseのAPIにHTTPリクエストを送り、ユーザーの入力した店舗番号に適した店の情報を取得しています。

genre_http_3でユーザーから受け取ったmaincategoriesとsubcategoriesを用いてジャンルのIDを取得します。
genre_http_4は、取得したジャンルIDをfirebaseのAPIにHTTPリクエストを送り、ジャンルIDが含まれる店舗からランダムで1店舗の情報を取得します。
最後に、send_message_1のWidgets(Send Message)を使ってかかってきた電話番号にSMSで取得した店舗紹介を送信し、gener_sey_3のWidgets(say/play)を使って取得した店舗情報を音声にして流します。

結合確認
最後に作ったフローと電話番号を紐づけていきます。
studioを開くときに使ったマークを押して今回は「Phone Numbers」を押します。
その先の画面の「番号を購入」から番号を買ってください。

次に、アクティブな電話番号を押して、買った電話番号を選択してください。
そして、「A CALL COMES IN」の中を左の赤丸は「Studio Flow」、右側の赤丸は自分の作ったフローの名前を選択してください。

これで紐づけ完了です。電話をかけてみて最後までフローが流れるか試してみましょう。
本ブログの著者
- 水杉仁
- 北九州工業高等専門学校 情報システムコース 5年生(2020年1月現在)
- 読書と麻雀が好きです。
- firebaseでのデータベース作成とTwilioでの会話フロー作成をしました。
- このブログではデータベース作成とTwilioの記述の部分を主に担当しています。
- 上田健太郎
- 北九州工業高等専門学校 情報システムコース 5年生(2020年1月現在)
- ラーメンとリズムゲームが好きな人です。
- 今回はFirebaseでのAPI作成などを手伝いました。
- このブログではAPIの記述の部分を主に担当しています
- タイピングゲームやリズムゲームを作ったりしています。
- [Github:ueken0307]
まとめ
今回はfirebaseとTwilioを使って音声ガイドシステムを作ってみました。Twilioには今回使っていない機能もたくさんあり、手軽に面白いものがいろいろ作れますので興味を持った方は試してみてください。

前職でiOS、Androidのネイティブアプリケーション開発、AngularやLaravelを用いたウェブアプリケーション開発に従事。KDDIウェブコミュニケーションズではTwilioの最新情報の発信やTwilioを用いた地域課題解決を担当。 個人では、Google Developer Group Tokyoのオーガナイザーを務める。
-
Twitter:https://twitter.com/chiino58