ゴマちゃんフロンティア

アザラシが大好きなエンジニアの開発日記です

2019年お盆休みにSlackからアザラシ検定クイズができるようにしたお話

time 2019/08/19

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などは不要です。

処理の流れは以下のような感じ。

  1. Slack上からSlashCommandを実行
  2. Lambdaがアザラシ検定アプリからクイズデータを取得し、InteractiveMessageに対応した形式のJSONを返却
  3. ユーザーがInteractiveMessageのボタンをクリック
  4. InteractiveComponentがクリックを検知し、Lambdaへリクエスト
  5. Lambdaがアザラシ検定アプリに回答内容を送信し、正解・不正解を判定
  6. 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の形式を正しく指定します。statusCodebodyを含めるのがポイントです。このあたりは公式にも書いてあります。
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_typein_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でアザラシ検定クイズをできるようにしました。
もっとさっくりいけるかと思いきや、ものすごく詰まったのでいい勉強になりました。
長期休暇中に何も目的がないと漠然とした日々を過ごしてしまいがちですが、やることを見つけて取り組めると気持ちがいいですね。

down

コメントする