Mở đầu
Discord đã trở thành một dịch vụ liên lạc phổ biến với chúng ta, nhất là đối với giới gaming, và trong hoàn cảnh dịch bệnh này thì nó lại càng trở nên phổ biến hơn. Trước nay, có nhiều Bot hỗ trợ nghe nhạc chung trên Discord như Groovy, Rythm, etc, nhưng gần đây, Youtube bắt đầu thắt chặt việc kiểm soát việc sử dụng nội dung hơn, nên các bot này lần lượt dừng hoạt động. Vậy tại sao chúng ta không tự tạo một Bot riêng và sử dụng nhỉ 😁
Đợt trước mình đã có bài viết hướng dẫn tạo một bot như vậy, nhưng đó là dựa trên Discord.js
v12, hiện nay đã là v13, cấu trúc, cách hoạt động đã thay đổi nhiều, nên hôm nay mình sẽ viết thêm một bài cho Discord.js
v13.
Tổng quan
Bot chúng ta tạo hôm nay sẽ sử dụng slash command
để gửi lệnh (giống Groovy
).
Chức năng của bot bao gồm:
Số thứ tự | Lệnh | Mô tả |
---|---|---|
1 | play | Tìm và thêm một bài hát trên Youtube vào hàng đợi bằng từ khoá hoặc url, hoặc thêm 1 playlist vào hàng đợi bằng url |
2 | soundcloud | Tìm và thêm một bài hát trên Soundcloud vào hàng đợi bằng từ khoá hoặc url, hoặc thêm 1 playlist/album vào hàng đợi bằng url |
3 | pause | Dừng chơi nhạc |
4 | resume | Tiếp tục chơi nhạc sau khi bị dừng |
5 | skip | Chuyển sang bài tiếp theo trong hàng đợi nêu có |
6 | leave | Dừng chơi nhạc và rời khỏi kệnh thoại |
7 | nowplaying | Lấy thông tin về bài hát đang được phát |
8 | queue | Xem danh sách các bài hát trong hàng đợi |
9 | jump | Phát ngay một bài hát trong hàng đợi bằng cách truyền vào vị trí của bài hát đó trong hàng đợi |
10 | remove | Xoá một bài hát trong hàng đợi bằng cách truyền vào vị trí của bài hát đó trong hàng đợi |
11 | ping | Trả về độ trễ tới server |
12 | help | Xem danh sách các lệnh của bot |
Nội dung
Đăng ký bot và cấp các quyền cần thiết
Đầu tiên, bạn hãy truy cập Discord Developer Portal và chọn New Application
để đăng ký một ứng dụng mới.
Sau khi tạo xong ứng dụng, bạn chuyển sang tab OAuth2
và chọn các quyền như hình dưới.
Sau khi bạn chọn các quyền cần thiết xong, trên màn hình sẽ hiện ra một đoạn liên kết, đây chính là liên kết mà chúng ta sử dụng để mời bot vào server Discord, bạn truy cập và mời luôn bot vào server nhé 😉
Tiếp theo bạn chuyển sang tab `Bot` và chọn `Copy` để copy token, token này sẽ sử dụng để đăng nhập bot, các bạn lưu lại nhé.
Tạo bot server
Yêu cầu
Ở đây mình sử dụng Discord.js
v13, phiên bản này yêu cầu bạn phải sử dụng Node.js
16.6.0 hoặc mới hơn. Ở đây mình sử dụng yarn
, bạn nào sử dụng npm
thì tự chuyển đổi nhá 😄
Thực hành
Cấu trúc thư mục
root/
├─ src/
│ ├─ commands/
│ │ ├─ collections/
│ │ ├─ messages/
│ │ ├─ schema/
│ ├─ constants/
│ ├─ models/
│ ├─ services/
│ ├─ types/
│ ├─ utils/
Đầu tiên bạn tạo một thư mục và chạy yarn init
và nhập các thuộc tính tương ứng như name, author,… tương ứng nhé.
Các package mình sử dụng bao gồm
Tên | Chức năng |
---|---|
discord.js | Thư viện để kết nối với Discord |
@discordjs/opus | Để sử dụng codec Opus |
@discordjs/voice | Sử dụng voice API của Discord |
reconlx | Tạo pagination embed message |
dotenv | Sử dụng các biến môi trường |
ffmpeg-static | Sử dụng ffmpeg trên Node.js |
libsodium-wrappers | Package mã hoá yêu cầu của @discordjs/voice |
moment | Format thời gian |
scdl-core | Sử dụng API và stream audio SoundCloud |
ytdl-core | Stream video Youtube |
ytpl | Lấy thông tin của playlist trên Youtube |
ytsr | Sử dụng API tìm kiếm của Youtube |
module-alias | Sử dụng absolute paths trong production |
Bên cạnh đó mình còn sử dụng nodemon
, ts-node
để thuận tiện hơn khi code và các package @types
của chúng.
Cài đặt các packages:
yarnadd discord.js @discordjs/opus @discordjs/voice dotenv ffmpeg-static libsodium-wrappers moment scdl-core ytdl-core ytpl ytsr module-alias reconlx
yarnadd @types/module-alias @types/node nodemon ts-node tsconfig-paths typescript -D
Đầu tiên mình tạo một số file config cơ bản.
Tạo file tsconfig.json
ở thư mục gốc với nội dung như sau:
{"ts-node":{"require":["tsconfig-paths/register"]},"compilerOptions":{"module":"commonjs","esModuleInterop":true,"target":"es6","noImplicitAny":true,"moduleResolution":"node","strict":true,"sourceMap":false,"outDir":"dist","baseUrl":"src","paths":{"@/*":["./*"],},},"include":["src/**/*"],"exclude":["node_modules"]}
Ở đây, để tiện cho việc import, mình setup absolute paths cho project với thư mục gốc là src
và có đường dẫn gốc là @/*
, và đăng ký này cho ts-node
.
Để sử dụng absolute paths trong production, bạn thêm đoạn mã sau vào file pagekage.json
:
"_moduleAliases":{"@":"dist"},
Tạo file nodemon.json
:
{"watch":["src"],"ext":"ts,json","ignore":["src/**/*.test.ts"],"exec":"ts-node --project tsconfig.json src/index.ts"}
Tạo file .env
:
TOKEN = bot_token # Token bạn đã copy ở trên
Thêm scripts
vào package.json
:
"scripts":{"start":"NODE_ENV=production node dist/index.js","build":"rm -rf dist && tsc","dev":"nodemon",},
Tạo file index.ts
trong thư mục src
:
import{ config }from"dotenv";config();if(process.env.NODE_ENV==="production"){require("module-alias/register");}import{ Client, Intents }from"discord.js";const client =newClient({
intents:[
Intents.FLAGS.GUILDS,
Intents.FLAGS.GUILD_MESSAGES,
Intents.FLAGS.GUILD_VOICE_STATES,
Intents.FLAGS.GUILD_INTEGRATIONS,],});
client.on("ready",()=>{
console.log(`> Bot is on ready`);});
client.login(process.env.TOKEN);
Bạn chạy thử yarn dev
, kết quả ta thu được như dưới và vào trong Discord server kiểm tra xem bot online chưa.
Triển khai slash commands
Tạo file index.ts
trong commands/schema
, đây là nơi chứa các commands của bot.
// Danh sách các slash command của botimport{ Constants, ApplicationCommandData }from'discord.js';exportconst schema: ApplicationCommandData[]=[{
name:'play',
description:'Plays a song or playlist on Youtube',
options:[{
name:'input',
type: Constants.ApplicationCommandOptionTypes.STRING,
description:'The url or keyword to search videos or playlist on Youtube',
required:true,},],},{
name:'soundcloud',
description:'Plays a song, album or playlist on SoundCloud',
options:[{
name:'input',
type: Constants.ApplicationCommandOptionTypes.STRING,
description:'The url or keyword to search videos or playlist on SoundCloud',
required:true,},],},{
name:'skip',
description:'Skip to the next song in the queue',},{
name:'queue',
description:'See the music queue',},{
name:'pause',
description:'Pauses the song that is currently playing',},{
name:'resume',
description:'Resume playback of the current song',},{
name:'leave',
description:'Leave the voice channel',},{
name:'nowplaying',
description:'See the song that is currently playing',},{
name:'jump',
description:'Jump to song in queue by position',
options:[{
name:'position',
type: Constants.ApplicationCommandOptionTypes.NUMBER,
description:'The position of song in queue',
required:true,},],},{
name:'remove',
description:'Remove song in queue by position',
options:[{
name:'position',
type: Constants.ApplicationCommandOptionTypes.NUMBER,
description:'The position of song in queue',
required:true,},],},{
name:'ping',
description:'See the ping to server',},{
name:'help',
description:'See the help for this bot',},];
Bây giờ ta cần deploy các slash command
này vào Discord server.
Tạo file deploy.ts
trong thư mục commands/collections
:
import{ Client }from'discord.js';import{ schema }from'../schema';exportconst deploy =(client: Client):void=>{
client.on('messageCreate',async(message)=>{if(!message.guild)return;// Chỉ cho phép deploy khi là người sở hữu serverif(!client.application?.owner)await client.application?.fetch();if(
message.content.toLowerCase()==='!deploy'&&
message.author.id === client.application?.owner?.id
){try{await message.guild.commands.set(schema);await message.reply('Deployed!');}catch(e){
message.reply('Fail to deploy!');}}});};
Tạo file index.ts
trong thư mục commands
:
import{ Client }from"discord.js";import{ deploy }from"./collections/deploy";exportconst bootstrap =(client: Client):void=>{deploy(client);};
Import hàm bootstrap
vào file src/index.ts
và sửa lại như sau:
//...
client.login(process.env.TOKEN).then(()=>{bootstrap(client);});//...
Bây giờ bạn vào Discord server, gửi !deploy
để triển khai các slash command vào server.
Tạo các services cho Youtube và SoundCloud
Tạo các types trong thư mục types
.
// Playlist.tsimport{ Song }from'./Song';exportinterfacePlaylist{
title: string;
thumbnail: string;
author: string;
songs: Song[];}
// Song.ts
export enum Platform {
YOUTUBE ='Youtube',
SOUND_CLOUD ='SoundCloud',}
export interface Song {
title: string;
length: number;
author: string;
thumbnail: string;
url: string;
platform: Platform;}
Tạo file regex.ts
trong thư mục constants
:
// Validate Youtube video URLexportconst youtubeVideoRegex =newRegExp(/(?:youtube.com/(?:[^\/]+/.+/|(?:v|e(?:mbed)?)/|.*[?&]v=)|youtu.be/)([^"&?\/s]{11})/,);// Validate Youtube playlist URLexportconst youtubePlaylistRegex =newRegExp(/(?!.*?.*bv=)https://www.youtube.com/.*?.*blist=.*/,);// Validate SoundCloud track URLexportconst soundCloudTrackRegex =newRegExp(/^https?://(soundcloud.com|snd.sc)/(.*)$/,);// Validate SoundCloud playlist/album URLexportconst soundCloudPlaylistRegex =newRegExp(/^https?://(soundcloud.com|snd.sc)/([^?])*/sets/(.*)$/,);
Tạo 2 file youtube.ts
và soundcloud.ts
trong thư mục services.
// youtube.tsimport{ youtubePlaylistRegex, youtubeVideoRegex }from'@/constants/regex';import{ Playlist }from'@/types/Playlist';import{ Platform, Song }from'@/types/Song';import ytdl from'ytdl-core';import ytpl from'ytpl';import ytsr,{ Video }from'ytsr';exportclassYoutubeService{publicstaticasyncgetVideoDetails(content: string): Promise<Song>{const parsedContent = content.match(youtubeVideoRegex);let id ='';if(!parsedContent){const result =awaitthis.searchVideo(content);if(!result)thrownewError();
id = result;}else{
id = parsedContent[1];}const videoUrl =this.generateVideoUrl(id);const result =await ytdl.getInfo(videoUrl);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: videoUrl,
platform: Platform.YOUTUBE,};}publicstaticasyncgetPlaylist(url: string): Promise<Playlist>{const id = url.split('?')[1].split('=')[1];const playlist =awaitytpl(id);const songs: Song[]=[];
playlist.items.forEach((item)=>{
songs.push({
title: item.title,
thumbnail: item.bestThumbnail.url ||'',
author: item.author.name,
url: item.shortUrl,
length: item.durationSec ||0,
platform: Platform.YOUTUBE,});});return{
title: playlist.title,
thumbnail: playlist.bestThumbnail.url ||'',
author: playlist.author.name,
songs,};}privatestaticasyncsearchVideo(keyword: string): Promise<string>{const result =awaitytsr(keyword,{ pages:1});const filteredRes = result.items.filter((item)=> item.type ==='video');if(filteredRes.length ===0)thrownewError();const item = filteredRes[0]as Video;return item.id;}publicstaticisPlaylist(url: string): string |null{const paths = url.match(youtubePlaylistRegex);if(paths)return paths[0];returnnull;}privatestaticgenerateVideoUrl(id: string){return`https://www.youtube.com/watch?v=${id}`;}}
// soundcloud.tsimport{
soundCloudPlaylistRegex,
soundCloudTrackRegex,}from'@/constants/regex';import{ Playlist }from'@/types/Playlist';import{ Platform, Song }from'@/types/Song';import{ SoundCloud }from'scdl-core';exportconst scdl =newSoundCloud();exportclassSoundCloudService{publicstaticasyncgetTrackDetails(content: string): Promise<Song>{let url ='';const paths = content.match(soundCloudTrackRegex);if(!paths){
url =awaitthis.searchTrack(content);}else{
url = paths[0];}if(!url)thrownewError();const track =await scdl.tracks.getTrack(url);if(track)return{
title: track.title,
length: track.duration /1000,
author: track.user.username,
thumbnail: track.artwork_url ? track.artwork_url :'',
url,
platform: Platform.SOUND_CLOUD,};thrownewError();}publicstaticasyncgetPlaylist(url: string): Promise<Playlist>{const playlist =await scdl.playlists.getPlaylist(url);if(!playlist)if(!url)thrownewError();const songs: Song[]=[];
playlist.tracks.forEach((track)=>{
songs.push({
title: track.title,
thumbnail: track.artwork_url ? track.artwork_url :'',
author: track.user.username,
url: track.permalink_url,
length: track.duration /1000,
platform: Platform.SOUND_CLOUD,});});return{
title:`SoundCloud set ${playlist.id}`,
thumbnail: playlist.artwork_url ? playlist.artwork_url :'',
author:`${playlist.user.first_name}${playlist.user.last_name}`,
songs,};}publicstaticisPlaylist(url: string): string |null{const paths = url.match(soundCloudPlaylistRegex);if(paths)return paths[0];returnnull;}privatestaticasyncsearchTrack(keyword: string): Promise<string>{const res =await scdl.search({
query: keyword,
filter:'tracks',});if(res.collection.length >0){return res.collection[0].permalink_url;}return'';}}
Mở file src/index.ts
và sửa lại như sau.
// ...import{ scdl }from'./services/soundcloud';// ...
client.login(process.env.TOKEN).then(async()=>{await scdl.connect();bootstrap(client);});
Triển khai các chức năng
Tạo file messages
trong thư mục constants
, đây là nơi chứa các message trả về cho người dùng.
// messages.ts// Các tin nhắn trả về cho người dùngexportdefault{
error:'❌ Error!',
cantFindAnyThing:"❌ Can't find anything!",
joinVoiceChannel:'🔊 Join a voice channel and try again!',
failToJoinVoiceChannel:'❌ Failed to join voice channel!',
failToPlay:'❌ Failed to play!',
addedToQueue:'Added to queue by',
author:'Author',
length:'Length',
type:'Type',
platform:'Platform',
noSongsInQueue:'👀 No songs in queue!',
skippedSong:'⏩ Skipped song!',
notPlaying:'🔇 Not playing!',
alreadyPaused:'⏸ Already paused!',
paused:'⏸ Paused!',
resumed:'▶ Resumed!',
alreadyPlaying:'▶ Already playing!',
leaved:'👋 Bye bye',
nothing:'🤷♂️ Nothing',
yourQueue:'🎶 Your queue',
invalidPosition:'❌ Invalid position!',
jumpedTo:'⏩ Jumped to',
removed:'🗑 Removed',
help:'💡 Help',
ping:'📶 Ping',};
Tạo file Server.ts
trong thư mục models
import{ scdl }from'@/services/soundcloud';import{ Platform, Song }from'@/types/Song';import{
AudioPlayer,
AudioPlayerStatus,
createAudioPlayer,
createAudioResource,
entersState,
VoiceConnection,
VoiceConnectionDisconnectReason,
VoiceConnectionStatus,}from'@discordjs/voice';import{ Snowflake }from'discord.js';import ytdl from'ytdl-core';exportinterfaceQueueItem{
song: Song;
requester:string;}exportclassServer{public guildId:string;public playing?: QueueItem;public queue: QueueItem[];publicreadonly voiceConnection: VoiceConnection;publicreadonly audioPlayer: AudioPlayer;private isReady =false;constructor(voiceConnection: VoiceConnection, guildId:string){this.voiceConnection = voiceConnection;this.audioPlayer =createAudioPlayer();this.queue =[];this.playing =undefined;this.guildId = guildId;this.voiceConnection.on('stateChange',async(_, newState)=>{if(newState.status === VoiceConnectionStatus.Disconnected){/*
Nếu websocket đã bị đóng với mã 4014 có 2 khả năng:
- Nếu nó có khả năng tự kết nối lại (có khả năng do chuyển kênh thoại), ta cho dảnh ra 5s để tìm hiểu và cho kết nối lại.
- Nếu bot bị kick khỏi kênh thoại, ta sẽ phá huỷ kết nối.
*/if(
newState.reason === VoiceConnectionDisconnectReason.WebSocketClose &&
newState.closeCode ===4014){try{awaitentersState(this.voiceConnection,
VoiceConnectionStatus.Connecting,5_000,);}catch(e){this.leave();}}elseif(this.voiceConnection.rejoinAttempts <5){this.voiceConnection.rejoin();}else{this.leave();}}elseif(newState.status === VoiceConnectionStatus.Destroyed){this.leave();}elseif(!this.isReady &&(newState.status === VoiceConnectionStatus.Connecting ||
newState.status === VoiceConnectionStatus.Signalling)){/*
Nếu tín hiệu kết nối ở trạng thái "Connecting" hoặc "Signalling", ta sẽ đợi 20s để kết nối sẵn sàng.
Sau 20s nếu kết nối không thành công, ta sẽ phá huỷ kết nối.
*/this.isReady =true;try{awaitentersState(this.voiceConnection,
VoiceConnectionStatus.Ready,20_000,);}catch{if(this.voiceConnection.state.status !==
VoiceConnectionStatus.Destroyed
)this.voiceConnection.destroy();}finally{this.isReady =false;}}});// Đây là sự kiện khi một bài hát kết thúc và ta chuyển sang bài mới.this.audioPlayer.on('stateChange',async(oldState, newState)=>{if(
newState.status === AudioPlayerStatus.Idle &&
oldState.status !== AudioPlayerStatus.Idle
){awaitthis.play();}});
voiceConnection.subscribe(this.audioPlayer);}publicasyncaddSongs(queueItems: QueueItem[]):Promise<void>{this.queue =this.queue.concat(queueItems);if(!this.playing){awaitthis.play();}}publicstop():void{this.playing =undefined;this.queue =[];this.audioPlayer.stop();}// Bot rời khỏi kênh thoại và xoá server hiện tại khỏi map.publicleave():void{if(this.voiceConnection.state.status !== VoiceConnectionStatus.Destroyed){this.voiceConnection.destroy();}this.stop();
servers.delete(this.guildId);}// Dừng bài hát đang phátpublicpause():void{this.audioPlayer.pause();}// Tiếp tục bài hát bị dừngpublicresume():void{this.audioPlayer.unpause();}// Chuyển tới bài hát trong queuepublicasyncjump(position:number):Promise<QueueItem>{const target =this.queue[position -1];this.queue =this.queue
.splice(0, position -1).concat(this.queue.splice(position,this.queue.length -1));this.queue.unshift(target);awaitthis.play();return target;}// Xoá bài hát trong queuepublicremove(position:number): QueueItem {returnthis.queue.splice(position -1,1)[0];}publicasyncplay():Promise<void>{try{// Phát bài hát đầu tiên trong queue nếu queue không rỗngif(this.queue.length >0){this.playing =this.queue.shift()as QueueItem;let stream:any;const highWaterMark =1024*1024*10;if(this.playing?.song.platform === Platform.YOUTUBE){
stream =ytdl(this.playing.song.url,{
highWaterMark,
filter:'audioonly',
quality:'highestaudio',});}else{
stream =await scdl.download(this.playing.song.url,{
highWaterMark,});}const audioResource =createAudioResource(stream);this.audioPlayer.play(audioResource);}else{// Dừng việc phát nhạc, gán thuộc tính playing = undefinedthis.playing =undefined;this.audioPlayer.stop();}}catch(e){// Nếu việc stream 1 bài hát có trục trặc gì, thì ta sẽ phát tiếp tục bài hát tiếp theothis.play();}}}// Map các server mà bot đang trong kênh thoạiexportconst servers =newMap<Snowflake, Server>();
Tạo file formatTime.ts
trong thư mục utils
, chứa hàm chuyển thời gian từ giây qua dạng mm:ss
hoặc hh:mm:ss
.
import moment from'moment';exportconst formatSeconds =(seconds: number):string=>{return moment
.utc(seconds *1000).format(seconds >3600?'HH:mm:ss':'mm:ss');};
Chức năng play
Tạo file playMessage.ts
trong folder commands/messages
, dùng để tạo embed message trả về khi dùng lệnh play
hoặc soundcloud
.
import messages from'@/constants/messages';import{ Platform }from'@/types/Song';import{ formatSeconds }from'@/utils/formatTime';import{ EmbedFieldData, MessageEmbed }from'discord.js';exportconst createPlayMessage =(payload:{
title: string;
url: string;
author: string;
thumbnail: string;
type:'Song'|'Playlist';
length: number;
platform: Platform;
requester: string;}):MessageEmbed=>{const author: EmbedFieldData ={
name: messages.author,
value: payload.author,
inline:true,};const length: EmbedFieldData ={
name: messages.length,
value:
payload.type ==='Playlist'? payload.length.toString():formatSeconds(payload.length),
inline:true,};const type: EmbedFieldData ={
name: messages.type,
value: payload.type,
inline:true,};returnnewMessageEmbed().setTitle(payload.title).setURL(payload.url).setAuthor(`${messages.addedToQueue}${payload.requester}`).setThumbnail(payload.thumbnail).addFields(author, length, type);};
Tạo file play.ts
trong thư mục commands/collections
.
import messages from'@/constants/messages';import{ QueueItem, Server, servers }from'@/models/Server';import{ YoutubeService }from'@/services/youtube';import{ Platform }from'@/types/Song';import{
entersState,
joinVoiceChannel,
VoiceConnectionStatus,}from'@discordjs/voice';import{ CommandInteraction, GuildMember }from'discord.js';import{ createPlayMessage }from'../messages/playMessage';exportconst play ={
name:'play',
execute:async(interaction: CommandInteraction): Promise<void>=>{await interaction.deferReply();let server = servers.get(interaction.guildId as string);if(!server){if(
interaction.member instanceofGuildMember&&
interaction.member.voice.channel
){const channel = interaction.member.voice.channel;
server =newServer(joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild.voiceAdapterCreator,}),
interaction.guildId as string,);
servers.set(interaction.guildId as string, server);}}if(!server){await interaction.followUp(messages.joinVoiceChannel);return;}// Make sure the connection is ready before processing the user's requesttry{awaitentersState(
server.voiceConnection,
VoiceConnectionStatus.Ready,20e3,);}catch(error){await interaction.followUp(messages.failToJoinVoiceChannel);return;}try{// eslint-disable-next-line @typescript-eslint/no-non-null-assertionconst input = interaction.options.get('input')!.value!as string;const playListId = YoutubeService.isPlaylist(input);if(playListId){const playlist =await YoutubeService.getPlaylist(playListId);const songs = playlist.songs.map((song)=>{const queueItem: QueueItem ={
song,
requester: interaction.member?.user.username as string,};return queueItem;});await server.addSongs(songs);
interaction.followUp({
embeds:[createPlayMessage({
title: playlist.title,
url: input,
author: playlist.author,
thumbnail: playlist.thumbnail,
type:'Playlist',
length: playlist.songs.length,
platform: Platform.YOUTUBE,
requester: interaction.member?.user.username as string,}),],});}else{const song =await YoutubeService.getVideoDetails(input);const queueItem: QueueItem ={
song,
requester: interaction.member?.user.username as string,};await server.addSongs([queueItem]);
interaction.followUp({
embeds:[createPlayMessage({
title: song.title,
url: song.url,
author: song.author,
thumbnail: song.thumbnail,
type:'Song',
length: song.length,
platform: Platform.YOUTUBE,
requester: interaction.member?.user.username as string,}),],});}}catch(error){await interaction.followUp(messages.failToPlay);}},};
Import file play.ts
vào file commands/index.ts
và sửa lại như sau.
import messages from'@/constants/messages';import{ Client }from'discord.js';import{ deploy }from'./collections/deploy';import{ play }from'./collections/play';exportconst bootstrap =(client: Client):void=>{deploy(client);
client.on('interactionCreate',async(interaction)=>{if(!interaction.isCommand()||!interaction.guildId)return;try{switch(interaction.commandName){case play.name:
play.execute(interaction);break;}}catch(e){
interaction.reply(messages.error);}});};
Test thử nào 😁
Tương tự với các chức năng còn lại.
Chức năng soundcloud
Tạo file soundcloud.ts
trong thư mục commands/collections
.
import messages from'@/constants/messages';import{ QueueItem, Server, servers }from'@/models/Server';import{ SoundCloudService }from'@/services/soundcloud';import{ Platform }from'@/types/Song';import{
entersState,
joinVoiceChannel,
VoiceConnectionStatus,}from'@discordjs/voice';import{ CommandInteraction, GuildMember }from'discord.js';import{ createPlayMessage }from'../messages/playMessage';exportconst soundcloud ={
name:'soundcloud',
execute:async(interaction: CommandInteraction): Promise<void>=>{await interaction.deferReply();let server = servers.get(interaction.guildId as string);if(!server){if(
interaction.member instanceofGuildMember&&
interaction.member.voice.channel
){const channel = interaction.member.voice.channel;
server =newServer(joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild.voiceAdapterCreator,}),
interaction.guildId as string,);
servers.set(interaction.guildId as string, server);}}if(!server){await interaction.followUp(messages.joinVoiceChannel);return;}// Make sure the connection is ready before processing the user's requesttry{awaitentersState(
server.voiceConnection,
VoiceConnectionStatus.Ready,20e3,);}catch(error){await interaction.followUp(messages.failToJoinVoiceChannel);return;}try{// eslint-disable-next-line @typescript-eslint/no-non-null-assertionconst input = interaction.options.get('input')!.value!as string;const playlistUrl = SoundCloudService.isPlaylist(input);if(playlistUrl){const playlist =await SoundCloudService.getPlaylist(playlistUrl);const songs = playlist.songs.map((song)=>{const queueItem: QueueItem ={
song,
requester: interaction.member?.user.username as string,};return queueItem;});await server.addSongs(songs);
interaction.followUp({
embeds:[createPlayMessage({
title: playlist.title,
url: playlistUrl,
author: playlist.author,
thumbnail: playlist.thumbnail,
type:'Playlist',
length: playlist.songs.length,
platform: Platform.SOUND_CLOUD,
requester: interaction.member?.user.username as string,}),],});}else{const song =await SoundCloudService.getTrackDetails(input);const queueItem: QueueItem ={
song,
requester: interaction.member?.user.username as string,};await server.addSongs([queueItem]);
interaction.followUp({
embeds:[createPlayMessage({
title: song.title,
url: song.url,
author: song.author,
thumbnail: song.thumbnail,
type:'Song',
length: song.length,
platform: Platform.SOUND_CLOUD,
requester: interaction.member?.user.username as string,}),],});}}catch(error){await interaction.followUp(messages.failToPlay);}},};
Chức năng pause
Tạo file pause.ts
trong commands/collections
import messages from'@/constants/messages';import{ servers }from'@/models/Server';import{ AudioPlayerStatus }from'@discordjs/voice';import{ CommandInteraction }from'discord.js';exportconst pause ={
name:'pause',
execute:async(interaction: CommandInteraction): Promise<void>=>{await interaction.deferReply();const server = servers.get(interaction.guildId as string);if(!server){await interaction.followUp(messages.joinVoiceChannel);return;}if(server.audioPlayer.state.status === AudioPlayerStatus.Playing){
server.audioPlayer.pause();await interaction.followUp(messages.paused);return;}await interaction.followUp(messages.notPlaying);},};
Chức năng resume
Tạo file resume.ts
trong commands/collections
import messages from'@/constants/messages';import{ servers }from'@/models/Server';import{ AudioPlayerStatus }from'@discordjs/voice';import{ CommandInteraction }from'discord.js';exportconst resume ={
name:'resume',
execute:async(interaction: CommandInteraction): Promise<void>=>{await interaction.deferReply();const server = servers.get(interaction.guildId as string);if(!server){await interaction.followUp(messages.joinVoiceChannel);return;}if(server.audioPlayer.state.status === AudioPlayerStatus.Paused){
server.audioPlayer.unpause();await interaction.followUp(messages.resumed);return;}await interaction.followUp(messages.notPlaying);},};
Chức năng skip
Tạo file skip.ts
trong commands/collections
import messages from'@/constants/messages';import{ servers }from'@/models/Server';import{ CommandInteraction }from'discord.js';exportconst skip ={
name:'skip',
execute:async(interaction: CommandInteraction): Promise<void>=>{await interaction.deferReply();const server = servers.get(interaction.guildId as string);if(!server){await interaction.followUp(messages.joinVoiceChannel);return;}if(server.queue.length ===0){await interaction.followUp(messages.noSongsInQueue);}await server.play();if(server.playing){await interaction.followUp(messages.skippedSong);}},};
Chức năng leave
Tạo file leave.ts
trong commands/collections
import messages from'@/constants/messages';import{ servers }from'@/models/Server';import{ CommandInteraction }from'discord.js';exportconst leave ={
name:'leave',
execute:async(interaction: CommandInteraction): Promise<void>=>{await interaction.deferReply();const server = servers.get(interaction.guildId as string);if(!server){await interaction.followUp(messages.joinVoiceChannel);return;}
server.leave();await interaction.followUp(messages.leaved);},};
Chức năng nowplaying
Tạo file nowPlayingMessage.ts
trong commands/messages
import messages from'@/constants/messages';import{ Platform }from'@/types/Song';import{ formatSeconds }from'@/utils/formatTime';import{ EmbedFieldData, MessageEmbed }from'discord.js';exportconst createNowPlayingMessage =(payload:{
title: string;
url: string;
author: string;
thumbnail: string;
length: number;
platform: Platform;
requester: string;}):MessageEmbed=>{const author: EmbedFieldData ={
name: messages.author,
value: payload.author,
inline:true,};const length: EmbedFieldData ={
name: messages.length,
value:formatSeconds(payload.length),
inline:true,};returnnewMessageEmbed().setTitle(payload.title).setURL(payload.url).setAuthor(`${messages.addedToQueue}${payload.requester}`).setThumbnail(payload.thumbnail).addFields(author, length);};
Tạo file nowplaying.ts
trong commands/collections
import messages from'@/constants/messages';import{ servers }from'@/models/Server';import{ CommandInteraction }from'discord.js';import{ createNowPlayingMessage }from'../messages/nowPlayingMessage';exportconst nowPlaying ={
name:'nowplaying',
execute:async(interaction: CommandInteraction): Promise<void>=>{await interaction.deferReply();const server = servers.get(interaction.guildId as string);if(!server){await interaction.followUp(messages.joinVoiceChannel);return;}if(!server.playing){await interaction.followUp(messages.notPlaying);return;}const playing = server.playing;const message =createNowPlayingMessage({
title: playing.song.title,
author: playing.song.author,
thumbnail: playing.song.thumbnail,
url: playing.song.url,
length: playing.song.length,
platform: playing.song.platform,
requester: playing.requester,});await interaction.followUp({
embeds:[message],});},};
Chức năng queue
Tạo file queueMessage.ts
trong commands/messages
import messages from'@/constants/messages';import{ QueueItem }from'@/models/Server';import{ formatSeconds }from'@/utils/formatTime';import{ MessageEmbed }from'discord.js';constMAX_SONGS_PER_PAGE=10;constgeneratePageMessage=(items: QueueItem[], start: number)=>{const embedMessage =newMessageEmbed({
title: messages.yourQueue,
fields: items.map((item, index)=>({
name:`${start +1+ index}. ${item.song.title} | ${item.song.author}`,
value:`${formatSeconds(item.song.length)} | ${item.song.platform} | ${
messages.addedToQueue
}${item.requester}`,})),});return embedMessage;};exportconst createQueueMessages =(queue: QueueItem[]): MessageEmbed[]=>{if(queue.length <MAX_SONGS_PER_PAGE){const embedMessage =generatePageMessage(queue,0);return[embedMessage];}else{const embedMessages =[];for(let i =0; i < queue.length; i +=MAX_SONGS_PER_PAGE){const items =generatePageMessage(
queue.slice(i, i +MAX_SONGS_PER_PAGE),
i,);
embedMessages.push(items);}return embedMessages;}};
Tạo file queue.ts
trong commands/collections
.
import messages from'@/constants/messages';import{ servers }from'@/models/Server';import{ CommandInteraction, TextChannel }from'discord.js';import{ pagination }from'reconlx';import{ createQueueMessages }from'../messages/queueMessage';exportconst queue ={
name:'queue',
execute:async(interaction: CommandInteraction): Promise<void>=>{await interaction.deferReply();const server = servers.get(interaction.guildId as string);if(!server){await interaction.followUp(messages.joinVoiceChannel);return;}if(server.queue.length ===0){await interaction.followUp(messages.nothing);return;}const embedMessages =createQueueMessages(server.queue);await interaction.editReply(messages.yourQueue);if(
interaction &&
interaction.channel &&
interaction.channel instanceofTextChannel){awaitpagination({
embeds: embedMessages,
channel: interaction.channel as TextChannel,
author: interaction.user,
fastSkip:true,});}},};
Chức năng jump
Tạo file jump.ts
trong commands/collections
.
import messages from'@/constants/messages';import{ servers }from'@/models/Server';import{ CommandInteraction }from'discord.js';exportconst jump ={
name:'jump',
execute:async(interaction: CommandInteraction): Promise<void>=>{await interaction.deferReply();const server = servers.get(interaction.guildId as string);if(!server){await interaction.followUp(messages.joinVoiceChannel);return;}// eslint-disable-next-line @typescript-eslint/no-non-null-assertionconst input = interaction.options.get('position')!.value!as number;if(input <1|| input > server.queue.length ||!Number.isInteger(input)){await interaction.followUp(messages.invalidPosition);return;}const target =await server.jump(input);await interaction.followUp(`${messages.jumpedTo}${target.song.title}`);},};
Chức năng remove
Tạo file remove.ts
trong commands/collections
.
import messages from'@/constants/messages';import{ servers }from'@/models/Server';import{ CommandInteraction }from'discord.js';exportconst remove ={
name:'remove',
execute:async(interaction: CommandInteraction): Promise<void>=>{await interaction.deferReply();const server = servers.get(interaction.guildId as string);if(!server){await interaction.followUp(messages.joinVoiceChannel);return;}// eslint-disable-next-line @typescript-eslint/no-non-null-assertionconst input = interaction.options.get('position')!.value!as number;if(input <1|| input > server.queue.length ||!Number.isInteger(input)){await interaction.followUp(messages.invalidPosition);return;}const target = server.remove(input);await interaction.followUp(`${messages.removed}${target.song.title}`);},};
Chức năng ping
Tạo file ping.ts
trong commands/collections
.
import messages from'@/constants/messages';import{ Client, CommandInteraction }from'discord.js';exportconst ping ={
name:'ping',
execute:async(
client: Client,
interaction: CommandInteraction,): Promise<void>=>{await interaction.deferReply();
interaction.followUp(`${messages.ping} - Latency: ${Math.round(
Date.now()- interaction.createdTimestamp,)}ms - API Latency: ${Math.round(client.ws.ping)}ms`,);},};
Chức năng help
Tạo file helpMessage.ts
trong commands/messages
.
import{ schema }from'@/commands/schema';import messages from'@/constants/messages';import{ BaseApplicationCommandOptionsData, MessageEmbed }from'discord.js';exportconst createHelpMessage =():MessageEmbed=>{const embedMessage =newMessageEmbed({
title: messages.help,
fields:(schema as BaseApplicationCommandOptionsData[]).map((item, index)=>({
name:`${index +1}. ${item.name}`,
value:`${item.description}`,}),),});return embedMessage;};
Tạo file help.ts
trong commands/collections
.
import{ CommandInteraction }from'discord.js';import{ createHelpMessage }from'../messages/helpMessage';exportconst help ={
name:'help',
execute:async(interaction: CommandInteraction): Promise<void>=>{await interaction.deferReply();await interaction.followUp({
embeds:[createHelpMessage()],});},};
Import tất cả các chức năng trong commands/collections
vào file commands/index.ts
và sửa lại như sau.
import messages from'@/constants/messages';import{ Client }from'discord.js';import{ deploy }from'./collections/deploy';import{ help }from'./collections/help';import{ jump }from'./collections/jump';import{ leave }from'./collections/leave';import{ nowPlaying }from'./collections/nowplaying';import{ pause }from'./collections/pause';import{ ping }from'./collections/ping';import{ play }from'./collections/play';import{ queue }from'./collections/queue';import{ remove }from'./collections/remove';import{ resume }from'./collections/resume';import{ skip }from'./collections/skip';import{ soundcloud }from'./collections/soundcloud';exportconst bootstrap =(client: Client):void=>{deploy(client);
client.on('interactionCreate',async(interaction)=>{if(!interaction.isCommand()||!interaction.guildId)return;try{switch(interaction.commandName){case play.name:
play.execute(interaction);break;case skip.name:
skip.execute(interaction);break;case soundcloud.name:
soundcloud.execute(interaction);break;case pause.name:
pause.execute(interaction);break;case resume.name:
resume.execute(interaction);break;case leave.name:
leave.execute(interaction);break;case nowPlaying.name:
nowPlaying.execute(interaction);break;case queue.name:
queue.execute(interaction);break;case jump.name:
jump.execute(interaction);break;case ping.name:
ping.execute(client, interaction);break;case remove.name:
remove.execute(interaction);break;case help.name:
help.execute(interaction);break;}}catch(e){
interaction.reply(messages.error);}});};
Demo
Deploy
Ở đây mình sẽ deploy lên heroku. Để bot không bị sleep, mình cần dùng package heroku-awake
để request mỗi 25 phút lên server.
Cài 2 packages express
và heroku-awake
yarnadd express heroku-awake
yarnadd @types/express -D
Sửa lại file src/index.ts
như sau:
import{ config }from'dotenv';config();if(process.env.NODE_ENV==='production'){require('module-alias/register');}import{ Client, Intents }from'discord.js';import{ bootstrap }from'./commands';import{ scdl }from'./services/soundcloud';import express,{ Request, Response }from'express';import herokuAwake from'heroku-awake';const client =newClient({
intents:[
Intents.FLAGS.GUILDS,
Intents.FLAGS.GUILD_MESSAGES,
Intents.FLAGS.GUILD_VOICE_STATES,
Intents.FLAGS.GUILD_INTEGRATIONS,],});
client.on('ready',()=>{
console.log(`> Bot is on ready`);});
client.login(process.env.TOKEN).then(async()=>{await scdl.connect();bootstrap(client);});const app =express();
app.get('/',(_req: Request, res: Response)=>{return res.send({
message:'Bot is running',});});
app.listen(process.env.PORT||3000,()=>{
console.log(`> Bot is on listening`);herokuAwake(process.env.APP_URL||'http://localhost:3000');});
Truy cập Heroku để tạo ứng dụng mới.
Chuyển qua tab Settings
chọn Reveal Config Vars
. Sau đó set 2 biến. TOKEN
và APP_URL
tương ứng của bạn.
Cài đặt heroku-cli
nếu bạn chưa có tại đây
Bật terminal
trong project của bạn.
Chạy lệnh dưới đây để login nếu bạn chưa làm.
heroku login
Chạy lệnh sau (nhớ thay đúng tên ứng dụng của bạn nhé 😂).
git init
heroku git:remote -a discordbot-ts gitadd.git commit -am "make it better"git push heroku master
- Vercel không host được bot do Vercel không hỗ trợ Websocket.
- Nếu các bạn muốn bot “nuột” hơn thì có thể host trên VPS (nếu có), hoặc các dịch vụ chuyên host bot (giá rất rẻ, chỉ từ $1/tháng). 😗
Tham khảo
Nguồn: viblo.asia