From 8e00726dc2c8179c7aa03f72e96544e78b4fb001 Mon Sep 17 00:00:00 2001 From: Max Isom Date: Thu, 27 Jan 2022 21:26:00 -0500 Subject: [PATCH] Add /favorites --- .../migration.sql | 10 + .../migration.sql | 10 + .../migration.sql | 8 + .../migration.sql | 11 + .../migration.sql | 21 ++ package.json | 4 +- schema.prisma | 13 +- src/bot.ts | 30 ++- src/commands/favorites.ts | 191 ++++++++++++++++++ src/commands/index.ts | 6 +- src/commands/play.ts | 178 ++-------------- src/events/guild-create.ts | 105 ++-------- src/inversify.config.ts | 8 +- src/managers/player.ts | 2 +- src/scripts/run-with-database-url.ts | 6 +- src/services/add-query-to-queue.ts | 155 ++++++++++++++ src/services/player.ts | 4 +- src/types.ts | 2 +- yarn.lock | 4 +- 19 files changed, 478 insertions(+), 290 deletions(-) create mode 100644 migrations/20220128000207_add_favorite_query_model/migration.sql create mode 100644 migrations/20220128000623_remove_shortcut_model/migration.sql create mode 100644 migrations/20220128003935_make_favorite_query_name_unqiue/migration.sql create mode 100644 migrations/20220128012347_fix_unique_constraint/migration.sql create mode 100644 migrations/20220128020826_remove_prefix_from_setting/migration.sql create mode 100644 src/commands/favorites.ts create mode 100644 src/services/add-query-to-queue.ts diff --git a/migrations/20220128000207_add_favorite_query_model/migration.sql b/migrations/20220128000207_add_favorite_query_model/migration.sql new file mode 100644 index 0000000..ec69910 --- /dev/null +++ b/migrations/20220128000207_add_favorite_query_model/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "FavoriteQuery" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "guildId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "query" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); diff --git a/migrations/20220128000623_remove_shortcut_model/migration.sql b/migrations/20220128000623_remove_shortcut_model/migration.sql new file mode 100644 index 0000000..469b453 --- /dev/null +++ b/migrations/20220128000623_remove_shortcut_model/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the `Shortcut` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "Shortcut"; +PRAGMA foreign_keys=on; diff --git a/migrations/20220128003935_make_favorite_query_name_unqiue/migration.sql b/migrations/20220128003935_make_favorite_query_name_unqiue/migration.sql new file mode 100644 index 0000000..39fb6f7 --- /dev/null +++ b/migrations/20220128003935_make_favorite_query_name_unqiue/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[name]` on the table `FavoriteQuery` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "FavoriteQuery_name_key" ON "FavoriteQuery"("name"); diff --git a/migrations/20220128012347_fix_unique_constraint/migration.sql b/migrations/20220128012347_fix_unique_constraint/migration.sql new file mode 100644 index 0000000..f777d06 --- /dev/null +++ b/migrations/20220128012347_fix_unique_constraint/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[guildId,name]` on the table `FavoriteQuery` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "FavoriteQuery_name_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "FavoriteQuery_guildId_name_key" ON "FavoriteQuery"("guildId", "name"); diff --git a/migrations/20220128020826_remove_prefix_from_setting/migration.sql b/migrations/20220128020826_remove_prefix_from_setting/migration.sql new file mode 100644 index 0000000..c06286b --- /dev/null +++ b/migrations/20220128020826_remove_prefix_from_setting/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - You are about to drop the column `finishedSetup` on the `Setting` table. All the data in the column will be lost. + - You are about to drop the column `prefix` on the `Setting` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Setting" ( + "guildId" TEXT NOT NULL PRIMARY KEY, + "channel" TEXT, + "playlistLimit" INTEGER NOT NULL DEFAULT 50, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_Setting" ("channel", "createdAt", "guildId", "playlistLimit", "updatedAt") SELECT "channel", "createdAt", "guildId", "playlistLimit", "updatedAt" FROM "Setting"; +DROP TABLE "Setting"; +ALTER TABLE "new_Setting" RENAME TO "Setting"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/package.json b/package.json index 554c400..831b368 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "eslint-config-xo-typescript": "^0.44.0", "husky": "^4.3.8", "nodemon": "^2.0.7", - "prisma": "^3.7.0", + "prisma": "^3.8.1", "release-it": "^14.11.8", "ts-node": "^10.4.0", "type-fest": "^2.8.0", @@ -85,7 +85,7 @@ "@discordjs/opus": "^0.7.0", "@discordjs/rest": "^0.1.0-canary.0", "@discordjs/voice": "^0.7.5", - "@prisma/client": "^3.7.0", + "@prisma/client": "^3.8.1", "@types/libsodium-wrappers": "^0.7.9", "array-shuffle": "^3.0.0", "debug": "^4.3.3", diff --git a/schema.prisma b/schema.prisma index 95ce596..bf61bd7 100644 --- a/schema.prisma +++ b/schema.prisma @@ -25,25 +25,20 @@ model KeyValueCache { model Setting { guildId String @id - prefix String channel String? - finishedSetup Boolean @default(false) playlistLimit Int @default(50) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } -model Shortcut { +model FavoriteQuery { id Int @id @default(autoincrement()) guildId String authorId String - shortcut String - command String + name String + query String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - - @@index([shortcut], map: "shortcuts_shortcut") - @@index([guildId], map: "shortcuts_guild_id") - @@index([guildId, shortcut]) + @@unique([guildId, name]) } diff --git a/src/bot.ts b/src/bot.ts index f89fad5..3ba15bc 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -35,16 +35,25 @@ export default class { public async register(): Promise { // Load in commands - container.getAll(TYPES.Command).forEach(command => { - // TODO: remove ! - if (command.slashCommand?.name) { + for (const command of container.getAll(TYPES.Command)) { + // Make sure we can serialize to JSON without errors + try { + command.slashCommand.toJSON(); + } catch (error) { + console.error(error); + throw new Error(`Could not serialize /${command.slashCommand.name ?? ''} to JSON`); + } + + if (command.slashCommand.name) { this.commandsByName.set(command.slashCommand.name, command); } if (command.handledButtonIds) { - command.handledButtonIds.forEach(id => this.commandsByButtonId.set(id, command)); + for (const buttonId of command.handledButtonIds) { + this.commandsByButtonId.set(buttonId, command); + } } - }); + } // Register event handlers this.client.on('interactionCreate', async interaction => { @@ -61,7 +70,9 @@ export default class { return; } - if (command.requiresVC && interaction.member && !isUserInVoice(interaction.guild, interaction.member.user as User)) { + const requiresVC = command.requiresVC instanceof Function ? command.requiresVC(interaction) : command.requiresVC; + + if (requiresVC && interaction.member && !isUserInVoice(interaction.guild, interaction.member.user as User)) { await interaction.reply({content: errorMsg('gotta be in a voice channel'), ephemeral: true}); return; } @@ -122,13 +133,16 @@ export default class { } else { spinner.text = '📡 updating commands in all guilds...'; - await Promise.all( - this.client.guilds.cache.map(async guild => { + await Promise.all([ + ...this.client.guilds.cache.map(async guild => { await rest.put( Routes.applicationGuildCommands(this.client.user!.id, guild.id), {body: this.commandsByName.map(command => command.slashCommand.toJSON())}, ); }), + // Remove commands registered on bot (if they exist) + rest.put(Routes.applicationCommands(this.client.user!.id), {body: []}), + ], ); } diff --git a/src/commands/favorites.ts b/src/commands/favorites.ts new file mode 100644 index 0000000..b15a5c9 --- /dev/null +++ b/src/commands/favorites.ts @@ -0,0 +1,191 @@ +import {SlashCommandBuilder} from '@discordjs/builders'; +import {AutocompleteInteraction, CommandInteraction, MessageEmbed} from 'discord.js'; +import {inject, injectable} from 'inversify'; +import Command from '.'; +import AddQueryToQueue from '../services/add-query-to-queue.js'; +import {TYPES} from '../types.js'; +import {prisma} from '../utils/db.js'; + +@injectable() +export default class implements Command { + public readonly slashCommand = new SlashCommandBuilder() + .setName('favorites') + .setDescription('adds a song to your favorites') + .addSubcommand(subcommand => subcommand + .setName('use') + .setDescription('use a favorite') + .addStringOption(option => option + .setName('name') + .setDescription('name of favorite') + .setRequired(true) + .setAutocomplete(true)) + .addBooleanOption(option => option + .setName('immediate') + .setDescription('add track to the front of the queue')) + .addBooleanOption(option => option + .setName('shuffle') + .setDescription('shuffle the input if you\'re adding multiple tracks'))) + .addSubcommand(subcommand => subcommand + .setName('list') + .setDescription('list all favorites')) + .addSubcommand(subcommand => subcommand + .setName('create') + .setDescription('create a new favorite') + .addStringOption(option => option + .setName('name') + .setDescription('you\'ll type this when using this favorite') + .setRequired(true)) + .addStringOption(option => option + .setName('query') + .setDescription('any input you\'d normally give to the play command') + .setRequired(true), + )) + .addSubcommand(subcommand => subcommand + .setName('remove') + .setDescription('remove a favorite') + .addStringOption(option => option + .setName('name') + .setDescription('name of favorite') + .setAutocomplete(true) + .setRequired(true), + ), + ); + + constructor(@inject(TYPES.Services.AddQueryToQueue) private readonly addQueryToQueue: AddQueryToQueue) {} + + requiresVC = (interaction: CommandInteraction) => interaction.options.getSubcommand() === 'use'; + + async execute(interaction: CommandInteraction) { + switch (interaction.options.getSubcommand()) { + case 'use': + await this.use(interaction); + break; + case 'list': + await this.list(interaction); + break; + case 'create': + await this.create(interaction); + break; + case 'remove': + await this.remove(interaction); + break; + default: + throw new Error('unknown subcommand'); + } + } + + async handleAutocompleteInteraction(interaction: AutocompleteInteraction) { + const query = interaction.options.getString('name')!.trim(); + + const favorites = await prisma.favoriteQuery.findMany({ + where: { + guildId: interaction.guild!.id, + }, + }); + + const names = favorites.map(favorite => favorite.name); + + const results = query === '' ? names : names.filter(name => name.startsWith(query)); + + await interaction.respond(results.map(r => ({ + name: r, + value: r, + }))); + } + + private async use(interaction: CommandInteraction) { + const name = interaction.options.getString('name')!.trim(); + + const favorite = await prisma.favoriteQuery.findFirst({ + where: { + name, + guildId: interaction.guild!.id, + }, + }); + + if (!favorite) { + throw new Error('no favorite with that name exists'); + } + + await this.addQueryToQueue.addToQueue({ + interaction, + query: favorite.query, + shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false, + addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false, + }); + } + + private async list(interaction: CommandInteraction) { + const favorites = await prisma.favoriteQuery.findMany({ + where: { + guildId: interaction.guild!.id, + }, + }); + + if (favorites.length === 0) { + await interaction.reply('there aren\'t any favorites yet'); + return; + } + + const embed = new MessageEmbed().setTitle('Favorites'); + + let description = ''; + for (const favorite of favorites) { + description += `**${favorite.name}**: ${favorite.query} (<@${favorite.authorId}>)\n`; + } + + embed.setDescription(description); + + await interaction.reply({ + embeds: [embed], + }); + } + + private async create(interaction: CommandInteraction) { + const name = interaction.options.getString('name')!.trim(); + const query = interaction.options.getString('query')!.trim(); + + const existingFavorite = await prisma.favoriteQuery.findFirst({where: { + guildId: interaction.guild!.id, + name, + }}); + + if (existingFavorite) { + throw new Error('a favorite with that name already exists'); + } + + await prisma.favoriteQuery.create({ + data: { + authorId: interaction.member!.user.id, + guildId: interaction.guild!.id, + name, + query, + }, + }); + + await interaction.reply('👍 favorite created'); + } + + private async remove(interaction: CommandInteraction) { + const name = interaction.options.getString('name')!.trim(); + + const favorite = await prisma.favoriteQuery.findFirst({where: { + name, + guildId: interaction.guild!.id, + }}); + + if (!favorite) { + throw new Error('no favorite with that name exists'); + } + + const isUserGuildOwner = interaction.member!.user.id === interaction.guild!.ownerId; + + if (favorite.authorId !== interaction.member!.user.id && !isUserGuildOwner) { + throw new Error('you can only remove your own favorites'); + } + + await prisma.favoriteQuery.delete({where: {id: favorite.id}}); + + await interaction.reply('👍 favorite removed'); + } +} diff --git a/src/commands/index.ts b/src/commands/index.ts index 1d1646f..02349d2 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,10 +1,10 @@ -import {SlashCommandBuilder} from '@discordjs/builders'; +import {SlashCommandBuilder, SlashCommandSubcommandsOnlyBuilder} from '@discordjs/builders'; import {AutocompleteInteraction, ButtonInteraction, CommandInteraction} from 'discord.js'; export default interface Command { - readonly slashCommand: Partial & Pick; + readonly slashCommand: Partial & Pick; readonly handledButtonIds?: readonly string[]; - readonly requiresVC?: boolean; + readonly requiresVC?: boolean | ((interaction: CommandInteraction) => boolean); execute: (interaction: CommandInteraction) => Promise; handleButtonInteraction?: (interaction: ButtonInteraction) => Promise; handleAutocompleteInteraction?: (interaction: AutocompleteInteraction) => Promise; diff --git a/src/commands/play.ts b/src/commands/play.ts index d1ab940..e8d565c 100644 --- a/src/commands/play.ts +++ b/src/commands/play.ts @@ -1,22 +1,15 @@ -import {AutocompleteInteraction, CommandInteraction, GuildMember} from 'discord.js'; +import {AutocompleteInteraction, CommandInteraction} from 'discord.js'; import {URL} from 'url'; -import {Except} from 'type-fest'; import {SlashCommandBuilder} from '@discordjs/builders'; -import shuffle from 'array-shuffle'; import {inject, injectable} from 'inversify'; import Spotify from 'spotify-web-api-node'; import Command from '.'; import {TYPES} from '../types.js'; -import {QueuedSong, STATUS} from '../services/player.js'; -import PlayerManager from '../managers/player.js'; -import {getMostPopularVoiceChannel, getMemberVoiceChannel} from '../utils/channels.js'; -import GetSongs from '../services/get-songs.js'; -import {prisma} from '../utils/db.js'; import ThirdParty from '../services/third-party.js'; import getYouTubeAndSpotifySuggestionsFor from '../utils/get-youtube-and-spotify-suggestions-for.js'; import KeyValueCacheProvider from '../services/key-value-cache.js'; import {ONE_HOUR_IN_SECONDS} from '../utils/constants.js'; -import {buildPlayingMessageEmbed} from '../utils/build-embed.js'; +import AddQueryToQueue from '../services/add-query-to-queue.js'; @injectable() export default class implements Command { @@ -30,178 +23,31 @@ export default class implements Command { .setAutocomplete(true)) .addBooleanOption(option => option .setName('immediate') - .setDescription('adds track to the front of the queue')) + .setDescription('add track to the front of the queue')) .addBooleanOption(option => option .setName('shuffle') - .setDescription('shuffles the input if it\'s a playlist')); + .setDescription('shuffle the input if you\'re adding multiple tracks')); public requiresVC = true; - private readonly playerManager: PlayerManager; - private readonly getSongs: GetSongs; private readonly spotify: Spotify; private readonly cache: KeyValueCacheProvider; + private readonly addQueryToQueue: AddQueryToQueue; - constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager, @inject(TYPES.Services.GetSongs) getSongs: GetSongs, @inject(TYPES.ThirdParty) thirdParty: ThirdParty, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) { - this.playerManager = playerManager; - this.getSongs = getSongs; + constructor(@inject(TYPES.ThirdParty) thirdParty: ThirdParty, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider, @inject(TYPES.Services.AddQueryToQueue) addQueryToQueue: AddQueryToQueue) { this.spotify = thirdParty.spotify; this.cache = cache; + this.addQueryToQueue = addQueryToQueue; } // eslint-disable-next-line complexity public async execute(interaction: CommandInteraction): Promise { - const [targetVoiceChannel] = getMemberVoiceChannel(interaction.member as GuildMember) ?? getMostPopularVoiceChannel(interaction.guild!); - - const settings = await prisma.setting.findUnique({where: {guildId: interaction.guild!.id}}); - - if (!settings) { - throw new Error('Could not find settings for guild'); - } - - const {playlistLimit} = settings; - - const player = this.playerManager.get(interaction.guild!.id); - const wasPlayingSong = player.getCurrent() !== null; - - const query = interaction.options.getString('query'); - - if (!query) { - if (player.status === STATUS.PLAYING) { - throw new Error('already playing, give me a song name'); - } - - // Must be resuming play - if (!wasPlayingSong) { - throw new Error('nothing to play'); - } - - await player.connect(targetVoiceChannel); - await player.play(); - - await interaction.reply({ - content: 'the stop-and-go light is now green', - embeds: [buildPlayingMessageEmbed(player)], - }); - - return; - } - - const addToFrontOfQueue = interaction.options.getBoolean('immediate'); - const shuffleAdditions = interaction.options.getBoolean('shuffle'); - - await interaction.deferReply(); - - let newSongs: Array> = []; - let extraMsg = ''; - - // Test if it's a complete URL - try { - const url = new URL(query); - - const YOUTUBE_HOSTS = [ - 'www.youtube.com', - 'youtu.be', - 'youtube.com', - 'music.youtube.com', - 'www.music.youtube.com', - ]; - - if (YOUTUBE_HOSTS.includes(url.host)) { - // YouTube source - if (url.searchParams.get('list')) { - // YouTube playlist - newSongs.push(...await this.getSongs.youtubePlaylist(url.searchParams.get('list')!)); - } else { - const song = await this.getSongs.youtubeVideo(url.href); - - if (song) { - newSongs.push(song); - } else { - throw new Error('that doesn\'t exist'); - } - } - } else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') { - const [convertedSongs, nSongsNotFound, totalSongs] = await this.getSongs.spotifySource(query, playlistLimit); - - if (totalSongs > playlistLimit) { - extraMsg = `a random sample of ${playlistLimit} songs was taken`; - } - - if (totalSongs > playlistLimit && nSongsNotFound !== 0) { - extraMsg += ' and '; - } - - if (nSongsNotFound !== 0) { - if (nSongsNotFound === 1) { - extraMsg += '1 song was not found'; - } else { - extraMsg += `${nSongsNotFound.toString()} songs were not found`; - } - } - - newSongs.push(...convertedSongs); - } - } catch (_: unknown) { - // Not a URL, must search YouTube - const song = await this.getSongs.youtubeVideoSearch(query); - - if (song) { - newSongs.push(song); - } else { - throw new Error('that doesn\'t exist'); - } - } - - if (newSongs.length === 0) { - throw new Error('no songs found'); - } - - if (shuffleAdditions) { - newSongs = shuffle(newSongs); - } - - newSongs.forEach(song => { - player.add({...song, addedInChannelId: interaction.channel!.id, requestedBy: interaction.member!.user.id}, {immediate: addToFrontOfQueue ?? false}); + await this.addQueryToQueue.addToQueue({ + interaction, + query: interaction.options.getString('query')!.trim(), + addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false, + shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false, }); - - const firstSong = newSongs[0]; - - let statusMsg = ''; - - if (player.voiceConnection === null) { - await player.connect(targetVoiceChannel); - - // Resume / start playback - await player.play(); - - if (wasPlayingSong) { - statusMsg = 'resuming playback'; - } - - await interaction.editReply({ - embeds: [buildPlayingMessageEmbed(player)], - }); - } - - // Build response message - if (statusMsg !== '') { - if (extraMsg === '') { - extraMsg = statusMsg; - } else { - extraMsg = `${statusMsg}, ${extraMsg}`; - } - } - - if (extraMsg !== '') { - extraMsg = ` (${extraMsg})`; - } - - if (newSongs.length === 1) { - await interaction.editReply(`u betcha, **${firstSong.title}** added to the${addToFrontOfQueue ? ' front of the' : ''} queue${extraMsg}`); - } else { - await interaction.editReply(`u betcha, **${firstSong.title}** and ${newSongs.length - 1} other songs were added to the queue${extraMsg}`); - } } public async handleAutocompleteInteraction(interaction: AutocompleteInteraction): Promise { diff --git a/src/events/guild-create.ts b/src/events/guild-create.ts index 3cf94ea..8db7e2f 100644 --- a/src/events/guild-create.ts +++ b/src/events/guild-create.ts @@ -1,15 +1,11 @@ -import {Guild, TextChannel, Message, MessageReaction, User, ApplicationCommandData, -} from 'discord.js'; -import emoji from 'node-emoji'; -import pEvent from 'p-event'; -import {chunk} from '../utils/arrays.js'; +import {Guild, Client} from 'discord.js'; import container from '../inversify.config.js'; import Command from '../commands'; import {TYPES} from '../types.js'; import Config from '../services/config.js'; import {prisma} from '../utils/db.js'; - -const DEFAULT_PREFIX = '!'; +import {REST} from '@discordjs/rest'; +import {Routes} from 'discord-api-types/v9'; export default async (guild: Guild): Promise => { await prisma.setting.upsert({ @@ -18,99 +14,22 @@ export default async (guild: Guild): Promise => { }, create: { guildId: guild.id, - prefix: DEFAULT_PREFIX, - }, - update: { - prefix: DEFAULT_PREFIX, }, + update: {}, }); const config = container.get(TYPES.Config); // Setup slash commands if (!config.REGISTER_COMMANDS_ON_BOT) { - const commands: ApplicationCommandData[] = container.getAll(TYPES.Command) - .filter(command => command.slashCommand?.name) - .map(command => command.slashCommand as ApplicationCommandData); + const token = container.get(TYPES.Config).DISCORD_TOKEN; + const client = container.get(TYPES.Client); - await guild.commands.set(commands); + const rest = new REST({version: '9'}).setToken(token); + + await rest.put( + Routes.applicationGuildCommands(client.user!.id, guild.id), + {body: container.getAll(TYPES.Command).map(command => command.slashCommand.toJSON())}, + ); } - - const owner = await guild.client.users.fetch(guild.ownerId); - - let firstStep = '👋 Hi!\n'; - firstStep += 'I just need to ask a few questions before you start listening to music.\n\n'; - firstStep += 'First, what channel should I listen to for music commands?\n\n'; - - const firstStepMsg = await owner.send(firstStep); - - // Show emoji selector - interface EmojiChannel { - name: string; - id: string; - emoji: string; - } - - const emojiChannels: EmojiChannel[] = []; - - for (const [channelId, channel] of guild.channels.cache) { - if (channel.type === 'GUILD_TEXT') { - emojiChannels.push({ - name: channel.name, - id: channelId, - emoji: emoji.random().emoji, - }); - } - } - - const sentMessageIds: string[] = []; - - chunk(emojiChannels, 10).map(async chunk => { - let str = ''; - for (const channel of chunk) { - str += `${channel.emoji}: #${channel.name}\n`; - } - - const msg = await owner.send(str); - - sentMessageIds.push(msg.id); - - await Promise.all(chunk.map(async channel => msg.react(channel.emoji))); - }); - - // Wait for response from user - const [choice] = await pEvent(guild.client, 'messageReactionAdd', { - multiArgs: true, - filter: ([reaction, user]: [MessageReaction, User]) => sentMessageIds.includes(reaction.message.id) && user.id === owner.id, - }); - - const chosenChannel = emojiChannels.find(e => e.emoji === (choice as unknown as MessageReaction).emoji.name)!; - - // Second setup step (get prefix) - let secondStep = `👍 Cool, I'll listen to **#${chosenChannel.name}** \n\n`; - secondStep += 'Last question: what character should I use for a prefix? Type a single character and hit enter.'; - - await owner.send(secondStep); - - const prefixResponses = await firstStepMsg.channel.awaitMessages({filter: (r: Message) => r.content.length === 1, max: 1}); - - const prefixCharacter = prefixResponses.first()!.content; - - // Save settings - await prisma.setting.update({ - where: { - guildId: guild.id, - }, - data: { - channel: chosenChannel.id, - prefix: prefixCharacter, - }, - }); - - // Send welcome - const boundChannel = guild.client.channels.cache.get(chosenChannel.id) as TextChannel; - - await boundChannel.send(`hey <@${owner.id}> try \`\\play https://www.youtube.com/watch?v=dQw4w9WgXcQ\``); - - await firstStepMsg.channel.send(`Sounds good. Check out **#${chosenChannel.name}** to get started.`); }; diff --git a/src/inversify.config.ts b/src/inversify.config.ts index a1187a9..f423614 100644 --- a/src/inversify.config.ts +++ b/src/inversify.config.ts @@ -8,13 +8,15 @@ import ConfigProvider from './services/config.js'; // Managers import PlayerManager from './managers/player.js'; -// Helpers +// Services +import AddQueryToQueue from './services/add-query-to-queue.js'; import GetSongs from './services/get-songs.js'; // Comands import Command from './commands'; import Clear from './commands/clear.js'; import Disconnect from './commands/disconnect.js'; +import Favorites from './commands/favorites.js'; import ForwardSeek from './commands/fseek.js'; import Pause from './commands/pause.js'; import Play from './commands/play.js'; @@ -45,13 +47,15 @@ container.bind(TYPES.Client).toConstantValue(new Client({intents})); // Managers container.bind(TYPES.Managers.Player).to(PlayerManager).inSingletonScope(); -// Helpers +// Services container.bind(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope(); +container.bind(TYPES.Services.AddQueryToQueue).to(AddQueryToQueue).inSingletonScope(); // Commands [ Clear, Disconnect, + Favorites, ForwardSeek, Pause, Play, diff --git a/src/managers/player.ts b/src/managers/player.ts index 5d816b8..218a0b2 100644 --- a/src/managers/player.ts +++ b/src/managers/player.ts @@ -20,7 +20,7 @@ export default class { let player = this.guildPlayers.get(guildId); if (!player) { - player = new Player(this.discordClient, this.fileCache); + player = new Player(this.discordClient, this.fileCache, guildId); this.guildPlayers.set(guildId, player); } diff --git a/src/scripts/run-with-database-url.ts b/src/scripts/run-with-database-url.ts index b7ae3a7..e4ab39a 100644 --- a/src/scripts/run-with-database-url.ts +++ b/src/scripts/run-with-database-url.ts @@ -2,12 +2,14 @@ import {DATA_DIR} from '../services/config.js'; import createDatabaseUrl from '../utils/create-database-url.js'; import {execa} from 'execa'; -process.env.DATABASE_URL = createDatabaseUrl(DATA_DIR); - (async () => { await execa(process.argv[2], process.argv.slice(3), { preferLocal: true, stderr: process.stderr, stdout: process.stdout, + stdin: process.stdin, + env: { + DATABASE_URL: createDatabaseUrl(DATA_DIR), + }, }); })(); diff --git a/src/services/add-query-to-queue.ts b/src/services/add-query-to-queue.ts new file mode 100644 index 0000000..bb512c6 --- /dev/null +++ b/src/services/add-query-to-queue.ts @@ -0,0 +1,155 @@ +import {CommandInteraction, GuildMember} from 'discord.js'; +import {inject, injectable} from 'inversify'; +import {Except} from 'type-fest'; +import shuffle from 'array-shuffle'; +import {TYPES} from '../types.js'; +import GetSongs from '../services/get-songs.js'; +import {QueuedSong} from './player.js'; +import PlayerManager from '../managers/player.js'; +import {prisma} from '../utils/db.js'; +import {buildPlayingMessageEmbed} from '../utils/build-embed.js'; +import {getMemberVoiceChannel, getMostPopularVoiceChannel} from '../utils/channels.js'; + +@injectable() +export default class AddQueryToQueue { + constructor(@inject(TYPES.Services.GetSongs) private readonly getSongs: GetSongs, @inject(TYPES.Managers.Player) private readonly playerManager: PlayerManager) {} + + public async addToQueue({ + query, + addToFrontOfQueue, + shuffleAdditions, + interaction, + }: { + query: string; + addToFrontOfQueue: boolean; + shuffleAdditions: boolean; + interaction: CommandInteraction; + }): Promise { + const guildId = interaction.guild!.id; + const player = this.playerManager.get(guildId); + const wasPlayingSong = player.getCurrent() !== null; + + const [targetVoiceChannel] = getMemberVoiceChannel(interaction.member as GuildMember) ?? getMostPopularVoiceChannel(interaction.guild!); + + const settings = await prisma.setting.findUnique({where: {guildId}}); + + if (!settings) { + throw new Error('Could not find settings for guild'); + } + + const {playlistLimit} = settings; + + await interaction.deferReply(); + + let newSongs: Array> = []; + let extraMsg = ''; + + // Test if it's a complete URL + try { + const url = new URL(query); + + const YOUTUBE_HOSTS = [ + 'www.youtube.com', + 'youtu.be', + 'youtube.com', + 'music.youtube.com', + 'www.music.youtube.com', + ]; + + if (YOUTUBE_HOSTS.includes(url.host)) { + // YouTube source + if (url.searchParams.get('list')) { + // YouTube playlist + newSongs.push(...await this.getSongs.youtubePlaylist(url.searchParams.get('list')!)); + } else { + const song = await this.getSongs.youtubeVideo(url.href); + + if (song) { + newSongs.push(song); + } else { + throw new Error('that doesn\'t exist'); + } + } + } else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') { + const [convertedSongs, nSongsNotFound, totalSongs] = await this.getSongs.spotifySource(query, playlistLimit); + + if (totalSongs > playlistLimit) { + extraMsg = `a random sample of ${playlistLimit} songs was taken`; + } + + if (totalSongs > playlistLimit && nSongsNotFound !== 0) { + extraMsg += ' and '; + } + + if (nSongsNotFound !== 0) { + if (nSongsNotFound === 1) { + extraMsg += '1 song was not found'; + } else { + extraMsg += `${nSongsNotFound.toString()} songs were not found`; + } + } + + newSongs.push(...convertedSongs); + } + } catch (_: unknown) { + // Not a URL, must search YouTube + const song = await this.getSongs.youtubeVideoSearch(query); + + if (song) { + newSongs.push(song); + } else { + throw new Error('that doesn\'t exist'); + } + } + + if (newSongs.length === 0) { + throw new Error('no songs found'); + } + + if (shuffleAdditions) { + newSongs = shuffle(newSongs); + } + + newSongs.forEach(song => { + player.add({...song, addedInChannelId: interaction.channel!.id, requestedBy: interaction.member!.user.id}, {immediate: addToFrontOfQueue ?? false}); + }); + + const firstSong = newSongs[0]; + + let statusMsg = ''; + + if (player.voiceConnection === null) { + await player.connect(targetVoiceChannel); + + // Resume / start playback + await player.play(); + + if (wasPlayingSong) { + statusMsg = 'resuming playback'; + } + + await interaction.editReply({ + embeds: [buildPlayingMessageEmbed(player)], + }); + } + + // Build response message + if (statusMsg !== '') { + if (extraMsg === '') { + extraMsg = statusMsg; + } else { + extraMsg = `${statusMsg}, ${extraMsg}`; + } + } + + if (extraMsg !== '') { + extraMsg = ` (${extraMsg})`; + } + + if (newSongs.length === 1) { + await interaction.editReply(`u betcha, **${firstSong.title}** added to the${addToFrontOfQueue ? ' front of the' : ''} queue${extraMsg}`); + } else { + await interaction.editReply(`u betcha, **${firstSong.title}** and ${newSongs.length - 1} other songs were added to the queue${extraMsg}`); + } + } +} diff --git a/src/services/player.ts b/src/services/player.ts index 871b1c1..8255119 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -38,6 +38,7 @@ export interface PlayerEvents { export default class { public voiceConnection: VoiceConnection | null = null; public status = STATUS.PAUSED; + public guildId: string; private queue: QueuedSong[] = []; private queuePosition = 0; @@ -51,9 +52,10 @@ export default class { private readonly discordClient: Client; private readonly fileCache: FileCacheProvider; - constructor(client: Client, fileCache: FileCacheProvider) { + constructor(client: Client, fileCache: FileCacheProvider, guildId: string) { this.discordClient = client; this.fileCache = fileCache; + this.guildId = guildId; } async connect(channel: VoiceChannel): Promise { diff --git a/src/types.ts b/src/types.ts index 19c734d..47108b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,7 +11,7 @@ export const TYPES = { UpdatingQueueEmbed: Symbol('UpdatingQueueEmbed'), }, Services: { + AddQueryToQueue: Symbol('AddQueryToQueue'), GetSongs: Symbol('GetSongs'), - NaturalLanguage: Symbol('NaturalLanguage'), }, }; diff --git a/yarn.lock b/yarn.lock index 16fe8bd..9804d00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -279,7 +279,7 @@ dependencies: "@octokit/openapi-types" "^11.2.0" -"@prisma/client@^3.7.0": +"@prisma/client@^3.8.1": version "3.8.1" resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.8.1.tgz#c11eda8e84760867552ffde4de7b48fb2cf1e1c0" integrity sha512-NxD1Xbkx1eT1mxSwo1RwZe665mqBETs0VxohuwNfFIxMqcp0g6d4TgugPxwZ4Jb4e5wCu8mQ9quMedhNWIWcZQ== @@ -2899,7 +2899,7 @@ prism-media@^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.7.0: +prisma@^3.8.1: version "3.8.1" resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.8.1.tgz#44395cef7cbb1ea86216cb84ee02f856c08a7873" integrity sha512-Q8zHwS9m70TaD7qI8u+8hTAmiTpK+IpvRYF3Rgb/OeWGQJOMgZCFFvNCiSfoLEQ95wilK7ctW3KOpc9AuYnRUA==