見出し画像

TwilioとGolangで理解してサクッと作る発着信Webアプリ

こんにちは、homie株式会社でVPoE兼エンジニアとして働いている石橋(@b0941015)です

はじめに

この記事はGo Advent Calendar 2021の18日目の記事です

みなさんTwilioを使っていますか?

Twilioは電話やSMSなどコミュニケーションに関するクラウドAPIを提供するSaaSで、プログラマティックに発着信・自動応答メッセージなどを扱うことができます

Twilioは機能だけでなくドキュメントが充実している一方、豊富すぎて全体像が掴めず、当初自分はどこから手をつけてよいかわかりませんでした

またサーバーサイドの言語としてGolangを普段使用しているのですが、Twilioでは正式にサポートされていないので、チュートリアルや公式のSDKが存在しないので困った覚えがあります

そこで、そんな人達に向けてTwilioとサーバー連携の流れを理解しつつ、発着信ができるWebアプリをサクッと作る方法を紹介したいと思います

この記事のゴール

今回作成するWebアプリは下記のようになっています

このWebアプリでは以下の2つの機能が実装されています

  1. 任意の電話番号へ発信&通話

  2. Twilioで購入した電話番号へ発信すると着信&通話

成果物をGithubにて公開しているので、必要であればこちらも参考にしてください

前提条件・環境

このWebアプリを作るにあたって、以下の環境が整っている前提に話を進めます

  • Twilioのアカウント(トライアルの状態でOK)

  • npm 7.24

  • Go 1.16

  • ngrok(localhostを外部に公開するために使用)

発信機能

発信時のフロー

発信機能を作成するにあたって全体のフローを確認します

アクセストークンの生成と初期化

Twilioと連携をするには必要な情報を載せたアクセストークンを生成して初期化する必要があります

アクセストークンはJWTの形式に則っていて、Twilioを利用する端末を一意に識別したり、権限情報を管理しています

Twilioの管理画面で発行されるシークレット等をサーバー上で管理し、有効期限を決めてアクセストークンを生成します
その後、ブラウザ上でそのトークンを利用し初期化することによって、発信・着信の準備が完了します

電話番号へ発信

日本にいて馴染みのある電話番号は090XXXXXXXX03XXXXXXXXなどの形式ですが、Twilioでは国際規格であるE.164という方式に則る必要があります

これは国コードを含む電話番号の形式で、日本の電話番号の場合先頭の0+81に変えることによって変換できます

Twilioとの連携とTwiMLについて

電話番号発信の処理を行うとTwilioから指定したURLへ発信情報を含むリクエストが飛んできて、誰がどこに発信をしたかなどの情報が含まれています

このリクエストに対して、TwilioではTwiMLと呼ばれるXML形式のレスポンスを返す必要があります

TwiMLは、 the Twilio Markup Languageの略称で、Twilioに動作を指示するためのタグや属性が定義されたXMLです

メッセージを読み上げる<Say>や電話接続を行う<Dial>などのタグで構成され色々なアクションが定義されています

各タグに属性やデータを付与することによって、通話内容の録音やメッセージを読み上げる声を変えたりすることも可能です

もし +81 90 1234 5678 へ電話をかける場合下記のようなTwiMLを生成する必要があります

<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Dial callerId="+815012345678">
    <Number>+819012345678</Number>
  </Dial>
</Response>

それでは発信する機能を作って行きましょう

発信機能を作成するにあたって①Twilioアカウント準備②バックエンド実装③フロントエンド実装の順番で紹介していきます

Twilioアカウント準備

Twilioのアクセストークンを生成するには下記の4つの情報と電話番号を購入する必要があります

  • AccountSID

    • Twilioのアカウントを識別するためのID

    • https://console.twilio.com

  • APIKeySID

    • APIキー

    • https://www.twilio.com/console/project/api-keys

  • APIKeySecret

    • APIキーのシックレット

    • https://www.twilio.com/console/project/api-keys

  • ApplicationSID

    • Twilio Voiceのアプリを識別するためのID

    • https://www.twilio.com/console/voice/twiml/apps

AccounSIDの取得

アカウントTOP画面よりAccountSIDを取得します

API KeyとAPI Key Secretの取得

API Keysの設定画面よりAPI Keyの作成を行います

