LoginSignup
8
2

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

Last updated at Posted at 2023-05-01

はじめに

本記事ではRustでDiscordの音楽再生botを作成する方法を説明します。
作成する音楽再生Botの仕様は以下のとおりです。

  • Youtubeの音楽をボイスチャンネルで流すことができる
  • 次に再生したい曲をキューに追加することができる
  • 再生中の音楽の一時停止、スキップ機能を持つ

ソースコードはsongbirdリポジトリのexamplesを参考に作成しました。
本記事で作成したBotのソースコードはGitHubで公開しています。手っ取り早くソースコードだけ見たい!という方はこちらをどうぞ。

説明すること

  • 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が必要なのでインストールしてください。

yt-dlpのインストール

yt-dlpとはPython製Youtubeダウンロードツールです。以下の記事を参考にインストールしてください。

一昔前はyoutube-dlというツールがデファクトスタンダードでしたが、現在は開発が停止しています。もしyoutube-dlを既にインストールされている方は削除してyt-dlpをインストールしてください。
ちなみにyt-dlpはyoutube-dlのフォークで、youtube-dlよりも非常に高速に改良されています。(参考)

FFmpegのインストール

FFmpegとは動画と音声を変換するツールです。以下の記事を参考にインストールしてください。

Discord Botの作成~設定

作成

  1. Discordのapplicationページに移動
  2. 右上のNew Applicationボタンをクリック
  3. botの名前を入力してCreateをクリック
    image.png

設定

  1. 左のタブからBotを選択して適当なアイコン、ユーザーネームを登録します
    image.png

  2. USERNAMEの下にあるReset Tokenをクリックしてトークンを作成し、Copyをクリックしてトークンをコピーします。こちらのトークンは後ほど使うのでメモしておいてください。
    スクリーンショット 2023-05-01 135117.png

  3. 少しスクロールしてMESSAGE CONTENT INTENTを有効にし、Save Changesをクリックして変更を反映します。
    スクリーンショット 2023-05-01 140126.png

サーバにBotを追加

  1. 左のタブのOAuth2 -> URL Generatorに移動します
  2. SCOPESbotにチェックを入れます
  3. BOT PERMISSIONSSend MessagesConnectSpeakにチェックを入れます。
    これでBotがメッセージの送信、ボイスチャンネルへの接続、ボイスチャンネルで発言(音楽再生)できるようになります。
    image.png
  4. 最下部までスクロールするとURLが生成されているのでコピーしてブラウザに貼り付けてください
  5. Botを参加させたいサーバを選択し、諸々の認証をしてください。(Bot開発用サーバを作成するのがオススメです)
  6. サーバにBotが参加すれば成功です。

Botに簡単な機能を追加

先程作成したBotに以下の仕様のコマンドを追加します。

コマンド 機能
~nurupo 「ガッ」のAAをメンション付きで返信
  1. 適当なディレクトリでリポジトリを作成

    cargo new my-music-bot
    
  2. Cargo.tomldependenciesを以下のように変更

    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"
    
  3. config.jsonを作成し、Discord Botのトークンを記述 (GItHubにアップロードしないように注意してください)

    config.json
    {
        "token": "先程取得したDiscord Botのトークン"
    }
    
  4. src/main.rsを以下のように変更してください

    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.");
    }
    
  5. src/util.rsを作成してください

    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);
        }
    }
    
  6. src内にcommandsディレクトリを作成してnurupo.rsを作成してください。

    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"{}
    ```
       ( ・∀・)   | | ガッ
      と    )    | |
        Y /ノ    人
         / )    <  >__Λ∩
       _/し' //. V`Д´)/
      (_フ彡        /
    ```",
            msg.author.mention()
        );
        check_msg(msg.channel_id.say(&context.http, res).await);
    
        Ok(())
    }
    
  7. src/commands.rsを作成してください

    commands.rs
    pub(crate) mod nurupo;
    
  8. Botを起動します

    cargo run
    # {Your Bot} is connected
    
  9. Botが参加しているテキストチャンネルで~nurupoと送信するとメンション付きで「ガッ」してくれます
    image.png

Botに音楽再生機能を追加

YouTubeの音楽を再生できる機能を追加します。以下の3つのコマンドを実装します。

コマンド 機能
~join 送信者が参加中のボイスチャンネルに接続
~leave 参加中のボイスチャンネルから切断
~play {YouTubeのURL} 送信者が参加中のボイスチャンネルでYouTubeを再生 (再生中ならキューに追加)
  1. Cargo.tomldependenciesに以下を追加してください

    Cargo.toml
    # feturesにyt-dlpを追加することでデフォルトがyoutube-dlからyt-dlpになる
    songbird = { version = "0.3.2", features = ["builtin-queue", "yt-dlp"] }
    
  2. src/main.rsを以下のように変更してください

    • 4行目あたり
      src/main.rs
      - use commands::nurupo::*;
      + use commands::{join::*, leave::*, nurupo::*, play::*};
      + use songbird::SerenityInit;
      
    • 28行目あたり
      src/main.rs
      - #[commands(nurupo)]
      + #[commands(nurupo, join, leave, play)]
      
    • 50行目あたり
      src/main.rs
      // Botのクライアントを作成
      let mut client = Client::builder(&token, intents)
          .event_handler(Handler)
          .framework(framework)
      +   .register_songbird()
          .await
          .expect("Err creating client");
      
  3. src/commands.rsを以下のように変更してください

    src/commands.rs
    pub(crate) mod join;
    pub(crate) mod leave;
    pub(crate) mod nurupo;
    pub(crate) mod play;
    
  4. src/commands/join.rsを作成してください

    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(())
    }
    
  5. src/commands/leave.rsを作成してください

    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(())
    }
    
  6. src/commands/play.rsを作成してください

    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(())
    }
    
  7. Botを起動します

    cargo run
    # {Your Bot} is connected
    
  8. 音楽再生コマンドが実行できるようになっています
    image.png

その他の機能を追加

以下のコマンドを実装します。

コマンド 機能
~skip キューを進めて次の曲を再生
~pause 再生中の曲を一時停止
~resume 一時停止解除
  1. src/main.rsを以下のように変更してください

    • 4行目あたり
    src/main.rs
    - use commands::{join::*, leave::*, nurupo::*, play::*};
    + use commands::{join::*, leave::*, nurupo::*, pause::*, play::*, resume::*, skip::*};
    
    • 28行目あたり
    src/main.rs
    - #[commands(nurupo, join, leave, play)]
    + #[commands(nurupo, join, leave, play, skip, pause, resume)]
    
  2. src/commands.rsを以下のように変更してください

    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;
    
  3. src/commands/skip.rsを作成してください

    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(())
    }
    
  4. src/commands/pause.rsを作成してください。

    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(())
    }
    
  5. src/commands/resume.rsを作成してください。

    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(())
    }
    
  6. Botを起動します

    cargo run
    # {Your Bot} is connected
    
  7. スキップ、一時停止ができるようになっています。
    image.png

まとめ

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

参考文献

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2