Added Music Bot Funationality 🎶

This commit is contained in:
Jyotirmoy Bandyopadhayaya 2023-05-14 20:06:38 +05:30
parent 0998cb71d8
commit bb67bcb79a
Signed by: bravo68web
GPG Key ID: F5671FD7BCB9917A
32 changed files with 1943 additions and 51 deletions

View File

@ -1,7 +1,7 @@
{
"defaultPrefix": "d!",
"defaultPrefix": "h!",
"disabledCommandCatagories": [],
"githubLink": "https://github.com/cktsun1031/DraconianBot",
"githubLink": "https://git.itsmebravo.dev/bravo68web/b68",
"name": "Hanako",
"ownerId": "457039372009865226",
"useShards": false

View File

@ -0,0 +1,8 @@
{
"MAX_PLAYLIST_SIZE": 10,
"PREFIX": "u&",
"PRUNING": false,
"LOCALE": "en",
"STAY_TIME": 30,
"DEFAULT_VOLUME": 100
}

View File

@ -0,0 +1,179 @@
{
"clip": {
"description": "Plays a clip sound",
"usagesReply": "Usage: {prefix}clip <name>",
"errorQueue": "Can't play clip because there is an active queue.",
"errorNotChannel": "You need to join a voice channel first!"
},
"clips": {
"description": "List all clips"
},
"help": {
"description": "Display all commands and descriptions",
"embedTitle": "{botname} Help",
"embedDescription": "List of all commands"
},
"invite": {
"description": "Send bot invite link"
},
"loop": {
"description": "Toggle music loop",
"errorNotQueue": "There is nothing playing.",
"result": "Loop is now {loop}"
},
"lyrics": {
"description": "Get lyrics for the currently playing song",
"errorNotQueue": "There is nothing playing.",
"lyricsNotFound": "No lyrics found for {title}.",
"embedTitle": "{title} - Lyrics"
},
"move": {
"description": "Move songs around in the queue",
"errorNotQueue": "There is no queue.",
"usagesReply": "Usage: {prefix}move <Queue Number>",
"result": "<@{author}> 🚚 moved **{title}** to {index} in the queue.",
"args": {
"movefrom": "Slot to move from",
"moveto": "Slot to move to"
}
},
"nowplaying": {
"description": "Show now playing song",
"errorNotQueue": "There is nothing playing.",
"embedTitle": "Now playing",
"live": " ◉ LIVE",
"timeRemaining": "Time Remaining: {time}"
},
"pause": {
"description": "Pause the currently playing music",
"errorNotQueue": "There is nothing playing.",
"result": "<@{author}> ⏸ paused the music."
},
"ping": {
"description": "Show the bot's average ping",
"result": "📈 Average ping to API: {ping} ms"
},
"play": {
"description": "Plays audio from YouTube",
"errorNotChannel": "You need to join a voice channel first!",
"errorNotInSameChannel": "You must be in the same channel as {user}",
"usageReply": "Usage: {prefix}play <YouTube URL | Video Name>",
"missingPermissionConnect": "Cannot connect to voice channel, missing permissions",
"missingPermissionSpeak": "I cannot speak in this voice channel, make sure I have the proper permissions!",
"queueAdded": "✅ **{title}** has been added to the queue by <@{author}>",
"cantJoinChannel": "Could not join the channel: {error}",
"queueEnded": "❌ Music queue ended.",
"queueError": "Error: {error}",
"startedPlaying": "🎶 Started playing: **{title}** {url}",
"skipSong": "<@{author}> ⏩ skipped the song",
"pauseSong": "<@{author}> ⏸ paused the music.",
"resumeSong": "<@{author}> ▶ resumed the music!",
"unmutedSong": "<@{author}> 🔊 unmuted the music!",
"mutedSong": "<@{author}> 🔇 muted the music!",
"decreasedVolume": "<@{author}> 🔉 decreased the volume, the volume is now {volume}%",
"increasedVolume": "<@{author}> 🔊 increased the volume, the volume is now {volume}%",
"loopSong": "<@{author}> Loop is now {loop}",
"stopSong": "<@{author}> ⏹ stopped the music!",
"leaveChannel": "Leaving voice channel...",
"songNotFound": "Audio Not Found",
"songAccessErr": "Video is age restricted, private or unavailable",
"errorNoResults": "No results found for {url}",
"errorInvalidURL": "Invalid URL, please try a search or youtube url"
},
"playlist": {
"description": "Play a playlist from youtube",
"usagesReply": "Usage: {prefix}playlist <YouTube Playlist URL | Playlist Name>",
"errorNotChannel": "You need to join a voice channel first!",
"errorNotInSameChannel": "You must be in the same channel as {user}",
"missingPermissionConnect": "Cannot connect to voice channel, missing permissions",
"missingPermissionSpeak": "I cannot speak in this voice channel, make sure I have the proper permissions!",
"errorNotFoundPlaylist": "Playlist not found :(",
"fetchingPlaylist": "⌛ fetching the playlist...",
"playlistCharLimit": "\nPlaylist larger than character limit...",
"startedPlaylist": "<@{author}> Started a playlist",
"cantJoinChannel": "Could not join the channel: {error}"
},
"pruning": {
"description": "Toggle pruning of bot messages",
"errorWritingFile": "There was an error writing to the file.",
"result": "Message pruning is {result}"
},
"queue": {
"description": "Show the music queue and now playing.",
"missingPermissionMessage": "Missing permission to manage messages or add reactions",
"errorNotQueue": "❌ **Nothing playing in this server**",
"currentPage": "Current Page - ",
"embedTitle": "Song Queue\n",
"embedCurrentSong": "**Current Song - [{title}]({url})**\n\n{info}"
},
"remove": {
"description": "Remove song from the queue",
"errorNotQueue": "There is no queue.",
"usageReply": "Usage: {prefix}remove <Queue Number>",
"result": "<@{author}> ❌ removed **{title}** from the queue."
},
"resume": {
"description": "Resume currently playing music",
"errorNotQueue": "There is nothing playing.",
"resultNotPlaying": "<@{author}> ▶ resumed the music!",
"errorPlaying": "The queue is not paused."
},
"search": {
"description": "Search and select videos to play",
"usageReply": "Usage: {prefix}{name} <Video Name>",
"errorAlreadyCollector": "A message collector is already active in this channel.",
"errorNotChannel": "You need to join a voice channel first!",
"resultEmbedTitle": "**Reply with the song number you want to play**",
"resultEmbedDesc": "Results for: {search}",
"optionQuery": "Search query"
},
"shuffle": {
"description": "Shuffle queue",
"errorNotQueue": "There is no queue.",
"result": "<@{author}> 🔀 shuffled the queue"
},
"skip": {
"description": "Skip the currently playing song",
"errorNotQueue": "There is nothing playing that I could skip for you.",
"result": "<@{author}> ⏭ skipped the song"
},
"skipto": {
"description": "Skip to the selected queue number",
"usageReply": "Usage: {prefix}{name} <Queue Number>",
"errorNotQueue": "There is no queue.",
"errorNotValid": "The queue is only {length} songs long!",
"result": "<@{author}> ⏭ skipped {arg} songs",
"args": {
"number": "The queue number to skip to"
}
},
"stop": {
"description": "Stops the music",
"errorNotQueue": "There is nothing playing.",
"result": "<@{author}> ⏹ stopped the music!"
},
"uptime": {
"description": "Check the uptime",
"result": "Uptime: `{days} day(s),{hours} hours, {minutes} minutes, {seconds} seconds`"
},
"volume": {
"description": "Change volume of currently playing music",
"errorNotQueue": "There is nothing playing.",
"errorNotChannel": "You need to join a voice channel first!",
"currentVolume": "🔊 The current volume is: **{volume}%**",
"errorNotNumber": "Please use a number to set volume.",
"errorNotValid": "Please use a number between 0 - 100.",
"result": "Volume set to: **{arg}%**"
},
"common": {
"on": "**on**",
"off": "**off**",
"enabled": "**enabled**",
"disabled": "**disabled**",
"errorNotChannel": "You need to join a voice channel first!",
"cooldownMessage": "please wait {time} more second(s) before reusing the `{name}` command.",
"errorCommand": "There was an error executing that command."
},
"Invite me to your server!": "Invite me to your server!",
"Invite": "Invite"
}

View File

@ -10,16 +10,20 @@
"devDependencies": {
"@types/crypto-js": "^4.1.1",
"@types/gifencoder": "^2.0.1",
"@types/i18n": "^0.13.6",
"@types/node": "^20.1.3",
"@types/pidusage": "^2.0.2",
"@types/string-similarity": "^4.0.0",
"nodemon": "^2.0.22",
"prettier": "^2.8.8",
"ts-node": "^10.9.1",
"typescript": "^5.0.4"
},
"dependencies": {
"@discordjs/core": "^0.6.0",
"@discordjs/opus": "^0.9.0",
"@discordjs/rest": "^1.7.1",
"@discordjs/voice": "^0.16.0",
"@esbuild-kit/esm-loader": "^2.5.5",
"@types/xml2js": "^0.4.11",
"axios": "^1.4.0",
@ -30,21 +34,34 @@
"dotenv": "^16.0.3",
"express": "^4.18.2",
"fastify": "^4.17.0",
"ffmpeg-static": "^4.4.1",
"gifencoder": "^2.0.1",
"glob": "^10.2.3",
"graphql": "^16.6.0",
"graphql-request": "^6.0.0",
"i18n": "^0.15.1",
"napi-nanoid": "^0.2.0",
"node-cache": "^5.1.2",
"pidusage": "^3.0.2",
"play-dl": "^1.9.6",
"redis": "^4.6.6",
"soundcloud-downloader": "^0.2.3",
"string-progressbar": "^1.0.4",
"string-similarity": "^4.0.4",
"xml2js": "^0.5.0",
"youtube-sr": "^4.3.4",
"zod": "^3.21.4"
},
"scripts": {
"dev": "NODE_ENV=development nodemon --watch 'src/**/*.ts' --exec node --loader @esbuild-kit/esm-loader src/index.ts",
"start": "node --loader @esbuild-kit/esm-loader src/index.ts",
"build": "tsc"
},
"optionalDependencies": {
"@discordjs/opus": "^0.9.0",
"libsodium-wrappers": "^0.7.11",
"opusscript": "^0.1.0",
"sodium-native": "^3.4.1",
"tweetnacl": "^1.0.3"
}
}

