Tạo một Discord Bot phát nhạc đơn giản bằng Node.js, Typescript và deploy lên Heroku
Bài viết này đã outdated, nếu muốn tạo Discord music bot mới, vui lòng xem bài viết này
Mở đầu
Chắc hẳn chúng ta đã nghe đến Discord. Đây là một nền tảng giao tiếp được thiết kế hướng tới game thủ, giúp ta có thể gửi tin nhắn, ảnh, file, hoặc trò chuyện qua kênh thoại.
Ở trong bài viết này, mình sẽ hướng dẫn các bạn tạo một con bot có thể phát nhạc trong kênh thoại với các chức năng play, pause, resume, skip, stop, clear, nowplaying.
Nội dung
Tạo bot Discord
Đầu tiên, chúng ta hãy truy cập https://discord.com/developers/applications để tạo một ứng dụng cho Discord.
Sau khi tạo ứng dụng xong, vào Bot, click "Add Bot" để tạo bot
Click Copy
để copy token của bot. Token này giúp bot đăng nhập với Discord.
Tạo server bot Node.js
Mình sử dụng các packages sau:
discord.js
: Đây là package của Discord để bot của bạn có thể login và tương tác với người dùng được.ytdl-core
: Dùng để get thông tin video và stream video trên Youtube.ytpl
: Dùng để get thông tin và danh sách video của 1 playlist trên Youtube.ytsr
: Dùng để tìm kiếm 1 video trên Youtube bằng từ khoá.ffmpeg-static
: Hoạt động cùng với ytdl-core để stream audio.dotenv
: Dùng để sử dụng với file .env.nodemon
: Giúp chúng ta thuận tiện hơn trong quá trình dev.
Cài đặt các packages cần thiết:
yarn add discord.js ytdl-core ytpl ytsr ffmpeg-static dotenv
hoặc
npm i add discord.js ytdl-core ytpl ytsr ffmpeg-static dotenv --save
yarn add @types/node @types/ws ts-node nodemon typescript -D
hoặc
npm i @types/node @types/ws ts-node nodemon typescript --save-dev
Tại thời điểm viết bài, phiên bản LTS mới nhất của Node.js là 14.17.0. Nhưng phiên bản này có 1 vài trục trặc với việc pause/resume của ytdl-core
nên mình sẽ sử dụng bản 14.15.4. Thêm dòng sau vào file pagekage.json
.
"engines": {
"node": "14.15.4"
},
Tạo file tsconfig.json với nội dung sau:
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"target": "es6",
"noImplicitAny": true,
"moduleResolution": "node",
"sourceMap": false,
"outDir": "dist",
"baseUrl": ".",
"paths": {
"*": [
"node_modules/*"
]
},
},
"include": [
"src/**/*"
]
}
Tạo file nodemon.json
với nội dung sau:
{
"watch": ["src"],
"ext": "ts,json",
"ignore": ["src/**/*.spec.ts"],
"exec": "ts-node ./src/index.ts"
}
Thêm đoạn sau vào package.json
.
"main": "dist/index.js",
"scripts": {
"dev": "nodemon",
"build": "tsc",
"start": "node dist/index.js",
},
Tạo file .env
ở root folder thêm đoạn sau vào file:
TOKEN = <Token mà bạn đã copy ở trên>
Tạo thư mục src và thêm file index.ts:
import { config } from "dotenv";
config();
import { Client } from "discord.js";
const client = new Client();
const token = process.env.TOKEN;
const prefix = "!";
// Đây là tiền tố trước mỗi lệnh mà ta ra hiệu cho bot từ khung chat.
// Lệnh có dạng như sau "!play Nhạc Đen Vâu", "!pause",...
client.on("message", (message) => {
const args = message.content.substring(prefix.length).split(" ");
const content = message.content.substring(prefix.length + args[0].length);
if (message.content[0] === "!") {
switch (args[0]) {
// Tại đây sẽ đặt các case mà bot cần thực hiện như play, pause, resume,....
}
}
});
client.on("ready", () => {
console.log("🏃♀️ Misabot is online! 💨");
});
client.once("reconnecting", () => {
console.log("🔗 Reconnecting!");
});
client.once("disconnect", () => {
console.log("🛑 Disconnect!");
});
client.login(token);
Tạo folder constant
trong src
chứa file regex.ts. Trong file này có các regex mà ta dùng để check url video hoặc playlist:
export const youtubeVideoRegex = new RegExp(
/(?:youtube\.com\/(?:[^\\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\\/\s]{11})/
);
export const youtubePlaylistRegex = new RegExp(
/(?!.*\?.*\bv=)https:\/\/www\.youtube\.com\/.*\?.*\blist=.*/
);
Tạo folder data
trong src
có file server.ts
như sau:
import { StreamDispatcher } from "discord.js";
import { Resource } from "../services/youtube";
export interface Song {
requester: string;
resource: Resource;
}
interface Server {
[key: string]: {
playing?: {
song: Song;
startedAt: number;
};
queue: Song[];
dispatcher?: StreamDispatcher;
};
}
export const servers: Server = {};
// Mỗi máy chủ ta tạo trên Discord có 1 Object Server riêng có key là id của máy chủ đó.
// Object này sẽ lưu danh sách các bài hát đang chờ được phát "queue".
// Bài hát đang phát "playing".
// Dispatcher quản lí việc stream từ server bot tới Discord.
Tạo foder services trong src có file youtube.ts. Đây là nơi ta tương tác với api Youtube:
import ytsr from "ytsr";
import ytdl from "ytdl-core";
import ytpl from "ytpl";
import { youtubeVideoRegex } from "../constant/regex";
// Tìm video bằng từ khoá và trả về id video nếu tìm thấy hoặc trả về tin nhắn lỗi.
const searchVideo = (keyword: string) => {
try {
return ytsr(keyword, { pages: 1 })
.then((result) => {
const filteredRes = result.items.filter((e) => e.type === "video");
if (filteredRes.length === 0) throw "🔎 Can't find video!";
const item = filteredRes[0] as {
id: string;
};
return item.id;
})
.catch((error) => {
throw error;
});
} catch (e) {
throw "❌ Invalid params";
}
};
// Cấu trúc của 1 video mà ta sẽ lưu vào server
export interface Resource {
title: string;
length: number;
author: string;
thumbnail: string;
url: string;
}
// Lấy thông tin của 1 video bằng nội dung truyền vào. URL hoặc từ khoá
export const getVideoDetails = async (content: string): Promise<Resource> => {
const parsedContent = content.match(youtubeVideoRegex);
let id = "";
if (!parsedContent) {
id = await searchVideo(content);
} else {
id = parsedContent[1];
}
const url = `https://www.youtube.com/watch?v=${id}`;
return ytdl
.getInfo(url)
.then((result) => {
return {
title: result.videoDetails.title,
length: parseInt(result.videoDetails.lengthSeconds, 10),
author: result.videoDetails.author.name,
thumbnail:
result.videoDetails.thumbnails[
result.videoDetails.thumbnails.length - 1
].url,
url,
};
})
.catch(() => {
throw "❌ Error";
});
};
interface Playlist {
title: string;
thumbnail: string;
author: string;
resources: Resource[];
}
// Lấy danh sách video và thông tin 1 playlist
export const getPlaylist = async (url: string): Promise<Playlist> => {
try {
const id = url.split("?")[1].split("=")[1];
const playlist = await ytpl(id);
const resources: Resource[] = [];
playlist.items.forEach((item) => {
resources.push({
title: item.title,
thumbnail: item.bestThumbnail.url,
author: item.author.name,
url: item.shortUrl,
length: item.durationSec,
});
});
return {
title: playlist.title,
thumbnail: playlist.bestThumbnail.url,
author: playlist.author.name,
resources,
};
} catch (e) {
throw "❌ Invalid playlist!";
}
};
Tạo folder utils
trong src
chứa file time.ts
:
// Fomat thời gian video từ giây sang dạng mm:ss
// Ví dụ. 70s -> 01:10
export const formatTimeRange = (timeRange: number): string => {
const mins = Math.floor(timeRange / 60);
const seconds = timeRange - hours * 60;
return `${mins < 10 ? "0" + mins : mins}:${seconds < 10 ? "0" + seconds : seconds}`;
};
Tạo folder actions trong src . Tại đây ta sẽ tạo các actions để xử lý các tác vụ như play, pause,... Ta tạo các file với nội dung lần lượt như sau:
- actions/play.ts
import { Message, VoiceConnection, MessageEmbed } from "discord.js";
import ytdl from "ytdl-core";
import { servers } from "../data/server";
import { getVideoDetails, getPlaylist } from "../services/youtube";
import { formatTimeRange } from "../utils/time";
import { youtubePlaylistRegex } from "../constant/regex";
// Đảm nhiệm stream nhạc và chuyển bài khi kết thúc
const play = (connection: VoiceConnection, message: Message) => {
const server = servers[message.guild.id];
const song = server.queue[0];
server.playing = {
song,
startedAt: new Date().getTime(),
};
server.dispatcher = connection.play(
ytdl(song.resource.url, { filter: "audioonly" })
);
server.queue.shift();
// Phát hiện việc bài hát kết thúc
server.dispatcher.on("finish", () => {
if (server.queue[0]) play(connection, message);
else {
server.playing = null;
server.queue = [];
connection.disconnect();
}
});
};
export default {
name: "play",
execute: (message: Message, content: string): void => {
if (!content)
message.channel.send(
"❌ You need to provide an Youtube URL or name of video\n\n✅ Ex: !play Shape of You"
);
else if (!message.member.voice.channel)
message.channel.send("❌ You must be in a voice channel!");
else {
if (!servers[message.guild.id])
servers[message.guild.id] = {
queue: [],
};
const server = servers[message.guild.id];
const paths = content.match(youtubePlaylistRegex);
if (paths) {
getPlaylist(paths[0])
.then((result) => {
const resources = result.resources;
resources.forEach((resource) => {
server.queue.push({
requester: message.member.displayName,
resource: resource,
});
});
const messageEmbed = new MessageEmbed()
.setColor("#0099ff")
.setTitle(result.title)
.setAuthor(
`Add playlist to order by ${message.member.displayName}`
)
.setThumbnail(result.thumbnail)
.addFields(
{ name: "Author", value: result.author, inline: true },
{
name: "Video count",
value: resources.length,
inline: true,
}
);
message.channel.send(messageEmbed).then(() => {
if (!message.guild.voice)
message.member.voice.channel.join().then((connection) => {
play(connection, message);
});
else if (!message.guild.voice.connection) {
message.member.voice.channel.join().then((connection) => {
play(connection, message);
});
}
});
})
.catch((e) => {
message.channel.send(JSON.stringify(e));
});
} else
getVideoDetails(content)
.then((result) => {
server.queue.push({
requester: message.member.displayName,
resource: result,
});
const messageEmbed = new MessageEmbed()
.setColor("#0099ff")
.setTitle(result.title)
.setAuthor(`Add to order by ${message.member.displayName}`)
.setThumbnail(result.thumbnail)
.addFields(
{ name: "Channel", value: result.author, inline: true },
{
name: "Length",
value: formatTimeRange(result.length),
inline: true,
}
)
.addField("Position in order", server.queue.length, true);
message.channel.send(messageEmbed).then(() => {
if (!message.guild.voice)
message.member.voice.channel.join().then((connection) => {
play(connection, message);
});
else if (!message.guild.voice.connection) {
message.member.voice.channel.join().then((connection) => {
play(connection, message);
});
}
});
})
.catch((e) => {
message.channel.send(JSON.stringify(e));
});
}
},
};
- actions/skip.ts
import { Message, MessageEmbed } from "discord.js";
import { formatTimeRange } from "../utils/time";
import { servers } from "../data/server";
export default {
name: "skip",
execute: (message: Message): void => {
const server = servers[message.guild.id];
if (server) {
if (server.dispatcher) {
if (server.queue.length === 0) {
server.dispatcher.end();
server.playing = null;
message.channel.send("❌ Nothing to skip!");
} else {
const song = server.queue[0];
const messageEmbed = new MessageEmbed()
.setColor("#0099ff")
.setTitle(song.resource.title)
.setAuthor(`Skipped by ${message.member.displayName}`)
.setThumbnail(song.resource.thumbnail)
.addFields(
{ name: "Channel", value: song.resource.author, inline: true },
{
name: "Length",
value: formatTimeRange(song.resource.length),
inline: true,
}
)
message.channel
.send(messageEmbed)
.then(() => server.dispatcher.end());
}
} else message.channel.send("❌ Nothing to skip!");
} else {
message.channel.send("❌ Nothing to skip!");
}
},
};
- actions/pause.ts
import { Message } from "discord.js";
import { servers } from "../data/server";
export default {
name: "pause",
execute: (message: Message): void => {
const server = servers[message.guild.id];
if (server) {
if (server.dispatcher && server.playing) {
message.channel.send("⏸ Paused").then(() => server.dispatcher.pause());
}
} else message.channel.send("❌ Nothing to pause!");
},
};
* actions/resume.ts
import { Message } from "discord.js";
import { servers } from "../data/server";
export default {
name: "resume",
execute: (message: Message): void => {
const server = servers[message.guild.id];
if (server) {
if (server.dispatcher && server.playing) {
server.dispatcher.resume();
message.channel.send("⏯ Resume");
} else message.channel.send("❌ Nothing to resume!");
} else message.channel.send("❌ Nothing to resume!");
},
};
- actions/nowplaying.ts
import { Message, MessageEmbed } from "discord.js";
import { formatTimeRange } from "../utils/time";
import { servers } from "../data/server";
export default {
name: ["nowplaying"],
execute: (message: Message): void => {
const server = servers[message.guild.id];
if (server) {
if (!server.playing) {
message.channel.send("❌ Nothing is played now!");
} else {
const song = server.playing.song;
const messageEmbed = new MessageEmbed()
.setColor("#0099ff")
.setTitle(song.resource.title)
.setAuthor(`Playing 🎵 `)
.setThumbnail(song.resource.thumbnail)
.addFields(
{ name: "Channel", value: song.resource.author, inline: true },
{
name: "Length",
value: formatTimeRange(song.resource.length),
inline: true,
}
)
message.channel.send(messageEmbed);
}
} else {
message.channel.send("❌ Nothing is played now!");
}
},
};
* actions/stop.ts
// Dừng phát nhạc và rời khỏi kênh thoại
import { Message } from "discord.js";
import { servers } from "../data/server";
export default {
name: "stop",
execute: (message: Message): void => {
const server = servers[message.guild.id];
if (message.guild.voice) {
if (server) {
if (server.dispatcher) {
for (let i = server.queue.length - 1; i >= 0; i--) {
server.queue.splice(i, 1);
}
server.playing = null;
server.dispatcher.end();
message.channel.send("Ending and leave voice channel!");
}
} else message.channel.send("❌ Nothing to stop!");
if (message.guild.voice.connection)
message.guild.voice.connection.disconnect();
} else message.channel.send("❌ Nothing to stop!");
},
};
* actions/clear.ts
// Xoá toàn bộ list video đang đợi phát
import { Message } from "discord.js";
import { servers } from "../data/server";
export default {
name: "clear",
execute: (message: Message): void => {
const server = servers[message.guild.id];
if (server) {
server.queue = [];
message.channel.send("🧹 Cleaned ordered list!");
} else {
message.channel.send("❌ Nothing to clear!");
}
},
};
Thêm các actions vừa tạo vào file index.ts
import { config } from "dotenv";
config();
import { Client } from "discord.js";
import play from "./actions/play";
import skip from "./actions/skip";
import nowplaying from "./actions/nowplaying";
import pause from "./actions/pause";
import resume from "./actions/resume";
import stop from "./actions/stop";
import clear from "./actions/clear";
const client = new Client();
const token = process.env.TOKEN;
const prefix = "!";
// Đây là tiền tố trước mỗi lệnh mà ta ra hiệu cho bot từ khung chat.
// Lệnh có dạng như sau "!play Nhạc Đen Vâu", "!pause",...
client.on("message", (message) => {
const args = message.content.substring(prefix.length).split(" ");
const content = message.content.substring(prefix.length + args[0].length);
if (message.content[0] === "!") {
switch (args[0]) {
// Tại đây sẽ đặt các case mà bot cần thực hiện như play, pause, resume,....
case play.name:
play.execute(message, content);
break;
case skip.name:
skip.execute(message);
break;
case nowplaying.name.toString():
nowplaying.execute(message);
break;
case pause.name:
pause.execute(message);
break;
case resume.name:
resume.execute(message);
break;
case stop.name:
stop.execute(message);
break;
case clear.name:
clear.execute(message);
break;
}
}
});
client.on("ready", () => {
console.log("🏃♀️ Misabot is online! 💨");
});
client.once("reconnecting", () => {
console.log("🔗 Reconnecting!");
});
client.once("disconnect", () => {
console.log("🛑 Disconnect!");
});
client.login(token);
Đến đây, bot của chúng ta đã có thể chạy rồi đó 😅.
Chạy yarn dev hoặc npm run dev để start dev server.
Truy cập lại vào app bạn tạo trên Discord tại https://discord.com/developers/applications. Click OAuth2
.
Tick vào bot và họn các quyền như hình dưới.
Click Copy
để copy link mời bot vào máy chủ. Mời bot và máy chủ và dùng thử thôi 😉.
Deploy lên Heroku
Tạo 1 web đơn giản chứa đường dẫn mời bot đến máy chủ và gắn vào bot bằng express (optional). Cái này mình không hướng dẫ ở đây. Bạn nào thích thì có thể làm thêm.
Install 1 vài package sau.
yarn add express heroku-awake
hoặc
npm i express heroku-awake --save
heroku-awake
giúp server không bị sleep.
yarn add @types/express -D
hoặc
npm i @types/express --save-dev
Sửa lại file index.ts như sau
import express from "express";
import herokuAwake from "heroku-awake";
import { Client } from "discord.js";
import play from "./actions/play";
import skip from "./actions/skip";
import nowplaying from "./actions/nowplaying";
import pause from "./actions/pause";
import resume from "./actions/resume";
import stop from "./actions/stop";
import clear from "./actions/clear";
const port = process.env.PORT || 3000;
const server = express();
const url = ""; // Đường dẫn của app bạn trên Heroku
const bot = (): void => {
const client = new Client();
const token = process.env.TOKEN;
client.on("message", (message) => {
const args = message.content.substring(prefix.length).split(" ");
const content = message.content.substring(prefix.length + args[0].length);
if (message.content[0] === "!") {
switch (args[0]) {
case play.name:
play.execute(message, content);
break;
case skip.name:
skip.execute(message);
break;
case nowplaying.name.toString():
nowplaying.execute(message);
break;
case pause.name:
pause.execute(message);
break;
case resume.name:
resume.execute(message);
break;
case stop.name:
stop.execute(message);
break;
case clear.name:
clear.execute(message);
break;
// More short command
case "np":
nowplaying.execute(message);
break;
case "fs":
skip.execute(message);
break;
}
}
});
client.on("ready", () => {
console.log("🏃♀️ Misabot is online! 💨");
});
client.once("reconnecting", () => {
console.log("🔗 Reconnecting!");
});
client.once("disconnect", () => {
console.log("🛑 Disconnect!");
});
client.login(token);
};
server.disable('x-powered-by');
server.listen(port, () => {
bot();
herokuAwake(url);
console.log(`🚀 Server is running on port ${port} ✨`);
});
Truy cập https://devcenter.heroku.com/articles/heroku-cli để cài đặt heroku-cli
nếu bạn chưa có.
Truy cập tiếp https://dashboard.heroku.com/apps để tạo ứng dụng mới.
Click tab Settings và thêm biến môi trường của bạn vào đây
Chạy lần lượt các câu lệnh sau để deploy ứng dụng của bạn.
$ heroku login
$ cd my-project/
$ git init
$ heroku git:remote -a <tên ứng dụng của bạn>
$ git add .
$ git commit -am "make it better"
$ git push heroku master
Tổng kết
Trên đây là cách tạo 1 bot Discord để phát nhạc trong Discord với các chức năng:
- play: phát 1 bài nhạc theo tên hoặc url Youtube, thêm nhạc vào danh sách chờ bằng url playlist.
- pause, resume: Dừng và tiếp tục.
- clear: Xoá danh sách phát đang chờ.
- stop: Dừng phát và rời khỏi kênh thoại.
- skip: Bỏ qua bài hát hiện tại
- nowplaying: Lấy thông tin bài hát đang phát.
Trong code có gì sơ suất mong mọi người thông cảm.
Tham khảo
Github repository
Demo on Heroku
Note: Tài nguyên trên Heroku khá ít và server đặt tại châu Âu nên bot join nhiều server hoặc internet của các bạn kém thì bot khá lag.😅
All rights reserved