わすれっぽいきみえ

みらいのじぶんにやさしくしてやる

Google Home から自前で作った買い物リストを使えるようにする

やりたいこと

ずっと前からGoogle Homeが欲しくてついに10月の末に買いました。Google Homeには初めから買い物リストがついてくるのですが、私はGoogle Homeを買うより前、夫と二人で住み始めてからずっとGoogle Sheets (旧Google SpreadSheets) で買い物リストを運用をしていました。

f:id:kimikimi714:20200329114435p:plain

Google Homeに初めからついているリストを使ってもいいのですが、もともと管理してた項目を追加し直すのが面倒くさい…。そこでGoogle Homeから自分で作ったGoogle Sheetsのリストを使えるようにしたい。これがやりたいことです。

早速、次の章からどうやって実現するか見ていきます。

どういう構成にするか?

私は以下のようなシーケンス図で処理が走るように実装しました。

f:id:kimikimi714:20200430181819p:plain

この構成自体はググると割とよく出てくる構成です。そんなに特殊なことをするつもりもなかったので、この辺は工夫していません。自分と家族しか使わないのでキャッシュも特に不要です。

この構成で実装するにはあらかじめ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を有効化するところの図などは省略しますが

cloud.google.com

の「コンソールへ移動」に進むと初回であればAPIを有効化するかダイアログで訊かれるはずです。

Sheets APIを使えるようにする

Cloud FunctionsからGoogle Sheetsのデータを参照するためにSheets APIを利用します。Sheets APIを使う部分の公式のサンプルコードはGo版のクイックスタートに載っていますが、ここからだと私の場合は新規GCP projectが作られてしまってややこしかったので、まず自分のGCP projectで「API & Services」の中の「Library」から「Sheets」と入力してGoogle Sheets APIを探してください。そこをクリックすると有効化の画面が表示されるはずです。

f:id:kimikimi714:20200329231307p:plain
Sheets APIの有効化画面。ENABLEを押すと有効化される。

サービスアカウントでSheets APIにアクセスできるようにする

cloud.google.com

サービスアカウントを経由してSheets APIにアクセスできるようにしてみます。今回は横着して昔作ったApp Engineのデフォルトサービスアカウントを用いました。上記リンク先の注意書きにある通り、本来ならApp Engineで用いられるべきサービスアカウントなので推奨ではないのですが、サクッと試すには楽だったので使っています。

上記のリンクにある通り PROJECT_ID@appspot.gserviceaccount.com の名前で作られてるものがあるはずなので、 PROJECT_ID のところを自分のGCP projectのIDに置き換えて、使いたいスプレッドシートの共有設定にサービスアカウントのメールアドレスを追加してください。

f:id:kimikimi714:20200329231103p:plain

Dialogflowを利用できるようにする

Cloud FunctionsからGoogle Sheetsを読み込む準備は整ったので、次はDialogflowを有効化します。

Dialogflowはこちらのページから使うことができます。コンソールに移動する際、Googleでログインすることを要求されるのでログインします。これでDialogflowを利用する準備もできました。

実装する

実際の実装順はシーケンス図の通りにはならないです。

  1. ステップ3にあるDialogflowの構文解析が動くようにする
  2. ステップ2、8をまとめて実装。Google HomeからDialogflowを叩いて期待通りの返事になるか試す
  3. ステップ5、6をまとめて実装。Cloud FunctionsからSheets APIを叩いてデータを取得できるようにする
  4. ステップ4、6をまとめて実装。DialogflowからCloud Functionsを叩いて期待通りの返事になるか試す

1と9は上記の実装がうまくいけばGoogle Homeが返事をいい感じにやってくれるので、実装は不要です。

上記に書いた実装順で進めます。

Dialogflowで構文解析できるようにする

f:id:kimikimi714:20200329114536p:plain

図の上の方にもあるように、DialogflowのV1 APIは使えなくなるようなので、初めからV2 APIを使います。昔はDialogflow専用のAPI Docsがあったようですが、GCPの方に新しいドキュメントが作られたと案内があったので新しいドキュメントを参照しつつ作っていきます。エージェントなどDialogflow に出てくる言葉の説明はこちらを参照してください。今回は言葉を丁寧に説明するための記事ではないので省略します。

早速、「Create Agent」からエージェントの作成を行います。昔「地味にできる子」というbotを作ったことがあったので、「じみこ」という名前で作ります(趣味です)。名前に日本語を使ってはならないと書いてなかったのでノリでやってみましたが案外作れました。次はインテントを作るように促されるので「かいもの」インテントを作ります。

次にどんなふうに呼び出したいか指定するため「かいもの」インテントの「Training phrases」に話しかけたいフレーズをたくさん登録しましょう。最低10個はないとトレーニングがうまくいかないらしいです。これによってGoogle Home経由で話しかけたらDialogflowが構文解析できるようになります。

