心の平穏のために、猫の画像をランダムに表示してくれる Slack command /neko
を作った。
Slack で /neko
と打つと、Cloud Functions にリクエストが飛び、Functions が TheCatAPI からランダムに猫画像 URL を取得するもの。
使ったもの
用途
Google Cloud Functions
バックエンド
Python 3.7
Functionsの実装言語
Serverless framework 1.40.0
GCPへのデプロイ
Serverless framework の初期設定
1
2
3
4
5
6
7
8
# create new service
$ serverless create --template google-python --path neko
# go to the service directory
$ cd neko
# install provider plugins
$ npm install
serverless.yml
を自分の GCP 環境に合わせて編集する。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
service : neko
provider :
name : google
stage : dev
runtime : python37
region : YOUR-REGION
project : YOUR-GCP-PROJECT-ID
credentials : ~/.gcloud/YOUR-KEYFILE.json
plugins :
- serverless-google-cloudfunctions
package :
exclude :
- node_modules/**
- .gitignore
- .git/**
functions :
neko-command :
handler : neko
events :
- http : path
memorySize : 256
timeout : 60s
labels : {
application : slack-slash-command ,
environment : development ,
owner : momota
}
neko コマンドの実装
まず TheCatAPI の API エンドポイントをコードの中に埋め込みたくないので、 config.json
という外部ファイルに書き出し、実行時にそこから読み込むことにする。
以下のような感じ。
1
2
3
{
"API_URL" : "https://api.thecatapi.com/v1/images/search?format=json"
}
Functions 用のコードは以下のような感じ。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import json
import requests
from flask import jsonify
# 外部ファイル config.json の読み込み
with open ( 'config.json' , 'r' ) as f :
data = f . read ()
config = json . loads ( data )
# TheCatAPI を叩いて、レスポンス (JSON) から 猫画像 URL を取得
def neko_url ():
res = requests . get ( config [ 'API_URL' ])
json_res = json . loads ( res . text )
return json_res [ 0 ][ 'url' ]
# Slackへの応答フォーマットに整形
def format_slack_message ( message ):
return {
'response_type' : 'in_channel' ,
'text' : message
}
# Handler: Functions がリクエストを受けたときに実行する関数
def neko ( request ):
message = neko_url ()
response = format_slack_message ( 'ねこです ' + message )
return jsonify ( response )
あとは GCP にデプロイして、Slack の Slash command の設定をすれば使える。
ちょっと頑張る: Slack トークン認証とエラーハンドリング
Slack の Verification Token による簡易的な認証処理を追加する。
Slack command のApp設定から、Basic Information > App Credentials > Verification Token からトークンを取得して、config.json
に書く。
1
2
3
4
{
"API_URL" : "https://api.thecatapi.com/v1/images/search?format=json" ,
"SLACK_TOKEN" : "YOUR-VERIFICATION-TOKEN"
}
以下のような処理を追加し、認証処理とエラーハンドリング処理を追加する。
とりあえず意図しないリクエストがバックエンド (Functions) にきたら、例外を投げる。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def verify_request ( request ):
# POSTメソッドじゃない
if request . method != 'POST' :
raise Exception ( 405 , 'Only POST requests are accepted' )
# データが POST されていない
if not request . form :
raise Exception ( 422 , 'POST form is empty' )
# POST データに ’token’ フィールドにない
if 'token' not in request . form :
raise Exception ( 422 , 'POST form is invalid' )
# トークンがマッチしない
if request . form [ 'token' ] != config [ 'SLACK_TOKEN' ]:
raise Exception ( 403 , 'Permission denied' )
メイン処理側では、例外を補足したら、それに応じたエラーを返すように変更する。
1
2
3
4
5
6
7
8
9
10
11
12
def neko ( request ):
try :
# validate request
verify_request ( request )
message = neko_url ()
response = format_slack_message ( 'ねこです ' + message )
return jsonify ( response )
except Exception as e :
code , msg = e . args
print ( 'Exception occured: <{}> {}' . format ( code , msg ))
return ( msg , code )
もうちょっと頑張る: 非同期処理
前回 も述べたが、
Slack command は 3000 ms (3秒) 以内に応答しないとタイムアウトになってしまう。
TheCatAPI のような外部 API に依存している場合、3 秒以内のレスポンスを保証できない可能性が高くなる。
そこで、バックエンドの Functions がリクエストを受けたら即時にレスポンスを返し、スレッドにより非同期で応答する。
Slack としても、そのような仕組みを支援するためにレスポンス用 URL を付与して、バックエンド側にリクエストを投げてくれる。
以下のようなイメージ。
スレッド処理のため、メイン処理を関数化し、レスポンス用 URL へ返答するよう書き換える。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from threading import Thread
def neko_async ( response_url ):
message = neko_url ()
response = format_slack_message ( 'ねこです ' + message )
post_data = json . dumps ( response )
post_response = post_to_slack ( response_url , post_data )
return
def neko ( request ):
try :
# validate request
verify_request ( request )
# レスポンス用 URL の取得
response_url = request . form [ 'response_url' ]
# スレッドで猫画像 URL処理を取得
thread = Thread ( target = neko_async , kwargs = { 'response_url' : response_url })
thread . run ()
return ''
except Exception as e :
code , msg = e . args
print ( 'Exception occured: <{}> {}' . format ( code , msg ))
return ( msg , code )
最終的には以下のような感じになる。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import json
import requests
from threading import Thread
# 外部ファイル config.json の読み込み
with open ( 'config.json' , 'r' ) as f :
data = f . read ()
config = json . loads ( data )
# エラーハンドリングと簡易認証処理
def verify_request ( request ):
# POSTメソッドじゃない
if request . method != 'POST' :
raise Exception ( 405 , 'Only POST requests are accepted' )
# データが POST されていない
if not request . form :
raise Exception ( 422 , 'POST form is empty' )
# POST データに ’token’ フィールドにない
if 'token' not in request . form :
raise Exception ( 422 , 'POST form is invalid' )
# トークンがマッチしない
if request . form [ 'token' ] != config [ 'SLACK_TOKEN' ]:
raise Exception ( 403 , 'Permission denied' )
# スレッド処理用関数
def neko_async ( response_url ):
message = neko_url ()
response = format_slack_message ( 'ねこです ' + message )
post_data = json . dumps ( response )
post_response = post_to_slack ( response_url , post_data )
return
# TheCatAPI を叩いて、レスポンス (JSON) から 猫画像 URL を取得
def neko_url ():
res = requests . get ( config [ 'API_URL' ])
json_res = json . loads ( res . text )
return json_res [ 0 ][ 'url' ]
# Slackへの応答フォーマットに整形
def format_slack_message ( message ):
return {
'response_type' : 'in_channel' ,
'text' : message
}
# Slack レスポンス用 URL への POST処理
def post_to_slack ( url , post_data ):
post_headers = {
'Content-type' : 'application/json; charset=utf-8'
}
return requests . post (
url ,
data = post_data ,
headers = post_headers
)
# Handler: Functions がリクエストを受けたときに実行する関数
def neko ( request ):
try :
# validate request
verify_request ( request )
response_url = request . form [ 'response_url' ]
thread = Thread ( target = neko_async , kwargs = { 'response_url' : response_url })
thread . run ()
return ''
except Exception as e :
code , msg = e . args
print ( 'Exception occured: <{}> {}' . format ( code , msg ))
return ( msg , code )