diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/bot.ts | 42 | ||||
| -rw-r--r-- | src/commands/index.ts | 8 | ||||
| -rw-r--r-- | src/commands/play.ts | 1 | ||||
| -rw-r--r-- | src/commands/queue.ts | 106 | ||||
| -rw-r--r-- | src/inversify.config.ts | 2 | ||||
| -rw-r--r-- | src/managers/updating-queue-embed.ts | 33 | ||||
| -rw-r--r-- | src/services/player.ts | 35 | ||||
| -rw-r--r-- | src/services/updating-queue-embed.ts | 196 | ||||
| -rw-r--r-- | src/types.ts | 1 |
9 files changed, 358 insertions, 66 deletions
@@ -18,12 +18,14 @@ import {Routes} from 'discord-api-types/v9'; export default class { private readonly client: Client; private readonly token: string; - private readonly commands!: Collection<string, Command>; + private readonly commandsByName!: Collection<string, Command>; + private readonly commandsByButtonId!: Collection<string, Command>; constructor(@inject(TYPES.Client) client: Client, @inject(TYPES.Config) config: Config) { this.client = client; this.token = config.DISCORD_TOKEN; - this.commands = new Collection(); + this.commandsByName = new Collection(); + this.commandsByButtonId = new Collection(); } public async listen(): Promise<void> { @@ -31,7 +33,11 @@ export default class { container.getAll<Command>(TYPES.Command).forEach(command => { // TODO: remove ! if (command.slashCommand?.name) { - this.commands.set(command.slashCommand.name, command); + this.commandsByName.set(command.slashCommand.name, command); + } + + if (command.handledButtonIds) { + command.handledButtonIds.forEach(id => this.commandsByButtonId.set(id, command)); } }); @@ -41,7 +47,7 @@ export default class { return; } - const command = this.commands.get(interaction.commandName); + const command = this.commandsByName.get(interaction.commandName); if (!command) { return; @@ -72,6 +78,32 @@ export default class { } }); + this.client.on('interactionCreate', async interaction => { + if (!interaction.isButton()) { + return; + } + + const command = this.commandsByButtonId.get(interaction.customId); + + if (!command) { + return; + } + + try { + if (command.handleButtonInteraction) { + await command.handleButtonInteraction(interaction); + } + } catch (error: unknown) { + debug(error); + + if (interaction.replied || interaction.deferred) { + await interaction.editReply(errorMsg('something went wrong')); + } else { + await interaction.reply({content: errorMsg(error as Error), ephemeral: true}); + } + } + }); + const spinner = ora('📡 connecting to Discord...').start(); this.client.once('ready', () => { @@ -94,7 +126,7 @@ export default class { await rest.put( Routes.applicationGuildCommands(this.client.user!.id, this.client.guilds.cache.first()!.id), // TODO: remove - {body: this.commands.map(command => command.slashCommand ? command.slashCommand.toJSON() : null)}, + {body: this.commandsByName.map(command => command.slashCommand ? command.slashCommand.toJSON() : null)}, ); } } diff --git a/src/commands/index.ts b/src/commands/index.ts index 91b76c5..1cd927b 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,12 +1,14 @@ import {SlashCommandBuilder} from '@discordjs/builders'; -import {CommandInteraction} from 'discord.js'; +import {ButtonInteraction, CommandInteraction} from 'discord.js'; -export default interface Command { +export default class Command { // TODO: remove name?: string; aliases?: string[]; examples?: string[][]; readonly slashCommand?: Partial<SlashCommandBuilder> & Pick<SlashCommandBuilder, 'toJSON'>; - requiresVC?: boolean; + readonly handledButtonIds?: readonly string[]; + readonly requiresVC?: boolean; executeFromInteraction?: (interaction: CommandInteraction) => Promise<void>; + handleButtonInteraction?: (interaction: ButtonInteraction) => Promise<void>; } diff --git a/src/commands/play.ts b/src/commands/play.ts index 6e76e37..7dc5e41 100644 --- a/src/commands/play.ts +++ b/src/commands/play.ts @@ -15,6 +15,7 @@ import GetSongs from '../services/get-songs.js'; export default class implements Command { public readonly slashCommand = new SlashCommandBuilder() .setName('play') + // TODO: make sure verb tense is consistent between all command descriptions .setDescription('play a song or resume playback') .addStringOption(option => option .setName('query') diff --git a/src/commands/queue.ts b/src/commands/queue.ts index 1c0735c..479bc33 100644 --- a/src/commands/queue.ts +++ b/src/commands/queue.ts @@ -1,82 +1,80 @@ -import {Message, MessageEmbed} from 'discord.js'; -import getYouTubeID from 'get-youtube-id'; +import {ButtonInteraction, 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 {STATUS} from '../services/player.js'; +import UpdatingQueueEmbedManager from '../managers/updating-queue-embed.js'; +import {BUTTON_IDS} from '../services/updating-queue-embed.js'; import Command from '.'; -import getProgressBar from '../utils/get-progress-bar.js'; -import errorMsg from '../utils/error-msg.js'; -import {prettyTime} from '../utils/time.js'; - -const PAGE_SIZE = 10; @injectable() export default class implements Command { - public name = 'queue'; - public aliases = ['q']; - public examples = [ - ['queue', 'shows current queue'], - ['queue 2', 'shows second page of queue'], - ]; + public readonly slashCommand = new SlashCommandBuilder() + .setName('queue') + .setDescription('show the current queue'); + + public readonly handledButtonIds = Object.values(BUTTON_IDS); private readonly playerManager: PlayerManager; + private readonly updatingQueueEmbedManager: UpdatingQueueEmbedManager; - constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) { + constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager, @inject(TYPES.Managers.UpdatingQueueEmbed) updatingQueueEmbedManager: UpdatingQueueEmbedManager) { this.playerManager = playerManager; + this.updatingQueueEmbedManager = updatingQueueEmbedManager; } - public async execute(msg: Message, args: string []): Promise<void> { - const player = this.playerManager.get(msg.guild!.id); - - const currentlyPlaying = player.getCurrent(); - - if (currentlyPlaying) { - const queueSize = player.queueSize(); - const queuePage = args[0] ? parseInt(args[0], 10) : 1; - - const maxQueuePage = Math.ceil((queueSize + 1) / PAGE_SIZE); + public async executeFromInteraction(interaction: CommandInteraction) { + const embed = this.updatingQueueEmbedManager.get(interaction.guild!.id); - if (queuePage > maxQueuePage) { - await msg.channel.send(errorMsg('the queue isn\'t that big')); - return; - } + await embed.createFromInteraction(interaction); + } - const embed = new MessageEmbed(); + public async handleButtonInteraction(interaction: ButtonInteraction) { + const player = this.playerManager.get(interaction.guild!.id); + const embed = this.updatingQueueEmbedManager.get(interaction.guild!.id); - embed.setTitle(currentlyPlaying.title); - embed.setURL(`https://www.youtube.com/watch?v=${currentlyPlaying.url.length === 11 ? currentlyPlaying.url : getYouTubeID(currentlyPlaying.url) ?? ''}`); + const buttonId = interaction.customId as keyof typeof this.handledButtonIds; - let description = player.status === STATUS.PLAYING ? '⏹️' : '▶️'; - description += ' '; - description += getProgressBar(20, player.getPosition() / currentlyPlaying.length); - description += ' '; - description += `\`[${prettyTime(player.getPosition())}/${currentlyPlaying.isLive ? 'live' : prettyTime(currentlyPlaying.length)}]\``; - description += ' 🔉'; - description += player.isQueueEmpty() ? '' : '\n\n**Next up:**'; + // 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(); - embed.setDescription(description); + try { + switch (buttonId) { + case BUTTON_IDS.TRACK_BACK: + await player.back(); + break; - let footer = `Source: ${currentlyPlaying.artist}`; + case BUTTON_IDS.TRACK_FORWARD: + await player.forward(1); + break; - if (currentlyPlaying.playlist) { - footer += ` (${currentlyPlaying.playlist.title})`; - } + case BUTTON_IDS.PAUSE: + player.pause(); + break; - embed.setFooter(footer); + case BUTTON_IDS.PLAY: + await player.play(); + break; - const queuePageBegin = (queuePage - 1) * PAGE_SIZE; - const queuePageEnd = queuePageBegin + PAGE_SIZE; + case BUTTON_IDS.PAGE_BACK: + await embed.pageBack(); + break; - player.getQueue().slice(queuePageBegin, queuePageEnd).forEach((song, i) => { - embed.addField(`${(i + 1 + queuePageBegin).toString()}/${queueSize.toString()}`, song.title, false); - }); + case BUTTON_IDS.PAGE_FORWARD: + await embed.pageForward(); + break; - embed.addField('Page', `${queuePage} out of ${maxQueuePage}`, false); + default: + throw new Error('unknown customId'); + } + } catch (error: unknown) { + await deferedUpdatePromise; - await msg.channel.send({embeds: [embed]}); - } else { - await msg.channel.send('queue empty'); + throw error; } } } diff --git a/src/inversify.config.ts b/src/inversify.config.ts index 6272dd3..623e631 100644 --- a/src/inversify.config.ts +++ b/src/inversify.config.ts @@ -7,6 +7,7 @@ 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,6 +47,7 @@ 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 new file mode 100644 index 0000000..37732de --- /dev/null +++ b/src/managers/updating-queue-embed.ts @@ -0,0 +1,33 @@ +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/player.ts b/src/services/player.ts index 5caace2..26e1e36 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -1,5 +1,7 @@ import {VoiceChannel, Snowflake, Client, TextChannel} from 'discord.js'; import {Readable} from 'stream'; +import EventEmitter from 'events'; +import TypedEmitter from 'typed-emitter'; import hasha from 'hasha'; import ytdl from 'ytdl-core'; import {WriteStream} from 'fs-capacitor'; @@ -29,8 +31,11 @@ export enum STATUS { PAUSED, } -export default class { - public status = STATUS.PAUSED; +export interface PlayerEvents { + statusChange: (oldStatus: STATUS, newStatus: STATUS) => void; +} + +export default class extends (EventEmitter as new () => TypedEmitter<PlayerEvents>) { public voiceConnection: VoiceConnection | null = null; private queue: QueuedSong[] = []; private queuePosition = 0; @@ -40,11 +45,14 @@ export default class { private lastSongURL = ''; private positionInSeconds = 0; + private internalStatus = STATUS.PAUSED; private readonly discordClient: Client; private readonly fileCache: FileCacheProvider; constructor(client: Client, fileCache: FileCacheProvider) { + // eslint-disable-next-line constructor-super + super(); this.discordClient = client; this.fileCache = fileCache; } @@ -203,8 +211,12 @@ export default class { } } + canGoForward(skip: number) { + return (this.queuePosition + skip - 1) < this.queue.length; + } + manualForward(skip: number): void { - if ((this.queuePosition + skip - 1) < this.queue.length) { + if (this.canGoForward(skip)) { this.queuePosition += skip; this.positionInSeconds = 0; this.stopTrackingPosition(); @@ -213,8 +225,12 @@ export default class { } } + canGoBack() { + return this.queuePosition - 1 >= 0; + } + async back(): Promise<void> { - if (this.queuePosition - 1 >= 0) { + if (this.canGoBack()) { this.queuePosition--; this.positionInSeconds = 0; this.stopTrackingPosition(); @@ -290,6 +306,17 @@ export default class { return this.queueSize() === 0; } + get status() { + return this.internalStatus; + } + + set status(newStatus: STATUS) { + const previousStatus = this.internalStatus; + this.internalStatus = newStatus; + + this.emit('statusChange', previousStatus, newStatus); + } + private getHashForCache(url: string): string { return hasha(url); } diff --git a/src/services/updating-queue-embed.ts b/src/services/updating-queue-embed.ts new file mode 100644 index 0000000..31d58ff --- /dev/null +++ b/src/services/updating-queue-embed.ts @@ -0,0 +1,196 @@ +import {CommandInteraction, MessageActionRow, MessageButton, MessageEmbed} 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) { + this.interaction = interaction; + this.currentPage = 1; + + await interaction.reply({ + embeds: [this.buildEmbed()], + components: this.buildButtons(this.player), + }); + + if (!this.refreshTimeout) { + this.refreshTimeout = setInterval(async () => this.update(), REFRESH_INTERVAL_MS); + } + } + + async update(shouldResetPage = false) { + if (shouldResetPage) { + this.currentPage = 1; + } + + await this.interaction?.editReply({ + embeds: [this.buildEmbed()], + components: this.buildButtons(this.player), + }); + } + + async pageBack() { + if (this.currentPage > 1) { + this.currentPage--; + } + + await this.update(); + } + + async pageForward() { + if (this.currentPage < this.getMaxPage()) { + this.currentPage++; + } + + await this.update(); + } + + 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/types.ts b/src/types.ts index e6edd14..19c734d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,7 @@ export const TYPES = { ThirdParty: Symbol('ThirdParty'), Managers: { Player: Symbol('PlayerManager'), + UpdatingQueueEmbed: Symbol('UpdatingQueueEmbed'), }, Services: { GetSongs: Symbol('GetSongs'), |
