やりたいこと
ずっと前からGoogle Homeが欲しくてついに10月の末に買いました。Google Homeには初めから買い物リストがついてくるのですが、私はGoogle Homeを買うより前、夫と二人で住み始めてからずっとGoogle Sheets (旧Google SpreadSheets) で買い物リストを運用をしていました。
Google Homeに初めからついているリストを使ってもいいのですが、もともと管理してた項目を追加し直すのが面倒くさい…。そこでGoogle Homeから自分で作ったGoogle Sheetsのリストを使えるようにしたい。これがやりたいことです。
早速、次の章からどうやって実現するか見ていきます。
どういう構成にするか?
私は以下のようなシーケンス図で処理が走るように実装しました。
この構成自体はググると割とよく出てくる構成です。そんなに特殊なことをするつもりもなかったので、この辺は工夫していません。自分と家族しか使わないのでキャッシュも特に不要です。
この構成で実装するにはあらかじめGoogle Homeと自分のアカウントのGoogle Assistantが繋がってることとGCPを扱えるようにしておく必要があります。最初の1年間は$300までは課金されないトライアル期間となります。私はつい先月までトライアル期間だったのですが、ついに終了したので12月からはアップグレードして課金勢になりました。従量課金制なので使わなければ課金はされませんが、そのへんが強く気になる人はまずは無料枠で試されることをオススメします。
GCPの無料枠についてはこちらの記事を参照してください。また今回使うCloud Functions の料金表とDialogflow の料金表はそれぞれのリンクを参照してください。 Google Sheetsに関しては個人利用のため初めから無料です。Cloud Funtionsを月に200万回も叩くことないですし、Dialogflowもそんなに凝ったことをしないのでStandard Edition(要するに無料枠)で私の用途なら十分です。従量課金といっても変なことしない限りそんなに困ることにはならないはずです(実際、2020年4月現在まで私の利用用途程度では課金されてません)。
ではさっそく、シーケンス図に出てくる各種サービスを有効化するところからはじめましょう。
各種サービスの有効化
Cloud Functionsを使えるようにする
Cloud Functionsを使えるようにするためにAPIを有効化してください。私はだいぶ前に有効化してしまってスクショの取りようがないため、コンソールからAPIを有効化するところの図などは省略しますが
の「コンソールへ移動」に進むと初回であればAPIを有効化するかダイアログで訊かれるはずです。
Sheets APIを使えるようにする
Cloud FunctionsからGoogle Sheetsのデータを参照するためにSheets APIを利用します。Sheets APIを使う部分の公式のサンプルコードはGo版のクイックスタートに載っていますが、ここからだと私の場合は新規GCP projectが作られてしまってややこしかったので、まず自分のGCP projectで「API & Services」の中の「Library」から「Sheets」と入力してGoogle Sheets APIを探してください。そこをクリックすると有効化の画面が表示されるはずです。
サービスアカウントでSheets APIにアクセスできるようにする
サービスアカウントを経由してSheets APIにアクセスできるようにしてみます。今回は横着して昔作ったApp Engineのデフォルトサービスアカウントを用いました。上記リンク先の注意書きにある通り、本来ならApp Engineで用いられるべきサービスアカウントなので推奨ではないのですが、サクッと試すには楽だったので使っています。
上記のリンクにある通り PROJECT_ID@appspot.gserviceaccount.com
の名前で作られてるものがあるはずなので、 PROJECT_ID
のところを自分のGCP projectのIDに置き換えて、使いたいスプレッドシートの共有設定にサービスアカウントのメールアドレスを追加してください。
Dialogflowを利用できるようにする
Cloud FunctionsからGoogle Sheetsを読み込む準備は整ったので、次はDialogflowを有効化します。
Dialogflowはこちらのページから使うことができます。コンソールに移動する際、Googleでログインすることを要求されるのでログインします。これでDialogflowを利用する準備もできました。
実装する
実際の実装順はシーケンス図の通りにはならないです。
- ステップ3にあるDialogflowの構文解析が動くようにする
- ステップ2、8をまとめて実装。Google HomeからDialogflowを叩いて期待通りの返事になるか試す
- ステップ5、6をまとめて実装。Cloud FunctionsからSheets APIを叩いてデータを取得できるようにする
- ステップ4、6をまとめて実装。DialogflowからCloud Functionsを叩いて期待通りの返事になるか試す
1と9は上記の実装がうまくいけばGoogle Homeが返事をいい感じにやってくれるので、実装は不要です。
上記に書いた実装順で進めます。
Dialogflowで構文解析できるようにする
図の上の方にもあるように、DialogflowのV1 APIは使えなくなるようなので、初めからV2 APIを使います。昔はDialogflow専用のAPI Docsがあったようですが、GCPの方に新しいドキュメントが作られたと案内があったので新しいドキュメントを参照しつつ作っていきます。エージェントなどDialogflow に出てくる言葉の説明はこちらを参照してください。今回は言葉を丁寧に説明するための記事ではないので省略します。
早速、「Create Agent」からエージェントの作成を行います。昔「地味にできる子」というbotを作ったことがあったので、「じみこ」という名前で作ります(趣味です)。名前に日本語を使ってはならないと書いてなかったのでノリでやってみましたが案外作れました。次はインテントを作るように促されるので「かいもの」インテントを作ります。
次にどんなふうに呼び出したいか指定するため「かいもの」インテントの「Training phrases」に話しかけたいフレーズをたくさん登録しましょう。最低10個はないとトレーニングがうまくいかないらしいです。これによってGoogle Home経由で話しかけたらDialogflowが構文解析できるようになります。
図だとすでに単語に色がついていますが、初めはなんの色もついていませんでした。単語の認識をさせるためにエンティティも登録します。左メニューの「Entity」をクリックし「Create Entity」ボタンを押して、エンティティを登録します。ここでは単語とその類義語を登録することができ、さらに先ほどのインテントのフレーズで特定の単語を値として認識させることができるようになります。
これが実際に登録した様子で、 exists
という値として「ある」、「ない」という単語が認識されるようになります。これで「ある」、「ない」という単語が色分けされるようになりました。画面にはないですが、これに加えて food
として「食べ物」、「食品」なども登録しておきます。もしすぐには色分けされない場合、もう一度インテントの画面に戻ってフレーズの中の「ある」という言葉を選択すると何という値として認識させたいか選ぶことができます。
これが実際に登録された様子です。ここまで来たら、今度は返信をしてほしくなるのでレスポンスを作ります。簡単にちゃんと値が認識されてるか見たいので、「Action and Parameters」には登録したentityとその値を取り出す変数の登録をし、その変数から値を取り出して表示するレスポンスを以下のように登録します。
この状態でテストをします。右側の「Try it now」に適当に話したいフレーズを入れてみます。以下のようになると成功です。
ちゃんと登録した単語も認識できたし、値も取り出せました。
Google HomeとDialogflowを接続する
ここまででGoogle HomeとDialogflowをつないで、期待のレスポンスが返ってくるか試してみます。 左メニューの「Integrations」を選択し、「Google Assistant」を選びます。するとさっき作ったインテントを登録するダイアログが出てきます。「かいもの」インテントを選択して、とりあえず見た目にわかるようにしておきたいので、自分のスマホのGoogle Assistantに話しかけて本当に登録されてるか見てみます。ダイアログを出したままの状態で下の方に出てくる「TEST」ボタンを押すと別タブでシミュレーターが開きます(このシミュレーターは後で使うのでタブを閉じないでください)。自分のスマホでいいので試しに「テスト用アプリにつないで」とGoogle Assistantに話しかけると、それ用のアプリが立ち上がります。ここで「食品ある」と話しかけたところが以下です。
「OK繋がった」はうまくいったので思わず続けてしゃべったらわかんないって返事をもらったところです…。
Cloud FunctionsからSheets APIを叩いてデータを取得できるようにする
Go版のクイックスタートにはGitHubにあがっているコードへのリンクがあります。そのコードから学生名簿となっているシートを選んで学生名を表示させるコードを私向けに修正します。
// 参照したいスプレッドシートのIDを環境変数からとってくる spreadsheetID := os.Getenv("SPREADSHEET_ID") readRange := "食品!A:B" resp, err := srv.Spreadsheets.Values.Get(spreadsheetID, readRange).Do() if err != nil { log.Fatalf("Unable to retrieve data from sheet: %v", err) } if len(resp.Values) == 0 { log.Println("No data found.") } else { log.Println("あるなし, もの:") for _, row := range resp.Values { log.Printf("%s, %s\n", row[0], row[1]) } }
これで「やりたいこと」に貼った私が管理するシートから在庫を取り出してログに出力する部分は作れます。 あるだけを取り出したいところですが、まぁ動作確認するだけならこれでも十分です。
os.Getenv("SPREADSHEET_ID")
として環境変数から利用したいシートのIDを渡すことができるようにしています。人によって使用するシートは違うはずなので、環境変数にそのIDを指定できるように作っています。 Cloud Functionsはdeploy時に環境変数を指定することができるのですが、毎回実行時引数に環境変数を渡すのは面倒です。このため .env.yaml
というファイルに環境変数のキーとバリューを記載しておき、毎回同じファイル名を実行時に渡すことでdeployコマンド自体を大きく変更しなくていいようにしました。 .env.yaml
には次のように書いておきます。
SPREADSHEET_ID: YourSpreadsheetID # YourSpreadsheetIDに呼び出したいスプレッドシートのIDを入れる
.env.yaml
が用意できたら、以下のdeployコマンドを叩きます。
$ gcloud functions deploy $FUNCTION --entry-point $ENDPOINT --trigger-http --runtime=go111 --region=$REGION --env-vars-file .env.yaml
$FUNCTION
はファンクション名、 $ENDPOINT
は叩くエンドポイントで呼び出される関数名、 $REGION
は利用しているファンクションのリージョンを各人に合わせて指定してください。
DialogflowからCloud Functionsを叩けるようにする
あとはDialogflowとCloud Funtionsが繋がればGoogle Homeがユーザーに返事してくれます。
DialogflowからCloud Functionsを叩くにはフルフィルメントという機能を使います。「かいもの」インテントの一番下にある「Enable webhook for this intent」を有効化します。
次に左メニューの「Fulfillment」をクリックし、WebhookのURLのところにさっきdeployしたCloud Functionsのエンドポイントを登録します。認証をかけている場合は認証情報も入力しましょう。
Cloud Functionsには簡単に以下のようなコードを書いて、webhookが叩かれてるかを確かめます。
type DialogflowRequestBody struct { QueryResult QueryResult `json:"queryResult"` } type QueryResult struct { QueryText string `json:"queryText"` Parameters map[string]interface{} `json:"parameters"` } // Dialog is Dialogflowからのリクエストを受け取るエンドポイント func Dialog(w http.ResponseWriter, r *http.Request) { var d model.DialogflowRequestBody if err := json.NewDecoder(r.Body).Decode(&d); err != nil { w.WriteHeader(http.StatusInternalServerError) log.Fatalf("failed to parse: %v", r.Body) } log.Print(d) w.WriteHeader(http.StatusOK) }
この状態でGoogle Assistantにさっきと同じような「食品ある」か聞いてみると、ログにリクエストの情報が載るので確認してみます。
ちゃんと繋がりましたね!
最終動作確認をする
仕上げです。 Dialogflowからのメッセージをパースし、もし今家にない食べ物がなにか知りたいときは「食べ物何がない?」と聞くことになると思います。ここは単純化して「食べ物何がない?」をそのままGoogle Homeに話しかけ、今ないものを取り出します。Google Homeで認識できる形式のレスポンスを返す必要もあるのでレスポンスの説明も読みつつ、必要なコードを書きました。
// Dialog is Dialogflowからのリクエストを受け取るエンドポイント func Dialog(w http.ResponseWriter, r *http.Request) { var d model.DialogflowRequestBody if err := json.NewDecoder(r.Body).Decode(&d); err != nil { w.WriteHeader(http.StatusInternalServerError) log.Fatalf("failed to parse: %v", r.Body) } str, err := reply(d.QueryResult) if err != nil { w.WriteHeader(http.StatusInternalServerError) } fmt.Fprint(w, html.EscapeString(str)) log.Print(d) w.WriteHeader(http.StatusOK) } // reply replies a message func reply(e QueryResult) (string, error) { exists := e.exists() food := CheckFood(exists) var jsonStr string var message string if exists { message = food + "はあるよ" } else { message = food + "はないよ" } jsonStr = createDialogFlowMessage(message) return jsonStr, nil } func (e QueryResult) exists() bool { params := e.Parameters if params["exists"] == "ある" { return true } return false } // createDialogFlowMessage creates a message to reply Dialogflow func createDialogFlowMessage(s string) string { str := `{ "payload": { "google": { "expectUserResponse": true, "richResponse": { "items": [ { "simpleResponse": { "textToSpeech": "` + s + `", "displayText": "` + s + `" } } ] } } } }` return str }
if len(resp.Values) == 0 { log.Println("No data found.") return "" } else { log.Println("checking") for _, row := range resp.Values { if exists == true && row[0] == "ある" { log.Printf("checked: %s", row[1]) return fmt.Sprintf("%s", row[1]) } else if exists == false && row[0] == "なし" { log.Printf("checked: %s", row[1]) return fmt.Sprintf("%s", row[1]) } } } return ""
さっきのGoogle Assistantで話しかけたときはテスト用アプリだったので、ちゃんと名前をつけようと思います。 Dialogflowと Google Assistantが繋がってることを確認した際のシミュレーターの上部メニューに「Development」というタブがあるので、そこをクリックし「Invocation」を開きます。すると名前の登録ができるので適当な名前をつけてください。
絶妙にイントネーションが違うのですが、まぁイントネーションをいじる方法はわからなかったので名前だけ登録して満足してます。というわけで早速じみこを呼び出し、本当に食品をシートから探してくれるかやってみます。さきほどは「テスト用アプリにつないで」でしたが、今度は「じみこにつないで」と話しかけてみます。アプリが立ち上がったら「食べ物何がない?」と話しかけてみます。
無事できましたね :tada:
音声を聞かせられないことが残念なのですが、これでGoogle Homeに対して「OK Google、じみこにつないで。食べ物何がない?」で、この画像と同じように「食パンはないよ」と返信してくれます。割と狙い通りに喋り返してくれると嬉しいものですね。
最後に
この記事は今日の昼くらいから作りながら書き始めてここまで行きました。前にSlack botでSheets APIを使えるようにしていた自前のコードがあったのでそれを流用したとはいえ、一日二日程度あれば作れてしまうというのは結構驚きです。
本当はもっと他にもできるようにしたいことがあるのですが、まず自分がやれるようにしたかった第一段階は突破した感じなので満足です。
今回扱った内容がひととおり触れるコードをおいておきます。またSlackで試しに動かすこともできるよう slack-adapter
というブランチも用意したので、slackでも試してみたい方はコードを読んでみてください(とはいえだいぶ適当ですが…)。
slack-adapter ブランチは master にマージしたので、ブランチを切り替えなくてもslack連携部分のコードが見れるようになりました。
slackに返信するだけのbotをCloud Functionsで作る件は
で、昔記事にしていました。