diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d731f3c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,98 @@ +Dockerfile +.dockerignore + +# See http://help.github.com/ignore-files/ for more about ignoring files. + +/build +/configs/* +!/configs/*.example.json + +# compiled output +/dist +/tmp +/out-tsc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# IDEs and editors +.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +#!.vscode/settings.json Not needed for now +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +.sass-cache +connect.lock +typings + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + + +# Dependency directories +node_modules/ +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next + +# Lerna +lerna-debug.log + +# System Files +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a73c170 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +NODE_ENV=development +#development +#production + +DATABASE_URL=postgresql://user:password@host:port/dbName?schema=public + +DISCORD_TOKEN=SuperSecretDiscordToken +DEVELOPER_IDS=DEV1_ID,DEV2_ID +PRIVATE_BOT=true + +OSU_API_KEY=SuperSecretosu!Key +YOUTUBE_COOKIE_BASE64=SuperSecretCookie_EncodeItInBase64_AndThen_RemoveTheLastEqualToo \ No newline at end of file diff --git a/.gitignore b/.gitignore index 80d3a0c..11a898b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ # See http://help.github.com/ignore-files/ for more about ignoring files. /build -/configs +/configs/* +!/configs/*.example.json # compiled output /dist diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..92ea1f6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM debian + +RUN mkdir -p /home/node/app/node_modules +WORKDIR /home/node/app + +COPY package*.json ./ +COPY prisma ./prisma/ + +RUN DEBIAN_FRONTEND=noninteractive + +RUN apt-get update -y +RUN apt-get -y install curl gnupg git python2 make g++ iputils-ping ffmpeg +RUN curl -sL https://deb.nodesource.com/setup_17.x | bash - +RUN apt-get -y install nodejs + +RUN npm i -g yarn + +RUN yarn +RUN yarn prisma generate +COPY . . + +RUN yarn build +CMD [ "yarn", "start"] \ No newline at end of file diff --git a/configs/Ping.example.json b/configs/Ping.example.json new file mode 100644 index 0000000..f22d237 --- /dev/null +++ b/configs/Ping.example.json @@ -0,0 +1,15 @@ +[ + { + "type": "ping", + "title": "☁️ Internet | ", + "host": "1.1.1.1" + }, + { + "type": "discordwebsocket", + "title": "🚀 Discord Websocket | " + }, + { + "type": "discordhttp", + "title": "🏓 Discord HTTP | " + } +] diff --git a/configs/ServiceAnnouncement.example.json b/configs/ServiceAnnouncement.example.json new file mode 100644 index 0000000..f28b9c8 --- /dev/null +++ b/configs/ServiceAnnouncement.example.json @@ -0,0 +1,55 @@ +{ + "News": { + "color": "#FAEDF0", + "title": "📰 Newsletter", + "description": "\u200B\n**{bot_username} have been super-charged and upgraded!**\nThe long-awaited update is now here, running latest and greatest version!\n\n**What's new?**\n\u200B\n", + "fields": [ + { + "name": "🔥 Feature 1!", + "value": "\u200B\nSuch wow! New feature so cool 10/10!!!!\n\u200B\n", + "inline": false + }, + { + "name": "✨ Shiny and new feature!", + "value": "\u200B\nNow brighter than ever!\n\u200B\n", + "inline": false + }, + { + "name": "🪲 Bugs squashed", + "value": "\u200B\nBe gone, bugs!\n\u200B\n", + "inline": false + } + ], + "thumbnail": { + "url": "bot_avatar" + } + }, + "Maintenance": { + "color": "#383b80", + "title": "🔧 Maintenance", + "description": "{bot_username} will be down for maintenance\nThe maintenance includes feature improvement to bug fixes\n\nDuring the maintenance, {bot_username}'s commands and features will be unavailable", + "fields": [ + { "name": "Start", "value": "", "inline": true}, + { "name": "End", "value": "", "inline": true} + ], + "thumbnail": { + "url": "bot_avatar" + } + }, + "Message": { + "color": "#A1DE93", + "title": "✉️ Message", + "description": "Hello everyone!", + "thumbnail": { + "url": "bot_avatar" + } + }, + "Alert": { + "color": "#EC255A", + "title": "🚨 Alert", + "description": "{bot_username} currently has an issue with lack of food, {bot_username} need more FOOD! MORE AND MORE!.\nWe're fixing it ASAP", + "thumbnail": { + "url": "bot_avatar" + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index 3c9dc64..0acf66e 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,29 @@ { "dependencies": { "@discordjs/builders": "^0.7.0", + "@discordjs/opus": "^0.7.0", + "@discordjs/rest": "^0.3.0", + "@discordjs/voice": "^0.8.0", "@types/color-thief-node": "^1.0.1", "bluebird": "^3.7.2", + "bufferutil": "^4.0.6", "color-thief-node": "^1.0.4", "country-code-emoji": "^2.3.0", "country-code-lookup": "^0.0.19", + "discord-api-types": "^0.26.1", "discord.js": "^13.6.0", "dotenv": "^10.0.0", + + "libsodium-wrappers": "^0.7.9", "node-osu": "^2.2.1", "ping": "^0.4.1", + "play-dl": "^1.8.6", "ts-custom-error": "^3.2.0", "typescript": "^4.4.4", + "utf-8-validate": "^5.0.8", "validator": "^13.7.0", - "winston": "^3.3.3" + "winston": "3.3.3", + "zlib-sync": "^0.1.7" }, "devDependencies": { "@prisma/client": "^3.3.0", @@ -27,6 +37,8 @@ "scripts": { "tsc": "tsc", "development": "ts-node-dev ./src/index.ts --respawn --transpileOnly", + "build": "tsc -b", + "start": "node ./build/index.js", "production": "tsc && node ./build/index.js", "test": "mocha -r ts-node/register tests/**/*.ts" }, diff --git a/src/discord/Help.ts b/src/discord/Help.ts index b16ce26..6596cf3 100644 --- a/src/discord/Help.ts +++ b/src/discord/Help.ts @@ -17,22 +17,37 @@ const EMBEDS = { let _e = makeInfoEmbed({ icon: "💌", title: `Help - ${DiscordProvider.client.user?.username}`, + description: `\u200b\n📰 **Info**\nYumi is currently undergoing complete rewrite, as expected, losing many of her functionality. All of the features will be re-implemented.\n\n🤔 **Why?**\nYumi was specifically written for use in a only 1 private server, now it's time to extend the border and globalize it (just kidding lol)\n\n🏷️ **Prefix**\nYou can call me using \`\`${prefix.replaceAll('`','`​')}\`\`, <@${DiscordProvider.client.user?.id}> or \`\`/slash command\`\`\n\n💻 **Available commands**`, fields: [ { - name: 'Info', - value: `Yumi is currently undergoing complete rewrite, as expected, losing many of her functionality. All of the features will be re-implemented.` + name: '☕ General', + value: `help, ping, invite, userinfo`, + inline: true }, { - name: 'Why?', - value: `Yumi was specifically written for use in a only 1 private server, now it's time to extend the border and globalize it (just kidding lol)` + name: '🎵 Music', + value: `play (p), search, skip, queue, nowplaying (np), join, leave`, + inline: true }, { - name: 'Prefix', - value: `You can call me using \`\`${prefix.replaceAll('`','`​')}\`\`, <@${DiscordProvider.client.user?.id}> or \`\`/slash command\`\`` + name: '🎮 Games', + value: `osu`, + inline: true }, { - name: 'Available commands', - value: '``help``\n``ping``\n``invite``\n``osu``\n``membershipscreening (ms)``\n``interaction`' + name: '🔐 Admin', + value: `settings, say, membershipscreening (ms)`, + inline: true + }, + { + name: '🔧 Developer', + value: 'interactions, serviceannouncement', + inline: true + }, + { + name: '\u200b', + value: '**Made with 💖 and [open source](https://github.com/YuzuZensai/Yumi)**', + inline: false } ], user: (data instanceof Interaction) ? data.user : data.author diff --git a/src/discord/MusicPlayer/Join.ts b/src/discord/MusicPlayer/Join.ts new file mode 100644 index 0000000..870f23e --- /dev/null +++ b/src/discord/MusicPlayer/Join.ts @@ -0,0 +1,221 @@ +import { Message, CommandInteraction, Interaction, VoiceChannel, Permissions, GuildMember, DMChannel, StageChannel, MessageActionRow, MessageButton, MessageAttachment, TextChannel } from "discord.js"; +import { getEmotes, makeSuccessEmbed, makeErrorEmbed, sendMessage, sendMessageOrInteractionResponse, makeInfoEmbed } from "../../utils/DiscordMessage"; +import DiscordProvider from "../../providers/Discord"; +import Users from "../../services/Users" +import Environment from "../../providers/Environment"; +import DiscordMusicPlayer, { PlayerPlayingEvent, ValidTracks, DiscordMusicPlayerInstance } from "../../providers/DiscordMusicPlayer"; +import Discord from "../../providers/Discord"; + +const EMBEDS = { + VOICECHANNEL_JOINED: (data: Message | Interaction) => { + return makeSuccessEmbed({ + title: `Success`, + description: `Joined voice channel`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + VOICECHANNEL_INUSE: (data: Message | Interaction) => { + + let haveForceMove = (data instanceof Message) ? (data as Message).member!.permissions.has([Permissions.FLAGS.MOVE_MEMBERS]) : ((data as Interaction).member! as GuildMember).permissions.has([Permissions.FLAGS.MOVE_MEMBERS]); + + return makeErrorEmbed({ + title: `I can't open portal to parallel universe`, + description: `I can't join mutiple voice channel on the same guild. + I wish I had a superpower, maybe... one day I will. + + There are also somebody listening to the music in the voice channel I'm currently in.${!haveForceMove ? ` Wait until I finish playing there or I'm alone there. Better yet, join them!` : " Wait until I finish playing there or I'm alone there. Better yet, join them! \n\n**Or... just (ab)use your admin permissions and move me to where you want!**"}`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + VOICECHANNEL_ALREADY_JOINED: (data: Message | Interaction) => { + return makeInfoEmbed({ + title: `I'm already here`, + description: `Already in the voice channel you are currently connected to`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + USER_NOT_IN_VOICECHANNEL: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `You need to be in the voice channel first!`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + NOW_PLAYING: (data: Message | Interaction, track: ValidTracks) => { + const embed = makeInfoEmbed({ + title: ' Now playing', + icon: '🎵', + description: `${track.title}`, + user: DiscordProvider.client.user + }); + + embed.setImage(track.thumbnails[0].url); + return embed; + } +} + +export async function joinVoiceChannelProcedure (data: Interaction | Message, instance: (DiscordMusicPlayerInstance | null), voiceChannel: (VoiceChannel | StageChannel)) { + + const isSlashCommand = data instanceof CommandInteraction && data.isCommand(); + const isAcceptableInteraction = data instanceof Interaction && data.isSelectMenu(); + const isMessage = data instanceof Message; + if ((!isSlashCommand && !isAcceptableInteraction) && !isMessage) return; + + if (!data.member) return; + if (!data.channel) return; + if (!data.guildId) return; + + if (data.channel instanceof DMChannel) return; + if (!(data.channel instanceof TextChannel)) return; + + const channel: any = isMessage ? data.member.voice.channel : DiscordProvider.client.guilds.cache.get((data as Interaction).guildId!)!.members.cache.get((data as Interaction).user.id)?.voice.channel; + if (!channel) return; + + + // If already in VoiceChannel + if (DiscordProvider.client.guilds.cache.get(data.guildId!)!.me!.voice.channelId) { + // But, no music instance yet (The bot might just restarted) + if (!instance) { + // User is in different VoiceChannel + if (channel.id !== DiscordProvider.client.guilds.cache.get(data.guildId)?.me?.voice) { + //Disconnect it + await DiscordProvider.client.guilds.cache.get(data.guildId)?.me?.voice.disconnect(); + } + + //Create instance for a new one + DiscordMusicPlayer.createGuildInstance(data.guildId, voiceChannel); + instance = DiscordMusicPlayer.getGuildInstance(data.guildId); + + instance!.joinVoiceChannel(voiceChannel, data.channel); + if(!isAcceptableInteraction) + await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.VOICECHANNEL_JOINED(data)] }); + } + // And, already have instance on the guild + else { + // And, User VoiceChannel is same as the instance + if (channel.id === instance.voiceChannel.id) { + if(!isAcceptableInteraction) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.VOICECHANNEL_ALREADY_JOINED(data)] }); + else return; + } + // But, not the same VoiceChannel + else { + let activeMembers = instance.voiceChannel.members.filter(member => member.user.bot === false); + // And someone else is using the bot + if(activeMembers.size > 0) { + if(!isAcceptableInteraction) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.VOICECHANNEL_INUSE(data)] }); + else return; + } + // And alone in the VoiceChannel + else { + // Move to the new voice channel + await DiscordProvider.client.guilds.cache.get(data.guildId)?.me?.voice.setChannel(voiceChannel); + if(!isAcceptableInteraction) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.VOICECHANNEL_JOINED(data)] }); + else return; + } + } + } + + } + // Not in any VoiceChannel + else { + + if (instance) + await DiscordMusicPlayer.destoryGuildInstance(data.guildId!); + + DiscordMusicPlayer.createGuildInstance(data.guildId, voiceChannel); + instance = DiscordMusicPlayer.getGuildInstance(data.guildId); + + instance!.joinVoiceChannel(voiceChannel, data.channel); + if(!isAcceptableInteraction) + await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.VOICECHANNEL_JOINED(data)] }); + } + + if (!instance) return; + + let nowPlayingMessage: any; + + // Register Event Listeners + instance.events.on('playing', async (event: PlayerPlayingEvent) => { + + const row = new MessageActionRow() + .addComponents( + new MessageButton() + .setEmoji('▶️') + .setLabel(' Open on YouTube') + .setURL(encodeURI(`https://www.youtube.com/watch?v=${event.instance.queue.track[0].id}`)) + .setStyle('LINK'), + ) + + if (event.instance.textChannel) { + await sendMessage(event.instance.textChannel, undefined, { embeds: [EMBEDS.NOW_PLAYING(data, event.instance.queue.track[0])], components: [row] }); + } + }) +} + +export default class Join { + + + async onCommand(command: string, args: any, message: Message) { + if (command.toLowerCase() !== 'join') return; + await this.process(message, args); + } + + async interactionCreate(interaction: CommandInteraction) { + if (interaction.isCommand()) { + if (typeof interaction.commandName === 'undefined') return; + if ((interaction.commandName).toLowerCase() !== 'join') return; + await this.process(interaction, interaction.options); + } + } + + async process(data: Interaction | Message, args: any) { + const isSlashCommand = data instanceof CommandInteraction && data.isCommand(); + const isMessage = data instanceof Message; + + if (!isSlashCommand && !isMessage) return; + + if (!data.member) return; + + this.joinCommands(data, args); + + } + + public async joinCommands(data: Interaction | Message, args: any) { + + const isSlashCommand = data instanceof CommandInteraction && data.isCommand(); + const isMessage = data instanceof Message; + if (!isSlashCommand && !isMessage) return; + + if (!data.member) return; + if (!data.channel) return; + if (!data.guildId) return; + + if (data.channel instanceof DMChannel) return; + if (!(data.channel instanceof TextChannel)) return; + + const channel: any = isMessage ? data.member.voice.channel : DiscordProvider.client.guilds.cache.get((data as Interaction).guildId!)!.members.cache.get((data as Interaction).user.id)?.voice.channel; + if (!channel) return; + + if (!data.member.voice.channel) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.USER_NOT_IN_VOICECHANNEL(data)] }); + + let voiceChannel = data.member.voice.channel; + + + + //if(!DiscordMusicPlayer.isGuildInstanceExists(data.guildId)) { + // DiscordMusicPlayer.createGuildInstance(data.guildId, voiceChannel); + //} + + let instance = DiscordMusicPlayer.getGuildInstance(data.guildId); + + await joinVoiceChannelProcedure(data, instance!, voiceChannel); + + //if(!instance.isConnected()) + // instance.joinVoiceChannel(voiceChannel, data.channel ? data.channel : undefined); + + + } +} \ No newline at end of file diff --git a/src/discord/MusicPlayer/Leave.ts b/src/discord/MusicPlayer/Leave.ts new file mode 100644 index 0000000..be8645f --- /dev/null +++ b/src/discord/MusicPlayer/Leave.ts @@ -0,0 +1,80 @@ +import { Message, CommandInteraction, Interaction, VoiceChannel } from "discord.js"; +import { getEmotes, makeSuccessEmbed, makeErrorEmbed, sendMessage, sendMessageOrInteractionResponse, makeInfoEmbed } from "../../utils/DiscordMessage"; +import DiscordProvider from "../../providers/Discord"; +import Users from "../../services/Users" +import Environment from "../../providers/Environment"; +import DiscordMusicPlayer, { DiscordMusicPlayerInstance } from "../../providers/DiscordMusicPlayer"; + +const EMBEDS = { + VOICECHANNEL_LEFT: (data: Message | Interaction) => { + return makeSuccessEmbed({ + title: `Success`, + description: `Left the voice channel`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + USER_NOT_IN_VOICECHANNEL: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `You need to be in the voice channel first!`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + NO_MUSIC_PLAYING: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `There are no music playing`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + USER_NOT_IN_SAME_VOICECHANNEL: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `You are not in the same voice channel!`, + user: (data instanceof Interaction) ? data.user : data.author + }); + } +} + +export default class Leave { + + async onCommand(command: string, args: any, message: Message) { + if(command.toLowerCase() !== 'leave') return; + await this.process(message, args); + } + + async interactionCreate(interaction: CommandInteraction) { + if(interaction.isCommand()) { + if(typeof interaction.commandName === 'undefined') return; + if((interaction.commandName).toLowerCase() !== 'leave') return; + await this.process(interaction, interaction.options); + } + } + + async process(data: Interaction | Message, args: any) { + const isSlashCommand = data instanceof CommandInteraction && data.isCommand(); + const isMessage = data instanceof Message; + + if(!isSlashCommand && !isMessage) return; + + if(!data.member) return; + if(!data.guildId) return; + + const channel: any = isMessage ? data.member.voice.channel : DiscordProvider.client.guilds.cache.get((data as Interaction).guildId!)!.members.cache.get((data as Interaction).user.id)?.voice.channel; + if (!data.member.voice.channel) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.USER_NOT_IN_VOICECHANNEL(data)] }); + + if(!channel) return; + + + let instance = DiscordMusicPlayer.getGuildInstance(data.guildId); + if(!instance) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.NO_MUSIC_PLAYING(data)] }); + + if(instance.voiceChannel.id !== data.member.voice.channel.id) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.USER_NOT_IN_SAME_VOICECHANNEL(data)] }); + + + DiscordMusicPlayer.destoryGuildInstance(data.guild?.id!); + await sendMessageOrInteractionResponse(data, { embeds:[EMBEDS.VOICECHANNEL_LEFT(data)] }); + + return; + } +} \ No newline at end of file diff --git a/src/discord/MusicPlayer/NowPlaying.ts b/src/discord/MusicPlayer/NowPlaying.ts new file mode 100644 index 0000000..7e8a3fb --- /dev/null +++ b/src/discord/MusicPlayer/NowPlaying.ts @@ -0,0 +1,70 @@ +import { Message, CommandInteraction, Interaction, MessageActionRow, TextChannel, MessageButton } from "discord.js"; +import { getEmotes, makeSuccessEmbed, makeErrorEmbed, sendMessage, sendMessageOrInteractionResponse, makeInfoEmbed } from "../../utils/DiscordMessage"; +import DiscordProvider from "../../providers/Discord"; +import Users from "../../services/Users" +import Environment from "../../providers/Environment"; +import DiscordMusicPlayer, { DiscordMusicPlayerInstance, ValidTracks, Queue } from "../../providers/DiscordMusicPlayer"; + +const EMBEDS = { + NOW_PLAYING: (data: Message | Interaction, track: ValidTracks) => { + const embed = makeInfoEmbed({ + title: ' Now playing', + icon: '🎵', + description: `${track.title}`, + user: DiscordProvider.client.user + }); + + embed.setImage(track.thumbnails[0].url); + return embed; + }, + NO_MUSIC_PLAYING: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `There are no music playing`, + user: (data instanceof Interaction) ? data.user : data.author + }); + } +} + +export default class NowPlaying { + + async onCommand(command: string, args: any, message: Message) { + if(command.toLowerCase() !== 'nowplaying' && command.toLowerCase() !== 'np') return; + await this.process(message, args); + } + + async interactionCreate(interaction: CommandInteraction) { + if(interaction.isCommand()) { + if(typeof interaction.commandName === 'undefined') return; + if((interaction.commandName).toLowerCase() !== 'nowplaying') return; + await this.process(interaction, interaction.options); + } + } + + async process(data: Interaction | Message, args: any) { + const isSlashCommand = data instanceof CommandInteraction && data.isCommand(); + const isMessage = data instanceof Message; + + if(!isSlashCommand && !isMessage) return; + + if(!data.member) return; + if(!data.guildId) return; + if(!(data.channel instanceof TextChannel)) return; + + //const channel: any = isMessage ? data.member.voice.channel : DiscordProvider.client.guilds.cache.get((data as Interaction).guildId!)!.members.cache.get((data as Interaction).user.id)?.voice.channel; + //if(!channel) return; + + const instance = DiscordMusicPlayer.getGuildInstance(data.guildId); + if(!instance || !instance.queue.track[0]) return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.NO_MUSIC_PLAYING(data)] }); + + const row = new MessageActionRow() + .addComponents( + new MessageButton() + .setEmoji('▶️') + .setLabel(' Open on YouTube') + .setURL(encodeURI(`https://www.youtube.com/watch?v=${instance.queue.track[0].id}`)) + .setStyle('LINK'), + ) + + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.NOW_PLAYING(data, instance.queue.track[0])], components: [row] }); + } +} \ No newline at end of file diff --git a/src/discord/MusicPlayer/Play.ts b/src/discord/MusicPlayer/Play.ts new file mode 100644 index 0000000..1a50b53 --- /dev/null +++ b/src/discord/MusicPlayer/Play.ts @@ -0,0 +1,204 @@ +import { Message, CommandInteraction, Interaction, VoiceChannel, MessageActionRow, Permissions, DMChannel, TextChannel } from "discord.js"; +import { makeErrorEmbed, makeSuccessEmbed, makeProcessingEmbed, sendMessage, sendMessageOrInteractionResponse, makeInfoEmbed } from "../../utils/DiscordMessage"; +import DiscordProvider from "../../providers/Discord"; +import Environment from "../../providers/Environment"; +import DiscordMusicPlayer, { ValidTracks } from "../../providers/DiscordMusicPlayer"; +import Prisma from "../../providers/Prisma"; +import { joinVoiceChannelProcedure } from "./Join"; + +const EMBEDS = { + YOUTUBE_ONLY: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `Only youtube.com link is supported for now`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + INVALID_LINK: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `Invalid Link`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + ADDED_QUEUE: (data: Message | Interaction, track: ValidTracks) => { + let embed = makeSuccessEmbed({ + title: `Song added to queue!`, + description: `${track.title}`, + user: (data instanceof Interaction) ? data.user : data.author + }); + embed.setImage(track.thumbnails[0].url); + return embed; + }, + USER_NOT_IN_VOICECHANNEL: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `You need to be in the voice channel first!`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + USER_NOT_IN_SAME_VOICECHANNEL: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `You are not in the same voice channel!`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, +} + + +export default class Play { + + async onCommand(command: string, args: any, message: Message) { + if (command.toLowerCase() !== 'play' && command.toLowerCase() !== 'p') return; + await this.process(message, args); + } + + async interactionCreate(interaction: Interaction) { + if (interaction.isCommand()) { + if (typeof interaction.commandName === 'undefined') return; + if ((interaction.commandName).toLowerCase() !== 'play') return; + await this.process(interaction, interaction.options); + } + else if (interaction.isSelectMenu()) { + if(!interaction.guild || !interaction.guildId) return; + if (!this.tryParseJSONObject(interaction.customId)) return; + + let payload = JSON.parse(interaction.customId); + if(!interaction.member?.user?.id) return; + + /* + Discord have 100 char custom id char limit + So we need to shorten our json. + MP_SM stands for MusicPlayer_SearchMenu + data.r stands for data.requester + data.v stands for data.voiceChannel + */ + if (typeof payload.module === 'undefined' || + typeof payload.action === 'undefined' || + payload.module !== 'MP_SM' || + payload.action !== 'play') return; + + await interaction.deferReply(); + + let voiceChannel = DiscordProvider.client.guilds.cache.get(interaction.guildId)?.channels.cache.get(payload.data.v); + let member = DiscordProvider.client.guilds.cache.get(interaction.guildId)?.members.cache.get(interaction.member?.user?.id); + + if(!member) return; + if (!voiceChannel || !(voiceChannel instanceof VoiceChannel)) return; + + + if (!member.voice.channel) + return await sendMessageOrInteractionResponse(interaction, { embeds: [EMBEDS.USER_NOT_IN_VOICECHANNEL(interaction)] }, true); + + if (!DiscordMusicPlayer.isGuildInstanceExists(interaction.guildId)) { + await joinVoiceChannelProcedure(interaction, null, voiceChannel); + } + + let instance = DiscordMusicPlayer.getGuildInstance(interaction.guildId); + + if(instance!.voiceChannel.id !== member.voice.channel.id) + return await sendMessageOrInteractionResponse(interaction, { embeds: [EMBEDS.USER_NOT_IN_SAME_VOICECHANNEL(interaction)] }, true); + + if (!instance!.isConnected()) { + await joinVoiceChannelProcedure(interaction, instance!, voiceChannel); + instance = DiscordMusicPlayer.getGuildInstance(interaction.guildId); + } + + if (!instance) return; + + let result = await DiscordMusicPlayer.searchYouTubeByYouTubeLink(DiscordMusicPlayer.parseYouTubeLink(interaction.values[0])); + if (!result) return; + + instance.addTrackToQueue(result); + //interaction.editReply({ embeds: [EMBEDS.ADDED_QUEUE(interaction, result)] }); + return await sendMessageOrInteractionResponse(interaction, { embeds: [EMBEDS.ADDED_QUEUE(interaction, result)] }, true); + } + } + + async process(data: Interaction | Message, args: any) { + const isSlashCommand = data instanceof CommandInteraction && data.isCommand(); + const isMessage = data instanceof Message; + + if (!isSlashCommand && !isMessage) return; + + if (!data.member) return; + if (!data.guild) return; + if (!data.guildId) return; + + let query; + + if (isMessage) { + if (data === null || !data.guildId || data.member === null || data.guild === null) return; + + if (args.length === 0) { + // TODO: Show info + //return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.MSINFO(data)] }); + return; + } + query = args[0]; + + } + else if (isSlashCommand) { + query = args.getSubcommand(); + } + + if (!query) return; + if (!(data.channel instanceof TextChannel)) return; + + if (!data.member.voice.channel) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.USER_NOT_IN_VOICECHANNEL(data)] }); + + let voiceChannel = data.member.voice.channel; + + if (!DiscordMusicPlayer.isGuildInstanceExists(data.guildId)) { + await joinVoiceChannelProcedure(data, null, voiceChannel); + } + + let instance = DiscordMusicPlayer.getGuildInstance(data.guildId); + + if(instance!.voiceChannel.id !== data.member.voice.channel.id) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.USER_NOT_IN_SAME_VOICECHANNEL(data)] }, true); + + if (!instance!.isConnected()) { + await joinVoiceChannelProcedure(data, instance!, voiceChannel); + instance = DiscordMusicPlayer.getGuildInstance(data.guildId); + } + + if (!instance) return; + + if (DiscordMusicPlayer.isYouTubeLink(query)) { + let result = await DiscordMusicPlayer.searchYouTubeByYouTubeLink(DiscordMusicPlayer.parseYouTubeLink(query)); + + if (!result) return; + + if (isMessage && (data as Message).embeds.length > 0) { + (data as Message).suppressEmbeds(true); + } + + instance.addTrackToQueue(result); + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.ADDED_QUEUE(data, result)] }); + } else { + let result = await DiscordMusicPlayer.searchYouTubeByQuery(query); + + if (!result) return; + instance.addTrackToQueue(result[0]); + + // TODO: Let user choose the video + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.ADDED_QUEUE(data, result[0])] }); + } + + return; + } + + tryParseJSONObject(jsonString: string) { + try { + let o = JSON.parse(jsonString); + if (o && typeof o === "object") { + return true; + //return o; + } + } + catch (e) { } + + return false; + } + + +} \ No newline at end of file diff --git a/src/discord/MusicPlayer/Queue.ts b/src/discord/MusicPlayer/Queue.ts new file mode 100644 index 0000000..069e85c --- /dev/null +++ b/src/discord/MusicPlayer/Queue.ts @@ -0,0 +1,86 @@ +import { Message, CommandInteraction, Interaction, VoiceChannel, TextChannel } from "discord.js"; +import { getEmotes, makeSuccessEmbed, makeErrorEmbed, sendMessage, sendMessageOrInteractionResponse, makeInfoEmbed } from "../../utils/DiscordMessage"; +import DiscordProvider from "../../providers/Discord"; +import Users from "../../services/Users" +import Environment from "../../providers/Environment"; +import DiscordMusicPlayer, { DiscordMusicPlayerInstance, ValidTracks, Queue } from "../../providers/DiscordMusicPlayer"; + +const EMBEDS = { + QUEUE: (data: Message | Interaction, queue: Queue) => { + if(!queue.track[0]) + return makeInfoEmbed({ + title: `Queue`, + description: `The queue is empty!`, + user: (data instanceof Interaction) ? data.user : data.author + }); + else { + if(queue.track.length == 1) { + let queueString = `1. [${queue.track[0].title}](${queue.track[0].url})` + return makeInfoEmbed({ + title: `Queue`, + description: `Now playing: ${queue.track[0].title}\n\nThere are ${queue.track.length} song in the queue!\n${queueString}`, + user: (data instanceof Interaction) ? data.user : data.author + }); + } + else if(queue.track.length >= 10) { + let first10 = queue.track.slice(0, 10); + let queueString = first10.map((track, index) => `${index + 1}. [${track.title}](${track.url})`).join("\n"); + return makeInfoEmbed({ + title: `Queue`, + description: `Now playing: ${queue.track[0].title}\nUpcoming song: ${queue.track[1].title}\n\nThere are ${queue.track.length} songs in the queue!\n${queueString}\n${queue.track.length > 10 ? `...${queue.track.length - 10} more songs` : ""}`, + user: (data instanceof Interaction) ? data.user : data.author + }); + } else { + let queueString = queue.track.map((track, index) => `${index + 1}. [${track.title}](${track.url})`).join("\n"); + return makeInfoEmbed({ + title: `Queue`, + description: `Now playing: ${queue.track[0].title}\nUpcoming song: ${queue.track[1].title}\n\nThere are ${queue.track.length} songs in the queue!\n${queueString}`, + user: (data instanceof Interaction) ? data.user : data.author + }); + } + } + }, + NO_MUSIC_PLAYING: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `There are no music playing`, + user: (data instanceof Interaction) ? data.user : data.author + }); + } +} + +export default class QueueCommand { + + async onCommand(command: string, args: any, message: Message) { + if(command.toLowerCase() !== 'queue') return; + await this.process(message, args); + } + + async interactionCreate(interaction: CommandInteraction) { + if(interaction.isCommand()) { + if(typeof interaction.commandName === 'undefined') return; + if((interaction.commandName).toLowerCase() !== 'queue') return; + await this.process(interaction, interaction.options); + } + } + + async process(data: Interaction | Message, args: any) { + const isSlashCommand = data instanceof CommandInteraction && data.isCommand(); + const isMessage = data instanceof Message; + + if(!isSlashCommand && !isMessage) return; + + if(!data.member) return; + if(!data.guildId) return; + if(!(data.channel instanceof TextChannel)) return; + + //const channel: any = isMessage ? data.member.voice.channel : DiscordProvider.client.guilds.cache.get((data as Interaction).guildId!)!.members.cache.get((data as Interaction).user.id)?.voice.channel; + //if(!channel) return; + + const instance = DiscordMusicPlayer.getGuildInstance(data.guildId); + if(!instance) return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.NO_MUSIC_PLAYING(data)] }); + + await sendMessageOrInteractionResponse(data, { embeds:[EMBEDS.QUEUE(data, instance.queue)] }); + + return; + } +} \ No newline at end of file diff --git a/src/discord/MusicPlayer/Search.ts b/src/discord/MusicPlayer/Search.ts new file mode 100644 index 0000000..54ea4d1 --- /dev/null +++ b/src/discord/MusicPlayer/Search.ts @@ -0,0 +1,158 @@ +import { Message, CommandInteraction, Interaction, VoiceChannel, MessageActionRow, Permissions, DMChannel, TextChannel, MessageSelectMenu, MessageSelectOptionData } from "discord.js"; +import { makeErrorEmbed, makeSuccessEmbed, makeProcessingEmbed, sendMessage, sendMessageOrInteractionResponse, makeInfoEmbed } from "../../utils/DiscordMessage"; +import DiscordProvider from "../../providers/Discord"; +import Environment from "../../providers/Environment"; +import DiscordMusicPlayer, { ValidTracks } from "../../providers/DiscordMusicPlayer"; +import Prisma from "../../providers/Prisma"; +import { joinVoiceChannelProcedure } from "./Join"; + +const EMBEDS = { + SEARCH_RESULT: (data: Message | Interaction, result: ValidTracks[]) => { + return makeInfoEmbed({ + title: `Search result`, + description: `${result.length} results found\n\n${result.map(track => `- [${track.title}](${track.url})`).join('\n')}`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + INVALID_LINK: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `Invalid Link`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + ADDED_QUEUE: (data: Message | Interaction, track: ValidTracks) => { + let embed = makeSuccessEmbed({ + title: `Song added to queue!`, + description: `${track.title}`, + user: (data instanceof Interaction) ? data.user : data.author + }); + embed.setImage(track.thumbnails[0].url); + return embed; + }, + USER_NOT_IN_VOICECHANNEL: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `You need to be in the voice channel first!`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + USER_NOT_IN_SAME_VOICECHANNEL: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `You are not in the same voice channel!`, + user: (data instanceof Interaction) ? data.user : data.author + }); + } +} + + +export default class Search { + + async onCommand(command: string, args: any, message: Message) { + if (command.toLowerCase() !== 'search') return; + await this.process(message, args); + } + + async interactionCreate(interaction: CommandInteraction) { + if (interaction.isCommand()) { + if (typeof interaction.commandName === 'undefined') return; + if ((interaction.commandName).toLowerCase() !== 'search') return; + await this.process(interaction, interaction.options); + } + } + + async process(data: Interaction | Message, args: any) { + const isSlashCommand = data instanceof CommandInteraction && data.isCommand(); + const isMessage = data instanceof Message; + + if (!isSlashCommand && !isMessage) return; + + if (!data.member) return; + if (!data.guild) return; + if (!data.guildId) return; + + let query; + + if (isMessage) { + if (data === null || !data.guildId || data.member === null || data.guild === null) return; + + if (args.length === 0) { + // TODO: Show info + //return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.MSINFO(data)] }); + return; + } + query = args[0]; + + } + else if (isSlashCommand) { + query = args.getSubcommand(); + } + + if (!query) return; + if (!(data.channel instanceof TextChannel)) return; + + + if (!data.member.voice.channel) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.USER_NOT_IN_VOICECHANNEL(data)] }); + + let voiceChannel = data.member.voice.channel; + + if (!DiscordMusicPlayer.isGuildInstanceExists(data.guildId)) { + await joinVoiceChannelProcedure(data, null, voiceChannel); + } + + let instance = DiscordMusicPlayer.getGuildInstance(data.guildId); + + if (instance!.voiceChannel.id !== voiceChannel.id) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.USER_NOT_IN_SAME_VOICECHANNEL(data)] }); + + if (!instance!.isConnected()) { + await joinVoiceChannelProcedure(data, instance!, voiceChannel); + instance = DiscordMusicPlayer.getGuildInstance(data.guildId); + } + + if (!instance) return; + + let result = await DiscordMusicPlayer.searchYouTubeByQuery(query); + + if (!result) return; + + const menuOptions: MessageSelectOptionData[] = []; + + const messageSelectMenu = new MessageSelectMenu(); + /* + Discord have 100 char custom id char limit + So we need to shorten our json. + MP_SM stands for MusicPlayer_SearchMenu + data.r stands for data.requester + data.v stands for data.voiceChannel + */ + messageSelectMenu.setCustomId(JSON.stringify({ + module: 'MP_SM', + action: 'play', + data: { + r: data.member.id, + v: voiceChannel.id + } + })) + messageSelectMenu.setPlaceholder('Nothing selected'); + + for(let song of result) { + if(!song.title) continue; + menuOptions.push({ + label: song.title, + description: `${song.durationInSec} sec | ${song.views} views`, + value: song.url + }); + } + + messageSelectMenu.addOptions(menuOptions); + + const row = new MessageActionRow(); + row.addComponents(messageSelectMenu); + + await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.SEARCH_RESULT(data, result)], components: [row] }); + + return; + } + + +} \ No newline at end of file diff --git a/src/discord/MusicPlayer/Skip.ts b/src/discord/MusicPlayer/Skip.ts new file mode 100644 index 0000000..d72a94a --- /dev/null +++ b/src/discord/MusicPlayer/Skip.ts @@ -0,0 +1,92 @@ +import { Message, CommandInteraction, Interaction, VoiceChannel } from "discord.js"; +import { getEmotes, makeSuccessEmbed, makeErrorEmbed, sendMessage, sendMessageOrInteractionResponse, makeInfoEmbed } from "../../utils/DiscordMessage"; +import DiscordProvider from "../../providers/Discord"; +import Users from "../../services/Users" +import Environment from "../../providers/Environment"; +import DiscordMusicPlayer from "../../providers/DiscordMusicPlayer"; + +const EMBEDS = { + SKIPPED: (data: Message | Interaction) => { + return makeSuccessEmbed({ + title: `Skipped`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + SKIPPED_LASTSONG: (data: Message | Interaction) => { + return makeSuccessEmbed({ + title: `Skipped, that was the last song`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + NO_MUSIC_PLAYING: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `There are no music playing`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + USER_NOT_IN_VOICECHANNEL: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `You need to be in the voice channel first!`, + user: (data instanceof Interaction) ? data.user : data.author + }); + }, + USER_NOT_IN_SAME_VOICECHANNEL: (data: Message | Interaction) => { + return makeErrorEmbed({ + title: `You are not in the same voice channel!`, + user: (data instanceof Interaction) ? data.user : data.author + }); + } +} + +export default class Skip { + + async onCommand(command: string, args: any, message: Message) { + if (command.toLowerCase() !== 'skip') return; + await this.process(message, args); + } + + async interactionCreate(interaction: CommandInteraction) { + if (interaction.isCommand()) { + if (typeof interaction.commandName === 'undefined') return; + if ((interaction.commandName).toLowerCase() !== 'skip') return; + await this.process(interaction, interaction.options); + } + } + + async process(data: Interaction | Message, args: any) { + const isSlashCommand = data instanceof CommandInteraction && data.isCommand(); + const isMessage = data instanceof Message; + + if (!isSlashCommand && !isMessage) return; + + if (!data.member) return; + if (!data.guildId) return; + + const channel: any = isMessage ? data.member.voice.channel : DiscordProvider.client.guilds.cache.get((data as Interaction).guildId!)!.members.cache.get((data as Interaction).user.id)?.voice.channel; + + if (!data.member.voice.channel) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.USER_NOT_IN_VOICECHANNEL(data)] }); + + if (!channel) return; + + let voiceChannel = data.member.voice.channel; + + if(!DiscordMusicPlayer.isGuildInstanceExists(data.guildId)) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.NO_MUSIC_PLAYING(data)] }); + + const instance = DiscordMusicPlayer.getGuildInstance(data.guildId); + + if(instance!.voiceChannel.id !== data.member.voice.channel.id) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.USER_NOT_IN_SAME_VOICECHANNEL(data)] }, true); + + if(instance?.queue.track.length === 0) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.NO_MUSIC_PLAYING(data)] }); + + instance!.skipTrack(); + + if(instance?.queue.track.length === 0) + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.SKIPPED_LASTSONG(data)] }); + else + return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.SKIPPED(data)] }); + } +} \ No newline at end of file diff --git a/src/discord/Ping.ts b/src/discord/Ping.ts index e4d899e..fb29cd7 100644 --- a/src/discord/Ping.ts +++ b/src/discord/Ping.ts @@ -14,31 +14,7 @@ enum MeasureType { DiscordWebsocket = "discordwebsocket" } -let measureList = [ - { - type: MeasureType.Ping, - title: "☁️ Internet | ", - host: "1.1.1.1" - }, - { - type: MeasureType.DiscordWebsocket, - title: "🚀 Discord Websocket | " - }, - { - type: MeasureType.DiscordHTTPPing, - title: "🏓 Discord HTTP | " - } -]; - -if(fs.existsSync(path.join(process.cwd(), 'configs/Ping.json'))) { - try { - const rawData = fs.readFileSync(path.join(process.cwd(), 'configs/Ping.json')); - const jsonData = JSON.parse(rawData.toString()); - measureList = jsonData; - } catch (err) { - Logger.error("Unable to load custom Ping config: " + err); - } -} +let measureList: any = []; const EMBEDS = { PING_INFO: (data: Message | Interaction, description: string) => { @@ -53,15 +29,29 @@ const EMBEDS = { export default class Ping { + async init() { + + if (fs.existsSync(path.join(process.cwd(), 'configs/Ping.json'))) { + try { + const rawData = fs.readFileSync(path.join(process.cwd(), 'configs/Ping.json')); + const jsonData = JSON.parse(rawData.toString()); + measureList = jsonData; + } catch (err) { + Logger.error("Unable to load custom Ping config: " + err); + } + } + + } + async onCommand(command: string, args: any, message: Message) { - if(command.toLowerCase() !== 'ping') return; + if (command.toLowerCase() !== 'ping') return; await this.process(message, args); } - async interactionCreate(interaction: CommandInteraction) { - if(interaction.isCommand()) { - if(typeof interaction.commandName === 'undefined') return; - if((interaction.commandName).toLowerCase() !== 'ping') return; + async interactionCreate(interaction: CommandInteraction) { + if (interaction.isCommand()) { + if (typeof interaction.commandName === 'undefined') return; + if ((interaction.commandName).toLowerCase() !== 'ping') return; await this.process(interaction, interaction.options); } } @@ -70,7 +60,7 @@ export default class Ping { const isSlashCommand = data instanceof CommandInteraction && data.isCommand(); const isMessage = data instanceof Message; - if(!isSlashCommand && !isMessage) return; + if (!isSlashCommand && !isMessage) return; let placeholder; @@ -81,7 +71,7 @@ export default class Ping { }); let beforeEditDate = Date.now(); - placeholder = await sendMessageOrInteractionResponse(data, { embeds:[loadingEmbed]} ); + placeholder = await sendMessageOrInteractionResponse(data, { embeds: [loadingEmbed] }); let afterEditDate = Date.now(); @@ -89,23 +79,23 @@ export default class Ping { // TODO: Optimize speed of this - for(let entry of measureList) { + for (let entry of measureList) { let stringCurrent = `${entry.title}`; - if(entry.type === MeasureType.Ping) { + if (entry.type === MeasureType.Ping) { const res = await NodePing.promise.probe(entry.host!, { min_reply: 4 }); - - if(!res.alive) { + + if (!res.alive) { stringCurrent += "Failed"; return desString.push(stringCurrent); } - - if(res.avg === 'unknown' || res.min === 'unknown' || res.max === 'unknown') { + + if (res.avg === 'unknown' || res.min === 'unknown' || res.max === 'unknown') { stringCurrent += "Failed"; return desString.push(stringCurrent); } - + stringCurrent += `${parseFloat(res.avg).toFixed(1)}ms (${parseFloat(res.min).toFixed(1)}ms - ${parseFloat(res.max).toFixed(1)}ms)`; desString.push(stringCurrent); } else if (entry.type === MeasureType.DiscordWebsocket) { @@ -114,15 +104,15 @@ export default class Ping { } else if (entry.type === MeasureType.DiscordHTTPPing) { stringCurrent += `${Math.abs(afterEditDate - beforeEditDate).toFixed(1)}ms`; desString.push(stringCurrent); - } - + } + } desString.push(""); desString.push("💻 Running on " + `${os.hostname()}${Environment.get().NODE_ENV === "development" ? ' / Development Environment' : ''}`); - if(isSlashCommand) return await data.editReply({ embeds: [EMBEDS.PING_INFO(data, desString.join('\n'))] }); - else if(typeof placeholder !== "undefined" && isMessage && placeholder instanceof Message) return await placeholder.edit({ embeds: [EMBEDS.PING_INFO(data, desString.join('\n'))] }); + if (isSlashCommand) return await data.editReply({ embeds: [EMBEDS.PING_INFO(data, desString.join('\n'))] }); + else if (typeof placeholder !== "undefined" && isMessage && placeholder instanceof Message) return await placeholder.edit({ embeds: [EMBEDS.PING_INFO(data, desString.join('\n'))] }); } } \ No newline at end of file diff --git a/src/discord/Stats.ts b/src/discord/Stats.ts new file mode 100644 index 0000000..bdf3044 --- /dev/null +++ b/src/discord/Stats.ts @@ -0,0 +1,43 @@ +import { Message, CommandInteraction, Interaction } from "discord.js"; +import { getEmotes, makeSuccessEmbed, makeProcessingEmbed, sendMessage, sendMessageOrInteractionResponse, makeInfoEmbed } from "../utils/DiscordMessage"; +import DiscordProvider from "../providers/Discord"; +import Users from "../services/Users" +import Environment from "../providers/Environment"; + +const EMBEDS = { + STATS_INFO: (data: Message | Interaction) => { + let message; + + return makeInfoEmbed({ + title: `Stats`, + icon: '📊', + description: `Serving in ${DiscordProvider.client.guilds.cache.size} servers\n${DiscordProvider.client.guilds.cache.map((g) => g.memberCount).reduce((a, c) => a + c)} users`, + user: (data instanceof Interaction) ? data.user : data.author + }); + } +} + +export default class Stats { + + async onCommand(command: string, args: any, message: Message) { + if(command.toLowerCase() !== 'stats') return; + await this.process(message, args); + } + + async interactionCreate(interaction: CommandInteraction) { + if(interaction.isCommand()) { + if(typeof interaction.commandName === 'undefined') return; + if((interaction.commandName).toLowerCase() !== 'stats') return; + await this.process(interaction, interaction.options); + } + } + + async process(data: Interaction | Message, args: any) { + const isSlashCommand = data instanceof CommandInteraction && data.isCommand(); + const isMessage = data instanceof Message; + + if(!isSlashCommand && !isMessage) return; + + return await sendMessageOrInteractionResponse(data, { embeds:[EMBEDS.STATS_INFO(data)] }); + } +} \ No newline at end of file diff --git a/src/discord/developer/ServiceAnnouncement.ts b/src/discord/developer/ServiceAnnouncement.ts index 91d95b1..31ea1e2 100644 --- a/src/discord/developer/ServiceAnnouncement.ts +++ b/src/discord/developer/ServiceAnnouncement.ts @@ -5,7 +5,7 @@ import { registerAllGuildsCommands, unregisterAllGuildsCommands } from "../../ut import Prisma from "../../providers/Prisma"; import Users from "../../services/Users"; -import {Promise, reject} from "bluebird"; +import { Promise, reject } from "bluebird"; import fs from "fs"; import path from "path"; import Logger from "../../libs/Logger"; @@ -124,20 +124,21 @@ let Announcements = { } } - -if (fs.existsSync(path.join(process.cwd(), 'configs/ServiceAnnouncement.json'))) { - try { - const rawData = fs.readFileSync(path.join(process.cwd(), 'configs/ServiceAnnouncement.json')); - const jsonData = JSON.parse(rawData.toString()); - Announcements = jsonData; - } catch (err) { - Logger.error("Unable to load custom ServiceAnnouncement config: " + err); - } -} else { - fs.writeFileSync(path.join(process.cwd(), 'configs/ServiceAnnouncement.json'), JSON.stringify(Announcements, null, 4), 'utf8'); -} - export default class InteractionManager { + + async init() { + + if (fs.existsSync(path.join(process.cwd(), 'configs/ServiceAnnouncement.json'))) { + try { + const rawData = fs.readFileSync(path.join(process.cwd(), 'configs/ServiceAnnouncement.json')); + const jsonData = JSON.parse(rawData.toString()); + Announcements = jsonData; + } catch (err) { + Logger.error("Unable to load custom ServiceAnnouncement config: " + err); + } + } + } + async onCommand(command: string, args: any, message: Message) { if (command.toLowerCase() !== 'serviceannouncement') return; await this.process(message, args); @@ -215,7 +216,7 @@ export default class InteractionManager { toSend.set(Guild, channel); } - let placeholder = await sendMessage(data.channel!, undefined, { embeds:[EMBEDS.SENDING_SERVICE_ANNOUNCEMENT(data)] }); + let placeholder = await sendMessage(data.channel!, undefined, { embeds: [EMBEDS.SENDING_SERVICE_ANNOUNCEMENT(data)] }); await Promise.map(toSend, element => { return new Promise(async (resolve, reject) => { @@ -223,14 +224,14 @@ export default class InteractionManager { let Channel: TextChannel = element[1]; try { const guildObject = DiscordProvider.client.guilds.cache.get(Guild.id); - if(!guildObject) + if (!guildObject) throw new Error('Guild not found'); Logger.info(`Sending Service Announcement to ${guildObject?.name} (${guildObject.id}) #${Channel.name} (${Channel.id})`); - await sendMessage(Channel, undefined, { embeds:[EMBEDS.MAKE_PAYLOAD(data, embed)] }) + await sendMessage(Channel, undefined, { embeds: [EMBEDS.MAKE_PAYLOAD(data, embed)] }) } catch (err) { withError = true; const guildObject = DiscordProvider.client.guilds.cache.get(Guild.id); - if(guildObject) + if (guildObject) Logger.info(`Unable to send Service Announcement to ${guildObject?.name} (${guildObject.id}) #${Channel.name} (${Channel.id})`); reject(err); @@ -241,16 +242,16 @@ export default class InteractionManager { }); }, { concurrency: 2 }); - if(withError) { - if(isMessage) + if (withError) { + if (isMessage) (placeholder as Message).edit({ embeds: [EMBEDS.SERVICE_ANNOUNCEMENT_SENT_WITH_ERRORS(data)] }); - else if(isSlashCommand) + else if (isSlashCommand) return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.SERVICE_ANNOUNCEMENT_SENT_WITH_ERRORS(data)] }, true); } else { - if(isMessage) + if (isMessage) (placeholder as Message).edit({ embeds: [EMBEDS.SERVICE_ANNOUNCEMENT_SENT(data)] }); - else if(isSlashCommand) + else if (isSlashCommand) return await sendMessageOrInteractionResponse(data, { embeds: [EMBEDS.SERVICE_ANNOUNCEMENT_SENT(data)] }, true); } } diff --git a/src/providers/App.ts b/src/providers/App.ts index 7fe79e1..4abe42f 100644 --- a/src/providers/App.ts +++ b/src/providers/App.ts @@ -8,7 +8,7 @@ import Configuration from './Configuration'; class App { - public readonly version = `0.05${Environment.get().NODE_ENV === "development" ? ' / Development Build' : ''}`; + public readonly version = `0.07${Environment.get().NODE_ENV === "development" ? ' / Development Build' : ''}`; public loadConfig(): void { Logger.log('info', 'Loading configuration'); diff --git a/src/providers/Configuration.ts b/src/providers/Configuration.ts index d61c738..91c1a79 100644 --- a/src/providers/Configuration.ts +++ b/src/providers/Configuration.ts @@ -10,6 +10,15 @@ class Configuration { if (!fs.existsSync(dir)) { fs.mkdirSync(dir); } + this.copyExampleIfNotExists("Ping.json"); + this.copyExampleIfNotExists("ServiceAnnouncement.json"); + } + + private copyExampleIfNotExists(file: string): void { + const dir = path.join(process.cwd(), 'configs/'); + if (!fs.existsSync(path.join(dir, file))) { + fs.copyFileSync(path.join(process.cwd(), 'configs/', `${file.replace('.json', '.example.json')}`), path.join(dir, file)); + } } // TODO: Make a real providers like Environment diff --git a/src/providers/Discord.ts b/src/providers/Discord.ts index 748f154..0335945 100644 --- a/src/providers/Discord.ts +++ b/src/providers/Discord.ts @@ -13,6 +13,16 @@ import Discord_InteractionManager from "../discord/InteractionManager"; import Discord_MembershipScreening from "../discord/MembershipScreening"; import Discord_osu from "../discord/osu"; import Discord_UserInfo from "../discord/UserInfo"; +import Discord_Stats from "../discord/Stats"; + +import Discord_MusicPlayer_Play from "../discord/MusicPlayer/Play"; +import Discord_MusicPlayer_Skip from "../discord/MusicPlayer/Skip"; +import Discord_MusicPlayer_Join from "../discord/MusicPlayer/Join"; +import Discord_MusicPlayer_Leave from "../discord/MusicPlayer/Leave"; +import Discord_MusicPlayer_Queue from "../discord/MusicPlayer/Queue"; +import Discord_MusicPlayer_Search from "../discord/MusicPlayer/Search"; +import Discord_MusicPlayer_Search_NowPlaying from "../discord/MusicPlayer/NowPlaying"; + import Discord_Developer_ServiceAnnouncement from "../discord/developer/ServiceAnnouncement" import Cache from "./Cache"; @@ -24,8 +34,8 @@ class Discord { constructor () { this.client = new Client({ - partials: ['MESSAGE', 'CHANNEL', 'REACTION'], - intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES, Intents.FLAGS.GUILD_MEMBERS, Intents.FLAGS.GUILD_PRESENCES] + //partials: ['MESSAGE', 'CHANNEL', 'REACTION'], + intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES, Intents.FLAGS.GUILD_MEMBERS, Intents.FLAGS.GUILD_PRESENCES, Intents.FLAGS.GUILD_VOICE_STATES] }); this.loaded_module = {}; } @@ -45,6 +55,15 @@ class Discord { this.loaded_module["Discord_Say"] = new Discord_Say(); this.loaded_module["Discord_osu"] = new Discord_osu(); this.loaded_module["Discord_UserInfo"] = new Discord_UserInfo(); + this.loaded_module["Discord_Stats"] = new Discord_Stats(); + + this.loaded_module["Discord_MusicPlayer_Play"] = new Discord_MusicPlayer_Play(); + this.loaded_module["Discord_MusicPlayer_Skip"] = new Discord_MusicPlayer_Skip(); + this.loaded_module["Discord_MusicPlayer_Join"] = new Discord_MusicPlayer_Join(); + this.loaded_module["Discord_MusicPlayer_Leave"] = new Discord_MusicPlayer_Leave(); + this.loaded_module["Discord_MusicPlayer_Queue"] = new Discord_MusicPlayer_Queue(); + this.loaded_module["Discord_MusicPlayer_Search"] = new Discord_MusicPlayer_Search(); + this.loaded_module["Discord_MusicPlayer_Search_NowPlaying"] = new Discord_MusicPlayer_Search_NowPlaying(); this.loaded_module["MembershipScreening"] = new Discord_MembershipScreening(); diff --git a/src/providers/DiscordMusicPlayer.ts b/src/providers/DiscordMusicPlayer.ts new file mode 100644 index 0000000..7fb24fa --- /dev/null +++ b/src/providers/DiscordMusicPlayer.ts @@ -0,0 +1,327 @@ +import playdl, { YouTubeVideo, InfoData } from "play-dl"; +import { Message, VoiceChannel, Snowflake, TextChannel, StageChannel, Guild } from "discord.js"; +import { AudioPlayer, VoiceConnection, createAudioPlayer, joinVoiceChannel, createAudioResource, StreamType, VoiceConnectionStatus, AudioPlayerStatus, NoSubscriberBehavior, VoiceConnectionDestroyedState, VoiceConnectionState, DiscordGatewayAdapterCreator, AudioResource } from "@discordjs/voice"; +import { EventEmitter } from "stream"; + +import DiscordProvider from "./Discord"; +import Environment from "./Environment"; + +export type ValidTracks = YouTubeVideo; + +if (Environment.get().YOUTUBE_COOKIE_BASE64) { + playdl.setToken({ + youtube: { + cookie: Buffer.from(Environment.get().YOUTUBE_COOKIE_BASE64, 'base64').toString() + } + }) +} +export class Queue { + public track: (ValidTracks)[] = []; +} + +export class YouTubeLink { + public videoId: string; + public list?: string; + + constructor(videoId: string, list: string) { + this.videoId = videoId; + this.list = list; + } +} + +export class PlayerPlayingEvent { + public instance: DiscordMusicPlayerInstance; + + constructor(instance: DiscordMusicPlayerInstance) { + this.instance = instance; + } +} + +export class DiscordMusicPlayerInstance { + public queue: Queue; + public player: AudioPlayer; + public textChannel?: TextChannel; + public voiceChannel: (VoiceChannel | StageChannel); + public voiceConnection?: VoiceConnection; + + public readonly events: EventEmitter; + + constructor({ voiceChannel }: { voiceChannel: (VoiceChannel | StageChannel) }) { + this.queue = new Queue(); + this.player = createAudioPlayer({ + behaviors: { + noSubscriber: NoSubscriberBehavior.Pause, + maxMissedFrames: 1000 + } + }); + this.voiceChannel = voiceChannel; + this.events = new EventEmitter(); + + this.player.on(AudioPlayerStatus.Idle, async (oldStage, newStage) => { + //The player stopped + if (newStage.status === AudioPlayerStatus.Idle && oldStage.status !== AudioPlayerStatus.Idle) { + if (this.queue.track.length !== 0) { + this.queue.track.shift(); + if (this.queue.track.length > 0) { + this.playTrack(this.queue.track[0]); + } + } + } + }); + + this.player.on(AudioPlayerStatus.Playing, (oldState: any, newState: any) => { + this.events.emit('playing', new PlayerPlayingEvent(this)); + }); + } + + public isReady() { + if (!this.voiceConnection) return false; + return this.voiceConnection.state.status === VoiceConnectionStatus.Ready; + } + + public isConnected() { + if (!this.voiceConnection) return false; + + if (this.voiceConnection.state.status === VoiceConnectionStatus.Destroyed) return false; + if (this.voiceConnection.state.status === VoiceConnectionStatus.Disconnected) return false; + + return true; + } + + public joinVoiceChannel(voiceChannel: (VoiceChannel | StageChannel), textChannel?: TextChannel) { + const permissions = voiceChannel.permissionsFor(DiscordProvider.client.user!); + if (!permissions) + throw new Error("No permissions"); + + if (!voiceChannel.joinable || !permissions.has("CONNECT")) + throw new Error("No permissions"); + + if (textChannel) + this.textChannel = textChannel; + + this.voiceConnection = joinVoiceChannel({ + channelId: this.voiceChannel.id, + guildId: this.voiceChannel.guild.id, + adapterCreator: this.voiceChannel.guild.voiceAdapterCreator + }); + + this.voiceConnection.on(VoiceConnectionStatus.Ready, (oldState: VoiceConnectionState, newState: VoiceConnectionState) => { + let currentVC = DiscordProvider.client.guilds.cache.get(voiceChannel.guild.id)?.me?.voice.channel; + if (currentVC && currentVC.id !== this.voiceChannel.id) { + this.voiceChannel = currentVC; + } + }); + + this.voiceConnection.on(VoiceConnectionStatus.Disconnected, (oldState: VoiceConnectionState, newState: VoiceConnectionState) => { + // TODO: Handle on Disconnect by user (Right-click > Disconnect) + }); + } + + public async leaveVoiceChannel() { + + if (this.player) + this.player.pause(); + + if (DiscordProvider.client.guilds.cache.get(this.voiceChannel.guild.id)?.me?.voice) { + await DiscordProvider.client.guilds.cache.get(this.voiceChannel.guild.id)?.me?.voice.disconnect(); + } + + } + + public addTrackToQueue(track: (ValidTracks)) { + + if (this.queue.track.length === 0) { + this.queue.track.push(track); + this.playTrack(this.queue.track[0]); + return; + } + + this.queue.track.push(track); + } + + public async playTrack(track: ValidTracks) { + + if (!this.voiceConnection) throw new Error("No voice connection"); + + const stream = await playdl.stream(track.url); + const resource = createAudioResource(stream.stream, { + inputType: stream.type + }); + + this.player.play(resource) + this.voiceConnection.subscribe(this.player) + } + + public async skipTrack() { + if (!this.voiceConnection) throw new Error("No voice connection"); + + if (this.queue.track.length > 1) { + this.queue.track.shift(); + this.playTrack(this.queue.track[0]); + } + else { + this.queue.track.shift(); + this.player.stop(); + } + } + + public async destroy() { + await this.leaveVoiceChannel(); + + if (this.voiceConnection) { + this.voiceConnection.removeAllListeners(); + if (this.voiceConnection.state.status !== 'destroyed') + this.voiceConnection.destroy(); + } + + if (this.player) { + this.player.removeAllListeners(); + this.player.stop(true); + } + + this.queue.track = []; + this.textChannel = undefined; + this.voiceConnection = undefined; + } + +} + +class DiscordMusicPlayer { + + public GuildQueue = new Map(); + + public getGuildInstance(guildId: Snowflake): (DiscordMusicPlayerInstance | null) { + if (!this.isGuildInstanceExists(guildId)) return null; + return this.GuildQueue.get(guildId); + } + + public isGuildInstanceExists(guildId: Snowflake) { + return this.GuildQueue.has(guildId); + } + + public createGuildInstance(guildId: Snowflake, voiceChannel: (VoiceChannel | StageChannel)) { + this.GuildQueue.set(guildId, new DiscordMusicPlayerInstance({ voiceChannel })); + } + + public async destoryGuildInstance(guild: (Guild | Snowflake)) { + + let guildId: Snowflake = guild instanceof Guild ? guild.id : guild; + + if (this.isGuildInstanceExists(guildId)) { + await this.GuildQueue.get(guildId).destroy(); + this.GuildQueue.delete(guildId); + } + } + + public async searchYouTubeByQuery(query: string) { + const searched: YouTubeVideo[] = await playdl.search(query, { source: { youtube: "video" } }); + if (searched.length == 0) return null; + return searched; + } + + public async searchYouTubeByYouTubeLink(youtubeLink: YouTubeLink) { + + // Search the url + const searched: YouTubeVideo[] = await playdl.search("https://www.youtube.com/watch?v=" + youtubeLink.videoId, { source: { youtube: "video" } }); + for (let video of searched) { + if (video.id === youtubeLink.videoId) + return video; + } + + // Serch the video Id + const searched2: YouTubeVideo[] = await playdl.search(youtubeLink.videoId, { source: { youtube: "video" } }); + for (let video of searched2) { + if (video.id === youtubeLink.videoId) + return video; + } + + // Last resort, search the title + const videoInfo = await playdl.video_basic_info("https://www.youtube.com/watch?v=" + youtubeLink.videoId); + if (videoInfo?.video_details?.title) { + const searched: YouTubeVideo[] = await playdl.search(videoInfo.video_details.title, { source: { youtube: "video" } }); + for (let video of searched) { + if (video.id === youtubeLink.videoId) + return video; + } + } + + let yt_info = await playdl.video_info("https://www.youtube.com/watch?v=" + youtubeLink.videoId); + if(yt_info) { + return new YouTubeVideo({ + id: yt_info.video_details.id, + url: yt_info.video_details.url, + type: yt_info.video_details.type, + title: yt_info.video_details.title, + description: yt_info.video_details.description, + durationRaw: yt_info.video_details.durationRaw, + durationInSec: yt_info.video_details.durationInSec, + uploadedAt: yt_info.video_details.uploadedAt, + upcoming: yt_info.video_details.upcoming, + views: yt_info.video_details.views, + thumbnails: yt_info.video_details.thumbnails, + channel: yt_info.video_details.channel, + likes: yt_info.video_details.likes, + live: yt_info.video_details.live, + private: yt_info.video_details.private, + tags: yt_info.video_details.tags, + discretionAdvised: yt_info.video_details.discretionAdvised, + music: yt_info.video_details.music + }); + } + + + return null; + } + + public isYouTubeLink(link: string): boolean { + try { + this.parseYouTubeLink(link); + return true; + } catch (err) { + return false; + } + } + + public parseYouTubeLink(query: string): YouTubeLink { + if (query.startsWith('https://www.youtube.com/watch?v=') || query.startsWith('http://www.youtube.com/watch?v=')) { + let data = this.parseURLQuery(query); + if (!data.v) + throw new Error('YouTube link is invalid'); + return { + videoId: data.v, + list: (data.list ? data.list : undefined) + } + } + else if (query.startsWith('https://youtu.be/') || query.startsWith('http://youtu.be/')) { + //Get youtube video id after the url + let videoId = query.split('/')[3]; + + if (!videoId) throw new Error('YouTube link is invalid'); + if (query.split('/')[4]) throw new Error('YouTube link is invalid'); + + return { + videoId: videoId + } + } + else { + throw new Error('YouTube link is invalid'); + } + } + + private parseURLQuery(query: string) { + let queryObject: any = {}; + if (query.indexOf('?') >= 0) { + let queryString = query.split('?')[1]; + let queryArray = queryString.split('&'); + for (let i = 0; i < queryArray.length; i++) { + let queryPair = queryArray[i].split('='); + queryObject[queryPair[0]] = queryPair[1]; + } + } + return queryObject; + } + +} + +const DiscordMusicPlayer_Instance = new DiscordMusicPlayer(); +export default DiscordMusicPlayer_Instance; \ No newline at end of file diff --git a/src/providers/Environment.ts b/src/providers/Environment.ts index edfee12..a9e1c11 100644 --- a/src/providers/Environment.ts +++ b/src/providers/Environment.ts @@ -41,6 +41,7 @@ class Environment { const PRIVATE_BOT = process.env.PRIVATE_BOT; const OSU_API_KEY = process.env.OSU_API_KEY; + const YOUTUBE_COOKIE_BASE64 = process.env.YOUTUBE_COOKIE_BASE64; return { NODE_ENV, @@ -49,7 +50,8 @@ class Environment { DEVELOPER_IDS, PRIVATE_BOT, - OSU_API_KEY + OSU_API_KEY, + YOUTUBE_COOKIE_BASE64 }; } diff --git a/src/utils/DiscordMessage.ts b/src/utils/DiscordMessage.ts index d265efe..8363c00 100644 --- a/src/utils/DiscordMessage.ts +++ b/src/utils/DiscordMessage.ts @@ -65,7 +65,9 @@ export async function sendMessage(channel: TextChannel | DMChannel | BaseGuildTe try { message = await channel.send(options); } catch(error) { - if(typeof user === 'undefined') return; + if(typeof user === 'undefined') { + return Logger.error(`Cannot find available destinations to send the message CID: ${channel.id} C_ERR: ${error}`); + } try { message = await user.send(options); } catch(errorDM) { Logger.error(`Cannot find available destinations to send the message CID: ${channel.id} UID: ${user.id} C_ERR: ${error} DM_ERR: ${errorDM}`); @@ -99,7 +101,7 @@ export async function sendMessageOrInteractionResponse(data: Message | Interacti const isSlashCommand = data instanceof Interaction && data.isCommand(); const isMessage = data instanceof Message; - if(isSlashCommand) { + if(isSlashCommand || (data instanceof Interaction && data.isSelectMenu())) { if(!data.replied) { let message; try { diff --git a/yarn.lock b/yarn.lock index 438db03..c202746 100644 --- a/yarn.lock +++ b/yarn.lock @@ -38,6 +38,54 @@ resolved "https://registry.yarnpkg.com/@discordjs/collection/-/collection-0.4.0.tgz#b6488286a1cc7b41b644d7e6086f25a1c1e6f837" integrity sha512-zmjq+l/rV35kE6zRrwe8BHqV78JvIh2ybJeZavBi5NySjWXqN3hmmAKg7kYMMXSeiWtSsMoZ/+MQi0DiQWy2lw== +"@discordjs/node-pre-gyp@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@discordjs/node-pre-gyp/-/node-pre-gyp-0.4.2.tgz#37dd27f8d353eeb87372fcce059c5249550b37ab" + integrity sha512-V239Czn+DXFGLhhuccwEDBoTdgMGrRu30dOlzm1GzrSIjwFj01ZJerNX7x+CEX1NG1Q/1gGfOOkeZFNHjycrRA== + dependencies: + detect-libc "^1.0.3" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.5" + nopt "^5.0.0" + npmlog "^5.0.1" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.11" + +"@discordjs/opus@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@discordjs/opus/-/opus-0.7.0.tgz#69e9611a6412ea470f8e640f54478da89326ca3b" + integrity sha512-3Xxa3dh7taSDwBAR5fLALZ/KTxvbMmHCMxYLYve6NlPO7Ms1CLmKqp/R4ZoVzkRGQVUVWEhaB1s0v9jfa2tfDg== + dependencies: + "@discordjs/node-pre-gyp" "^0.4.2" + node-addon-api "^4.2.0" + +"@discordjs/rest@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@discordjs/rest/-/rest-0.3.0.tgz#89e06a42b168c91598891d6bf860353e28fba5d2" + integrity sha512-F9aeP3odlAlllM1ciBZLdd+adiAyBj4VaZBejj4UMj4afE2wfCkNTGvYYiRxrXUE9fN7e/BuDP2ePl0tVA2m7Q== + dependencies: + "@discordjs/collection" "^0.4.0" + "@sapphire/async-queue" "^1.1.9" + "@sapphire/snowflake" "^3.0.1" + discord-api-types "^0.26.1" + form-data "^4.0.0" + node-fetch "^2.6.5" + tslib "^2.3.1" + +"@discordjs/voice@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@discordjs/voice/-/voice-0.8.0.tgz#5d790fc25b883698f6eb7762efe1af00b6440947" + integrity sha512-o0JfVLMs3eLjUzPf6oxMydEeum40I7xzfUc66SLN+RrKpSAsTbngf5qnCF53nm+KDNSvrwg1AZqNm4LEAdxJIA== + dependencies: + "@types/ws" "^8.2.0" + discord-api-types "^0.26.1" + prism-media "^1.3.2" + tiny-typed-emitter "^2.1.0" + tslib "^2.3.1" + ws "^8.4.2" + "@mapbox/node-pre-gyp@^1.0.0": version "1.0.8" resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.8.tgz#32abc8a5c624bc4e46c43d84dfb8b26d33a96f58" @@ -71,14 +119,19 @@ integrity sha512-bHYubuItSN/DGYo36aDu7xJiJmK52JOSHs4MK+KbceAtwS20BCWadRgtpQ3iZ2EXfN/B1T0iCXlNraaNwnpU2w== "@sapphire/async-queue@^1.1.9": - version "1.1.9" - resolved "https://registry.yarnpkg.com/@sapphire/async-queue/-/async-queue-1.1.9.tgz#ce69611c8753c4affd905a7ef43061c7eb95c01b" - integrity sha512-CbXaGwwlEMq+l1TRu01FJCvySJ1CEFKFclHT48nIfNeZXaAAmmwwy7scUKmYHPUa3GhoMp6Qr1B3eAJux6XgOQ== + version "1.2.0" + resolved "https://registry.yarnpkg.com/@sapphire/async-queue/-/async-queue-1.2.0.tgz#7a56afd318101d338433d7180ebd6af349243268" + integrity sha512-O5ND5Ljpef86X5oy8zXorQ754TMjWALcPSAgPBu4+76HLtDTrNoDyzU2uGE2G4A8Wv51u0MXHzGQ0WZ4GMtpIw== + +"@sapphire/snowflake@^3.0.1": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@sapphire/snowflake/-/snowflake-3.1.0.tgz#1c2d9781d3b1de0bcb02b7079898947ce754d394" + integrity sha512-K+OiqXSx4clIaXcoaghrCV56zsm3bZZ5SBpgJkgvAKegFFdETMntHviUfypjt8xVleIuDaNyQA4APOIl3BMcxg== "@sindresorhus/is@^4.0.1", "@sindresorhus/is@^4.2.0": - version "4.3.0" - resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.3.0.tgz#344fd9bf808a84567ba563d00cc54b2f428dbab1" - integrity sha512-wwOvh0eO3PiTEivGJWiZ+b946SlMSb4pe+y+Ur/4S87cwo09pYi+FWHHnbrM3W9W7cBYKDqQXcrFYjYUCOJUEQ== + version "4.4.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.4.0.tgz#e277e5bdbdf7cb1e20d320f02f5e2ed113cd3185" + integrity sha512-QppPM/8l3Mawvh4rn9CNEYIU9bxpXUCRMaX9yUpvBk1nMKusLKpfXGDEKExKaPhLzcn3lzil7pR6rnJ11HgeRQ== "@types/bluebird@^3.5.36": version "3.5.36" @@ -99,9 +152,9 @@ form-data "^3.0.0" "@types/node@*": - version "17.0.12" - resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.12.tgz#f7aa331b27f08244888c47b7df126184bc2339c5" - integrity sha512-4YpbAsnJXWYK/fpTVFlMIcUIho2AYCi4wg5aNPrG1ng7fn/1/RZfCIpRCiBX+12RVa34RluilnvCqD+g3KiSiA== + version "17.0.13" + resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.13.tgz#5ed7ed7c662948335fcad6c412bb42d99ea754e3" + integrity sha512-Y86MAxASe25hNzlDbsviXl8jQHb0RDvKt4c40ZJQ1Don0AAL0STLZSs4N+6gLEO55pedy7r2cLwS+ZDxPm/2Bw== "@types/node@^16.11.6": version "16.11.21" @@ -128,7 +181,7 @@ resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.7.1.tgz#cdab1b4779f6b1718a08de89d92d2603b71950cb" integrity sha512-I6OUIZ5cYRk5lp14xSOAiXjWrfVoMZVjDuevBYgQDYzZIjsf2CAISpEcXOkFAtpAHbmWIDLcZObejqny/9xq5Q== -"@types/ws@^8.2.2": +"@types/ws@^8.2.0", "@types/ws@^8.2.2": version "8.2.2" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.2.2.tgz#7c5be4decb19500ae6b3d563043cd407bf366c21" integrity sha512-NOn5eIcgWLOo6qW8AcuLZ7G8PycXu0xTxxkS6Q18VWFxgPUSOwV0pBj2a/4viNZVu25i7RIB7GttdkAIUUXOOg== @@ -178,7 +231,7 @@ arg@^4.1.0: resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== -async@^3.2.3: +async@^3.1.0: version "3.2.3" resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9" integrity sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g== @@ -223,6 +276,13 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +bufferutil@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-4.0.6.tgz#ebd6c67c7922a0e902f053e5d8be5ec850e48433" + integrity sha512-jduaYOYtnio4aIAyc6UbvPCVcgq7nYpVnucyxr6eCYg/Woad9Hf/oxxBRDnGGjPfjUm6j5O/uBWhIu4iLebFaw== + dependencies: + node-gyp-build "^4.3.0" + call-bind@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" @@ -404,7 +464,7 @@ discord-api-types@^0.24.0: resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.24.0.tgz#9e429b8a1ddb4147134dfb3109093422de7ec549" integrity sha512-X0uA2a92cRjowUEXpLZIHWl4jiX1NsUpDhcEOpa1/hpO1vkaokgZ8kkPtPih9hHth5UVQ3mHBu/PpB4qjyfJ4A== -discord-api-types@^0.26.0: +discord-api-types@^0.26.0, discord-api-types@^0.26.1: version "0.26.1" resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.26.1.tgz#726f766ddc37d60da95740991d22cb6ef2ed787b" integrity sha512-T5PdMQ+Y1MEECYMV5wmyi9VEYPagEDEi4S0amgsszpWY0VB9JJ/hEvM6BgLhbdnKky4gfmZEXtEEtojN8ZKJQQ== @@ -657,12 +717,24 @@ kuler@^2.0.0: resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== +libsodium-wrappers@^0.7.9: + version "0.7.9" + resolved "https://registry.yarnpkg.com/libsodium-wrappers/-/libsodium-wrappers-0.7.9.tgz#4ffc2b69b8f7c7c7c5594a93a4803f80f6d0f346" + integrity sha512-9HaAeBGk1nKTRFRHkt7nzxqCvnkWTjn1pdjKgcUnZxj0FyOP4CnhgFhMdrFfgNsukijBGyBLpP2m2uKT1vuWhQ== + dependencies: + libsodium "^0.7.0" + +libsodium@^0.7.0: + version "0.7.9" + resolved "https://registry.yarnpkg.com/libsodium/-/libsodium-0.7.9.tgz#4bb7bcbf662ddd920d8795c227ae25bbbfa3821b" + integrity sha512-gfeADtR4D/CM0oRUviKBViMGXZDgnFdMKMzHsvBdqLBHd9ySi6EtYnmuhHVDDYgYpAO8eU8hEY+F8vIUAPh08A== + lodash.isequal@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= -logform@^2.3.2: +logform@^2.2.0, logform@^2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/logform/-/logform-2.3.2.tgz#68babe6a74ab09a1fd15a9b1e6cbc7713d41cb5b" integrity sha512-V6JiPThZzTsbVRspNO6TmHkR99oqYTs8fivMBYQkjZj6rxW92KxtDCPE6IkAk1DNBnYKNkjm4jYBm6JDUcyhOA== @@ -761,11 +833,16 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -nan@^2.15.0: +nan@^2.14.0, nan@^2.15.0: version "2.15.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ== +node-addon-api@^4.2.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" + integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== + node-fetch@^2.6.1, node-fetch@^2.6.5: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" @@ -773,6 +850,11 @@ node-fetch@^2.6.1, node-fetch@^2.6.5: dependencies: whatwg-url "^5.0.0" +node-gyp-build@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.3.0.tgz#9f256b03e5826150be39c764bf51e993946d71a3" + integrity sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q== + node-osu@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/node-osu/-/node-osu-2.2.1.tgz#2dcf9bef2e92490cab7a215842559f003d702ac1" @@ -861,6 +943,23 @@ ping@^0.4.1: q "1.x" underscore "^1.12.0" +play-audio@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/play-audio/-/play-audio-0.5.2.tgz#191dd45b95ff64ae20bbd718671da4e987d4edd1" + integrity sha512-ZAqHUKkQLix2Iga7pPbsf1LpUoBjcpwU93F1l3qBIfxYddQLhxS6GKmS0d3jV8kSVaUbr6NnOEcEMFvuX93SWQ== + +play-dl@^1.8.6: + version "1.8.6" + resolved "https://registry.yarnpkg.com/play-dl/-/play-dl-1.8.6.tgz#483420c0273ad38f0a07346dcd78fb76ce31c2c9" + integrity sha512-NOnSmg6ikdrf6x6R4ML5QY6AJS4h4KkIIynlKuDx7RVDPE7C2kowgtS+e6RRMuuAXXXESkmlWrpUb+bnZFWy1w== + dependencies: + play-audio "^0.5.2" + +prism-media@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/prism-media/-/prism-media-1.3.2.tgz#a1f04423ec15d22f3d62b1987b6a25dc49aad13b" + integrity sha512-L6UsGHcT6i4wrQhFF1aPK+MNYgjRqR2tUoIqEY+CG1NqVkMjPRKzS37j9f8GiYPlD6wG9ruBj+q5Ax+bH8Ik1g== + prisma@^3.3.0: version "3.8.1" resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.8.1.tgz#44395cef7cbb1ea86216cb84ee02f856c08a7873" @@ -1071,6 +1170,11 @@ text-hex@1.0.x: resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== +tiny-typed-emitter@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz#b3b027fdd389ff81a152c8e847ee2f5be9fad7b5" + integrity sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA== + to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" @@ -1161,6 +1265,13 @@ underscore@^1.12.0: resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.2.tgz#276cea1e8b9722a8dbed0100a407dda572125881" integrity sha512-ekY1NhRzq0B08g4bGuX4wd2jZx5GnKz6mKSqFL4nqBlfyMGiG10gDFhDTMEfYmDL6Jy0FUIZp7wiRB+0BP7J2g== +utf-8-validate@^5.0.8: + version "5.0.8" + resolved "https://registry.yarnpkg.com/utf-8-validate/-/utf-8-validate-5.0.8.tgz#4a735a61661dbb1c59a0868c397d2fe263f14e58" + integrity sha512-k4dW/Qja1BYDl2qD4tOMB9PFVha/UJtxTc1cXYOe3WwA/2m0Yn4qB7wLMpJyLJ/7DR0XnTut3HsCSzDT4ZvKgA== + dependencies: + node-gyp-build "^4.3.0" + util-deprecate@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -1196,7 +1307,7 @@ wide-align@^1.1.2: dependencies: string-width "^1.0.2 || 2 || 3 || 4" -winston-transport@^4.4.2: +winston-transport@^4.4.0: version "4.4.2" resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.4.2.tgz#554efe3fce229d046df006e0e3c411d240652e51" integrity sha512-9jmhltAr5ygt5usgUTQbEiw/7RYXpyUbEAFRCSicIacpUzPkrnQsQZSPGEI12aLK9Jth4zNcYJx3Cvznwrl8pw== @@ -1205,27 +1316,27 @@ winston-transport@^4.4.2: readable-stream "^3.4.0" triple-beam "^1.2.0" -winston@^3.3.3: - version "3.4.0" - resolved "https://registry.yarnpkg.com/winston/-/winston-3.4.0.tgz#7080f24b02a0684f8a37f9d5c6afb1ac23e95b84" - integrity sha512-FqilVj+5HKwCfIHQzMxrrd5tBIH10JTS3koFGbLVWBODjiIYq7zir08rFyBT4rrTYG/eaTqDcfSIbcjSM78YSw== +winston@3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.3.3.tgz#ae6172042cafb29786afa3d09c8ff833ab7c9170" + integrity sha512-oEXTISQnC8VlSAKf1KYSSd7J6IWuRPQqDdo8eoRNaYKLvwSb5+79Z3Yi1lrl6KDpU6/VWaxpakDAtb1oQ4n9aw== dependencies: "@dabh/diagnostics" "^2.0.2" - async "^3.2.3" + async "^3.1.0" is-stream "^2.0.0" - logform "^2.3.2" + logform "^2.2.0" one-time "^1.0.0" readable-stream "^3.4.0" stack-trace "0.0.x" triple-beam "^1.3.0" - winston-transport "^4.4.2" + winston-transport "^4.4.0" wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= -ws@^8.4.0: +ws@^8.4.0, ws@^8.4.2: version "8.4.2" resolved "https://registry.yarnpkg.com/ws/-/ws-8.4.2.tgz#18e749868d8439f2268368829042894b6907aa0b" integrity sha512-Kbk4Nxyq7/ZWqr/tarI9yIt/+iNNFOjBXEWgTb4ydaNHBNGgvf2QHbS9fdfsndfjFlFwEd4Al+mw83YkaD10ZA== @@ -1245,6 +1356,13 @@ yn@3.1.1: resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== +zlib-sync@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/zlib-sync/-/zlib-sync-0.1.7.tgz#b0b24f9428e434943e306ad586428ef8baf3ed60" + integrity sha512-UmciU6ZrIwtwPC8noMzq+kGMdiWwNRZ3wC0SbED4Ew5Ikqx14MqDPRs/Pbk+3rZPh5SzsOgUBs1WRE0iieddpg== + dependencies: + nan "^2.14.0" + zod@^3.11.6: version "3.11.6" resolved "https://registry.yarnpkg.com/zod/-/zod-3.11.6.tgz#e43a5e0c213ae2e02aefe7cb2b1a6fa3d7f1f483"