【用 JS 寫一個 Discord Bot!】03 Discord.js v14 音樂機器人 (新版)

相較於舊版,這次發布的機器人有一些新的改變:

  • 播放音樂更穩定,斷線後會自動重連
  • 可將指令新增至 DC 群中
  • 可以播放 Youtube 播放清單
  • 摒棄 join 指令,播放音樂後機器人自動加入語音

[注意]

雖然相較於之前的版本,播放音樂相對穩定,但仍會有短暫沒有音樂、過一段時間才恢復的問題。
目前實測播放一小時的音樂,中間斷過一次,持續 30 秒左右,1 分鐘內自動恢復。但這仍取決於伺服器狀態及機器網路狀態。

前言

今天我們來寫一個具有以下功能的音樂機器人:

  • 播放 YouTube 歌曲/播放清單
  • 暫停/恢復播放
  • 跳過歌曲
  • 歌曲隊列

如果還不知道怎麼建立機器人,可以參考我之前寫的這篇文章:【用 JS 寫一個 Discord Bot!】01 建立機器人

安裝套件

必備:

  • Node.js 版本 v16.9.0 以上

  • 編輯器 (推薦 Visual Studio Code)

  • Discord.js v14.9.0

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() {
/**
* 下面的物件都是以 Discord guild id 當 key,例如:
* this.isPlaying = {
* 724145832802385970: false
* }
*/

/**
* 機器人是否正在播放音樂
* this.isPlaying = {
* 724145832802385970: false
* }
*/
this.isPlaying = {};

/**
* 等待播放的音樂隊列,例如:
* this.queue = {
* 724145832802385970: [{
* name: 'G.E.M.鄧紫棋【好想好想你 Missing You】Official Music Video',
* url: 'https://www.youtube.com/watch?v=P6QXo88IG2c&ab_channel=GEM%E9%84%A7%E7%B4%AB%E6%A3%8B'
* }]
* }
*/
this.queue = {};

// https://discord.js.org/#/docs/voice/main/class/VoiceConnection
this.connection = {};

// https://discord.js.org/#/docs/voice/main/class/AudioPlayer
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) {

// 語音群的 ID
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;

// 取得前 10 筆播放清單的列表歌曲
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) {

// 伺服器 ID
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;

// 移除 queue 中目前播放的歌曲
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('歌曲發生錯誤...');

// 移除 queue 中目前播放的歌曲
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 = '';

// 字串處理,將 Object 組成字串
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];

// 改變 isPlaying 狀態為 false
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() // 建立指令的 function
.setName('play') // 指令名稱
.setDescription('播放音樂') // 指令描述
.addStringOption(option => // 增加 option
option
.setName('url') // option name
.setDescription('提供 Youtube url 網址') // option 描述
.setRequired(true) // option 是否為必要 (如果為必要,輸入指令時會自動帶入)
),
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"); // 引入 Discord.js 模組
const { token } = require("./config.json"); // 從 config.json 讀取 token

// 創建一個 Discord.js client
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
});

// 創建一個 Collection 來存放指令
client.commands = new Collection();

// 用來存放 commands
const commands = [];

// 讀取 commands 資料夾下的 js 檔案
const commandsPath = path.join(__dirname, "commands");
const commandFiles = fs.readdirSync(commandsPath).filter((file) => file.endsWith(".js"));

// 將指令加入 Collection
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = require(filePath);

// 在 Collection 中以指令名稱作為 key,指令模組作為 value 加入
if ("data" in command && "execute" in command) {
client.commands.set(command.data.name, command);
} else {
console.log(`[警告] ${filePath} 中的指令缺少必要的 "data" 或 "execute" 屬性。`);
}

// 存進 commands array
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 就緒時顯示訊息
client.once(Events.ClientReady, async (client) => {
console.log(`已就緒!已登入帳號:${client.user.tag}`);
await registerCommands(client);
});

// 使用 token 進行登入
client.login(token);

運行機器人

1
node discord.js

運行後會看到機器人已登入的訊息,然後就可以看到指令出現在指令區了。

機器人功能如下:

  • 【播放音樂】/play url:音樂網址
  • 【暫停播放】/pause
  • 【恢復播放】/resume
  • 【跳過這首歌曲】/skip
  • 【查看歌曲隊列】/queue
  • 【刪除播放清單中的所有歌曲】/deleteplaylist id:id
  • 【查看機器人指令】/command
  • 【讓機器人離開語音頻道(會清空歌曲隊列)】/leave

本來想把程式碼切開在文中講解,但是發現這樣寫起來會篇幅太長而且雜亂,所以就乾脆把註解寫在 code 裡面。

本次音樂機器人的 Github Repository,可以自行 clone 下來研究或修改。

特別感謝

本文由 ChatGPT 協助產出,感恩 ❤️


【用 JS 寫一個 Discord Bot!】系列文章

文章結束囉~

如果我的文章對你有幫助,可以幫我拍個手,感謝支持!