View File

@ -1,4 +1,9 @@
import { Client, Collection, GatewayIntentBits, Partials } from 'discord.js'
import {
ApplicationCommandDataResolvable,
Client,
Collection,
Snowflake,
} from 'discord.js'
import './server'
@ -6,40 +11,36 @@ import { loadSlashCommand, loadTextCommand } from './loaders/command'
import { loadDiscordEvent } from './loaders/event'
import type { SlashCommand, TextCommand } from './sturctures/command'
import { hgqlInit } from './utils/database'
import { MusicQueue } from './utils/musicQueue'
import { Command } from './interfaces/Command'
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.DirectMessages,
GatewayIntentBits.MessageContent,
],
allowedMentions: { parse: ['users', 'roles'], repliedUser: false },
partials: [
Partials.User,
Partials.Channel,
Partials.Message,
Partials.GuildMember,
],
})
export class Bot {
public prefix = 'h!'
public commands = new Collection<string, Command>()
public slashCommands = new Array<ApplicationCommandDataResolvable>()
public slashCommandsMap = new Collection<string, Command>()
public cooldowns = new Collection<string, Collection<Snowflake, number>>()
public queues = new Collection<Snowflake, MusicQueue>()
client.commands = new Collection()
client.commandsCatagories = new Collection()
client.aliases = new Collection()
client.slashcommands = new Collection()
public constructor(public client: Client) {
this.client.commands = new Collection()
this.client.commandsCatagories = new Collection()
this.client.aliases = new Collection()
this.client.slashcommands = new Collection()
await loadDiscordEvent(client)
await loadTextCommand(client)
loadDiscordEvent(this.client)
loadTextCommand(this.client)
await hgqlInit()
hgqlInit()
await client.login(process.env.TOKEN)
await loadSlashCommand(client, process.env.CLIENT_ID, process.env.TOKEN)
export default client
this.client.login(process.env.TOKEN)
this.client.on('ready', () => {
console.log(`🟢 Logged in as ${client.user?.tag}!`)
loadSlashCommand(client, process.env.CLIENT_ID, process.env.TOKEN)
})
}
}
// declare types.
declare module 'discord.js' {
export interface Client {
@ -47,5 +48,6 @@ declare module 'discord.js' {
commands: Collection<string, TextCommand>
slashcommands: Collection<string, SlashCommand>
commandsCatagories: Collection<string, string[]>
queues: Collection<Snowflake, MusicQueue>
}
}

View File

@ -0,0 +1,42 @@
import { SlashCommandBuilder } from 'discord.js'
import { bot } from '../../index'
import { i18n } from '../../utils/i18n'
import { canModifyQueue } from '../../utils/queue'
import { SlashCommand } from '../../sturctures/command'
export const command: SlashCommand = {
slashData: new SlashCommandBuilder()
.setName('loop')
.setDescription(i18n.__('loop.description')),
run: (itd) => {
const { interaction }: any = itd
const queue = bot.queues.get(interaction.guild!.id)
const guildMemer = interaction.guild!.members.cache.get(
interaction.user.id
)
if (!queue)
return interaction
.reply({
content: i18n.__('loop.errorNotQueue'),
ephemeral: true,
})
.catch(console.error)
if (!guildMemer || !canModifyQueue(guildMemer))
return i18n.__('common.errorNotChannel')
queue.loop = !queue.loop
const content = {
content: i18n.__mf('loop.result', {
loop: queue.loop ? i18n.__('common.on') : i18n.__('common.off'),
}),
}
if (interaction.replied)
interaction.followUp(content).catch(console.error)
else interaction.reply(content).catch(console.error)
},
}

View File

@ -0,0 +1,62 @@
import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'
import { splitBar } from 'string-progressbar'
import { bot } from '../../index'
import { i18n } from '../../utils/i18n'
import { SlashCommand } from '../../sturctures/command'
export const command: SlashCommand = {
slashData: new SlashCommandBuilder()
.setName('nowplaying')
.setDescription(i18n.__('nowplaying.description')),
run: (itd) => {
const { interaction }: any = itd
const queue = bot.queues.get(interaction.guild!.id)
console.log(i18n.__('nowplaying.description'))
if (!queue || !queue.songs.length)
return interaction
.reply({
content: i18n.__('nowplaying.errorNotQueue'),
ephemeral: true,
})
.catch(console.error)
const song = queue.songs[0]
const seek = queue.resource.playbackDuration / 1000
const left = song.duration - seek
const nowPlaying = new EmbedBuilder()
.setTitle(i18n.__('nowplaying.embedTitle'))
.setDescription(`${song.title}\n${song.url}`)
.setColor('#F8AA2A')
if (song.duration > 0) {
nowPlaying.addFields({
name: '\u200b',
value:
new Date(seek * 1000).toISOString().substr(11, 8) +
'[' +
splitBar(
song.duration == 0 ? seek : song.duration,
seek,
20
)[0] +
']' +
(song.duration == 0
? ' ◉ LIVE'
: new Date(song.duration * 1000)
.toISOString()
.substr(11, 8)),
inline: false,
})
nowPlaying.setFooter({
text: i18n.__mf('nowplaying.timeRemaining', {
time: new Date(left * 1000).toISOString().substr(11, 8),
}),
})
}
return interaction.reply({ embeds: [nowPlaying] })
},
}

View File

@ -0,0 +1,40 @@
import { SlashCommandBuilder } from 'discord.js'
import { bot } from '../../index'
import { i18n } from '../../utils/i18n'
import { canModifyQueue } from '../../utils/queue'
import { SlashCommand } from '../../sturctures/command'
export const command: SlashCommand = {
slashData: new SlashCommandBuilder()
.setName('pause')
.setDescription(i18n.__('pause.description')),
run: (itd) => {
const { interaction }: any = itd
const guildMemer = interaction.guild!.members.cache.get(
interaction.user.id
)
const queue = bot.queues.get(interaction.guild!.id)
if (!queue)
return interaction
.reply({ content: i18n.__('pause.errorNotQueue') })
.catch(console.error)
if (!canModifyQueue(guildMemer!))
return i18n.__('common.errorNotChannel')
if (queue.player.pause()) {
const content = {
content: i18n.__mf('pause.result', {
author: interaction.user.id,
}),
}
if (interaction.replied)
interaction.followUp(content).catch(console.error)
else interaction.reply(content).catch(console.error)
return true
}
},
}

View File

@ -0,0 +1,145 @@
import {
DiscordGatewayAdapterCreator,
joinVoiceChannel,
} from '@discordjs/voice'
import { SlashCommandBuilder, TextChannel } from 'discord.js'
import { bot } from '../../index'
import { MusicQueue } from '../../utils/musicQueue'
import { Song } from '../../utils/song'
import { i18n } from '../../utils/i18n'
import { playlistPattern } from '../../utils/patterns'
import { SlashCommand } from '../../sturctures/command'
export const command: SlashCommand = {
slashData: new SlashCommandBuilder()
.setName('play')
.setDescription(i18n.__('play.description'))
.addStringOption((option) =>
option
.setName('song')
.setDescription('The song you want to play')
.setRequired(true)
),
run: async (itd) => {
const { interaction, input }: any = itd
let argSongName = interaction.options.getString('song')
if (!argSongName) argSongName = input
const guildMember = interaction.guild!.members.cache.get(
interaction.user.id
)
const { channel } = guildMember!.voice
if (!channel)
return interaction
.reply({
content: i18n.__('play.errorNotChannel'),
ephemeral: true,
})
.catch(console.error)
const queue = bot.queues.get(interaction.guild!.id)
if (queue && channel.id !== queue.connection.joinConfig.channelId)
return interaction
.reply({
content: i18n.__mf('play.errorNotInSameChannel', {
user: bot.client.user!.username,
}),
ephemeral: true,
})
.catch(console.error)
if (!argSongName)
return interaction
.reply({
content: i18n.__mf('play.usageReply', {
prefix: bot.prefix,
}),
ephemeral: true,
})
.catch(console.error)
const url = argSongName
if (interaction.replied)
await interaction.editReply('⏳ Loading...').catch(console.error)
else await interaction.reply('⏳ Loading...')
// Start the playlist if playlist url was provided
if (playlistPattern.test(url)) {
await interaction
.editReply('🔗 Link is playlist')
.catch(console.error)
return bot.slashCommandsMap.get('playlist')!.execute(interaction)
}
let song
try {
song = await Song.from(url, url)
} catch (error: any) {
if (error.name == 'NoResults')
return interaction
.reply({
content: i18n.__mf('play.errorNoResults', {
url: `<${url}>`,
}),
ephemeral: true,
})
.catch(console.error)
if (error.name == 'InvalidURL')
return interaction
.reply({
content: i18n.__mf('play.errorInvalidURL', {
url: `<${url}>`,
}),
ephemeral: true,
})
.catch(console.error)
console.error(error)
if (interaction.replied)
return await interaction
.editReply({ content: i18n.__('common.errorCommand') })
.catch(console.error)
else
return interaction
.reply({
content: i18n.__('common.errorCommand'),
ephemeral: true,
})
.catch(console.error)
}
if (queue) {
queue.enqueue(song)
return (interaction.channel as TextChannel)
.send({
content: i18n.__mf('play.queueAdded', {
title: song.title,
author: interaction.user.id,
}),
})
.catch(console.error)
}
const newQueue = new MusicQueue({
interaction,
textChannel: interaction.channel! as TextChannel,
connection: joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild
.voiceAdapterCreator as DiscordGatewayAdapterCreator,
}),
})
bot.queues.set(interaction.guild!.id, newQueue)
newQueue.enqueue(song)
interaction.deleteReply().catch(console.error)
},
}

