diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 55ff2dff..1317fbfa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -156,11 +156,12 @@ model blacklistedUsers { model userData { id String @id @default(auto()) @map("_id") @db.ObjectId userId String @unique + voteCount Int @default(0) // username is only guarenteed to be set and/or used for blacklisted users username String? locale String? lastVoted DateTime? - voteCount Int @default(0) + banned Boolean? @default(false) blacklistedFrom hubBlacklist[] // if user has seen the welcome message when they first use the network viewedNetworkWelcome Boolean @default(false) diff --git a/src/commands/slash/Staff/ban.ts b/src/commands/slash/Staff/ban.ts new file mode 100644 index 00000000..ac1c0f15 --- /dev/null +++ b/src/commands/slash/Staff/ban.ts @@ -0,0 +1,58 @@ +import { + ApplicationCommandOptionType, + ChatInputCommandInteraction, + RESTPostAPIChatInputApplicationCommandsJSONBody, +} from 'discord.js'; +import BaseCommand from '../../../core/BaseCommand.js'; +import db from '../../../utils/Db.js'; +import { simpleEmbed } from '../../../utils/Utils.js'; +import { emojis } from '../../../utils/Constants.js'; + +export default class Ban extends BaseCommand { + readonly staffOnly = true; + data: RESTPostAPIChatInputApplicationCommandsJSONBody = { + name: 'ban', + description: '🔨 Ban a user from using the bot. (Dev Only)', + options: [ + { + type: ApplicationCommandOptionType.User, + name: 'user', + description: '🔨 The user to ban.', + required: true, + }, + ], + }; + override async execute(interaction: ChatInputCommandInteraction): Promise { + const user = interaction.options.getUser('user', true); + const alreadyBanned = await db.userData.findFirst({ + where: { userId: user.id, banned: true }, + }); + + if (alreadyBanned) { + await interaction.reply({ + embeds: [simpleEmbed(`${emojis.slash} User **${user.username}** is already banned.`)], + }); + return; + } + + await db.userData.upsert({ + where: { userId: user.id }, + create: { + userId: user.id, + username: user.username, + viewedNetworkWelcome: false, + voteCount: 0, + banned: true, + }, + update: { + banned: true, + }, + }); + + await interaction.reply({ + embeds: [ + simpleEmbed(`${emojis.tick} Successfully banned **${user.username}**. They can no longer use the bot.`), + ], + }); + } +} diff --git a/src/commands/slash/Staff/unban.ts b/src/commands/slash/Staff/unban.ts new file mode 100644 index 00000000..1ca907a7 --- /dev/null +++ b/src/commands/slash/Staff/unban.ts @@ -0,0 +1,58 @@ +import { + ApplicationCommandOptionType, + ChatInputCommandInteraction, + RESTPostAPIChatInputApplicationCommandsJSONBody, +} from 'discord.js'; +import BaseCommand from '../../../core/BaseCommand.js'; +import db from '../../../utils/Db.js'; +import { simpleEmbed } from '../../../utils/Utils.js'; +import { emojis } from '../../../utils/Constants.js'; + +export default class Unban extends BaseCommand { + readonly staffOnly = true; + data: RESTPostAPIChatInputApplicationCommandsJSONBody = { + name: 'unban', + description: '🔨 Unban a user from using the bot. (Dev Only)', + options: [ + { + type: ApplicationCommandOptionType.User, + name: 'user', + description: '🔨 The user to unban.', + required: true, + }, + ], + }; + override async execute(interaction: ChatInputCommandInteraction): Promise { + const user = interaction.options.getUser('user', true); + const alreadyBanned = await db.userData.findFirst({ + where: { userId: user.id, banned: true }, + }); + + if (!alreadyBanned) { + await interaction.reply({ + embeds: [simpleEmbed(`${emojis.slash} User **${user.username}** is not banned.`)], + }); + return; + } + + await db.userData.upsert({ + where: { userId: user.id }, + create: { + userId: user.id, + username: user.username, + viewedNetworkWelcome: false, + voteCount: 0, + banned: false, + }, + update: { + banned: false, + }, + }); + + await interaction.reply({ + embeds: [ + simpleEmbed(`${emojis.tick} Successfully banned **${user.username}**. They can no longer use the bot.`), + ], + }); + } +} diff --git a/src/managers/EventManager.ts b/src/managers/EventManager.ts index 451166c9..e958de3b 100644 --- a/src/managers/EventManager.ts +++ b/src/managers/EventManager.ts @@ -33,7 +33,7 @@ import { getAttachmentURL, getUserLocale, handleError, simpleEmbed, wait } from import { addReaction, updateReactions } from '../scripts/reaction/actions.js'; import { checkBlacklists } from '../scripts/reaction/helpers.js'; import { CustomID } from '../utils/CustomID.js'; -import { logServerLeave } from '../utils/HubLogger/JoinLeave.js'; +import { logGuildLeaveToHub } from '../utils/HubLogger/JoinLeave.js'; import { deleteConnections, modifyConnection } from '../utils/ConnectedList.js'; export default abstract class EventManager { @@ -218,20 +218,17 @@ export default abstract class EventManager { Logger.info(`Left ${guild.name} (${guild.id})`); - // find all connections that belong to this guild const connections = await db.connectedList.findMany({ where: { serverId: guild.id } }); - // delete them from the database await deleteConnections({ serverId: guild.id }); - // send server leave log to hubs - connections.forEach((connection) => logServerLeave(connection.hubId, guild)); + connections.forEach(async (connection) => await logGuildLeaveToHub(connection.hubId, guild)); await logGuildLeave(guild, channels.goal); } @GatewayEvent('messageCreate') static async onMessageCreate(message: Message): Promise { - if (message.author?.bot || message.system || message.webhookId || !message.inGuild()) return; + if (message.author.bot || message.system || message.webhookId || !message.inGuild()) return; const { connectionCache, cachePopulated } = message.client; @@ -243,14 +240,11 @@ export default abstract class EventManager { return; } - const locale = await getUserLocale(message.author.id); - message.author.locale = locale; - // check if the message was sent in a network channel const connection = connectionCache.get(message.channel.id); if (!connection?.connected) return; - const hub = await db.hubs.findFirst({ where: { id: connection?.hubId } }); + const hub = await db.hubs.findFirst({ where: { id: connection.hubId } }); if (!hub) return; const settings = new HubSettingsBitField(hub.settings); @@ -259,11 +253,35 @@ export default abstract class EventManager { con.hubId === connection.hubId && con.connected && con.channelId !== message.channel.id, ); - const attachment = message.attachments.first(); - const attachmentURL = attachment ? attachment.url : await getAttachmentURL(message.content); + let userData = await db.userData.findFirst({ where: { userId: message.author.id } }); + if (!userData?.viewedNetworkWelcome) { + userData = await db.userData.upsert({ + where: { userId: message.author.id }, + create: { + userId: message.author.id, + username: message.author.username, + viewedNetworkWelcome: true, + }, + update: { viewedNetworkWelcome: true }, + }); + + await sendWelcomeMsg(message, hubConnections.size.toString(), hub.name); + } + + // set locale for the user + message.author.locale = getUserLocale(userData); + + const attachmentURL = + message.attachments.first()?.url ?? (await getAttachmentURL(message.content)); // run checks on the message to determine if it can be sent in the network - if (!(await runChecks(message, settings, connection.hubId, { attachmentURL }))) return; + const passingChecks = await runChecks(message, connection.hubId, { + settings, + userData, + attachmentURL, + }); + + if (passingChecks === false) return; message.channel.sendTyping().catch(() => null); @@ -273,7 +291,6 @@ export default abstract class EventManager { : null; const { dbReferrence, referredAuthor } = await getReferredMsgData(referredMessage); - const sendResult = sendBroadcast(message, hub, hubConnections, settings, { attachmentURL, dbReferrence, @@ -286,12 +303,6 @@ export default abstract class EventManager { // deleting attachments will make the image not show up in the embed (discord removes it from its cdn) // if (!attachment) message.delete().catch(() => null); - const userData = await db.userData.findFirst({ - where: { userId: message.author.id, viewedNetworkWelcome: true }, - }); - - if (!userData) await sendWelcomeMsg(message, hubConnections.size.toString(), hub.name); - // store the message in the db await storeMessageData(message, await Promise.all(sendResult), connection.hubId, dbReferrence); } @@ -300,7 +311,8 @@ export default abstract class EventManager { static async onInteractionCreate(interaction: Interaction): Promise { try { const { commands, interactions } = interaction.client; - interaction.user.locale = await getUserLocale(interaction.user.id); + const userData = await db.userData.findFirst({ where: { userId: interaction.user.id } }); + interaction.user.locale = getUserLocale(userData); if (interaction.isMessageComponent() || interaction.isModalSubmit()) { const ignoreList = ['page_', 'onboarding_']; @@ -334,12 +346,8 @@ export default abstract class EventManager { } const command = commands.get(interaction.commandName); - if (!interaction.isAutocomplete()) { - await command?.execute(interaction); - } // normal slashie/context menu - else if (command?.autocomplete) { - await command.autocomplete(interaction); - } // autocomplete options + if (!interaction.isAutocomplete()) await command?.execute(interaction); // slash commands + else if (command?.autocomplete) await command.autocomplete(interaction); // autocomplete } catch (e) { handleError(e, interaction); diff --git a/src/scripts/network/helpers.ts b/src/scripts/network/helpers.ts index 222a4120..923d7adf 100644 --- a/src/scripts/network/helpers.ts +++ b/src/scripts/network/helpers.ts @@ -33,7 +33,7 @@ export const getReferredContent = (referredMessage: Message) => { }; export const getReferredMsgData = async (referredMessage: Message | null) => { - if (!referredMessage) return { dbReferrence: undefined, referredAuthor: undefined }; + if (!referredMessage) return { dbReferrence: null, referredAuthor: null }; const { client } = referredMessage; @@ -41,18 +41,18 @@ export const getReferredMsgData = async (referredMessage: Message | null) => { const dbReferrence = referredMessage ? ( await db.broadcastedMessages.findFirst({ - where: { messageId: referredMessage?.id }, + where: { messageId: referredMessage.id }, include: { originalMsg: { include: { broadcastMsgs: true } } }, }) )?.originalMsg - : undefined; + : null; - if (!dbReferrence) return { dbReferrence: undefined, referredAuthor: undefined }; + if (!dbReferrence) return { dbReferrence: null, referredAuthor: null }; const referredAuthor = referredMessage.author.id === client.user.id ? client.user - : await client.users.fetch(dbReferrence.authorId).catch(() => undefined); + : await client.users.fetch(dbReferrence.authorId).catch(() => null); // fetch the acttual user ("referredMessage" is a webhook message) return { dbReferrence, referredAuthor }; }; @@ -137,16 +137,6 @@ export const generateJumpButton = ( }; export const sendWelcomeMsg = async (message: Message, totalServers: string, hub: string) => { - await db.userData.upsert({ - where: { userId: message.author.id }, - create: { - userId: message.author.id, - username: message.author.username, - viewedNetworkWelcome: true, - }, - update: { viewedNetworkWelcome: true }, - }); - const linkButtons = new ActionRowBuilder().addComponents( new ButtonBuilder() .setStyle(ButtonStyle.Link) diff --git a/src/scripts/network/runChecks.ts b/src/scripts/network/runChecks.ts index 1220fad2..9a09ec5e 100644 --- a/src/scripts/network/runChecks.ts +++ b/src/scripts/network/runChecks.ts @@ -1,4 +1,3 @@ -import db from '../../utils/Db.js'; import logProfanity from '../../utils/HubLogger/Profanity.js'; import { Message, EmbedBuilder } from 'discord.js'; import { HubSettingsBitField } from '../../utils/BitFields.js'; @@ -9,6 +8,7 @@ import { check as checkProfanity } from '../../utils/Profanity.js'; import { runAntiSpam } from './antiSpam.js'; import { analyzeImageForNSFW, isUnsafeImage } from '../../utils/NSFWDetection.js'; import { logBlacklist } from '../../utils/HubLogger/ModLogs.js'; +import { userData as userDataCol } from '@prisma/client'; // if account is created within the last 7 days export const isNewUser = (message: Message) => { @@ -16,14 +16,6 @@ export const isNewUser = (message: Message) => { return message.author.createdTimestamp > sevenDaysAgo; }; -export const isUserBlacklisted = async (message: Message, hubId: string) => { - const isBlacklisted = await db.userData.findFirst({ - where: { userId: message.author.id, blacklistedFrom: { some: { hubId: { equals: hubId } } } }, - }); - - return Boolean(isBlacklisted); -}; - export const replyToMsg = async (message: Message, content: string) => { const reply = await message.reply(content).catch(() => null); if (!reply) { @@ -41,7 +33,9 @@ export const isCaughtSpam = async ( ) => { const antiSpamResult = runAntiSpam(message.author, 3); if (!antiSpamResult) return false; - const { addUserBlacklist, notifyBlacklist, scheduleRemoval } = message.client.blacklistManager; + /* FIXME: Don't use { addUserBlacklist, notifyBlacklist } it makes the methods lose their "this" property + better to not have a class like this at all tbh */ + const blacklistManager = message.client.blacklistManager; if (settings.has('SpamFilter') && antiSpamResult.infractions >= 3) { const expires = new Date(Date.now() + 60 * 5000); @@ -49,9 +43,13 @@ export const isCaughtSpam = async ( const target = message.author; const mod = message.client.user; - await addUserBlacklist(hubId, target.id, reason, mod.id, 60 * 5000); - scheduleRemoval('user', target.id, hubId, 60 * 5000); - await notifyBlacklist('user', target.id, { hubId, expires, reason }).catch(() => null); + await blacklistManager.addUserBlacklist(hubId, target.id, reason, mod.id, 60 * 5000); + blacklistManager.scheduleRemoval('user', target.id, hubId, 60 * 5000); + + await blacklistManager + .notifyBlacklist('user', target.id, { hubId, expires, reason }) + .catch(() => null); + await logBlacklist(hubId, message.client, { target, mod, reason, expires }).catch(() => null); } @@ -83,7 +81,7 @@ export const containsLinks = (message: Message, settings: HubSettingsBitField) = }; export const unsupportedAttachment = (message: Message) => { const attachment = message.attachments.first(); - // NOTE: Even 'image/gif' was allowed + // NOTE: Even 'image/gif' was allowed before const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg']; return (attachment?.contentType && !allowedTypes.includes(attachment.contentType)) === true; @@ -102,18 +100,22 @@ export const attachmentTooLarge = (message: Message) => { */ export const runChecks = async ( - message: Message, - settings: HubSettingsBitField, + message: Message, hubId: string, - opts?: { attachmentURL?: string | null }, + opts: { settings: HubSettingsBitField; userData: userDataCol; attachmentURL?: string | null }, ): Promise => { const { locale } = message.author; const { hasProfanity, hasSlurs } = checkProfanity(message.content); + const { settings, userData, attachmentURL } = opts; - if (!message.inGuild()) return false; - if (await isUserBlacklisted(message, hubId)) return false; if (await isCaughtSpam(message, settings, hubId)) return false; if (containsLinks(message, settings)) message.content = replaceLinks(message.content); + + // banned / blacklisted + if (userData.banned || userData.blacklistedFrom.some((b) => b.hubId === hubId)) { + return false; + } + // send a log to the log channel set by the hub if (hasProfanity || hasSlurs) { logProfanity(hubId, message.content, message.author, message.guild); @@ -164,7 +166,7 @@ export const runChecks = async ( return false; } - const isNsfw = await containsNSFW(message, opts?.attachmentURL); + const isNsfw = await containsNSFW(message, attachmentURL); if (isNsfw?.unsafe) { const nsfwEmbed = new EmbedBuilder() .setTitle(t({ phrase: 'network.nsfw.title', locale })) diff --git a/src/scripts/network/sendBroadcast.ts b/src/scripts/network/sendBroadcast.ts index 1abfa863..80a5f376 100644 --- a/src/scripts/network/sendBroadcast.ts +++ b/src/scripts/network/sendBroadcast.ts @@ -22,8 +22,8 @@ type BroadcastOpts = { embedColor?: HexColorString | null; attachmentURL?: string | null; referredMessage: Message | null; - dbReferrence: (originalMessages & { broadcastMsgs: broadcastedMessages[] }) | undefined; - referredAuthor: User | undefined; + dbReferrence: (originalMessages & { broadcastMsgs: broadcastedMessages[] }) | null; + referredAuthor: User | null; }; export default ( diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 8519e433..f45f69e2 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -46,6 +46,7 @@ import { captureException } from '@sentry/node'; import { CustomID } from './CustomID.js'; import { ClusterManager } from 'discord-hybrid-sharding'; import { deleteConnection, deleteConnections } from './ConnectedList.js'; +import { userData } from '@prisma/client'; /** Convert milliseconds to a human readable time (eg: 1d 2h 3m 4s) */ export const msToReadable = (milliseconds: number) => { @@ -495,10 +496,8 @@ export const fetchHub = async (id: string) => { return await db.hubs.findFirst({ where: { id } }); }; -export const getUserLocale = async (userId: Snowflake) => { - const fetch = await db.userData.findFirst({ where: { userId } }); - - return (fetch?.locale as supportedLocaleCodes | undefined) || 'en'; +export const getUserLocale = (user: userData | undefined | null) => { + return (user?.locale as supportedLocaleCodes | null | undefined) || 'en'; }; export const containsInviteLinks = (str: string) => {