Alexa に次のバスが何分後に来るかしゃべらせるスキルを作った

いつもナビタイムの「次のバス」スキルにお世話になっていました。

これは「最寄りのバス停を登録しておくと "次のバスのスキルを開いて" というだけで次のバスが何分後に来るか、さらにその次のバスは何時何分か」を教えてくれる便利なスキルです。

ところが、このスキルが今月末にサービス終了するようです。

これは非常に困るので自分で Alexa スキルを作ります。

Alexa を開発できるようにするための事前準備は前回の記事をご参照ください。

talkeyboid.com

コード書くぞ!

まずは結論から。以下のコードで実現できました。※時刻表の値はダミーです。

# -*- coding: utf-8 -*-
import logging
import ask_sdk_core.utils as ask_utils

from ask_sdk_core.skill_builder import SkillBuilder
from ask_sdk_core.dispatch_components import AbstractRequestHandler
from ask_sdk_core.dispatch_components import AbstractExceptionHandler
from ask_sdk_core.handler_input import HandlerInput

from ask_sdk_model import Response

# 日付時刻系インポート
from datetime import date, datetime, timezone, timedelta
import jpholiday
import math

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

# 時刻表マスタ ※祝日はsunday扱い
def get_today_table(weekday_en, year, month, day):
    master_timetable = {
        "weekday": [
            datetime(year, month, day, 6, 5, 00, tzinfo=timezone(timedelta(hours=9))),
            datetime(year, month, day, 6, 23, 00, tzinfo=timezone(timedelta(hours=9))),
            datetime(year, month, day, 6, 48, 00, tzinfo=timezone(timedelta(hours=9))),
            ... # 以下略
            ],
        "saturday": [
            datetime(year, month, day, 6, 5, 00, tzinfo=timezone(timedelta(hours=9))),
            datetime(year, month, day, 6, 45, 00, tzinfo=timezone(timedelta(hours=9))),
            ... # 以下略
            ],
        "sunday": [
            datetime(year, month, day, 6, 5, 00, tzinfo=timezone(timedelta(hours=9))),
            datetime(year, month, day, 6, 45, 00, tzinfo=timezone(timedelta(hours=9))),
            ... # 以下略
            ]
    }
    return master_timetable[weekday_en];

class LaunchRequestHandler(AbstractRequestHandler):
    def can_handle(self, handler_input):
        return ask_utils.is_request_type("LaunchRequest")(handler_input)

    def handle(self, handler_input):
        # 日時取得
        timezone_tokyo = timezone(timedelta(hours=9))
        datetime_now_jst = datetime.now(timezone_tokyo)
        
        year = datetime_now_jst.year
        month = datetime_now_jst.month
        day = datetime_now_jst.day
        
        # 曜日判定:月曜0-日曜6
        weekday = date(year, month, day).weekday()
        
        weekday_en = ""
        if weekday == 5:
            weekday_en = "saturday"
        elif weekday == 6:
            weekday_en = "sunday"
        else:
            weekday_en = "weekday"
        
        # 祝日判定
        if jpholiday.is_holiday(date(year, month, day)):
            weekday_en = "sunday"
        
        # 時刻表取得
        timetable = get_today_table(weekday_en, year, month, day)
        
        # 次とその次のバスを取得
        next_bus = []
        for item in timetable:
            if datetime_now_jst < item:
                next_bus.append(item)
        
        next_bus = next_bus[:2]
        
        # リストのサイズで終バス判断し返却値を出し分ける
        if len(next_bus) == 0:
            speak_output = "もう今日のバスはありません。"
        elif len(next_bus) == 1:
            diff = next_bus[0] - datetime_now_jst
            minute_after = math.floor(diff.total_seconds()/60)
            next_bus_hour = next_bus[0].hour
            next_bus_minute = next_bus[0].minute
            speak_output = f"次のバスは{minute_after}分後の{next_bus_hour}時{next_bus_minute}分です。このバスが最終です。"
        else:
            diff_1 = next_bus[0] - datetime_now_jst
            minute_after_1 = math.floor(diff_1.total_seconds()/60)
            diff_2 = next_bus[1] - datetime_now_jst
            minute_after_2 = math.floor(diff_2.total_seconds()/60)
            next_bus_hour = next_bus[1].hour
            next_bus_minute = next_bus[1].minute
            speak_output = f"次のバスは{minute_after_1}分後です。その次は{minute_after_2}分後の{next_bus_hour}時{next_bus_minute}分です。"
        
        return (
            handler_input.response_builder
                .speak(speak_output)
                # .ask(speak_output) # これがあるとセッションが維持される
                .set_should_end_session(True) # 1回しゃべったらセッション切る
                .response
        )


