わすれっぽいきみえ

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

久しぶりにアドベントカレンダーに参加した

qiita.com

久しぶりにアドベントカレンダーに投稿した。

例の Google Home を使ってゴニョゴニョする系の記事。すごい久しぶりに書いた割に結構あっさり動かすところまでいけた感じで、自分としては驚きだった。

本当は Google Home から買った食べ物のステータス変えるとか、まだリストにない食べ物を追加するとか、日用品は別タブなのでそっちも呼び出せるようにするとか手を出したいところはたくさんあるけど、とりあえず一気通貫で連携できるところまでできたのは満足。Google Home は夫のスマホも連携してるので、夫のスマホからじみこが呼び出せるかどうかも確かめたいところ。まぁ今度呼び出してもらおう。

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

やりたいこと

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

f:id:kimikimi714:20200329114435p:plain

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

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

どういう構成で Google Home + Google Sheet を操作できるようにするか?

私は以下のような構成で実装しました。

Google Home + Dialogflow + Cloud Functions + Sheets

この構成自体はググると割とよく出てくる構成です。そんなに特殊なことをするつもりもなかったので、この辺は工夫していません。また、あらかじめ Google Home と自分のスマホGoogle Assistant が繋がってる前提とします。

この構成で実装するにはGCPを扱えるようにしておく必要があります。最初の1年間は$300までは課金されないトライアル期間となります。私はつい先月までトライアル期間だったのですが、ついに終了したので12月からはアップグレードして課金勢になりました。従量課金制なので使わなければ課金はされませんが、そのへんが強く気になる人はまずは無料枠で試されることをおすすめします。

GCPの無料枠についてはこちらの記事を参照してください。また今回使う Cloud Functions の料金表Dialogflow の料金表はそれぞれのリンクを参照してください。 Google Sheetsに関しては個人利用のため初めから無料です。Cloud Funtions を月に200万回も叩くことないですし、 Dialogflow もそんなに凝ったことをしないので Standard Edition (要するに無料枠)で私の用途なら十分です。従量課金といっても変なことしない限りそんなに困ることにはならないはずです(実際、2020年3月現在まで私の利用用途程度では課金されてません)。

Cloud Functions から Google Sheet を操作できるようにする

Cloud Functions を使えるようにする

Cloud Functions を使えるようにするためにAPIを有効化してください。私はだいぶ前に有効化してしまってスクショの取りようがないため、コンソールからAPIを有効化するところの図などは省略しますが

cloud.google.com

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

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

自分のSheetsを読み込むコードを書いてみる

Go版のクイックスタートにはGitHubにあがっているコードへのリンクがあります。そのコードから学生名簿となっているシートを選んで学生名を表示させるコードを私向けに修正します。本当はGCSからサービスアカウントの認証情報を読み出すコードに変更も必要なのですが本質ではないのでここでは省略します。

   // 参照したいスプレッドシートのIDを環境変数からとってくる
    spreadsheetID := os.Getenv("SPREADSHEET_ID")
    readRange := "食品!A2: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 から Sheets は扱えるようになったので、 Dialogflow から Cloud Functions を叩いて、結果 Sheets が操作できるようにします。

Dialogflow はこちらのページから使うことができます。コンソールに移動する際、Googleでログインすることを要求されるのでログインも済ませておきます。

f:id:kimikimi714:20200329114536p:plain

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

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

次にどんなふうに呼び出したいか指定するため「かいもの」インテントの「Training phrases」に話しかけたいフレーズをたくさん登録しましょう。最低10個はないとトレーニングがうまくいかないらしいです。

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

f:id:kimikimi714:20200329115155p:plain

「OK繋がった」はうまくいったので思わず続けてしゃべったらわかんないって返事をもらったところです…。

Dialogflow から Cloud Functions を叩けるようにする

あとは Dialogflow と Cloud Funtions が繋がればシートの中身を Google Assistant から見れます。最終は Google Home ですが、とにかくやってみましょう。

Dialogflow から外のAPIを叩くにはフルフィルメントという機能を使います。「かいもの」インテントの一番下にある「Enable webhook for this intent」を有効化します。

f:id:kimikimi714:20200329115246p:plain

次に左メニューの「Fulfillment」をクリックし、WebhookのURLのところに 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)
        return
    }

    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)
        return
    }
    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でも試してみたい方はコードを読んでみてください(とはいえだいぶ適当ですが…)。

github.com

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

kimikimi714.hatenablog.com

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

Google Home mini と Nature Remo mini を買ってしまった

f:id:kimikimi714:20191028232458j:plain

f:id:kimikimi714:20191028232639j:plain

ついに買ってしまった。家の帰りにビックカメラに寄ったらセットで10,978円で売ってて、驚くほどやすくなってたので衝動買いした。

セットアップくらいしかしてないからなんて記事にならないけど、とりあえず家のインターネットがひどすぎてYouTubeですらしょっちゅうローディング画面になってしまうのが、 Google Home が考え込んでるみたいに見えるのが不思議だった。

また土日に遊ぼう。