Tạo Discord bot phát nhạc bằng Typescript và Discord.js v13

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

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.tssoundcloud.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 expressheroku-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. TOKENAPP_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

Github repository

Nguồn: viblo.asia

Bài viết liên quan

WebP là gì? Hướng dẫn cách để chuyển hình ảnh jpg, png qua webp

WebP là gì? WebP là một định dạng ảnh hiện đại, được phát triển bởi Google

Điểm khác biệt giữa IPv4 và IPv6 là gì?

IPv4 và IPv6 là hai phiên bản của hệ thống địa chỉ Giao thức Internet (IP). IP l

Check nameservers của tên miền xem website trỏ đúng chưa

Tìm hiểu cách check nameservers của tên miền để xác định tên miền đó đang dùn

Mình đang dùng Google Domains để check tên miền hàng ngày

Từ khi thông báo dịch vụ Google Domains bỏ mác Beta, mình mới để ý và bắt đầ