作成したAPI Keyに関するシークレットは生成時の一度きりしか表示されないので、忘れずメモをしておきましょう

ApplicationSIDの取得と発信時URLの設定

発信などの音声通話を行うにはTwilio Voice機能からTwiMLアプリを作成する必要があります

Twilio Voiceの設定画面よりアプリを作成します

アプリを作成するとApplicationSIDが発行されるのでメモしておきましょう

また先程のフローにあった発信時に叩かれるURLはここで設定することになります
生成されるURLについてはバックエンドの説明の中でもう一度説明します

電話番号の購入

Twilioで購入可能な日本の電話番号として050, 0800, 0120の3種類があるのですが、こちらは個人や法人を証明するためのRegulatory Bundlesというのを申請する必要があります

https://support.twilio.com/hc/en-us/articles/4406158662171-%E6%97%A5%E6%9C%AC%E5%90%91%E3%81%91-Regulatory-Bundle-%E8%A6%8F%E5%88%B6%E6%83%85%E5%A0%B1-%E3%81%AB%E9%96%A2%E3%82%8F%E3%82%8B%E6%9B%B8%E9%A1%9E%E3%81%AE%E6%8F%90%E5%87%BA%E6%96%B9%E6%B3%95

今回は発信・着信できれば良いので、Regulatory Bundlesの申請が不要なアメリカの電話番号を購入します

番号購入の管理画面より、Countryで (+1) United States - USを選び、適当な電話番語を購入し、メモしておいてください

発信時のバックエンド実装

続いて発信機能の実装をしていきましょう

用意する機能としては①アクセストークンの発行と②発信時に叩かれるエンドポイントの準備です

また③フロント用のhtmlやjsを取得できるような静的ファイルへのアクセス機能と④ローカルのエンドポイントの外部公開の作業も行います

①アクセストークンの発行

アクセストークンの発行にはgotwilioというライブラリを利用します

このライブラリを用いたアクセストークン生成のエンドポイントの処理は下記のようになります

// アクセストークンの生成処理
func genCallToken() (string, error) {
	// Twilioクライアントの初期化
	twilio := gotwilio.Twilio{
		AccountSid:   "ACf009aXXXXXXXXXXXXXXXXXXXXXXXXX",
		APIKeySid:    "SKe241caXXXXXXXXXXXXXXXXXXXXXXXX",
		APIKeySecret: "esjidSNAXXXXXXXXXXXXXXXXXXXXXXXX",
	}

	// アクセストークンの初期化と権限設定(発信を許可する)
	tk := twilio.NewAccessToken()
	tk.AddGrant(gotwilio.VoiceGrant{
		Outgoing: gotwilio.VoiceGrantOutgoing{
			ApplicationSID: "APde3ffxxxxxxxxxxxxxxxxxxxxxxxx",
		},
	})

	// デバイスの識別名(ログイン名、着信時に利用)
	tk.Identity = "planet-meron"

	// アクセストークンの有効期限を1時間に設定
	tk.ExpiresAt = time.Now().Add(1 * time.Hour)

	// アクセストークンの生成
	return tk.ToJWT()
}
func main() {
	// アクセストークンの生成エンドポイント
	http.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
		token, err := genCallToken()
		if err != nil {
			panic(err)
		}
		if _, err := w.Write([]byte(token)); err != nil {
			panic(err)
		}
	})


	// サーバーの起動
	if err := http.ListenAndServe(":8686", nil); err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}

goのアプリケーションを実行して、アクセストークンが取得可能か確認してみましょう

# アプリケーションの起動
go run main.go
# トークンが取得できるか確認
$ curl -s localhost:8686/token
eyJhbGciOiJIUzI1NiIsImN0eSI6InR3aWxpby1mcGE7dj0xIiwidHlwIjoiSldUIn0.eyJleHAiOjE2Mzk3MzcxMDksImp0aSI6IlNLZTI0MWNhMjcxZjdiMDAwMDAwMDAwMDAwMDAwMDAwODgtMTYzOTczMzUwOTg1MzkwMzAwMCIsImlzcyI6IlNLZTI0MWNhMjcxZjdiMDAwMDAwMDAwMDAwMDAwMDAwODgiLCJuYmYiOjE2Mzk3MzM1MDksInN1YiI6IkFDZjAwOWE0MjBmNmM4MDAwMDAwMDAwMDAwMDAwMDAwOWIiLCJncmFudHMiOnsiaWRlbnRpdHkiOiJwbGFuZXQtbWVyb24iLCJ2b2ljZSI6eyJpbmNvbWluZyI6eyJhbGxvdyI6dHJ1ZX0sIm91dGdvaW5nIjp7ImFwcGxpY2F0aW9uX3NpZCI6IkFQZGUzZmZiY2E0NjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAifX19fQ.96nCGt2BXG_IXE1mC97XtiOhgG1bqmZYYffdotCG350

