serenity&songbirdを使ってRustでDiscordの音楽botを作ろう

# はじめに
本記事ではRustでDiscordの音楽再生botを作成する方法を説明します。
作成する音楽再生Botの仕様は以下のとおりです。
* Youtubeの音楽をボイスチャンネルで流すことができる
* 次に再生したい曲をキューに追加することができる
* 再生中の音楽の一時停止、スキップ機能を持つ

ソースコードは[songbirdリポジトリのexamples](https://github.com/serenity-rs/songbird/tree/current/examples)を参考に作成しました。
本記事で作成したBotのソースコードはGitHubで公開しています。手っ取り早くソースコードだけ見たい！という方はこちらをどうぞ。
* [ppputtyo/my-music-bot](https://github.com/ppputtyo/my-music-bot)




# 説明すること
* Discord Botの作成方法
* Rust製Discord Bot用ライブラリであるserenity、songbirdを使用した音楽再生botの作成方法

# 説明しないこと
* クラウド上でのDiscord Bot実行方法

# 環境
* OS: Windows 11 Home
* Rust: 1.26.0
* Python: 3.9.13

# 準備
各種ツールをインストールします。

## Rust、Python3のインストール
YouTubeダウンロードツールであるyt-dlpの実行にPython3が必要なのでインストールしてください。

* [Rust をインストール - Rustプログラミング言語](https://www.rust-lang.org/ja/tools/install)
* [Download Python | Python.org](https://www.python.org/downloads/)

## yt-dlpのインストール
[yt-dlp](https://github.com/yt-dlp/yt-dlp)とはPython製Youtubeダウンロードツールです。以下の記事を参考にインストールしてください。

* [youtube-dlの代わりにyt-dlpをインストールする | Toru's Terminal](https://torusblog.org/youtubedl_yt-dlp_windows/)

:::note warn
一昔前はyoutube-dlというツールがデファクトスタンダードでしたが、現在は開発が停止しています。もしyoutube-dlを既にインストールされている方は削除してyt-dlpをインストールしてください。
ちなみにyt-dlpはyoutube-dlのフォークで、youtube-dlよりも非常に高速に改良されています。([参考](https://self-development.info/youtube-dl%E3%81%AE100%E5%80%8D%E9%80%9F%E3%81%8F%E5%8B%95%E7%94%BB%E3%82%92%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89%E3%81%A7%E3%81%8D%E3%82%8Byt-dlp/))
:::

## FFmpegのインストール
[FFmpeg](https://ffmpeg.org/)とは動画と音声を変換するツールです。以下の記事を参考にインストールしてください。

* [【初心者】FFmpegのダウンロードとインストール手順～Windows/Mac/Linux](https://jp.videoproc.com/edit-convert/how-to-download-and-install-ffmpeg.htm)

# Discord Botの作成~設定
### 作成
1. [Discordのapplicationページ](https://discord.com/developers/applications)に移動
1. 右上の`New Application`ボタンをクリック
1. botの名前を入力して`Create`をクリック
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2608343/f6ae45a4-f6cc-1ebe-b76e-8abec232cced.png)

### 設定
1. 左のタブから`Bot`を選択して適当なアイコン、ユーザーネームを登録します
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2608343/b7735a93-77a2-8b2b-0583-ebe1a9d7410c.png)
1. `USERNAME`の下にある`Reset Token`をクリックしてトークンを作成し、`Copy`をクリックしてトークンをコピーします。こちらのトークンは後ほど使うのでメモしておいてください。
    ![スクリーンショット 2023-05-01 135117.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2608343/e3aa6e09-e4c4-91c3-bed6-9ef8597415c4.png)

1. 少しスクロールして`MESSAGE CONTENT INTENT`を有効にし、`Save Changes`をクリックして変更を反映します。
    ![スクリーンショット 2023-05-01 140126.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2608343/ed0a9bf9-62fc-42aa-1e5f-3c1f311fe7ef.png)

### サーバにBotを追加
1. 左のタブの`OAuth2` -> `URL Generator`に移動します
1. `SCOPES`の`bot`にチェックを入れます
1. `BOT PERMISSIONS`の`Send Messages`、`Connect`、`Speak`にチェックを入れます。
    これでBotがメッセージの送信、ボイスチャンネルへの接続、ボイスチャンネルで発言(音楽再生)できるようになります。
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2608343/851da60b-4248-7bd3-6651-32ec87e29261.png)
1. 最下部までスクロールするとURLが生成されているのでコピーしてブラウザに貼り付けてください
1. Botを参加させたいサーバを選択し、諸々の認証をしてください。(Bot開発用サーバを作成するのがオススメです)
1. サーバにBotが参加すれば成功です。

# Botに簡単な機能を追加
先程作成したBotに以下の仕様のコマンドを追加します。
|コマンド|機能|
|-|-|
|~nurupo|「ガッ」のAAをメンション付きで返信|

1. 適当なディレクトリでリポジトリを作成
    ```sh
    cargo new my-music-bot
    ```
1. `Cargo.toml`の`dependencies`を以下のように変更
    ```toml: Cargo.toml
    [dependencies]
    serde = "1.0.160"
    serde_json = "1.0.96"
    serenity = { versions = "0.11.5", features = [
        "cache",
        "client",
        "standard_framework",
        "rustls_backend",
    ] }
    tokio = { version = "1.28.0", features = [
        "macros",
        "rt-multi-thread",
        "signal",
    ] }
    tracing = "0.1.37"
    tracing-subscriber = "0.3.17"
    tracing-futures = "0.2.5"
    ```
1. `config.json`を作成し、Discord Botのトークンを記述 (GItHubにアップロードしないように注意してください)
    ```config.json
    {
        "token": "先程取得したDiscord Botのトークン"
    }
    ```
1. `src/main.rs`を以下のように変更してください
    ```Rust: src/main.rs
    mod commands;
    mod util;
    
    use commands::nurupo::*;
    use serenity::client::Context;
    use serenity::{
        async_trait,
        client::{Client, EventHandler},
        framework::{standard::macros::group, StandardFramework},
        model::gateway::Ready,
        prelude::GatewayIntents,
    };
    use util::get_token;
    
    struct Handler;
    
    #[async_trait]
    impl EventHandler for Handler {
        // Bot起動時の処理
        async fn ready(&self, _: Context, ready: Ready) {
            println!("{} is connected", ready.user.name);
        }
    }
    
    // 有効なコマンド
    #[group]
    #[commands(nurupo)]
    
    struct General;
    
    #[tokio::main]
    async fn main() {
        // ログ出力するように設定
        tracing_subscriber::fmt::init();
    
        // トークンが記述されたconfigファイルを取得
        let token = get_token("config.json").expect("token not found");
    
        // フレームワークの作成
        let framework = StandardFramework::new()
            .configure(|c| c.prefix("~"))
            .group(&GENERAL_GROUP);
    
        // 特権とされていないintentとメッセージに関するintent
        let intents = GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT;
    
        // Botのクライアントを作成
        let mut client = Client::builder(&token, intents)
            .event_handler(Handler)
            .framework(framework)
            .await
            .expect("Err creating client");
    
        // クライアントを実行
        tokio::spawn(async move {
            let _ = client
                .start()
                .await
                .map_err(|why| println!("Client ended: {:?}", why));
        });
    
        // Ctrl+Cを検知した場合
        tokio::signal::ctrl_c().await.expect("");
        println!("Received Ctrl-C, shutting down.");
    }
    ```

1. `src/util.rs`を作成してください
    ```Rust: src/util.rs
    use serde::{Deserialize, Serialize};
    use serde_json::Result;
    use serenity::{model::prelude::Message, Result as SerenityResult};
    use std::{fs::File, io::BufReader};
    
    #[derive(Serialize, Deserialize)]
    struct Token {
        token: String,
    }
    
    /// config.jsonからDiscord Botトークンを取得
    pub(crate) fn get_token(file_name: &str) -> Result<String> {
        let file = File::open(file_name).unwrap();
        let reader = BufReader::new(file);
        let t: Token = serde_json::from_reader(reader).unwrap();
        Ok(t.token)
    }
    
    /// メッセージの送信に失敗した場合はエラーメッセージを表示
    pub(crate) fn check_msg(result: SerenityResult<Message>) {
        if let Err(why) = result {
            println!("Error sending message: {:?}", why);
        }
    }
    ``` 
1. `src`内に`commands`ディレクトリを作成して`nurupo.rs`を作成してください。
    ~~~Rust: src/commands/nurupo.rs
    use crate::util::check_msg;
    use serenity::{
        framework::standard::{macros::command, CommandResult},
        model::prelude::Message,
        prelude::{Context, Mentionable},
    };
    #[command]
    async fn nurupo(context: &Context, msg: &Message) -> CommandResult {
        let res = format!(
            r"{}
    ```
    　　 （　・∀・）　　　|　|　ｶﾞｯ
    　　と　　　　）　 　 |　|
    　　　 Ｙ　/ノ　　　 人
    　　　　 /　）　 　 < 　>__Λ∩
    　　 ＿/し'　／／. Ｖ｀Д´）/
    　　（＿フ彡　　　　　 　　/
    ```",
            msg.author.mention()
        );
        check_msg(msg.channel_id.say(&context.http, res).await);
    
        Ok(())
    }
    ~~~
1. `src/commands.rs`を作成してください
    ```Rust: commands.rs
    pub(crate) mod nurupo;
    ```
1. Botを起動します
    ```sh
    cargo run
    # {Your Bot} is connected
    ```
1. Botが参加しているテキストチャンネルで`~nurupo`と送信するとメンション付きで「ガッ」してくれます
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2608343/14cac8e6-be3f-8ccb-8c84-c5a7d48b62e0.png)

# Botに音楽再生機能を追加
YouTubeの音楽を再生できる機能を追加します。以下の3つのコマンドを実装します。
|コマンド|機能|
|-|-|
|~join|送信者が参加中のボイスチャンネルに接続|
|~leave|参加中のボイスチャンネルから切断|
|~play {YouTubeのURL}|送信者が参加中のボイスチャンネルでYouTubeを再生 (再生中ならキューに追加)|


1. `Cargo.toml`の`dependencies`に以下を追加してください
    ```toml: Cargo.toml
    # feturesにyt-dlpを追加することでデフォルトがyoutube-dlからyt-dlpになる
    songbird = { version = "0.3.2", features = ["builtin-queue", "yt-dlp"] }
    ```
1. `src/main.rs`を以下のように変更してください
    * 4行目あたり
        ```diff_rust: src/main.rs
        - use commands::nurupo::*;
        + use commands::{join::*, leave::*, nurupo::*, play::*};
        + use songbird::SerenityInit;
        ```
    * 28行目あたり
        ```diff_rust: src/main.rs
        - #[commands(nurupo)]
        + #[commands(nurupo, join, leave, play)]
        ```
    * 50行目あたり
        ```diff_rust: src/main.rs
        // Botのクライアントを作成
        let mut client = Client::builder(&token, intents)
            .event_handler(Handler)
            .framework(framework)
        +   .register_songbird()
            .await
            .expect("Err creating client");
        ```
1. `src/commands.rs`を以下のように変更してください
    ```diff_rust: src/commands.rs
    pub(crate) mod join;
    pub(crate) mod leave;
    pub(crate) mod nurupo;
    pub(crate) mod play;
    ```
1. `src/commands/join.rs`を作成してください
    ```rust: src/commands/join.rs
    use crate::util::check_msg;

    use serenity::{
        framework::standard::{macros::command, CommandResult},
        model::prelude::Message,
        prelude::Context,
    };
    
    #[command]
    #[only_in(guilds)]
    pub(crate) async fn join(ctx: &Context, msg: &Message) -> CommandResult {
        // サーバ情報の取得
        let guild = msg.guild(&ctx.cache).unwrap();
        let guild_id = guild.id;
    
        // メッセージ送信者が参加中のボイスチャンネルを取得
        let channel_id = guild
            .voice_states
            .get(&msg.author.id)
            .and_then(|voice_state| voice_state.channel_id);
    
        // 接続するボイスチャンネルがなければreturn
        let connect_to = match channel_id {
            Some(channel) => channel,
            None => {
                check_msg(
                    msg.reply(ctx, "ボイスチャンネル入ってからコマンド送ってね")
                        .await,
                );
                return Ok(());
            }
        };
    
        // クライアントマネージャの取得
        let manager = songbird::get(ctx)
            .await
            .expect("Songbird Voice client placed in at initialisation.")
            .clone();
    
        // ボイスチャンネルに接続
        let _handler = manager.join(guild_id, connect_to).await;
    
        check_msg(
            msg.channel_id
                .say(&ctx.http, "ボイスチャンネルに接続しました！")
                .await,
        );
    
        Ok(())
    }
    ```

1. `src/commands/leave.rs`を作成してください
    ```rust: src/commands/leave.rs
    use crate::util::check_msg;
    
    use serenity::{
        framework::standard::{macros::command, CommandResult},
        model::prelude::Message,
        prelude::Context,
    };
    
    #[command]
    #[only_in(guilds)]
    async fn leave(ctx: &Context, msg: &Message) -> CommandResult {
        // サーバ情報の取得
        let guild = msg.guild(&ctx.cache).unwrap();
        let guild_id = guild.id;
    
        // クライアントマネージャの取得
        let manager = songbird::get(ctx)
            .await
            .expect("Songbird Voice client placed in at initialisation.")
            .clone();
    
        // Botがサーバのボイスチャンネルに参加中ならTrue
        let has_handler = manager.get(guild_id).is_some();
    
        if has_handler {
            // サーバのボイスチャンネルから切断
            if let Err(e) = manager.remove(guild_id).await {
                check_msg(
                    msg.channel_id
                        .say(&ctx.http, format!("Failed: {:?}", e))
                        .await,
                );
            }
    
            check_msg(
                msg.channel_id
                    .say(&ctx.http, "ボイスチャンネルから切断したよ")
                    .await,
            );
        } else {
            check_msg(msg.reply(ctx, "ボイスチャンネルに入ってないよ").await);
        }
    
        Ok(())
    }
    ```
1. `src/commands/play.rs`を作成してください
    ```rust: src/commands/play.rs
    use crate::{commands::join, util::check_msg};
    
    use serenity::{
        framework::standard::{macros::command, Args, CommandResult},
        model::prelude::Message,
        prelude::Context,
    };
    use songbird::input::Restartable;
    
    #[command]
    #[only_in(guilds)]
    async fn play(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
        // 引数からURLを取得
        let url = match args.single::<String>() {
            Ok(url) => url,
            Err(_) => {
                check_msg(msg.channel_id.say(&ctx.http, "URL頂戴").await);
                return Ok(());
            }
        };
    
        // httpから始まらない場合はエラー
        if !url.starts_with("http") {
            check_msg(msg.channel_id.say(&ctx.http, "ちゃんとしたURL頂戴").await);
            return Ok(());
        }
    
        // サーバ情報の取得
        let guild = msg.guild(&ctx.cache).unwrap();
        let guild_id = guild.id;
    
        // クライアントマネージャの取得
        let manager = songbird::get(ctx)
            .await
            .expect("Songbird Voice client placed in at initialisation.")
            .clone();
    
        // ボイスチャンネルに接続していない場合は接続
        if let None = manager.get(guild_id) {
            join::join(ctx, msg, args)
                .await
                .expect("Voice channel connection failed");
        }
    
        if let Some(handler_lock) = manager.get(guild_id) {
            let mut handler = handler_lock.lock().await;
    
            // URLから音楽をダウンロード
            let source = match Restartable::ytdl(url, true).await {
                Ok(source) => source,
                Err(why) => {
                    println!("Err starting source: {:?}", why);
    
                    check_msg(
                        msg.channel_id
                            .say(&ctx.http, "FFmpegが見つかりません")
                            .await,
                    );
    
                    return Ok(());
                }
            };
    
            // 再生キューに音楽を追加
            handler.enqueue_source(source.into());
    
            // 現在のキューの長さを取得
            let queue_len = handler.queue().len();
            if queue_len == 1 {
                check_msg(msg.channel_id.say(&ctx.http, "再生中～～").await);
            } else {
                check_msg(
                    msg.channel_id
                        .say(&ctx.http, format!("{}曲後に再生されるよ", queue_len - 1))
                        .await,
                );
            }
        }
    
        Ok(())
    }
    ```
1. Botを起動します
    ```sh
    cargo run
    # {Your Bot} is connected
    ```
1. 音楽再生コマンドが実行できるようになっています
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2608343/c44152d9-8b68-8629-af4b-21f8a2bb1fb6.png)

# その他の機能を追加
以下のコマンドを実装します。
|コマンド|機能|
|-|-|
|~skip|キューを進めて次の曲を再生|
|~pause|再生中の曲を一時停止|
|~resume|一時停止解除|

1. `src/main.rs`を以下のように変更してください
    * 4行目あたり
    ```diff_rust: src/main.rs
    - use commands::{join::*, leave::*, nurupo::*, play::*};
    + use commands::{join::*, leave::*, nurupo::*, pause::*, play::*, resume::*, skip::*};
    ```
    * 28行目あたり
    ```diff_rust: src/main.rs
    - #[commands(nurupo, join, leave, play)]
    + #[commands(nurupo, join, leave, play, skip, pause, resume)]
    ```
1. `src/commands.rs`を以下のように変更してください
    ```diff_rust: src/commands.rs
    pub(crate) mod join;
    pub(crate) mod leave;
    pub(crate) mod nurupo;
    pub(crate) mod pause;
    pub(crate) mod play;
    pub(crate) mod resume;
    pub(crate) mod skip;
    ```
1. `src/commands/skip.rs`を作成してください
    ```rust: src/commands/skip.rs
    use crate::util::check_msg;

    use serenity::{
        framework::standard::{macros::command, Args, CommandResult},
        model::prelude::Message,
        prelude::Context,
    };
    
    #[command]
    #[only_in(guilds)]
    async fn skip(ctx: &Context, msg: &Message, _args: Args) -> CommandResult {
        // サーバ情報の取得
        let guild = msg.guild(&ctx.cache).unwrap();
        let guild_id = guild.id;
    
        // クライアントマネージャの取得
        let manager = songbird::get(ctx)
            .await
            .expect("Songbird Voice client placed in at initialisation.")
            .clone();
    
        if let Some(handler_lock) = manager.get(guild_id) {
            let handler = handler_lock.lock().await;
            let queue = handler.queue();
    
            // キューの長さを取得
            let queue_len = queue.len();
            if queue_len == 0 {
                check_msg(
                    msg.channel_id
                        .say(&ctx.http, format!("スキップする曲がないよ"))
                        .await,
                );
                return Ok(());
            }
    
            queue.skip().expect("Skip Failed");
    
            check_msg(
                msg.channel_id
                    .say(
                        &ctx.http,
                        format!("スキップ成功: あと{}曲残ってるよ ", queue_len - 1),
                    )
                    .await,
            );
        } else {
            check_msg(
                msg.channel_id
                    .say(&ctx.http, "ボイスチャンネルに入ってないよ")
                    .await,
            );
        }
    
        Ok(())
    }
    ```

1. `src/commands/pause.rs`を作成してください。
    ```rust: src/commands/pause.rs
    use crate::util::check_msg;
    
    use serenity::{
        framework::standard::{macros::command, Args, CommandResult},
        model::prelude::Message,
        prelude::Context,
    };
    
    #[command]
    #[only_in(guilds)]
    async fn pause(ctx: &Context, msg: &Message, _args: Args) -> CommandResult {
        let guild = msg.guild(&ctx.cache).unwrap();
        let guild_id = guild.id;
    
        let manager = songbird::get(ctx)
            .await
            .expect("Songbird Voice client placed in at initialisation.")
            .clone();
    
        if let Some(handler_lock) = manager.get(guild_id) {
            let handler = handler_lock.lock().await;
    
            let queue = handler.queue();

            // 一時停止
            queue.pause().expect("Pause failed");
    
            check_msg(msg.channel_id.say(&ctx.http, "一時停止中…").await);
        } else {
            check_msg(
                msg.channel_id
                    .say(&ctx.http, "ボイスチャンネルに入ってないよ")
                    .await,
            );
        }
    
        Ok(())
    }
    ```

1. `src/commands/resume.rs`を作成してください。
    ```rust:src/commands/resume.rs
    use crate::util::check_msg;
    
    use serenity::{
        framework::standard::{macros::command, Args, CommandResult},
        model::prelude::Message,
        prelude::Context,
    };
    
    #[command]
    #[only_in(guilds)]
    async fn resume(ctx: &Context, msg: &Message, _args: Args) -> CommandResult {
        // サーバ情報の取得
        let guild = msg.guild(&ctx.cache).unwrap();
        let guild_id = guild.id;
    
        // クライアントマネージャの取得
        let manager = songbird::get(ctx)
            .await
            .expect("Songbird Voice client placed in at initialisation.")
            .clone();
    
        if let Some(handler_lock) = manager.get(guild_id) {
            let handler = handler_lock.lock().await;
    
            let queue = handler.queue();
    
            // 一時停止解除
            queue.resume().expect("Resume failed");
    
            check_msg(msg.channel_id.say(&ctx.http, "一時停止解除").await);
        } else {
            check_msg(
                msg.channel_id
                    .say(&ctx.http, "ボイスチャンネルに入ってないよ")
                    .await,
            );
        }
    
        Ok(())
    }
    ```

1. Botを起動します
    ```sh
    cargo run
    # {Your Bot} is connected
    ```
1. スキップ、一時停止ができるようになっています。
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2608343/73f84a63-578c-2cbe-168f-32563a6f6e44.png)

# まとめ
少し長くなってしまいましたが最後まで読んでいただきありがとうございました！
良きDiscordライフを！！
 

# 参考文献
* [songbird - Rust](https://docs.rs/songbird/latest/songbird/index.html)
* [動画ダウンロードツール youtube-dl のフォークである yt-dlp を使ってみる - Vがいる日々](https://vlike-vlife.netlify.app/posts/cli_yt-dlp)
* [Rustで作るdiscord bot入門編 (serenity使用)](https://zenn.dev/t4t5u0/articles/cd731e0293cf224cb4dc)
* [[Rust] Discordのbotを作ってみる [Serenity] | DevelopersIO](https://dev.classmethod.jp/articles/rust-discordbot/)
* [Serenity (Rust) でDiscord Botを開発するときに躓いたところ - わたすけのへや](https://watasuke.net/blog/article/discord-bot-by-rust-and-serenity/)