aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--migrations/20220128000207_add_favorite_query_model/migration.sql10
-rw-r--r--migrations/20220128000623_remove_shortcut_model/migration.sql10
-rw-r--r--migrations/20220128003935_make_favorite_query_name_unqiue/migration.sql8
-rw-r--r--migrations/20220128012347_fix_unique_constraint/migration.sql11
-rw-r--r--migrations/20220128020826_remove_prefix_from_setting/migration.sql21
-rw-r--r--package.json4
-rw-r--r--schema.prisma13
-rw-r--r--src/bot.ts30
-rw-r--r--src/commands/favorites.ts191
-rw-r--r--src/commands/index.ts6
-rw-r--r--src/commands/play.ts178
-rw-r--r--src/events/guild-create.ts103
-rw-r--r--src/inversify.config.ts8
-rw-r--r--src/managers/player.ts2
-rw-r--r--src/scripts/run-with-database-url.ts6
-rw-r--r--src/services/add-query-to-queue.ts155
-rw-r--r--src/services/player.ts4
-rw-r--r--src/types.ts2
-rw-r--r--yarn.lock4
19 files changed, 477 insertions, 289 deletions
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<void> {
// Load in commands
- container.getAll<Command>(TYPES.Command).forEach(command => {
- // TODO: remove !
- if (command.slashCommand?.name) {
+ for (const command of container.getAll<Command>(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<SlashCommandBuilder> & Pick<SlashCommandBuilder, 'toJSON'>;
+ readonly slashCommand: Partial<SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder> & Pick<SlashCommandBuilder, 'toJSON'>;
readonly handledButtonIds?: readonly string[];
- readonly requiresVC?: boolean;
+ readonly requiresVC?: boolean | ((interaction: CommandInteraction) => boolean);
execute: (interaction: CommandInteraction) => Promise<void>;
handleButtonInteraction?: (interaction: ButtonInteraction) => Promise<void>;
handleAutocompleteInteraction?: (interaction: AutocompleteInteraction) => Promise<void>;
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<void> {
- 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<Except<QueuedSong, 'addedInChannelId' | 'requestedBy'>> = [];
- 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<void> {
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<void> => {
await prisma.setting.upsert({
@@ -18,99 +14,22 @@ export default async (guild: Guild): Promise<void> => {
},
create: {
guildId: guild.id,
- prefix: DEFAULT_PREFIX,
- },
- update: {
- prefix: DEFAULT_PREFIX,
},
+ update: {},
});
const config = container.get<Config>(TYPES.Config);
// Setup slash commands
if (!config.REGISTER_COMMANDS_ON_BOT) {
- const commands: ApplicationCommandData[] = container.getAll<Command>(TYPES.Command)
- .filter(command => command.slashCommand?.name)
- .map(command => command.slashCommand as ApplicationCommandData);
-
- await guild.commands.set(commands);
- }
-
- 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 token = container.get<Config>(TYPES.Config).DISCORD_TOKEN;
+ const client = container.get<Client>(TYPES.Client);
- const emojiChannels: EmojiChannel[] = [];
+ const rest = new REST({version: '9'}).setToken(token);
- for (const [channelId, channel] of guild.channels.cache) {
- if (channel.type === 'GUILD_TEXT') {
- emojiChannels.push({
- name: channel.name,
- id: channelId,
- emoji: emoji.random().emoji,
- });
- }
+ await rest.put(
+ Routes.applicationGuildCommands(client.user!.id, guild.id),
+ {body: container.getAll<Command>(TYPES.Command).map(command => command.slashCommand.toJSON())},
+ );
}
-
- 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<Client>(TYPES.Client).toConstantValue(new Client({intents}));
// Managers
container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope();
-// Helpers
+// Services
container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope();
+container.bind<AddQueryToQueue>(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<void> {
+ 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<Except<QueuedSong, 'addedInChannelId' | 'requestedBy'>> = [];
+ 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<void> {
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==