View File

@ -0,0 +1,142 @@
import {
DiscordGatewayAdapterCreator,
joinVoiceChannel,
} from '@discordjs/voice'
import { EmbedBuilder, SlashCommandBuilder, TextChannel } from 'discord.js'
import { bot } from '../../index'
import { MusicQueue } from '../../utils/musicQueue'
import { Playlist } from '../../utils/playlist'
import { Song } from '../../utils/song'
import { i18n } from '../../utils/i18n'
import { SlashCommand } from '../../sturctures/command'
export const command: SlashCommand = {
slashData: new SlashCommandBuilder()
.setName('playlist')
.setDescription(i18n.__('playlist.description'))
.addStringOption((option) =>
option
.setName('playlist')
.setDescription('Playlist name or link')
.setRequired(true)
),
run: async (itd) => {
const { interaction }: any = itd
const argSongName = interaction.options.getString('playlist')
const guildMemer = interaction.guild!.members.cache.get(
interaction.user.id
)
const { channel } = guildMemer!.voice
const queue = bot.queues.get(interaction.guild!.id)
if (!channel)
return interaction
.reply({
content: i18n.__('playlist.errorNotChannel'),
ephemeral: true,
})
.catch(console.error)
if (queue && channel.id !== queue.connection.joinConfig.channelId)
if (interaction.replied)
return interaction
.editReply({
content: i18n.__mf('play.errorNotInSameChannel', {
user: interaction.client.user!.username,
}),
})
.catch(console.error)
else
return interaction
.reply({
content: i18n.__mf('play.errorNotInSameChannel', {
user: interaction.client.user!.username,
}),
ephemeral: true,
})
.catch(console.error)
let playlist
try {
playlist = await Playlist.from(
argSongName!.split(' ')[0],
argSongName!
)
} catch (error) {
console.error(error)
if (interaction.replied)
return interaction
.editReply({
content: i18n.__('playlist.errorNotFoundPlaylist'),
})
.catch(console.error)
else
return interaction
.reply({
content: i18n.__('playlist.errorNotFoundPlaylist'),
ephemeral: true,
})
.catch(console.error)
}
if (queue) {
queue.songs.push(...playlist.videos)
} else {
const newQueue = new MusicQueue({
interaction,
textChannel: interaction.channel! as TextChannel,
connection: joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild
.voiceAdapterCreator as DiscordGatewayAdapterCreator,
}),
})
bot.queues.set(interaction.guild!.id, newQueue)
newQueue.songs.push(...playlist.videos)
newQueue.enqueue(playlist.videos[0])
}
const playlistEmbed = new EmbedBuilder()
.setTitle(`${playlist.data.title}`)
.setDescription(
playlist.videos
.map(
(song: Song, index: number) =>
`${index + 1}. ${song.title}`
)
.join('\n')
)
.setURL(playlist.data.url!)
.setColor('#F8AA2A')
.setTimestamp()
if (playlistEmbed.data.description!.length >= 2048)
playlistEmbed.setDescription(
playlistEmbed.data.description!.substr(0, 2007) +
i18n.__('playlist.playlistCharLimit')
)
if (interaction.replied)
return interaction.editReply({
content: i18n.__mf('playlist.startedPlaylist', {
author: interaction.user.id,
}),
embeds: [playlistEmbed],
})
interaction
.reply({
content: i18n.__mf('playlist.startedPlaylist', {
author: interaction.user.id,
}),
embeds: [playlistEmbed],
})
.catch(console.error)
},
}

