【用 JS 寫一個 Discord Bot!】03 Discord.js v14 音樂機器人 (新版)
相較於舊版,這次發布的機器人有一些新的改變:
- 播放音樂更穩定,斷線後會自動重連
- 可將指令新增至 DC 群中
- 可以播放 Youtube 播放清單
- 摒棄
join
指令,播放音樂後機器人自動加入語音
[注意]
雖然相較於之前的版本,播放音樂相對穩定,但仍會有短暫沒有音樂、過一段時間才恢復的問題。
目前實測播放一小時的音樂,中間斷過一次,持續 30 秒左右,1 分鐘內自動恢復。但這仍取決於伺服器狀態及機器網路狀態。
前言
今天我們來寫一個具有以下功能的音樂機器人:
- 播放 YouTube 歌曲/播放清單
- 暫停/恢復播放
- 跳過歌曲
- 歌曲隊列
如果還不知道怎麼建立機器人,可以參考我之前寫的這篇文章:【用 JS 寫一個 Discord Bot!】01 建立機器人。
安裝套件
必備:
Discord 機器人的核心套件
1
| npm install discord.js@14.9.0
|
Discord 播放音樂的核心套件
1
| npm install @discordjs/voice@0.16.0
|
Opus 編碼器
1
| npm install @discordjs/opus@0.9.0
|
執行音樂的轉檔、串流功能
1
| npm install ffmpeg-static@5.1.0
|
Encryption packages
1
| npm install libsodium-wrappers@0.7.11
|
Streaming library
1
| npm install play-dl@1.9.6
|
編寫程式碼
這次我們會需要將主程式、音樂模組、指令、部屬指令的程式碼分成多個檔案,最終專案會呈現這個結構:
discord-bot/
├─ commands/
│ ├ command.js
│ ├ delete_playlist.js
│ ├ leave.js
│ ├ pause.js
│ ├ play.js
│ ├ queue.js
│ ├ resume.js
│ ├ skip.js
├─ utils/
│ ├ music.js
├ config.json
├ discord.js
├ package-lock.json
├ package.json
建立 config.json
在開始之前,我們先在專案中建立 config.json,放一些常用配置。
1 2 3 4
| { "token": "your token", "clientId": "your bot client id" }
|
編寫 /utils/music.js
這個檔案會放在 utils 目錄底下,用於處理音樂播放等功能,也是本文的重點。
點我展開程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306
| const play = require('play-dl'); const { createAudioPlayer, createAudioResource, joinVoiceChannel, NoSubscriberBehavior, AudioPlayerStatus } = require('@discordjs/voice');
class Music {
constructor() {
this.isPlaying = {};
this.queue = {};
this.connection = {};
this.dispatcher = {}; }
command(interaction) { interaction.reply({ content: `【播放音樂】/play url:音樂網址\n【暫停播放】/pause\n【恢復播放】/resume\n【跳過這首歌曲】/skip\n【查看歌曲隊列】/queue\n【刪除播放清單中的所有歌曲】/deleteplaylist id:id\n【查看機器人指令】/command\n【讓機器人離開語音頻道(會清空歌曲隊列)】/leave` }); }
isPlayList(url) { if (url.indexOf('&list') > -1 && url.indexOf('music.youtube') < 0) { return true; }
return false; }
async play(interaction) {
const guildID = interaction.guildId;
if (interaction.member.voice.channel === null) { interaction.reply({ content: '請先進入語音頻道', ephemeral: true }); return; }
this.connection[guildID] = joinVoiceChannel({ channelId: interaction.member.voice.channel.id, guildId: guildID, adapterCreator: interaction.guild.voiceAdapterCreator });
let musicURL = interaction.options.getString('url').trim();
try {
if (!this.queue[guildID]) { this.queue[guildID] = []; }
let musicName = null;
const isPlayList = this.isPlayList(musicURL); if (isPlayList) {
const res = await play.playlist_info(musicURL); musicName = res.title;
const videoTitles = res.videos.map((v, i) => `[${i+1}] ${v.title}`).slice(0, 10).join('\n'); interaction.channel.send(`**加入播放清單:${musicName}**\nID 識別碼:[${res.id}]\n==========================\n${videoTitles}\n……以及其他 ${res.videos.length - 10} 首歌 `);
res.videos.forEach(v => { this.queue[guildID].push({ id: res.id, name: v.title, url: v.url }); });
} else {
const res = await play.video_basic_info(musicURL); musicName = res.video_details.title;
this.queue[guildID].push({ id: res.video_details.id, name: musicName, url: musicURL });
}
if (this.isPlaying[guildID]) { interaction.reply({ content: `歌曲加入隊列:${musicName}` }); } else { this.isPlaying[guildID] = true; interaction.reply({ content: `🎵 播放音樂:${this.queue[guildID][0].name}` }); this.playMusic(interaction, this.queue[guildID][0], true); }
} catch(e) { console.log(e); interaction.reply({ content: '發生錯誤 :('}); }
}
playNextMusic(interaction) {
const guildID = interaction.guildId;
if (this.queue[guildID].length > 0) { this.playMusic(interaction, this.queue[guildID][0], false); } else { this.isPlaying[guildID] = false; } }
async playMusic(interaction, musicInfo, isReplied) {
const guildID = interaction.guildId;
try {
if (!isReplied) { const content = `🎵 播放音樂:${musicInfo.name}`; interaction.channel.send(content); } const stream = await play.stream(musicInfo.url); const resource = createAudioResource(stream.stream, { inputType: stream.type });
const player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
player.play(resource);
this.connection[guildID].subscribe(player); this.dispatcher[guildID] = player;
this.queue[guildID].shift();
player.on('stateChange', (oldState, newState) => {
if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) { this.playNextMusic(interaction); }
}); } catch(e) { console.log(e); interaction.channel.send('歌曲發生錯誤...');
this.queue[guildID].shift();
this.playNextMusic(interaction); }
}
resume(interaction) {
const guildID = interaction.guildId; if (this.dispatcher[guildID]) { this.dispatcher[guildID].unpause(); interaction.reply({ content: '恢復播放' }); } else { interaction.reply({ content: '機器人目前未加入頻道' }); }
}
pause(interaction) {
const guildID = interaction.guildId; if (this.dispatcher[guildID]) { this.dispatcher[guildID].pause(); interaction.reply({ content: '暫停播放' }); } else { interaction.reply({ content: '機器人目前未加入頻道' }); }
}
skip(interaction) {
const guildID = interaction.guildId; if (this.dispatcher[guildID]) { this.dispatcher[guildID].stop(); interaction.reply({ content: '跳過目前歌曲' }); } else { interaction.reply({ content: '機器人目前未加入頻道' }); }
}
nowQueue(interaction) {
const guildID = interaction.guildId;
if (this.queue[guildID] && this.queue[guildID].length > 0) { let queueString = '';
let queue = this.queue[guildID].map((item, index) => `[${index+1}] ${item.name}`); if (queue.length > 10) { queue = queue.slice(0, 10); queueString = `目前歌單:\n${queue.join('\n')}\n……與其他 ${this.queue[guildID].length - 10} 首歌`; } else { queueString = `目前歌單:\n${queue.join('\n')}`; }
interaction.reply({ content: queueString }); } else { interaction.reply({ content: '目前隊列中沒有歌曲' }); }
}
deletePlayList(interaction) { const guildID = interaction.guildId; const id = interaction.options.getString('id').trim();
this.queue[guildID] = this.queue[guildID].filter(q => q.id !== id); interaction.reply({ content: `刪除ID為 ${id} 的播放清單歌曲` }); }
leave(interaction) {
const guildID = interaction.guildId;
if (this.connection[guildID]) {
if (this.queue.hasOwnProperty(guildID)) {
delete this.queue[guildID];
this.isPlaying[guildID] = false; }
this.connection[guildID].disconnect();
interaction.reply({ content: '離開頻道' }); } else { interaction.reply({ content: '機器人未加入任何頻道' }); }
} }
module.exports = new Music();
|
指令編寫
建立一個 /commands
資料夾來放置指令程式碼,這次我們將一個指令分成一個檔案來管理。
由於檔案眾多,這邊僅放上其中一個指令的程式碼,其他指令程式碼請到 Github Repository 查看:B-l-u-e-b-e-r-r-y/Discord-Bot-03/commands。
/commands/play.js
點我展開程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const { SlashCommandBuilder } = require('discord.js'); const music = require('../utils/music');
module.exports = { data: new SlashCommandBuilder() .setName('play') .setDescription('播放音樂') .addStringOption(option => option .setName('url') .setDescription('提供 Youtube url 網址') .setRequired(true) ), async execute(interaction) { await music.play(interaction); }, };
|
編寫 discord.js
discord.js 在這邊用於處理、回應指令、註冊指令等工作。
點我展開程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
| const fs = require("node:fs"); const path = require("node:path"); const { Client, Collection, Events, GatewayIntentBits } = require("discord.js"); const { token } = require("./config.json");
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates], });
client.commands = new Collection();
const commands = [];
const commandsPath = path.join(__dirname, "commands"); const commandFiles = fs.readdirSync(commandsPath).filter((file) => file.endsWith(".js"));
for (const file of commandFiles) { const filePath = path.join(commandsPath, file); const command = require(filePath);
if ("data" in command && "execute" in command) { client.commands.set(command.data.name, command); } else { console.log(`[警告] ${filePath} 中的指令缺少必要的 "data" 或 "execute" 屬性。`); }
commands.push(command.data.toJSON()); }
client.on(Events.InteractionCreate, async (interaction) => { if (!interaction.isChatInputCommand()) return;
const command = interaction.client.commands.get(interaction.commandName);
if (!command) { console.error(`找不到指令 ${interaction.commandName}。`); return; }
try { await command.execute(interaction); } catch (error) { console.error(error); if (interaction.replied || interaction.deferred) { await interaction.followUp({ content: "執行指令時發生錯誤!", ephemeral: true, }); } else { await interaction.reply({ content: "執行指令時發生錯誤!", ephemeral: true, }); } } });
const registerCommands = async (client) => { try { if (client.application) { console.log(`Started refreshing ${commands.length} application (/) commands.`); const data = await client.application.commands.set(commands); console.log(`Successfully reloaded ${data.size} application (/) commands.`); } } catch(e) { console.error(e); } }
client.once(Events.ClientReady, async (client) => { console.log(`已就緒!已登入帳號:${client.user.tag}`); await registerCommands(client); });
client.login(token);
|
運行機器人
運行後會看到機器人已登入的訊息,然後就可以看到指令出現在指令區了。
機器人功能如下:
- 【播放音樂】
/play url:音樂網址
- 【暫停播放】
/pause
- 【恢復播放】
/resume
- 【跳過這首歌曲】
/skip
- 【查看歌曲隊列】
/queue
- 【刪除播放清單中的所有歌曲】
/deleteplaylist id:id
- 【查看機器人指令】
/command
- 【讓機器人離開語音頻道(會清空歌曲隊列)】
/leave
本來想把程式碼切開在文中講解,但是發現這樣寫起來會篇幅太長而且雜亂,所以就乾脆把註解寫在 code 裡面。
本次音樂機器人的 Github Repository,可以自行 clone 下來研究或修改。
特別感謝
本文由 ChatGPT 協助產出,感恩 ❤️
【用 JS 寫一個 Discord Bot!】系列文章
文章結束囉~
如果我的文章對你有幫助,可以幫我拍個手,感謝支持!