LoginSignup
11
14

More than 5 years have passed since last update.

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

Posted at

はじめに

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

全体構成

こんな感じ
天気bot解説02.jpg

選定理由

  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
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形式のデータが取得できる。

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

→ 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ファイル
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
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
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

失敗時(辞書に該当する地名がない)
image.png

失敗時(入力エラー:地名の指定がない)
image.png

失敗時(入力エラー:入力文字数制限)
image.png

所感

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

参考

Webサイト
1. SlackのOutgoing Webhooksを使って投稿に反応するbotを作る
2. AWS Lambda (Python3) + API Gateway で Slackのbotを作ろう
3. slackのbotに天気を教えてもらう(Python on AWS Lambda + API Gateway)

書籍
1.AWS Lambda実践ガイド
2.HTTPの教科書

11
14
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
14