RustではじめるDiscord Bot (1)

# はじめに

この記事では、DiscordでのBotの制作をRustを用いて行うための方法を紹介し、その導入部分を解説します。

Rust自体の文法や、Discord Botを作るうえでの必要なトークンの準備など解説していません。
トークンの取得方法など基本的なことを知りたい場合には[こちらの記事](https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f#bot%E3%82%A2%E3%82%AB%E3%82%A6%E3%83%B3%E3%83%88%E3%81%AE%E4%BD%9C%E6%88%90%E3%81%A8%E7%99%BB%E9%8C%B2)の初期設定の項目などを参考にして、準備を行ってください。

# 動作環境

この記事で使用しているRustのバージョンはNightly版を使用していますが、Stable版のRustであっても問題なく起動可能です。
また、ライブラリとしてはRustのDiscord APIラッパーライブラリの最大手である[Serenity](https://github.com/serenity-rs/serenity)をベースに作成している[Poise](https://github.com/kangalioo/poise)を使用します。

Poiseは開発中のライブラリであるようで、今後の実装状況次第では破壊的な変更があるかもしれません。可能な限り、変更に対して追従しますが常に動作可能なコードではないことをご理解ください。

では、なぜそのようなライブラリを使うのかということになりますが、PoiseではRustのプロシージャルマクロ機能などをうまく活用し、現状DiscordのBotを開発するうえで向き合わなくてはならない[Slash Commands](https://support.discord.com/hc/ja/articles/1500000368501-Slash-Commands-FAQ)への対応が容易に行えるという利点があるためです。

Discord公式の声明によると**2022年4月にメッセージの内容を取得するためのIntentが特権Intentに追加されます。**([記事はこちら](https://support-dev.discord.com/hc/en-us/articles/4404772028055-Message-Content-Privileged-Intent-for-Verified-Bots))

100サーバー以上に導入されたBotが特権Intentを使用するにはDiscordのサポートに、特権Intentを使う理由などを報告しなければなりません。

すなわち、100サーバーを超えるような中規模のBotを開発を予定している場合には、メッセージの内容を使用するために妥当な機能を開発する必要があります。単に`>ban @someone`のような単純な引数を受け取るようなコマンドのみで構成されたBotの場合だと、Slash Commandsで十分代用可能であるため許諾が下りない可能性があります。

そのためこの記事では、Slash Commandsと従来のテキストベースのコマンド双方の対応が容易なライブラリを使用して開発を行います。

Rustのバージョンは1.60.0 Nightly(498eeb72f 2022-01-31)です。

依存関係は以下の通りです。

```toml:Cargo.toml
[package]
name = "qiita_poise"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
dotenv = "0.15.0"
env_logger = "0.9.0"
log = "0.4.14"
poise = { git = "https://github.com/kangalioo/poise", rev = "9ebc7b5" }
thiserror = "1.0.30"
tokio = { version = "1.16.1", features = ["rt-multi-thread"] }

```

# 簡単なコマンドの実装

このコードの解説は後程行います。とりあえず、Botとして動かすための必要最小限のコードを以下に記します。

```rust:main.rs
#[macro_use]
extern crate log;

use std::env;

#[derive(thiserror::Error, Debug)]
enum AppError {
    #[error("{0}")]
    Serenity(#[from] poise::serenity::Error),
}

type Context<'a> = poise::Context<'a, (), AppError>;

#[poise::command(prefix_command, hide_in_help)]
async fn register(ctx: Context<'_>, #[flag] global: bool) -> Result<(), AppError> {
    poise::builtins::register_application_commands(ctx, global).await?;
    Ok(())
}

/// Add two number.
#[poise::command(prefix_command, slash_command)]
async fn add(
    ctx: Context<'_>,
    #[description = "The first number."] a: i32,
    #[description = "The second number."] b: i32,
) -> Result<(), AppError> {
    poise::say_reply(ctx, format!("{}", a + b)).await?;
    Ok(())
}

async fn on_error(error: poise::FrameworkError<'_, (), AppError>) {
    error!("{:?}", error);
}

#[tokio::main]
async fn main() {
    dotenv::dotenv().ok();
    env_logger::init();
    let token = env::var("DISCORD_TOKEN").expect("DISCORD_TOKEN not set");

    let options = poise::FrameworkOptions {
        commands: vec![register(), add()],
        prefix_options: poise::PrefixFrameworkOptions {
            prefix: Some("::".to_string()),
            ..Default::default()
        },
        on_error: |err| Box::pin(on_error(err)),
        ..Default::default()
    };

    poise::Framework::build()
        .token(token)
        .options(options)
        .user_data_setup(|_, _, _| Box::pin(async { Ok(()) }))
        .run()
        .await
        .unwrap();
}
```

このコードを記述したのち、プロジェクトのルートに`.env`ファイルを作成し、最初に作成したDiscordトークンを環境変数に登録します。

```.env
RUST_LOG=info
DISCORD_TOKEN=FaketokenFaketokenFaketo.kenFak.etokenFaketokenFaketokenFak
```

これにより、`cargo run`でコードを走らせることができます。

まず、作成したスラッシュコマンドを登録させるために`::register`と入力します。

`::register global`と入力することですべてのサーバーに作成したコマンドの反映を行いますが、globalを指定しない場合にはこのregisterコマンドを呼び出したサーバーのみに反映を行います。開発時には基本的にglobalを指定しないで、完成した際にglobalを使用して全体に登録を行うようにさせましょう。

また、Slash Commandに移行して、メッセージ内容の取得が行えなくなった場合であっても**BotへのメンションやBot間のDMメッセージ**の内容は読み取ることが可能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/217391/0bf01c73-b307-52cf-dd7e-53e9c40c68ff.png)

`Done!`の表示が確認出来たら、メッセージ欄に`/`と打ち込んでみましょう。先ほど登録した`/add`が見えていれば成功です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/217391/665aac58-712a-1600-5cfb-c06b156cd24c.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/217391/04c1224e-b7a3-dbfb-c0f9-05bc90daeb4e.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/217391/f774dbe5-296e-689d-01db-6b777ee26762.png)

動作確認を行いましょう。上記のように加算の結果が表示されていれば成功です。

## コードの解説

上記のコードの解説を行います。

### 1.エラーの列挙体

```rust
#[derive(thiserror::Error, Debug)]
enum AppError {
    #[error("{0}")]
    Serenity(#[from] poise::serenity::Error),
}
```

このコードではエラーハンドリングを容易にするために[thiserror](https://github.com/dtolnay/thiserror)を使用しています。列挙体の各要素に`#[error("{0}")]`などのようにアトリビュートを記述することで指定したライブラリのエラーに対する、`From`や`Debug`traitの実装を簡潔にすることができます。各ライブラリのエラーを`AppError`列挙体に集約させているため、関数の返り値を`Result<(), AppError>`と記述することができ、エラーのハンドリングを`AppError`で行い、エラーに関係ないコマンドの実装部分をコードの煩雑化を防ぐことができます。

### 2.コマンドの実装

```rust
#[poise::command(prefix_command, hide_in_help)]
async fn register(ctx: Context<'_>, #[flag] global: bool) -> Result<(), AppError> {
    poise::builtins::register_application_commands(ctx, global).await?;
    Ok(())
}

/// Add two number.
#[poise::command(prefix_command, slash_command)]
async fn add(
    ctx: Context<'_>,
    #[description = "The first number."] a: i32,
    #[description = "The second number."] b: i32,
) -> Result<(), AppError> {
    poise::say_reply(ctx, format!("{}", a + b)).await?;
    Ok(())
}
```

ここでは、registerとaddの2つのコマンドを実装しています。面倒なスラッシュコマンドの登録はpoiseが肩代わりしてくれます。`poise::builtins::register_application_commands`がその正体です。後程Botに登録したコマンドをSlash Commandとして登録するために各コマンドの引数の型などを解析しDiscordAPIに登録用の処理を投げます。

各コマンドの第一引数には`Context`を指定する必要があります。この`Context`は名前の通り、コマンドが打たれた「文脈」でありコマンドの投稿者やチャンネルの取得の際などに利用します。

`#[poise::command]`は[さまざまなオプションを受け取ることができます。](https://kangalioo.github.io/poise/master/poise/attr.command.html)
中でもよく使うものが`prefix_command`、`slash_command`です。前者が従来のBotのコマンドであり後者が名前の通りSlash Commandsです。

すなわち、`prefix_command`と`slash_command`の処理を同一化して書くことができます。(Serenity単体の使用ではこれら二つのコマンドのハンドリングを別々に記述する必要があります)

また、Slashコマンドの使用の際にはコマンド及び引数の説明を追加しなければなりません。それぞれDocumentation comment(`///`の行;PythonでいうところのDocstring)と`#[description]`に書かれた内容がそのまま反映されます。

### 3.メイン部分

```rust
#[tokio::main]
async fn main() {
    // 3.1.
    dotenv::dotenv().ok();
    env_logger::init();
    let token = env::var("DISCORD_TOKEN").expect("DISCORD_TOKEN not set");

    // 3.2.
    let options = poise::FrameworkOptions {
        commands: vec![register(), add()],
        // 3.2.1.
        prefix_options: poise::PrefixFrameworkOptions {
            prefix: Some("::".to_string()),
            ..Default::default()
        },
        // 3.2.2.
        on_error: |err| Box::pin(on_error(err)),
        ..Default::default()
    };

    // 3.3.
    poise::Framework::build()
        .token(token)
        .options(options)
        .user_data_setup(|_, _, _| Box::pin(async { Ok(()) }))
        .run()
        .await
        .unwrap();
}
```

#### 3.1. 環境変数の読み込み

.envに登録されたか環境変数をロードしています。`RUST_LOG`環境変数を指定すると`info!`や`error!`といったログを出力することができます。

#### 3.2. オプションの設定

**3.2.1. Prefix Option**

Prefix Optionは主にコマンドの接頭辞(`!`や`?`など)の登録を行います。ここでは実装していませんが、メッセージの編集を検知し編集内容に応じて再度処理を行う設定なども指定可能です。

**3.2.2. on_error**

起動中や実行中にエラーが発生した際のコールバックを設定できます。ここでは簡易的にログに流すだけの簡素なものに設定しています。

#### 3.3. 実行

先ほど指定したオプションを使用してBotを起動します。`.user_data_setup(|_, _, _| Box::pin(async { Ok(()) }))`では、Botを通して使用するデータ(データベースへの接続や全サーバーで同期する必要のあるデータ)を登録できます。この部分の詳細は次回以降の記事にて解説を行います。

# おわりに

ほんの基本的な部分の紹介でしたが、比較的直観的にRustを用いてSlashコマンドを実装できるということは伝わりましたでしょうか。

Slash Commandへの移行は辟易する部分もありますが、この記事がSlash Commandへの移行の足場となっていただければ幸いです。

需要次第ですが、より複雑なBotの実装方法を次回以降解説いたします。