diff --git a/meme.jpg b/meme.jpg new file mode 100644 index 0000000..d4a6ab8 Binary files /dev/null and b/meme.jpg differ diff --git a/package-lock.json b/package-lock.json index d71704a..01b5d26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -118,25 +118,6 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, - "node_modules/@discordjs/node-pre-gyp/node_modules/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/@discordjs/opus": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@discordjs/opus/-/opus-0.9.0.tgz", @@ -1220,6 +1201,25 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/nodemon": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.1.tgz", diff --git a/src/app.ts b/src/app.ts index f24e04a..0b65c64 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,21 +1,19 @@ import { ActivityType, Client, Events, GatewayIntentBits } from "discord.js"; -import { YOUTUBE, TOKEN, BRUH } from '../config.json'; -import { Commands } from "./lib/command"; +import { YOUTUBE, TOKEN } from '../config.json'; +import { COMMANDS } from "./commands"; import { VoiceManager } from "./lib/voice"; import { StateManager } from "./lib/state"; -import { BruhListener } from "./lib/listeners/bruh"; - import YTDlpWrap from "yt-dlp-wrap"; +import { Embeds } from "./lib/utils/embeds"; export const ytdl = new YTDlpWrap(YOUTUBE.BINARY); export const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent ] }); export const voiceManager = new VoiceManager(); export const stateManager = new StateManager(); -export const bruhManager = new BruhListener(); client.once(Events.ClientReady, async client => { console.log(`Logged in as ${client.user.tag}`); - console.log(`| Registered ${Commands.length} commands!`); + console.log(`| Registered ${COMMANDS.length} commands!`); console.log(`| Voice Manager Status: ${voiceManager ? "ONLINE" : "OFFLINE"}`); client.user.setPresence({ @@ -31,9 +29,9 @@ client.once(Events.ClientReady, async client => { client.on(Events.InteractionCreate, async interaction => { if (interaction.isChatInputCommand()) { - const command = Commands.find(command => command.data.name === interaction.commandName); + const command = COMMANDS.find(command => command.data.name === interaction.commandName); if (!command) { - interaction.followUp({ content: "An error has occurred." }); + await Embeds.error(interaction, "Something went terribly wrong! Contact the developer."); return; } await interaction.deferReply(); @@ -41,12 +39,4 @@ client.on(Events.InteractionCreate, async interaction => { } }); -client.on(Events.MessageCreate, async message => { - if (message.author.bot) return; - let content = message.content.toLowerCase(); - if (message.channelId === BRUH.MESSAGE && content === "bruh") stateManager.bruhCount++; -}); - -//deploy(); - client.login(TOKEN); \ No newline at end of file diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts new file mode 100644 index 0000000..b198e06 --- /dev/null +++ b/src/commands/deploy.ts @@ -0,0 +1,20 @@ +import { SlashCommandBuilder } from "discord.js"; +import { Command, deploy } from "../lib/command"; +import { Embeds } from "../lib/utils/embeds"; +import { COMMANDS } from "."; + +const MY_SNOWFLAKE = "140520164629151744"; + +export const Deploy: Command = { + data: new SlashCommandBuilder() + .setName('deploy') + .setDescription('Deploys slash commands for the bot.'), + execute: async (client, interaction) => { + if (interaction.user.id === MY_SNOWFLAKE) { + deploy(); + await Embeds.send(interaction, embed => embed + .setTitle("Deployed commands") + .setDescription(`${COMMANDS.length} commands have been deployed.`)); + } else await Embeds.error(interaction, "You do not have permission to execute this command!"); + }, +} \ No newline at end of file diff --git a/src/commands/help.ts b/src/commands/help.ts new file mode 100644 index 0000000..9b5f534 --- /dev/null +++ b/src/commands/help.ts @@ -0,0 +1,17 @@ +import { SlashCommandBuilder } from "discord.js"; +import { Command } from "../lib/command"; +import { Embeds } from "../lib/utils/embeds"; +import { Time } from "../lib/utils/misc"; +import { COMMANDS } from "."; + +export const Help: Command = { + data: new SlashCommandBuilder() + .setName('help') + .setDescription('Lists all the available commands.'), + execute: async (client, interaction) => { + await Embeds.send(interaction, embed => embed + .setTitle("Help") + .setDescription(`There's a lot I can do for you. Here is a list of the avaliable commands:`) + .addFields(COMMANDS.map(command => ({ name: `/${command.data.name}`, value: command.data.description, inline: true })))); + }, +} \ No newline at end of file diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..6b76055 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,10 @@ +import { Play } from "../commands/voice/play"; +import { Skip } from "../commands/voice/skip" +import { Queue } from "../commands/voice/queue"; +import { Stop } from "../commands/voice/stop"; +import { Deploy } from "../commands/deploy"; +import { Status } from "../commands/status"; +import { Meme } from "./meme"; +import { Help } from "./help"; + +export const COMMANDS = [ Help, Play, Skip, Queue, Stop, Meme, Status, Deploy ]; \ No newline at end of file diff --git a/src/commands/meme.ts b/src/commands/meme.ts new file mode 100644 index 0000000..bde72b1 --- /dev/null +++ b/src/commands/meme.ts @@ -0,0 +1,44 @@ +import { AttachmentBuilder, SlashCommandBuilder } from "discord.js"; +import { Command } from "../lib/command"; +import axios from "axios"; +import { Embeds } from "../lib/utils/embeds"; + +export const Meme: Command = { + data: new SlashCommandBuilder() + .setName('meme') + .setDescription('Create a meme with top and bottom text.') + .addStringOption(option => option.setName("top").setRequired(true).setDescription("The text on the top of the meme.")) + .addAttachmentOption(option => option.setName("image").setRequired(true).setDescription("The image to create a meme out of.")) + .addStringOption(option => option.setName("bottom").setRequired(false).setDescription("The text on the bottom of the meme.")), + execute: async (client, interaction) => { + let top = interaction.options.get("top", true); + let bottom = interaction.options.get("bottom", false); + let { attachment } = interaction.options.get("image", true); + + if (!attachment) { + await Embeds.error(interaction, "You must provide an image to generate a meme."); + return; + } + + const res = await axios.post("https://api.memegen.link/images/custom", + { + "background": attachment.url, + "text": bottom ? [ top.value, bottom.value ] : [ top.value ], + "extension": "png", + "redirect": false + }, + { + headers: { + "Content-Type": "application/json" + } + } + ).catch(_ => null); + + if (!res) { + await Embeds.error(interaction, "Failed to generate meme. (This probably was the APIs fault, not yours.)"); + return; + } + + interaction.editReply({ files: [ new AttachmentBuilder(res.data.url) ] }); + }, +} \ No newline at end of file diff --git a/src/commands/ping.ts b/src/commands/ping.ts deleted file mode 100644 index 9be7bbe..0000000 --- a/src/commands/ping.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { SlashCommandBuilder } from "discord.js"; -import { Command } from "../lib/command"; - -export const Ping: Command = { - data: new SlashCommandBuilder() - .setName('ping') - .setDescription('Replies with Pong!'), - execute: async (client, interaction) => { - await interaction.followUp({ - ephemeral: true, - content: "Pong!" - }); - }, -} \ No newline at end of file diff --git a/src/commands/status.ts b/src/commands/status.ts new file mode 100644 index 0000000..3ff95c7 --- /dev/null +++ b/src/commands/status.ts @@ -0,0 +1,22 @@ +import { SlashCommandBuilder } from "discord.js"; +import { Command } from "../lib/command"; +import { Embeds } from "../lib/utils/embeds"; +import { Time } from "../lib/utils/misc"; +import { Style } from "../lib/utils/style"; + +const TIME_AT_START = Date.now(); + +export const Status: Command = { + data: new SlashCommandBuilder() + .setName('status') + .setDescription('Shows the current status for the bot.'), + execute: async (client, interaction) => { + await Embeds.send(interaction, embed => embed + .setTitle("Status") + .setDescription(` + Use \`/help\` to see all the commands. \n + Currently running version \`${Style.VERSION}\` using \`${Style.ENGINE_VERSION}\`. + ${Style.NAME} has been online for \`${Time.latestTime(Date.now() - TIME_AT_START)}\`. + `)); + }, +} \ No newline at end of file diff --git a/src/commands/voice/play.ts b/src/commands/voice/play.ts index 573aaf3..35164c2 100644 --- a/src/commands/voice/play.ts +++ b/src/commands/voice/play.ts @@ -3,6 +3,7 @@ import { voiceManager } from "../../app"; import { Command } from "../../lib/command"; import { Embeds } from "../../lib/utils/embeds"; import YouTubeAPI, { YoutubeMetadata } from "../../lib/utils/youtube"; +import { Text } from "../../lib/utils/misc"; export const Play: Command = { data: new SlashCommandBuilder() @@ -45,26 +46,84 @@ export const Play: Command = { .setImage(metadata.getThumbnailUrl())); } - let search = interaction.options.get("search", false); + const playPlaylist = async (metadata?: YoutubeMetadata[]) => { + if (!interaction.guild || !interaction.member) { + await Embeds.error(interaction, "You are not in a guild!"); + return; + } + + let user = await interaction.guild.members.cache.get(interaction.member.user.id); + if (!user || !user.voice || !user.voice.channel) { + await Embeds.error(interaction, "You are not in a voice channel!"); + return; + } + + if (!metadata || metadata.length === 0) { + await Embeds.error(interaction, `An invalid playlist was provided.`); + return; + } + + let connection = voiceManager.get(interaction.guild.id); + if (connection) { + connection.play(...metadata); + await Embeds.send(interaction, embed => embed.setAuthor({ name: "Added to queue" }) + .setTitle(`Playlist with ${Text.number(others.length, "song")}`) + .setThumbnail(metadata[0].getThumbnailUrl()) + .addFields(metadata.map((metadata, index) => + ({ + name: `${index}. ${metadata.getTitle()}`, + value: `by ${metadata.getAuthor()}` + })))); + return; + } + + connection = voiceManager.join(user.voice.channel); + connection.play(...metadata); + + let [ first, ...others ] = metadata; + await Embeds.send(interaction, embed => embed.setAuthor({ name: "Now Playing"}) + .setTitle(first.getTitle()) + .setURL(first.getUrl()) + .setDescription(` + by ${first.getAuthor()} + Added ${Text.number(others.length, "other song")} to the queue: + `).addFields(others.map((other) => + ({ + name: `${other.getTitle()}`, + value: `by ${other.getAuthor()}` + }))) + .setImage(first.getThumbnailUrl()) + ); + } + let url = interaction.options.get("url", false); if (url) { + if (YouTubeAPI.isPlaylist(url.value as string)) { + playPlaylist(await YouTubeAPI.getPlaylistMetadata(url.value as string)); + return; + } + let metadata = await YouTubeAPI.getMetadata(url.value as string); if (!metadata) { await Embeds.error(interaction, `\`${url.value}\` is not a valid YouTube URL.`); return; } await playMetadata(metadata); - } else if (search) { + return; + } + + let search = interaction.options.get("search", false); + if (search) { let metadata = await YouTubeAPI.search(search.value as string); if (!metadata) { await Embeds.error(interaction, `The search \`${search.value}\` did not return anything on YouTube.`); return; } await playMetadata(metadata); - } else { - await Embeds.error(interaction, `Please specify either a search or valid YouTube URL.`); return; } + await Embeds.error(interaction, `Please specify either a search or valid YouTube URL.`); + return; }, } \ No newline at end of file diff --git a/src/commands/voice/queue.ts b/src/commands/voice/queue.ts index 291512a..98ef426 100644 --- a/src/commands/voice/queue.ts +++ b/src/commands/voice/queue.ts @@ -42,7 +42,7 @@ export const Queue: Command = { .setThumbnail(playing.getThumbnailUrl()) .addFields({ name: '\u200B', value: 'Next in the queue:' }) - if (queue.length > 0) embed.addFields(queue.slice(0, Math.min(8, queue.length)).map((metadata, index) => ({ name: `${index + 2}. ${metadata.getTitle()}`, value: `by ${metadata.getAuthor()}` }))); + if (queue.length > 0) embed.addFields(queue.slice(0, Math.min(9, queue.length)).map((metadata, index) => ({ name: `${index + 2}. ${metadata.getTitle()}`, value: `by ${metadata.getAuthor()}` }))); else embed.addFields({ name: 'There is nothing next in the queue.', value: '\u200B' }) await Embeds.send(interaction, () => embed); diff --git a/src/lib/command.ts b/src/lib/command.ts index 0475897..9626463 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -1,18 +1,12 @@ import { CommandInteraction, Client, REST, Routes, SlashCommandBuilder } from "discord.js"; -import { Ping } from "../commands/ping"; -import { Play } from "../commands/voice/play"; -import { Skip } from "../commands/voice/skip"; import { TOKEN, DEVELOPMENT } from '../../config.json'; -import { Queue } from "../commands/voice/queue"; -import { Stop } from "../commands/voice/stop"; +import { COMMANDS } from "../commands"; export interface Command { data: Omit, execute: (client: Client, interaction: CommandInteraction) => void; } -export const Commands: Command[] = [ Ping, Play, Skip, Queue, Stop ]; - export const deploy = () => { // Construct and prepare an instance of the REST module const rest = new REST().setToken(TOKEN); @@ -20,14 +14,14 @@ export const deploy = () => { // and deploy your commands! (async () => { try { - console.log(`Started refreshing ${Commands.length} application (/) commands.`); + console.log(`Started refreshing ${COMMANDS.length} application (/) commands.`); - console.log(Commands.map(command => command.data.toJSON())) + console.log(COMMANDS.map(command => command.data.toJSON())) // The put method is used to fully refresh all commands in the guild with the current set const data = await rest.put( Routes.applicationGuildCommands(DEVELOPMENT.APPLICATION_ID, DEVELOPMENT.GUILD_ID), - { body: Commands.map(command => command.data) }, + { body: COMMANDS.map(command => command.data) }, ) as any[]; console.log(`Successfully reloaded ${data.length} application (/) commands.`); diff --git a/src/lib/listeners/bruh.ts b/src/lib/listeners/bruh.ts deleted file mode 100644 index 7551052..0000000 --- a/src/lib/listeners/bruh.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { client, stateManager } from "../../app"; -import { BRUH } from "../../../config.json"; - -export class BruhListener { - - private thread?: NodeJS.Timer; - - constructor() { - this.thread = setInterval(() => { - let guild = client.guilds.resolve(BRUH.GUILD); - if (!guild) return; - - let channel = guild.channels.resolve(BRUH.VOICE); - if (!channel) return; - channel.edit({ name: `Bruh Count: ${stateManager.bruhCount}`, reason: "New bruh added" }); - }, 5 * 60 * 1000); // 5 minutes - } - - -} \ No newline at end of file diff --git a/src/lib/utils/embeds.ts b/src/lib/utils/embeds.ts index 37b5eab..dec3037 100644 --- a/src/lib/utils/embeds.ts +++ b/src/lib/utils/embeds.ts @@ -1,5 +1,6 @@ import { EmbedBuilder } from "@discordjs/builders"; import { CacheType, Colors, CommandInteraction, Interaction } from "discord.js"; +import { Style } from "./style"; export namespace Embeds { @@ -10,7 +11,7 @@ export namespace Embeds { return new EmbedBuilder() .setColor(DEFAULT) .setTimestamp() - .setFooter({ text: 'Barbarian v2', iconURL: 'https://cdn.discordapp.com/avatars/937850544255545394/5953a93c37c8de47f42058649c45e6ec?size=1024' }); + .setFooter({ text: Style.ENGINE_VERSION, iconURL: Style.PROFILE_URL }); } export const send = async (interaction: CommandInteraction, callback: (embed: EmbedBuilder) => EmbedBuilder) => { diff --git a/src/lib/utils/misc.ts b/src/lib/utils/misc.ts index 3d5d2cf..3b105d0 100644 --- a/src/lib/utils/misc.ts +++ b/src/lib/utils/misc.ts @@ -36,6 +36,15 @@ export namespace Entities { export namespace Time { + export const Units = { + year: { label: 'y', divisor: 31104000000 }, + month: { label: 'mo', divisor: 2592000000 }, + day: { label: 'd', divisor: 86400000 }, + hour: { label: 'h', divisor: 3600000 }, + minute: { label: 'm', divisor: 60000 }, + second: { label: 's', divisor: 1000 }, + }; + export const PROGRESS_UNFILLED = ":white_large_square:"; export const PROGRESS_FILLED = ":yellow_square:"; @@ -61,4 +70,27 @@ export namespace Time { return Array(percent).fill(PROGRESS_FILLED).concat(Array(overall).fill(PROGRESS_UNFILLED)).join(""); } + export const latestTime = (time: number) => { + let result = ''; + + for (const unit of Object.values(Time.Units)) { + const value = Math.floor(time / unit.divisor); + if (value !== 0) { + result += `${value}${unit.label}, `; + time %= unit.divisor; + } + } + + result = result.substring(0, Math.max(0, result.length - 2)); + if (result === '') return '0s'; + + return result; + } + +} + +export namespace Text { + + export const number = (value: number, singular: string, plural: string = `${singular}s`) => `${value} ${value == 1 ? singular : plural}`; + } \ No newline at end of file diff --git a/src/lib/utils/style.ts b/src/lib/utils/style.ts new file mode 100644 index 0000000..c31d831 --- /dev/null +++ b/src/lib/utils/style.ts @@ -0,0 +1,7 @@ + +export namespace Style { + export const NAME = "BarbarianBot"; + export const ENGINE_VERSION = "Barbarian v2"; + export const VERSION = "2.0"; + export const PROFILE_URL = "https://cdn.discordapp.com/avatars/937850544255545394/5953a93c37c8de47f42058649c45e6ec?size=1024" +} diff --git a/src/lib/utils/youtube.ts b/src/lib/utils/youtube.ts index b8bc54d..baea36c 100644 --- a/src/lib/utils/youtube.ts +++ b/src/lib/utils/youtube.ts @@ -3,31 +3,56 @@ import { YOUTUBE } from "../../../config.json"; import { Entities, Time } from './misc'; import { ytdl } from '../../app'; -const YOUTUBE_SHORTENED_URL = "https://youtu.be/"; const YOUTUBE_URL_REGEX = /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube(-nocookie)?\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|live\/|v\/)?)([\w\-]+)(\S+)?$/; +const YOUTUBE_PLAYLIST_QUERY_REGEX = /\?list=(.+)/; const search = async (query: string): Promise => { - const url = `https://youtube.googleapis.com/youtube/v3/search?part=snippet&channelType=any&eventType=none&q=${query}&videoType=any&key=${YOUTUBE.KEY}` - const res = await Axios.get(url).then(res => res.data).catch(_ => undefined); + const res = await Axios.get(`https://youtube.googleapis.com/youtube/v3/search?part=contentDetails&channelType=any&eventType=none&q=${query}&videoType=any&key=${YOUTUBE.KEY}`) + .then(res => res.data) + .catch(_ => undefined); if (!res) return undefined; + let { contentDetails: { videoId } } = res.items[0]; + return getMetadata(createShareUrl(videoId), false); +} - let { id: { videoId } } = res.items[0]; - return getMetadata(createShareUrl(videoId)); - } - -const getVideoIdFromUrl = (url: string) => { - let capture = YOUTUBE_URL_REGEX.exec(url); - return capture ? capture[6] : ""; +const getPlaylistMetadata = async (url: string): Promise => { + let playlistParse = YOUTUBE_PLAYLIST_QUERY_REGEX.exec(url); + if (!playlistParse || !playlistParse[1]) return undefined; + + const res = await Axios.get(`https://www.googleapis.com/youtube/v3/playlistItems?part=contentDetails&playlistId=${playlistParse[1]}&maxResults=15&key=${YOUTUBE.KEY}`) + .then(res => res.data) + .catch(_ => undefined); + if (!res) return undefined; + return await Promise.all(res.items.map((item: any) => getMetadata(createShareUrl(item.contentDetails.videoId), false))); } + +/** + * Get the metadata associated with a YouTube video, using its URL. + * + * @param url The YouTube URL to get metadata for. + * @param cleanse If this URL is already in the share format, set this to false to skip the cleanse . + * @returns A YoutubeMetadata object if the URL is valid, otherwise undefined. + */ +const getMetadata = async (url: string, cleanse: boolean = true): Promise => { + if (cleanse) { + let capture = YOUTUBE_URL_REGEX.exec(url); + if (!capture || !capture[6]) return undefined; + url = createShareUrl(capture[6]); + } -const getMetadata = async (url: string): Promise => { let res = await ytdl.getVideoInfo(url).catch(_ => undefined); - if (res) return new YoutubeMetadata(res.fulltitle, res.channel, createShareUrl(res.display_id), res.thumbnail, res.duration); + if (res) return new YoutubeMetadata(res.fulltitle, res.channel, url, res.thumbnail, res.duration); else return undefined; } +const isPlaylist = (url: string): boolean => { + let captures = YOUTUBE_URL_REGEX.exec(url); + return !!captures && captures[6] === "playlist"; +}; + + const getThumbnailUrl = (id: string): string => `https://i3.ytimg.com/vi/${id}/maxresdefault.jpg`; -const createShareUrl = (id: string): string => `${YOUTUBE_SHORTENED_URL}${id}`; +const createShareUrl = (id: string): string => `https://youtu.be/${id}`; export class YoutubeMetadata { @@ -71,4 +96,4 @@ export class YoutubeMetadata { } -export default { search, getVideoIdFromUrl, getMetadata, createShareUrl }; \ No newline at end of file +export default { search, getMetadata, isPlaylist, getPlaylistMetadata, createShareUrl }; \ No newline at end of file diff --git a/src/lib/voice.ts b/src/lib/voice.ts index 27b77a5..d6e13d3 100644 --- a/src/lib/voice.ts +++ b/src/lib/voice.ts @@ -74,8 +74,8 @@ export class VoiceConnection { get = () => getVoiceConnection(this.guildId); - play = (metadata: YoutubeMetadata) => { - this.queue.push(metadata); + play = (...metadata: YoutubeMetadata[]) => { + this.queue.push(...metadata); if (!this.playing) this.next(); };