はじめまして。私はカニです。
きっかけ
身内で「大乱闘スマッシュブラザーズ SPECIAL」(以下、スマブラSP)を遊ぶ際のこと。
使用ファイターをランダムで決めることが度々あり1、ダイスロールbot+数字とファイターの対応表という運用を行っていました。
ただ、表を逐次で照合するのは思ったより骨が折れます。これを1つのスラッシュコマンドに押し込めないか?というのが切っ掛けです。
ランダムで抽選する遊びをする人、discordのスラッシュコマンドを作ってみたい人がこの記事を役立ててくだされば幸いです。
なお、使用言語はpython、match-caseを使うためランタイムバージョンは3.10以上を想定。
discord botの作り方、サーバへの導入方法、インフラ側の説明は気が向いたときに書きます。
要件
スラッシュコマンドに求められる要件は以下の3つ。
- 抽選回数は任意。少なくとも10くらいまでは同時に抽選できる
- 実行するたび、ファイターの番号2と名前を重複なしリストで返却する
- いつでも呼び出せる
3を実現するため3、学習も兼ねてLambdaとAPI Gateway(HTTP API)内に実装し、呼び出すたび起動する形としました。
discord botの制約
調べて初めて知ったことも多いのですが、制約が割と多い。
スラッシュコマンドの登録はAPI経由のみ
ここで作成するアプリと別に、スラッシュコマンド登録用のpythonを作り、実行すること。
import requests
import os
import json
commands = {
"name": "oma",
"description": "ファイターを抽選します",
"type": 1, # 通常コマンド
"options": [
{
"name": "num",
"description": "抽選回数",
"type": 4, # int
"required": True
}
],
}
def main():
url = f"https://discord.com/api/v10/applications/{app_id}/commands"
headers = {
"Authorization": f'Bot {bot_token}',
"Content-Type": "application/json",
}
res = requests.post(url, headers=headers, data=json.dumps(commands))
print(res.content)
if __name__ == "__main__":
main()
cd '上記pythonを配置したディレクトリ'
python3 ./register_command.py
エンドポイントの疎通にpingを通す必要がある
大したことではないですが、pingが来たらpongを返す処理を組み込むこと。
識別はinteraction内のtypeで行えます。
interaction = json.loads(body)
match interaction['type']:
# Discordからの接続確認(PING)への応答
case InteractionType.PING:
return {"statusCode": 200, "body": json.dumps({"type": InteractionResponseType.PONG})}
認証にbodyが必要
この条件があるため、API Gatewayの「Lambda Authorizer」を利用できません。
認証をキャッシュできない以上、利用頻度によってはトークンをSecrets Managerに保存しない方が無難。
レスポンス形式
return {
"statusCode": #任意のコード ,
"headers": {
"Content-Type": "application/json"
},
"body": json.dumps({
# 中身は必要に応じて
})
}
return {
"content": f"エラーが発生しました: {str(e)}"
}
今回はやってないですが、フォーマット用の関数を一つ拵えた方がいいな~と思いました。反省。
応答は3秒以内
リクエストに対し、3秒以内にレスポンスを投げ返さないとエラーになります。4
要件の都合、Lambdaのコールドスタートが不可避なことから、以下の実装で対処しました。
フロント側
コマンドの実処理は別のLambda関数に掃き出し、非同期でキック。
discord側には「考え中...」で待機させる。
# スラッシュコマンド
case InteractionType.APPLICATION_COMMAND:
# コマンドの実態は別のlambdaを非同期実行
lambda_client.invoke(
FunctionName='oma', # Lambda関数名を指定
InvocationType='Event', # 非同期実行
Payload=json.dumps(event)
)
# 「考え中...」で待機させる
return {
"statusCode": 200,
"headers": {
"Content-Type": "application/json"
},
"body": json.dumps({
"type": InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE # 後で送る
})
}
バックエンド側
discordのwebhookを経由し、「考え中...」を上書きします。
キー項目はinteractionから抜けるので問題なし。
def lambda_handler(event, context):
# 1. フロント側から渡された interaction の情報を取得
interaction = json.loads(event.get('body', '{}'))
app_id = interaction['application_id']
token = interaction['token']
# 2. コマンドを実行
try:
result = slash_commands(interaction)
except Exception as e:
result = {
"content": f"エラーが発生しました: {str(e)}"
}
# 3. HTTP PATCH で結果を送信し、メッセージを上書き
# ※ @original を指定すると、先に返したレスポンスの「考え中...」を上書きする
url = f"https://discord.com/api/webhooks/{app_id}/{token}/messages/@original"
response = requests.patch(url, json=result)
# 処理終了
return {
"statusCode": response.status_code,
"headers": {
"Content-Type": "application/json"
},
"body": "Success"
}
実装
ここまでの流れをおさらいして、以下の形になりました。
なお、Lambdaはpython標準とboto以外のパッケージは、デプロイするzipファイルに含めるかlayerに放り込む必要があります。(今回ならdiscord_interactionsとrequestsが該当)
フロント側
file
├ lambda_function.py
├ discord_auth.py
└(discord_interaction package)
import json
import discord_auth
from discord_interactions import InteractionType, InteractionResponseType
#boto3はlambdaに入ってるのでパッケージに含めなくてよい
import boto3
# 別のLambdaを呼び出すためのクライアント
lambda_client = boto3.client('lambda')
#----------------------------------------------------
# lambdaの監視部分
#----------------------------------------------------
def lambda_handler(event, context):
#----------------------------------------------------
# 1.discord認証を実施(キャッシュできないのは致し方なし)
#----------------------------------------------------
body = event.get('body', "{}")
chk = discord_auth.discord_auth(body, event['headers']);
if chk is not None:
return chk
#----------------------------------------------------
# 2. データの解析
#----------------------------------------------------
interaction = json.loads(body)
match interaction['type']:
# Discordからの接続確認(PING)への応答
case InteractionType.PING:
return {
"statusCode": 200,
"body": json.dumps({
"type": InteractionResponseType.PONG
})
}
# スラッシュコマンド
case InteractionType.APPLICATION_COMMAND:
# コマンドの実態は別のlambdaを非同期実行
lambda_client.invoke(
FunctionName='oma', # Lambda関数名を指定
InvocationType='Event', # 非同期実行
Payload=json.dumps(event)
)
# 仮の返答を返し、待ってもらう
return {
"statusCode": 200,
"headers": {
"Content-Type": "application/json"
},
"body": json.dumps({
"type": InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE # 後で送る
})
}
# 上記以外の応答には400エラーを返却
case _:
return {
"statusCode": 400,
"headers": {
"Content-Type": "application/json"
},
"body": json.dumps({
"unhandled request"
})
}
import os
import json
from discord_interactions import verify_key
#boto3はlambdaに入ってるのでパッケージに含めなくてよい
import boto3
from botocore.exceptions import ClientError
#----------------------------------------------------
# 認証処理
#----------------------------------------------------
def discord_auth(body,headers):
#----------------------------------------------------
# 署名検証 (Discordからの安全な通信か確認)
#----------------------------------------------------
signature = headers.get('x-signature-ed25519')
timestamp = headers.get('x-signature-timestamp')
#----------------------------------------------------
# 挙動と速度の都合、妥協して環境変数から取得
#----------------------------------------------------
bot_token = os.environ['bot_token']
allowed_app_id = os.environ['app_id']
if not verify_key(body.encode(), signature, timestamp, bot_token):
return {
"statusCode": 401,
"headers": {
"Content-Type": "application/json"
},
"body": json.dumps({
"invalid request signature"
})
}
#----------------------------------------------------
# 認可されたサーバ・アプリ以外の実行を拒否
#----------------------------------------------------
interaction = json.loads(body)
if not (interaction['application_id'] == allowed_app_id):
return {
"statusCode": 403,
"headers": {
"Content-Type": "application/json"
},
"body": json/dumps({
"forbidden"
})
}
バックエンド側
file
├ lambda_function.py
├ oma.py
├ fighters.csv
└(discord_interaction package)
import json
import requests
import oma
#boto3はlambdaに入ってるのでパッケージに含めなくてよい
import boto3
#----------------------------------------------------
# lambdaの監視部分
#----------------------------------------------------
def lambda_handler(event, context):
# 1. フロント側から渡された interaction の情報を取得
interaction = json.loads(event.get('body', '{}'))
app_id = interaction['application_id']
token = interaction['token']
# 2. コマンドを実行
try:
result = slash_commands(interaction)
except Exception as e:
result = {
"content": f"エラーが発生しました: {str(e)}"
}
# 3. HTTP PATCH で結果を送信し、メッセージを上書き
# ※ @original を指定すると、先に返したレスポンスの「考え中...」を上書きする
url = f"https://discord.com/api/webhooks/{app_id}/{token}/messages/@original"
response = requests.patch(url, json=result)
# 処理終了
return {
"statusCode": response.status_code,
"headers": {
"Content-Type": "application/json"
},
"body": "Success"
}
#----------------------------------------------------
# スラッシュコマンドの実行部分
#----------------------------------------------------
def slash_commands(interaction):
# コマンド名から処理を呼び出し
match interaction['data']['name']:
#試行回数ぶん、おまかせでファイターを表示
case 'oma':
num = interaction['data']['options'][0]['value']
return oma.choice(num)
#上記以外にはエラーを返却
case _:
return {"content": "undifined command"}
import csv
import random
#----------------------------------------------------
# おまかせ選択(重複なし)
#----------------------------------------------------
def choice(num) :
#----------------------------------------------------
# 1. CSVを取得(1列目:ファイター名)
#----------------------------------------------------
try:
with open('fighters.csv', mode='r', encoding='utf-8') as f:
reader = csv.reader(f)
fighters = [row[0] for row in reader if row] # 空行除外
except FileNotFoundError:
return {
"content":"CSV file not found"
}
#----------------------------------------------------
# 2.実行回数を取得
#----------------------------------------------------
# バリデーション:numが数字以外、負の数、または要素数を超過する場合エラー
if not isinstance(num, int) or not (1 <= num <= len(fighters)):
return {
"content": f"1から{len(fighters)}までの整数を指定してください。"
}
#----------------------------------------------------
# 3. サイコロを回し、昇順ソート(重複なしランダム)
#----------------------------------------------------
chosen_indices = sorted(random.sample(range(len(fighters)), num))
#----------------------------------------------------
# 4.CSVデータと出目を突合し、「・No.名前」の形式に変換
#----------------------------------------------------
result_names=[]
for idx in chosen_indices:
line = f"{idx + 1:02d}\. {fighters[idx]}"
result_names.append(line)
res_txt = "\n".join(result_names)
#----------------------------------------------------
# 5.結果を返却
#----------------------------------------------------
return {
"content": res_txt
}
if __name__ == '__main__':
print(choice(5))
おわりに
本当はもっと厳密に構成の解説などできたら…と思ってましたが、正直、まだまだ理解できていない部分が多いです。
参考になりましたら幸いです。
-
ご存じの通り「おまかせ」はmiiファイターが抽選されない、長時間・複数人利用で如実に出目が偏る…といったデメリットが存在します。また、複数抽選し、そこから対戦相手に選ばせる使い方もしていました。 ↩
-
ダッシュファイターは元のファイターと異なる項番で管理し、重複を許容。そのため、項番は公式サイト(https://www.smashbros.com/ja_JP/fighter/index.html)とずれます。 ↩
-
常時稼働のサーバを用意できず、安く済ませたいことも一因。 ↩
-
https://zenn.dev/lambta/articles/248275cc7dc8baがとても参考になりました。 ↩
