Google アシスタントによるパラメタつき機器制御を課金なしで実現する方法

# でばぐ
2018/11/21 に Google assistant の仕様変更?があったらしく、webhookからの返り値をチェックするようになりました。そのため本記事の初期バージョンのやり方ではエラーが出ます。これについて、本記事中の hook.io のコードを修正しました。

# はじめに

IoTぽい工作として、Googleアシスタント (Google home とかの会話機能) を使ってある程度複雑な (単に **キーワードをトリガとして特定の動作だけをするのではない** ような) 機器制御をしようと思ったら意外と **無料サービスで完結する方法** がネット上の解説として見つからなかったのでいろいろ頑張って実現してみました。

作るもののイメージはこんなです。

> YOU 「ねえGoogle、**俺さまスイッチ** につないで。」
> COM _『**俺さまスイッチ**です。』_
> YOU 「スイッチオン」
> COM _『スイッチをonします。』_
> スイッチがONになります。(この辺のI/O制御はこの記事では扱いません。print するだけ。)
> YOU 「スイッチ操作」   ← 曖昧な指示
> COM _『スイッチをどうしますか？』_  ← 具体的な指示を要求
> YOU 「切る」  ← さっきの続き（曖昧だった部分）だけ答えればいい
> COM _『スイッチをoffします。』_
> スイッチがOFFになります。on/off というパラメタを使った機器操作ができました！

