diff --git a/package.json b/package.json index cb1e4ba..7f37400 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,22 @@ { - "name": "stavbot", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "start": "ts-node index.ts" - }, - "author": "Stav", - "license": "ISC", - "dependencies": { - "@discordjs/opus": "^0.5.3", - "@discordjs/voice": "^0.6.0", - "axios": "^0.21.4", - "discord.js": "^13.1.0", - "dotenv": "^10.0.0", - "ffmpeg-static": "^4.4.0", - "tweetnacl": "^1.0.3", - "ytdl-core": "^4.9.1" - } + "name": "stavbot", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "ts-node index.ts", + "dev": "ts-node-dev index.ts" + }, + "author": "Stav", + "license": "ISC", + "dependencies": { + "@discordjs/opus": "^0.5.3", + "@discordjs/voice": "^0.6.0", + "axios": "^0.21.4", + "discord.js": "^13.1.0", + "dotenv": "^10.0.0", + "ffmpeg-static": "^4.4.0", + "tweetnacl": "^1.0.3", + "ytdl-core": "^4.9.1" + } } diff --git a/src/ClientHandler.ts b/src/ClientHandler.ts index 7ceab60..a9b3fa0 100644 --- a/src/ClientHandler.ts +++ b/src/ClientHandler.ts @@ -1,5 +1,16 @@ -import { Channel, ChannelResolvable, Client, Intents } from 'discord.js'; +import { + Channel, + ChannelResolvable, + Client, + Intents, + MessageOptions, + MessagePayload, + TextBasedChannels, +} from 'discord.js'; +import { createInteraction } from './buttonInteractions/ButtonInteractionFactory'; import { createCommand, isCommandExists } from './commands/CommandFactory'; +import { CommandDoesntExistError } from './errors/CommandDoesntExistError'; +import { InteractionDoesntExistError } from './errors/InteractionDoesntExistError'; let client: Client; @@ -20,14 +31,31 @@ const createClient = () => { if (message.content[0] !== '!') { return; } - const args = message.content.substring(1).split(' '); - const command = args[0]; - if (!isCommandExists(command)) { - return; + try { + createCommand( + message.content.substring(1).split(' '), + message + ).execute(); + } catch (error) { + if (!(error instanceof CommandDoesntExistError)) { + throw error; + } } + }); - createCommand(args, message).execute(); + client.on('interactionCreate', async interaction => { + if (interaction.isButton()) { + try { + await createInteraction(interaction).execute(); + } catch (error) { + if (!(error instanceof InteractionDoesntExistError)) { + throw error; + } + + console.warn(error.message); + } + } }); }; @@ -41,3 +69,17 @@ export const getClient = () => { export const getChannel = (channelId: string): Channel | null => client.channels.resolve(channelId); + +export const sendMessage = ( + channelId: string, + message: string | MessagePayload | MessageOptions +) => { + const channel = getChannel(channelId); + if (!channel || !channel.isText()) { + console.warn( + `Tried to send message to non existing or voice channel! \nMessage: ${message} \nChannelId: ${channelId}` + ); + } + + return (channel as TextBasedChannels).send(message); +}; diff --git a/src/QueueManager.ts b/src/QueueManager.ts index 8351275..c38f22c 100644 --- a/src/QueueManager.ts +++ b/src/QueueManager.ts @@ -2,7 +2,7 @@ import { TextBasedChannel, TextBasedChannels } from 'discord.js'; import { Queue } from './types/Queue'; import { Track } from './types/Track'; import { pause, play, stop } from './AudioHandler'; -import { getChannel } from './ClientHandler'; +import { getChannel, sendMessage } from './ClientHandler'; let queues: { [guildId: string]: Queue } = {}; @@ -41,7 +41,7 @@ export const playNext = (guildId: string) => { throw new Error('Tried to play next with an empty queue!'); } - const lastTrack = guildQueue.tracks.splice(0, 1)[0]; + const lastTrack = guildQueue.tracks.shift() as Track; if (guildQueue.tracks.length === 0) { guildQueue.isPlaying = false; stop(guildId); @@ -69,17 +69,6 @@ const playTrack = (guildID: string, track: Track) => { play(guildID, track.path); }; -const sendMessage = (channelId: string, message: string) => { - const channel = getChannel(channelId); - if (!channel || !channel.isText()) { - console.warn( - `Tried to send message to non existing or voice channel! \nMessage: ${message} \nChannelId: ${channelId}` - ); - } - - (channel as TextBasedChannels).send(message); -}; - export const queueToString = (queue: Queue) => { if (queue.tracks.length === 0) { return 'Queue is empty'; diff --git a/src/YoutubeHandler.ts b/src/YoutubeHandler.ts index 8746aa4..df1fab6 100644 --- a/src/YoutubeHandler.ts +++ b/src/YoutubeHandler.ts @@ -1,6 +1,8 @@ import ytdl from 'ytdl-core'; import fs from 'fs'; -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; +import { RawYoutubeVideo } from './types/RawYoutubeVideo'; +import { ParsedYoutubeVideo } from './types/ParsedYoutubeVideo'; export const currentlyDownloading: string[] = []; @@ -43,28 +45,53 @@ export const getAudioFromVideoId = async (videoId: string) => { return fileName; }; -export const searchVideo = async (searchQuery: string) => { - const res = await axios.get( +const getYoutubeSearchResults = async (searchQuery: string) => + await axios.get( `https://www.googleapis.com/youtube/v3/search?key=${ process.env.YT_API_KEY }&q=${encodeURIComponent(searchQuery)}&part=snippet&type=video` ); - if (res.status !== 200) { - console.error(`Search request failed: ${res.statusText} -${res.data}`); +export const searchVideo = async (searchQuery: string) => { + try { + const res = await getYoutubeSearchResults(searchQuery); + + const resultItems = res.data.items; + if (resultItems.length === 0) { + return 'no results'; + } + + return { + id: resultItems[0].id.videoId, + title: resultItems[0].snippet.title, + }; + } catch (error) { + if (axios.isAxiosError(error)) { + console.error(error.response); + } + return; } +}; - const resultItems = res.data.items; - if (resultItems.length === 0) { - return 'no results'; - } +export const searchMultipleVideos = async ( + searchQuery: string +): Promise => { + try { + const res = await getYoutubeSearchResults(searchQuery); - return { - id: resultItems[0].id.videoId, - title: resultItems[0].snippet.title, - }; + return res.data.items.map((currentVideo: RawYoutubeVideo) => ({ + id: currentVideo.id.videoId, + title: currentVideo.snippet.title, + thumbnail: currentVideo.snippet.thumbnails.default.url, + })); + } catch (error: any | AxiosError) { + if (axios.isAxiosError(error)) { + console.error(error.response); + } + + return []; + } }; export const decodeHtmlEntity = (str: string) => diff --git a/src/buttonInteractions/ButtonInteraction.ts b/src/buttonInteractions/ButtonInteraction.ts new file mode 100644 index 0000000..b1ca291 --- /dev/null +++ b/src/buttonInteractions/ButtonInteraction.ts @@ -0,0 +1,13 @@ +import { ButtonInteraction as discordJSButtonInteraction } from 'discord.js'; + +export const getNameFromCustomId = (customId: string) => customId.split(' ')[0]; + +export abstract class ButtonInteraction { + interaction: discordJSButtonInteraction; + + constructor(interaction: discordJSButtonInteraction) { + this.interaction = interaction; + } + + abstract execute(): void; +} diff --git a/src/buttonInteractions/ButtonInteractionFactory.ts b/src/buttonInteractions/ButtonInteractionFactory.ts new file mode 100644 index 0000000..23203cc --- /dev/null +++ b/src/buttonInteractions/ButtonInteractionFactory.ts @@ -0,0 +1,25 @@ +import { ButtonInteraction as discordJSButtonInteraction } from 'discord.js'; +import { InteractionDoesntExistError } from '../errors/InteractionDoesntExistError'; +import { ButtonInteraction, getNameFromCustomId } from './ButtonInteraction'; +import { PlayInteraction, PlayInteractionName } from './PlayInteraction'; + +const buttonInterctions: { + [interactionName: string]: new ( + interaction: discordJSButtonInteraction + ) => ButtonInteraction; +} = { + [PlayInteractionName]: PlayInteraction, +}; + +const isInteractionExists = (interactionName: string) => + Object.keys(buttonInterctions).includes(interactionName); + +export const createInteraction = (interaction: discordJSButtonInteraction) => { + const interactionName = getNameFromCustomId(interaction.customId); + + if (!isInteractionExists(interactionName)) { + throw new InteractionDoesntExistError(); + } + + return new buttonInterctions[interactionName](interaction); +}; diff --git a/src/buttonInteractions/PlayInteraction.ts b/src/buttonInteractions/PlayInteraction.ts new file mode 100644 index 0000000..1175c94 --- /dev/null +++ b/src/buttonInteractions/PlayInteraction.ts @@ -0,0 +1,67 @@ +import { Message, TextBasedChannels } from 'discord.js'; +import { establishPlayer } from '../AudioHandler'; +import { sendMessage } from '../ClientHandler'; +import { deleteAllSearchMessages } from '../commands/SearchCommand'; +import { addToQueue } from '../QueueManager'; +import { Track } from '../types/Track'; +import { establishVoiceChannel } from '../VoiceChannelHandler'; +import { + decodeHtmlEntity, + getAudioFromVideoId, + searchVideo, +} from '../YoutubeHandler'; +import { ButtonInteraction } from './ButtonInteraction'; + +export const PlayInteractionName = 'PLAY'; + +export const constructPlayCutomId = (videoId: string) => + `${PlayInteractionName} ${videoId}`; + +export const deconstructPlayCustomId = (customId: string) => { + const args = customId.split(' '); + + return { + name: args[0], + videoId: args[1], + }; +}; + +export class PlayInteraction extends ButtonInteraction { + async execute() { + const args = deconstructPlayCustomId(this.interaction.customId); + + if (!establishVoiceChannel(this.interaction as unknown as Message)) { + return; + } + + const guildId = this.interaction.guild?.id; + if (!guildId || !establishPlayer(guildId)) { + return; + } + + const audioURL = await getAudioFromVideoId(args.videoId); + if (!audioURL) { + sendMessage( + this.interaction.channelId as string, + 'Something went wrong with audio decoding, sorry' + ); + return; + } + + const videoResult = await searchVideo(args.videoId); + + if (!videoResult || videoResult === 'no results') { + return; + } + + const track: Track = { + title: decodeHtmlEntity(videoResult.title), + path: audioURL, + requestChannelId: this.interaction.channelId as string, + }; + + addToQueue(guildId, track); + + deleteAllSearchMessages(this.interaction.channel as TextBasedChannels); + } +} diff --git a/src/commands/CommandFactory.ts b/src/commands/CommandFactory.ts index 2279267..e3b4aa6 100644 --- a/src/commands/CommandFactory.ts +++ b/src/commands/CommandFactory.ts @@ -1,4 +1,5 @@ import { Message } from 'discord.js'; +import { CommandDoesntExistError } from '../errors/CommandDoesntExistError'; import { Command } from './Command'; import { GayCommand } from './GayCommand'; import { LeaveCommand } from './LeaveCommand'; @@ -7,6 +8,7 @@ import { PlayCommand } from './PlayCommand'; import { QueueCommand } from './QueueCommand'; import { RemoveCommand } from './RemoveCommand'; import { ResumeCommand } from './ResumeCommand'; +import { SearchCommand } from './SearchCommand'; import { SkipCommand } from './SkipCommand'; import { SummonCommand } from './SummonCommand'; @@ -22,6 +24,7 @@ const commands: { skip: SkipCommand, remove: RemoveCommand, queue: QueueCommand, + search: SearchCommand, }; export const isCommandExists = (commandName: string) => @@ -30,7 +33,7 @@ export const isCommandExists = (commandName: string) => export const createCommand = (args: string[], message: Message) => { const commandName = args[0]; if (!isCommandExists(commandName)) { - throw new Error('Tried to create non existing command'); + throw new CommandDoesntExistError(); } return new commands[commandName](args, message); diff --git a/src/commands/SearchCommand.ts b/src/commands/SearchCommand.ts new file mode 100644 index 0000000..1fba8e2 --- /dev/null +++ b/src/commands/SearchCommand.ts @@ -0,0 +1,77 @@ +import { + Message, + MessageActionRow, + MessageButton, + MessageEmbed, + TextBasedChannels, +} from 'discord.js'; +import { + constructPlayCutomId, + PlayInteractionName, +} from '../buttonInteractions/PlayInteraction'; +import { sendMessage } from '../ClientHandler'; +import { decodeHtmlEntity, searchMultipleVideos } from '../YoutubeHandler'; +import { Command } from './Command'; + +const MESSAGE_DELETE_TIMOUT = 60000; + +const filterPlayButtonMessages = (messages: Message[]) => + messages.filter(currMessage => + currMessage.components.some(currComponent => + currComponent.components.some(currSubComponent => + currSubComponent.customId?.startsWith(PlayInteractionName) + ) + ) + ); + +export const deleteAllSearchMessages = async (channel: TextBasedChannels) => { + filterPlayButtonMessages([ + ...(await channel.messages.fetch({ limit: 100 })).values(), + ]).forEach(currMessage => { + if (!currMessage.deleted) { + currMessage.delete(); + } + }); +}; + +const deleteMessagesAfterTimeout = (channel: TextBasedChannels) => + setTimeout(async () => { + await deleteAllSearchMessages(channel); + }, MESSAGE_DELETE_TIMOUT); + +export class SearchCommand extends Command { + async execute() { + await deleteAllSearchMessages(this.message.channel); + + if (this.args.length <= 1) { + this.message.reply('Search what exactly?'); + return; + } + + const searchQuery = this.args.slice(1).join(' '); + const searchResults = await searchMultipleVideos(searchQuery); + + searchResults.forEach(async currSearchResult => { + const videoEmbed = new MessageEmbed() + .setTitle(decodeHtmlEntity(currSearchResult.title)) + .setURL( + `https://www.youtube.com/watch?v=${currSearchResult.id}` + ) + .setThumbnail(currSearchResult.thumbnailURL); + + const playVideoButton = new MessageActionRow().addComponents( + new MessageButton() + .setCustomId(constructPlayCutomId(currSearchResult.id)) + .setLabel('Play') + .setStyle('SUCCESS') + ); + + await sendMessage(this.message.channelId, { + embeds: [videoEmbed], + components: [playVideoButton], + }); + }); + + deleteMessagesAfterTimeout(this.message.channel); + } +} diff --git a/src/errors/CommandDoesntExistError.ts b/src/errors/CommandDoesntExistError.ts new file mode 100644 index 0000000..7fdf379 --- /dev/null +++ b/src/errors/CommandDoesntExistError.ts @@ -0,0 +1,5 @@ +export class CommandDoesntExistError extends Error { + constructor(message: string = 'Tried to create non existing command') { + super(message); + } +} diff --git a/src/errors/InteractionDoesntExistError.ts b/src/errors/InteractionDoesntExistError.ts new file mode 100644 index 0000000..41266d2 --- /dev/null +++ b/src/errors/InteractionDoesntExistError.ts @@ -0,0 +1,5 @@ +export class InteractionDoesntExistError extends Error { + constructor(message: string = 'Tried to create non existing interaction') { + super(message); + } +} diff --git a/src/types/ParsedYoutubeVideo.ts b/src/types/ParsedYoutubeVideo.ts new file mode 100644 index 0000000..8367058 --- /dev/null +++ b/src/types/ParsedYoutubeVideo.ts @@ -0,0 +1,5 @@ +export type ParsedYoutubeVideo = { + id: string; + title: string; + thumbnailURL: string; +}; diff --git a/src/types/RawYoutubeVideo.ts b/src/types/RawYoutubeVideo.ts new file mode 100644 index 0000000..5c273e8 --- /dev/null +++ b/src/types/RawYoutubeVideo.ts @@ -0,0 +1,7 @@ +export type RawYoutubeVideo = { + id: { videoId: string }; + snippet: { + title: string; + thumbnails: { default: { url: string } }; + }; +};