LoginSignup
11
5

More than 5 years have passed since last update.

Googleアシスタント+Djangoに次のバスを調べてもらう

Last updated at Posted at 2018-09-08

はじめに

  • Google Homeが我が家にやってきたので、なにかしたい
  • 暑い中家から駅までのバスを待つのがつらい

ということで、Googleアシスタントに家から駅までのバスの時刻を教えてもらうことにしました。

素敵な先人たち(とくにほぼ同じ要件のこの記事)のおかげで、普段コーディングはあまりしない + Web系経験ゼロの自分でも実現はできたのですが、やはりハマりポイントはあるもの。先人たちへの感謝を込めてまとめておきたいと思います。

要件

  • バスの時間を教えてくれる
    • 現在から一番近いバスの時間
    • 指定した時刻から一番近いバスの時間
    • 教えてくれたバスの時間の次のバスの時間
  • インタフェースはGoogleアシスタントと、ブラウザ(いちいちOK, Googleするのがめんどくさいときにブックマークで呼び出したい)
  • 家庭内でしか使わないので、バスの経路は家の最寄りバス停から最寄り駅までに固定

構成

ブラウザからもアクセスしたいので、下記の構成をとることにしました。
- Webサーバを用意して、バスの検索機能はそこに持たせる
- ブラウザで検索するときは、Webサーバに直接アクセスする
- Googleアシスタントで検索するときは、DialogflowからWebhookでバスの検索機能を呼び出す。応答もWebサーバに作成してもらう

まずは定型文

まずは、特定の検索ワード(「次のバスは?」)に対して定型文を返せるようにしたい。

Dialogflowの初期設定+定型文

下記の記事を参考に進めた。
- Dialogflow入門
- GoogleHomeMiniにバスの遅延を調べてもらう

「入門」の記事にあるように、DialogflowやActions on Google独自の概念が多く(Intent, Fulfillment, ...) 苦戦しましたが、試行錯誤しつつ熟読すると理解できました。先人たちに感謝です。

やったことをざっくりと

基本的に先人の足跡を辿っただけなので、箇条書きで。

  • Actions on Googleで新しいプロジェクトを作成
  • Invocationにアプリ名「次のバス」を設定
  • Actionsから新しいDialogflowのIntentを作成してtraining phrasesに「次のバスは?」を設定。
  • Responsesに返したい定型文を設定。
  • IntegrationsにGoogle Assistantを設定して、TESTを実行。
  • 家のGoogle Homeに「次のバスにつないで」でアプリ起動。「次のバスは?」に対して定型文を返してくれた!

ハマったポイント

  • Intentの画面が変わったらしく、「入門」の記事にあるような「User Says」の欄がなかった
    • 「Training Phrases」に設定するとうまくいった
  • DialogflowのagentをActions on Googleに紐づける方法がなかなかわからなかった
    • Dialogflowの「Integrations」のメニューを見落としていた。。

Webhookで定型文を喋らせる

Dialogflowに閉じて定型文を喋らせることはできたので、次はWebhook経由で取得した定型文を喋らせます。

Webサーバをなんとか簡単に立ち上げたい!と考えていたところ、以前QiitaのトレンドにあがっていたGoogle Apps Scriptを使って簡易APIをサクッと作るの記事を思い出し、まずはGoogle Apps Script (GAS) で試してみました。

結論から言うと、Google Apps Scriptに応答文を返してもらうことはできませんでした。ここでハマった人でなければ、次の章へどうぞ。。

Google Apps Scriptで簡易APIを作成

こんな感じのスクリプトで定型文を返すようにしてみました。
Dialogflowのドキュメンテーションによると、DialogflowはPOSTメソッドでAPIに発話の内容のJSONを渡し、逆に応答のJSONにfulfillmentTextを設定すればその通りに喋ってくれるようです。

DialogflowからこのAPIが叩けるように、「全員(匿名ユーザも含む)」でこのスクリプトをWebアプリとして公開します。

function doPost(e) {
  // デバッグのためにPOSTデータを記録する
  var ss = SpreadsheetApp.openById('(your spreadsheet id here)');
  var sheet = ss.getSheets()[0];
  var range  = sheet.getRange('A1');
  range.setValue(e.postData.getDataAsString())

  // JSONで定型文を返す
  var output = ContentService.createTextOutput();
  output.setMimeType(ContentService.MimeType.JSON);
  output.setContent(JSON.stringify({"fulfillmentText": "This is a text response"}));
  return output
}

DialogflowでFulfillmentとWebhookを設定

