一緒に通話している人が持っている共通のゲームをリストアップするbotを作った
DiscordのVCにいて、何がゲームしませんかという場面でみんなが持っているゲームをリストアップするためのbotを組んでみた。

こんな感じで通話中のメンバーのライブラリを読んで共通のゲームを10個ずつ返してくれる。
使い方
もし、使いたい人がいれば使ってみてくれるととても嬉しいです。 ただ、これはすごくお試しのため動作はかなり保証できないことを了承してほしいです。招待リンクからこのbotを追加できる。
サーバーに追加したら、とりあえず /help コマンドを実行して欲しい。

あらかじめ、このbotにはDiscordのユーザーとSteamのアカウントを紐づけておく必要がある。 紐づけにはSteamのIDが必要なので、Steamのアカウント詳細ページから取得しておく。

SteamIDとは、表示名やカスタムURLで使用している任意のIDではなく、Steam側で固定されている数値のもののこと。
続いて、 /register コマンドにコピーしたSteamIDを渡して登録する。

登録できると、登録されたIDを返してくれる。埋め込みはエラーになっているが、この数値のリンクから自分のSteamのプロフィールにアクセスできればOK。

これで事前準備は完了となる。
あとは、VCに参加してから適当なサーバー内のチャンネルで /get-common-games を呼び出せば一覧を返してくれる。

これはひとりぼっちなので意味がないが…
一応、何人のライブラリを読むことができたかを最初に表示するので、登録できていない人がいるかはわかるはず。
実装の話
すこし前にshuttle.rsというRustで書いたHTTPサーバーだとかをデプロイできるサービスを知って一度使ってみたいと思っていた。
shuttle-examplesにaxumだとかのHTTPサーバーに紛れてserenityのサンプルもあるようなので、これでDiscord Botを書くかとなった。
Shuttle の話
導入は cargo-shuttle を cargo install か cargo bininstall でインストールして cargo shuttle init すると、Shuttleのプロジェクトが作成される。
このあたりは正直かなり雰囲気なのであまり説明できることがない。適当に書いて実装したものは cargo shuttle run でローカルで実行できるし、 cargo shuttle deploy でShuttleにデプロイできる。
Shuttleが提供してくれるデータベースというものは、以下のものがある。
当初はDiscordアカウントとSteamアカウントの紐づけを保持するためにShuttle AWS RDSを使ったのだけど、のちにKVストアであるShuttle Persistに乗り換えた。コードを書く分にはShuttle Persistの方が単純で良さそうだと思ったから。
PricingにはHobbyプランで500MBのストレージが用意されているとある。
そうそう食いつくすことはないだろうとタカ括っているが、古くなったデータだとかは削除するようにしておきたい。しかし、shuttle-persistのドキュメントを見る限り、キーに紐づくデータを削除する関数は用意されていないようでちょっとよくわからない…
shuttle-persist 自体は、serdeを通したデータを格納できる。なので結構楽。
また、Shuttleの各サービスはmain.rsのエントリにパラメータを足すだけで使える。
#[shuttle_runtime::main]
async fn serenity(
#[shuttle_secrets::Secrets] secret_store: SecretStore,
#[shuttle_persist::Persist] persist: PersistInstance,
) -> shuttle_serenity::ShuttleSerenity {}
SecretStore は、 Secrets.toml ファイルに書いたシークレットを読み取ってくれる。
もし、 shuttle-aws-rdsを追加したい場合も、同じように引数を足せば良い。
serenityにpostgresを組み合わせたサンプルも用意されている。
ローカルの実行ではデフォルトで Docker のコンテナを勝手に立ててそこに接続してくれる。考えることが減って良いと思う。
Herokuの無料プランだとしばらくアクセスしないと休眠に入ってしまう、というようなことがあったが、このShuttleでも似たようなことがある。
Idle Projectsには、デフォルトで30分間アクセスがないとプロジェクトが停止してしまうとある。
このタイムアウト時間は任意に変更できるようで、 cargo shuttle project start --idle-minutes 0 とすると、無制限にさえできる。既にプロジェクトが存在している場合は、 start を restart に置き換えることで機能する。
Steam Web API について
Steamアカウントのライブラリを読み取るために、Steam Web APIを使用した。
正直用意されているものが少なくて勝手が悪い。この中の GetOwnedGames を使ってライブラリを読むのだけど、DLCやTest Serverみたいなものも含めて返してくれる。別途詳細を取得してフィルターをかけることができれば良いのだけど、どうにもそのようなメソッドは用意されていない。
欲を言えば、マルチプレイヤーに対応しているかとかもフィルターの材料にしたかった。
詳細を取得しようと思えば、 https://store.steampowered.com/api/appdetails からJSONを取れるのだけど、数百のリクエストをかけた程度で私のSteamアカウント自IPが一時的にSteamから遮断されてしまった。本当に無茶苦茶焦った。もう二度としない。
GetOwnedGames で取得できるデータは以下のようなものがある。
appid… ゲームのIDname… ゲームの名前playtime_forever… ユーザーがゲームをプレイした時間
プレイ時間を元に表示するゲームの優先度を決めたりとかもやりたかったが、とりあえず簡単なものを実装したかったので appid と name だけを取り出して使用した。
共通のゲームを取り出すのは単純に appid の HashSet をユーザー毎に intersection かけているだけ。
Discord 側の話
serenityはいつも通りなのだけど、今回はスラッシュコマンドを使ってみた。
プリインで入っている /giphy とかがそう。

