diff --git a/prisma/schema.prisma b/prisma/schema.prisma index baca0cb8a..1a54429aa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,6 +39,15 @@ type HubModerator { // honestly... I might as well make permissions lol } +// type HubSettings { +// useNicknames Boolean @default(false) +// allowInvites Boolean @default(true) +// allowLinks Boolean @default(true) +// allowReactions Boolean @default(true) +// profanityFilter Boolean @default(false) +// spamFilter Boolean @default(true) +// } + model blacklistedServers { id String @id @default(auto()) @map("_id") @db.ObjectId serverId String @@ -86,7 +95,8 @@ model hubs { bannerUrl String? private Boolean @default(true) createdAt DateTime @default(now()) - useNicknames Boolean @default(false) + // settings are stored as a number, each bit is a setting + settings Int // all the stuff below is relations to other collections invites hubInvites[] messages messageData[] diff --git a/src/Commands/Main/hub.ts b/src/Commands/Main/hub.ts index 11170a9bf..a9766e9ea 100644 --- a/src/Commands/Main/hub.ts +++ b/src/Commands/Main/hub.ts @@ -238,6 +238,17 @@ export default { .setAutocomplete(true) .setRequired(true), ), + ) .addSubcommand(subcommand => + subcommand + .setName('settings') + .setDescription('Manage hub settings') + .addStringOption(stringOption => + stringOption + .setName('hub') + .setDescription('The name of the hub') + .setAutocomplete(true) + .setRequired(true), + ), ), async execute(interaction: ChatInputCommandInteraction) { const subcommand = interaction.options.getSubcommand(); @@ -263,7 +274,7 @@ export default { }); } - else if (subcommand === 'manage' || subcommand === 'networks') { + else if (subcommand === 'manage' || subcommand === 'networks' || subcommand === 'settings') { hubChoices = await getDb().hubs.findMany({ where: { name: { diff --git a/src/Commands/Network/editMsg.ts b/src/Commands/Network/editMsg.ts index a65dc5d46..989344fe6 100644 --- a/src/Commands/Network/editMsg.ts +++ b/src/Commands/Network/editMsg.ts @@ -1,8 +1,9 @@ import { ContextMenuCommandBuilder, MessageContextMenuCommandInteraction, ApplicationCommandType, ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle, WebhookClient, EmbedBuilder } from 'discord.js'; import { networkMsgUpdate } from '../../Scripts/networkLogs/msgUpdate'; -import { checkIfStaff, getDb, getGuildName, topgg } from '../../Utils/functions/utils'; +import { checkIfStaff, getDb, getGuildName, replaceLinks, topgg } from '../../Utils/functions/utils'; import wordFiler from '../../Utils/functions/wordFilter'; import { captureException } from '@sentry/node'; +import { HubSettingsBitField } from '../../Utils/hubs/hubSettingsBitfield'; export default { description: 'Edit a message that was sent in the network.', @@ -23,6 +24,7 @@ export default { const db = getDb(); const messageInDb = await db.messageData.findFirst({ where: { channelAndMessageIds: { some: { messageId: { equals: target.id } } } }, + include: { hub: true }, }); if (!messageInDb) { @@ -71,10 +73,21 @@ export default { if (!editInteraction) return; + const hubSettings = new HubSettingsBitField(messageInDb.hub?.settings); // get the input from user - const newMessage = editInteraction.fields.getTextInputValue('newMessage'); + const newMessage = hubSettings.has('HideLinks') + ? replaceLinks(editInteraction.fields.getTextInputValue('newMessage')) + : editInteraction.fields.getTextInputValue('newMessage'); const censoredNewMessage = wordFiler.censor(newMessage); + if (newMessage.includes('discord.gg') || newMessage.includes('discord.com/invite') || newMessage.includes('dsc.gg')) { + editInteraction.reply({ + content: `${interaction.client.emotes.normal.no} Do not advertise or promote servers in the network. Set an invite in \`/network manage\` instead!`, + ephemeral: true, + }); + return; + } + // if the message being edited is in compact mode // then we create a new embed with the new message and old reply // else we just use the old embed and replace the description diff --git a/src/Events/messageCreate.ts b/src/Events/messageCreate.ts index 8f38ed406..bdfec959a 100644 --- a/src/Events/messageCreate.ts +++ b/src/Events/messageCreate.ts @@ -5,6 +5,7 @@ import { APIMessage, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, import { getDb, colors } from '../Utils/functions/utils'; import { censor } from '../Utils/functions/wordFilter'; import { messageData } from '@prisma/client'; +import { HubSettingsBitField } from '../Utils/hubs/hubSettingsBitfield'; export interface NetworkMessage extends Message { censored_content: string, @@ -27,7 +28,8 @@ export default { }); if (channelInDb && channelInDb?.hub) { - if (!await checks.execute(message, channelInDb)) return; + const settings = new HubSettingsBitField(channelInDb.hub?.settings); + if (!await checks.execute(message, channelInDb, settings)) return; message.censored_content = censor(message.content); const attachment = message.attachments.first(); @@ -53,11 +55,13 @@ export default { } } + const useNicknames = settings.has('UseNicknames'); + // for nicknames setting - const displayNameOrUsername = channelInDb.hub.useNicknames + const displayNameOrUsername = useNicknames ? message.member?.displayName || message.author.displayName : message.author.username; - const avatarURL = channelInDb.hub.useNicknames + const avatarURL = useNicknames ? message.member?.user.displayAvatarURL() : message.author.displayAvatarURL(); diff --git a/src/Scripts/hub/create.ts b/src/Scripts/hub/create.ts index 9e887e98c..f8a7ffac1 100644 --- a/src/Scripts/hub/create.ts +++ b/src/Scripts/hub/create.ts @@ -1,5 +1,6 @@ import { ChatInputCommandInteraction, ModalBuilder, TextInputBuilder, EmbedBuilder, ActionRowBuilder, TextInputStyle, Collection } from 'discord.js'; import { getDb } from '../../Utils/functions/utils'; +import { HubSettingsBits } from '../../Utils/hubs/hubSettingsBitfield'; const cooldowns = new Collection(); @@ -82,6 +83,8 @@ export async function execute(interaction: ChatInputCommandInteraction) { const description = submitIntr.fields.getTextInputValue('description'); const tags = submitIntr.fields.getTextInputValue('tags'); + // FIXME: settings is a required field, add the fields to every collection + // in prod db before pushing it await db.hubs.create({ data: { name: hubName, @@ -91,6 +94,7 @@ export async function execute(interaction: ChatInputCommandInteraction) { ownerId: submitIntr.user.id, iconUrl: imgurIcons[0], bannerUrl: imgurBanners?.[0], + settings: HubSettingsBits.SpamFilter, }, }); diff --git a/src/Scripts/hub/manage.ts b/src/Scripts/hub/manage.ts index 769158e1b..0bab8be5c 100644 --- a/src/Scripts/hub/manage.ts +++ b/src/Scripts/hub/manage.ts @@ -87,7 +87,6 @@ export async function execute(interaction: ChatInputCommandInteraction) { ${hub.description} - __**Tags:**__ ${hub.tags.join(', ')} - __**Public:**__ ${hub.private ? emotes.normal.no : emotes.normal.yes} - - __**Use Nicknames:**__ ${hub.useNicknames ? emotes.normal.yes : emotes.normal.no} `) .setThumbnail(hub.iconUrl) .setImage(hub.bannerUrl) @@ -194,19 +193,6 @@ export async function execute(interaction: ChatInputCommandInteraction) { break; } - case 'nickname': { - await db.hubs.update({ - where: { id: hubInDb?.id }, - data: { useNicknames: !hubInDb?.useNicknames }, - }); - - await i.reply({ - content: `**${hubInDb?.useNicknames ? 'Usernames' : 'Display Names'}** will now be displayed for user names on messages instead.`, - ephemeral: true, - }); - break; - } - case 'description': { const modal = new ModalBuilder() .setCustomId(i.id) diff --git a/src/Scripts/hub/settings.ts b/src/Scripts/hub/settings.ts new file mode 100644 index 000000000..3b2370fc8 --- /dev/null +++ b/src/Scripts/hub/settings.ts @@ -0,0 +1,103 @@ +import { ActionRowBuilder, ChatInputCommandInteraction, ComponentType, EmbedBuilder, StringSelectMenuBuilder } from 'discord.js'; +import { colors, getDb } from '../../Utils/functions/utils'; +import { hubs } from '@prisma/client'; +import { HubSettingsBitField, HubSettingsString } from '../../Utils/hubs/hubSettingsBitfield'; + +const genSettingsEmbed = (hub: hubs, yesEmoji: string, noEmoji: string) => { + const settings = new HubSettingsBitField(hub.settings); + const settingDescriptions = { + HideLinks: '**Hide Links** - Redact links sent by users.', + Reactions: '**Reactions** - Allow users to react to messages.', + BlockInvites: '**Block Invites** - Prevent users from sending Discord invites.', + SpamFilter: '**Spam Filter** - Automatically blacklist spammers for 5 minutes.', + UseNicknames: '**Use Nicknames** - Use server nicknames as the network usernames.', + }; + + return new EmbedBuilder() + .setAuthor({ name: `${hub.name} Settings`, iconURL: hub.iconUrl }) + .setDescription(Object.entries(settingDescriptions).map(([key, value]) => { + const flag = settings.has(key as HubSettingsString); + return `${flag ? yesEmoji : noEmoji} ${value}`; + }).join('\n')) + .setFooter({ text: 'Use the select menu below to toggle.' }) + .setColor(colors('chatbot')) + .setTimestamp(); +}; + +const genSelectMenu = ( + hubSettings: HubSettingsBitField, + disabledEmote: string, + enabledEmote: string, +) => { + return new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('hub_settings') + .setPlaceholder('Select an option') + .addOptions( + Object.keys(HubSettingsBitField.Flags).map((key) => { + const flag = hubSettings.has(key as HubSettingsString); + const emoji = flag ? disabledEmote : enabledEmote; + return { + label: `${flag ? 'Disable' : 'Enable'} ${key}`, + value: key, + emoji, + }; + }), + ), + ); +}; + +export async function execute(interaction: ChatInputCommandInteraction) { + const hubName = interaction.options.getString('hub', true); + + const db = getDb(); + let hub = await db.hubs.findUnique({ + where: { + name: hubName, + OR: [ + { + moderators: { some: { userId: interaction.user.id, position: 'manager' } }, + }, + { ownerId: interaction.user.id }, + ], + }, + }); + + if (!hub) { + return interaction.reply({ + content: 'Hub not found.', + ephemeral: true, + }); + } + + const hubSettings = new HubSettingsBitField(hub.settings); + const emotes = interaction.client.emotes.normal; + const embed = genSettingsEmbed(hub, emotes.enabled, emotes.disabled); + const selects = genSelectMenu(hubSettings, emotes.disabled, emotes.enabled); + + const initReply = await interaction.reply({ embeds: [embed], components: [selects] }); + + const collector = initReply.createMessageComponentCollector({ + time: 60 * 1000, + filter: (i) => i.user.id === interaction.user.id, + componentType: ComponentType.StringSelect, + }); + + // respond to select menu + collector.on('collect', async (i) => { + const selected = i.values[0] as HubSettingsString; + + hub = await db.hubs.update({ + where: { name: hub?.name }, + data: { settings: hubSettings.toggle(selected).bitfield }, + }); + + const newEmbed = genSettingsEmbed(hub, emotes.enabled, emotes.disabled); + const newSelects = genSelectMenu(hubSettings, emotes.disabled, emotes.enabled); + + await i.update({ + embeds: [newEmbed], + components: [newSelects], + }); + }); +} \ No newline at end of file diff --git a/src/Scripts/message/checks.ts b/src/Scripts/message/checks.ts index b29447289..4a823bffe 100644 --- a/src/Scripts/message/checks.ts +++ b/src/Scripts/message/checks.ts @@ -2,18 +2,14 @@ import wordFilter from '../../Utils/functions/wordFilter'; import antiSpam from './antispam'; import { Message } from 'discord.js'; import { slurs } from '../../Utils/JSON/badwords.json'; -import { addUserBlacklist, getDb } from '../../Utils/functions/utils'; +import { addUserBlacklist, getDb, replaceLinks } from '../../Utils/functions/utils'; import { connectedList } from '@prisma/client'; +import { HubSettingsBitField } from '../../Utils/hubs/hubSettingsBitfield'; export = { - async execute(message: Message, networkData: connectedList) { + async execute(message: Message, networkData: connectedList, settings: HubSettingsBitField) { // true = pass, false = fail (checks) - if (!networkData.hubId) { - message.reply('Using InterChat without a joining hub is no longer supported. Join a hub by using `/hub join` and explore hubs using `/hub browse`.'); - return false; - } - const db = getDb(); const userInBlacklist = await db.blacklistedUsers?.findFirst({ where: { hubId: networkData.hubId, userId: message.author.id }, @@ -31,17 +27,18 @@ export = { } if (serverInBlacklist) return false; - const antiSpamResult = antiSpam(message.author, 3); - if (antiSpamResult) { - if (antiSpamResult.infractions >= 3) { - addUserBlacklist(networkData.hubId, message.client.user, message.author, 'Auto-blacklisted for spamming.', 60 * 5000); + if (settings.has('SpamFilter')) { + const antiSpamResult = antiSpam(message.author, 3); + if (antiSpamResult) { + if (antiSpamResult.infractions >= 3) addUserBlacklist(networkData.hubId, message.client.user, message.author, 'Auto-blacklisted for spamming.', 60 * 5000); + message.react(message.client.emotes.icons.timeout); + return false; + } + + if (message.content.length > 1000) { + message.reply('Please keep your message shorter than 1000 characters long.'); + return false; } - message.react(message.client.emotes.icons.timeout); - return false; - } - if (message.content.length > 1000) { - message.reply('Please keep your message shorter than 1000 characters long.'); - return false; } // check if message contains slurs @@ -51,6 +48,7 @@ export = { } if ( + settings.has('BlockInvites') && message.content.includes('discord.gg') || message.content.includes('discord.com/invite') || message.content.includes('dsc.gg') @@ -59,12 +57,6 @@ export = { return false; } - // dont send message if guild name is inappropriate - if (wordFilter.check(message.guild?.name)) { - message.channel.send('I have detected words in the server name that are potentially offensive, Please fix it before using this chat!'); - return false; - } - if (message.stickers.size > 0 && !message.content) { message.reply('Sending stickers in the network is not possible due to discord\'s limitations.'); return false; @@ -84,8 +76,19 @@ export = { return false; } + // dont send message if guild name is inappropriate + if (wordFilter.check(message.guild?.name)) { + message.channel.send('I have detected words in the server name that are potentially offensive, Please fix it before using this chat!'); + return false; + } + if (wordFilter.check(message.content)) wordFilter.log(message.content, message.author, message.guildId, networkData.hubId); + const urlRegex = /https?:\/\/(?!tenor\.com|giphy\.com)\S+/g; + if (settings.has('HideLinks') && message.content.match(urlRegex)) { + message.content = replaceLinks(message.content); + } + return true; }, }; diff --git a/src/Scripts/network/displaySettings.ts b/src/Scripts/network/displaySettings.ts index a16f18f53..8ce42988c 100644 --- a/src/Scripts/network/displaySettings.ts +++ b/src/Scripts/network/displaySettings.ts @@ -1,13 +1,9 @@ import { ChatInputCommandInteraction, ButtonBuilder, ActionRowBuilder, ButtonStyle, GuildTextBasedChannel, EmbedBuilder, ChannelType, ComponentType, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Interaction, ChannelSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, TextChannel, ButtonInteraction, AnySelectMenuInteraction, Webhook, ThreadChannel } from 'discord.js'; import { reconnect, disconnect } from '../../Structures/network'; -import { colors, getDb } from '../../Utils/functions/utils'; +import { colors, getDb, yesOrNoEmoji } from '../../Utils/functions/utils'; import logger from '../../Utils/logger'; import { captureException } from '@sentry/node'; -function yesOrNo(option: unknown, yesEmoji: string, noEmoji: string) { - return option ? yesEmoji : noEmoji; -} - function updateConnectionButtons(connected: boolean | undefined, disconnectEmoji: string, connectEmoji: string) { return new ActionRowBuilder().addComponents([ new ButtonBuilder() @@ -16,7 +12,6 @@ function updateConnectionButtons(connected: boolean | undefined, disconnectEmoji .setStyle(connected ? ButtonStyle.Danger : ButtonStyle.Success) .setEmoji(connected ? disconnectEmoji : connectEmoji), ]); - } // function to make it easier to edit embeds with updated data @@ -35,9 +30,9 @@ async function setupEmbed(interaction: Interaction, channelId: string) { { name: 'Channel', value: `<#${channelId}>`, inline: true }, { name: 'Hub', value: `${networkData?.hub?.name}`, inline: true }, { name: 'Invite', value: invite, inline: true }, - { name: 'Connected', value: yesOrNo(networkData?.connected, yes, no), inline: true }, - { name: 'Compact', value: yesOrNo(networkData?.compact, enabled, disabled), inline: true }, - { name: 'Profanity Filter', value: yesOrNo(networkData?.profFilter, enabled, disabled), inline: true }, + { name: 'Connected', value: yesOrNoEmoji(networkData?.connected, yes, no), inline: true }, + { name: 'Compact', value: yesOrNoEmoji(networkData?.compact, enabled, disabled), inline: true }, + { name: 'Profanity Filter', value: yesOrNoEmoji(networkData?.profFilter, enabled, disabled), inline: true }, ]) .setColor(colors('chatbot')) .setThumbnail(interaction.guild?.iconURL() || interaction.client.user.avatarURL()) diff --git a/src/Utils/functions/utils.ts b/src/Utils/functions/utils.ts index c2c77e1b0..51ccc6727 100644 --- a/src/Utils/functions/utils.ts +++ b/src/Utils/functions/utils.ts @@ -69,6 +69,15 @@ export const constants = { export const topgg = new Api(process.env.TOPGG as string); const _prisma = new PrismaClient(); +export function replaceLinks(string: string, replaceText = '`[LINK HIDDEN]`') { + const urlRegex = /https?:\/\/(?!tenor\.com|giphy\.com)\S+/g; + return string.replaceAll(urlRegex, replaceText); +} + +export function yesOrNoEmoji(option: unknown, yesEmoji: string, noEmoji: string) { + return option ? yesEmoji : noEmoji; +} + export function toTitleCase(txt: string): string { return startCase(toLower(txt)); } diff --git a/src/Utils/hubs/hubSettingsBitfield.ts b/src/Utils/hubs/hubSettingsBitfield.ts new file mode 100644 index 000000000..dc971b1ec --- /dev/null +++ b/src/Utils/hubs/hubSettingsBitfield.ts @@ -0,0 +1,19 @@ +import { BitField } from 'discord.js'; + +export const HubSettingsBits = { + Reactions: 1 << 0, + HideLinks: 1 << 1, + SpamFilter: 1 << 2, + BlockInvites: 1 << 3, + UseNicknames: 1 << 4, +} as const; + +export type HubSettingsString = keyof typeof HubSettingsBits; + +export class HubSettingsBitField extends BitField { + public static Flags = HubSettingsBits; + /** toggle a setting */ + public toggle(...setting: HubSettingsString[]) { + return this.has(setting) ? this.remove(setting) : this.add(setting); + } +}