View File

@ -0,0 +1,130 @@
import {
CommandInteraction,
EmbedBuilder,
MessageReaction,
SlashCommandBuilder,
TextChannel,
User,
} from 'discord.js'
import { bot } from '../../index'
import { Song } from '../../utils/song'
import { i18n } from '../../utils/i18n'
import { SlashCommand } from '../../sturctures/command'
export const command: SlashCommand = {
slashData: new SlashCommandBuilder()
.setName('queue')
.setDescription(i18n.__('queue.description')),
run: async (itd) => {
const { interaction }: any = itd
const queue = bot.queues.get(interaction.guild!.id)
if (!queue || !queue.songs.length)
return interaction.reply({
content: i18n.__('queue.errorNotQueue'),
})
let currentPage = 0
const embeds = generateQueueEmbed(interaction, queue.songs)
await interaction.reply('⏳ Loading queue...')
if (interaction.replied)
await interaction.editReply({
content: `**${i18n.__mf('queue.currentPage')} ${
currentPage + 1
}/${embeds.length}**`,
embeds: [embeds[currentPage]],
})
const queueEmbed = await interaction.fetchReply()
try {
await queueEmbed.react('⬅️')
await queueEmbed.react('⏹')
await queueEmbed.react('➡️')
} catch (error: any) {
console.error(error)
;(interaction.channel as TextChannel)
.send(error.message)
.catch(console.error)
}
const filter = (reaction: MessageReaction, user: User) =>
['⬅️', '⏹', '➡️'].includes(reaction.emoji.name!) &&
interaction.user.id === user.id
const collector = queueEmbed.createReactionCollector({
filter,
time: 60000,
})
collector.on('collect', async (reaction: any, _user: any) => {
try {
if (reaction.emoji.name === '➡️') {
if (currentPage < embeds.length - 1) {
currentPage++
queueEmbed.edit({
content: i18n.__mf('queue.currentPage', {
page: currentPage + 1,
length: embeds.length,
}),
embeds: [embeds[currentPage]],
})
}
} else if (reaction.emoji.name === '⬅️') {
if (currentPage !== 0) {
--currentPage
queueEmbed.edit({
content: i18n.__mf('queue.currentPage', {
page: currentPage + 1,
length: embeds.length,
}),
embeds: [embeds[currentPage]],
})
}
} else {
collector.stop()
reaction.message.reactions.removeAll()
}
await reaction.users.remove(interaction.user.id)
} catch (error: any) {
console.error(error)
return (interaction.channel as TextChannel)
.send(error.message)
.catch(console.error)
}
})
},
}
function generateQueueEmbed(interaction: CommandInteraction, songs: Song[]) {
const embeds = []
let k = 10
for (let i = 0; i < songs.length; i += 10) {
const current = songs.slice(i, k)
let j = i
k += 10
const info = current
.map((track) => `${++j} - [${track.title}](${track.url})`)
.join('\n')
const embed = new EmbedBuilder()
.setTitle(i18n.__('queue.embedTitle'))
// eslint-disable-next-line
.setThumbnail(interaction.guild?.iconURL()!)
.setColor('#F8AA2A')
.setDescription(
i18n.__mf('queue.embedCurrentSong', {
title: songs[0].title,
url: songs[0].url,
info: info,
})
)
.setTimestamp()
embeds.push(embed)
}
return embeds
}

View File

@ -0,0 +1,82 @@
import { SlashCommandBuilder } from 'discord.js'
import { bot } from '../../index'
import { Song } from '../../utils/song'
import { i18n } from '../../utils/i18n'
import { canModifyQueue } from '../../utils/queue'
const pattern = /^[0-9]{1,2}(\s*,\s*[0-9]{1,2})*$/
import { SlashCommand } from '../../sturctures/command'
export const command: SlashCommand = {
slashData: new SlashCommandBuilder()
.setName('remove')
.setDescription(i18n.__('remove.description'))
.addStringOption((option) =>
option
.setName('slot')
.setDescription(i18n.__('remove.description'))
.setRequired(true)
),
run: (itd) => {
const { interaction }: any = itd
const guildMemer = interaction.guild!.members.cache.get(
interaction.user.id
)
const removeArgs = interaction.options.getString('slot')
const queue = bot.queues.get(interaction.guild!.id)
if (!queue)
return interaction
.reply({
content: i18n.__('remove.errorNotQueue'),
ephemeral: true,
})
.catch(console.error)
if (!canModifyQueue(guildMemer!))
return i18n.__('common.errorNotChannel')
if (!removeArgs)
return interaction.reply({
content: i18n.__mf('remove.usageReply', { prefix: bot.prefix }),
ephemeral: true,
})
const songs = removeArgs.split(',').map((arg: any) => parseInt(arg))
const removed: Song[] = []
if (pattern.test(removeArgs)) {
// eslint-disable-next-line
queue.songs = queue.songs.filter((item, index) => {
if (songs.find((songIndex: any) => songIndex - 1 === index))
removed.push(item)
else return true
})
interaction.reply(
i18n.__mf('remove.result', {
title: removed.map((song) => song.title).join('\n'),
author: interaction.user.id,
})
)
} else if (
!isNaN(+removeArgs) &&
+removeArgs >= 1 &&
+removeArgs <= queue.songs.length
) {
return interaction.reply(
i18n.__mf('remove.result', {
title: queue.songs.splice(+removeArgs - 1, 1)[0].title,
author: interaction.user.id,
})
)
} else {
return interaction.reply({
content: i18n.__mf('remove.usageReply', { prefix: bot.prefix }),
})
}
},
}

