lambdaとSlackで天気予報botを作成してみた 

# はじめに
何番煎じかわからないが、自己学習のためにbotを作成した。
基本は「参考」のサイトをベースに「天気　地名」で特定の場所の天気予報を返すslack botに手を加えた。
botを作成する中でAPIを使ったアプリケーション作成に必要な知識の習得を目的とする。
記事はインターフェースを中心に記載した。
記事の中で誤りがある場合はご指摘いただけるとありがたいです。

# 全体構成
こんな感じ
![天気bot解説02.jpg](https://qiita-image-store.s3.amazonaws.com/0/263093/fbc8f31e-c6a7-ff20-9b89-a6282aaa82dc.jpeg)


# 選定理由
1. slack   
    - ユーザー数が急増しており，かつ日本ではIT企業を中心にコミュニケーション基盤として利用されることが多い。     
2. Lambda
    - 関数だけ作成すればよいので，一から環境構築をする必要がない。（Function as a Serviceと呼ぶ。）  
    - コードの実行時間に対する課金のため，ボットと相性がよい。(無料の範囲で充分に利用可能)     
3. livedoor天気   
    - 無料で利用できる（商用利用不可）
    - レスポンスがJSON形式
    - 作成事例が多いため，詰まったときに助かる 
  
# Outgoing Webhook
## Outgoing Webhooksとは
Outgoing Webhooksは、条件を満たすメッセージが投稿されたとき、事前に設定したURL（Webサービス）にHTTPリクエストを送信する。
条件は、

- 特定の公開チャンネルへのメッセージ投稿  
- あるキーワードを含む任意の公開チャンネルへのメッセージ投稿  

のいずれかで設定可能。

詳しくは、https://api.slack.com/custom-integrations/outgoing-webhooks　を参照

## リクエスト
条件を満たす投稿があると、POSTで下記の形式のデータを送信する。

```
token=XXXXXXXXXXXXXXXXXX
team_id=T0001
team_domain=example
channel_id=C2147483705
channel_name=test
thread_ts=1504640714.003543
timestamp=1504640775.000005
user_id=U2147483697
user_name=Steve
text=googlebot: What is the air-speed velocity of an unladen swallow?
trigger_word=googlebot:
```
## レスポンス
HTTPレスポンスをチャンネルへ返信する場合、下記のようなJSON形式を返す必要がある。

```
{
    "text": "African or European?"
}
```

# Amazon API Gateway
## API Gateway とは
API Gatewayは、HTTPS経由でLambda関数を呼び出すための中継機能。API Gatewayを構成すると、エンドポイントと呼ばれるHTTPSのURLが作成され、そのURLに対してリクエストを送信することで、Lambda関数が実行されるようになる。
このときLambda関数には、HTTPSのGETメソッドやPOSTメソッドで送信されたデータやWebブラウザから送信されたUser-Agentなどのヘッダー情報などが、イベント引数として渡される。

## Lambdaプロキシ統合
Lambda関数を使ったシステム構成
![天気bot解説01.jpg](https://qiita-image-store.s3.amazonaws.com/0/263093/88ad688a-70c5-7a6d-e453-81cf110124b8.jpeg)
API GatewayはHTTPSプロトコルで流れてきたデータを受け取り、それをLambda関数に渡し、Lambda関数からの戻り値を、HTTPSプロトコルに変換してクライアントへ返す。
したがって、「HTTPSプロトコルのリクエストのどの項目とLambda関数のイベント引数のどの項目に設定するか」、「Lambda関数からの戻り値をHTTPSプロトコルのどのレスポンス項目に設定するか」をマッピングする必要がある。
このマッピングを自動的に設定してくれる機能がLambdaプロキシ統合。


# AWS Lambda
## Lambda とは
サーバレスのプログラム実行環境。実行したいプログラムを関数として実装（Lambda関数）してアップロードするだけで，プログラム実行ができる。実行の際，コンテナが自動生成されて実行してくれる。Lambda関数は「AWSのサービスから呼び出されて，何かしら処理をして，別のAWSサービスを呼び出す」という処理構成が多い。

**メリット** 
1. 保守・運用が低負荷　→　マネージドサービスのため，OS・フレームワークなどの保守が不要 
2. 高負荷耐性　→　負荷に応じた自動スケーリングに対応 
3. 低コスト　→　関数の実行時間に対する課金  

**制限事項** 
1. ステートレス　→　都度実行され，処理が終わると環境ごと破棄されるため，前回の状態は保持できない。 
2. 稼働時間が短い　→　最大稼働時間は5分。継続稼働ではなく，必要に応じて少しだけ動く処理に向く。  


## Lambda関数

```
def 関数名(event, context):
    """ 関数の処理 """
    return 戻り値
```

### 引数

1. event：イベントソースに依存する任意のデータ
2. context：実行環境の状態（割り当てられたメモリ容量、実行時間など）

Lambdaプロキシ統合で API Gateway から呼び出されたときに設定されるevent引数の内容

```
{
  "resource": リソースのパス,
  "path": URLのパス,
  "httpMethod": HTTPメソッドの種類,
  "headers": ヘッダ情報,
  "queryStringParameters": クエリパラメータ情報,
  "pathParameters": 拡張パス情報,
  "stageVariables": ステージング名,
  "requestContext": リクエストコンテキスト,
  "body": 送信されたボディ部,
  "isBase64Encoded": bodyがBase64エンコードされているかどうか
}
```

| 項目       　　　        | 意味               | 
|:------------------------|:-----------------:|
| resource                | 呼び出し元のリソース名。Lambdaプロキシ統合から呼び出された場合、「{proxy+}」という文字列。 | 
| path                    | URLのパス|
| httpMethod              | HTTPのメソッド。「GET」「HEAD」「POST」など | 
| headers                 | クライアントから送信されたHTTPヘッダのリスト | 
| queryStringParameters   | URLの末尾（「?」以降）に着けられたクエリパラメータのリスト |
| pathParameters          | URLの末尾に着けられた拡張パス名|
| stageVariables          | ステージに設定された変数の値群|
| requestContext          | クライアントのリクエストに関するコンテキスト情報。identifyのなかにクライアントの詳細情報が格納されており、たとえば、送信元IP（sourceIP）やブラウザの種類（userAgent）などが含まれている。|
| body                    | クライアントから送信されたボディ部のデータ|
| isBase64Encoded         | bodyがBase64でエンコードされている場合はtrue、そうでなければ、false。通常はfalse|

### 戻り値
Lambdaプロキシ統合を有効にしている場合、Lambda関数の戻り値は次のデータ形式で設定する。

```
{
    "isBase64Encoded": bodyがBase64エンコードの場合はtrue、そうでない場合はfalse,
    "status": HTTPステータスコード,
    "headers": { クライアントに返したいヘッダー情報 },
    "body": クライアントに返したいボディ情報
}
```

# livedoor 天気情報
## お天気情報サービス仕様
> お天気Webサービス（Livedoor Weather Web Service / LWWS）は、現在全国142カ所の今日・明日・あさっての天気予報・予想気温と都道府県の天気概況情報を提供しています。
> 引用元：お天気情報サービス仕様　http://weather.livedoor.com/weather_hacks/webservice

基本URL + 地域別ID番号　で生成したURLにアクセスするとJSON形式のデータが取得できる。


例）「東京都　東京」の天気を取得する  

- 基本URL：http://weather.livedoor.com/forecast/webservice/json/v1  　
- ID番号：130010  (地域とIDの対応は[ここ](http://weather.livedoor.com/forecast/rss/primary_area.xml)のxmlファイルに定義されている。)       

→　http://weather.livedoor.com/forecast/webservice/json/v1?city=130010


# 構築
構築自体は、「参考」に記載したWebサイトを参照すればできた。
今回は追加で下記の2点について、ソースコードに手を加えてみた。
 
- 天気を調べたい地名での天気予報出力（slackへ「天気　地名」で投稿）   
- slack投稿時の入力チェック・エラー出力

```
tenkibot                  # Lambda上プログラムをまとめるディレクトリ。名前は何でもOK
├─ lambda_function.py     # Lambda関数。slackに「天気」を含む投稿があったとき、livedoor_tenki.pyへslackからの投稿を投げる。
|　　　　　　　　　　　　　　　livedoor_tenki.pyから戻ってきた天気予報データをHTTPレスポンスとしてAPI Gatewayへ渡す。
├─ livedoor_tenki.py      # lambda_function.pyから地名を受け取り、city_list.pyで作成した辞書からID番号を取得
|                           ID番号からURLを生成して、livedoor天気情報から天気予報を取得し、戻り値として返す。
├─ city_list.py           # primary_area.xmlから地域とID番号の辞書を作成
└─ primary_area.xml       # 地域とID番号の対応が記載されたXMLファイル
```

```python:lambda_function.py
import json
import os
import livedoor_tenki
from urllib.request import urlopen, Request
from urllib.parse import parse_qs

def lambda_handler(event, context):
    print("event",event)
    token = os.environ['SLACK_TOKEN']
    query = parse_qs(event.get('body') or '')

    # エラー解析用にCloudWatchへログ出力
    print("query",query)

    if query.get('token', [''])[0] != token:
        # 予期しない呼び出し。400 Bad Requestを返す
        return { 'statusCode': 400,
            'body': json.dumps({
            'text': "400 Bad Request"
        })}

    slackbot_name = 'slackbot'

    if query.get('user_name', [slackbot_name])[0] == slackbot_name:
        # Botによる書き込み。無限ループを避けるために、何も書き込まない
        return { 'statusCode': 200 }

    # slackの投稿を slack_input_text へ格納
    slack_input_text = str(query['text'][0])

    m = str(slack_input_text.find('天気'))

    # slackの投稿に「天気」の文字列が入っている場合、livedoor天気情報
    if m != -1:
       msg = livedoor_tenki.getWeatherInformation(slack_input_text)

    response = {
        'statusCode': 200,
        'body': json.dumps({
            'text': msg
        })
    }
    
    print(response)
    return response
```

```python:livedoor_tenki.py
import json
import city_list
from urllib.request import urlopen, Request

# 例外処理のクラス定義(参考：https://docs.python.jp/3/tutorial/errors.html)
class Error(Exception):
    """Base class for exceptions in this module."""
    pass

class OverLengthError(Error):
    """Error for input data length

    Attributes:
        expression -- input expression in which the error occurred
        message -- explanation of the error
    """
    def __init__(self, expression, message):
        self.expression = expression
        self.message = message

class EmptyError(Error):
    """Error which input data is empty.

    Attributes:
        expression -- input expression in which the error occurred
        message -- explanation of the error
    """
    def __init__(self,  expression, message):
        self.expression = expression
        self.message = message

def getWeatherInformation(text):
    """例「天気 地名」という形式の投稿に対して、livedoor天気情報のリストを検索し、
    ヒットした場合、その地名の天気予報を返す

    Attributes:
        text -- post messages of slack user
    """

    # 基本URL
    weather_api_url = 'http://weather.livedoor.com/forecast/webservice/json/v1'

    # slackへの返答を初期化
    response_string = ''

    # URLパラメータであるcity_idを初期化
    city_id = ''

    # slackの入力を分割し、地名をdiv_text[1]に格納
    div_text = text.split()

    # 入力文字数の上限を設定
    max_len_place = 5

    try:
        # 入力文字列が空でないことを確認
        if len(div_text) < 2:
            raise EmptyError(text,"InputCheckError")

        place = div_text[1]
        len_place = len(place)

        # 入力の型をチェック
        if isinstance(place, str) == False:
            raise TypeError(place,"InputCheckError")

        # 入力文字数のチェック
        elif len_place > max_len_place:
            raise OverLengthError(place,"InputCheckError")

        # primary_area.xmlから地名と対応するcity_idのリストを抽出し、city_dictへ格納
        city_dict = city_list.get_weather_list()

        # 地名でリストを検索し、ヒットした地名のcity_idを格納
        # 地名が city_dict から発見できない場合、KeyErrorが発生して、expect文へ
        city_id = city_dict[div_text[1]]

        # livedoor天気情報のWeather HacksのURLを生成
        url = weather_api_url + "?city=" + city_id

        # URLから天気情報をJSON形式で取得し、response_dictへ格納
        response = Request(url,headers = {'User-Agent': 'Mozilla/5.0'})
        response = urlopen(response)
        response_dict = json.loads(response.read())

        # 都道府県名を取得
        title = response_dict["title"]

        # 天気概況文を取得
        description = response_dict["description"]["text"]

        # 地名をレスポンスに追加
        response_string += title + "です。:nerd_face:\n\n"

        # JSONから，今日・明日・明後日の天気を取得し，配列に格納
        forecasts_array = response_dict["forecasts"]

        forcast_array = []

        for forcast in forecasts_array:
            telop = forcast["telop"]
            telop_icon = ''
            if telop.find('雪') > -1:
                telop_icon = ':showman:'
            elif telop.find('雷') > -1:
                telop_icon = ':thunder_cloud_and_rain:'
            elif telop.find('晴') > -1:
                if telop.find('曇') > -1:
                    telop_icon = ':partly_sunny:'
                elif telop.find('雨') > -1:
                    telop_icon = ':partly_sunny_rain:'
                else:
                    telop_icon = ':sunny:'
            elif telop.find('雨') > -1:
                telop_icon = ':umbrella:'
            elif telop.find('曇') > -1:
                telop_icon = ':cloud:'
            else:
                telop_icon = ':fire:'

            # 気温の記述を生成
            temperature = forcast["temperature"]
            min_temp = temperature["min"]
            max_temp = temperature["max"]
            temp_text = ''
            if min_temp is not None:
                if len(min_temp) > 0:
                    temp_text += '\n最低気温は' + min_temp["celsius"] + "度です。"
            if max_temp is not None:
                if len(max_temp) > 0:
                    temp_text += '\n最高気温は' + max_temp["celsius"] + "度です。"

            forcast_array.append(forcast["dateLabel"] + ' ' + telop + telop_icon + temp_text)
        if len(forcast_array) > 0:
            response_string += '\n\n'.join(forcast_array)
        response_string += '\n\n' + description

    # 例外処理
    except TypeError as e:
        response_string = "すみません… 地名をうまく読み取れませんでした…"
        print(e)
        print("Input Data Type is not characters. Please try again.")

    except EmptyError as e:
        response_string = "地名の指定がされていません... すみませんが，「天気 地名」で再度入力してください．"
        print(e)
        print("Input Data is Empty. Please try again.")

    except OverLengthError as e:
        response_string = "すみません… 地名は5文字以内の全角日本語で入力してください。"
        print(e)
        print("Over 5 characters. Please try again.")

    except KeyError as e:
        response_string = "すみません… 地名が検索にヒットしませんでした…"
        print(e)
        print("Your Input couldn't discover. Please try another word again.")

    except Exception as e:
        response_string = "すみません… 何らかのエラーが発生しました"
        print(e)
        print("Unknown error.")

    return response_string
```

```python:city_list.py
from xml.etree import ElementTree
from urllib.request import urlopen, Request, urlretrieve

def get_weather_list():
    # parse()関数でファイルを読み込んでElementTreeオブジェクトを得る。
    url = "http://weather.livedoor.com/forecast/rss/primary_area.xml"

    #savename = 'primary_area.xml'
    #urlretrieve(url,savename)
    tree = ElementTree.parse('primary_area.xml')

    # getroot()メソッドでXMLのルート要素（この例ではrss要素）に対応するElementオブジェクトを得る
    root = tree.getroot()

    # 都市リスト:city_dictの初期化
    city_dict = {}

    # findall()メソッドでXPathにマッチする要素のリストを取得する
    for pref in root.findall('.//pref'):

        pref_name = pref.get('title')

        for city in pref.findall('.//city'):
            city_name = city.get('title')
            city_id = city.get('id')
            city_dict[city_name] = city_id

    return city_dict
```
# 検証
成功時
![image.png](https://qiita-image-store.s3.amazonaws.com/0/263093/2f8005b4-8efa-d864-3365-25df93466b9a.png)

失敗時（辞書に該当する地名がない）
![image.png](https://qiita-image-store.s3.amazonaws.com/0/263093/0de734da-4bc3-7dcd-05e8-0945c2d3023a.png)

失敗時（入力エラー：地名の指定がない）
![image.png](https://qiita-image-store.s3.amazonaws.com/0/263093/22f63d3c-ad7e-6c74-07bb-481b13d6c067.png)

失敗時（入力エラー：入力文字数制限）
![image.png](https://qiita-image-store.s3.amazonaws.com/0/263093/dbda63c6-9f05-e019-1620-c76444f948b4.png)

# 所感
地名をlivedoor天気情報のxmlから引っ張ってきてリストにしているため、リストにない地名はヒットしない。
そのため、例えば、「天気　北海道」のような検索は「北海道」がリストにないため検索に失敗する。「北海道」のような都道府県名で検索してもヒットしないのは、アプリとしてはお粗末としか言いようがない。DBで作成して裏側でテーブルの紐づけをする、あるいは天気APIを変えるなどの対応が必要である。そのあたりは今後の課題とする。

# 参考
**Webサイト**
1. [SlackのOutgoing Webhooksを使って投稿に反応するbotを作る](https://qiita.com/chike0905/items/58222a99be460f325ab8)
2. [AWS Lambda (Python3) + API Gateway で Slackのbotを作ろう](https://qiita.com/t--k/items/1377d1a57075fdbc238d) 
3. [slackのbotに天気を教えてもらう(Python on AWS Lambda + API Gateway)](https://www.yamamanx.com/slack-bot-weather-python-on-aws-lambda-api-gateway/)

**書籍**
1.[AWS Lambda実践ガイド](https://www.amazon.co.jp/dp/B0764C5MT5/ref=dp-kindle-redirect?_encoding=UTF8&btkr=1)
2.[HTTPの教科書] (https://www.amazon.co.jp/dp/B00EESW7K0/ref=dp-kindle-redirect?_encoding=UTF8&btkr=1)