aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMax Isom <[email protected]>2022-01-26 12:58:33 -0500
committerMax Isom <[email protected]>2022-01-26 12:58:33 -0500
commitaacb107f43db6b8c53d160b5913701959f81cf09 (patch)
treea63d0717d03c84987b69d5af1c1f5b45ef019a4a /src
parent09665af53ee1b1903fc9ea719722aa5dfdc26325 (diff)
parentaf05210be4a8857ea707866192efa79b3945b314 (diff)
downloadmuse-aacb107f43db6b8c53d160b5913701959f81cf09.tar.xz
muse-aacb107f43db6b8c53d160b5913701959f81cf09.zip
Merge branch 'master' into feature/slash-commands
Diffstat (limited to 'src')
-rw-r--r--src/bot.ts75
-rw-r--r--src/commands/play.ts47
-rw-r--r--src/commands/queue.ts68
-rw-r--r--src/commands/skip.ts8
-rw-r--r--src/commands/unskip.ts7
-rw-r--r--src/index.ts2
-rw-r--r--src/inversify.config.ts2
-rw-r--r--src/managers/updating-queue-embed.ts33
-rw-r--r--src/services/get-songs.ts31
-rw-r--r--src/services/natural-language-commands.ts6
-rw-r--r--src/services/player.ts4
-rw-r--r--src/services/updating-queue-embed.ts229
-rw-r--r--src/utils/build-embed.ts127
-rw-r--r--src/utils/error-msg.ts2
-rw-r--r--src/utils/loading-message.ts81
-rw-r--r--src/utils/string.ts2
16 files changed, 235 insertions, 489 deletions
diff --git a/src/bot.ts b/src/bot.ts
index c51caf9..f89fad5 100644
--- a/src/bot.ts
+++ b/src/bot.ts
@@ -33,7 +33,7 @@ export default class {
this.commandsByButtonId = new Collection();
}
- public async listen(): Promise<void> {
+ public async register(): Promise<void> {
// Load in commands
container.getAll<Command>(TYPES.Command).forEach(command => {
// TODO: remove !
@@ -48,47 +48,28 @@ export default class {
// Register event handlers
this.client.on('interactionCreate', async interaction => {
- if (!interaction.isCommand()) {
- return;
- }
-
- const command = this.commandsByName.get(interaction.commandName);
-
- if (!command) {
- return;
- }
-
- if (!interaction.guild) {
- await interaction.reply(errorMsg('you can\'t use this bot in a DM'));
- return;
- }
-
try {
- if (command.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;
- }
+ if (interaction.isCommand()) {
+ const command = this.commandsByName.get(interaction.commandName);
- if (command.execute) {
- await command.execute(interaction);
- }
- } catch (error: unknown) {
- debug(error);
+ if (!command) {
+ return;
+ }
- // This can fail if the message was deleted, and we don't want to crash the whole bot
- try {
- if (interaction.replied || interaction.deferred) {
- await interaction.editReply(errorMsg('something went wrong'));
- } else {
- await interaction.reply({content: errorMsg(error as Error), ephemeral: true});
+ if (!interaction.guild) {
+ await interaction.reply(errorMsg('you can\'t use this bot in a DM'));
+ return;
}
- } catch {}
- }
- });
- this.client.on('interactionCreate', async interaction => {
- try {
- if (interaction.isButton()) {
+ if (command.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;
+ }
+
+ if (command.execute) {
+ await command.execute(interaction);
+ }
+ } else if (interaction.isButton()) {
const command = this.commandsByButtonId.get(interaction.customId);
if (!command) {
@@ -98,9 +79,7 @@ export default class {
if (command.handleButtonInteraction) {
await command.handleButtonInteraction(interaction);
}
- }
-
- if (interaction.isAutocomplete()) {
+ } else if (interaction.isAutocomplete()) {
const command = this.commandsByName.get(interaction.commandName);
if (!command) {
@@ -114,14 +93,14 @@ export default class {
} catch (error: unknown) {
debug(error);
- // Can't reply with errors for autocomplete queries
- if (interaction.isButton()) {
- if (interaction.replied || interaction.deferred) {
- await interaction.editReply(errorMsg('something went wrong'));
- } else {
+ // This can fail if the message was deleted, and we don't want to crash the whole bot
+ try {
+ if ((interaction.isApplicationCommand() || interaction.isButton()) && (interaction.replied || interaction.deferred)) {
+ await interaction.editReply(errorMsg(error as Error));
+ } else if (interaction.isApplicationCommand() || interaction.isButton()) {
await interaction.reply({content: errorMsg(error as Error), ephemeral: true});
}
- }
+ } catch {}
}
});
@@ -138,7 +117,7 @@ export default class {
await rest.put(
Routes.applicationCommands(this.client.user!.id),
- {body: this.commandsByName.map(command => command.slashCommand ? command.slashCommand.toJSON() : null)},
+ {body: this.commandsByName.map(command => command.slashCommand.toJSON())},
);
} else {
spinner.text = '📡 updating commands in all guilds...';
@@ -147,7 +126,7 @@ export default class {
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 ? command.slashCommand.toJSON() : null)},
+ {body: this.commandsByName.map(command => command.slashCommand.toJSON())},
);
}),
);
diff --git a/src/commands/play.ts b/src/commands/play.ts
index 5ef4c1d..d1ab940 100644
--- a/src/commands/play.ts
+++ b/src/commands/play.ts
@@ -10,13 +10,13 @@ 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 errorMsg from '../utils/error-msg.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';
@injectable()
export default class implements Command {
@@ -68,31 +68,33 @@ export default class implements Command {
if (!query) {
if (player.status === STATUS.PLAYING) {
- await interaction.reply({content: errorMsg('already playing, give me a song name'), ephemeral: true});
- return;
+ throw new Error('already playing, give me a song name');
}
// Must be resuming play
if (!wasPlayingSong) {
- await interaction.reply({content: errorMsg('nothing to play'), ephemeral: true});
- return;
+ throw new Error('nothing to play');
}
await player.connect(targetVoiceChannel);
await player.play();
- await interaction.reply('the stop-and-go light is now green');
+ 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');
- let newSongs: Array<Except<QueuedSong, 'addedInChannelId'>> = [];
- let extraMsg = '';
-
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);
@@ -111,14 +113,12 @@ export default class implements Command {
// YouTube playlist
newSongs.push(...await this.getSongs.youtubePlaylist(url.searchParams.get('list')!));
} else {
- // Single video
const song = await this.getSongs.youtubeVideo(url.href);
if (song) {
newSongs.push(song);
} else {
- await interaction.editReply(errorMsg('that doesn\'t exist'));
- return;
+ throw new Error('that doesn\'t exist');
}
}
} else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') {
@@ -149,14 +149,12 @@ export default class implements Command {
if (song) {
newSongs.push(song);
} else {
- await interaction.editReply(errorMsg('that doesn\'t exist'));
- return;
+ throw new Error('that doesn\'t exist');
}
}
if (newSongs.length === 0) {
- await interaction.editReply(errorMsg('no songs found'));
- return;
+ throw new Error('no songs found');
}
if (shuffleAdditions) {
@@ -164,7 +162,7 @@ export default class implements Command {
}
newSongs.forEach(song => {
- player.add({...song, addedInChannelId: interaction.channel?.id}, {immediate: addToFrontOfQueue ?? false});
+ player.add({...song, addedInChannelId: interaction.channel!.id, requestedBy: interaction.member!.user.id}, {immediate: addToFrontOfQueue ?? false});
});
const firstSong = newSongs[0];
@@ -180,6 +178,10 @@ export default class implements Command {
if (wasPlayingSong) {
statusMsg = 'resuming playback';
}
+
+ await interaction.editReply({
+ embeds: [buildPlayingMessageEmbed(player)],
+ });
}
// Build response message
@@ -206,9 +208,18 @@ export default class implements Command {
const query = interaction.options.getString('query')?.trim();
if (!query || query.length === 0) {
- return interaction.respond([]);
+ await interaction.respond([]);
+ return;
}
+ try {
+ // Don't return suggestions for URLs
+ // eslint-disable-next-line no-new
+ new URL(query);
+ await interaction.respond([]);
+ return;
+ } catch {}
+
const suggestions = await this.cache.wrap(
getYouTubeAndSpotifySuggestionsFor,
query,
diff --git a/src/commands/queue.ts b/src/commands/queue.ts
index 9768f58..4d75d42 100644
--- a/src/commands/queue.ts
+++ b/src/commands/queue.ts
@@ -1,80 +1,32 @@
-import {ButtonInteraction, CommandInteraction} from 'discord.js';
+import {CommandInteraction} from 'discord.js';
import {SlashCommandBuilder} from '@discordjs/builders';
import {inject, injectable} from 'inversify';
import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js';
-import UpdatingQueueEmbedManager from '../managers/updating-queue-embed.js';
-import {BUTTON_IDS} from '../services/updating-queue-embed.js';
import Command from '.';
+import {buildQueueEmbed} from '../utils/build-embed.js';
@injectable()
export default class implements Command {
public readonly slashCommand = new SlashCommandBuilder()
.setName('queue')
- .setDescription('show the current queue');
-
- public readonly handledButtonIds = Object.values(BUTTON_IDS);
+ .setDescription('show the current queue')
+ .addIntegerOption(option => option
+ .setName('page')
+ .setDescription('page of queue to show [default: 1]')
+ .setRequired(false));
private readonly playerManager: PlayerManager;
- private readonly updatingQueueEmbedManager: UpdatingQueueEmbedManager;
- constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager, @inject(TYPES.Managers.UpdatingQueueEmbed) updatingQueueEmbedManager: UpdatingQueueEmbedManager) {
+ constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) {
this.playerManager = playerManager;
- this.updatingQueueEmbedManager = updatingQueueEmbedManager;
}
public async execute(interaction: CommandInteraction) {
- const embed = this.updatingQueueEmbedManager.get(interaction.guild!.id);
-
- await embed.createFromInteraction(interaction);
- }
-
- public async handleButtonInteraction(interaction: ButtonInteraction) {
const player = this.playerManager.get(interaction.guild!.id);
- const embed = this.updatingQueueEmbedManager.get(interaction.guild!.id);
-
- const buttonId = interaction.customId as keyof typeof this.handledButtonIds;
-
- // Not entirely sure why this is necessary.
- // We don't wait for the Promise to resolve here to avoid blocking the
- // main logic. However, we need to wait for the Promise to be resolved before
- // throwing as otherwise a race condition pops up when bot.ts tries updating
- // the interaction.
- const deferedUpdatePromise = interaction.deferUpdate();
-
- try {
- switch (buttonId) {
- case BUTTON_IDS.TRACK_BACK:
- await player.back();
- break;
-
- case BUTTON_IDS.TRACK_FORWARD:
- await player.forward(1);
- break;
-
- case BUTTON_IDS.PAUSE:
- player.pause();
- break;
-
- case BUTTON_IDS.PLAY:
- await player.play();
- break;
-
- case BUTTON_IDS.PAGE_BACK:
- await embed.pageBack();
- break;
-
- case BUTTON_IDS.PAGE_FORWARD:
- await embed.pageForward();
- break;
- default:
- throw new Error('unknown customId');
- }
- } catch (error: unknown) {
- await deferedUpdatePromise;
+ const embed = buildQueueEmbed(player, interaction.options.getInteger('page') ?? 1);
- throw error;
- }
+ await interaction.reply({embeds: [embed]});
}
}
diff --git a/src/commands/skip.ts b/src/commands/skip.ts
index dd24eb2..cfcd605 100644
--- a/src/commands/skip.ts
+++ b/src/commands/skip.ts
@@ -5,6 +5,7 @@ import PlayerManager from '../managers/player.js';
import Command from '.';
import errorMsg from '../utils/error-msg.js';
import {SlashCommandBuilder} from '@discordjs/builders';
+import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
@injectable()
export default class implements Command {
@@ -35,9 +36,12 @@ export default class implements Command {
try {
await player.forward(numToSkip);
- await interaction.reply('keep \'er movin\'');
+ await interaction.reply({
+ content: 'keep \'er movin\'',
+ embeds: player.getCurrent() ? [buildPlayingMessageEmbed(player)] : [],
+ });
} catch (_: unknown) {
- await interaction.reply({content: errorMsg('invalid number of songs to skip'), ephemeral: true});
+ await interaction.reply({content: errorMsg('no song to skip to'), ephemeral: true});
}
}
}
diff --git a/src/commands/unskip.ts b/src/commands/unskip.ts
index 746cdf9..b1947b8 100644
--- a/src/commands/unskip.ts
+++ b/src/commands/unskip.ts
@@ -5,6 +5,7 @@ import PlayerManager from '../managers/player.js';
import errorMsg from '../utils/error-msg.js';
import Command from '.';
import {SlashCommandBuilder} from '@discordjs/builders';
+import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
@injectable()
export default class implements Command {
@@ -25,8 +26,10 @@ export default class implements Command {
try {
await player.back();
-
- await interaction.reply('back \'er up\'');
+ await interaction.reply({
+ content: 'back \'er up\'',
+ embeds: player.getCurrent() ? [buildPlayingMessageEmbed(player)] : [],
+ });
} catch (_: unknown) {
await interaction.reply({
content: errorMsg('no song to go back to'),
diff --git a/src/index.ts b/src/index.ts
index e6df09d..08a7152 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -18,7 +18,7 @@ const startBot = async () => {
await container.get<FileCacheProvider>(TYPES.FileCache).cleanup();
- await bot.listen();
+ await bot.register();
};
export {startBot};
diff --git a/src/inversify.config.ts b/src/inversify.config.ts
index 5714d6f..b2a18fb 100644
--- a/src/inversify.config.ts
+++ b/src/inversify.config.ts
@@ -7,7 +7,6 @@ import ConfigProvider from './services/config.js';
// Managers
import PlayerManager from './managers/player.js';
-import UpdatingQueueEmbed from './managers/updating-queue-embed.js';
// Helpers
import GetSongs from './services/get-songs.js';
@@ -46,7 +45,6 @@ container.bind<Client>(TYPES.Client).toConstantValue(new Client({intents}));
// Managers
container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope();
-container.bind<UpdatingQueueEmbed>(TYPES.Managers.UpdatingQueueEmbed).to(UpdatingQueueEmbed).inSingletonScope();
// Helpers
container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope();
diff --git a/src/managers/updating-queue-embed.ts b/src/managers/updating-queue-embed.ts
deleted file mode 100644
index 37732de..0000000
--- a/src/managers/updating-queue-embed.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import {inject, injectable} from 'inversify';
-import {TYPES} from '../types.js';
-import PlayerManager from '../managers/player.js';
-import UpdatingQueueEmbed from '../services/updating-queue-embed.js';
-
-@injectable()
-export default class {
- private readonly embedsByGuild: Map<string, UpdatingQueueEmbed>;
- private readonly playerManager: PlayerManager;
-
- constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) {
- this.embedsByGuild = new Map();
- this.playerManager = playerManager;
- }
-
- get(guildId: string): UpdatingQueueEmbed {
- let embed = this.embedsByGuild.get(guildId);
-
- if (!embed) {
- const player = this.playerManager.get(guildId);
-
- if (!player) {
- throw new Error('Player does not exist for guild.');
- }
-
- embed = new UpdatingQueueEmbed(player);
-
- this.embedsByGuild.set(guildId, embed);
- }
-
- return embed;
- }
-}
diff --git a/src/services/get-songs.ts b/src/services/get-songs.ts
index 996a638..33c3173 100644
--- a/src/services/get-songs.ts
+++ b/src/services/get-songs.ts
@@ -17,7 +17,7 @@ import Config from './config.js';
import KeyValueCacheProvider from './key-value-cache.js';
import {ONE_HOUR_IN_SECONDS, ONE_MINUTE_IN_SECONDS} from '../utils/constants.js';
-type QueuedSongWithoutChannel = Except<QueuedSong, 'addedInChannelId'>;
+type SongMetadata = Except<QueuedSong, 'addedInChannelId' | 'requestedBy'>;
@injectable()
export default class {
@@ -40,7 +40,7 @@ export default class {
this.ytsrQueue = new PQueue({concurrency: 4});
}
- async youtubeVideoSearch(query: string): Promise<QueuedSongWithoutChannel> {
+ async youtubeVideoSearch(query: string): Promise<SongMetadata> {
const {items} = await this.ytsrQueue.add(async () => this.cache.wrap(
ytsr,
query,
@@ -68,7 +68,7 @@ export default class {
return this.youtubeVideo(firstVideo.id);
}
- async youtubeVideo(url: string): Promise<QueuedSongWithoutChannel> {
+ async youtubeVideo(url: string): Promise<SongMetadata> {
const videoDetails = await this.cache.wrap(
this.youtube.videos.get,
cleanUrl(url),
@@ -84,10 +84,11 @@ export default class {
url: videoDetails.id,
playlist: null,
isLive: videoDetails.snippet.liveBroadcastContent === 'live',
+ thumbnailUrl: videoDetails.snippet.thumbnails.medium.url,
};
}
- async youtubePlaylist(listId: string): Promise<QueuedSongWithoutChannel[]> {
+ async youtubePlaylist(listId: string): Promise<SongMetadata[]> {
// YouTube playlist
const playlist = await this.cache.wrap(
this.youtube.playlists.get,
@@ -156,7 +157,7 @@ export default class {
const queuedPlaylist = {title: playlist.snippet.title, source: playlist.id};
- const songsToReturn: QueuedSongWithoutChannel[] = [];
+ const songsToReturn: SongMetadata[] = [];
for (const video of playlistVideos) {
try {
@@ -169,6 +170,7 @@ export default class {
url: video.contentDetails.videoId,
playlist: queuedPlaylist,
isLive: false,
+ thumbnailUrl: video.snippet.thumbnails.medium.url,
});
} catch (_: unknown) {
// Private and deleted videos are sometimes in playlists, duration of these is not returned and they should not be added to the queue.
@@ -178,7 +180,7 @@ export default class {
return songsToReturn;
}
- async spotifySource(url: string, playlistLimit: number): Promise<[QueuedSongWithoutChannel[], number, number]> {
+ async spotifySource(url: string, playlistLimit: number): Promise<[SongMetadata[], number, number]> {
const parsed = spotifyURI.parse(url);
let tracks: SpotifyApi.TrackObjectSimplified[] = [];
@@ -251,14 +253,17 @@ export default class {
tracks = shuffled.slice(0, playlistLimit);
}
- let songs = await Promise.all(tracks.map(async track => this.spotifyToYouTube(track, playlist)));
+ const searchResults = await Promise.allSettled(tracks.map(async track => this.spotifyToYouTube(track)));
let nSongsNotFound = 0;
- // Get rid of null values
- songs = songs.reduce((accum: QueuedSongWithoutChannel[], song) => {
- if (song) {
- accum.push(song);
+ // Count songs that couldn't be found
+ const songs: SongMetadata[] = searchResults.reduce((accum: SongMetadata[], result) => {
+ if (result.status === 'fulfilled') {
+ accum.push({
+ ...result.value,
+ ...(playlist ? {playlist} : {}),
+ });
} else {
nSongsNotFound++;
}
@@ -266,10 +271,10 @@ export default class {
return accum;
}, []);
- return [songs as QueuedSongWithoutChannel[], nSongsNotFound, originalNSongs];
+ return [songs, nSongsNotFound, originalNSongs];
}
- private async spotifyToYouTube(track: SpotifyApi.TrackObjectSimplified, _: QueuedPlaylist | null): Promise<QueuedSongWithoutChannel> {
+ private async spotifyToYouTube(track: SpotifyApi.TrackObjectSimplified): Promise<SongMetadata> {
return this.youtubeVideoSearch(`"${track.name}" "${track.artists[0].name}"`);
}
}
diff --git a/src/services/natural-language-commands.ts b/src/services/natural-language-commands.ts
index 1401eac..4ce8ea3 100644
--- a/src/services/natural-language-commands.ts
+++ b/src/services/natural-language-commands.ts
@@ -32,6 +32,8 @@ export default class {
playlist: null,
isLive: false,
addedInChannelId: msg.channel.id,
+ thumbnailUrl: null,
+ requestedBy: msg.author.id,
}, 8, 10),
]);
@@ -49,6 +51,8 @@ export default class {
playlist: null,
isLive: false,
addedInChannelId: msg.channel.id,
+ thumbnailUrl: null,
+ requestedBy: msg.author.id,
}, 358, 5.5),
]);
@@ -66,6 +70,8 @@ export default class {
playlist: null,
isLive: false,
addedInChannelId: msg.channel.id,
+ thumbnailUrl: null,
+ requestedBy: msg.author.id,
}, 50, 13),
]);
diff --git a/src/services/player.ts b/src/services/player.ts
index 87a33e6..9257d98 100644
--- a/src/services/player.ts
+++ b/src/services/player.ts
@@ -23,7 +23,9 @@ export interface QueuedSong {
length: number;
playlist: QueuedPlaylist | null;
isLive: boolean;
- addedInChannelId?: Snowflake;
+ addedInChannelId: Snowflake;
+ thumbnailUrl: string | null;
+ requestedBy: string;
}
export enum STATUS {
diff --git a/src/services/updating-queue-embed.ts b/src/services/updating-queue-embed.ts
deleted file mode 100644
index 6496fb5..0000000
--- a/src/services/updating-queue-embed.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-import {CommandInteraction, MessageActionRow, MessageButton, MessageEmbed, DiscordAPIError} from 'discord.js';
-import getYouTubeID from 'get-youtube-id';
-import getProgressBar from '../utils/get-progress-bar.js';
-import {prettyTime} from '../utils/time.js';
-import Player, {STATUS} from './player.js';
-
-const PAGE_SIZE = 10;
-
-const REFRESH_INTERVAL_MS = 5 * 1000;
-
-export enum BUTTON_IDS {
- PAGE_BACK = 'page-back',
- PAGE_FORWARD = 'page-forward',
- TRACK_BACK = 'track-back',
- TRACK_FORWARD = 'track-forward',
- PAUSE = 'pause',
- PLAY = 'play',
-}
-
-export default class {
- private readonly player: Player;
- private interaction?: CommandInteraction;
-
- // 1-indexed
- private currentPage = 1;
-
- private refreshTimeout?: NodeJS.Timeout;
-
- constructor(player: Player) {
- this.player = player;
-
- this.addEventHandlers();
- }
-
- /**
- * Creates & replies with a new embed from the given interaction.
- * Starts updating the embed at a regular interval.
- * Can be called multiple times within the lifecycle of this class.
- * Calling this method will make it forgot the previous interaction & reply.
- * @param interaction
- */
- async createFromInteraction(interaction: CommandInteraction) {
- const oldInteraction = this.interaction;
-
- this.resetState();
-
- this.interaction = interaction;
-
- await Promise.all([
- interaction.reply({
- embeds: [this.buildEmbed()],
- components: this.buildButtons(this.player),
- }),
- (async () => {
- if (oldInteraction) {
- await oldInteraction.deleteReply();
- }
- })(),
- ]);
-
- if (!this.refreshTimeout) {
- this.refreshTimeout = setInterval(async () => this.update(), REFRESH_INTERVAL_MS);
- }
- }
-
- async update(shouldResetPage = false) {
- if (shouldResetPage) {
- this.currentPage = 1;
- }
-
- try {
- await this.interaction?.editReply({
- embeds: [this.buildEmbed()],
- components: this.buildButtons(this.player),
- });
- } catch (error: unknown) {
- if (error instanceof DiscordAPIError) {
- // Interaction / message was deleted
- if (error.code === 10008) {
- this.resetState();
-
- return;
- }
- }
-
- throw error;
- }
- }
-
- async pageBack() {
- if (this.currentPage > 1) {
- this.currentPage--;
- }
-
- await this.update();
- }
-
- async pageForward() {
- if (this.currentPage < this.getMaxPage()) {
- this.currentPage++;
- }
-
- await this.update();
- }
-
- private resetState() {
- if (this.refreshTimeout) {
- clearInterval(this.refreshTimeout);
- this.refreshTimeout = undefined;
- }
-
- this.currentPage = 1;
- this.interaction = undefined;
- }
-
- private buildButtons(player: Player): MessageActionRow[] {
- const queuePageControls = new MessageActionRow()
- .addComponents(
- new MessageButton()
- .setCustomId(BUTTON_IDS.PAGE_BACK)
- .setStyle('SECONDARY')
- .setDisabled(this.currentPage === 1)
- .setEmoji('âŦ…ī¸'),
-
- new MessageButton()
- .setCustomId(BUTTON_IDS.PAGE_FORWARD)
- .setStyle('SECONDARY')
- .setDisabled(this.currentPage >= this.getMaxPage())
- .setEmoji('âžĄī¸'),
- );
-
- const components = [];
-
- components.push(
- new MessageButton()
- .setCustomId(BUTTON_IDS.TRACK_BACK)
- .setStyle('PRIMARY')
- .setDisabled(!player.canGoBack())
- .setEmoji('⏎'));
-
- if (player.status === STATUS.PLAYING) {
- components.push(
- new MessageButton()
- .setCustomId(BUTTON_IDS.PAUSE)
- .setStyle('PRIMARY')
- .setDisabled(!player.getCurrent())
- .setEmoji('â¸ī¸'));
- } else {
- components.push(
- new MessageButton()
- .setCustomId(BUTTON_IDS.PLAY)
- .setStyle('PRIMARY')
- .setDisabled(!player.getCurrent())
- .setEmoji('â–ļī¸'));
- }
-
- components.push(
- new MessageButton()
- .setCustomId(BUTTON_IDS.TRACK_FORWARD)
- .setStyle('PRIMARY')
- .setDisabled(!player.canGoForward(1))
- .setEmoji('⏭'),
- );
-
- const playerControls = new MessageActionRow().addComponents(components);
-
- return [queuePageControls, playerControls];
- }
-
- /**
- * Generates an embed for the current page of the queue.
- * @returns MessageEmbed
- */
- private buildEmbed() {
- const currentlyPlaying = this.player.getCurrent();
-
- if (!currentlyPlaying) {
- throw new Error('queue is empty');
- }
-
- const queueSize = this.player.queueSize();
-
- if (this.currentPage > this.getMaxPage()) {
- throw new Error('the queue isn\'t that big');
- }
-
- const embed = new MessageEmbed();
-
- embed.setTitle(currentlyPlaying.title);
- embed.setURL(`https://www.youtube.com/watch?v=${currentlyPlaying.url.length === 11 ? currentlyPlaying.url : getYouTubeID(currentlyPlaying.url) ?? ''}`);
-
- let description = getProgressBar(20, this.player.getPosition() / currentlyPlaying.length);
- description += ' ';
- description += `\`[${prettyTime(this.player.getPosition())}/${currentlyPlaying.isLive ? 'live' : prettyTime(currentlyPlaying.length)}]\``;
- description += ' 🔉';
- description += this.player.isQueueEmpty() ? '' : '\n\n**Next up:**';
-
- embed.setDescription(description);
-
- let footer = `Source: ${currentlyPlaying.artist}`;
-
- if (currentlyPlaying.playlist) {
- footer += ` (${currentlyPlaying.playlist.title})`;
- }
-
- embed.setFooter(footer);
-
- const queuePageBegin = (this.currentPage - 1) * PAGE_SIZE;
- const queuePageEnd = queuePageBegin + PAGE_SIZE;
-
- this.player.getQueue().slice(queuePageBegin, queuePageEnd).forEach((song, i) => {
- embed.addField(`${(i + 1 + queuePageBegin).toString()}/${queueSize.toString()}`, song.title, false);
- });
-
- embed.addField('Page', `${this.currentPage} out of ${this.getMaxPage()}`, false);
-
- return embed;
- }
-
- private getMaxPage() {
- return Math.ceil((this.player.queueSize() + 1) / PAGE_SIZE);
- }
-
- private addEventHandlers() {
- this.player.on('statusChange', async () => this.update(true));
-
- // TODO: also update on other player events
- }
-}
diff --git a/src/utils/build-embed.ts b/src/utils/build-embed.ts
new file mode 100644
index 0000000..5a2f758
--- /dev/null
+++ b/src/utils/build-embed.ts
@@ -0,0 +1,127 @@
+import getYouTubeID from 'get-youtube-id';
+import {MessageEmbed} from 'discord.js';
+import Player, {QueuedSong, STATUS} from '../services/player.js';
+import getProgressBar from './get-progress-bar.js';
+import {prettyTime} from './time.js';
+import {truncate} from './string.js';
+
+const PAGE_SIZE = 10;
+
+const getMaxSongTitleLength = (title: string) => {
+ // eslint-disable-next-line no-control-regex
+ const nonASCII = /[^\x00-\x7F]+/;
+ return nonASCII.test(title) ? 28 : 48;
+};
+
+const getSongTitle = ({title, url}: QueuedSong, shouldTruncate = false) => {
+ const cleanSongTitle = title.replace(/\[.*\]/, '').trim();
+
+ const songTitle = shouldTruncate ? truncate(cleanSongTitle, getMaxSongTitleLength(cleanSongTitle)) : cleanSongTitle;
+ const youtubeId = url.length === 11 ? url : getYouTubeID(url) ?? '';
+
+ return `[${songTitle}](https://www.youtube.com/watch?v=${youtubeId})`;
+};
+
+const getQueueInfo = (player: Player) => {
+ const queueSize = player.queueSize();
+ if (queueSize === 0) {
+ return '-';
+ }
+
+ return queueSize === 1 ? '1 song' : `${queueSize} songs`;
+};
+
+const getPlayerUI = (player: Player) => {
+ const song = player.getCurrent();
+
+ if (!song) {
+ return '';
+ }
+
+ const position = player.getPosition();
+ const button = player.status === STATUS.PLAYING ? 'âšī¸' : 'â–ļī¸';
+ const progressBar = getProgressBar(15, position / song.length);
+ const elapsedTime = `${prettyTime(position)}/${song.isLive ? 'live' : prettyTime(song.length)}`;
+
+ return `${button} ${progressBar} \`[${elapsedTime}]\` 🔉`;
+};
+
+export const buildPlayingMessageEmbed = (player: Player): MessageEmbed => {
+ const currentlyPlaying = player.getCurrent();
+
+ if (!currentlyPlaying) {
+ throw new Error('No playing song found');
+ }
+
+ const {artist, thumbnailUrl, requestedBy} = currentlyPlaying;
+ const message = new MessageEmbed();
+
+ message
+ .setColor('DARK_GREEN')
+ .setTitle('Now Playing')
+ .setDescription(`
+ **${getSongTitle(currentlyPlaying)}**
+ Requested by: <@${requestedBy}>\n
+ ${getPlayerUI(player)}
+ `)
+ .setFooter({text: `Source: ${artist}`});
+
+ if (thumbnailUrl) {
+ message.setThumbnail(thumbnailUrl);
+ }
+
+ return message;
+};
+
+export const buildQueueEmbed = (player: Player, page: number): MessageEmbed => {
+ const currentlyPlaying = player.getCurrent();
+
+ if (!currentlyPlaying) {
+ throw new Error('queue is empty');
+ }
+
+ const queueSize = player.queueSize();
+ const maxQueuePage = Math.ceil((queueSize + 1) / PAGE_SIZE);
+
+ if (page > maxQueuePage) {
+ throw new Error('the queue isn\'t that big');
+ }
+
+ const queuePageBegin = (page - 1) * PAGE_SIZE;
+ const queuePageEnd = queuePageBegin + PAGE_SIZE;
+ const queuedSongs = player
+ .getQueue()
+ .slice(queuePageBegin, queuePageEnd)
+ .map((song, index) => `\`${index + 1 + queuePageBegin}.\` ${getSongTitle(song, true)} \`[${prettyTime(song.length)}]\``)
+ .join('\n');
+
+ const {artist, thumbnailUrl, playlist, requestedBy} = currentlyPlaying;
+ const playlistTitle = playlist ? `(${playlist.title})` : '';
+ const totalLength = player.getQueue().reduce((accumulator, current) => accumulator + current.length, 0);
+
+ const message = new MessageEmbed();
+
+ let description = `**${getSongTitle(currentlyPlaying)}**\n`;
+ description += `Requested by: <@${requestedBy}>\n\n`;
+ description += `${getPlayerUI(player)}\n\n`;
+
+ if (player.getQueue().length > 0) {
+ description += '**Up next:**\n';
+ description += queuedSongs;
+ }
+
+ message
+ .setTitle(player.status === STATUS.PLAYING ? 'Now Playing' : 'Queued songs')
+ .setColor(player.status === STATUS.PLAYING ? 'DARK_GREEN' : 'NOT_QUITE_BLACK')
+ .setDescription(description)
+ .addField('In queue', getQueueInfo(player), true)
+ .addField('Total length', `${totalLength > 0 ? prettyTime(totalLength) : '-'}`, true)
+ .addField('Page', `${page} out of ${maxQueuePage}`, true)
+ .setFooter({text: `Source: ${artist} ${playlistTitle}`});
+
+ if (thumbnailUrl) {
+ message.setThumbnail(thumbnailUrl);
+ }
+
+ return message;
+};
diff --git a/src/utils/error-msg.ts b/src/utils/error-msg.ts
index 832de3c..0f4e0b2 100644
--- a/src/utils/error-msg.ts
+++ b/src/utils/error-msg.ts
@@ -5,7 +5,7 @@ export default (error?: string | Error): string => {
if (typeof error === 'string') {
str = `đŸšĢ ${error}`;
} else if (error instanceof Error) {
- str = `đŸšĢ ope: ${error.name}`;
+ str = `đŸšĢ ope: ${error.message}`;
}
}
diff --git a/src/utils/loading-message.ts b/src/utils/loading-message.ts
deleted file mode 100644
index 53a2aed..0000000
--- a/src/utils/loading-message.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import {TextChannel, Message, MessageReaction} from 'discord.js';
-import delay from 'delay';
-
-const INITAL_DELAY = 500;
-const PERIOD = 500;
-
-export default class {
- public isStopped = true;
- private readonly channel: TextChannel;
- private readonly text: string;
- private msg!: Message;
-
- constructor(channel: TextChannel, text = 'cows! count \'em') {
- this.channel = channel;
- this.text = text;
- }
-
- async start(): Promise<void> {
- this.msg = await this.channel.send(this.text);
-
- const icons = ['🐮', '🐴', '🐄'];
-
- const reactions: MessageReaction[] = [];
-
- let i = 0;
- let isRemoving = false;
-
- this.isStopped = false;
-
- (async () => {
- await delay(INITAL_DELAY);
-
- while (!this.isStopped) {
- if (reactions.length === icons.length) {
- isRemoving = true;
- }
-
- // eslint-disable-next-line no-await-in-loop
- await delay(PERIOD);
-
- if (isRemoving) {
- const reactionToRemove = reactions.shift();
-
- if (reactionToRemove) {
- // eslint-disable-next-line no-await-in-loop
- await reactionToRemove.users.remove(this.msg.client.user!.id);
- } else {
- isRemoving = false;
- }
- } else {
- if (!this.isStopped) {
- // eslint-disable-next-line no-await-in-loop
- reactions.push(await this.msg.react(icons[i % icons.length]));
- }
-
- i++;
- }
- }
- })();
- }
-
- async stop(str = 'u betcha'): Promise<Message> {
- const wasAlreadyStopped = this.isStopped;
-
- this.isStopped = true;
-
- const editPromise = str ? this.msg.edit(str) : null;
- const reactPromise = str && !wasAlreadyStopped ? (async () => {
- await this.msg.fetch();
- await Promise.all(this.msg.reactions.cache.map(async react => {
- if (react.me) {
- await react.users.remove(this.msg.client.user!.id);
- }
- }));
- })() : null;
-
- await Promise.all([editPromise, reactPromise]);
-
- return this.msg;
- }
-}
diff --git a/src/utils/string.ts b/src/utils/string.ts
new file mode 100644
index 0000000..0b49114
--- /dev/null
+++ b/src/utils/string.ts
@@ -0,0 +1,2 @@
+export const truncate = (text: string, maxLength = 50) =>
+ text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;