Dialogflow入門を参考に進めました。

  • 先ほどの「次のバスは?」IntentでWebhookを有効にする
  • FulfillmentでWebhookをenabledにして、先ほどのAPIのURLを貼り付けてSave

ハマったポイント: Google Apps ScriptにPOSTしたらMoved Temporarilyが返ってくる

これでいける!と思いきや、Dialogflowのシミュレータは応答なし。応答の欄はNot Availableになっています。。

スクリプトのスプレッドシートにDialogflowからのリクエスト (スクリプトの前半) が残っているので、リクエストは到達しているがレスポンスが返っていない様子。

DialogflowのシミュレータのDIAGNOSTIC INFOを見ると、FULFILLMENT RESPONSEにてリダイレクトがかかっていました。

Webhookからのレスポンス
<HTML>
<HEAD>
<TITLE>Moved Temporarily</TITLE>
</HEAD>
<BODY BGCOLOR="#FFFFFF" TEXT="#000000">
<H1>Moved Temporarily</H1>
The document has moved <A HREF="https://script.googleusercontent.com/macros/echo?user_content_key=(後略)">here</A>.
</BODY>
</HTML>

ぐぐったところ先人が経験済みのようで、curl -L (Follow redirects) を打ってみたところきちんとレスポンスが返ってきました。

GASのドキュメントに下記の記載を見つけました。無効化はできない雰囲気です。

For security reasons, content returned by the Content service isn't served from script.google.com, but instead redirected to a one-time URL at script.googleusercontent.com. This means that if you use the Content service to return data to another application, you must ensure that the HTTP client is configured to follow redirects.

DialogflowのWebhookはリダイレクトを追いかけない

ではDialogflowでもリダイレクションにきちんとついていけばよい、と思い設定を探してみましたが、見当たりませんでした。

Heroku + Djangoで応答文用のサーバを立ち上げる

Google Appsは諦めて、Redirectされないサーバをきちんと用意することにします。

まずは同じGoogleのFirebaseを考えましたが、Firebase外にアクセス(たとえば、バスを検索する)には有料プランにする必要があるようです。
無料枠もそこそこあるようですが、なんとなくクレカの情報を入れたくなかったため、Herokuを使うことにしました。

WebサーバにはDjangoを使うことにしました。
JavascriptやRubyをろくに書いたことがなく、Pythonは一通り書けるから、程度の理由です。(時間があれば勉強してみたい)

Webサーバを立ち上げる

Heroku公式のGetting Started with Pythonがとても親切で、これに従うだけでHeroku上でDjangoのサーバが動いてしまいました。
git push heroku master一発でデプロイできてしまい感動。すごい時代だ。。

Djangoで定型文を返す

チュートリアルでつくったgettingstartedプロジェクトのhelloアプリをそのまま修正して、定型文を返すようにします。この時点での主なファイルは下記のような感じです。

├── app.json
├── gettingstarted
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── hello
│   ├── __init__.py
│   ├── admin.py
│   ├── models.py
│   ├── tests.py
│   └── views.py
├── manage.py
└── staticfiles

まずは、定型文をfulfillmentTextに返す処理をhello/views.pyに追記します。

views.py
import collections
from django.http.response import JsonResponse
# ... 中略 ... #
def buskensaku(request, status=None):
    fulfillment_text = '定型文'
    data = collections.OrderedDict()
    data['fulfillmentText'] = fulfillment_text
    return JsonResponse(data)

gettingstarted/urls.pyを編集して、先ほどのビュー (buskensaku) にURLを紐付けます。ついでに、チュートリアルのページはもう使わないので開けっぱなしにせずコメントアウトしました。

urls.py
# ... 前略 ... #
urlpatterns = [
#    url(r'^$', hello.views.index, name='index'),  # <-- コメントアウト
#    url(r'^db', hello.views.db, name='db'),  # <-- コメントアウト
#    path('admin/', admin.site.urls),  # <-- コメントアウト
    url(r'^buskensaku', hello.views.buskensaku, name='buskensaku')
]

ハマったポイント: DjangoにPOSTするときはCSRF対策をoffにする必要がある

ここまでの内容で/buskensaku/を叩いてみたところ、GETではうまくいくのにPOSTでは下記のメッセージが出てうまくいきません。

Forbidden (403)
CSRF verification failed. Request aborted. (後略)

DjangoのCSRF周りの設定の先人の記事を見ると、POSTさせるためのフォームに一時的なトークンを埋め込んでそれをPOSTデータで送信させることで、クロスサイトリクエストフォージェリ (CSRF) を防ぐ仕組みのように見えました。