sb = SkillBuilder()
sb.add_request_handler(LaunchRequestHandler())

lambda_handler = sb.lambda_handler()

めちゃシンプルなので特に解説することもないかと思いますが、どうせ忘れるので自分自身のメモとして残しておきます。

import

今回は祝日判定のために jpholiday を利用するので requirements.txt に追記します。

boto3==1.9.216
ask-sdk-core==1.11.0
jpholiday # 追記

github.com

時刻計算のために datetimetimedelta を分に換算するために math をインポートします。

from datetime import date, datetime, timezone, timedelta
import jpholiday
import math

時刻表マスタ

時刻表はハードコーディングします。自分だけで使うしバス停も1つなのでこっちの方が圧倒的保守性に優れてるためです。

DynamoDB 使うならパーティションキーに バス会社名-路線名-バス停名 、ソートキーに 時刻 がよいでしょうか。

それなら範囲検索できますしおすし(知らんけど)。

def get_today_table(weekday_en, year, month, day):
    master_timetable = {
        "weekday": [
            datetime(year, month, day, 6, 5, 00, tzinfo=timezone(timedelta(hours=9))),
            datetime(year, month, day, 6, 23, 00, tzinfo=timezone(timedelta(hours=9))),
            datetime(year, month, day, 6, 48, 00, tzinfo=timezone(timedelta(hours=9))),
            ... # 以下略
            ],
        "saturday": [
            datetime(year, month, day, 6, 5, 00, tzinfo=timezone(timedelta(hours=9))),
            datetime(year, month, day, 6, 45, 00, tzinfo=timezone(timedelta(hours=9))),
            ... # 以下略
            ],
        "sunday": [
            datetime(year, month, day, 6, 5, 00, tzinfo=timezone(timedelta(hours=9))),
            datetime(year, month, day, 6, 45, 00, tzinfo=timezone(timedelta(hours=9))),
            ... # 以下略
            ]
    }
    return master_timetable[weekday_en];

「あと何分後」を計算するために時刻差を取得する必要がありますが、バス時刻表って日付ごとではなく曜日ごとです。

そのため year, month, day を指定することで "その日" の時刻表を取得できるようにしています。

weekday_en によって曜日ごとの時刻表を出し分けています。

処理部分

LaunchRequestHandler の中身は普通にごり押しで実装してるだけです。

前回、エラーをキャッチしてしまう問題は .ask(speak_output) していたためでした。これをするとセッションが維持されるっぽいので外しています。

また、Alexa を放っておいてもスキルが勝手に終了せず2回しゃべってしまう問題はAlexa が一回しゃべったらすぐにセッションを切る設定(.set_should_end_session(True))をすることで解決しました。

あと、時刻表リストを全部ぶん回して最後に silce しててあんまりかっこよくはないんですが、時刻表なんか高々数十くらいのオーダーなんでこれでいいです。

動かすぞ!

動きました。※バス時刻はダミーです。

www.youtube.com

とりあえず動いてよかったです。

ベータ版テストでしか動かせてないので、次はちゃんとプライベートで公開できるようにしたいですね(Alexa for Business めんどくさそうなのでベータテストを3カ月に1回更新するでもいいかも???)。

とりあえず Alexa スキル動かすだけ

いつもナビタイムの「次のバス」スキルにお世話になっていました。

これは「最寄りのバス停を登録しておくと "次のバスのスキルを開いて" というだけで次のバスが何分後に来るか、さらにその次のバスは何時何分か」を教えてくれる便利なスキルです。

ところが、このスキルが今月末にサービス終了するようです。

これは非常に困るので自分で Alexa スキルを作ります。

Alexa スキルは作ったことがなく、「Lambda とか DynamoDB くらいは使うんだろうなー」くらいに考えていましたがどうやらスキルを作るのに AWS は不要らしいです。

もちろん凝ったものを作ろうと思えば別でしょうが、今回は時刻表引いて返すだけのドシンプルなものです。

できれば簡単に作りたいですね。

さっそく作るぜ!

Alexa Developer Console とゆーのに EC サイト(NOT AWS)の Amazon アカウントでログインします。

スキルの作成 をクリック。

