はじめに
本記事では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の作成~設定
作成
- Discordのapplicationページに移動
- 右上の
New Application
ボタンをクリック - botの名前を入力して
Create
をクリック
設定
-
USERNAME
の下にあるReset Token
をクリックしてトークンを作成し、Copy
をクリックしてトークンをコピーします。こちらのトークンは後ほど使うのでメモしておいてください。
-
少しスクロールして
MESSAGE CONTENT INTENT
を有効にし、Save Changes
をクリックして変更を反映します。
サーバにBotを追加
- 左のタブの
OAuth2
->URL Generator
に移動します -
SCOPES
のbot
にチェックを入れます -
BOT PERMISSIONS
のSend Messages
、Connect
、Speak
にチェックを入れます。
これでBotがメッセージの送信、ボイスチャンネルへの接続、ボイスチャンネルで発言(音楽再生)できるようになります。
- 最下部までスクロールするとURLが生成されているのでコピーしてブラウザに貼り付けてください
- Botを参加させたいサーバを選択し、諸々の認証をしてください。(Bot開発用サーバを作成するのがオススメです)
- サーバにBotが参加すれば成功です。
Botに簡単な機能を追加
先程作成したBotに以下の仕様のコマンドを追加します。
コマンド | 機能 |
---|---|
~nurupo | 「ガッ」のAAをメンション付きで返信 |
-
適当なディレクトリでリポジトリを作成
cargo new my-music-bot
-
Cargo.toml
のdependencies
を以下のように変更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"
-
config.json
を作成し、Discord Botのトークンを記述 (GItHubにアップロードしないように注意してください)config.json{ "token": "先程取得したDiscord Botのトークン" }
-
src/main.rs
を以下のように変更してくださいsrc/main.rsmod 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."); }
-
src/util.rs
を作成してくださいsrc/util.rsuse 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); } }
-
src
内にcommands
ディレクトリを作成してnurupo.rs
を作成してください。src/commands/nurupo.rsuse 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(()) }
-
src/commands.rs
を作成してくださいcommands.rspub(crate) mod nurupo;
-
Botを起動します
cargo run # {Your Bot} is connected
Botに音楽再生機能を追加
YouTubeの音楽を再生できる機能を追加します。以下の3つのコマンドを実装します。
コマンド | 機能 |
---|---|
~join | 送信者が参加中のボイスチャンネルに接続 |
~leave | 参加中のボイスチャンネルから切断 |
~play {YouTubeのURL} | 送信者が参加中のボイスチャンネルでYouTubeを再生 (再生中ならキューに追加) |
-
Cargo.toml
のdependencies
に以下を追加してくださいCargo.toml# feturesにyt-dlpを追加することでデフォルトがyoutube-dlからyt-dlpになる songbird = { version = "0.3.2", features = ["builtin-queue", "yt-dlp"] }
-
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");
- 4行目あたり
-
src/commands.rs
を以下のように変更してくださいsrc/commands.rspub(crate) mod join; pub(crate) mod leave; pub(crate) mod nurupo; pub(crate) mod play;
-
src/commands/join.rs
を作成してくださいsrc/commands/join.rsuse 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(()) }
-
src/commands/leave.rs
を作成してくださいsrc/commands/leave.rsuse 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(()) }
-
src/commands/play.rs
を作成してくださいsrc/commands/play.rsuse 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(()) }
-
Botを起動します
cargo run # {Your Bot} is connected
その他の機能を追加
以下のコマンドを実装します。
コマンド | 機能 |
---|---|
~skip | キューを進めて次の曲を再生 |
~pause | 再生中の曲を一時停止 |
~resume | 一時停止解除 |
-
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)]
-
src/commands.rs
を以下のように変更してくださいsrc/commands.rspub(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;
-
src/commands/skip.rs
を作成してくださいsrc/commands/skip.rsuse 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(()) }
-
src/commands/pause.rs
を作成してください。src/commands/pause.rsuse 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(()) }
-
src/commands/resume.rs
を作成してください。src/commands/resume.rsuse 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(()) }
-
Botを起動します
cargo run # {Your Bot} is connected
まとめ
少し長くなってしまいましたが最後まで読んでいただきありがとうございました!
良きDiscordライフを!!