View File

@ -0,0 +1,50 @@
import { SlashCommandBuilder } from 'discord.js'
import { bot } from '../../index'
import { i18n } from '../../utils/i18n'
import { canModifyQueue } from '../../utils/queue'
import { SlashCommand } from '../../sturctures/command'
export const command: SlashCommand = {
slashData: new SlashCommandBuilder()
.setName('resume')
.setDescription(i18n.__('resume.description')),
run: (itd) => {
const { interaction }: any = itd
const queue = bot.queues.get(interaction.guild!.id)
const guildMemer = interaction.guild!.members.cache.get(
interaction.user.id
)
if (!queue)
return interaction
.reply({
content: i18n.__('resume.errorNotQueue'),
ephemeral: true,
})
.catch(console.error)
if (!canModifyQueue(guildMemer!))
return i18n.__('common.errorNotChannel')
if (queue.player.unpause()) {
const content = {
content: i18n.__mf('resume.resultNotPlaying', {
author: interaction.user.id,
}),
}
if (interaction.replied)
interaction.followUp(content).catch(console.error)
else interaction.reply(content).catch(console.error)
return true
}
const content = { content: i18n.__('resume.errorPlaying') }
if (interaction.replied)
interaction.followUp(content).catch(console.error)
else interaction.reply(content).catch(console.error)
return false
},
}

View File

@ -0,0 +1,114 @@
import {
ActionRowBuilder,
SlashCommandBuilder,
StringSelectMenuBuilder,
StringSelectMenuInteraction,
} from 'discord.js'
import youtube, { Video } from 'youtube-sr'
import { bot } from '../../index'
import { i18n } from '../../utils/i18n'
import { SlashCommand } from '../../sturctures/command'
export const command: SlashCommand = {
slashData: new SlashCommandBuilder()
.setName('search')
.setDescription(i18n.__('search.description'))
.addStringOption((option) =>
option
.setName('query')
.setDescription(i18n.__('search.optionQuery'))
.setRequired(true)
),
run: async (itd) => {
const { interaction }: any = itd
const query = interaction.options.getString('query', true)
const member = interaction.guild!.members.cache.get(interaction.user.id)
if (!member?.voice.channel)
return interaction
.reply({
content: i18n.__('search.errorNotChannel'),
ephemeral: true,
})
.catch(console.error)
const search = query
await interaction.reply('⏳ Loading...').catch(console.error)
let results: Video[] = []
try {
results = await youtube.search(search, { limit: 10, type: 'video' })
} catch (error: any) {
console.error(error)
interaction
.editReply({ content: i18n.__('common.errorCommand') })
.catch(console.error)
}
if (!results) return
const options = results!.map((video) => {
return {
label: video.title ?? '',
value: video.url,
}
})
const row =
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder()
.setCustomId('search-select')
.setPlaceholder('Nothing selected')
.setMinValues(1)
.setMaxValues(10)
.addOptions(options)
)
const followUp = await interaction.followUp({
content: 'Choose songs to play',
components: [row],
})
followUp
.awaitMessageComponent({
time: 30000,
})
.then(
(selectInteraction: {
update: (arg0: {
content: string
components: never[]
}) => void
values: any[]
}) => {
if (
!(
selectInteraction instanceof
StringSelectMenuInteraction
)
)
return
selectInteraction.update({
content: '⏳ Loading the selected songs...',
components: [],
})
bot.slashCommandsMap
.get('play')!
.execute(interaction, selectInteraction.values[0])
.then(() => {
selectInteraction.values.slice(1).forEach((url) => {
bot.slashCommandsMap
.get('play')!
.execute(interaction, url)
})
})
}
)
.catch(console.error)
},
}

View File

@ -0,0 +1,48 @@
import { SlashCommandBuilder } from 'discord.js'
import { bot } from '../../index'
import { i18n } from '../../utils/i18n'
import { canModifyQueue } from '../../utils/queue'
import { SlashCommand } from '../../sturctures/command'
export const command: SlashCommand = {
slashData: new SlashCommandBuilder()
.setName('shuffle')
.setDescription(i18n.__('shuffle.description')),
run: (itd) => {
const { interaction }: any = itd
const queue = bot.queues.get(interaction.guild!.id)
const guildMemer = interaction.guild!.members.cache.get(
interaction.user.id
)
if (!queue)
return interaction
.reply({
content: i18n.__('shuffle.errorNotQueue'),
ephemeral: true,
})
.catch(console.error)
if (!guildMemer || !canModifyQueue(guildMemer))
return i18n.__('common.errorNotChannel')
const songs = queue.songs
for (let i = songs.length - 1; i > 1; i--) {
const j = 1 + Math.floor(Math.random() * i)
;[songs[i], songs[j]] = [songs[j], songs[i]]
}
queue.songs = songs
const content = {
content: i18n.__mf('shuffle.result', {
author: interaction.user.id,
}),
}
if (interaction.replied)
interaction.followUp(content).catch(console.error)
else interaction.reply(content).catch(console.error)
},
}

View File

@ -0,0 +1,36 @@
import { SlashCommandBuilder } from 'discord.js'
import { bot } from '../../index'
import { i18n } from '../../utils/i18n'
import { canModifyQueue } from '../../utils/queue'
import { SlashCommand } from '../../sturctures/command'
export const command: SlashCommand = {
slashData: new SlashCommandBuilder()
.setName('skip')
.setDescription(i18n.__('skip.description')),
run: (itd) => {
const { interaction }: any = itd
const queue = bot.queues.get(interaction.guild!.id)
const guildMemer = interaction.guild!.members.cache.get(
interaction.user.id
)
if (!queue)
return interaction
.reply(i18n.__('skip.errorNotQueue'))
.catch(console.error)
if (!canModifyQueue(guildMemer!))
return i18n.__('common.errorNotChannel')
queue.player.stop(true)
interaction
.reply({
content: i18n.__mf('skip.result', {
author: interaction.user.id,
}),
})
.catch(console.error)
},
}

View File