適当にスキルの名前を付けます。「次のバス」って名前を付けちゃうと既存ナビタイムアプリとウェイクワードが衝突します。

まあいいでしょう。どのみち後で変えることになります。

エクスペリエンスのタイプを「その他」に設定。

この項目自体にあまり意味はなくて、次の「モデルを選択する」で選べる選択肢のフィルタリングに使われてるっぽいです。

モデルは「カスタム」に設定。

カスタムにしとけば後で融通利くかなという感じ。変にプレビルド設定されてもよくわからんし。

「ヘルプドキュメント」のリンクが貼られてるが読みませ~ん。家電使うときに困ってから説明書読むタイプです。

ホスティングサービスは「Alexa-hosted(Python)」にしました。

下画像の内容を見る限りだと Alexa-hosted が Alexa 側で Lambda と DynamoDB の無料枠を使わせてくれるみたい。

Alexa-hosted でも無料枠を超えたら AWS アカウントに紐づける必要があるようです。

なぜ Node.js でなく Python なのか。わざわざ Python の選択肢あるのにサーバ側 JavaScript で書くの辛くない?ってだけです。

ホストリージョンは「オレゴンに」設定。そもそも東京リージョンという選択肢はない(独自のプロビジョニング を選択してたら使えるのかな?)。

うーん、バス時刻聞くだけなのに海越えていくなんてロマン溢れる~☆彡

ちな、バージニア北部よりオレゴンの方がちょっと速いみたいです。まあ普通に海底ケーブル太平洋通ってるから西海岸の方が速い。

各AWSリージョンとのネットワーク遅延を計測したい | DevelopersIO

テンプレートは「スクラッチで作成」を選択。これは悩んだが、まぁググればそれっぽいコードがゴロゴロ落ちてるだろという期待。

最終的な設定はこちら。

とりま動かすぜ!

さっそく意味わからんポータルが出てきた!右側の「スキルのビルド」の部分はビルド設定っぽいが全部チェックマークついてるので無視。

上タブで「テスト」に移動し、非公開 -> 開発中 に変更。

マイクアイコンを長押ししながら発話すると返答が返ってきます。すごい!

上タブで「公開」に移動し、適当に入力。

そうか、オレゴンリージョンにデプロイするからソフトウェア輸出規制に引っかかる恐れがあるのか。個人情報も企業機密や軍事機密もないのでOKでしょう。

ベータテストができないとのことで困る。が、とりあえず進めるところまで進んでみる。

エラーが出てきた!やったー!!これさえ解消すればベータテストできるはず。

サンプルフレーズを入力。これに伴い、スキル名を 次のバス から バスの時間 に変更。

スキルアイコンを適当に作る。アイコンビルダーないかなーと思ったらやはりあった。神。

dev.classmethod.jp

適当にバスのアイコンを作って設定。

さっきのベータテストの設定が記入できるようになったので適当に記載し有効化。

※メアド晒したくないので画像上はブランクです。

ベータテストは有効期限付きかー。まあそりゃそうか。

永続的にデプロイしたいがそのためにはやはり「Alexa for Business」が必要なんだろうか。

再度検証するとエラーは全て解消してました。

招待メールが届いているのでリンクをクリックし、スキルをデバイス側に登録する。

さっそく実行!※注意:再生するとあなたの家の Alexa が反応します

www.youtube.com

あれ。。。全然反応しない。。。

コンソールを見返す。ウェイクワードっぽい設定箇所は以下。

  • ビルド > 呼び出し > スキルの呼び出し名
  • 公開 > 公開名
  • 公開 > サンプルフレーズ

で、さっき 次のバス から バスの時間 に設定しなおしたのは「公開設定」のみ。

公開設定は文字通り「公開するための設定」であって、Alexa スキルの設定ではないですね。ただのスキルの説明文です。

なので、「スキルの呼び出し名」を変更する必要がありました。

再度スキルをビルドし、テスト。ちゃんと答え返ってきてるっぽい!

ちょっとだけコード書くぞ!

コード書くといっても返答を日本語にするだけです。

上タブから「コードエディタ」へ移動。すでにサンプルコードが書かれてる。

以下の GitHub サンプルと同じコードだと思われ。

github.com

さっきのテストコードでの返答が "Welcome, you can say Hello or Help. Which would you like to try?" だったので、LaunchRequestHandler の中身の文字列だけ書き換えればよさそう。

書き換え前

書き換え後

ワードで分岐するような性質のスキルを作るわけじゃないので、handler_input は触らなくていいですね。

