Start migrating queue command

Also: make it persistent & updating, add buttons 
This commit is contained in:
Max Isom 2021-12-15 22:01:54 -05:00
parent e965c02358
commit 0b20cb3982
No known key found for this signature in database
GPG key ID: 25C9B1A7F6798880
11 changed files with 370 additions and 72 deletions

View file

@ -48,6 +48,7 @@
"nodemon": "^2.0.7", "nodemon": "^2.0.7",
"ts-node": "^10.4.0", "ts-node": "^10.4.0",
"type-fest": "^2.5.4", "type-fest": "^2.5.4",
"typed-emitter": "^1.4.0",
"typescript": "^4.5.3" "typescript": "^4.5.3"
}, },
"eslintConfig": { "eslintConfig": {

View file

@ -18,12 +18,14 @@ import {Routes} from 'discord-api-types/v9';
export default class { export default class {
private readonly client: Client; private readonly client: Client;
private readonly token: string; 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) { constructor(@inject(TYPES.Client) client: Client, @inject(TYPES.Config) config: Config) {
this.client = client; this.client = client;
this.token = config.DISCORD_TOKEN; this.token = config.DISCORD_TOKEN;
this.commands = new Collection(); this.commandsByName = new Collection();
this.commandsByButtonId = new Collection();
} }
public async listen(): Promise<void> { public async listen(): Promise<void> {
@ -31,7 +33,11 @@ export default class {
container.getAll<Command>(TYPES.Command).forEach(command => { container.getAll<Command>(TYPES.Command).forEach(command => {
// TODO: remove ! // TODO: remove !
if (command.slashCommand?.name) { 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; return;
} }
const command = this.commands.get(interaction.commandName); const command = this.commandsByName.get(interaction.commandName);
if (!command) { if (!command) {
return; 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(); const spinner = ora('📡 connecting to Discord...').start();
this.client.once('ready', () => { this.client.once('ready', () => {
@ -94,7 +126,7 @@ export default class {
await rest.put( await rest.put(
Routes.applicationGuildCommands(this.client.user!.id, this.client.guilds.cache.first()!.id), Routes.applicationGuildCommands(this.client.user!.id, this.client.guilds.cache.first()!.id),
// TODO: remove // TODO: remove
{body: this.commands.map(command => command.slashCommand ? command.slashCommand.toJSON() : null)}, {body: this.commandsByName.map(command => command.slashCommand ? command.slashCommand.toJSON() : null)},
); );
} }
} }

View file

@ -1,12 +1,14 @@
import {SlashCommandBuilder} from '@discordjs/builders'; 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 // TODO: remove
name?: string; name?: string;
aliases?: string[]; aliases?: string[];
examples?: string[][]; examples?: string[][];
readonly slashCommand?: Partial<SlashCommandBuilder> & Pick<SlashCommandBuilder, 'toJSON'>; readonly slashCommand?: Partial<SlashCommandBuilder> & Pick<SlashCommandBuilder, 'toJSON'>;
requiresVC?: boolean; readonly handledButtonIds?: readonly string[];
readonly requiresVC?: boolean;
executeFromInteraction?: (interaction: CommandInteraction) => Promise<void>; executeFromInteraction?: (interaction: CommandInteraction) => Promise<void>;
handleButtonInteraction?: (interaction: ButtonInteraction) => Promise<void>;
} }

View file

@ -15,6 +15,7 @@ import GetSongs from '../services/get-songs.js';
export default class implements Command { export default class implements Command {
public readonly slashCommand = new SlashCommandBuilder() public readonly slashCommand = new SlashCommandBuilder()
.setName('play') .setName('play')
// TODO: make sure verb tense is consistent between all command descriptions
.setDescription('play a song or resume playback') .setDescription('play a song or resume playback')
.addStringOption(option => option .addStringOption(option => option
.setName('query') .setName('query')

View file

@ -1,82 +1,80 @@
import {Message, MessageEmbed} from 'discord.js'; import {ButtonInteraction, CommandInteraction} from 'discord.js';
import getYouTubeID from 'get-youtube-id'; import {SlashCommandBuilder} from '@discordjs/builders';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.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 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() @injectable()
export default class implements Command { export default class implements Command {
public name = 'queue'; public readonly slashCommand = new SlashCommandBuilder()
public aliases = ['q']; .setName('queue')
public examples = [ .setDescription('show the current queue');
['queue', 'shows current queue'],
['queue 2', 'shows second page of queue'], public readonly handledButtonIds = Object.values(BUTTON_IDS);
];
private readonly playerManager: PlayerManager; 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.playerManager = playerManager;
this.updatingQueueEmbedManager = updatingQueueEmbedManager;
} }
public async execute(msg: Message, args: string []): Promise<void> { public async executeFromInteraction(interaction: CommandInteraction) {
const player = this.playerManager.get(msg.guild!.id); const embed = this.updatingQueueEmbedManager.get(interaction.guild!.id);
const currentlyPlaying = player.getCurrent(); await embed.createFromInteraction(interaction);
}
if (currentlyPlaying) { public async handleButtonInteraction(interaction: ButtonInteraction) {
const queueSize = player.queueSize(); const player = this.playerManager.get(interaction.guild!.id);
const queuePage = args[0] ? parseInt(args[0], 10) : 1; const embed = this.updatingQueueEmbedManager.get(interaction.guild!.id);
const maxQueuePage = Math.ceil((queueSize + 1) / PAGE_SIZE); const buttonId = interaction.customId as keyof typeof this.handledButtonIds;
if (queuePage > maxQueuePage) { // Not entirely sure why this is necessary.
await msg.channel.send(errorMsg('the queue isn\'t that big')); // We don't wait for the Promise to resolve here to avoid blocking the
return; // 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 = new MessageEmbed(); throw error;
embed.setTitle(currentlyPlaying.title);
embed.setURL(`https://www.youtube.com/watch?v=${currentlyPlaying.url.length === 11 ? currentlyPlaying.url : getYouTubeID(currentlyPlaying.url) ?? ''}`);
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:**';
embed.setDescription(description);
let footer = `Source: ${currentlyPlaying.artist}`;
if (currentlyPlaying.playlist) {
footer += ` (${currentlyPlaying.playlist.title})`;
}
embed.setFooter(footer);
const queuePageBegin = (queuePage - 1) * PAGE_SIZE;
const queuePageEnd = queuePageBegin + PAGE_SIZE;
player.getQueue().slice(queuePageBegin, queuePageEnd).forEach((song, i) => {
embed.addField(`${(i + 1 + queuePageBegin).toString()}/${queueSize.toString()}`, song.title, false);
});
embed.addField('Page', `${queuePage} out of ${maxQueuePage}`, false);
await msg.channel.send({embeds: [embed]});
} else {
await msg.channel.send('queue empty');
} }
} }
} }

View file

@ -7,6 +7,7 @@ import ConfigProvider from './services/config.js';
// Managers // Managers
import PlayerManager from './managers/player.js'; import PlayerManager from './managers/player.js';
import UpdatingQueueEmbed from './managers/updating-queue-embed.js';
// Helpers // Helpers
import GetSongs from './services/get-songs.js'; import GetSongs from './services/get-songs.js';
@ -46,6 +47,7 @@ container.bind<Client>(TYPES.Client).toConstantValue(new Client({intents}));
// Managers // Managers
container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope(); container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope();
container.bind<UpdatingQueueEmbed>(TYPES.Managers.UpdatingQueueEmbed).to(UpdatingQueueEmbed).inSingletonScope();
// Helpers // Helpers
container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope(); container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope();

View file

@ -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;
}
}

View file

@ -1,5 +1,7 @@
import {VoiceChannel, Snowflake, Client, TextChannel} from 'discord.js'; import {VoiceChannel, Snowflake, Client, TextChannel} from 'discord.js';
import {Readable} from 'stream'; import {Readable} from 'stream';
import EventEmitter from 'events';
import TypedEmitter from 'typed-emitter';
import hasha from 'hasha'; import hasha from 'hasha';
import ytdl from 'ytdl-core'; import ytdl from 'ytdl-core';
import {WriteStream} from 'fs-capacitor'; import {WriteStream} from 'fs-capacitor';
@ -29,8 +31,11 @@ export enum STATUS {
PAUSED, PAUSED,
} }
export default class { export interface PlayerEvents {
public status = STATUS.PAUSED; statusChange: (oldStatus: STATUS, newStatus: STATUS) => void;
}
export default class extends (EventEmitter as new () => TypedEmitter<PlayerEvents>) {
public voiceConnection: VoiceConnection | null = null; public voiceConnection: VoiceConnection | null = null;
private queue: QueuedSong[] = []; private queue: QueuedSong[] = [];
private queuePosition = 0; private queuePosition = 0;
@ -40,11 +45,14 @@ export default class {
private lastSongURL = ''; private lastSongURL = '';
private positionInSeconds = 0; private positionInSeconds = 0;
private internalStatus = STATUS.PAUSED;
private readonly discordClient: Client; private readonly discordClient: Client;
private readonly fileCache: FileCacheProvider; private readonly fileCache: FileCacheProvider;
constructor(client: Client, fileCache: FileCacheProvider) { constructor(client: Client, fileCache: FileCacheProvider) {
// eslint-disable-next-line constructor-super
super();
this.discordClient = client; this.discordClient = client;
this.fileCache = fileCache; 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 { manualForward(skip: number): void {
if ((this.queuePosition + skip - 1) < this.queue.length) { if (this.canGoForward(skip)) {
this.queuePosition += skip; this.queuePosition += skip;
this.positionInSeconds = 0; this.positionInSeconds = 0;
this.stopTrackingPosition(); this.stopTrackingPosition();
@ -213,8 +225,12 @@ export default class {
} }
} }
canGoBack() {
return this.queuePosition - 1 >= 0;
}
async back(): Promise<void> { async back(): Promise<void> {
if (this.queuePosition - 1 >= 0) { if (this.canGoBack()) {
this.queuePosition--; this.queuePosition--;
this.positionInSeconds = 0; this.positionInSeconds = 0;
this.stopTrackingPosition(); this.stopTrackingPosition();
@ -290,6 +306,17 @@ export default class {
return this.queueSize() === 0; 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 { private getHashForCache(url: string): string {
return hasha(url); return hasha(url);
} }

View file

@ -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
}
}

View file

@ -8,6 +8,7 @@ export const TYPES = {
ThirdParty: Symbol('ThirdParty'), ThirdParty: Symbol('ThirdParty'),
Managers: { Managers: {
Player: Symbol('PlayerManager'), Player: Symbol('PlayerManager'),
UpdatingQueueEmbed: Symbol('UpdatingQueueEmbed'),
}, },
Services: { Services: {
GetSongs: Symbol('GetSongs'), GetSongs: Symbol('GetSongs'),

View file

@ -3496,6 +3496,11 @@ type-fest@^2.5.4:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.5.4.tgz#1613bf29a172ff1c66c29325466af9096fe505b5" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.5.4.tgz#1613bf29a172ff1c66c29325466af9096fe505b5"
integrity sha512-zyPomVvb6u7+gJ/GPYUH6/nLDNiTtVOqXVUHtxFv5PmZQh6skgfeRtFYzWC01T5KeNWNIx5/0P111rKFLlkFvA== integrity sha512-zyPomVvb6u7+gJ/GPYUH6/nLDNiTtVOqXVUHtxFv5PmZQh6skgfeRtFYzWC01T5KeNWNIx5/0P111rKFLlkFvA==
typed-emitter@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/typed-emitter/-/typed-emitter-1.4.0.tgz#38c6bf1224e764906bb20cb0b458fa914100607c"
integrity sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==
typedarray-to-buffer@^3.1.5: typedarray-to-buffer@^3.1.5:
version "3.1.5" version "3.1.5"
resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"