今回のユースケースの場合、DialogflowからいきなりリクエストがPOSTされるので、トークンを払い出す契機がありません。DjangoのCSRF対策の機能を無効化するしかなさそうです。

ぐぐったところ、@csrf_exemptというええ感じのデコレータが見つかりました。これを使うことにします。

views.py
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def buskensaku(request, status=None):
    # 略

この状態でDialogflowに話しかけてみたところ、無事定型文が返ってきました!

現在の次のバスの時間を返す

あとはいい感じに次のバスの時間を外部サイトで検索して応答文 (fulfillmentText) に埋め込むだけで、次のバスの時間を教えてくれるようになりました!
応答文中の時間は14:17:00のような書式 (datetime.datetime.strftimeでいうと'%H:%M:%S') で渡すと「14時17分」のようにきちんと喋ってくれます。

Dialogflowからパラメータを受け取る

指定した時刻から一番近いバスの時間を教えてもらえるように機能を追加します。

Dialogflowにパラメータを抽出してもらう

まずは時刻指定検索のために新しいIntentを作ります。
Training phrasesに思いつく限りの検索フレーズを入力すると、パラメータっぽいところを抽出してくれます。すごい。
パラメータ部分を伸縮させることもできます。
image.png

Dialogflowからのリクエストはこんな感じになります。先ほどのパラメータの名前にはthe_timeを指定しており、これがparametersに設定されています。

