Migrate to slash commands (#431)

Co-authored-by: Federico fuji97 Rapetti <fuji1097@gmail.com>
This commit is contained in:
Max Isom 2022-02-05 16:16:17 -06:00 committed by GitHub
parent e883275d83
commit 56a469a999
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 1270 additions and 1294 deletions

View file

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

View file

@ -13,6 +13,7 @@ const CONFIG_MAP = {
YOUTUBE_API_KEY: process.env.YOUTUBE_API_KEY,
SPOTIFY_CLIENT_ID: process.env.SPOTIFY_CLIENT_ID,
SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET,
REGISTER_COMMANDS_ON_BOT: process.env.REGISTER_COMMANDS_ON_BOT === 'true',
DATA_DIR,
CACHE_DIR: path.join(DATA_DIR, 'cache'),
CACHE_LIMIT_IN_BYTES: xbytes.parseSize(process.env.CACHE_LIMIT ?? '2GB'),
@ -24,6 +25,7 @@ export default class Config {
readonly YOUTUBE_API_KEY!: string;
readonly SPOTIFY_CLIENT_ID!: string;
readonly SPOTIFY_CLIENT_SECRET!: string;
readonly REGISTER_COMMANDS_ON_BOT!: boolean;
readonly DATA_DIR!: string;
readonly CACHE_DIR!: string;
readonly CACHE_LIMIT_IN_BYTES!: number;
@ -39,6 +41,8 @@ export default class Config {
this[key as ConditionalKeys<typeof CONFIG_MAP, number>] = value;
} else if (typeof value === 'string') {
this[key as ConditionalKeys<typeof CONFIG_MAP, string>] = value.trim();
} else if (typeof value === 'boolean') {
this[key as ConditionalKeys<typeof CONFIG_MAP, boolean>] = value;
} else {
throw new Error(`Unsupported type for ${key}`);
}

View file

@ -15,12 +15,10 @@ import {cleanUrl} from '../utils/url.js';
import ThirdParty from './third-party.js';
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 SongMetadata = Except<QueuedSong, 'addedInChannelId' | 'requestedBy'>;
const ONE_HOUR_IN_SECONDS = 60 * 60;
const ONE_MINUTE_IN_SECONDS = 1 * 60;
@injectable()
export default class {
private readonly youtube: YouTube;

View file

@ -1,131 +0,0 @@
import {inject, injectable} from 'inversify';
import {Message, Guild, GuildMember} from 'discord.js';
import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js';
import {QueuedSong} from '../services/player.js';
import {getMostPopularVoiceChannel, getMemberVoiceChannel} from '../utils/channels.js';
@injectable()
export default class {
private readonly playerManager: PlayerManager;
constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) {
this.playerManager = playerManager;
}
async execute(msg: Message): Promise<boolean> {
if (msg.content.startsWith('say') && msg.content.endsWith('muse')) {
const res = msg.content.slice(3, msg.content.indexOf('muse')).trim();
await msg.channel.send(res);
return true;
}
if (msg.content.toLowerCase().includes('packers')) {
await Promise.all([
msg.channel.send('GO PACKERS GO!!!'),
this.playClip(msg.guild!, msg.member!, {
title: 'GO PACKERS!',
artist: 'Unknown',
url: 'https://www.youtube.com/watch?v=qkdtID7mY3E',
length: 204,
playlist: null,
isLive: false,
addedInChannelId: msg.channel.id,
thumbnailUrl: null,
requestedBy: msg.author.id,
}, 8, 10),
]);
return true;
}
if (msg.content.toLowerCase().includes('bears')) {
await Promise.all([
msg.channel.send('F*** THE BEARS'),
this.playClip(msg.guild!, msg.member!, {
title: 'GO PACKERS!',
artist: 'Charlie Berens',
url: 'https://www.youtube.com/watch?v=UaqlE9Pyy_Q',
length: 385,
playlist: null,
isLive: false,
addedInChannelId: msg.channel.id,
thumbnailUrl: null,
requestedBy: msg.author.id,
}, 358, 5.5),
]);
return true;
}
if (msg.content.toLowerCase().includes('bitconnect')) {
await Promise.all([
msg.channel.send('🌊 🌊 🌊 🌊'),
this.playClip(msg.guild!, msg.member!, {
title: 'BITCONNEEECCT',
artist: 'Carlos Matos',
url: 'https://www.youtube.com/watch?v=lCcwn6bGUtU',
length: 227,
playlist: null,
isLive: false,
addedInChannelId: msg.channel.id,
thumbnailUrl: null,
requestedBy: msg.author.id,
}, 50, 13),
]);
return true;
}
return false;
}
private async playClip(guild: Guild, member: GuildMember, song: QueuedSong, position: number, duration: number): Promise<void> {
const player = this.playerManager.get(guild.id);
const [channel, n] = getMemberVoiceChannel(member) ?? getMostPopularVoiceChannel(guild);
if (!player.voiceConnection && n === 0) {
return;
}
if (!player.voiceConnection) {
await player.connect(channel);
}
const isPlaying = player.getCurrent() !== null;
let oldPosition = 0;
player.add(song, {immediate: true});
if (isPlaying) {
oldPosition = player.getPosition();
player.manualForward(1);
}
await player.seek(position);
return new Promise((resolve, reject) => {
try {
setTimeout(async () => {
if (player.getCurrent()?.title === song.title) {
player.removeCurrent();
if (isPlaying) {
await player.back();
await player.seek(oldPosition);
} else {
player.disconnect();
}
}
resolve();
}, duration * 1000);
} catch (error: unknown) {
reject(error);
}
});
}
}

View file

@ -1,13 +1,13 @@
import {VoiceChannel, Snowflake, Client, TextChannel} from 'discord.js';
import {VoiceChannel, Snowflake} from 'discord.js';
import {Readable} from 'stream';
import hasha from 'hasha';
import ytdl from 'ytdl-core';
import {WriteStream} from 'fs-capacitor';
import ffmpeg from 'fluent-ffmpeg';
import shuffle from 'array-shuffle';
import errorMsg from '../utils/error-msg.js';
import {AudioPlayer, AudioPlayerStatus, createAudioPlayer, createAudioResource, joinVoiceChannel, StreamType, VoiceConnection, VoiceConnectionStatus} from '@discordjs/voice';
import FileCacheProvider from './file-cache.js';
import debug from '../utils/debug.js';
export interface QueuedPlaylist {
title: string;
@ -31,9 +31,15 @@ export enum STATUS {
PAUSED,
}
export interface PlayerEvents {
statusChange: (oldStatus: STATUS, newStatus: STATUS) => void;
}
export default class {
public status = STATUS.PAUSED;
public voiceConnection: VoiceConnection | null = null;
public status = STATUS.PAUSED;
public guildId: string;
private queue: QueuedSong[] = [];
private queuePosition = 0;
private audioPlayer: AudioPlayer | null = null;
@ -43,12 +49,11 @@ export default class {
private positionInSeconds = 0;
private readonly discordClient: Client;
private readonly fileCache: FileCacheProvider;
constructor(client: Client, fileCache: FileCacheProvider) {
this.discordClient = client;
constructor(fileCache: FileCacheProvider, guildId: string) {
this.fileCache = fileCache;
this.guildId = guildId;
}
async connect(channel: VoiceChannel): Promise<void> {
@ -150,9 +155,11 @@ export default class {
},
});
this.voiceConnection.subscribe(this.audioPlayer);
this.audioPlayer.play(createAudioResource(stream, {
const resource = createAudioResource(stream, {
inputType: StreamType.WebmOpus,
}));
});
this.audioPlayer.play(resource);
this.attachListeners();
@ -167,14 +174,13 @@ export default class {
this.lastSongURL = currentSong.url;
}
} catch (error: unknown) {
const currentSong = this.getCurrent();
await this.forward(1);
if ((error as {statusCode: number}).statusCode === 410 && currentSong) {
const channelId = currentSong.addedInChannelId;
if (channelId) {
await (this.discordClient.channels.cache.get(channelId) as TextChannel).send(errorMsg(`${currentSong.title} is unavailable`));
debug(`${currentSong.title} is unavailable`);
return;
}
}
@ -213,8 +219,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();
@ -223,8 +233,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();
@ -397,6 +411,9 @@ export default class {
.on('error', error => {
console.error(error);
reject(error);
})
.on('start', command => {
debug(`Spawned ffmpeg with ${command as string}`);
});
youtubeStream.pipe(capacitor);