@ -0,0 +1,77 @@
import { SlashCommandBuilder } from 'discord.js'
import { bot } from '../../index'
import { i18n } from '../../utils/i18n'
import { canModifyQueue } from '../../utils/queue'
import { SlashCommand } from '../../sturctures/command'
export const command: SlashCommand = {
slashData: new SlashCommandBuilder()
.setName('skipto')
.setDescription(i18n.__('skipto.description'))
.addIntegerOption((option) =>
option
.setName('number')
.setDescription(i18n.__('skipto.args.number'))
.setRequired(true)
),
run: (itd) => {
const { interaction }: any = itd
const playlistSlotArg = interaction.options.getInteger('number')
const guildMemer = interaction.guild!.members.cache.get(
interaction.user.id
)
if (!playlistSlotArg || isNaN(playlistSlotArg))
return interaction
.reply({
content: i18n.__mf('skipto.usageReply', {
prefix: bot.prefix,
name: module.exports.name,
}),
ephemeral: true,
})
.catch(console.error)
const queue = bot.queues.get(interaction.guild!.id)
if (!queue)
return interaction
.reply({
content: i18n.__('skipto.errorNotQueue'),
ephemeral: true,
})
.catch(console.error)
if (!canModifyQueue(guildMemer!))
return i18n.__('common.errorNotChannel')
if (playlistSlotArg > queue.songs.length)
return interaction
.reply({
content: i18n.__mf('skipto.errorNotValid', {
length: queue.songs.length,
}),
ephemeral: true,
})
.catch(console.error)
if (queue.loop) {
for (let i = 0; i < playlistSlotArg - 2; i++) {
queue.songs.push(queue.songs.shift()!)
}
} else {
queue.songs = queue.songs.slice(playlistSlotArg - 2)
}
queue.player.stop()
interaction
.reply({
content: i18n.__mf('skipto.result', {
author: interaction.user.id,
arg: playlistSlotArg - 1,
}),
})
.catch(console.error)
},
}

View File

@ -0,0 +1,35 @@
import { SlashCommandBuilder } from 'discord.js'
import { bot } from '../../index'
import { i18n } from '../../utils/i18n'
import { canModifyQueue } from '../../utils/queue'
import { SlashCommand } from '../../sturctures/command'
export const command: SlashCommand = {
slashData: new SlashCommandBuilder()
.setName('stop')
.setDescription(i18n.__('stop.description')),
run: (itd) => {
const { interaction }: any = itd
const queue = bot.queues.get(interaction.guild!.id)
const guildMemer = interaction.guild!.members.cache.get(
interaction.user.id
)
if (!queue)
return interaction
.reply(i18n.__('stop.errorNotQueue'))
.catch(console.error)
if (!guildMemer || !canModifyQueue(guildMemer))
return i18n.__('common.errorNotChannel')
queue.stop()
interaction
.reply({
content: i18n.__mf('stop.result', {
author: interaction.user.id,
}),
})
.catch(console.error)
},
}

View File

@ -0,0 +1,72 @@
import { SlashCommandBuilder } from 'discord.js'
import { bot } from '../../index'
import { i18n } from '../../utils/i18n'
import { canModifyQueue } from '../../utils/queue'
import { SlashCommand } from '../../sturctures/command'
export const command: SlashCommand = {
slashData: new SlashCommandBuilder()
.setName('volume')
.setDescription(i18n.__('volume.description'))
.addIntegerOption((option) =>
option
.setName('volume')
.setDescription(i18n.__('volume.description'))
),
run: async (intData) => {
const { interaction }: any = intData
const queue = bot.queues.get(interaction.guild!.id)
const guildMemer = interaction.guild!.members.cache.get(
interaction.user.id
)
const volumeArg = interaction.options.getInteger('volume')
if (!queue)
return interaction
.reply({
content: i18n.__('volume.errorNotQueue'),
ephemeral: true,
})
.catch(console.error)
if (!canModifyQueue(guildMemer!))
return interaction
.reply({
content: i18n.__('volume.errorNotChannel'),
ephemeral: true,
})
.catch(console.error)
if (!volumeArg || volumeArg === queue.volume)
return interaction
.reply({
content: i18n.__mf('volume.currentVolume', {
volume: queue.volume,
}),
})
.catch(console.error)
if (isNaN(volumeArg))
return interaction
.reply({
content: i18n.__('volume.errorNotNumber'),
ephemeral: true,
})
.catch(console.error)
if (Number(volumeArg) > 100 || Number(volumeArg) < 0)
return interaction
.reply({
content: i18n.__('volume.errorNotValid'),
ephemeral: true,
})
.catch(console.error)
queue.volume = volumeArg
queue.resource.volume?.setVolumeLogarithmic(volumeArg / 100)
return interaction
.reply({ content: i18n.__mf('volume.result', { arg: volumeArg }) })
.catch(console.error)
},
}

View File

@ -1,7 +1,6 @@
import type { Client } from 'discord.js'
import { ActivityType } from 'discord.js'
import { defaultPrefix } from '../../config/bot.json'
import type { DiscordEvent } from '../sturctures/event'
export const event: DiscordEvent = {
@ -9,18 +8,10 @@ export const event: DiscordEvent = {
name: 'ready',
run: (client: Client) => {
// Dynamic Status
let index = 0
const status = [`${client.guilds.cache.size} Servers`]
setInterval(() => {
const _status = status[index++ % status.length]
const text = `${defaultPrefix}help | ${_status}`
client.user?.setActivity(text, {
type: ActivityType.Watching,
})
}, 15 * 1000)
const text = `to my master's wishes 🧜🏻‍♀️`
client.user?.setActivity(text, {
type: ActivityType.Listening,
})
console.log('🤖 Bot is in ready status!')
},
}

View File

@ -1,9 +1,10 @@
import 'dotenv/config'
import chalk from 'chalk'
import { Client, GatewayIntentBits, Partials } from 'discord.js'
import './validate-env'
import { Bot } from './bot'
import { isDev } from './utils/constants'
// Check NODE Version
@ -31,4 +32,23 @@ if (isDev) {
process.on('unhandledRejection', console.error)
}
import('./bot')
export const bot = new Bot(
new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.DirectMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMessageReactions,
],
allowedMentions: { parse: ['users', 'roles'], repliedUser: false },
partials: [
Partials.User,
Partials.Channel,
Partials.Message,
Partials.GuildMember,
],
})
)

View File

@ -0,0 +1,8 @@
import { SlashCommandBuilder } from 'discord.js'
export interface Command {
permissions?: string[]
cooldown?: number
data: SlashCommandBuilder
execute(...args: any): any
}

View File

@ -0,0 +1,9 @@
export interface Config {
TOKEN: string
PREFIX: string
MAX_PLAYLIST_SIZE: number
PRUNING: boolean
STAY_TIME: number
DEFAULT_VOLUME: number
LOCALE: string
}

View File

@ -0,0 +1,8 @@
import { VoiceConnection } from '@discordjs/voice'
import { CommandInteraction, TextChannel } from 'discord.js'
export interface QueueOptions {
interaction: CommandInteraction
textChannel: TextChannel
connection: VoiceConnection
}

View File

