diff --git a/src/classes/interactions.ts b/src/classes/interactions.ts index 2e2840b1..79308571 100644 --- a/src/classes/interactions.ts +++ b/src/classes/interactions.ts @@ -1,5 +1,5 @@ import config from 'config'; -import { GuildQueue, PlayerTimestamp, Track } from 'discord-player'; +import { GuildQueue, GuildQueueHistory, PlayerTimestamp, Track } from 'discord-player'; import { ApplicationCommandOptionChoiceData, AutocompleteInteraction, @@ -114,7 +114,10 @@ abstract class BaseInteraction { return thumbnailUrl; } - protected getFooterDisplayPageInfo(interaction: ChatInputCommandInteraction, queue: GuildQueue): EmbedFooterData { + protected getFooterDisplayPageInfo( + interaction: ChatInputCommandInteraction, + queue: GuildQueue | GuildQueueHistory + ): EmbedFooterData { if (!queue.tracks.data.length) { return { text: 'Page 1 of 1 (0 tracks)' }; } diff --git a/src/interactions/commands/player/history.ts b/src/interactions/commands/player/history.ts new file mode 100644 index 00000000..1aea167b --- /dev/null +++ b/src/interactions/commands/player/history.ts @@ -0,0 +1,172 @@ +import { GuildQueue, GuildQueueHistory, Track, useHistory, useQueue } from 'discord-player'; +import { ChatInputCommandInteraction, EmbedBuilder, EmbedFooterData, SlashCommandBuilder } from 'discord.js'; +import { Logger } from 'pino'; +import { BaseSlashCommandInteraction } from '../../../classes/interactions'; +import { BaseSlashCommandParams, BaseSlashCommandReturnType } from '../../../types/interactionTypes'; +import { checkHistoryExists } from '../../../utils/validation/queueValidator'; +import { checkInVoiceChannel, checkSameVoiceChannel } from '../../../utils/validation/voiceChannelValidator'; + +class HistoryCommand extends BaseSlashCommandInteraction { + constructor() { + const data = new SlashCommandBuilder() + .setName('history') + .setDescription('Show history of tracks that have been played.') + .addNumberOption((option) => + option.setName('page').setDescription('Page number to display for the history').setMinValue(1) + ); + super(data); + } + + async execute(params: BaseSlashCommandParams): BaseSlashCommandReturnType { + const { executionId, interaction } = params; + const logger = this.getLogger(this.name, executionId, interaction); + + const history: GuildQueueHistory = useHistory(interaction.guild!.id)!; + const queue: GuildQueue = useQueue(interaction.guild!.id)!; + + await this.runValidators({ interaction, history, executionId }, [ + checkInVoiceChannel, + checkSameVoiceChannel, + checkHistoryExists + ]); + + const pageIndex: number = this.getPageIndex(interaction); + const totalPages: number = this.getTotalPages(history); + + if (pageIndex > totalPages - 1) { + return await this.handleInvalidPage(logger, interaction, pageIndex, totalPages); + } + + const historyTracksListString: string = this.getHistoryTracksListString(history, pageIndex); + + const currentTrack: Track = history.currentTrack!; + if (currentTrack) { + return await this.handleCurrentTrack( + logger, + interaction, + queue, + history, + currentTrack, + historyTracksListString + ); + } + + return await this.handleNoCurrentTrack(logger, interaction, queue, history, historyTracksListString); + } + + private async handleCurrentTrack( + logger: Logger, + interaction: ChatInputCommandInteraction, + queue: GuildQueue, + history: GuildQueueHistory, + currentTrack: Track, + historyTracksListString: string + ) { + logger.debug('History exists with current track, gathering information.'); + + logger.debug('Responding with info embed.'); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setAuthor(this.getEmbedQueueAuthor(interaction, queue)) + .setDescription( + `**${this.embedOptions.icons.audioPlaying} Now playing**\n` + + `${this.getFormattedTrackUrl(currentTrack)}\n` + + `**Requested by:** ${this.getDisplayTrackRequestedBy(currentTrack)}\n` + + `${this.getDisplayQueueProgressBar(queue)}\n\n` + + `${this.getDisplayRepeatMode(queue.repeatMode)}` + + `**${this.embedOptions.icons.queue} Tracks in history**\n` + + historyTracksListString + ) + .setThumbnail(this.getTrackThumbnailUrl(currentTrack)) + .setFooter(this.getDisplayFullFooterInfo(interaction, history)) + .setColor(this.embedOptions.colors.info) + ] + }); + return Promise.resolve(); + } + + private async handleNoCurrentTrack( + logger: Logger, + interaction: ChatInputCommandInteraction, + queue: GuildQueue, + history: GuildQueueHistory, + historyTracksListString: string + ) { + logger.debug('History exists but there is no current track.'); + + logger.debug('Responding with info embed.'); + return await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setAuthor(this.getEmbedQueueAuthor(interaction, queue)) + .setDescription( + `${this.getDisplayRepeatMode(queue.repeatMode)}` + + `**${this.embedOptions.icons.queue} Tracks in history**\n` + + historyTracksListString + ) + .setFooter(this.getDisplayFullFooterInfo(interaction, history)) + .setColor(this.embedOptions.colors.info) + ] + }); + } + + private async handleInvalidPage( + logger: Logger, + interaction: ChatInputCommandInteraction, + pageIndex: number, + totalPages: number + ) { + logger.debug('Specified page was higher than total pages.'); + + logger.debug('Responding with warning embed.'); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setDescription( + `**${this.embedOptions.icons.warning} Oops!**\n` + + `Page **\`${pageIndex + 1}\`** is not a valid page number.\n\n` + + `There are only a total of **\`${totalPages}\`** pages in the history.` + ) + .setColor(this.embedOptions.colors.warning) + ] + }); + return Promise.resolve(); + } + + private getPageIndex(interaction: ChatInputCommandInteraction): number { + return (interaction.options.getNumber('page') || 1) - 1; + } + + private getTotalPages(history: GuildQueueHistory): number { + return Math.ceil(history.tracks.data.length / 10) || 1; + } + + private getHistoryTracksListString(history: GuildQueueHistory, pageIndex: number): string { + if (!history || history.tracks.data.length === 0) { + return 'The history is empty, add some tracks with **`/play`**!'; + } + + return history.tracks.data + .slice(pageIndex * 10, pageIndex * 10 + 10) + .map((track, index) => { + return `**${pageIndex * 10 + index + 1}.** ${this.getDisplayTrackDurationAndUrl(track)}`; + }) + .join('\n'); + } + + private getDisplayFullFooterInfo( + interaction: ChatInputCommandInteraction, + history: GuildQueueHistory + ): EmbedFooterData { + const pagination = this.getFooterDisplayPageInfo(interaction, history); + + const fullFooterData = { + text: `${pagination.text}` + }; + + return fullFooterData; + } +} + +export default new HistoryCommand(); diff --git a/src/types/utilTypes.d.ts b/src/types/utilTypes.d.ts index 8c645340..528ffdb5 100644 --- a/src/types/utilTypes.d.ts +++ b/src/types/utilTypes.d.ts @@ -27,6 +27,7 @@ export type Validator = (args: ValidatorParams) => Promise; export type ValidatorParams = { interaction: ChatInputCommandInteraction | MessageComponentInteraction; queue?: GuildQueue; + history?: GuildQueueHistory; executionId: string; }; diff --git a/src/utils/validation/queueValidator.ts b/src/utils/validation/queueValidator.ts index 60e263ce..05d34152 100644 --- a/src/utils/validation/queueValidator.ts +++ b/src/utils/validation/queueValidator.ts @@ -44,6 +44,43 @@ export const checkQueueExists = async ({ interaction, queue, executionId }: Vali return; }; +export const checkHistoryExists = async ({ interaction, history, executionId }: ValidatorParams) => { + const logger: Logger = loggerModule.child({ + module: 'utilValidation', + name: 'historyDoesNotExist', + executionId: executionId, + shardId: interaction.guild?.shardId, + guildId: interaction.guild?.id + }); + + const interactionIdentifier = + interaction.type === InteractionType.ApplicationCommand ? interaction.commandName : interaction.customId; + + if (!history) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setDescription( + `**${embedOptions.icons.warning} Oops!**\nThere are no tracks in the history and nothing currently playing. First add some tracks with **\`/play\`**!` + ) + .setColor(embedOptions.colors.warning) + .setFooter({ + text: + interaction.member instanceof GuildMember + ? interaction.member.nickname || interaction.user.username + : interaction.user.username, + iconURL: interaction.user.avatarURL() || embedOptions.info.fallbackIconUrl + }) + ] + }); + + logger.debug(`User tried to use command '${interactionIdentifier}' but there was no history.`); + throw new InteractionValidationError('History does not exist.'); + } + + return; +}; + export const checkQueueCurrentTrack = async ({ interaction, queue, executionId }: ValidatorParams) => { const logger: Logger = loggerModule.child({ module: 'utilValidation',