Serenity (Rust) でDiscord Botを開発するときに躓いたところ
自分は同じクラスの人間が集まるDiscordサーバーに入っているのですが、わりとVCが活発なんですよね。ただ、「人がいたら入ろうかな」という人も一定数いて(自分もそうです)、そういう人々はいちいちDiscordをチェックしなければいけません。面倒ですよね。
ということで、DiscordのVCチャンネルに誰かが入退出したら通知してくれるBotを作成しました。たぶん探したらたくさんあるとは思いますが、今回はRustとBot作成用クレートのSerenityを学ぼうと思い、自分で書きました。
今回は、制作過程で難しかったところ、日本語でまとめておけばわかりやすそうだなと思ったことをまとめておこうと思います。
この記事は共同開発鯖Advent Calendar 2021の5日目の記事です。昨日は鯖民向けBlogの使い方でした。鯖民向けのブログがあるのにこっちで書いてます。あと、今回のBotはこの鯖にいるBotを丸パクリしたものです。ゆるしてください。
どんなやつ作ったの
これです。
誰かがどこかのボイスチャンネルに入ったり出たりすると、あらかじめ指定したチャンネルに、こんな感じのメッセージを送ります。
わからないポイント0:そもそもドキュメントが読めない
Rustのクレートはかなりしっかりドキュメントが整備されていることが多いですよね。ビルドツールそのものにドキュメント生成機能が備わっているだけあります。
もちろんSerenityのドキュメントも用意されています。
ただ、開発初期はこのドキュメントをぜんぜん読めませんでした。正直どこに何が書いてあるのかわかりません。
これはどうしようもないと思います(?)。example を見ながら、わからない構造体をぜんぶ検索欄に入れましょう。検索欄は結構役に立ちました。
わからないポイント1:VCに入ったかどうかはどうやって検出する?
そもそもVCに入ったタイミングがわからなければおわりです。
ということで、とりあえずexamplesの一番上、basic_ping_bot
でも見ましょうか。
// コメント等、いろいろ省略
struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn message(&self, ctx: Context, msg: Message) {
// 中略
#[tokio::main]
async fn main() {
let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment");
let mut client =
Client::builder(&token).event_handler(Handler).await.expect("Err creating client");
if let Err(why) = client.start().await {
println!("Client error: {:?}", why);
}
}
構造体Handlerに、EventHandlerをimplしています(表現が正しいのかわからん)。そして、Handlerはクライアント生成の時に使われるっぽいです。
ということで、ドキュメントでEventHandlerを検索しましょう。serenity::prelude::EventHandler
がそれっぽいので開き、ページ内検索でchannel
とかvoice
とか調べてみます。
ヒットしたのはvoice_server_update
とvoice_state_update
。それぞれ説明文を見てみます。
voice_server_update
はこんな感じ。
Dispatched when a guild’s voice server was updated (or changed to another one).
Provides the voice server’s data.
voice_state_update
はこんな感じ。
Dispatched when a user joins, leaves or moves to a voice channel.
Provides the guild’s id (if available) and the old and the new state of the guild’s voice channels.
ということで、見るからにvoice_state_update
っぽいですね。これを使います。
(最初の方、間違えてvoice_server_update
と書いててずっと悩んでいました)
わからないポイント2:入退出はどう検出する?
誰かがVCに入退出した時に関数が呼ばれる(だけでなく、ミュートにしたときなども呼ばれるのですが、このときは気づかなかった)というのはわかりましたが、入室と退出はどうやって判定するのでしょうか?
これはもう引数をぜんぶprintln!で出力して比較しました。oldはOption<VoiceState>
で、newがVoiceState
なのですが、oldがSomeかつnew.channel_id
がNoneであればLeave、oldがNoneであればJoinということがわか りました。
これもVoiceStateをドキュメントで検索して、それを見つつやりました。
わからないポイント3:メッセージ送信
入退出が検知できたら9割完成です。あとは適切なタイミングでメッセージを送れば・・・メッセージを・・・送る・・・?
メッセージの送り方がわかりません。exampleを見ましょう。またもやbasic_ping_bot
です。
async fn message(&self, ctx: Context, msg: Message) {
if msg.content == "!ping" {
if let Err(why) = msg.channel_id.say(&ctx.http, "Pong!").await {
println!("Error sending message: {:?}", why);
}
}
}
Message構造体がいるらしい?どうしよう・・・
散々悩んで、Twitterの人に助けてもらいました。というかよく読めばわかるのですが、ChannelIdにsay関数があるのでこれを使えば良いです。
わからないポイント4:設定からチャンネルIDを読み込む
といっても設定自体の読み込みはserde_jsonにポイすれば終了で、問題は「構造体(Handler)からどうやって設定を読み込めば良いのか?」です。
examplesフォルダを流し読みするとglobal_data
という、いかにもなサンプルがあるので見ます。
struct CommandCounter;
impl TypeMapKey for CommandCounter {
type Value = Arc<RwLock<HashMap<String, u64>>>;
}
struct MessageCount;
impl TypeMapKey for MessageCount {
// While you will be using RwLock or Mutex most of the time you want to modify data,
// sometimes it's not required; like for example, with static data, or if you are using other
// kinds of atomic operators.
//
// Arc should stay, to allow for the data lock to be closed early.
type Value = Arc<AtomicUsize>;
}
見てもわかりませんでした。Arc<RwLock<HashMap<String, u64>>>
って何だよ・・・。
ただ、これは書き込みもできるデータ構造で、下に書いてあるとおり、読み込み専用ならArc<hoge>
でよさそうです。hoge内には登録したい型が入るらしく、StringならArc<String>
です。今回はSetting構造体を使いたいのでArc<Setting>
となります。
そして、クライアントを生成した後、こんな感じで登録します。
let file = File::open("env.json").unwrap();
let settings: Settings = serde_json::from_reader(BufReader::new(file)).unwrap();
{
let mut data = client.data.write().await;
data.insert::<Settings>(Arc::new(settings));
}
EventHandlerの関数にはたいていContextが渡されるので、以下のような感じで読み出せばOKです。最終的にOption型が返されることに注意してください。
let data = ctx.data.read().await;
let settings = data.get::<Settings>();
さっきのと合わせて、設定ファイルで記述したチャンネルにメッセージを送信するには、こんな感じにすれば良いわけです。
let ch = ChannelId(settings.channel_id);
ch.say(&ctx.http, "Hello);
ちなみに、ドキュメントのserenity::model::id::ChannelId
を見る限り、sayの第2引数にはDisplayが実装されてたら(println!("{}")
で表示できたら?)なんでもよさそうです。
わからないポイント5:embed使いたい
Botといえばやっぱりembedじゃないですか?テキストだけだとやっぱり味気ないですよね。
またexamplesを漁ります。create_message_builder
が良さげです。
channel_id.send_messageの第2引数にクロージャを渡して、その中でembedすれば解決するらしいです。具体的にどんな関数があるのかはドキュメントでわかります。
ちなみに、クロージャ内で全然補完が効きませんでした。変数名にカーソル合わせたら型推論してくれるのになんで補完はしてくれないんですかね・・・。いちおう型注釈すれば治ります。
ch.send_message(&ctx.http, |m: &mut CreateMessage| {
m.embed(|e: &mut CreateEmbed| {
...
おしまい
情報量が少なくて大変でした・・・公式ドキュメントとexamplesのおかげでなんとかなったと思います。
今回の制作過程では嫌というほどOptionを使ったので、Option完全に理解しました。今後もRustを使っていろいろ書いていきたいです。
明日は@kat0h氏による「Vimでmarkdownをプレビューするプラグインを作った(最新版)」です(たぶん)。よろしくおねがいします。