diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c70ac4d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +venv/ +__pycache__/ +*.pyc +Dockerfile +LICENSE +readme.md +*.sh +docker-compose.yml + diff --git a/.gitignore b/.gitignore index 5276466..579e49e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ -node_modules -package-lock.json config.json -*.core *.db +__pycache__/ +*.pyc +venv/ +.env +*.pickle +*.log +logs/ diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index e936d79..0000000 --- a/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "trailingComma": "es5", - "tabWidth": 4, - "semi": true, - "useTabs": false, - "arrowParens": "avoid", - "endOfLine": "lf", - "bracketSpacing": true, - "quoteProps": "consistent", - "singleQuote": false -} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7eb2c77 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1 +FROM python:3.11 + +# app workdir +WORKDIR /app + +# copying files +COPY . /app/ + +# building +RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt +RUN mkdir -p /app/logs + +# running the app +CMD ["python3", "main.py"] + diff --git a/app.js b/app.js deleted file mode 100644 index a83e288..0000000 --- a/app.js +++ /dev/null @@ -1,107 +0,0 @@ -// imports -import User from "./commands/user.js"; -import Post from "./commands/post.js"; -import Help from "./commands/help.js"; -import About from "./commands/about.js"; -import Config from "./commands/config.js"; -import Hash from "./commands/hashfiles.js"; -import DevelopmentHandler from "./env.dev.js"; -import ProductionHandler from "./env.prod.js"; -import { Client, GatewayIntentBits, Events } from "discord.js"; -import config from "./config.json" assert { type: "json" }; -import Icon from "./commands/icon.js"; - -// creating a list of valid commands used by the bot -const Commands = [Post, User, About, Help, Hash, Config, Icon]; - -// Create a new client instance -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.MessageContent, - ], -}); - -// the list of all bot commands -client.commands = Commands; - -// getting all the handler functions depending on the environment -const handler = ((c) => { - // the cleanest way I could do this - switch (process.env.NODE_ENV) { - case "dev": - console.log("Running the bot in development mode."); - return DevelopmentHandler(c); - case "prod": - console.log("Running the bot in production mode."); - return ProductionHandler(c); - default: - console.log("No environment found, running in production mode."); - return ProductionHandler(c); - } -})(client); - -// logging on success -client.once(Events.ClientReady, handler.ClientReady); - -// regular message handler -client.on(Events.MessageCreate, handler.MessageCreate); - -// slash command handler -client.on(Events.InteractionCreate, handler.InteractionCreate); - -// callback loop -(async () => { - try { - // mapping the commands to return their "data" prop - const payload = client.commands.map(command => { - return command.data; - }); - - // logging the payload for debug purposes - switch (process.env.NODE_ENV) { - case "dev": - console.log("Logging the payload:", payload); - break; - case "prod": - break; - default: - break; - } - - // logging what commands are sent to the discord API - console.log( - "Loading commands:", - payload - .map(command => { - return command.name; - }) - .join(", ") - ); - - // logging - console.log( - `Started refreshing ${payload.length} application (/) commands.` - ); - - // The put method is used to fully refresh all commands in the guild with the current set - try { - const data = await handler.Put(payload); - - // more logging - console.log( - `Successfully reloaded ${data.length} application (/) commands.` - ); - } catch (e) { - console.error("There was an unexpected error: ", e.stack); - } - - // logging into discord - client.login(config.token); - } catch (error) { - // logging any errors - console.error(error); - return; - } -})(); diff --git a/commands/about.js b/commands/about.js deleted file mode 100644 index 9547c7e..0000000 --- a/commands/about.js +++ /dev/null @@ -1,30 +0,0 @@ -import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; -import { randomQuote, iFunnyIcon, avatarIcon } from "../utils/misc.js"; - -async function about(interaction) { - // creating the embed - const embed = new EmbedBuilder() - .setColor(0x0099ff) - .setTitle("Source Code") - .setAuthor({ name: "bruhulance.if", iconURL: avatarIcon }) - .setURL("https://github.com/JimAppreciator512/ifunny-bot") - .setDescription( - "Mainly uses slash commands (/), will auto-parse iFunny.co links in messages." - ) - .setThumbnail(iFunnyIcon) - .setFooter({ text: randomQuote() }); - - // replying with the embed - return interaction.reply({ embeds: [embed] }); -} - -const About = { - data: new SlashCommandBuilder() - .setName("about") - .setDescription( - "Replies with a description of the bot and a link to the source code." - ), - execute: about, -}; - -export default About; diff --git a/commands/config.js b/commands/config.js deleted file mode 100644 index 2ad16af..0000000 --- a/commands/config.js +++ /dev/null @@ -1,523 +0,0 @@ -// new file for configuring the bot on a server basis -import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; -import { - executeQuery, - prisma, - pullServerConfig, - pullServerConfigNoInsert, - sha1sum, -} from "../utils/db.js"; -import { imageExportFormats } from "../utils/utils.js"; - -/** - * this function shows the configuration, enables or disables auto-embed - * @param {import("discord.js").Interaction} interaction the discord interaction - */ -async function configAutoEmbed(interaction) { - const data = interaction.options.getBoolean("value"); - - // getting the current value of autoEmbed - const config = await pullServerConfigNoInsert(interaction.guild.id); - - // breaking if config is null - if (!config) { - console.log(`Error retrieving information about ${sha1sum(interaction.guild.id)}.`); - return await interaction.reply({ - content: "There was an error retrieving information about your server.", - ephemeral: true - }); - } - - // user wants to display the current setting - if (data === undefined) { - // responding to caller - return await interaction.reply({ - ephemeral: true, - content: config.globalEmbed - ? "Auto-embedding is set to true, any message in any channel that contains an iFunny link will be embedded." - : 'Auto-embedding is set to false, any message in any channel that contains an iFunny link will be ignored. This can be overridden if there are any channels set via. "/config channels"', - }); - } - - // user wants to set the configuration - if (config.globalEmbed === data) { - // breaking early here since there is no point changing true to true - return await interaction.reply({ - content: `Set "auto-embed" to ${data}`, - ephemeral: true, - }); - } - - // user is actually changing the config, need to update the database - const result = await executeQuery(interaction.guild.id, "config", "update", { - data: { - globalEmbed: data - } - }); - - // evaluating the response - if (result && result.globalEmbed === data) { - return await interaction.reply({ - content: `Set "auto-embed" to ${data}`, - ephemeral: true, - }); - } else { - console.error("Error during configAutoEmbed.\n", result); - return await interaction.reply({ - content: `There was an error updating "auto-embed".`, - ephemeral: true, - }); - } -} - -/** - * this function shows the configuration, sets or updates the set "role" - * @param {import("discord.js").Interaction} interaction the discord interaction - */ -async function configRole(interaction) { - const data = interaction.options.getRole("role"); - - // getting the current value of autoEmbed - const config = await pullServerConfigNoInsert(interaction.guild.id); - - // breaking if config is null - if (!config) { - return await interaction.reply({ - content: "There was an error retrieving information about your server.", - ephemeral: true - }); - } - - // user wants to display the current setting - if (!data) { - // logging - console.log( - `Replying to user ${interaction.user.username} with the saved role ${config.role}.` - ); - - // if the role is "@everyone" this actually means "manage guild" & "admin" - var message; - if (!config.role || config.role === "1036130140977119322") { - message = - 'Any user with "Manage Server" or "Administrator" can use this command.'; - } else { - message = `Any user with "${ - interaction.guild.roles.resolve(config.role).name - }" can use this command.`; - } - - // responding to caller - return await interaction.reply({ - ephemeral: true, - content: message, - }); - } - - // logging - console.log( - `Updating the saved role ${config.role} to ${data} for server ${sha1sum(interaction.guild.id)}.` - ); - - // mapping the id to the actual name to make more sense - const oldRoleName = interaction.guild.roles.resolve(config.role).name; - const newRoleName = interaction.guild.roles.resolve(data.id).name; - - // not applying unneeded changes - if (config.role === data.id) { - return await interaction.reply({ - content: `Changed ${oldRoleName} to ${newRoleName}`, - ephemeral: true, - }); - } - - // user is actually changing the role, we need to update the database - const result = await executeQuery(interaction.guild.id, "config", "update", { - data: { - role: data.id - } - }); - - // evaluating the response - if (result && result.role === data.id) { - return await interaction.reply({ - content: `Changed ${oldRoleName} to ${newRoleName}`, - ephemeral: true, - }); - } else { - return await interaction.reply({ - content: `There was an error updating ${oldRoleName} to ${newRoleName}.`, - ephemeral: true, - }); - } -} - -/** - * this function shows, updates or sets the channels which autoembedding can occur in - * @param {import("discord.js").Interaction} interaction the discord interaction - */ -async function configExportFormat(interaction) { - // getting options - const format = interaction.options.getString("format"); - - // config - const config = await pullServerConfigNoInsert(interaction.guild.id); - - // breaking if config is null - if (!config) { - return await interaction.reply({ - content: "There was an error retrieving information about your server.", - ephemeral: true - }); - } - - // if no action, list current export format - if (!format) { - // returning the set export format - const msg = `I am currently exporting images in "${config.exportFormat}" format.`; - return await interaction.reply(msg); - } - - // if the current format is the same as the desired format, don't do anything - if (format === config.exportFormat) { - return await interaction.reply({ - content: `Changed export format to ${format}.`, - ephemeral: true, - }); - } - - // logging - console.log(`Updating image format of ${sha1sum(interaction.guild.id)} to ${format}.`); - - // user is actually changing the role, we need to update the database - const result = await executeQuery(interaction.guild.id, "config", "update", { - data: { - exportFormat: format - } - }); - - // evaluating the response - if (result && result.exportFormat === format) { - return await interaction.reply({ - content: `Changed export format to ${format}.`, - ephemeral: true, - }); - } else { - return await interaction.reply({ - content: `There was an error updating ${config.exportFormat} to ${format}.`, - ephemeral: true, - }); - } -} - -/** - * this function shows, updates or sets the channels which autoembedding can occur in - * @param {import("discord.js").Interaction} interaction the discord interaction - */ -async function configChannels(interaction) { - // getting options - const action = interaction.options.getString("action"); - const channel = interaction.options.getChannel("channel"); - - // pulling all channels - const channels = await prisma.channel.findMany({ - where: { - server: sha1sum(interaction.guild.id), - }, - }); - - // if no action, list all channels - if (!action && !channel) { - // there are no saved channels - if (channels.length === 0) { - return await interaction.reply({ - content: "There are no saved channels for this server.", - ephemeral: true, - }); - } - - // there are one or more saved channels - const humanReadableChannels = channels.map(obj => { - return interaction.guild.channels.resolve(obj.channel); - }); - const msg = `Here are the channels I have saved:\n${humanReadableChannels - .toString() - .replaceAll(",", "\n")}`; - - return await interaction.reply(msg); - } - - // if no action and channel exists, reply with help message - if (!action && channel) { - return await interaction.reply({ - content: `Need an action for ${channel}.`, - ephemeral: true, - }); - } - - // if action exists and no channel, reply with help message - if (action && !channel) { - return await interaction.reply({ - content: `Need a channel to ${action}.`, - ephemeral: true, - }); - } - - // logging - console.log( - `${sha1sum(interaction.guild.id)} has ${channels.length} channels saved.` - ); - - if (!["add", "remove"].includes(action)) { - return await interaction.reply({ - content: "You somehow managed to get here, good job!", - ephemeral: true, - }); - } - - // for string formatting - const verb = action === "add" ? "sav" : "remov"; - - /// testing if breaking early (adding a duplicate, removing a channel that isn't saved) - const breakEarly = ((a) => { - switch (a) { - case "add": - return channels.filter(obj => { - return obj.channel === channel.id; - }).length > 0; - case "remove": - return channels.filter(obj => { - return obj.channel === channel.id; - }).length === 0; - } - })(action); - - if (breakEarly) { - // logging - console.log( - `${channel.id} was${action === "remove" ? "n't" : ""} found in the database, not ${verb}ing.` - ); - - // channel was found, breaking early - return await interaction.reply({ - content: `Successfully ${verb}ed ${channel}.`, - ephemeral: true, - }); - } else { - // logging - console.log(`${channel.id} was${action === "add" ? "n't" : ""} found in the database, ${action === "add" ? "adding" : "removing"}.`); - } - - // performing query - const query = await (async (a) => { - switch (a) { - case "remove": - return await executeQuery(interaction.guild.id, "channel", "delete", { - where: { - channel: channel.id - } - }); - default: - return await executeQuery(interaction.guild.id, "channel", "create", { - data: { - channel: channel.id, - server: sha1sum(interaction.guild.id), - }, - }) - } - })(action); - - // asserting the result of the query - if ( - query && - query.channel === channel.id - ) { - // logging - console.log(`Successfully ${verb}ed ${channel.id} to the database.`); - - return await interaction.reply({ - content: `Successfully ${verb}ed ${channel}.`, - ephemeral: true, - }); - } else { - // logging - console.error("There was an error", query); - - return await interaction.reply({ - content: `There was an error ${verb}ing ${channel}.`, - ephemeral: true, - }); - } -} - -/** - * the handler function for the "Config" command - * @param {import("discord.js").Interaction} interaction the discord interaction - */ -async function config(interaction) { - // check if the server is saved in the database - // if saved, - // continue - // else, - // add the server to the database - const config = await pullServerConfig(interaction.guild.id); - - // breaking if there's an error - if (!config) { - return await interaction.reply({ - content: "There was an error retrieving information about your server.", - ephemeral: true - }); - } - - // checking if the user has Administrator or ManageGuild - if ( - !interaction.memberPermissions.has( - PermissionFlagsBits.ManageGuild | PermissionFlagsBits.Administrator - ) - ) { - // user doesn't have ManageGuild nor are they Administrator - // checking if there is a saved role in this server, the user could still execute this command - if (config.role !== "") { - // logging - console.log( - `There is a saved role for server ${config.server}, checking if user ${interaction.user.username} has it.` - ); - - // if caller has saved role, continue - // else, abort - if (!interaction.member.roles.cache.has(config.role)) { - // role name - const roleName = interaction.guild.roles.resolve( - config.role - ).name; - - // this looks fucking stupid - console.log( - `User ${interaction.user.username} doesn't have the set role "${roleName}", they cannot execute Config.` - ); - return interaction.reply({ - ephemeral: true, - content: `You do not have the role ${roleName} and cannot execute this command.`, - }); - } - } else { - // logging - console.log( - `There is no saved role for server ${config.server}. User ${interaction.user.username} cannot execute Config.` - ); - return interaction.reply({ - ephemeral: true, - content: - "You do not have the required permissions to execute this command.", - }); - } - } - - /// delegating to smaller functions - - // I don't think there will be more than one entry in `options.data` - const option = interaction.options.data[0].name; - - // logging - console.log( - `User ${interaction.user.username} has valid permissions, they can execute config option: ${option}.` - ); - - // choosing the right function handler based on the action - switch (option) { - case "autoembed": - return await configAutoEmbed(interaction); - case "channels": - return await configChannels(interaction); - case "role": - return await configRole(interaction); - case "file-format": - return await configExportFormat(interaction); - default: - return interaction.reply({ - content: `Cannot configure unknown option: ${option}.`, - ephemeral: true, - }); - } -} - -const Config = { - data: new SlashCommandBuilder() - .setName("config") - .setDescription( - "Exposes configuration tools to control how the bot behaves on this server." - ) - .addSubcommand(subcommand => { - return subcommand - .setName("autoembed") - .setDescription( - "Controls whether the bot auto-embeds posts found in messages in any channel." - ) - .addBooleanOption(option => { - return option - .setName("value") - .setDescription("True to enable, false to disable."); - }); - }) - .addSubcommand(subcommand => { - return subcommand - .setName("channels") - .setDescription( - "Whitelisted channels the bot will autoembed in." - ) - .addStringOption(option => { - return option - .setName("action") - .setDescription( - "Adds or removes a channel to the list." - ) - .addChoices({ name: "add", value: "add" }) - .addChoices({ name: "remove", value: "remove" }); - }) - .addChannelOption(option => { - return option - .setName("channel") - .setDescription("The channel to listen in."); - }); - }) - .addSubcommand(subcommand => { - return subcommand - .setName("role") - .setDescription( - 'The role that can configure the bot. (default "manage server" or "administrator")' - ) - .addRoleOption(option => { - return option - .setName("role") - .setDescription( - "The role that can configure the bot ." - ); - }); - }) - .addSubcommand(subcommand => { - return subcommand - .setName("file-format") - .setDescription( - 'Changes the output format of images. (default "png")' - ) - .addStringOption(option => { - // setting up the option - option - .setName("format") - .setDescription( - "List of preset image formats." - ); - - // adding all the options - imageExportFormats.forEach(format => { - option.addChoices({ name: format, value: format }); - }); - - // returning the option - return option; - }) - }), - execute: config, -}; - -export default Config; diff --git a/commands/hashfiles.js b/commands/hashfiles.js deleted file mode 100644 index dedf202..0000000 --- a/commands/hashfiles.js +++ /dev/null @@ -1,87 +0,0 @@ -import path from "node:path/posix"; -import fs from "node:fs"; -import { execSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { SlashCommandBuilder } from "discord.js"; - -const restricted = ["config.json", ".git", "node_modules", "main.db"]; - -async function hash(interaction) { - // deferring the reply - await interaction.deferReply(); - - // recursive file finding function - function base(dir, done) { - var results = []; - - fs.readdirSync(dir).forEach(file => { - // not hashing certain files nor directories - if (restricted.includes(file)) { - return; - } - - /// stating the file to figure out if it's a dir or not - const stat = fs.statSync(path.resolve(dir, file)); - - // if directory, walk, else hash - if (stat && stat.isDirectory()) { - // walk directory - base(path.resolve(dir, file), (_, res) => { - res.forEach(f => { - results.push({ - name: `${file}/${f.name}`, - hash: f.hash, - }); - }); - }); - } else { - // hash the file - const md5sum = createHash("md5") - .update(fs.readFileSync(path.resolve(dir, file))) - .digest("hex"); - - // logging - console.log(`${md5sum}: ${path.resolve(dir, file)}`); - - // and push it to the array - results.push({ - name: path.basename(file), - hash: md5sum, - }); - } - }); - - // returning the files - return done(null, results); - } - - // calling the recursive file hashing command - return base(process.cwd(), (_, res) => { - // pulling the latest git commit - const output = execSync( - "git log --oneline | head -n 1 | tr -d \"\n\"", // fuck dealing with buffers dude - { cwd: process.cwd() } - ); - - // forming the string to return - var str = `Latest git commit: \`${output}\`\nMD5 Hashes\n\`\`\`\n`; - - // formatting the files into a string - for (const file of res) { - str = str.concat(`${file.hash}: ${file.name}\n`); - } - str = str.concat("```"); - - // replying with the data - return interaction.editReply(str); - }); -} - -const Hash = { - data: new SlashCommandBuilder() - .setName("hash") - .setDescription("Hash all the files the bot is using."), - execute: hash, -}; - -export default Hash; diff --git a/commands/help.js b/commands/help.js deleted file mode 100644 index dea1c9b..0000000 --- a/commands/help.js +++ /dev/null @@ -1,40 +0,0 @@ -import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; -import { randomQuote } from "../utils/misc.js"; -import Post from "./post.js"; -import User from "./user.js"; -import About from "./about.js"; -import Hash from "./hashfiles.js"; -import Config from "./config.js"; - -const commands = [Post, User, About, Hash, Config]; - -const fields = [ - ...commands.map(cmd => { - return { - name: "/" + cmd.data.name, - value: cmd.data.description, - }; - }), - { - name: "Message", - value: "If any message sent contains a link to a post on iFunny, it will auto execute the /post function.", - }, -]; - -const embed = new EmbedBuilder() - .setColor(0x0099ff) - .setTitle("Help") - .setDescription("List of all commands") - .setFields(...fields) - .setFooter({ text: randomQuote() }); - -const Help = { - data: new SlashCommandBuilder() - .setName("help") - .setDescription("Shows all the commands for the bot and what they do."), - execute: async interaction => { - await interaction.reply({ embeds: [embed] }); - }, -}; - -export default Help; diff --git a/commands/icon.js b/commands/icon.js deleted file mode 100644 index 91a5755..0000000 --- a/commands/icon.js +++ /dev/null @@ -1,106 +0,0 @@ -import { AttachmentBuilder, SlashCommandBuilder } from "discord.js"; -import { JSDOM } from "jsdom"; -import request from "request"; -import { getExportFormat, sanitizeUsername } from "../utils/utils.js"; -import { pullServerConfigNoInsert } from "../utils/db.js"; -import { request_image } from "../utils/format_image.js"; - -async function icon(interaction) { - // deferring the reply - await interaction.deferReply(); - - // pulling the server config - const config = await pullServerConfigNoInsert(interaction.guild.id); - - // getting the user to search for - /** @type String */ - const raw_username = interaction.options.getString("name"); - - // logging - console.log(`Looking user "${raw_username}"...`); - - // forming the URL of the potential user - const url = `https://ifunny.co/user/${raw_username}`; - - /** @type String */ - const username = sanitizeUsername(raw_username); - - /// posting to the URL - - // trying to get an HTTP request from that url - request(url, { json: true }, (err, res, _) => { - if (err) { - console.log("An error occurred:", err); - return interaction.editReply("An error talking to iFunny servers."); - } - if (res) { - if (res.statusCode !== 200) { - const msg = `User ${username} could not be found.`; - console.log(msg); - return interaction.editReply(msg); - } else { - // logging - console.log(`Found user ${username}.`); - - // turning the payload into something parse-able - const dom = new JSDOM(res.body).window.document; - - // parsing the icon - var icon; - const iconEl = dom.querySelector( - "span._4nz- > span.F6b- > img.k3q9" - ); - if (iconEl) { - // user has an avatar - icon = iconEl.getAttribute("src"); - } else { - // no avatar if here - console.log(`User ${username} has no icon.`); - - // breaking early - return interaction.editReply(`${username} has the default profile picture.`); - } - - // choosing export image format - const __format = getExportFormat(config.exportFormat); - - // creating callbacks - const resolve = (file) => { - const files = new AttachmentBuilder() - .setFile(file) - .setName(`${username}_pfp.png`); - - const message = `${username}'s profile picture.`; - - // editing the response - return interaction.editReply({ content: message, files: [files] }); - }; - - const error = (message) => { - console.log(message); - return interaction.editReply(`There was an error formatting the image to ${__format}.`); - }; - - // calling the function - return request_image(icon, __format, false, resolve, error); - } - } - }); -} - -const Icon = { - data: new SlashCommandBuilder() - .setName("icon") - .setDescription( - "Retrieves a user's profile picture. (case insensitive)" - ) - .addStringOption(option => { - return option - .setName("name") - .setDescription("The user's name.") - .setRequired(true); - }), - execute: icon, -}; - -export default Icon; diff --git a/commands/post.js b/commands/post.js deleted file mode 100644 index 102b187..0000000 --- a/commands/post.js +++ /dev/null @@ -1,51 +0,0 @@ -import { SlashCommandBuilder, ChatInputCommandInteraction } from "discord.js"; -import extractPost from "../utils/extractpost.js"; -import { pullServerConfigNoInsert } from "../utils/db.js"; - -/** - * this function is a nice wrapper around the `extractPost` function - * @param {ChatInputCommandInteraction} interaction the slash command - */ -async function post(interaction) { - // deferring the reply later - await interaction.deferReply(); - - /// shorthands to reply to a message - // this should only accept a string - const ereply = message => { - return interaction.editReply({ content: message, ephemeral: true }); - }; - - // can accept either an object payload or a string - const reply = message => { - if (typeof message === "object") { - return interaction.editReply(message); - } - return interaction.editReply({ content: message }); - }; - - // getting the url to search in - /** @type String */ - const url = interaction.options.getString("link"); - - // pulling the server config - const config = await pullServerConfigNoInsert(interaction.guild.id); - - // extracting the post - await extractPost(url, reply, ereply, config ? config.exportFormat : "png"); -} - -const Post = { - data: new SlashCommandBuilder() - .setName("post") - .setDescription("Posts a video/image from iFunny.") - .addStringOption(option => { - return option - .setName("link") - .setDescription("An iFunny.co link e.g., ifunny.co/video/...") - .setRequired(true); - }), - execute: post, -}; - -export default Post; diff --git a/commands/user.js b/commands/user.js deleted file mode 100644 index 0469239..0000000 --- a/commands/user.js +++ /dev/null @@ -1,126 +0,0 @@ -import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; -import { JSDOM } from "jsdom"; -import request from "request"; -import { iFunnyIcon } from "../utils/misc.js"; -import { sanitizeUsername } from "../utils/utils.js"; - -async function user(interaction) { - // deferring the reply - await interaction.deferReply(); - - // getting the user to search for - /** @type String */ - const raw_username = interaction.options.getString("name"); - - // logging - console.log(`Looking user "${raw_username}"...`); - - // forming the URL of the potential user - const url = `https://ifunny.co/user/${raw_username}`; - - /** @type String */ - const username = sanitizeUsername(raw_username); - - /// posting to the URL - - // trying to get an HTTP request from that url - request(url, { json: true }, (err, res, _) => { - if (err) { - console.log("An error occurred:", err); - return interaction.editReply("An error talking to iFunny servers."); - } - if (res) { - if (res.statusCode !== 200) { - const msg = `User ${username} could not be found.`; - console.log(msg); - return interaction.editReply(msg); - } else { - // logging - console.log(`Found user ${username}.`); - - // turning the payload into something parse-able - const dom = new JSDOM(res.body).window.document; - - // parsing the icon - var icon; - const iconEl = dom.querySelector( - "span._4nz- > span.F6b- > img.k3q9" - ); - if (iconEl) { - // user has an avatar - icon = iconEl.getAttribute("src"); - } else { - // no avatar if here - icon = iFunnyIcon; - console.log(`User ${username} has no icon.`); - } - - // getting the description - const description = (() => { - try { - return dom.querySelector("div.Hi31 > div.vjX5") - .textContent; - } catch (error) { - return "No description."; - } - })(); - - // parsing the sub count - const subCount = (() => { - try { - return / (.*) subscriber/.exec( - dom.querySelector( - "div.Hi31 > div[class='g+J7'] > a.sWk7" - ).textContent - )[1]; - } catch (error) { - return "No subscribers."; - } - })(); - - // parsing the number of features - const featureCount = (() => { - try { - return dom - .querySelector("div.Hi31 > div._2tcI") - .textContent.trim(); - } catch (error) { - return "No features."; - } - })(); - - // forming the footer of the embed - const footer = `${subCount} subcribers - ${featureCount}`; - - // creating a nice embed - const embed = new EmbedBuilder() - .setColor(0x0099ff) - .setTitle(username) - .setURL(url) - .setThumbnail(icon) - .setDescription(description) - .setFooter({ text: footer }); - - // editing the response - return interaction.editReply({ embeds: [embed] }); - } - } - }); -} - -const User = { - data: new SlashCommandBuilder() - .setName("user") - .setDescription( - "Embeds the link to a user's profile. (case insensitive)" - ) - .addStringOption(option => { - return option - .setName("name") - .setDescription("The user's name.") - .setRequired(true); - }), - execute: user, -}; - -export default User; diff --git a/db b/db deleted file mode 100755 index eb1d219..0000000 --- a/db +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env bash - -### setup - -# checking for npm -if ! command -v 'npm' &>/dev/null; then - echo "npm not found, npm and nodejs are required for this bot" - exit 1 -fi - -# checking for npx -if ! command -v 'npx' &>/dev/null; then - echo "npx not found, cannot run Prisma which is required for the bot" - exit 1 -fi - -# checking if prisma is intalled -if ! eval "$(npm list | grep 'prisma' &>/dev/null)"; then - echo "prisma is not installed, run 'npm i .' to install all packages for the project" - exit 1 -fi - -# checking for sqlite3 -if ! command -v 'sqlite3' &>/dev/null; then - echo "sqlite3 not found, required to create the database for the bot" - exit 1 -fi - -# vars -DB="main.db" -BACKUPDIR="backups" - -### functions - -# usage function -usage() { - echo "$0 " - echo - echo -e "\tpull\t- calls Prisma to generate a schema from the database" - echo -e "\tinit\t- creates a SQLite3 database ($DB) with prisma/schema.sql" - echo -e "\treset\t- backs up, deletes, recreates the database and calls Prisma" - echo -e "\tbackup\t- creates a backup of the current database" - echo -e "\tclear\t- removes all backups" -} - -# removing all backups -__clear() { - rm -rfv "${BACKUPDIR:?}/" -} - -# backing up the database -backup() { - # creating backup dir if not exists - if [ ! -d "$BACKUPDIR" ]; then - mkdir "$BACKUPDIR" - fi - - # forming name of backup - TARGET="$(cut -d '.' -f 1 <<< $DB).$(date -I'seconds' -u | cut -d '+' -f 1).db" - - # logging - echo "Creating backup database, $TARGET" - - # making copy - cp -v "$DB" "$BACKUPDIR/$TARGET" -} - -# calling prisma to pull & generate a schema from the database -pull() { - # logging - echo "Updating prisma to changes made to the schema" - - # if npx call prisma (this should already be installed) - npx prisma db pull - npx prisma generate -} - -# create the database function -init() { - # making backup if database exists - if [ -f "$DB" ]; then - echo "Not overwriting an existing database" - backup - rm "$DB" - fi - - # creating - cat "prisma/schema.sql" | sqlite3 "$DB" - - # logging - echo "Created $DB" -} - -# checking args -[ $# -eq 0 ] && usage && exit 1 - -# parsing arguments -case "$1" in - b*) - backup - ;; - c*) - __clear - ;; - p*) - pull - ;; - i*) - init - ;; - r*) - backup - rm "$DB" - init - pull - ;; - *) - usage - exit 1 - ;; -esac - -# exit code -exit 0 - diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..902476f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3" + +services: + funnybot: + container_name: "funnybot" + build: . + restart: "unless-stopped" + volumes: + - ./logs/:/app/logs/ + networks: + - funnybot + +networks: + funnybot: + driver: bridge + diff --git a/env.dev.js b/env.dev.js deleted file mode 100644 index c0b961a..0000000 --- a/env.dev.js +++ /dev/null @@ -1,173 +0,0 @@ -// list of handler commands -import { Routes, REST } from "discord.js"; -import config from "./config.json" assert { type: "json" }; -import { isValidiFunnyLink, requiredPermissions } from "./utils/utils.js"; -import extractPost from "./utils/extractpost.js"; -import { pullServerConfig, pullChannels, sha1sum } from "./utils/db.js"; - -function OnReady(client) { - console.log(`Developer mode active, logged in as: ${client.user.tag}. Are you breaking shit?`); - - // for maintenance - client.user.setActivity("Down for maintenance."); - client.user.setStatus("dnd"); -} - -function OnMessageCreate(client) { - return async message => { - // to bullet proof my code - if (process.NODE_ENV === "dev") { - // ignoring messages that aren't from the development server - if (message.guild.id !== config.guildId) return; - } - - // don't react to the bot sending messages - if (message.author.id === client.user.id) return; - - // automatically embed a post if there is a valid ifunny link in it - if (!isValidiFunnyLink(message.content)) return; - - // logging - console.log(`Auto-embedding content from ${message.content}`); - - // querying the prisma db to see if this server has "globalEmbed" enabled - const serverConfig = await pullServerConfig(message.guild.id); - - console.log(serverConfig) - - // if global config is disabled, stop function - if (serverConfig) { - if (serverConfig.globalEmbed === false) { - // global embed is disabled, aborting - console.log( - `Server ${message.guild.id} has global embed disabled, checking for a saved channel.` - ); - - // if the interaction was in a saved channel, allow - const channels = await pullChannels(message.guild.id); - - // there are no saved channels - if (channels && channels.length === 0) { - console.log( - `There are no saved channels for ${message.guild.id}, aborting auto-embed.` - ); - return; - } - - // there are one or more saved channels - console.log( - `${message.guild.id} has one or more saved channels, checking if the interaction's channel is valid.` - ); - - if ( - channels.filter(obj => { - return obj.channel === message.channel.id; - }).length === 0 - ) { - console.log( - `The message was sent in a channel that doesn't have auto-embed enabled, aborting.` - ); - return; - } - - // logging - console.log( - `Allowing auto-embed in channel ${message.channel.id}.` - ); - } - } else { - console.error("WARNING: Could not pull server information during auto-embed, embedding anyways."); - } - - // test for file permissions - if ( - !requiredPermissions.every(perm => { - return message.guild.members.me.permissions.has(perm); - }) - ) { - return message.reply({ - content: "I cannot post images or messages in this channel, update my permissions.", - ephemeral: true, - }); - } - - // extracting the post in the message - return extractPost( - message.content, - resolve => { - message.reply(resolve); - }, - error => { - console.log(`There was an error during auto embed: ${error}`); - }, - serverConfig ? serverConfig.exportFormat : "png" - ); - }; -} - -function OnInteractionCreate(client) { - return async interaction => { - if (!interaction.isChatInputCommand()) return; - - // filtering through the commands to find the requested command - const [command] = client.commands.filter(command => { - return command.data.name === interaction.commandName; - }); - - // just in case the command that was called is undefined - if (command === undefined) { - await interaction.reply({ - content: `You somehow tried to call an undefined command "${interaction.commandName}" Good job!`, - ephemeral: true, - }); - console.log( - `${interaction.member.user.username} executed an undefined command ${interaction.commandName}.` - ); - return; - } - - // trying to execute the command - try { - // logging - console.log( - `The command ${interaction.commandName} was executed by ${interaction.member.user.username}. ` + - `With args:` - ); - interaction.options.data.map(U => { - console.log(U); - }); - - // executing the command - await command.execute(interaction); - } catch (error) { - // something went wrong executing this command - console.error("An error occured:\n", error); - await interaction.reply({ - content: `An error occurred using ${interaction.commandName}`, - ephemeral: true, - }); - } - }; -} - -async function OnUploadCommands(payload) { - // establishing a rest connection to discord - const rest = new REST({ version: "10" }).setToken(config.token); - - return await rest.put( - Routes.applicationGuildCommands(config.clientId, config.guildId), - { body: payload } - ); -} - -// a collection of commands for a nice and clean export -function DevelopmentHandler(client) { - return { - ClientReady: OnReady, - MessageCreate: OnMessageCreate(client), - InteractionCreate: OnInteractionCreate(client), - Put: OnUploadCommands, - }; -} - -export default DevelopmentHandler; diff --git a/env.prod.js b/env.prod.js deleted file mode 100644 index c46cb3b..0000000 --- a/env.prod.js +++ /dev/null @@ -1,171 +0,0 @@ -// list of handler commands -import { Routes, REST } from "discord.js"; -import config from "./config.json" assert { type: "json" }; -import { isValidiFunnyLink, requiredPermissions } from "./utils/utils.js"; -import extractPost from "./utils/extractpost.js"; -import { pullServerConfig, pullChannels } from "./utils/db.js"; - -function OnReady(client) { - console.log(`Ready! Logged in as ${client.user.tag}`); - - // for maintenance - client.user.setActivity("Checkout the /about command!"); -} - -function OnMessageCreate(client) { - return async message => { - // to bullet proof my code - if (process.NODE_ENV === "dev") { - // ignoring messages that aren't from the development server - if (message.guild.id !== config.guildId) return; - } - - // don't react to the bot sending messages - if (message.author.id === client.user.id) return; - - // automatically embed a post if there is a valid ifunny link in it - if (!isValidiFunnyLink(message.content)) return; - - // logging - console.log(`Auto-embedding content from ${message.content}`); - - // querying the prisma db to see if this server has "globalEmbed" enabled - const serverConfig = await pullServerConfig(message.guild.id); - - console.log(serverConfig) - - // if global config is disabled, stop function - if (serverConfig) { - if (serverConfig.globalEmbed === false) { - // global embed is disabled, aborting - console.log( - `Server ${message.guild.id} has global embed disabled, checking for a saved channel.` - ); - - // if the interaction was in a saved channel, allow - const channels = await pullChannels(message.guild.id); - - // there are no saved channels - if (channels && channels.length === 0) { - console.log( - `There are no saved channels for ${message.guild.id}, aborting auto-embed.` - ); - return; - } - - // there are one or more saved channels - console.log( - `${message.guild.id} has one or more saved channels, checking if the interaction's channel is valid.` - ); - - if ( - channels.filter(obj => { - return obj.channel === message.channel.id; - }).length === 0 - ) { - console.log( - `The message was sent in a channel that doesn't have auto-embed enabled, aborting.` - ); - return; - } - - // logging - console.log( - `Allowing auto-embed in channel ${message.channel.id}.` - ); - } - } else { - console.error("WARNING: Could not pull server information during auto-embed, embedding anyways."); - } - - // test for file permissions - if ( - !requiredPermissions.every(perm => { - return message.guild.members.me.permissions.has(perm); - }) - ) { - return message.reply({ - content: "I cannot post images or messages in this channel, update my permissions.", - ephemeral: true, - }); - } - - // extracting the post in the message - return extractPost( - message.content, - resolve => { - message.reply(resolve); - }, - error => { - console.log(`There was an error during auto embed: ${error}`); - }, - serverConfig ? serverConfig.exportFormat : "png" - ); - }; -} - -function OnInteractionCreate(client) { - return async interaction => { - if (!interaction.isChatInputCommand()) return; - - // filtering through the commands to find the requested command - const [command] = client.commands.filter(command => { - return command.data.name === interaction.commandName; - }); - - // just in case the command that was called is undefined - if (command === undefined) { - await interaction.reply({ - content: `You somehow tried to call an undefined command "${interaction.commandName}" Good job!`, - ephemeral: true, - }); - console.log( - `${interaction.member.user.username} executed an undefined command ${interaction.commandName}.` - ); - return; - } - - // trying to execute the command - try { - // logging - console.log( - `The command ${interaction.commandName} was executed by ${interaction.member.user.username}. ` + - `With args:` - ); - interaction.options.data.map(U => { - console.log(U); - }); - - // executing the command - await command.execute(interaction); - } catch (error) { - // something went wrong executing this command - console.error(error); - await interaction.reply({ - content: `An error occurred using ${interaction.commandName}`, - ephemeral: true, - }); - } - }; -} - -async function OnUploadCommands(payload) { - // establishing a rest connection to discord - const rest = new REST({ version: "10" }).setToken(config.token); - - return await rest.put(Routes.applicationCommands(config.clientId), { - body: payload, - }); -} - -// a collection of commands for a nice and clean export -function ProductionHandler(client) { - return { - ClientReady: OnReady, - MessageCreate: OnMessageCreate(client), - InteractionCreate: OnInteractionCreate(client), - Put: OnUploadCommands, - }; -} - -export default ProductionHandler; diff --git a/ifunnybot/__init__.py b/ifunnybot/__init__.py new file mode 100644 index 0000000..4108d82 --- /dev/null +++ b/ifunnybot/__init__.py @@ -0,0 +1,5 @@ +from .core import * +from .data import * +from .types import * +from .utils import * + diff --git a/ifunnybot/core/__init__.py b/ifunnybot/core/__init__.py new file mode 100644 index 0000000..ce5f4bb --- /dev/null +++ b/ifunnybot/core/__init__.py @@ -0,0 +1,5 @@ +from .bot import * +from .get_post import * +from .get_profile import * +from .logging import * + diff --git a/ifunnybot/core/bot.py b/ifunnybot/core/bot.py new file mode 100644 index 0000000..8dda808 --- /dev/null +++ b/ifunnybot/core/bot.py @@ -0,0 +1,35 @@ +""" +This file contains the bot object. +""" + +import logging +import discord +from discord import app_commands + +class FunnyBot(discord.Client): + def __init__(self, *, intents: discord.Intents, guildId: int = 0, logger: logging.Logger) -> None: + super().__init__(intents=intents) + + # saving the reference to the logger + self._log = logger + + # the tree variable holds slash commands + self.tree = app_commands.CommandTree(self) + + # saving the target guild id + if guildId: + self._log.info("Starting bot in development mode") + self._guild = discord.Object(id=guildId) + else: + self._guild = None + + async def setup_hook(self) -> None: + # dispatching commands + if self._guild: + self._log.info(f"Copying commands to development server.") + self.tree.copy_global_to(guild=self._guild) + await self.tree.sync(guild=self._guild) + + # logging + self._log.info("Set up self") + diff --git a/ifunnybot/core/get_post.py b/ifunnybot/core/get_post.py new file mode 100644 index 0000000..6954eb5 --- /dev/null +++ b/ifunnybot/core/get_post.py @@ -0,0 +1,109 @@ +import requests +from typing import Optional +from bs4 import BeautifulSoup as soup + +from ifunnybot.core.logging import Logger +from ifunnybot.data.headers import Headers +from ifunnybot.types.post import Post +from ifunnybot.types.post_type import PostType +from ifunnybot.utils.urls import get_datatype +from ifunnybot.utils.html import html_selectors + +def get_post(url: str, _headers=Headers) -> Optional[Post]: + + # getting the post, assuming that it is a proper link + response = None + try: + response = requests.get(url, headers=_headers, allow_redirects=False) + except Exception as e: + Logger.error(f"There was an exception making a GET request to {url}: {e}") + return None + + # testing the response code + if response.status_code != requests.codes.ok: + # do something here + Logger.error(f"There was an error making the HTTP request to {url}") + return + + # transforming the response into something useable + dom = soup(response.text, "html.parser") + if not dom.css: + Logger.fatal(f"There was an internal error with BeautifulSoup, cannot use CSS selectors") + return + + ## the response was OK, now scraping information + info = Post() + + # saving the url + info.url = url + + # getting the datatype of the url + datatype = get_datatype(url) + if not datatype: + # do something here + Logger.error(f"Could not find any content at {url}.") + return + + # logging + Logger.info(f"Found {datatype} at {url}") + + # setting the data type of the post + info.post_type = datatype + + # the targeted HTML element + element = None + + ## getting the content of the post + if info.post_type != PostType.MEME: + # getting selectors & attributes + selector, attribute = html_selectors[info.post_type] + + # using BeautifulSoup to get what I want + element = dom.css.select(selector) + if not element: + Logger.error(f"Could not grab the content from {url}") + return + + info.content_url = element[0].get(attribute) + else: + # need to iterate through all the selectors to find the proper + # one because ifunny lol + for _type in html_selectors.keys(): + ## searching for the right one + + # getting selectors & attributes + selector, attribute = html_selectors[_type] + + # using BeautifulSoup to get what I want + element = dom.css.select(selector) + if not element: + Logger.debug(f"Post at {url} is not {_type}") + continue + + + # breaking early because we found the correct selector + Logger.debug(f"Post at {url} is {_type}") + info.post_type = _type + info.content_url = element[0].get(attribute) + + break + + ## scraping other info about the post + info.username = dom.css.select("div._9JPE > a.WiQc > span.IfB6")[0].text.replace(" ", "") + info.icon_url = dom.css.select("div._9JPE > a.WiQc > img.dLxH")[0].get("data-src") + info.likes = dom.css.select("div._9JPE > button.Cgfc > span.Y2eM > span")[0].text + info.comments = dom.css.select("div._9JPE > button.Cgfc > span.Y2eM > span")[1].text + + # logging + Logger.info(f"Retrieved from {url}: {info}") + + # getting the content of the post + info.retrieve_content() + + # if the post is an image, crop it + if info.post_type == PostType.PICTURE: + info.crop_watermark() + + # returning the collected information + return info + diff --git a/ifunnybot/core/get_profile.py b/ifunnybot/core/get_profile.py new file mode 100644 index 0000000..f8dda7e --- /dev/null +++ b/ifunnybot/core/get_profile.py @@ -0,0 +1,79 @@ +import requests +from typing import Optional +from bs4 import BeautifulSoup as soup + +from ifunnybot.core.logging import Logger +from ifunnybot.data import Headers +from ifunnybot.types import Profile +from ifunnybot.utils import ifunny_no_pfp + +def get_profile(username: str, _headers=Headers) -> Optional[Profile]: + + # creating the url of the user + url = f"https://ifunny.co/user/{username}" + + # creating the profile object + profile = Profile(username=username) + + # getting the post, assuming that it is a proper link + response = None + try: + response = requests.get(url, headers=_headers, allow_redirects=False) + except Exception as e: + Logger.error(f"There was an exception making a GET request to {url}: {e}") + return None + + # testing the response code + if response.status_code != requests.codes.ok: + # do something here + Logger.error(f"No such user at {url} exists.") + return None + + # transforming the response into something useable + dom = soup(response.text, "html.parser") + if not dom.css: + Logger.fatal(f"There was an internal error with BeautifulSoup, cannot use CSS selectors") + return None + + ## scraping information + + # getting the profile picture + if icon_el := dom.css.select("span._4nz- > span.F6b- > img.k3q9"): + # the user has a pfp + profile.icon_url = icon_el[0].get("src") + else: + # the user does not have a pfp + Logger.info(f"User {username} doesn't have a pfp.") + profile.icon_url = ifunny_no_pfp + + # getting the description + if description_el := dom.css.select("div.Hi31 > div.vjX5"): + # the user has a description + profile.description = description_el[0].text.strip() + else: + profile.description = "No description." + + # getting the subscriber count + if subscriber_el := dom.css.select("div.Hi31 > div[class='g+J7'] > a.sWk7"): + profile.subscribers = subscriber_el[0].text.strip().split(" ")[0] + else: + profile.subscribers = "No subscribers." + + # getting the subscriptions + if subscription_el := dom.css.select("div.Hi31 > div[class='g+J7'] > a.sWk7"): + profile.subscriptions = subscription_el[1].text.strip().split(" ")[0] + else: + profile.subscriptions = "No subscriptions." + + # getting the features + if features_el := dom.css.select("div.Hi31 > div._2tcI"): + profile.features = features_el[0].text.strip().split(" ")[0] + else: + profile.features = "No features." + + # logging + Logger.info(f"Retrieved from {url}: {profile}") + + # returning the collected information + return profile + diff --git a/ifunnybot/core/logging.py b/ifunnybot/core/logging.py new file mode 100644 index 0000000..26ec0df --- /dev/null +++ b/ifunnybot/core/logging.py @@ -0,0 +1,38 @@ +import logging +import sys +from datetime import datetime + +class HighPassFilter(logging.Filter): + def __init__(self, level): + self.level = level + + def filter(self, record): + return record.levelno >= self.level + +# filename +filename = f"logs/{int(datetime.utcnow().timestamp())}-funnybot.log" + +# creating a Logger +Logger = logging.getLogger("FunnyBot") +Logger.setLevel(logging.DEBUG) +Logger.addFilter(HighPassFilter(logging.DEBUG)) + +# creating formatter +fmt = logging.Formatter("%(asctime)s - %(name)s - %(levelname)-7s: %(message)s") + +# stdout handler +std = logging.StreamHandler(sys.stdout) +std.setFormatter(fmt) +std.setLevel(logging.ERROR) +std.addFilter(HighPassFilter(logging.INFO)) + +# file handler +fd = logging.FileHandler(filename, mode="w") +fd.setFormatter(fmt) +fd.setLevel(logging.DEBUG) +fd.addFilter(HighPassFilter(logging.DEBUG)) + +# adding handlers +Logger.addHandler(std) +Logger.addHandler(fd) + diff --git a/ifunnybot/data/__init__.py b/ifunnybot/data/__init__.py new file mode 100644 index 0000000..9c7a22a --- /dev/null +++ b/ifunnybot/data/__init__.py @@ -0,0 +1,2 @@ +from .headers import * + diff --git a/ifunnybot/data/headers.py b/ifunnybot/data/headers.py new file mode 100644 index 0000000..020ad3f --- /dev/null +++ b/ifunnybot/data/headers.py @@ -0,0 +1,7 @@ +import pickle + +# reading in headers +Headers = None +with open("ifunnybot/data/headers.pickle", "rb") as fd: + Headers = pickle.load(fd) + diff --git a/ifunnybot/types/__init__.py b/ifunnybot/types/__init__.py new file mode 100644 index 0000000..ec94af3 --- /dev/null +++ b/ifunnybot/types/__init__.py @@ -0,0 +1,4 @@ +from .post import * +from .post_type import * +from .profile import * + diff --git a/ifunnybot/types/post.py b/ifunnybot/types/post.py new file mode 100644 index 0000000..8bf8b9b --- /dev/null +++ b/ifunnybot/types/post.py @@ -0,0 +1,144 @@ +import io + +from PIL import Image, ImageOps + +from ifunnybot.core.logging import Logger +from ifunnybot.types.post_type import PostType +from ifunnybot.utils.image import retrieve_content + +class Post(object): + def __init__(self, likes: str = "", comments: str = "", username: str = "", + url: str = "", post_type: PostType = PostType.MEME, + content_url: str = "", icon_url: str = ""): + + # saving fields + self._likes: str = likes + self._comments: str = comments + self._username: str = username + self._url: str = url + self._post_type: PostType = post_type + self._content_url: str = content_url + self._icon_url: str = icon_url + self._content: io.BytesIO = io.BytesIO() + + def __repr__(self) -> str: + return f"" + + def retrieve_content(self): + """Retrieves the content located at `self.content_url`.""" + + # logging + Logger.info(f"Retrieving content from CDN: {self._content_url}") + + # saving the response as a byte array + if (_buf := retrieve_content(self._content_url)): + self._content = _buf + else: + Logger.error(f"Failed to retrieve content from {self._content_url}.") + + def crop_watermark(self): + """Removes the iFunny watermark from images.""" + + # logging + Logger.info(f"Cropping watermark.") + + # checking the post type first + if self._post_type != PostType.PICTURE: + Logger.warn(f"Tried to crop something that wasn't an image: {self._post_type}") + return + + # turning bytes into an image + image = Image.open(self._content) + + # cropping & the image + cropped = ImageOps.crop(image, (0, 0, 0, 20)) + + # saving the image back into the bytes buffer + # I hate this hack + self._content.close() + del self._content + self._content = io.BytesIO() + cropped.save(self._content, format="PNG") + + # cleanup + image.close() + cropped.close() + del image, cropped + + # seeking back to the beginning because this fixes things + self._content.seek(0) + + @property + def content(self) -> io.BytesIO: + return self._content + + @property + def likes(self) -> str: + """Returns the number of likes the post has at the time that the post was retrieved.""" + return self._likes + + @likes.setter + def likes(self, value: str): + """Sets the number of likes to `value`""" + self._likes = str(value) + + @property + def comments(self) -> str: + """Returns the number of comments the post has at the time that the post was retrieved.""" + return self._comments + + @comments.setter + def comments(self, value: str): + """Sets the number of comments to `value`""" + self._comments = str(value) + + @property + def username(self) -> str: + """Returns the username of the poster.""" + return self._username + + @username.setter + def username(self, value: str): + """Sets the number of username to `value`""" + self._username = str(value) + + @property + def url(self) -> str: + """Returns the url of post.""" + return self._url + + @url.setter + def url(self, value: str): + """Sets the number of url to `value`""" + self._url = str(value) + + @property + def post_type(self) -> PostType: + """Returns the type of post.""" + return self._post_type + + @post_type.setter + def post_type(self, value: PostType): + """Sets the number of post_type to `value`""" + self._post_type = value + + @property + def content_url(self) -> str: + """Returns the content_url of post.""" + return self._content_url + + @content_url.setter + def content_url(self, value: str): + """Sets the number of content_url to `value`""" + self._content_url = str(value) + + @property + def icon_url(self) -> str: + """Returns the icon_url of post.""" + return self._icon_url + + @icon_url.setter + def icon_url(self, value: str): + """Sets the number of icon_url to `value`""" + self._icon_url = str(value) + diff --git a/ifunnybot/types/post_type.py b/ifunnybot/types/post_type.py new file mode 100644 index 0000000..cbededa --- /dev/null +++ b/ifunnybot/types/post_type.py @@ -0,0 +1,35 @@ +from enum import Enum + +class PostType(Enum): + PICTURE = 0 + VIDEO = 1 + GIF = 2 + MEME = 3 + + @staticmethod + def value_of(string: str): + match string: + case "picture": + return PostType.PICTURE + case "video": + return PostType.VIDEO + case "gif": + return PostType.GIF + case "meme": + return PostType.MEME + case _: + return None + + def __str__(self): + match self.value: + case 0: + return "picture" + case 1: + return "video" + case 2: + return "gif" + case 3: + return "meme" + case _: + raise TypeError(f"Tried to convert invalid enum value to string: {self.value}") + diff --git a/ifunnybot/types/profile.py b/ifunnybot/types/profile.py new file mode 100644 index 0000000..5bfde95 --- /dev/null +++ b/ifunnybot/types/profile.py @@ -0,0 +1,93 @@ +import io +from typing import Optional + +from ifunnybot.utils.image import retrieve_content, convert_image_to_png + +class Profile(object): + def __init__(self, username: str = "", icon_url: str = "", subscribers: str = "", + subscriptions: str = "", features: str = "", description: str = ""): + + # saving fields + self._username: str = username + self._icon_url: str = icon_url + self._subscribers: str = subscribers + self._subscriptions: str = subscriptions + self._features: str = features + self._description: str = description + self._profile_url: str = f"https://ifunny.co/user/{self._username}" + + def __repr__(self) -> str: + return f"" + + def retrieve_icon(self) -> Optional[io.BytesIO]: + """Retrieves the icon of the user.""" + if not (_bytes := retrieve_content(self._icon_url)): + return None + return convert_image_to_png(_bytes) + + @property + def username(self) -> str: + """Returns the username.""" + return self._username + + @username.setter + def username(self, value: str): + """Sets the number of username to `value`""" + self._username = str(value) + self._profile_url = f"https://ifunny.co/user/{value}" + + @property + def profile_url(self) -> str: + """Returns the URL of the profile.""" + return self._profile_url + + @property + def subscribers(self) -> str: + """Returns the number of subscribers the user has.""" + return self._subscribers + + @subscribers.setter + def subscribers(self, value: str): + """Sets the number of subscribers to `value`""" + self._subscribers = str(value) + + @property + def subscriptions(self) -> str: + """Returns the number of subscriptions the user has.""" + return self._subscriptions + + @subscriptions.setter + def subscriptions(self, value: str): + """Sets the number of subscriptions to `value`""" + self._subscriptions = str(value) + + @property + def features(self) -> str: + """Returns the number of features the user has.""" + return self._features + + @features.setter + def features(self, value: str): + """Sets the number of features to `value`""" + self._features = str(value) + + @property + def icon_url(self) -> str: + """Returns the URL of the profile picture of the user.""" + return self._icon_url + + @icon_url.setter + def icon_url(self, value: str): + """Sets the number of icon_url to `value`""" + self._icon_url = str(value) + + @property + def description(self) -> str: + """Returns the user's description.""" + return self._description + + @description.setter + def description(self, value: str): + """Sets the number of description to `value`""" + self._description = str(value) + diff --git a/ifunnybot/utils/__init__.py b/ifunnybot/utils/__init__.py new file mode 100644 index 0000000..35c1f52 --- /dev/null +++ b/ifunnybot/utils/__init__.py @@ -0,0 +1,5 @@ +from .urls import * +from .html import * +from .utils import * +from .image import * + diff --git a/ifunnybot/utils/html.py b/ifunnybot/utils/html.py new file mode 100644 index 0000000..512befb --- /dev/null +++ b/ifunnybot/utils/html.py @@ -0,0 +1,9 @@ +from ifunnybot.types.post_type import PostType + +# CSS selectors for the content of the post +html_selectors = { + PostType.PICTURE: ["div._3ZEF > img", "src"], + PostType.VIDEO: ["div._3ZEF > div > video", "data-src"], + PostType.GIF: ['head > link[as="image"]', "href"] + } + diff --git a/ifunnybot/utils/image.py b/ifunnybot/utils/image.py new file mode 100644 index 0000000..6018fcf --- /dev/null +++ b/ifunnybot/utils/image.py @@ -0,0 +1,40 @@ +import io +from typing import Optional + +import requests +from PIL import Image + +from ifunnybot.core.logging import Logger + +def retrieve_content(url: str) -> Optional[io.BytesIO]: + # getting the post, assuming that it is a proper link + response = None + try: + response = requests.get(url, allow_redirects=False) + except Exception: + return None + + buffer = io.BytesIO(response.content) + + # logging again + Logger.debug(f"Response size: {len(response.content)}, saved size: {buffer.getbuffer().nbytes}") + + # saving the response as a byte array + return buffer + +def convert_image_to_png(_bytes: io.BytesIO) -> io.BytesIO: + # new buffer + nbuf = io.BytesIO() + + # turning bytes into an image + image = Image.open(_bytes) + image.save(nbuf, format="PNG") + + # cleanup + _bytes.close() + del _bytes + nbuf.seek(0) + + # returning the new buffer + return nbuf + diff --git a/ifunnybot/utils/urls.py b/ifunnybot/utils/urls.py new file mode 100644 index 0000000..337a2c6 --- /dev/null +++ b/ifunnybot/utils/urls.py @@ -0,0 +1,50 @@ +import re +from typing import Optional, Any + +from ifunnybot.types.post_type import PostType + +# regular expressions +ifunny_url = r"(https:\/\/(br\.)?ifunny.co\/(picture|video|gif|meme)\/[\w|-]+(\?s=cl)?)" +ifunny_datatype = r"(picture|video|gif|meme)" + +# constants +ifunny_no_pfp = "https://play-lh.googleusercontent.com/Wr4GnjKU360bQEFoVimXfi-OlA6To9DkdrQBQ37CMdx1Kx5gRE07MgTDh1o7lAPV1ws" + +## function shorthands + +def get_url(text: str) -> Optional[list[Any]]: + """ + Scrapes the ifunny.co url from the `text` input. + """ + + # using re.search because re.match is fucking stupid + m = re.findall(ifunny_url, text, re.DOTALL) + + # returning a match object + if m: + return list(map(lambda x: x[0], m)) + return m + +def has_url(text: str) -> bool: + """ + Returns true if the text has an ifunny url, false otherwise. + """ + + return get_url(text) != None + +def get_datatype(url: str) -> Optional[PostType]: + """ + Gets the datatype of the post from the iFunny url. + """ + + if not has_url(url): + return None + + # using re.search because re.match is fucking stupid + m = re.search(ifunny_datatype, url) + + # testing for a type + if m: + return PostType.value_of(m.group(0)) + return None + diff --git a/ifunnybot/utils/utils.py b/ifunnybot/utils/utils.py new file mode 100644 index 0000000..46b12b6 --- /dev/null +++ b/ifunnybot/utils/utils.py @@ -0,0 +1,31 @@ +import re +import io +from typing import Optional, TYPE_CHECKING + +import requests + +from ifunnybot.core.logging import Logger + +# dear fucking God never remove this +if TYPE_CHECKING: + from ifunnybot.types.post import Post + +filename_pattern = r"co\/\w+\/([0-9a-f]*)(?:_\d)?\.(\w{3,4})$" + +def sanitize_discord(text: str) -> str: + illegal = ["\\", "*", "_", "|", "~", ">", "`"] + sanitized = text + + # iterating over the illegal chars and escaping them + for char in illegal: + sanitized = sanitized.replace(char, "\\" + char) + + # returning the "sanitized" string + return sanitized + +def create_filename(post: "Post") -> Optional[str]: + if not (match := re.search(filename_pattern, post.content_url)): + return None + + return match.group(1) + diff --git a/main.py b/main.py new file mode 100644 index 0000000..bcfc8c0 --- /dev/null +++ b/main.py @@ -0,0 +1,202 @@ +""" +The main file for the bot. +""" + +import sys + +import discord +from discord import app_commands +from dotenv import dotenv_values + +import ifunnybot as funny +from ifunnybot.core import Logger, FunnyBot +from ifunnybot.utils import sanitize_discord, create_filename + +# loading in config values +config = { + **dotenv_values(".env") +} + +# development server ID +development_server = 0 + +# checking the .env values +if not config["GUILDID"]: + Logger.fatal("Couldn't start bot, missing 'GUILDID' from .env values.") + sys.exit(1) +else: + try: + development_server = int(config["GUILDID"]) + except: + development_server = 0 + +if not config["TOKEN"]: + Logger.fatal("Couldn't start bot, missing 'TOKEN' from .env values.") + sys.exit(1) + + +# intents +intents = discord.Intents.default() +intents.message_content = True + +# creating the client +client = FunnyBot(intents=intents, logger=Logger, guildId=development_server) + +@client.event +async def on_ready(): + Logger.info(f"Logged in as: '{client.user}'") + await client.change_presence(activity=discord.Activity(type=discord.ActivityType.playing, name="Down for maintenance."), status=discord.Status.do_not_disturb) + + +@client.event +async def on_message(message: discord.message.Message): + # guard clauses + if message.author == client.user: # not replying to myself + return + if message.author.bot: # not replying to any messages from other bots + return + # ignoring messages that do not have an ifunny link + if not funny.has_url(message.content): + return + + # testing if the interaction contains an iFunny link + if not (urls := funny.get_url(message.content)): + return + + # there might be multiple urls + for url in urls: + # got a valid link, getting the post information + if not (post := funny.get_post(url)): + Logger.error(f"There was an error extracting information from {message.content}") + await message.reply(content=f"There was an internal error embedding the post from {message.content}", silent=True) + + # looping + continue + + # creating an embed + embed = discord.Embed(title=f"Post by {sanitize_discord(post.username)}", + url=post.url, + description=f"{post.likes} likes.\t{post.comments} comments.") + embed.set_author(name="", icon_url=post.icon_url) + + # create the filename + filename = create_filename(post) + + # forming the file extension + extension = "" + match post.post_type: + case funny.PostType.PICTURE: + extension = "png" + case funny.PostType.VIDEO: + extension = "mp4" + case funny.PostType.GIF: + extension = "gif" + case _: + # this should never happen + Logger.error(f"Tried to make extension of invalid post type: {post.post_type}") + + # creating the file object + file = discord.File(post.content, filename=f"{filename}.{extension}") + + # logging + Logger.info(f"Replying to interaction with '{filename}.{extension}'") + + await message.reply(embed=embed, file=file) + + +@client.tree.command(name="icon", description="Retrieves a user's profile picture. (case insensitive)") +@app_commands.describe(user="The user's name.") +async def icon(interaction: discord.Interaction, user: str): + # deferring the reply + await interaction.response.defer(thinking=True) + + # getting the user's profile + if not (profile := funny.get_profile(user)): + return await interaction.followup.send(content=f"Could not find user '{user}' (although they may exist)") + else: + # getting the icon of the user + if not (image := profile.retrieve_icon()): + return await interaction.followup.send(content=f"An error occurred getting '{user}'s profile picture.") + + # user has icon, returning it + file = discord.File(image, filename=f"{profile.username}_pfp.png") + + # returning the image + return await interaction.followup.send(file=file) + +@client.tree.command(name="user", description="Embeds the link to a user's profile. (case insensitive)") +@app_commands.describe(user="The user's name.") +async def user(interaction: discord.Interaction, user: str): + # deferring the reply + await interaction.response.defer(thinking=True) + + # getting the user's profile + if not (profile := funny.get_profile(user)): + return await interaction.followup.send(content=f"Could not find user '{user}' (although they may exist)") + else: + # creating an embed for the profile + embed = discord.Embed(description=profile.description) + + # adding info + embed.set_author(name=sanitize_discord(profile.username), + url=profile.profile_url) + embed.set_thumbnail(url=profile.icon_url) + embed.set_footer(text=f"{profile.subscribers} subscribers, {profile.subscriptions} subscriptions, {profile.features} features") + + return await interaction.followup.send(embed=embed) + + +@client.tree.command(name="post", description="Posts a video/image from iFunny.") +@app_commands.describe(link="An iFunny.co link e.g., ifunny.co/video/...") +async def post(interaction: discord.Interaction, link: str): + # deferring the reply + await interaction.response.defer(thinking=True) + + # testing if the interaction contains an iFunny link + if not (url := funny.get_url(link)): + # logging & returning + Logger.info(f"Received an improper link: {link}") + return await interaction.followup.send(content=f"The url {link}, isn't a proper iFunny url.") + + # simple hack, my precious + url = url[0] + + # got a valid link, getting the post information + if not (post := funny.get_post(url)): + Logger.error(f"There was an error extracting information from {link}") + return await interaction.followup.send(content=f"There was an internal error embedding the post from {link}") + + # creating an embed + embed = discord.Embed(title=f"Post by {sanitize_discord(post.username)}", + url=post.url, + description=f"{post.likes} likes.\t{post.comments} comments.") + embed.set_author(name="", icon_url=post.icon_url) + + # create the filename + filename = create_filename(post) + + # forming the file extension + extension = "" + match post.post_type: + case funny.PostType.PICTURE: + extension = "png" + case funny.PostType.VIDEO: + extension = "mp4" + case funny.PostType.GIF: + extension = "gif" + case _: + # this should never happen + Logger.error(f"Tried to make extension of invalid post type: {post.post_type}") + + # creating the file object + file = discord.File(post.content, filename=f"{filename}.{extension}") + + # logging + Logger.info(f"Replying to interaction with '{filename}.{extension}'") + + return await interaction.followup.send(embed=embed, file=file) + + +# main loop +client.run(config["TOKEN"]) + diff --git a/package.json b/package.json deleted file mode 100644 index 2b51cc6..0000000 --- a/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "iFunny-Bot", - "version": "3.0.0", - "description": "A discord bot that properly embeds iFunny.co links", - "main": "app.js", - "type": "module", - "scripts": { - "start": "NODE_ENV=prod node app.js", - "dev": "NODE_ENV=dev nodemon app.js" - }, - "author": "bruhulance", - "license": "GPL3", - "dependencies": { - "@prisma/client": "^5.0.0", - "data-uri-to-buffer": "^4.0.0", - "discord.js": "^14.7.1", - "dotenv": "^16.0.0", - "jsdom": "^20.0.3", - "nan": "^2.17.0", - "npm": "^9.6.2", - "prisma": "^5.0.0", - "request": "^2.88.2", - "sharp": "^0.32.0" - }, - "devDependencies": { - "nodemon": "^2.0.20" - } -} diff --git a/prisma/schema.prisma b/prisma/schema.prisma deleted file mode 100644 index bb86971..0000000 --- a/prisma/schema.prisma +++ /dev/null @@ -1,22 +0,0 @@ -generator client { - provider = "prisma-client-js" -} - -datasource db { - provider = "sqlite" - url = "file:./../main.db" -} - -model Channel { - channel String @id - server String - Config Config @relation(fields: [server], references: [server], onDelete: NoAction, onUpdate: NoAction) -} - -model Config { - server String @id - globalEmbed Boolean @default(true) - role String @default("") - exportFormat String @default("png") - Channel Channel[] -} diff --git a/prisma/schema.sql b/prisma/schema.sql deleted file mode 100644 index acbbba6..0000000 --- a/prisma/schema.sql +++ /dev/null @@ -1,14 +0,0 @@ -create table if not exists "Config" ( - server varchar(40) primary key not null, - globalEmbed boolean not null default true, - role varchar(19) not null default "", - exportFormat varchar(10) not null default "png" -); - -create table if not exists "Channel" ( - channel varchar(19) primary key not null, - server varchar(40) not null, - foreign key (server) references Config(server), - unique(channel) -); - diff --git a/readme.md b/readme.md index 952964a..38b08ec 100644 --- a/readme.md +++ b/readme.md @@ -15,24 +15,20 @@ So I made this bot with this main feature in mind and a couple other features. > This bot can run on x86, x86-64 and arm (I've only run this on a raspberry pi 4) -This bot requires `npm` and `node.js` to run. +This bot was written in Python and is containerized in Docker. -1. Clone the repository to your chosen location. -1. Run `npm install` to install all the packages. -1. After npm finishes, then run either `npm run dev` or `npm start` to start the bot. +### Bot Configuration You will also need your own bot token along with the bot's client ID -which means you will need to create a `config.json` file to store it. +which means you will need to create a `.env` file to store it. Finally, for development purposes, you should create a private (I don't really care if it's public) server to test the bot on. See example below: -```json -{ - "token": "your token goes here", - "clientId": "your application id goes here" - "guildId": "the id of your development server goes here" -} +```env +TOKEN=YOUR_BOT_TOKEN +CLIENTID=YOUR_APPLICATION_ID +GUILDID=YOUR_DEV_GUILD_ID ``` ## Server Configuration diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ae5dd32 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +aiohttp==3.9.3 +aiosignal==1.3.1 +attrs==23.2.0 +beautifulsoup4==4.12.3 +certifi==2024.2.2 +charset-normalizer==3.3.2 +discord.py==2.3.2 +frozenlist==1.4.1 +idna==3.6 +multidict==6.0.5 +pillow==10.2.0 +python-dotenv==1.0.1 +requests==2.31.0 +soupsieve==2.5 +urllib3==2.2.1 +yarl==1.9.4 diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..9d7296f --- /dev/null +++ b/setup.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +# create venv +python -m venv venv --prompt="funny-bot" + +# activate venv +source ./venv/bin/activate + +# install requirements +python -m pip install -r requirements.txt + diff --git a/utils/db.js b/utils/db.js deleted file mode 100644 index e76a6a9..0000000 --- a/utils/db.js +++ /dev/null @@ -1,203 +0,0 @@ -import { PrismaClient } from "@prisma/client"; -import Crypto from "node:crypto"; - -// the connection to the database -const prisma = (_ => { - try { - const __prisma = new PrismaClient(); - return __prisma - } catch (error) { - console.error("Could not instantiate the prisma client."); - return undefined - } -})(); - -// returns the sha1 digest of some input -const sha1sum = input => { - return Crypto.createHash("sha1").update(input).digest("hex"); -}; - -// a simple shorthand to log error -function prismaErrorHandler(reason) { - if (reason.code === "P2021") { - console.error("WARNING: RUNNING BOT WITHOUT LIVE DATABASE\n", reason); - } else { - console.error("WARNING: UNKNOWN ERROR\n", reason); - } - return undefined; -} - -/** - * this function pulls all of the channel ids that are associated with the server - * @param {String} id the id of the server - * @returns a list of channels - */ -async function pullChannels(id) { - // early aborting - if (!prisma) { - return prismaErrorHandler({ reason: "Couldn't instantiate prisma client." }); - } - - // hashing - const hash = sha1sum(id); - - // logging - console.log(`Fetching all channels from ${hash}.`); - - // pulling all the channels from the channels table - return prisma.channel - .findMany({ - where: { - server: hash, - }, - }) - .then(result => { - return result; - }) - .catch(prismaErrorHandler); -} - -/** - * this function inserts a server of `id` into the database - * @param {String} id the id of the server - * @returns the result of the insert - */ -async function insertServerToDB(id) { - // early aborting - if (!prisma) { - return prismaErrorHandler({ reason: "Couldn't instantiate prisma client." }); - } - - // hashing - const hash = sha1sum(id); - - // logging - console.log(`Inserting the server ${hash} into the database.`); - - // adding the server to the database - return prisma.config - .create({ - data: { - server: hash, - globalEmbed: true, - role: "", - exportFormat: "png", - }, - }) - .then(result => { - return result; - }) - .catch(prismaErrorHandler); -} - -/** - * this function retrieves the configuration of a server without inserting it into the database - * @param {String} id the id of the server - * @param {Boolean} verbose if true, print logging message - * @returns the configuration of the server - */ -async function pullServerConfigNoInsert(id) { - // early aborting - if (!prisma) { - return prismaErrorHandler({ reason: "Couldn't instantiate prisma client." }); - } - - // hashing - const hash = sha1sum(id); - - // pulling the server config without inserting the server into the database - return prisma.config - .findFirst({ - where: { - server: hash, - }, - }) - .then(result => { - return result; - }) - .catch(prismaErrorHandler); -} - -/** - * this function retrieves the configuration of a server and inserts the server - * into the database if not exists - * @param {String} id the id of the server - * @returns the configuration of the server - */ -async function pullServerConfig(id) { - // early aborting - if (!prisma) { - return prismaErrorHandler({ reason: "Couldn't instantiate prisma client." }); - } - - // hashing - const server_hash = sha1sum(id); - - // pulling the server config and inserting the server if not exists - const config = await pullServerConfigNoInsert(id); - - // inserting server into DB if not exists - if (!config) { - // logging - console.log( - `Configuration for server ${server_hash} doesn't exist, creating.` - ); - - // inserting the server into the database - const result = await insertServerToDB(id); - - // if valid insertion - if (result && result.server === server_hash) { - console.log( - `Successfully added server ${server_hash} to the "Config" table.` - ); - } else { - console.error( - `Couldn't add server ${server_hash} to the "Config" table.` - ); - console.error("Reason:", result); - return undefined; - } - - // returning with the new config - return await pullServerConfigNoInsert(id); - } - - // don't need to pull the configuration twice if there's already config available - return config; -} - -async function executeQuery(server, table, action, query) { - // early aborting - if (!prisma) { - return prismaErrorHandler({ reason: "Couldn't instantiate prisma client." }); - } - - // copying the query - const __q = query; - - // passing in the server hash - if (__q.data && !Object.keys(__q.data).includes("server")) { - if (__q.where) { - __q.where.server = sha1sum(server); - } else { - __q.where = { server: sha1sum(server) }; - } - } - // run query against table - return await prisma[table][action](query) - .then(result => { - return result; - }) - .catch(prismaErrorHandler); -} - -export { - prisma, - insertServerToDB, - pullServerConfig, - pullServerConfigNoInsert, - pullChannels, - executeQuery, - sha1sum, -}; diff --git a/utils/extractpost.js b/utils/extractpost.js deleted file mode 100644 index 8c3ab83..0000000 --- a/utils/extractpost.js +++ /dev/null @@ -1,193 +0,0 @@ -import request from "request"; -import { - extractDatatype, - extractiFunnyLink, - getExportFormat, - imageExportFormats, - scrapePostInformation, -} from "../utils/utils.js"; -import { AttachmentBuilder } from "discord.js"; -import { JSDOM } from "jsdom"; -import { EmbedBuilder } from "@discordjs/builders"; -import { chooseRandomPost } from "../utils/misc.js"; -import { request_image } from "./format_image.js"; - -/** - * this function does the heavy lifting by making an HTTP request to the iFunny link - * you need the `resolve` and `err` methods because trying to return out of the `request` block - * just doesn't work, also different use cases - * @param {String} content the content to be parsed - * @param {function(String)} resolve a way to send a message to discord - * @param {function(String)} err a way to send an error message to the user - * @param {String} format the target format of the image - */ -async function extractPost(content, resolve, err, format) { - // extracting the url from the string - const url = extractiFunnyLink(content); - if (url === null) { - // give the user some helpful feedback - const __url = chooseRandomPost(); - - return err(`Invalid url. Sample url: ${__url}`); - } - - // logging - console.log(`Looking in ${url}...`); - - // parsing the datatype from the url - var datatype = extractDatatype(url); - - // trying to get an HTTP request from that url - try { - request(url, (__err, res, _) => { - if (__err) { - console.log("An error occurred", __err); - return err( - "Something went wrong when making the HTTP request." - ); - } - - // if no response - if (!res || res.statusCode > 404) { - const msg = "There was an error contacting iFunny servers."; - console.error(msg); - return err(msg); - } - - // if response wasn't 200, the meme was removed - if (res.statusCode === 404) { - const msg = `Meme at ${url} has been removed.`; - console.log(msg); - return err(msg); - } - - // transforming the paylod into a DOM - const dom = new JSDOM(res.body).window.document; - - // making selectors - const dataset = { - picture: ["div._3ZEF > img", "src"], - video: ["div._3ZEF > div > video", "data-src"], - gif: ['head > link[as="image"]', "href"], - }; - - /// getting post statistics - const payload = scrapePostInformation(dom); - - // creating an embed for information about the post - const embed = new EmbedBuilder() - .setDescription( - `${payload.likes} likes.\t${payload.comments} comments.` - ) - .setTitle(`Post by ${payload.username}`) - .setURL(url) - .setThumbnail(payload.iconUrl); - - // if content is meme, guess the selector - const [__datatype, contentUrl] = (d => { - // storing the element we want to scrape the source of the video/image/gif from - var __el, sel, attr; - var __d = d; - - /// if not meme, then directly assign and not guess - if (d !== "meme") { - // getting the selector and attribute - [sel, attr] = dataset[d]; - - // searching the DOM for a d tag - __el = dom.querySelector(sel); - } else { - // we need to guess the selector that has the content url - for (const key of Object.keys(dataset)) { - // getting the selector and attribute - [sel, attr] = dataset[key]; - - // trying to find the element - __el = dom.querySelector(sel); - - // if null, loop, else return false from func - if (__el !== null) { - // updating the d - __d = key; - break; - } - - // logging - console.log(`Couldn't find ${key} at ${url}.`); - } - } - - // getting the content url of the video/image/gif - const __cu = (__el !== null ? __el.getAttribute(attr) : __el); - - return [__d, __cu]; - })(datatype); - - // updating the datatype - datatype = __datatype; - - // this branches if the program couldn't find the element to scrape the content from - if (!contentUrl) { - const msg = `Couldn't find ${datatype} at ${url}.`; - console.error(msg); - return err(msg); - } - - // logging - console.log(`Found a ${datatype} at ${url}`); - - // getting the filename - const fpattern = /co\/\w+\/([0-9a-f]*)(?:_\d)?\.(\w{3,4})$/; - const match = contentUrl.match(fpattern); - // console.log(match); - const fname = `${match[1]}.${ - match[2] === "jpg" ? format : match[2] - }`; - - // logging - console.log(`Naming the ${datatype} at ${contentUrl} as ${fname}`); - - // auto-cropping the image if it is a picture - if (datatype !== "picture") { - // logging - console.log( - `Returning with the ${datatype} from ${contentUrl}` - ); - - // replying to the user with the url - resolve({ - files: [ - new AttachmentBuilder() - .setName(fname) - .setFile(contentUrl), - ], - embeds: [embed], - }); - } else { - console.log(`Cropping picture found at ${url}...`); - - // returning the cropped image from the url - return request_image(contentUrl, format, true, (file) => { - resolve({ - files: [ - new AttachmentBuilder() - .setName(fname) - .setFile(file), - ], - embeds: [embed], - }); - }, - () => { - console.log("There was an error cropping the image."); - return err("There was an error cropping the image."); - }); - } - }); - } catch (e) { - console.log("ERR: AAAAAAAAAAAAAAAAAAAAAAAA"); // just something to grep for - console.log(e); - return err("Something unknown happened, contact the dev."); - } -} - -export default extractPost; diff --git a/utils/format_image.js b/utils/format_image.js deleted file mode 100644 index 4a572c9..0000000 --- a/utils/format_image.js +++ /dev/null @@ -1,115 +0,0 @@ -import request from "request"; -import sharp from "sharp"; -import { Buffer } from "node:buffer"; - -export function request_image(image_url, format, crop, resolve, err) { - // requesting the image from the source - request( - { uri: image_url, encoding: null }, - (__err2, res2, body) => { - // there was an error in transit - if (__err2) { - console.log( - "Error during HTTP request for image", - __err2 - ); - return err( - "There was an error while getting the image from the source." - ); - } - - // if no response - if (!res2 || res2.statusCode > 404) { - console.error( - `Couldn't contact the CDN (${image_url}) for the image.` - ); - return err( - "There was an error getting the source of the image." - ); - } - - // erroring if the code isn't 200 or a client/server error - if (res2.statusCode === 404) { - console.error( - `The contacting ${image_url} yielded status code ${res2.statusCode}` - ); - return err( - "There was an error getting the source of the image." - ); - } - - // logging - console.log(`Found image at ${image_url}, cropping and exporting as ${format}.`); - - // formatting the image - return format_image(body, format, crop, resolve, err); - } - ); -} - -export function format_image(bytes, format, crop, resolve, error) { - /// using Sharp because Clipper is shit - const image = sharp(Buffer.from(bytes)); - image - .metadata() - .then(meta => { - // logging - console.log("Cropping the image provided."); - - // cropping if enabled - if (crop) { - // resizing the image - image.resize({ - width: meta.width, - height: meta.height - 20, // cropping out the watermark - position: "top", - }); - } - - // logging - console.log( - `Exporting image to ${format} format.` - ); - - // formatting the image - switch (format) { - case "png": - image.png({ - quality: 85, - palette: true, - }); - break; - case "heif": - image.heif({ - quality: 85, - lossless: true, - }); - break; - default: - // defaulting to png - image.png({ - quality: 85, - palette: true, - }); - break; - } - - // returning the image object - return image.toBuffer({ - resolveWithObject: true, - }); - }) - .then(({ data }) => { - return resolve(data); - }) - .catch(__err3 => { - console.log( - "Error during image cropping.", - __err3 - ); - return error( - "There was an error while cropping the image." - ); - }); -} - diff --git a/utils/misc.js b/utils/misc.js deleted file mode 100644 index def5fa6..0000000 --- a/utils/misc.js +++ /dev/null @@ -1,65 +0,0 @@ -export const avatarIcon = - "https://imageproxy.ifunny.co/crop:square,resize:100x,quality:90/user_photos/57459299098918f644f560dc5e73e0c4a10c9495_0.webp"; -export const iFunnyIcon = - "https://play-lh.googleusercontent.com/Wr4GnjKU360bQEFoVimXfi-OlA6To9DkdrQBQ37CMdx1Kx5gRE07MgTDh1o7lAPV1ws"; - -export const footerQuotes = [ - { quote: "Go out and be based.", author: "anonymous" }, - { - quote: "A based man in a cringe place makes all the difference.", - author: "The G-Man", - }, - { - quote: "It's good to be king. Wait, maybe. I think maybe I'm just like a little bizarre little person who walks back and forth. Whatever, you know, but...", - author: "Terry Davis", - }, - { - quote: "All my characters are me. I'm not a good enough actor to become a character. I hear about actors who become the role and I think 'I wonder what that feels like.' Because for me, they're all me.", - author: "Ryan Gosling", - }, - { quote: "I gotta fart", author: "me" }, - { - quote: "Do not compare yourself to others. If you do so, you are insulting yourself.", - author: "anonymous", - }, - { - quote: "For God so loved the world, that he gave his only begotten Son, that whosoever believeth in him should not perish, but have everlasting life.", - author: "John 3:16", - }, - { - quote: "Why should you feel anger at the world? As if the world would notice.", - author: "Marcus Aurelius", - }, - { quote: "What? Rappers say it all the time.", author: "Asuka Soryu" }, - { quote: "Wash your penis.", author: "Jorden Peterson" }, - { quote: "I am a federal agent.", author: "I'm being serious" }, - { - quote: "Imagine being so universally despised that there has to be laws to prevent people from hating you.", - author: "anonymous", - }, - { quote: "He's literally me.", author: "Ryan Gosling" }, -]; - -export function randomQuote() { - // choosing the quote - const quote = footerQuotes[Math.floor(Math.random() * footerQuotes.length)]; - - // joining the fields - return `"${quote.quote}" - ${quote.author}`; -} - -// some handpicked posts -export function chooseRandomPost() { - const posts = [ - "https://ifunny.co/gif/dwi-fondling-his-bulls-balls-as-he-does-his-wife-JOqBtxEfA", - "https://ifunny.co/gif/me-after-i-host-funny-clash-2023-with-the-headlining-IK8N9RwhA", - "https://ifunny.co/video/riggs-and-dwi-listing-out-the-age-range-they-ve-WebODXSjA", - "https://ifunny.co/video/qN0jEBRCA", - "https://ifunny.co/gif/deep-web-intel-zqBDS10aA", - "https://ifunny.co/gif/deep-web-intel-when-you-mention-the-archive-link-or-exo1giA98", - "https://ifunny.co/picture/deep-web-intel-vs-his-fat-gf-s-dad-2mBos8df8", - ]; - - return posts[Math.floor(Math.random() * posts.length)]; -} - diff --git a/utils/utils.js b/utils/utils.js deleted file mode 100644 index c8a0631..0000000 --- a/utils/utils.js +++ /dev/null @@ -1,77 +0,0 @@ -import { PermissionsBitField } from "discord.js"; - -export const ifunnyLinkPattern = - /(https:\/\/ifunny.co\/(picture|video|gif|meme)\/(.*){1,20}(\?.*){0,20})/; -export const ifunnyLinkDatatype = /(picture|video|gif|meme)/; - -export function sanitizeUsername(username) { - // escaping all special characters - var e_username = username; - ["\\", "*", "_", "|", "~", ">", "`"].forEach(char => { - e_username = e_username.replaceAll(char, "\\" + char); - }); - - return e_username; -} - -// a function to scrape information about a post -export function scrapePostInformation(html) { - const payload = {}; - - // this will only fail if iFunny every changes their HTML layout - payload.username = html - .querySelector("div._9JPE > a.WiQc > span.IfB6") - .textContent.replace(" ", ""); - payload.iconUrl = html - .querySelector("div._9JPE > a.WiQc > img.dLxH") - .getAttribute("data-src"); - payload.likes = html.querySelectorAll( - "div._9JPE > button.Cgfc > span.Y2eM > span" - )[0].textContent; - payload.comments = html.querySelectorAll( - "div._9JPE > button.Cgfc > span.Y2eM > span" - )[1].textContent; - - // sanitizing the username's special characters - payload.username = sanitizeUsername(payload.username); - - return payload; -} - -export function isValidiFunnyLink(url) { - // testing if the url is from the expected domain - return url.match(ifunnyLinkPattern) !== null; -} - -export function extractiFunnyLink(url) { - // extracting the ifunny url from the content - const link = url.match(ifunnyLinkPattern); - if (link !== null) { - return link[0]; - } else { - return null; - } -} - -export function extractDatatype(url) { - // getting the datatype of the url - return url.match(ifunnyLinkDatatype)[1]; -} - -export const imageExportFormats = ["png", "heif"]; - -export const requiredPermissions = [ - PermissionsBitField.Flags.EmbedLinks, - PermissionsBitField.Flags.SendMessages, - PermissionsBitField.Flags.AttachFiles, -]; - -export function getExportFormat(format) { - return (f => { - if (imageExportFormats.includes(f)) { - return f; - } - console.error(`There was an invalid image format specified, ${f}`); - return "png"; - })(format); -}