2020/11/09
2019年のお盆休みは丸々1週間、土日含めて10日もありました。
以前勤めていた会社では2日+土日分しか休みがもらえなかったので、単純に2.5倍になりました。この時間は貴重です。
となれば、何か面白いことをやりたいですね。
…という経緯から、「Slackでアザラシ検定のクイズができる」SlackAppを作成しました!
サイトへアクセスして10問分回答する必要のあるwebアプリ版と比べて、Slackならコマンドで手軽に呼び出せる上、1問ずつ手軽に遊べます。
今回はこのAPIの構成と詰まった点について書いていきます!
構成と機能
インフラ周りはAWSを使用します。
Slackからのリクエストを受け付ける窓口として「API Gateway」、クイズデータの取得とJSON整形のために「Lambda」を使用します。
Slackからコマンドを実行するときだけ動けばいいので、サーバーレスでコストを抑えられるこの構成が適しますね。
Slack側の設定として新しいアプリを作成し、「Slash Command」「Interactive Component」を設定します。各設定のリクエスト先にはAPI GatewayのURLを指定しましょう。
その他のWebhookやBotなどは不要です。
処理の流れは以下のような感じ。
- Slack上からSlashCommandを実行
- Lambdaがアザラシ検定アプリからクイズデータを取得し、InteractiveMessageに対応した形式のJSONを返却
- ユーザーがInteractiveMessageのボタンをクリック
- InteractiveComponentがクリックを検知し、Lambdaへリクエスト
- Lambdaがアザラシ検定アプリに回答内容を送信し、正解・不正解を判定
- Lambdaから回答結果のJSON返却
まずSlashCommandを実行してクイズを表示させます。
アザラシ検定は私の管理するVPS上で動かしているので、その問題データを利用する形になります。問題形式もアザラシ検定のそれに則っているので必ず4択で、正解は1つです。
ボタンが表示されるので正解と思うものを選択します。すると回答結果に応じてメッセージが送信されてきます。
皆で一斉にやるとメッセージが邪魔になりそうなので、問題メッセージのスレッドに送信するようにしました。
作成したLambda関数
ランタイムは「Python 3.7」を選択しました。
アザラシ検定関連の処理が多く見にくいですが、参考にどうぞ。
SlashCommand用
コマンド入力時にリクエストされます。
import json import random import urllib.request import urllib.parse def lambda_handler(event, context): request_data = urllib.parse.parse_qs(event["body"]) # アザラシ検定アプリからクイズデータを取得 level = random.randrange(1, 4, 1) url = "アザラシ検定のURL" req = urllib.request.Request(url) with urllib.request.urlopen(req) as res: # クイズデータをJSONで読み込み quiz_data = res.read().decode("utf-8") quiz_json = json.loads(quiz_data)[0] # 回答の選択肢をactionsに設定 actions = []; for choice in quiz_json["choices"]: actions.append({ "name": "answer", "text": choice['choice_text'], "type": "button", "value": choice['choice_index'] }) data = { "response_type": "in_channel", "replace_original": True, "delete_original": True, "text": "問題ID:" + str(quiz_json['quiz_id']) + " レベル:" + str(level), "attachments": [ { "text": quiz_json['question_text'], "callback_id": "quiz_" + str(quiz_json['quiz_id']), "actions": actions } ] } # response_urlに対して送信し遅延実行させる post_data = json.dumps(data).encode("utf-8") delayed_req = urllib.request.Request(str(request_data["response_url"]).replace("['", "").replace("']", ""), data=post_data) urllib.request.urlopen(delayed_req) # SlashCommandの呼び出しには空を返す response_data = { "statusCode": 200, "body": "" } return response_data
InteractiveMessage用
クイズのボタンクリック時にリクエストされます。
import json import urllib.parse import urllib.request def lambda_handler(event, context): decode_request_string = urllib.parse.unquote(event["body"]).replace("payload=", "") request_data = json.loads(decode_request_string) # 回答内容を元にアザラシ検定アプリへ確認 quiz_id = int(request_data["callback_id"].replace("quiz_", "")) correct_value = request_data["actions"][0]["value"] post_data = urllib.parse.urlencode({ "quiz_id": quiz_id, "answer_index": correct_value }).encode() url = "アザラシ検定のURL" headers = {'Content-Type': 'application/x-www-form-urlencoded'} req = urllib.request.Request(url, data=post_data, headers=headers) with urllib.request.urlopen(req) as res: answer_data = res.read().decode("utf-8") answer_result = json.loads(answer_data) response_text = "正解です!" if answer_result else "不正解です!" response_text = "<@" + request_data["user"]["id"] + "> " + response_text data = { "text": response_text, "response_type": "in_channel", "thread_ts": request_data["original_message"]["ts"], "replace_original": False, "delete_original": False } response_data = { "statusCode": 200, "body": json.dumps(data, ensure_ascii=False) } return response_data
ハマったこと
機能としてはたったこれだけなのですが、作業中は様々なところで詰まってしまいました。
ということで片っ端から書き残しておきます。
Lambdaから返すマルチバイト文字がUnicode表記になる
JSON文字列をさらにjson.dumps()
していたため、二重にエスケープされてしまったようです。
他のサイトを見ると「Content-TypeのcharsetにUTF-8を指定する」とか、「Pythonの文字列の前にuを付ける」とか載っていましたが、私の場合は特に必要ありませんでした。
送信データがLambdaで参照できない
Slackから送られてくるデータはContent-Typeがapplication/x-www-form-urlencoded
なので、API Gatewayの統合リクエスト→マッピングテンプレートを追加し、Lambda側で処理しやすい形に変換する必要がありました。
マッピングテンプレートはstackoverflowの回答に載っていたものをそのまま使わせていただきました。
https://stackoverflow.com/questions/32057053/how-to-pass-a-params-from-post-to-aws-lambda-from-amazon-api-gateway
ただし後述する「SlashCommandのコマンドが残ってしまう」問題解決のため、今回はすべてのAPIで「Lambdaプロキシ統合の使用」にチェックを入れました。
なので今回は使っていませんが、詰まったことに間違いはないので書いておきます。
InteractiveComponentsで指定したURLへリクエストが飛ばない
SlashCommand実行時に返すJSONの「attachments」に「callback_id」がありませんでした。
Slackのログにその旨のエラーメッセージが出ていたので、それで判断できました。この例に限らず、コマンドやボタンクリック時の処理が上手くいかない場合はログを見ると良いです。
Windowsであれば以下のパスに保存されています。
C:\Users\[ユーザー名]\AppData\Roaming\Slack\logs\webapp-console.log
InteractiveComponentsの応答でLambdaを介すと400エラーになる
API Gatewayの統合リクエスト→「Lambdaプロキシ統合の使用」にチェックを入れます。
その上でLambdaから返却するJSONの形式を正しく指定します。statusCode
とbody
を含めるのがポイントです。このあたりは公式にも書いてあります。
https://aws.amazon.com/jp/premiumsupport/knowledge-center/malformed-502-api-gateway/
Lambda統合プロキシについては以下のQiitaの記事が分かりやすかったです。
https://qiita.com/_mogaming/items/2bd83204e212e35b2c6c
InteractiveComponentsからのリクエストのパラメータ取得
前述のように「Lambda統合プロキシ」を使用しているため、event
変数の中身がSlashCommandとは異なっています。POSTされたパラメータはevent["body"]
に入っています。
これをデコードしてjson.loads()
で読み込むわけですが、InteractiveComponentsからのリクエストの場合、bodyの冒頭には「payload=」という文字列が含まれており、これを取り除かないとパースエラーになります。
一方でSlashCommandからのリクエストはGETパラメータ形式で入っているので、urllib.parse.parse_qs()
を使ってevent["body"]
をパースします。
APIからのメンションの打ち方
レスポンスのtext
に<@ユーザーID>を入れるとメンションになります。実値では<@UM3U4BHGU>のような感じですね。
通常のSlackのように@riberunn
などではメンション扱いにならないので気を付けましょう。
SlashCommandのresponse_typeを「in_channel」に指定するとコマンドが残ってしまう
SlashCommandの実行結果をチャンネルに共有させたい場合、response_type
をin_channel
にして返却する必要がありますが、Slack上にトリガーとなったコマンドが残り続けてしまいます。
Slackのリファレンスによると、遅延応答させた場合は呼び出し元となったコマンドが含まれなくなるので、その上で空のレスポンスを返すといけるようです。
https://api.slack.com/slash-commands#responding_response_url
遅延応答はSlackからのリクエストに含まれているresponse_url
に対し、投稿したいメッセージのJSONをPOSTすると実行できます。
「空のレスポンスを返す」のは一見簡単そうに見えますが、Lambdaから「Noneを返す」「空文字列を返す」「空のJSONを返す」のいずれも上手くいきませんでした。
調べるとAWSのフォーラムにそのものズバリな話がありました。
https://forums.aws.amazon.com/thread.jspa?threadID=225711
ということで、結局コマンドの方でも「Lambda統合プロキシの使用」にチェックを入れる羽目に。
最後の最後まで詰まりましたが、これでやっと満足いく動作になりました。
あとがき
そんなわけで、お盆休みにSlackでアザラシ検定クイズをできるようにしました。
もっとさっくりいけるかと思いきや、ものすごく詰まったのでいい勉強になりました。
長期休暇中に何も目的がないと漠然とした日々を過ごしてしまいがちですが、やることを見つけて取り組めると気持ちがいいですね。