flask-askを使ってAlexa Skillを簡単に作ってみる

この記事はニフティグループ Advent Calendar 2018の15日目の記事です。
14日目は@5_21maimaiさんの[もし３年目の女子エンジニアが５年もののiOSアプリに自動テストを導入したら](http://blog.web.nifty.com/engineer/1865)でした。
# はじめに
今回紹介する[flask-ask](https://github.com/johnwheeler/flask-ask)はpythonのWebアプリケーションフレームワークであるflaskでAlexa Skillを作成するための拡張機能のようなものです。
弊社でもいくつか音声スキルをリリースをしており、その中のスキルの一部でflask-askを利用して開発を行いました。
# 特徴
Flaskの拡張機能であるので、普段からFlaskで小規模アプリケーションやAPIの開発をしている人は取っ掛かりやすいです。また、Flask-sql-alchemyと組み合わせるとDBセッション管理をあまり意識しないアプリケーションが簡単に構築できます。DBを利用した簡単なAlexa Skillを作成するのには向いていると思います。

#とりあえず準備
まずはflask-askをpipで落としましょう
```pip install flask-ask```

今回はエンドポイントをAWS上に構築します。
エンドポイントを自分のサーバにしたい時は、nginx + uwsgiの構成で構築するのをお勧めします。

AWSに自動でデプロイをするために[Zappa](https://github.com/Miserlou/Zappa)をインストールします。
```pip install zappa```

次にzappaを利用するためにはvirtualenvが必要なのでインストールをします。
```pip install virtualenv```

インストールができたらvirtualenvで仮想環境を作ります
```virtualenv alexa-test```

仮想環境をアクティベートします
```source alexa-test/bin/activate```

ディレクトリを作成してzappaの設定をします
基本的に設定はデフォルトのままで問題ないです。

```
$ mkdir alexa-app && cd alexa-app
$ zappa init

███████╗ █████╗ ██████╗ ██████╗  █████╗
╚══███╔╝██╔══██╗██╔══██╗██╔══██╗██╔══██╗
  ███╔╝ ███████║██████╔╝██████╔╝███████║
 ███╔╝  ██╔══██║██╔═══╝ ██╔═══╝ ██╔══██║
███████╗██║  ██║██║     ██║     ██║  ██║
╚══════╝╚═╝  ╚═╝╚═╝     ╚═╝     ╚═╝  ╚═╝

Welcome to Zappa!

Zappa is a system for running server-less Python web applications on AWS Lambda and AWS API Gateway.
This `init` command will help you create and configure your new Zappa deployment.
Let's get started!

Your Zappa configuration can support multiple production stages, like 'dev', 'staging', and 'production'.
What do you want to call this environment (default 'dev'): 

AWS Lambda and API Gateway are only available in certain regions. Let's check to make sure you have a profile set up in one that will work.
We couldn't find an AWS profile to use. Before using Zappa, you'll need to set one up. See here for more info: https://boto3.readthedocs.io/en/latest/guide/quickstart.html#configuration

Your Zappa deployments will need to be uploaded to a private S3 bucket.
If you don't have a bucket yet, we'll create one for you too.
What do you want to call your bucket? (default 'zappa-7lxks7tfp'): 

It looks like this is a Flask application.
What's the modular path to your app's function?
This will likely be something like 'your_module.app'.
Where is your app's function?: main.py

You can optionally deploy to all available regions in order to provide fast global service.
If you are using Zappa for the first time, you probably don't want to do this!
Would you like to deploy this application globally? (default 'n') [y/n/(p)rimary]: n

Okay, here's your zappa_settings.json:

{
    "dev": {
        "app_function": "app.app", 
        "profile_name": null, 
        "project_name": "alexa-app", 
        "runtime": "python2.7", 
        "s3_bucket": "zappa-7lxks7tfp"
    }
}

Does this look okay? (default 'y') [y/n]: y
```

#実際にスキルを作る
今回は[mail@nifty](http://www.nifty.com/mail/)でIMAPを利用した未読メールについて教えてくれるスキルを作成します。

##デプロイ方法
先ほどインストールしたZappaを利用して|AWSにコマンドでデプロイを行なっていきます。[このページ](https://developer.amazon.com/ja/blogs/post/8e8ad73a-99e9-4c0f-a7b3-60f92287b0bf/new-alexa-tutorial-deploy-flask-ask-skills-to-aws-lambda-with-zappa)が非常に参考になります。

##基本的な書き方
###レスポンス
Flask-askにおいてレスポンスを書くのは非常に容易です。
セッションを終了させたいレスポンスはstatementを使用します

```return statement(statement_text).simple_card(card_title, statement_text)
```

セッションを継続させたいレスポンスはquestionを使用します。
repromptはユーザがしばらく反応しなかった際に呼びかけるテキストです。
```return question(question_text).reprompt(reprompt_text).simple_card(card_title, question_text)
```

session_attributesについてもオブジェクトに格納するだけで対応ができます。

```python
# 格納
session.attributes["test"] = "test"
# 取得
test = session.attributes["test"]
```

カードについてもアカウントリンキングカード等にも対応しています

###リクエスト
 Flask-askは普段Flaskのアプリケーションを作成するときと同様にデコレータを使用して書いていきます。

```python
@ask.launch
def launch():
    """
    起動リクエスト
    """
    card_title = "ようこそ"
    question_text = "ようこそ。テストスキルです"
    reprompt_text = "反応してよ"
    return question(question_text).reprompt(reprompt_text).simple_card(card_title, question_text)


@ask.intent('TestIntent')
def test():
    """
    テストインテントのリクエストが来た時の処理
    """
    ~~~~

```

#スキルを作成する
## スキルを設定

今回は未読メールを知るためだけのインテントとYES,NOインテントがあればいいので下のように設定します
<img width="1203" alt="スクリーンショット 2018-12-15 19.50.42.png" src="https://qiita-image-store.s3.amazonaws.com/0/218049/98ae1be0-6afa-ea69-9408-bea498a871c1.png">

エンドポイントはZappaでアップロードした際に表示されるAPI GatewayのURLを指定します。

##コード

下のように書いてみました。
FlaskでAPIを書くようにして簡単にスキルのコードが書けますね。


```python:app.py

# -*- coding: utf-8 -*-
import logging
import os

from flask import Flask, json
from flask_ask import Ask, request, session, question, statement
import sys, codecs
import imaplib
import email
from email.header import decode_header, make_header

# メールユーザ情報
maildatas=[]
server="imap.nifty.com"
user=USER_ACCOUNT
password=PASSWORD

app = Flask(__name__)
ask = Ask(app, "/")
logging.getLogger('flask_ask').setLevel(logging.DEBUG)

@ask.launch
def launch():
    card_title = "ようこそ"
    question_text = "ようこそ。未読メール確認君です。未読のメールの通数を確認したければ、未読メールを教えて。と聞いてみてね。"
    reprompt_text = "未読のメールの通数を確認したければ、未読メールはある?と聞いてみてね。"
    return question(question_text).reprompt(reprompt_text).simple_card(card_title, question_text)


@ask.intent('AskUnseenIntent')
def unseen_count():
    card_title = "未読通数確認"
    try:
        data = count_unseen()
    except Exception as e:
        print e
        return error_message()
    if data:
        mail_uids = data[0].split()
        mail_count = len(mail_uids)
        session.attributes["mail_uids"] = mail_uids
        session.attributes["nextstep"] = "summary"
        question_text = str(mail_count) + "通の未読メールが見つかりました。題名を読み上げますか。"
        reprompt_text = question_text
        return question(question_text).reprompt(reprompt_text).simple_card(card_title, question_text)
    else:
        statement_text = "メールがうまく取得できませんでした。"
        return statement(statement_text).simple_card(card_title, statement_text)


@ask.intent('AMAZON.YesIntent')
def yes():
    if session.attributes.get("nextstep") is not None:
        if session.attributes.get("nextstep")  == "summary":
            card_title = "題名を読み上げる"
            mail_uid = session.attributes.get("mail_uids").pop(0)
            try:
                mail_from, mail_subject, encode = fetch_from_and_subject(mail_uid)
            except Exception as e:
                print e
                return error_message()
            session.attributes["nextstep"] = "body"
            session.attributes["mail_uid"] = mail_uid
            question_text = "メールの題名は" + str(mail_subject)  + "です。" + str(mail_from) + "からのメールです。メールの本文をカードで送りますか。"
            reprompt_text = question_text
            return question(question_text).reprompt(reprompt_text).simple_card(card_title, question_text)
        elif session.attributes.get("nextstep") == "body":
            card_title = "本文を読み上げる"
            mail_uid = session.attributes["mail_uid"]
            try:
                body = fetch_body(mail_uid)
            except Exception as e:
                print e
                return error_message()
            statement_text = "メールを既読にしてカードに送りました。"
            return statement(statement_text).simple_card(card_title, str(body))
    else:
        card_title = "不明なリクエスト"
        statement_text = "よくわかりませんでした。"
        return statement(statement_text).simple_card(card_title, statement_text)


@ask.intent('AMAZON.NoIntent')
def no():
    if session.attributes.get("nextstep") is not None:
        if session.attributes.get("nextstep")  == "summary":
            card_title = "スキル終了"
            statement_text = "スキルを終了します"
            return statement(body).simple_card(card_title, statement_text)
        elif session.attributes.get("nextstep")  == "body":
            card_title = "本文読み上げ中止"
            question_text = "次のメールの題名を読み上げますか。"
            session.attributes["nextstep"] = "summary"
            reprompt_text = question_text
            return question(question_text).reprompt(reprompt_text).simple_card(card_title, question_text)


@ask.session_ended
def session_ended():
    return "{}", 200


def error_message():
    """
    エラーメッセージを返す
    """
    card_title = "エラー発生"
    statement_text = "エラーが起きました"
    return statement(statement_text).simple_card(card_title)


def count_unseen():
    """
    未読メール数を確認する
    """
    mail = imaplib.IMAP4_SSL(server, 993)
    mail.login(user, password)
    mail.select('INBOX')
    result, data = mail.search(None, "UNSEEN")
    mail.close()
    mail.logout()
    # 正常に取得できたら
    if result == "OK":
        return data
    else:
        return None


def fetch_from_and_subject(mail_uid):
    """
    メールのfromと題名の取得
    """
    mail = imaplib.IMAP4_SSL(server, 993)
    mail.login(user, password)
    mail.select('INBOX')
    typ, data = mail.fetch(mail_uid, '(RFC822)')
    maildata = email.message_from_string(data[0][1])
    mail.close()
    mail.logout()
    # From情報の取得
    _from = email.Header.decode_header(maildata.get('From'))[0][0]
    # Subjectを取得
    subject = email.Header.decode_header(maildata.get('Subject'))[0][0]
    # エンコード情報を取得
    encode = email.Header.decode_header(maildata.get('Subject'))[0][1]
    # エンコード情報のデフォルトをiso-2022-jpにする
    if encode is None:
        encode = "iso-2022-jp"
    # エンコードしてFromを取得
    mail_from = _from.decode(encode)
    # エンコードしてSubjectを取得
    mail_subject = subject.decode(encode)

    return mail_from, mail_subject, encode


def fetch_body(mail_uid):
    """
    メールのbodyデータの取得
    """
    mail = imaplib.IMAP4_SSL(server, 993)
    mail.login(user, password)
    mail.select('INBOX')
    typ, data = mail.fetch(mail_uid, '(RFC822)')
    mail.store(mail_uid, '+FLAGS', '\\SEEN')
    mail.close()
    mail.logout()
    maildata = email.message_from_string(data[0][1])
    charset = maildata.get_content_charset()
    message = maildata.get_payload()
    mail_message = message.decode(charset, "ignore")
    return mail_message

if __name__ == "__main__":
    app.run()
```

##デモ
今回はAlexaのシミュレータを利用してデモをします。
まずスキル名は「未読メール確認君」としました。
まずは「未読メール確認君を開いて」と話しかけてスキルを起動します。
<img width="441" alt="スクリーンショット 2018-12-15 19.59.53.png" src="https://qiita-image-store.s3.amazonaws.com/0/218049/a8722338-7b70-469a-c9be-70f5f786097d.png">
スキルが起動したら、次に案内された通り「未読メールを教えて」と尋ねてみます。
<img width="401" alt="スクリーンショット 2018-12-15 20.00.30.png" src="https://qiita-image-store.s3.amazonaws.com/0/218049/cf451dca-8e9d-e480-31a8-bbc6df45518f.png">
6件のメールが見つかりましたね。
続けて「題名を読み上げますか」と聞かれたのでそのまま「はい」と答えてみます。
<img width="395" alt="スクリーンショット 2018-12-15 20.01.02.png" src="https://qiita-image-store.s3.amazonaws.com/0/218049/dee75b2a-cdfd-116d-d5cb-4b0bd29e66ee.png">
するとメールの題名と誰からのメールかを教えてくれます。
さらに、メールの本文をカードで送るのか聞かれるので、「はい」と答えてみます。
<img width="395" alt="スクリーンショット 2018-12-15 20.01.17.png" src="https://qiita-image-store.s3.amazonaws.com/0/218049/fbedabfb-88e7-a5b1-fc3d-f96292ad6639.png">
するとカードにメールの本文が送られてきます。さらに本文を取得したメールは既読状態にしたと案内されます。本当に既読状態になったのか確認するためにワンショットで起動して未読メールを再度確認してみます。
<img width="402" alt="スクリーンショット 2018-12-15 20.01.46.png" src="https://qiita-image-store.s3.amazonaws.com/0/218049/839d5bfa-b9ab-fd18-3a64-ddcd3aafef4b.png">
すると、先ほどは6件あった未読メールが5件に減っているため、カードに送られたメールが既読のステータスに変更されていることがわかります。

#最後に
Flask-askを利用することで、FlaskのAPIを書くのと同じようにAlexaのSkillを作成することができます。
Zappaを利用することで、簡単にデプロイもできますし一度試してみてはいかがでしょうか。
また、ニフティでもいくつか[音声スキル](https://www.amazon.co.jp/s/ref=nb_sb_noss?__mk_ja_JP=カタカナ&url=search-alias%3Dalexa-skills&field-keywords=nifty)をリリースしてますのでぜひ一度試してみてください。
明日は@9roさんです!お楽しみに！