Twilioブログ

TwilioとFirebaseを用いた動的な自動音声応答の作り方

前回、TwilioとFirebaseを用いたアプリ開発方法について北九州でハンズオンを開催しました。

今回のブログでは、実際にハンズオンでどのようなアプリを作ったのかご紹介いたします。

システム概要

このシステムでは電話の自動応答にTwilioを使います。
Twilio Studioを使うことによってソースコードを書くことなくGUIで自動応答のフローを実装することができます。

また、店舗情報のデータベースと店舗情報取得用APIをFirebaseで実装します。

これをTwilio側から呼び出すことによって店舗情報の提供を行います。FirebaseではFirestoreとFunctionsという機能を使います。
 

firebaseとTwilioの連携した店舗情報

Firebase

データベース作成

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

タブの中から 「Database」を選びます。
firebaseのdatabase
「データベースの作成」を押します。
firebaseのデータベース作成
今回は 「テストモード」で行います。
今回は 「asia-northeast1」で行います。

これでデータベースは作れました。

作るテーブルのイメージ

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

firebaseの作るテーブルのイメージ

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

テーブル作成

Categories

「コレクションを開始」を押します。
コレクションに 「categories」と入力し、「次へ」を押します。
ここで maincategoriesの内容を入力をします。
numは電話をかけてジャンルを絞るときに押すダイヤルキーになるので同じ階層では数字が被らないようにします。
maincategoriesの2つ目以降を書く場合は 「ドキュメントを追加」を押して上と同じ手順を繰り返してください。
maincategoriesのデータを入力し終えたら次はsubcategoriesの入力に進みます。
まず、numを1にしたmaincategories(揚げ物)の「コレクションを開始」を押します。
コレクションに 「subcategories」を入力し、「次へ」を押します。
フィールドと値の欄にデータを入力していきます。
どのsubcategoriesも同じ名前、同じ数のフィールドを入力してください。
maincategoriesのときのnumと同じように数が被らないようにnumを設定する必要があります。
全てのmaincategoriesにsubcategoriesを入力していきます。
これでジャンルで絞っていく方のデータ入力は終了です。

Stores

次に店舗番号を入力して情報を得る方のデータ入力をしていきます。
categoriesを入力した階層の「コレクションを開始」を押してコレクションに「stores」を入力して「次へ」を押します。
ここで各店舗の情報を入力していきます。
どの店舗も同じフィールドとタイプを入力してください。
フィールド タイプ
SMSname string 送信するSMSに載せる店舗名
categories array 店舗に関わるsubcategories IDの配列
name string 音声で読ませる店舗名
※きちんと読まれないことがあるので日本語にしておくことをお勧めします。
num string ダイヤルキーで押す数字
say_num string 「●●ならば1(いち)を」のように読む数字
link string SMSに載せるURL
すべて入力し終えたらsubcategoriesに進み、ドキュメントのIDをコピーします。
このときこのIDがどのジャンルのものなのか(今回でいうと唐揚げ)を覚えておいてください。
storesの中から、コピーしたIDのジャンルのものがあるところを探し、フィールド名categoriesの配列の中に貼り付けます。
複数のジャンルを同じブースに入れる場合は 「+ボタン」を押すと追加できます。

これで全てのデータ入力が完了です。

環境構築

今回は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のようにアクセスすると、num02の店情報を取得することができます。

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});
  }
})

少し長い関数となっていますが難しいことはしていないので順番に見ていきましょう。
まず返却用に値を代入する変数を用意します。そして、mainCategorysubCategoryがそれぞれ渡されているかを表すhasMainhasSubを用意します。これ以降はhasMainhasSubの値によって次のように分岐をし、処理を行っています。

クエリパラメータの状態 処理
何も渡されていない状態
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アカウントを持っている方はログイン、持っていない方は「無料アカウント作成」を押してアカウント作成しましょう。

 

Twilioのコンソールを開いたら赤丸で囲ったマークのボタンを押します。
「Studio」のボタンを押します。
「Create a flow」のボタンを押します。
フローの名前を決めてNextを押します。
赤で囲ったボタンを押してNextを押します。
以下画面が開いたら準備完了です。
フローではブロック1つ1つをWidgetsと呼び、Widgetsを組み合わせることで、プログラミングをせずに自動音声応答を作ることができ、 リファレンスを見ながらWidgetsをいろいろ書き換えてみると理解が深まります。
twilioのstudioでフローを作成

店舗検索フローの作成

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

twilioのstudioでフローを手軽に作成

次に、ジャンルから検索するフローと店舗番号から検索するフローを作成します。
赤で囲まれた方がジャンルから検索するフローで、青で囲まれた方が店舗番号から検索するフローです。
赤側の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のオーガナイザーを務める。

CTA_まずはtwilioを使ってみる。

Share!!

この記事を読んだ人へのオススメ

  • お役立ち情報
  • イベント情報
  • 相談会申込
  • 導入事例