![dfiot.png](https://qiita-image-store.s3.amazonaws.com/0/265397/8f5d764e-10b8-9e42-0ef3-91e295c9e7e8.png)


# ポイント

Googleのチュートリアルどおりやると、Fulfillment を実行するのに Google の [firebase](https://firebase.google.com) を使うことになるのですが、**無料プラン (Spark プラン) では送信リクエストを Google 外のサービスに投げることができません** (参考資料: [料金表](https://firebase.google.com/pricing/?hl=ja) の Cloud Functions の項目)。したがって無料の webhook サービス (本記事では [hook.io](https://hook.io) ) を利用する必要があります。


# 機能やサービスの用語のおさらい

  - Google assistant  
    スマートフォンや Google home などのアプリで音声や文字で会話してくれるアプリ。これが、さらに別アプリ（Alexaのスキルみたいなもの？) を起動し、そうすると会話UIはそのアプリとの会話に引き継がれます。引き継がれると言っても Actions on Google が中継するのでフロントエンドにいるのはあくまでもこいつ。
  - [Actions on Google](https://console.actions.google.com)  
    この辺 (https://developers.google.com/actions/extending-the-assistant?hl=ja) に書いてありますが、ようはGoogleアシスタントと別アプリの間を取り持つような仕組み？ (ごめんちゃんと説明できません。) 具体的にはユーザの要求に適したアプリを起動して、その後の会話情報をパイプするような、そんなものかな。
  - [DialogFlow](https://console.dialogflow.com)  
    いくつかある会話アプリを作る方法の中でたぶん一番標準的なもの。それなりに簡単でけっこう賢いです。これを使って自作するとちょっとしたAIエージェントを作れた気分になれます。今回はこれを使って IoT ぽい機器操作を行います。開発はweb上のインタフェースでできるので楽です。

まとめると、Google assistantアプリと会話していて、特定の発話（「〇〇につないで」等）をするとActions on Googleという仕組みを介して DialogFlow で作ったアプリに会話が引き継がれる、という感じ。細かいところは違っているかも（→指摘してください）。


# Actions on Google でプロジェクトを作る

  1. Actions on Google のコンソールで新規プロジェクトを作ります。プロジェクト名と言語・地域を設定して `CREATE PROJECT`。テンプレートプロジェクトがいくつも並びますが、右上の`SKIP`でまっさらな状態から始めましょう。

  2. Overview のギヤのアイコンから LANGUAGES の設定を行います。ま、Japanese で。
  3. Invocation メニューから起動条件を作成します。Display name に書いた言葉が「ねえGoogle。〇〇につないで。」の〇〇になります。Display name と言葉を別のものにしたければ Modify the pronunciation if it doesn't sound right から好きなものを入れれば ok です。
  4. Actions メニューから `ADD ACTION` します。「ユーザの要求」という意味で "intent" という言葉が使われていますが、テンプレート的にいくつかの Built-in intents があります。ですがここではせっかくだから Custom intent を選びましょう。以下、この Custom intent (ユーザの要求) を満足するように (DialogFlow で) 会話を作ります。intent という語は何度も出てきますが、Android アプリの Intent とは直接の関係はありませんので注意。どちらかというと (Android の Intent クラスのような) 固有名詞ではなく辞書にある意味の一般名詞と捉えた方が気が楽だと思います。


# DialogFlow で会話のシナリオを作る

Custom intent の作成を選択すると DialogFlow の web コンソールが起動します。ここは詳しく説明すると長いし [公式の説明](https://developers.google.com/actions/dialogflow/first-app?hl=ja) の方が正確で詳しいので詳しくはそちらを見てください。

ここでは簡単な例として「スイッチon/off」を操作するエージェントを作りましょう。

  1. entity  
     entity とはデータの型のようなものです。ここではスイッチの操作として「on」あるいは「off」の値を取る`@on_off`というentityを作ります。この値を指定する言葉のゆらぎも登録しておきます。値はすべて日本語でも良いのですが、最初のものは後からJSONで送られるのでASCII文字列にしておいた方が何かと無難かも。
![df_entities.png](https://qiita-image-store.s3.amazonaws.com/0/265397/3df01e47-54b8-d411-b56f-e6922482bc8f.png)


  2. intent  
     intentは日本語にしにくいですが、満たすべきユーザの要求のようなもので、この場合はスイッチをオンあるいはオフにしたい、という要求です。エージェントはこの要求内容を具体的に確定したいのです (確定したら次項の fulfillment として成就します)。そのために `Training phrases` というところにこの intent を持ったユーザが言いそうなフレーズをどんどん入力します。(するとうまいことAIさんが良きに計らってくれます。)
![intents1.png](https://qiita-image-store.s3.amazonaws.com/0/265397/7fefb632-5a1e-98ad-2313-267a286d3208.png)
その中にentityがあれば自動的に切り出してくれます。この **intent を成就するために必須の entity があれば** `Action and parameters` の `REQUIRED` にチェックを入れます。intent に対して REQUIRED な entity が得られない場合、`PROMPTS` に登録したセリフで聞き直します。
![intents2.png](https://qiita-image-store.s3.amazonaws.com/0/265397/d49b18cd-ca75-be0e-c392-56442994d5e1.png)
無反応だと寂しいので応答文を`Responses`に書きます。(2018/11/22追記: 現在の使用だとfulfillmentを使った場合、Responses は無視されるようです。fulfillmentからの応答(fulfillmentText)をしゃべります。)
![intents3.png](https://qiita-image-store.s3.amazonaws.com/0/265397/f50c2219-05fc-f4b8-e297-41a421c4afef.png)
確定した intent は Fulfillment として webhook をトリガーできます。ここでは`Enable webhook call for this intent` スイッチをオンにしておきます。
![intents4.png](https://qiita-image-store.s3.amazonaws.com/0/265397/8908ae1c-d5c4-7b27-3e36-a5db8b4bcbfa.png)

  3. Fulfillment
     ここで intent を成就すべく行動に入ります。今回は webhook で外部プログラムを起動します。ちょと注意がいるのは、**fulfillment は一つだけ** ということです。intent に応じて使い分けることはできません。いろいろな intent がある場合は、webhook 先のプログラムで対応する必要があります。それでは`Fulfillment` の `Webhook` を `ENABLED` にして、URL 欄に次節で説明する hook.io で作成するサービスの URL を記入しましょう。その他は空欄で構いません。
![fulfillment.png](https://qiita-image-store.s3.amazonaws.com/0/265397/02bb97b1-3e3f-2434-8239-99ca1f4fa970.png)


# hook.io

DialogFlow の Fulfillment の webhooks を受け取るために hook.io というサービスを使います。
`Create New Service`として、お好きな名前で microservice を作ってください。作成画面でURLが表示されるので、それを前節のFulfillmentのURL欄に入力します。
![hookionew.png](https://qiita-image-store.s3.amazonaws.com/0/265397/113b850d-8867-bd8f-69a1-c1f8f8c6b244.png)

`Source Code` 欄に javascript でコードを入力します。コードはこんな感じになります。
やっていることはdialogflowから受け取ったjsonをBeebotteというサービスにリレーしているだけです。JSONオブジェクトのキーが、dialogflowは`parameters`固定で、Beebotteは`data`固定なのでその変換をしています。どちらかがもう少し柔軟ならhook.io不要なはずです。

DialogFlow への返り値は[WebhookResponse](https://dialogflow.com/docs/reference/api-v2/rest/Shared.Types/WebhookResponse) として定義されているのでそれに従います。最も単純にするなら `fulfillmentText` だけでいいようです。

```js
module['exports'] = function myService (hook) {
  var req = require('request');  
  var params = hook.params['queryResult']['parameters'];
  var options = {
    uri: 'https://api.beebotte.com/v1/data/publish/[beebotteのchannel]/[beebotteのresource]?token=[beebotteのchannel token]',
    method:  'POST',
    headers: {'Content-Type': 'application/json'},
    json:    {'data': params}, 
  };
  var ret = {"fulfillmentText": "OK!"};  // これがレスポンスとして喋られます。
  console.log(params);  // デバグよう。hook.io の View Logs から見られる。
  req.post(options, (err, resp, body) => {hook.res.end(ret)});
};
```

コード中の`[boobotteのchannel]`、`[beebotteのresource]` および`[boobotteのchannle token]`のところに次節で説明するBeebotteの情報を入れます。

# Beebotte

channelを一つ作って、その中にresourceを一つ作ります。resource名は何でもいいですが、これで前節のソースコードに埋め込むURLが決まります。channel token は自動的に生成されるので copy & paste してください。

# お手許の Raspberry pi や PC 等、最終末端

長旅を終えてようやくゴールです。以下 Ruby と Python の例です。Raspberry Pi 等と組み合わせれば汎用家電操作リモコンもバッチリ作れそうです。

```ruby
#!/usr/bin/ruby
# Google Assistant -> DialogFlow -> hook.io -> beebotte -> THIS.rb

require "beebotte"              # gem install beebotte
require "json"

TOPIC = "[channel]/[resource]"  # channel/resource of Beebotte
TOKEN = "[token]"               # token of Beebotte

s = Beebotte::Stream.new({token: TOKEN})
s.connect
s.subscribe(TOPIC)
loop {
  _, msg = s.get
  j = JSON.parse(msg)["data"]
  if j.has_key? "on_off"
    puts "turn switch ON!"  if j["on_off"] == "on"
    puts "turn switch OFF!" if j["on_off"] == "off"
  else
    puts j
  end
}

```

```python
#!/usr/bin/python
# Google Assistant -> DialogFlow -> hook.io -> beebotte -> THIS.py

import paho.mqtt.client as mqtt   # pip install paho-mqtt
import json

HOST = 'api.beebotte.com'
TOPIC = '[channel]/[resource]'  # channel/resource of Beebotte
TOKEN = '[token]'               # token of Beebotte

def on_connect(client, userdata, flags, response_code):
    client.subscribe(TOPIC)

def on_message(client, userdata, msg):
    j = json.loads(msg.payload.decode("utf-8"))["data"]
    if "on_off" in j:
        if j["on_off"] == "on":
            print("turn switch ON!")
        else:
            print("turn switch OFF!")
    else:
        print(j)
            
if __name__ == '__main__':
    client = mqtt.Client()
    client.on_connect = on_connect
    client.on_message = on_message
    client.username_pw_set("token:%s" % TOKEN)
    client.connect(HOST)
    client.loop_forever()
```

# (hook.ioじゃなくて) IFTTT じゃダメか？

DialogFlow からトリガかけるだけならたぶん IFTTT でもいいと思います。が、パラメタ (本記事の場合、on/off) を渡すには JSON を変換しないとならないので恐らく無理なんじゃないでしょうか。
というか、パラメタ無しでキーワードでトリガするだけならそもそもDialogFlowなんて面倒なものも不要です。IFTTT > Beebotte > 末端 で行けます。

# セキュリティ対策

hook.io の無料サービスでは public なものしか提供できません。つまり誰でも webhook 投げられてしまうわけです。そこで例えばDialogFlow のEntity名を少し複雑な文字列にして、最終側できちんとチェックすればたぶん大丈夫ではないかと思います。 (ただし上記のクライアントの例は beebotte から先の通信がセキュアではないので注意)