ついたて将棋ビューワーの bot を AWS CloudFront + WAF + Lambda として実装する最小テンプレートです。
Lambda はサイトの dispatcher から手番リクエストを受け取り、HMAC 署名を検証してから、bot の指し手を move として返します。
Python 3.12 のほか、AWS CDK、AWS CLI、Node.js/npm が必要です。
AWSアカウントの認証設定、cdk bootstrap などを事前に行う必要があります(参考)。
この README に記載しているコマンドは、主に macOS / Linux 向けです。Windows の方は適宜読み替えてください。
このサンプルでは、ついたて将棋のエンジンとしてこちらのライブラリを使用しています。Lambda Linux/arm64 向けの wheel は上記レポジトリの scripts/build_py_game_lambda_wheel.sh により作成できます。作成された target フォルダをこちらのルートフォルダ直下に配置してください。
Lambda に deploy する wheel と、ローカルでテストを実行する wheel は別物です。
- deploy 用: Lambda と同じ Linux/arm64 / Python 3.12 向け wheel が必要です。macOS / Windows / x86_64 Linux で作った通常の wheel は Lambda arm64 では動きません。
- ローカルテスト用: 自分の PC の OS / CPU / Python に合う wheel をインストールします。
tests/unit/test_strategy.pyはtsuitate_bindingsを import するため、未インストールだと失敗します。
ローカルで tsuitate_bindings を使う場合は、tsuitate-shogi-crates/tsuitate_bindings/ フォルダで以下を実行してください。
pip install maturin
python -m maturin developルートフォルダ直下 target/wheels に Lambda Linux/arm64 向けの tsuitate_bindings wheel を配置してから deploy します。
配置例:
tsuitate_sample_bot/
target/
wheels/
tsuitate_bindings-...-linux_aarch64.whl
app/
app.py
cdk.json
cd app
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtWebhookPath は Webhook Secret とは別のランダムな値にしてください。macOS / Linux では以下のように生成できます。
openssl rand -hex 16WebhookSecret は必須パラメータです。ついたて将棋ビューワーへの登録前はまだ発行されていないため、初回 deploy では仮の値を使います。このとき、あとで使い続ける WebhookPath も同時に指定してください。
cdk deploy \
--parameters WebhookSecret=temporary-secret-before-registration \
--parameters WebhookPath=/webhook/<上で生成したランダム値>deploy 後に出力される BotEndpointUrl を bot の endpoint として、ついたて将棋ビューワーに登録します。
登録時に表示される Webhook Secret を使用して、同じ WebhookPath のまま再度 deploy します。Webhook Secret はあとから再表示できないので、安全な場所に控えてください。
cdk deploy \
--parameters WebhookSecret=<ついたて将棋ビューワーで返されたwebhookSecret> \
--parameters WebhookPath=/webhook/<初回deployと同じランダム値>以下は必須ではないですが、セキュリティを高めるための推奨手順です。
deploy 後、AWS のコンソール上 CloudFront から BotCloudFrontDomainName の distribution を Free plan (CloudFront Free flat-rate plan) に紐づけます。
Free plan 紐づけ後、plan 側で関連付けられた AWS WAF WebACL に以下のルールを設定してください。
| Name | Action | 条件 |
|---|---|---|
RateLimitByIp |
Block | Rate-based rule。Aggregate は Source IP、5 分間あたり 1000 requests。 |
AWSリソースを一通り削除するには、以下のコマンドを実行します。
cdk destroyFree plan への紐づけを行った場合は、エラーになる場合があります。
そのときは、AWS コンソールの CloudFormation のページからスタックの削除をお願いします。
CDK template のテストのみ実行する場合:
cd app
pip install pytest
pytest tests/unit/test_app_stack.pybot の指し手生成も含めてテストする場合は、ローカル環境に合う tsuitate_bindings wheel を先にインストールしてから実行してください。
cd app
pip install pytest
pytestWEBHOOK_SECRET: ついたて将棋ビューワーで一度だけ返されるシークレット。CDK のWebhookSecretパラメータから設定されます。MAX_BODY_BYTES: 受け付けるリクエストの最大バイト数。未指定時は128 * 1024。WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS: HMAC timestamp の許容秒数。未指定時は300。GAME_STATE_TABLE_NAME: 対局ごとのgameと結合済みpositionsを保存する DynamoDB テーブル。CDK により自動設定されます。GAME_STATE_TTL_SECONDS: 対局状態の有効期間。CDK では86400(1 日)に固定されています。対局状態を更新するたびに期限も延長されます。ReservedConcurrency: Lambda の同時実行数上限。未指定時は2。非常時は0に変更すると停止できます。WebhookPath: bot 登録に使う webhook path。HMAC secret とは別のランダム値を指定してください。
登録した Webhook endpoint には、bot の手番になるたびに JSON 形式の POST リクエストが届きます。初回リクエストには手数 0 から現在までの全局面と game が含まれ、2 回目以降は前回から増えた局面だけが送られます。最新の仕様はついたて将棋ビューワーの Bot 開発者向け仕様も参照してください。
Headers:
Content-Type: application/json
X-Tsuitate-Bot-Id: {botId}
X-Tsuitate-Timestamp: {unixSeconds}
X-Tsuitate-Signature: sha256={hexHmac}
x-amz-content-sha256: {hexSha256OfBody}Body:
type Position = {
sfen: string;
fouls?: { b: number; w: number };
times?: { b: number; w: number };
byoyomiActive?: { b: boolean; w: boolean };
lastMove?: string;
lastInfo?: number;
lastCapture?: string;
wasPromotion?: boolean;
};
type Game = {
type: string;
param: string;
requiredPlayers: { b: number; w: number };
};
type BotTurnRequestBase = {
requestId: string;
gameId: string;
color: "b" | "w";
number: number;
ply: number;
};
type InitialBotTurnRequest = BotTurnRequestBase & {
positions: Record<string, Position>; // 手数 0 から現在まで
game: Game;
};
type SubsequentBotTurnRequest = BotTurnRequestBase & {
basePly: number;
positions: Record<string, Position>; // basePly + 1 から現在まで
game?: never;
};
type BotTurnRequest = InitialBotTurnRequest | SubsequentBotTurnRequest;パラメータの意味:
requestId: リクエストごとの識別子。gameId: 対局の識別子。局面履歴とgameはこの値ごとに保持する。color: bot の手番。bは先手、wは後手。number: 同じ色の中で何番目の参加者かを表す 0 始まりの番号。リレー形式の場合のみ 1 以上になり得る。ply: 初期盤面を 0 とした手数。反則手も手数に含める。一方でsfenの末尾に含まれる番号は、反則手を除く着手数 + 1 (初期盤面 1)である。basePly: 2 回目以降のリクエストにのみ含まれる、bot が保持済みと想定される最終手数。positions: 手数の文字列をキーにした局面履歴。初回は手数 0 から現在まで、2 回目以降はbasePly + 1から現在までが含まれる。sfen: そのレコード時点で bot から見えている局面fouls: 残り反則数times: 残り時間byoyomiActive: 秒読みに入ったかlastMove: 直前手。CSA 形式。- 例:
+7776FU - 相手の
lastMoveはマスクされたものが送られる。基本は+0000ZZ/-0000ZZで、lastCaptureがある場合のみ+0076ZZのように移動先のマスが開示される。
- 例:
lastCapture: 直前手で獲得した駒。CSA 形式。例:FUlastInfo: 直前手の情報。王手・反則判定に使う。値の意味は以下の通り。
INFO_NONE = 0
INFO_FOUL = 1
INFO_FOUL_UNDER_CHECK = 2
INFO_CHECK = 3
INFO_CHECKMATE = 4
wasPromotion: 直前手で成ったかどうか。相手の指し手は常にfalseにマスクされる。- game.type:
ダーク|ついたて|ついたて5五|ついたてリレー|カスタムのいずれか - game.param: ゲームのパラメータ。
game.typeがカスタムのときはここから初期盤面などの設定を読み取る必要がある。パラメータの記法は、ついたて将棋ビューワーを参照のこと。 - game.requiredPlayers: プレイヤー数。リレー形式の場合のみ 2 以上になり得る。
その対局で bot に届く最初のリクエストには、手数 0 から現在までの全局面と game が含まれます。
{
"requestId": "4f31a682d9c04b55b51aa231:0:b:0",
"gameId": "4f31a682d9c04b55b51aa231",
"color": "b",
"number": 0,
"ply": 0,
"positions": {
"0": {
"sfen": "9/9/9/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1",
"fouls": { "b": 9, "w": 9 },
"times": { "b": 300, "w": 300 },
"byoyomiActive": { "b": false, "w": false }
}
},
"game": {
"type": "ついたて",
"param": "&promotion_rank=3&enable_try_rule=false&n_players=1.1&visibility_rule=tsuitate&time_limits=180.180&fischer_increment=0&byoyomi=30&foul_limits=9.9&draw_move_count=150",
"requiredPlayers": { "b": 1, "w": 1 }
}
}basePly は bot が保持済みと想定される最終手数です。game は含まれず、positions にはその次の局面から現在局面までが入ります。
{
"requestId": "4f31a682d9c04b55b51aa231:2:b:0",
"gameId": "4f31a682d9c04b55b51aa231",
"color": "b",
"number": 0,
"ply": 2,
"basePly": 0,
"positions": {
"1": {
"sfen": "9/9/9/9/9/9/PP1PPPPPP/1B5R1/LNSGKGSNL w - 2",
"fouls": { "b": 9, "w": 9 },
"times": { "b": 294, "w": 300 },
"byoyomiActive": { "b": false, "w": false },
"lastMove": "+7776FU",
"lastInfo": 0
},
"2": {
"sfen": "9/9/9/9/9/9/PP1PPPPPP/1B5R1/LNSGKGSNL b - 3",
"fouls": { "b": 9, "w": 9 },
"times": { "b": 294, "w": 298 },
"byoyomiActive": { "b": false, "w": false },
"lastMove": "-0000ZZ",
"lastInfo": 0
}
}
}bot は gameId ごとに game と positions を保持してください。2 回目以降は、保持済みの最終手数が basePly と一致することを確認してから、受信した positions を同じ手数のキーで追加します。上の例では、保持している手数 0 に手数 1 と 2 を追加します。
このサンプルでは DynamoDB に対局状態を保存します。保存単位は bot、対局、手番側、プレイヤー番号の組み合わせです。状態は最後の更新から 1 日で期限切れになり、DynamoDB TTL により自動削除されます。TTL の削除は非同期であるため、期限を過ぎた項目が物理的に残っている場合でも handler は利用しません。
- HMAC署名を検証する。
- timestampが5分以上ずれているリクエストを拒否する。
- 初回は
gameとpositionsを保存し、2 回目以降はbasePlyを検証して局面履歴を更新する。 - 現在局面を読み、指し手を決定する。
- 指し手を
moveとして返す。
{
"move": "+7776FU"
}- raw request bodyを取得する。
X-Tsuitate-Timestampを取得する。- 現在のtimestampと
WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS以上ずれている場合は拒否する。 timestamp + "." + rawBodyに対してHMAC-SHA256を計算する。X-Tsuitate-Signatureと定数時間比較する。- 検証に失敗したらHTTP 401または403を返す。