@ -1,10 +1,10 @@
import fastify from 'fastify'
import client from '../bot'
import { bot } from '../index'
const server = fastify()
server.get('/', () => {
return client.user?.username + ' is ready !!'
return bot.client.user?.username + ' is ready !!'
})
await server.listen({

View File

@ -0,0 +1,38 @@
import i18n from 'i18n'
import path, { join } from 'path'
import config from '../../config/music.json'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
i18n.configure({
locales: ['en'],
directory: join(__dirname, '../..', 'locales'),
defaultLocale: 'en',
retryInDefaultLocale: true,
objectNotation: true,
register: global,
logWarnFn: function (msg: any) {
console.log(msg)
},
logErrorFn: function (msg: any) {
console.log(msg)
},
missingKeyFn: function (_locale: any, value: any) {
return value
},
mustacheConfig: {
tags: ['{{', '}}'],
disable: false,
},
})
i18n.setLocale(config.LOCALE)
export { i18n }

View File

@ -0,0 +1,387 @@
import {
AudioPlayer,
AudioPlayerState,
AudioPlayerStatus,
AudioResource,
createAudioPlayer,
entersState,
NoSubscriberBehavior,
VoiceConnection,
VoiceConnectionDisconnectReason,
VoiceConnectionState,
VoiceConnectionStatus,
} from '@discordjs/voice'
import { CommandInteraction, Message, TextChannel, User } from 'discord.js'
import { promisify } from 'node:util'
import { bot } from '../index'
import { QueueOptions } from '../interfaces/QueueOptions'
import config from '../../config/music.json'
import { i18n } from '../utils/i18n'
import { canModifyQueue } from './queue'
import { Song } from './song'
const wait = promisify(setTimeout)
export class MusicQueue {
public readonly interaction: CommandInteraction
public readonly connection: VoiceConnection
public readonly player: AudioPlayer
public readonly textChannel: TextChannel
public readonly bot = bot
public resource: AudioResource
public songs: Song[] = []
public volume = config.DEFAULT_VOLUME || 100
public loop = false
public muted = false
public waitTimeout: NodeJS.Timeout | null
private queueLock = false
private readyLock = false
private stopped = false
public constructor(options: QueueOptions) {
Object.assign(this, options)
this.player = createAudioPlayer({
behaviors: { noSubscriber: NoSubscriberBehavior.Play },
})
this.connection.subscribe(this.player)
const networkStateChangeHandler = (
_oldNetworkState: any,
newNetworkState: any
) => {
const newUdp = Reflect.get(newNetworkState, 'udp')
clearInterval(newUdp?.keepAliveInterval)
}
this.connection.on(
'stateChange' as any,
async (
oldState: VoiceConnectionState,
newState: VoiceConnectionState
) => {
Reflect.get(oldState, 'networking')?.off(
'stateChange',
networkStateChangeHandler
)
Reflect.get(newState, 'networking')?.on(
'stateChange',
networkStateChangeHandler
)
if (newState.status === VoiceConnectionStatus.Disconnected) {
if (
newState.reason ===
VoiceConnectionDisconnectReason.WebSocketClose &&
newState.closeCode === 4014
) {
try {
this.stop()
} catch (e) {
console.log(e)
this.stop()
}
} else if (this.connection.rejoinAttempts < 5) {
await wait((this.connection.rejoinAttempts + 1) * 5_000)
this.connection.rejoin()
} else {
this.connection.destroy()
}
} else if (
!this.readyLock &&
(newState.status === VoiceConnectionStatus.Connecting ||
newState.status === VoiceConnectionStatus.Signalling)
) {
this.readyLock = true
try {
await entersState(
this.connection,
VoiceConnectionStatus.Ready,
20_000
)
} catch {
if (
this.connection.state.status !==
VoiceConnectionStatus.Destroyed
) {
try {
this.connection.destroy()
} catch {
console.log('Failed to destroy connection')
}
}
} finally {
this.readyLock = false
}
}
}
)
this.player.on(
'stateChange' as any,
async (oldState: AudioPlayerState, newState: AudioPlayerState) => {
if (
oldState.status !== AudioPlayerStatus.Idle &&
newState.status === AudioPlayerStatus.Idle
) {
if (this.loop && this.songs.length) {
this.songs.push(this.songs.shift()!)
} else {
this.songs.shift()
if (!this.songs.length) return this.stop()
}
if (this.songs.length || this.resource.audioPlayer)
this.processQueue()
} else if (
oldState.status === AudioPlayerStatus.Buffering &&
newState.status === AudioPlayerStatus.Playing
) {
this.sendPlayingMessage(newState)
}
}
)
this.player.on('error', (error) => {
console.error(error)
if (this.loop && this.songs.length) {
this.songs.push(this.songs.shift()!)
} else {
this.songs.shift()
}
this.processQueue()
})
}
public enqueue(...songs: Song[]) {
if (this.waitTimeout !== null) clearTimeout(this.waitTimeout)
this.waitTimeout = null
this.stopped = false
this.songs = this.songs.concat(songs)
this.processQueue()
}
public stop() {
if (this.stopped) return
this.stopped = true
this.loop = false
this.songs = []
this.player.stop()
!config.PRUNING &&
this.textChannel
.send(i18n.__('play.queueEnded'))
.catch(console.error)
if (this.waitTimeout !== null) return
this.waitTimeout = setTimeout(() => {
if (
this.connection.state.status !== VoiceConnectionStatus.Destroyed
) {
try {
this.connection.destroy()
} catch {
console.log('Failed to destroy the connection.')
}
}
bot.queues.delete(this.interaction.guild!.id)
!config.PRUNING &&
this.textChannel.send(i18n.__('play.leaveChannel'))
}, config.STAY_TIME * 1000)
}
public async processQueue(): Promise<void> {
if (
this.queueLock ||
this.player.state.status !== AudioPlayerStatus.Idle
) {
return
}
if (!this.songs.length) {
return this.stop()
}
this.queueLock = true
const next = this.songs[0]
try {
const resource = await next.makeResource()
this.resource = resource!
this.player.play(this.resource)
this.resource.volume?.setVolumeLogarithmic(this.volume / 100)
} catch (error) {
console.error(error)
return this.processQueue()
} finally {
this.queueLock = false
}
}
private async sendPlayingMessage(newState: any) {
const song = (newState.resource as AudioResource<Song>).metadata
let playingMessage: Message
try {
playingMessage = await this.textChannel.send(
(
newState.resource as AudioResource<Song>
).metadata.startMessage()
)
await playingMessage.react('⏭')
await playingMessage.react('⏯')
await playingMessage.react('🔇')
await playingMessage.react('🔉')
await playingMessage.react('🔊')
await playingMessage.react('🔁')
await playingMessage.react('🔀')
await playingMessage.react('⏹')
} catch (error: any) {
console.error(error)
this.textChannel.send(error.message)
return
}
const filter = (_reaction: any, user: User) =>
user.id !== this.textChannel.client.user!.id
const collector = playingMessage.createReactionCollector({
filter,
time: song.duration > 0 ? song.duration * 1000 : 600000,
})
collector.on('collect', async (reaction, user) => {
if (!this.songs) return
const member = await playingMessage.guild!.members.fetch(user)
switch (reaction.emoji.name) {
case '⏭':
reaction.users.remove(user).catch(console.error)
await this.bot.slashCommandsMap
.get('skip')!
.execute(this.interaction)
break
case '⏯':
reaction.users.remove(user).catch(console.error)
if (this.player.state.status == AudioPlayerStatus.Playing) {
await this.bot.slashCommandsMap
.get('pause')!
.execute(this.interaction)
} else {
await this.bot.slashCommandsMap
.get('resume')!
.execute(this.interaction)
}
break
case '🔇':
reaction.users.remove(user).catch(console.error)
if (!canModifyQueue(member))
return i18n.__('common.errorNotChannel')
this.muted = !this.muted
if (this.muted) {
this.resource.volume?.setVolumeLogarithmic(0)
this.textChannel
.send(i18n.__mf('play.mutedSong', { author: user }))
.catch(console.error)
} else {
this.resource.volume?.setVolumeLogarithmic(
this.volume / 100
)
this.textChannel
.send(
i18n.__mf('play.unmutedSong', { author: user })
)
.catch(console.error)
}
break
case '🔉':
reaction.users.remove(user).catch(console.error)
if (this.volume == 0) return
if (!canModifyQueue(member))
return i18n.__('common.errorNotChannel')
this.volume = Math.max(this.volume - 10, 0)
this.resource.volume?.setVolumeLogarithmic(
this.volume / 100
)
this.textChannel
.send(
i18n.__mf('play.decreasedVolume', {
author: user,
volume: this.volume,
})
)
.catch(console.error)
break
case '🔊':
reaction.users.remove(user).catch(console.error)
if (this.volume == 100) return
if (!canModifyQueue(member))
return i18n.__('common.errorNotChannel')
this.volume = Math.min(this.volume + 10, 100)
this.resource.volume?.setVolumeLogarithmic(
this.volume / 100
)
this.textChannel
.send(
i18n.__mf('play.increasedVolume', {
author: user,
volume: this.volume,
})
)
.catch(console.error)
break
case '🔁':
reaction.users.remove(user).catch(console.error)
await this.bot.slashCommandsMap
.get('loop')!
.execute(this.interaction)
break
case '🔀':
reaction.users.remove(user).catch(console.error)
await this.bot.slashCommandsMap
.get('shuffle')!
.execute(this.interaction)
break
case '⏹':
reaction.users.remove(user).catch(console.error)
await this.bot.slashCommandsMap
.get('stop')!
.execute(this.interaction)
collector.stop()
break
default:
reaction.users.remove(user).catch(console.error)
break
}
})
collector.on('end', () => {
playingMessage.reactions.removeAll().catch(console.error)
if (config.PRUNING) {
setTimeout(() => {
playingMessage.delete().catch()
}, 3000)
}
})
}
}

View File

@ -0,0 +1,8 @@
/* eslint-disable no-useless-escape */
export const videoPattern =
/^(https?:\/\/)?(www\.)?(m\.|music\.)?(youtube\.com|youtu\.?be)\/.+$/
export const playlistPattern = /^.*(list=)([^#\&\?]*).*/
export const scRegex = /^https?:\/\/(soundcloud\.com)\/(.*)$/
export const mobileScRegex = /^https?:\/\/(soundcloud\.app\.goo\.gl)\/(.*)$/
export const isURL =
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/

View File

@ -0,0 +1,45 @@
import youtube, { Playlist as YoutubePlaylist } from 'youtube-sr'
import config from '../../config/music.json'
import { Song } from './song'
// eslint-disable-next-line no-useless-escape
const pattern = /^.*(youtu.be\/|list=)([^#\&\?]*).*/i
export class Playlist {
public data: YoutubePlaylist
public videos: Song[]
public constructor(playlist: YoutubePlaylist) {
this.data = playlist
this.videos = this.data.videos
.filter(
(video) =>
video.title != 'Private video' &&
video.title != 'Deleted video'
)
.slice(0, config.MAX_PLAYLIST_SIZE - 1)
.map((video) => {
return new Song({
title: video.title!,
url: `https://youtube.com/watch?v=${video.id}`,
duration: video.duration / 1000,
})
})
}
public static async from(url = '', search = '') {
const urlValid = pattern.test(url)
let playlist
if (urlValid) {
playlist = await youtube.getPlaylist(url)
} else {
const result = await youtube.searchOne(search, 'playlist')
playlist = await youtube.getPlaylist(result.url!)
}
return new this(playlist)
}
}

View File

@ -0,0 +1,4 @@
import { GuildMember } from 'discord.js'
export const canModifyQueue = (member: GuildMember) =>
member.voice.channelId === member.guild.members.me!.voice.channelId

View File

@ -0,0 +1,93 @@
import { AudioResource, createAudioResource } from '@discordjs/voice'
import youtube from 'youtube-sr'
import { videoPattern, isURL } from './patterns'
import { i18n } from './i18n'
import {
SoundCloudStream,
stream,
video_basic_info,
YouTubeStream,
} from 'play-dl'
export interface SongData {
url: string
title: string
duration: number
}
export class Song {
public readonly url: string
public readonly title: string
public readonly duration: number
public constructor({ url, title, duration }: SongData) {
this.url = url
this.title = title
this.duration = duration
}
public static async from(url = '', search = '') {
const isYoutubeUrl = videoPattern.test(url)
let songInfo
if (isYoutubeUrl) {
songInfo = (await video_basic_info(url)) as any
return new this({
url: songInfo.video_details.url,
title: songInfo.video_details.title,
duration: parseInt(songInfo.video_details.durationInSec),
})
} else {
const result = await youtube.searchOne(search)
result ? null : console.log(`No results found for ${search}`) // This is for handling the case where no results are found (spotify links for example)
if (!result) {
const err = new Error(`No search results found for ${search}`)
err.name = 'NoResults'
if (isURL.test(url)) err.name = 'InvalidURL'
throw err
}
songInfo = (await video_basic_info(
`https://youtube.com/watch?v=${result.id}`
)) as any
return new this({
url: songInfo.video_details.url,
title: songInfo.video_details.title,
duration: parseInt(songInfo.video_details.durationInSec),
})
}
}
public async makeResource(): Promise<AudioResource<Song> | void> {
let playStream: YouTubeStream | SoundCloudStream | any = null
// let type = this.url.includes("youtube.com") ? StreamType.Opus : StreamType.OggOpus;
const source = this.url.includes('youtube') ? 'youtube' : 'soundcloud'
if (source === 'youtube') {
playStream = await stream(this.url)
}
if (!stream) return
return createAudioResource(playStream.stream, {
metadata: this,
inputType: playStream.type,
inlineVolume: true,
})
}
public startMessage() {
return i18n.__mf('play.startedPlaying', {
title: this.title,
url: this.url,
})
}
}

View File

@ -18,7 +18,7 @@
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noImplicitReturns": false,
"noImplicitThis": true,
"noUncheckedIndexedAccess": false,
@ -26,10 +26,10 @@
"noUnusedParameters": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
// "strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"strictPropertyInitialization": true,
"strictPropertyInitialization": false,
"target": "esnext",
"outDir": "./dist"
},