f:id:kimikimi714:20200329114642p:plain

図だとすでに単語に色がついていますが、初めはなんの色もついていませんでした。単語の認識をさせるためにエンティティも登録します。左メニューの「Entity」をクリックし「Create Entity」ボタンを押して、エンティティを登録します。ここでは単語とその類義語を登録することができ、さらに先ほどのインテントのフレーズで特定の単語を値として認識させることができるようになります。

f:id:kimikimi714:20200329114740p:plain

これが実際に登録した様子で、 exists という値として「ある」、「ない」という単語が認識されるようになります。これで「ある」、「ない」という単語が色分けされるようになりました。画面にはないですが、これに加えて food として「食べ物」、「食品」なども登録しておきます。もしすぐには色分けされない場合、もう一度インテントの画面に戻ってフレーズの中の「ある」という言葉を選択すると何という値として認識させたいか選ぶことができます。

f:id:kimikimi714:20200329114834p:plain

これが実際に登録された様子です。ここまで来たら、今度は返信をしてほしくなるのでレスポンスを作ります。簡単にちゃんと値が認識されてるか見たいので、「Action and Parameters」には登録したentityとその値を取り出す変数の登録をし、その変数から値を取り出して表示するレスポンスを以下のように登録します。

f:id:kimikimi714:20200329114936p:plain f:id:kimikimi714:20200329115020p:plain

この状態でテストをします。右側の「Try it now」に適当に話したいフレーズを入れてみます。以下のようになると成功です。

f:id:kimikimi714:20200329115057p:plain

ちゃんと登録した単語も認識できたし、値も取り出せました。

Google HomeとDialogflowを接続する

ここまででGoogle HomeとDialogflowをつないで、期待のレスポンスが返ってくるか試してみます。 左メニューの「Integrations」を選択し、「Google Assistant」を選びます。するとさっき作ったインテントを登録するダイアログが出てきます。「かいもの」インテントを選択して、とりあえず見た目にわかるようにしておきたいので、自分のスマホGoogle Assistantに話しかけて本当に登録されてるか見てみます。ダイアログを出したままの状態で下の方に出てくる「TEST」ボタンを押すと別タブでシミュレーターが開きます(このシミュレーターは後で使うのでタブを閉じないでください)。自分のスマホでいいので試しに「テスト用アプリにつないで」とGoogle Assistantに話しかけると、それ用のアプリが立ち上がります。ここで「食品ある」と話しかけたところが以下です。

f:id:kimikimi714:20200329115155p:plain

「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」を有効化します。

f:id:kimikimi714:20200329115246p:plain

次に左メニューの「Fulfillment」をクリックし、WebhookのURLのところにさっきdeployしたCloud Functionsのエンドポイントを登録します。認証をかけている場合は認証情報も入力しましょう。

f:id:kimikimi714:20200329115322p:plain

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にさっきと同じような「食品ある」か聞いてみると、ログにリクエストの情報が載るので確認してみます。

f:id:kimikimi714:20200329115358p:plain

ちゃんと繋がりましたね!

最終動作確認をする

  仕上げです。 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」を開きます。すると名前の登録ができるので適当な名前をつけてください。

f:id:kimikimi714:20200329115442p:plain

絶妙にイントネーションが違うのですが、まぁイントネーションをいじる方法はわからなかったので名前だけ登録して満足してます。というわけで早速じみこを呼び出し、本当に食品をシートから探してくれるかやってみます。さきほどは「テスト用アプリにつないで」でしたが、今度は「じみこにつないで」と話しかけてみます。アプリが立ち上がったら「食べ物何がない?」と話しかけてみます。

f:id:kimikimi714:20200329115518p:plain

無事できましたね :tada:

音声を聞かせられないことが残念なのですが、これでGoogle Homeに対して「OK Google、じみこにつないで。食べ物何がない?」で、この画像と同じように「食パンはないよ」と返信してくれます。割と狙い通りに喋り返してくれると嬉しいものですね。

最後に

この記事は今日の昼くらいから作りながら書き始めてここまで行きました。前にSlack botでSheets APIを使えるようにしていた自前のコードがあったのでそれを流用したとはいえ、一日二日程度あれば作れてしまうというのは結構驚きです。

本当はもっと他にもできるようにしたいことがあるのですが、まず自分がやれるようにしたかった第一段階は突破した感じなので満足です。

今回扱った内容がひととおり触れるコードをおいておきます。またSlackで試しに動かすこともできるよう slack-adapter というブランチも用意したので、slackでも試してみたい方はコードを読んでみてください(とはいえだいぶ適当ですが…)。

info

slack-adapter ブランチは master にマージしたので、ブランチを切り替えなくてもslack連携部分のコードが見れるようになりました。

github.com

slackに返信するだけのbotをCloud Functionsで作る件は

kimikimi714.hatenablog.com

で、昔記事にしていました。