ざっくりした使い方としては
まず、botの起動時にあらかじめコマンドの名前や説明、あとオプションだとかを設定しておく。
async fn ready(&self, ctx: Context, ready: Ready) {
Command::create_global_application_command(&ctx, |command| {
command.name("help").description("使い方を表示します。")
})
.await
}
次にコマンドが実行されると、 interaction_create メソッドに飛んでくるので、コマンド名を使って分岐させる。
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
match interaction {
Interaction::ApplicationCommand(command) => {
let resp = match command.data.name.as_str() {
"help" => {
// ここでヘルプメッセージを返す
}
_ => {}
}
}
_ => {}
}
}
という具合。
そういえば、ここの description だとかを変更すると別のコマンド扱いになるっぽくて、Discord側の反映を待たないと呼び出しても古いコマンドですと言われてしまった。

また、過去に登録して現在は存在しないコマンドだとかも残ったままになってしまっている。これは終了時に削除するとかはできないのだろうか。
たぶん知らないだけで流石に何かしら対処がある気がするので、現状のままだとお行儀の悪いbotということになる。
今回、ヘルプメッセージは埋め込みを使って返すようにした。
/// 抜粋: https://github.com/ekuinox/steam-discord-bridge-bot/blob/master/src/commands/help.rs
command
.create_interaction_response(ctx, |response| {
response
.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|msg| {
msg.ephemeral(true)
.embed(|embed| embed.field("使い方", HOW_TO_USE, true))
})
})
.await?;
HOW_TO_USE がテキスト本文で、ここではMarkdownが使える。文字数は1024以内の制限がある。
ephemeral を true にしておくと呼び出した本人にのみメッセージを表示することができる。
/get-common-games の流れとしては以下の動きになっている。
/get-common-gamesが呼ばれる- 通話の参加者のSteamライブラリを読み取り、共通しているゲームを抜き出す
PersistInstanceからユーザーIDに対して一覧を保存する- 最初の10件と次の10件へ移動するためのボタンを表示して終了
- ボタンが押されると、ボタンのカスタムIDからページ番号とユーザーIDを取り出す
PersistInstanceから共通しているゲームの一覧を取り出す- ページ番号に応じた最大10件と前後に移動するためのボタンを表示する
- 前に表示していた一覧を削除して終了(繰り返し)
ボタンのカスタムIDは以下の構造体をJSONにしたものを使っている。
// 抜粋: https://github.com/ekuinox/steam-discord-bridge-bot/blob/master/src/common_games.rs
#[derive(Serialize, Deserialize, Debug)]
pub struct CommonGamesButtonCustomId {
/// 遷移先のページの ID
pub page: usize,
/// ストアのキー
/// 呼び出したユーザーの ID に紐づけて保存する
pub key: String,
}
これで大丈夫なのかもよくわかっていないところはある。コードを読んでこれ怪しくね?ってなったら本当に声かけて欲しい。
ボタンの押下も interaction_create から取れる。
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
match interaction {
Interaction::MessageComponent(component) => {
if let Ok(custom_id) =
CommonGamesButtonCustomId::from_str(&component.data.custom_id)
// ...
}
_ => {}
}
}
一覧はボタンが押されるたびに新しいメッセージとして作り直しているが、可能であれば編集して維持したかった。
component.edit_original_interaction_response(http, f) から一見編集できそうだったが、実行時エラーがどうやっても出るので気が折れてしまった。何か知っていたら教えて欲しい。
ほか
一応 shuttle-aws-rds を sqlx で使っていた際に起こったプチだるいことをメモしておく。
- TLSのバックエンドを
serenityとsqlxでたぶん喧嘩しないようにしとかないといけない。 - sqlx のバージョンが最新だと型が合わない
結果として動作させられた際の Cargo.toml は以下。
# 抜粋: https://github.com/ekuinox/steam-discord-bridge-bot/blob/bc97acd828ecf68c0be7dd5ebdf70bc5708b4dbe/Cargo.toml
serenity = { version = "0.11.5", default-features = false, features = [
"client",
"gateway",
"native_tls_backend",
"model",
"cache",
] }
shuttle-aws-rds = { version = "0.21.0", features = ["postgres"] }
shuttle-serenity = "0.21.0"
sqlx = { version = "0.6.2", features = [
"postgres",
"runtime-tokio-native-tls",
] }
今回はHTTPしか使っていなくて default-features = false にしているけど、場合によっては reqwest もTLSのバックエンドを統一しないといけないかも。
間抜けなので、サンプル用に Secrets.toml.example をコピーで置いたら、 Secrets.toml 側を編集しちゃってて、トークン大公開を果たしてしまった。

マジですぐに来た。本当に助かる。ていうかどういう仕組み?
SteamのAPIキーも一緒にバレたことになるが、こちらも再発行ができて良かった。
