はじめに
何番煎じかわからないが、自己学習のためにbotを作成した。
基本は「参考」のサイトをベースに「天気 地名」で特定の場所の天気予報を返すslack botに手を加えた。
botを作成する中でAPIを使ったアプリケーション作成に必要な知識の習得を目的とする。
記事はインターフェースを中心に記載した。
記事の中で誤りがある場合はご指摘いただけるとありがたいです。
全体構成
選定理由
- slack
- ユーザー数が急増しており,かつ日本ではIT企業を中心にコミュニケーション基盤として利用されることが多い。
- Lambda
- 関数だけ作成すればよいので,一から環境構築をする必要がない。(Function as a Serviceと呼ぶ。)
- コードの実行時間に対する課金のため,ボットと相性がよい。(無料の範囲で充分に利用可能)
- 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関数を使ったシステム構成
API GatewayはHTTPSプロトコルで流れてきたデータを受け取り、それをLambda関数に渡し、Lambda関数からの戻り値を、HTTPSプロトコルに変換してクライアントへ返す。
したがって、「HTTPSプロトコルのリクエストのどの項目とLambda関数のイベント引数のどの項目に設定するか」、「Lambda関数からの戻り値をHTTPSプロトコルのどのレスポンス項目に設定するか」をマッピングする必要がある。
このマッピングを自動的に設定してくれる機能がLambdaプロキシ統合。
AWS Lambda
Lambda とは
サーバレスのプログラム実行環境。実行したいプログラムを関数として実装(Lambda関数)してアップロードするだけで,プログラム実行ができる。実行の際,コンテナが自動生成されて実行してくれる。Lambda関数は「AWSのサービスから呼び出されて,何かしら処理をして,別のAWSサービスを呼び出す」という処理構成が多い。
メリット
- 保守・運用が低負荷 → マネージドサービスのため,OS・フレームワークなどの保守が不要
- 高負荷耐性 → 負荷に応じた自動スケーリングに対応
- 低コスト → 関数の実行時間に対する課金
制限事項
- ステートレス → 都度実行され,処理が終わると環境ごと破棄されるため,前回の状態は保持できない。
- 稼働時間が短い → 最大稼働時間は5分。継続稼働ではなく,必要に応じて少しだけ動く処理に向く。
Lambda関数
def 関数名(event, context):
""" 関数の処理 """
return 戻り値
引数
- event:イベントソースに依存する任意のデータ
- 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の対応はここの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ファイル
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
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
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
検証
所感
地名をlivedoor天気情報のxmlから引っ張ってきてリストにしているため、リストにない地名はヒットしない。
そのため、例えば、「天気 北海道」のような検索は「北海道」がリストにないため検索に失敗する。「北海道」のような都道府県名で検索してもヒットしないのは、アプリとしてはお粗末としか言いようがない。DBで作成して裏側でテーブルの紐づけをする、あるいは天気APIを変えるなどの対応が必要である。そのあたりは今後の課題とする。
参考
Webサイト
- SlackのOutgoing Webhooksを使って投稿に反応するbotを作る
- AWS Lambda (Python3) + API Gateway で Slackのbotを作ろう
- slackのbotに天気を教えてもらう(Python on AWS Lambda + API Gateway)
書籍
1.AWS Lambda実践ガイド
2.[HTTPの教科書] (https://www.amazon.co.jp/dp/B00EESW7K0/ref=dp-kindle-redirect?_encoding=UTF8&btkr=1)