# アクセストークンの中身を確認
$ curl -s localhost:8686/token | cut -d. -f2 | base64 -d
{"exp":1639737093,"jti":"SKe241ca271f7b00000000000000000088-1639733493970552000","iss":"SKe241ca271f7b00000000000000000088","nbf":1639733493,"sub":"ACf009a420f6c80000000000000000009b","grants":{"identity":"planet-meron","voice":{"incoming":{"allow":true},"outgoing":{"application_sid":"APde3ffbca460000000000000000000000"}}}

設定したAccountSIDなどがアクセストークンに含まれているのがわかりますね

②発信時に叩かれるエンドポイントの準備

これで発信時の準備ができたので、リクエストが叩かれた時にTwiMLを返すためのエンドポイントを用意しましょう

今回TwiMLを生成するライブラリとして下記のものを利用しました

それでは実際の処理内容を見ていきましょう

// phoneNumberへ電話を掛けるTwiMLの生成
func genOutgoingTwiml(phoneNumber string) ([]byte, error) {
	resp := twiml.NewVoiceResponse().
		AppendDial(twiml.NewDial(
			// 購入した電話番号をE.164の形式でセット
			attr.CallerID("+19000000000"),
		).Number(phoneNumber))
	xml, err := resp.ToXML()
	if err != nil {
		return nil, err
	}
	return []byte(xml), nil
}
	// 発信用TwiMLの生成エンドポイントの設定
	http.HandleFunc("/outgoing", func(w http.ResponseWriter, r *http.Request) {
		if err := r.ParseForm(); err != nil {
			panic(err)
		}
		// 発信先の電話番号の取得
		to := r.FormValue("To")

		// 発信先へ電話をかけるTwiMLの生成
		resp, err := genOutgoingTwiml(to)
		if err != nil {
			panic(err)
		}

		if _, err := w.Write(resp); err != nil {
			panic(err)
		}
	}

再度ビルドを行って、リクエストに含まれている電話番号に対して電話をかけるTwiMLが生成されていることを確認しましょう

$ curl -X POST localhost:8686/outgoing --data-urlencode "To=+819000000000"
<?xml version="1.0" encoding="UTF-8"?>
<Response><Dial callerId="+190000000"><Number>+819000000000</Number></Dial></Response>

③フロント用の静的ファイルの配置

ブラウザで描画・処理を行うためににHTMLとJSファイルを用意してアクセスできるようにします

publicというディレクトリを用意し、HTMLとJSファイルを配置します

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>TwilioとGolangで理解してサクッと作る発着信WEBアプリ</title>
</head>
<body>
  <script src="index.js"></script>
</body>
</html>
console.log("hello")

ディレクトリを公開するエンドポイントを定義して、ブラウザからアクセスできることを確認しましょう

http://localhost:8686

タイトルの表示とコンソールログにhelloが表示されていれば成功です

④ローカルネットワークの外部公開とTwilioに発信用エンドポイントとして登録

TwilioがローカルPCのエンドポイントにアクセスできるようにngrokでエンドポイントを外部公開します

外部公開をしたらURLを取得して、Twilioの管理画面に登録します

# ローカルPCのポート8686を外部公開
$ ngrok http 8686

# ngrokで公開されているURLの取得
$ curl -s localhost:4040/api/tunnels | jq '.tunnels[] | select(.name == "command_line") | .public_url' -r
https://8388-116-82-241-122.ngrok.io

これで発信時にTwilioからリクエストを受け取れるようになりました

発信時のフロントエンド実装

TwilioのJS SDKはCDN上で公開されていないので、パッケージマネージャを用いてダウンロードしたものを利用します

以下のようなpackage.jsonを用意し、TwilioのJS SDKをダウンロードします
ダウンロードが完了したら対象のJSファイルを、public配下に置いてアクセスできるようにします

{
  "name": "twilio_golang_sample",
  "version": "1.0.0",
  "main": "index.js",
  "author": "wakusei-meron- <b0941015+gihub@gmail.com>",
  "license": "MIT",
  "dependencies": {
    "@twilio/voice-sdk": "^2.0.1"
  }
}
# twilio voice SDKのダウンロード
$ yarn

# ダウンロードしたSDKをpublic配下に配置
$ cp node_modules/@twilio/voice-sdk/dist/twilio.min.js public 

それでは発信のためにHTMLにボタンやラベルを配置します

<body>
  <h2>初期化</h2>
  <button
      id="js-setup-button"
      type="button"
  >セットアップ</button>
  <p>ログイン名: <span id="js-username">未ログイン</span></p>

  <h2>発信</h2>
  <input
    id="js-phone-number-input"
    type="tel"
    placeholder="電話番号(ex. +81 90 0000 0000)"
    size="30"
  />
  <button
    id="js-outgoing-button"
    type="button"
    disabled
  >発信</button>

  <script type="text/javascript" src="twilio.min.js"></script>
  <script src="index.js"></script>
</body>
</html>

続いて、これらのボタンが押された時の動作などの処理を実装してきます

// 必要な要素の取得
const setupButton = document.getElementById("js-setup-button");
const usernameElement = document.getElementById("js-username");
const phoneNumberElement = document.getElementById("js-phone-number-input");
const outgoingButton = document.getElementById("js-outgoing-button");

// ステータスの定義
const Status = {
    Setup: 1,
    Outgoing: 2,
}

// アプリケーション内で利用する変数の定義
let device
let outgoingCall


// トークンの取得とTwilioクライアントの初期化
setupButton.onclick = async (e) => {
    // アクセストークンの取得
    const resp = await fetch("http://localhost:8686/token")
    const token = await resp.text()

    // twilioのSDKを初期化
    device = new Twilio.Device(token)

    // twilioのイベントのコールバック時の挙動定義と登録
    addDeviceListeners(device);
    device.register()

    // 発行したトークンからログイン名の取得
    const payload = token.split(".")[1]
    const claims = JSON.parse(atob(payload))
    usernameElement.textContent = claims.grants.identity
}

// 発信・切電ボタン
outgoingButton.onclick = async (e) => {
    // 架電時のアクション
    if (outgoingCall) {
        outgoingCall.disconnect()
        outgoingCall = undefined
        updateUIAndState(Status.Setup)
        return
    }

    // 入力された電話番号に対して発信開始
    outgoingCall = await device.connect({params: {To: phoneNumberElement.value}})

    // コールバック時の処理の定義
    outgoingCall.on("accept", () => updateUIAndState(Status.Outgoing)) // 相手が電話出た時
    outgoingCall.on("disconnect", () => {updateUIAndState(Status.Setup)}) // 電話が切れた時
}

// twilioのコールバックの定義
addDeviceListeners = (device) => {
    // 初期化完了
    device.on('registered', () => updateUIAndState(Status.Setup))
}

// UIと変数の更新
updateUIAndState = (status) => {
    switch (status) {
        case Status.Setup:
            outgoingCall = undefined

            outgoingButton.disabled = false
            outgoingButton.textContent = "発信"
            break
        case Status.Outgoing:
            outgoingButton.textContent = "切電"
            break
    }
}

ここまで実装が完了したらブラウザの画面をリロードして、 セットアップ→電話番号の入力→発信を押した時に電話がかかってくれば成功です!

着信機能

続いて着信機能も作っていきましょう

着信時のフロー

着信機能も同様にフローの確認をします

着信リクエストとTwiML

着信時のリクエストに対しては、電話番号に対して電話を接続するのではなく、ログインしているTwilioデバイスに接続する必要があるため、 <Client> タグを利用します

この<Client>タグでログインしているユーザーのIdentityを指定することにより電話接続を行うことができます

<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Dial>
    <Client>planet-meron</Client>
  </Dial>
</Response>

それでは着信機能を作るために①電話番号着信時のURL設定と②バックエンド③フロントエンドの実装を見ていきましょう

着信時のURL設定

Twilioでは購入した電話番号ごとに着信した際のアクションを定義するURLを設定できます

購入した電話番号の詳細画面から着信用のURLを設定しましょう

着信時のバックエンド実装

アクセストークンには権限情報が含まれていて、着信させるには権限を付与する必要があります

	tk := twilio.NewAccessToken()
	tk.AddGrant(gotwilio.VoiceGrant{
		Incoming: gotwilio.VoiceGrantIncoming{Allow: true}, // 着信用に追加
		Outgoing: gotwilio.VoiceGrantOutgoing{
			ApplicationSID: "APde3ff0000000000000000000000000",
		},
	})

また、先程設定したURL同様に着信用のTwiMLを生成する処理とエンドポイントを用意します

// 着信用のTwiML生成
func genIncomingTwiml() ([]byte, error) {
	resp := twiml.NewVoiceResponse().
		AppendDial(twiml.NewDial().Client("planet-meron"),
		)
	xml, err := resp.ToXML()
	if err != nil {
		return nil, err
	}
	return []byte(xml), nil
}
	// 着信用TwiMLの生成のエンドポイント
	http.HandleFunc("/incoming", func(w http.ResponseWriter, r *http.Request) {
		resp, err := genIncomingTwiml()
		if err != nil {
			panic(err)
		}
		if _, err := w.Write(resp); err != nil {
			panic(err)
		}
	})

準備したエンドポイントに対してリクエストを投げた時に次のようなレスポンスが返ってくればバックエンドの準備は完了です

$ curl -X POST http://localhost:8686/incoming
<?xml version="1.0" encoding="UTF-8"?>
<Response><Dial><Client>planet-meron</Client></Dial></Response>

着信時のフロント実装

フロントでは着信用のボタンを準備し、jsで着信時の処理を実装していきます

  <h2>着信</h2>
  <button
    id="js-incoming-button"
    type="button"
    disabled
  >電話に出る</button>
const incomingButton = document.getElementById("js-incoming-button");

// ステータスの定義
const Status = {
    Setup: 1,
    Outgoing: 2,
    Incoming: 3,
    IncomingAccept: 4,
}

...

// twilioのコールバックの定義
addDeviceListeners = (device) => {
    // 着信準備完了
    device.on('registered', () => updateUIAndState(Status.Setup))

    // 着信
    device.on("incoming", handleIncomingCall);
}

// UIと変数の更新
updateUIAndState = (status) => {
    switch (status) {
        case Status.Setup:
            outgoingCall = undefined
            incomingCall = undefined

            outgoingButton.disabled = false
            outgoingButton.textContent = "発信"
            incomingButton.disabled = true
            incomingButton.textContent = "電話に出る"
            break
        case Status.Outgoing:
            outgoingButton.textContent = "切電"
            break
        // 着信時の画面・変数情報処理を追加
        case Status.Incoming: // 着信時
            incomingButton.disabled = false
            break
        case Status.IncomingAccept: // 着信時に電話を出た時
            incomingButton.textContent = "電話を切る"
            break
    }
}


// 着信時の処理
handleIncomingCall = (call) => {
    updateUIAndState(Status.Incoming)

    incomingButton.onclick = () => {
        if (incomingCall) {
            incomingCall.disconnect()
            return
        }
        call.accept()
        incomingCall = call
        updateUIAndState(Status.IncomingAccept)
    }

    call.on("cancel", () => updateUIAndState(Status.Setup)) // 電話に出ず発信元が電話を切った時
    call.on("disconnect", () => updateUIAndState(Status.Setup)) // 通話終了時
}

この状態で電話をかけた時に着信の音がなり、電話に出るボタンをと通話することができます

まとめ

以上TwilioとGolangを使った発着信WEBアプリのフローの説明と具体的な実装方法でした

今回は最低限の機能のみ紹介しているので、通話内容の録音やダイヤルパッドを用いた入力等の説明をしていません

Twilio自体本当に機能が豊富なので、ぜひ使い倒してみてください!

この記事で自分と同じような問題を抱えた人の少しでもお役に立てれば幸いです

それでは良いお年を!