「保存」「デプロイ」し、テストタブに移動してテストする。

ちゃんと動いた!

既に Lambda へ反映されてるのでデバイス側のスキルは更新しなくてもいいかな?

とりま Alexa にしゃべりかける。

www.youtube.com

動いてはいるが、返答を2回繰り返したり、なんか英語話し出したりしている。

たぶん FallbackIntentHandler に反応してる。

ちょっとコードの意味を調べる。

developer.amazon.com

an_handle:can_handleメソッドは、SDKによって呼び出され、指定されたハンドラーが受け取ったリクエストを処理できるかどうかを判断します。この関数はハンドラー入力オブジェクトを受け付け、ブール型を返すように想定されています。メソッドがTrueを返せば、ハンドラーによってリクエストが正常に処理されたと考えられます。Falseを返す場合、ハンドラーが入力リクエストを処理できず、したがって実行されず完了もしなかったと考えられます。HandlerInputオブジェクトにはさまざまなアトリビュートがあるため、リクエストを正常に処理できるかどうかをSDKが判別するための任意の条件を作成できます。 handle:handleメソッドは、リクエストハンドラーを呼び出すときにSDKによって呼び出されます。この関数には、ハンドラーのリクエスト処理ロジックが含まれており、ハンドラー入力を受け取り、応答オブジェクトを返します。

blog.serverworks.co.jp

Alexaでの意味はユーザーの意図や目的を実現するためのアクション、といったところでしょうか。ユーザーが豆知識を教えてほしいという目的のときは「豆知識」というと「GetNewFactIntent」が実行され、ユーザーがヘルプを聞きたいときは「ヘルプ」と言うと「HelpIntent」が実行されるという感じですね。ユーザーの目的ごとにインテントが必要になる、というイメージです。  今回追加する「FallbackIntent」はユーザーが意図や目的をもってAlexaに話しかけても、Alexa側にそれに対応するためのインテントがない場合に実行されるインテントです。ちょっと特別なインテントですね。 とゆーことで、LaunchRequestHandler 以外のハンドラーを使わないようにする。

ふむふむ... そーゆーことか!わかったぞぉ!

今回作るスキルはインテントとか全く関係ないのでインテントハンドラーは必要ない。

SkillBuilder.add_request_handler をコメントアウトすれば無効化できる気がする。

ただし、最後のガードとしてまるっと例外キャッチする sb.add_exception_handler(CatchAllExceptionHandler()) は残しておく。

保存・デプロイして手元の Alexa で試した結果。

2回発話してしまうこと以外は想定通り(ちゃんと最後に例外キャッチした結果が返ってくる)。

www.youtube.com

今日は夜遅いのでここまで!

次はバス時刻表をしゃべらせるようにコーディングしていきます。

残課題

  • 1回しゃべったら自動的にスキルを終了する
  • 現在時刻から後ろで一番近いバス時刻と2番目に近いバス時刻をしゃべらせる ※最終便の場合、もうそれ以降バスがない場合の処理を忘れずに
  • ベータテストじゃなくちゃんとプライベートでホストする方法を探す(Skill for Business しかないのか。。。???)

DynamoDB 属性レベル制御とちょっとだけ項目レベル制御

具体的によく知らなかったので調べた。

最近のデバイスはハイスぺなんで、一部の項目だけに絞ってサイズを小さくするってこともあんまりないし、特定アイテム(レコード)内でのデータの可視範囲を制御したいならそのデータはDynamoDBの外部に置いておく方が安全だし、あんまり使いどころがわからん。

あーでも実際にはアプリからのリクエスト時には(普通なら) API Gateway とか AppSync 経由でするように設計するんで API 開発はしやすいかもしれない。あとたぶんデータ転送量も抑えられるのでめちゃでかいシステムなら恩恵あるかも???

というのは個人的な貧相な経験からの感想だが、よくよく考えてみると、「DynamoDBのテーブル数は少ない方がよい。優れた設計はテーブル1つだ!」といった思想があった気がする。テーブル一つなら属性レベル制御絶対に必要だよね。

とりま試験のための勉強なんで概要だけをさらっと。

項目レベル制御と属性レベル制御

docs.aws.amazon.com

項目レベル制御: 項目レベルでアクセス可能・不可を制御。

属性レベル制御:属性レベルでアクセス可能・不可(可視・不可視)を制御。

利用イメージ(属性レベル制御)

docs.aws.amazon.com