{
  "responseId": "10dca552-cea8-44da-b13e-84f1a8c6e5a3",
  "queryResult": {
    "queryText": "19時で探して",
    "action": "時刻指定検索",
    "parameters": {
      "the_time": "2018-09-05T19:00:00+09:00"
    },
# 後略

ハマったポイント: Dialogflowが「xx時半」を抽出してくれない

先ほどのJSONの例は「19時で探して」という問いに対してにきちんと"19:00:00"をパラメータとして抽出してくれています。これを「19時半で探して」としても、なんと"19:00:00"が抽出されてきてしまいます。
queryTextはきちんと「19時半で探して」になっているので、パラメータの抽出時に「半」が無視されてしまうようです。

結局parametersは使わずに、queryTextから正規表現でひっかけることにしました。

RE_QUERY_TIME = r"""(?P<hour>\d\d?)時((?P<minute>\d\d?)分|(?P<han>半))?"""
def detect_time(txt) -> str:
    """
    発話 (txt) から時刻を抽出する。hh:mm:ssにして返す
    見つからなければ、''を返す
    """
    m = re.match(RE_QUERY_TIME, txt)
    if m is None:
        return ''

    hour = int(m.group('hour'))
    if m.group('han'):
        minute = 30
    elif m.group('minute') is None:
        minute = 0
    else:
        minute = int(m.group('minute'))
    return f'{hour:02d}:{minute:02d}:00'

うーん力技。

あとは現在時刻ではなく上記でひっかけた時刻で検索してあげれば、きちんと時刻指定検索ができました。

DialogflowのContextを使う

さて、次はさっき検索したバスの次のバスを検索します。
もちろん「さっき検索したバス」は文脈に依存しますが、この文脈はDialogflowのContextという機能で扱うことができます。

WebhookからContextを渡す

まずは、毎回の検索時に「さっき検索したバスの時刻」のContextをサーバから返してあげる機能をつくります。

views.py
@csrf_exempt
def buskensaku(request, status=None):
    session_id = ''
    try:
        req_json = json.load(request)
        intent_name = req_json['queryResult']['intent']['displayName']
        query_text = req_json['queryResult']['queryText']
        session_id = req_json['session']
    except:
        pass
# 略
    data = collections.OrderedDict()
    data['fulfillmentText'] = fulfillment_text  # <-- 略の部分で設定済み
    data['outputContexts'] = [
            collections.OrderedDict([
                ('name', session_id + '/contexts/last_search'),
                ('lifespanCount', 5),
                ('parameters', {'bus_time': time_str}),  # <-- 今回検索結果として出てきたバスの時刻
            ]),
    ]
    return JsonResponse(data)

ハマったポイント: ContextにはセッションIDを含める必要がある

DialogflowのFulfillment画面に、Firebaseでfulfillmentを処理するためのサンプルコードがあり、それを参考にしていたらハマりました。サンプルコードではnameにセッションIDが含まれていませんが、この状態で話しかけると、DialogflowにWebhookのレスポンスは渡っているものの応答文が読み上げられません。

index.js
agent.setContext({ name: 'weather', lifespan: 2, parameters: { city: 'Rome' }});

DialogflowのAPIが何処かのタイミングでバージョンアップ (V1->V2) されているようでした。Dialogflowのドキュメンテーションを見ると下記の記載があり、Project IDとSession IDが設定必須のようです。

Format: 
projects/<Project ID>/agent/sessions/<Session ID>/contexts/<Context ID>, or
projects/<Project ID>/agent/environments/<Environment ID>/users/<User ID>/sessions/<Session ID>/contexts/<Context ID>.

リクエストのJSONに"session": "projects/(Project ID)/agent/sessions/(session ID)"の要素があるので、これに/contexts/last_search (last_searchはContextの名前) をくっつけてContextのname要素に設定すると、きちんと応答が返ってきました。

Contextを受け取る

Dialogflowで「さっきのバスの次のバスを検索」するためのIntentを作成します。名前は「next bus」としました。
ここでContextsの欄のinput contextに先ほどのlast_searchを追加します。

ここでinput contextにlast_searchを追加することで、last_searchのcontextがない場合にこのIntentが呼び出されなくなるようです。(追加しなくてもWebhookには渡されます)

Webhookのリクエスト (queryResult.outputContexts) にDialogflowがcontextのリストを設定してくれるので、サーバ側ではnameが一致するcontextをリストから取り出すだけです。

views.py
def buskensaku(request, status=None):
# 略
        if intent_name == 'next bus':  # <-- 「さっきの次のバス」のIntentの名前
            contexts = req_json['queryResult']['outputContexts']
            last_search = list(filter(
                lambda x: x['name'] == session_id + '/contexts/last_search', 
                contexts))[0]
            bus_time = last_search['parameters']['bus_time']

これで、さっき検索したバスの次のバスを調べられるようになりました。

HTTPS + Basic認証を使う

DialogflowのWebhookの設定にBasic認証の欄があったので、念のため認証をかけておくことにしました。

Django でBasic認証をかけるを参考にしましたが、下記の変更を加えました。

  • Djangoのドキュメントを見るとMiddlewareの書き方がDjangoの何処かのバージョンから変わっているようなので、その書き方でかいてみた
  • Python 3化
  • APIを叩く用途なので、認証に失敗したら401 (Unauthorized) ではなく403 (Forbidden) を返すようにした
  • パスワードはなんとなくハッシュしてみた
middleware.py
import base64
import logging

from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.contrib.auth.hashers import PBKDF2PasswordHasher

logger = logging.getLogger('django')

class BasicAuthMiddleware(object):
    """
    performs basic auth.
    if unsuccessful, returns 403 Forbidden.
    """
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if 'HTTP_AUTHORIZATION' not in request.META:
            raise PermissionDenied

        (method, encoded) = request.META['HTTP_AUTHORIZATION'].split()
        if method.lower() != 'basic':
            raise PermissionDenied

        (username, password) = base64.b64decode(encoded).split(b':')
        encoding = request.encoding or settings.DEFAULT_CHARSET
        try:
            logger.info(f'auth attempt with user {username}')
            username = username.decode(encoding)
            password = password.decode(encoding)
            if PBKDF2PasswordHasher().verify(password, settings.BASIC_AUTH_IDS[username]):
                return self.get_response(request)
        except:
            pass

        raise PermissionDenied
settings.py
MIDDLEWARE = [
# 略。下記を追記
    'gettingstarted.middleware.BasicAuthMiddleware',
]

BASIC_AUTH_IDS = {
    'ユーザID1': 'パスワードを元にPBKDF2PasswordHasher.encodeで生成したハッシュ',
}

Basic認証を使うので、settings.pyにSECURE_SSL_REDIRECT = Trueを設定して、HTTPSを強制(HTTPでアクセスするとMoved PermanentlyでHTTPSのURLにリダイレクト)するようにしました。

これでサーバ側の設定は完了したので、DialogflowのWebhookの設定にsettingsで指定したIDとパスワードを入力します。
パスワードが正しいときに応答に成功し、間違っているときに失敗することを確認します。

まとめ

  • Dialogflowの設定は、独自概念に慣れれば初学者でもなんとかなった
  • Google Apps Scriptはリダイレクト必須。Dialogflowはリダイレクト非対応なので相性がよろしくない
  • Herokuすんげえ
  • DjangoのCSRF然り、DialogflowのセッションID然り、セキュリティ周りの知見があると実装もスムーズにできそう
  • 時間のような定型表現は、Dialogflowのパラメータ抽出に頼らないようが早いかも
  • あ、ブラウザインターフェースつくってない
11
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
5