docs.aws.amazon.com

GetItem, Query, Scan オペレーション実行時にプロジェクション式(projection expression)を指定することで特定の属性のみに絞ることができる。

例えば、以下の項目があったとする。★の部分のみが欲しい。

{
    "Id": 123,
    "Title": "Bicycle 123",
    "Description": "123 description",・・・★
    "BicycleType": "Hybrid",
    "Brand": "Brand-Company C",
    "Price": 500,
    "Color": ["Red", "Black"],
    "ProductCategory": "Bicycle",
    "InStock": true,
    "QuantityOnHand": null,
    "RelatedItems": [
        341,・・・★
        472,
        649
    ],
    "Pictures": {
        "FrontView": "http://example.com/products/123_front.jpg",
        "RearView": "http://example.com/products/123_rear.jpg",
        "SideView": "http://example.com/products/123_left_side.jpg"
    },
    "ProductReviews": {
        "FiveStar": [・・・★
                "Excellent! Can't recommend it highly enough! Buy it!",
                "Do yourself a favor and buy this."
        ],
        "OneStar": [
                "Terrible product! Do not buy this."
        ]
    },
    "Comment": "This product sells out quickly during the summer",
    "Safety.Warning": "Always wear a helmet"
 }

CLIで、--projection-expression を指定して実行する。

aws dynamodb get-item \
    --table-name ProductCatalog \
    --key file://key.json \
    --projection-expression "Description, RelatedItems[0], ProductReviews.FiveStar"

★の部分のみ取得できる。

{
    "Item": {
        "Description": {
            "S": "123 description"
        },
        "ProductReviews": {
            "M": {
                "FiveStar": {
                    "L": [
                        {
                            "S": "Excellent! Can't recommend it highly enough! Buy it!"
                        },
                        {
                            "S": "Do yourself a favor and buy this."
                        }
                    ]
                }
            }
        },
        "RelatedItems": {
            "L": [
                {
                    "N": "341"
                }
            ]
        }
    }
}

権限制御に使う(属性レベル制御)

repost.aws

こんなテーブルがあったとする。※上の例とは違うので注意。

GameScoresテーブルの情報がここしかなかった。ドキュメントやサポート回答で利用されるテーブル情報がまとまってるなら欲しい。。。

docs.aws.amazon.com

以下のポリシーでは、WinsLosses は見れないようになっている。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "LimitAccessToSpecificAttributes",
            "Effect": "Allow",
            "Action": [
                "dynamodb:GetItem",
                "dynamodb:Query",
                "dynamodb:BatchGetItem",
                "dynamodb:Scan",
                "dynamodb:TransactGetItems"
            ],
            "Resource": [
                "arn:aws:dynamodb:eu-west-1:123456789012:table/GameScores"
            ],
            "Condition": {
                "ForAllValues:StringEquals": {
                    "dynamodb:Attributes": [
                        "TopScoreDateTime",
                        "TopScore",
                        "UserId",
                        "GameTitle"
                    ]
                },
                "StringEquals": {
                    "dynamodb:Select": "SPECIFIC_ATTRIBUTES"
                }
            }
        }
    ]
}

CLIの例は以下。

# 全部の属性を取得しようとするため AccessDeniedException
aws dynamodb query --table-name GameScores --key-condition-expression "UserId = :useridval" --expression-attribute-values '{":useridval":{"S":"stefano_123"}}'
# Query 成功
aws dynamodb query --table-name GameScores --key-condition-expression "UserId = :useridval" --expression-attribute-values '{":useridval":{"S":"stefano_123"}}' --projection-expression "TopScore, TopScoreDateTime, GameTitle"
# GetItem 成功
aws dynamodb get-item --table-name GameScores --key '{"UserId":{"S":"stefano_123"},"GameTitle":{"S":"Game Zero"}}' --projection-expression "UserId, GameTitle, TopScore, TopScoreDateTime"

ユースケース

で、どんな時にこんな使い方できるのかな?とちょっと考えてみた。たぶん、「ゲームランキング一覧生成のために全員分のデータを取得する必要はあるが詳細な情報は取得しないようにしたい」みたいなケースかな。

となると、項目レベルの制御で「自分のレコードは全部読めるけど、他人のレコードは概要情報だけ」みたいな制御がしたくなってくる。

項目レベルの制御はここら辺に詳しく書いてあるが、詳細は必要になったら読もう。

docs.aws.amazon.com

docs.aws.amazon.com