diff --git a/.vscode/settings.json b/.vscode/settings.json index 8175e6315..c488c9b23 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,6 @@ "sonarlint.connectedMode.project": { "connectionId": "discord-interchat", "projectKey": "Discord-InterChat_InterChat" - } + }, + "codescene.previewCodeHealthMonitoring": true } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 47edca9ba..509a2065f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -89,18 +89,18 @@ model ServerInfraction { @@index([serverId, hubId, status]) } +// TODO: major refactor needed for mode and profFilter thing model connectedList { id String @id @default(auto()) @map("_id") @db.ObjectId + mode Int @default(0) // 0 = compact, 1 = embed channelId String @unique // channel can be thread, or a normal channel - parentId String? // ID of the parent channel, if it's a thread @map("parentChannelId") + parentId String? // ID of the parent channel, if it's a thread serverId String - connected Boolean - compact Boolean invite String? - profFilter Boolean embedColor String? webhookURL String lastActive DateTime? @default(now()) + // TODO: rename to createdAt date DateTime @default(now()) hub Hub? @relation(fields: [hubId], references: [id]) hubId String @db.ObjectId @@ -121,11 +121,12 @@ model Hub { appealCooldownHours Int @default(168) // 7 days createdAt DateTime @default(now()) settings Int // each bit is a setting - // all the stuff below is relations to other collections + // relations invites HubInvite[] moderators HubModerator[] connections connectedList[] logConfig HubLogConfig[] + msgBlockList MessageBlockList[] originalMessages originalMessages[] userInfractions UserInfraction[] serverInfractions ServerInfraction[] @@ -133,6 +134,19 @@ model Hub { @@index([id, name, ownerId]) } +model MessageBlockList { + id String @id @default(auto()) @map("_id") @db.ObjectId + name String + words String + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + hub Hub @relation(fields: [hubId], references: [id]) + hubId String @db.ObjectId + + @@index([id, words]) +} + model HubLogConfig { id String @id @default(auto()) @map("_id") @db.ObjectId modLogs String? @@ -147,7 +161,7 @@ model HubLogConfig { } model HubInvite { - code String @id @default(nanoid(10)) @map("_id") + code String @id @default(nanoid(10)) @map("_id") expires DateTime hub Hub @relation(fields: [hubId], references: [id]) hubId String @db.ObjectId diff --git a/src/cluster.ts b/src/cluster.ts index 5e9cfa0e9..629c254fa 100644 --- a/src/cluster.ts +++ b/src/cluster.ts @@ -11,14 +11,21 @@ import 'dotenv/config'; const shardsPerClusters = 5; const clusterManager = new ClusterManager('build/index.js', { token: process.env.DISCORD_TOKEN, + totalShards: 'auto', totalClusters: 'auto', shardsPerClusters, }); -clusterManager.extend(new HeartbeatManager({ interval: 60 * 1000, maxMissedHeartbeats: 2 })); +clusterManager.extend(new HeartbeatManager({ interval: 10 * 1000, maxMissedHeartbeats: 2 })); clusterManager.extend(new ReClusterManager()); clusterManager.on('clusterReady', (cluster) => { + Logger.info( + `Cluster ${cluster.id} is ready with shards ${cluster.shardList[0]}...${cluster.shardList.at(-1)}.`, + ); + + if (cluster.id === clusterManager.totalClusters - 1) startTasks(); + cluster.on('message', async (message) => { if (message === 'recluster') { Logger.info('Recluster requested, starting recluster...'); @@ -34,38 +41,28 @@ clusterManager.on('clusterReady', (cluster) => { }); }); - -// clusterManager.on('clientRequest', (n) => { -// cons -// }) - // spawn clusters and start the api that handles nsfw filter and votes -clusterManager - .spawn({ timeout: -1 }) - .then(() => { - const scheduler = new Scheduler(); +clusterManager.spawn({ timeout: -1 }); - deleteExpiredInvites().catch(Logger.error); - - // store network message timestamps to connectedList every minute - scheduler.addRecurringTask('storeMsgTimestamps', 10 * 60 * 1000, storeMsgTimestamps); - scheduler.addRecurringTask('deleteExpiredInvites', 60 * 60 * 1000, deleteExpiredInvites); +function startTasks() { + pauseIdleConnections(clusterManager).catch(Logger.error); + deleteExpiredInvites().catch(Logger.error); - // production only tasks - if (Constants.isDevBuild) return; + const scheduler = new Scheduler(); + // store network message timestamps to connectedList every minute + scheduler.addRecurringTask('storeMsgTimestamps', 10 * 60 * 1000, storeMsgTimestamps); + scheduler.addRecurringTask('cleanupTasks', 60 * 60 * 1000, () => { + deleteExpiredInvites().catch(Logger.error); pauseIdleConnections(clusterManager).catch(Logger.error); + }); + // production only tasks + if (!Constants.isDevBuild) { scheduler.addRecurringTask('syncBotlistStats', 10 * 60 * 10_000, async () => { - // perform start up tasks - const serverCount = (await clusterManager.fetchClientValues('guilds.cache.size')).reduce( - (p: number, n: number) => p + n, - 0, - ); + const servers = await clusterManager.fetchClientValues('guilds.cache.size'); + const serverCount = servers.reduce((p: number, n: number) => p + n, 0); syncBotlistStats({ serverCount, shardCount: clusterManager.totalShards }); }); - scheduler.addRecurringTask('pauseIdleConnections', 60 * 60 * 1000, () => - pauseIdleConnections(clusterManager), - ); - }) - .catch(Logger.error); + } +} diff --git a/src/commands/slash/Main/hub/blockwords.ts b/src/commands/slash/Main/hub/blockwords.ts new file mode 100644 index 000000000..f3a40fd45 --- /dev/null +++ b/src/commands/slash/Main/hub/blockwords.ts @@ -0,0 +1,137 @@ +import HubCommand from '#main/commands/slash/Main/hub/index.js'; +import { emojis } from '#main/config/Constants.js'; +import { RegisterInteractionHandler } from '#main/decorators/Interaction.js'; +import { CustomID } from '#utils/CustomID.js'; +import db from '#utils/Db.js'; +import { isStaffOrHubMod } from '#utils/hub/utils.js'; +import { t } from '#utils/Locale.js'; +import { + buildBlockWordsListEmbed, + buildBlockWordsModal, + buildModifyBlockedWordsBtn, + sanitizeWords, +} from '#utils/moderation/blockedWords.js'; +import { Hub, MessageBlockList } from '@prisma/client'; +import { + type ButtonInteraction, + type ChatInputCommandInteraction, + type ModalSubmitInteraction, +} from 'discord.js'; + +export default class BlockWordCommand extends HubCommand { + async execute(interaction: ChatInputCommandInteraction) { + const hubName = interaction.options.getString('hub', true); + const hub = await this.fetchHub({ name: hubName }); + + if (!hub || !isStaffOrHubMod(interaction.user.id, hub)) { + const locale = await this.getLocale(interaction); + await this.replyEmbed(interaction, t('hub.notFound_mod', locale, { emoji: emojis.no }), { + ephemeral: true, + }); + return; + } + + switch (interaction.options.getSubcommand()) { + case 'modify': + await this.handleModifySubcommand(interaction, hub); + break; + case 'create': + await this.handleAdd(interaction, hub); + break; + default: + break; + } + } + + @RegisterInteractionHandler('blockwordsButton', 'modify') + async handleModifyButtons(interaction: ButtonInteraction) { + const customId = CustomID.parseCustomId(interaction.customId); + const [hubId, ruleId] = customId.args; + + const hub = await this.fetchHub({ id: hubId }); + + if (!hub || !isStaffOrHubMod(interaction.user.id, hub)) { + const locale = await this.getLocale(interaction); + await this.replyEmbed(interaction, t('hub.notFound_mod', locale, { emoji: emojis.no }), { + ephemeral: true, + }); + return; + } + + const blockWords = hub.msgBlockList; + const presetRule = blockWords.find((r) => r.id === ruleId); + + if (!presetRule) { + await interaction.reply({ content: 'This rule does not exist.', ephemeral: true }); + return; + } + + const modal = buildBlockWordsModal(hub.id, { presetRule }); + await interaction.showModal(modal); + } + + @RegisterInteractionHandler('blockwordsModal') + async handleModals(interaction: ModalSubmitInteraction) { + const customId = CustomID.parseCustomId(interaction.customId); + const [hubId, ruleId] = customId.args as [string, string?]; + + const hub = await this.fetchHub({ id: hubId }); + if (!hub) return; + + await interaction.reply({ + content: `${emojis.loading} Validating blocked words...`, + ephemeral: true, + }); + + const name = interaction.fields.getTextInputValue('name'); + const newWords = sanitizeWords(interaction.fields.getTextInputValue('words')); + if (!ruleId) { + if (hub.msgBlockList.length >= 2) { + await interaction.editReply('You can only have 2 block word rules per hub.'); + return; + } + + await db.messageBlockList.create({ + data: { hubId, name, createdBy: interaction.user.id, words: newWords }, + }); + await interaction.editReply(`${emojis.yes} Rule added.`); + } + else if (newWords.length === 0) { + await db.messageBlockList.delete({ where: { id: ruleId } }); + await interaction.editReply(`${emojis.yes} Rule removed.`); + } + else { + await db.messageBlockList.update({ where: { id: ruleId }, data: { words: newWords, name } }); + await interaction.editReply(`${emojis.yes} Rule updated.`); + } + } + + private async handleModifySubcommand( + interaction: ChatInputCommandInteraction, + hub: Hub & { msgBlockList: MessageBlockList[] }, + ) { + const blockWords = hub.msgBlockList; + + if (!blockWords.length) { + await this.replyEmbed( + interaction, + 'No block word rules are in this hub yet. Use `/hub blockwords add` to add some.', + { ephemeral: true }, + ); + return; + } + + const embed = buildBlockWordsListEmbed(blockWords); + const buttons = buildModifyBlockedWordsBtn(hub.id, blockWords); + await interaction.reply({ embeds: [embed], components: [buttons] }); + } + + private async handleAdd(interaction: ChatInputCommandInteraction | ButtonInteraction, hub: Hub) { + const modal = buildBlockWordsModal(hub.id); + await interaction.showModal(modal); + } + + private async fetchHub({ id, name }: { id?: string; name?: string }) { + return await db.hub.findFirst({ where: { id, name }, include: { msgBlockList: true } }); + } +} diff --git a/src/commands/slash/Main/hub/index.ts b/src/commands/slash/Main/hub/index.ts index b0032f444..f781c822a 100644 --- a/src/commands/slash/Main/hub/index.ts +++ b/src/commands/slash/Main/hub/index.ts @@ -401,6 +401,25 @@ export default class HubCommand extends BaseCommand { }, ], }, + { + type: ApplicationCommandOptionType.SubcommandGroup, + name: 'blockwords', + description: 'Manage blocked words in your hub.', + options: [ + { + type: ApplicationCommandOptionType.Subcommand, + name: 'create', + description: 'Create a new blocked word rule to your hub.', + options: [hubOption], + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: 'modify', + description: 'Modify an existing blocked word rule in your hub.', + options: [hubOption], + }, + ], + }, ], }; @@ -416,7 +435,7 @@ export default class HubCommand extends BaseCommand { } async autocomplete(interaction: AutocompleteInteraction): Promise { - const managerCmds = ['edit', 'settings', 'invite', 'moderator', 'logging', 'appeal']; + const managerCmds = ['edit', 'settings', 'invite', 'moderator', 'logging', 'appeal', 'blockwords']; const modCmds = ['servers']; const subcommand = interaction.options.getSubcommand(); diff --git a/src/core/BaseCommand.ts b/src/core/BaseCommand.ts index c4b47e5ed..7647d8715 100644 --- a/src/core/BaseCommand.ts +++ b/src/core/BaseCommand.ts @@ -26,6 +26,7 @@ import { type RESTPostAPIChatInputApplicationCommandsJSONBody, type RESTPostAPIContextMenuApplicationCommandsJSONBody, Collection, + Interaction, time, } from 'discord.js'; @@ -175,4 +176,9 @@ export default abstract class BaseCommand { MetadataHandler.loadMetadata(command, map); Logger.debug(`Finished adding interactions for command: ${command.data.name}`); } + + protected async getLocale(interaction: Interaction): Promise { + const { userManager } = interaction.client; + return await userManager.getUserLocale(interaction.user.id); + } } diff --git a/src/events/shardReady.ts b/src/events/shardReady.ts deleted file mode 100644 index 0f968c3b0..000000000 --- a/src/events/shardReady.ts +++ /dev/null @@ -1,17 +0,0 @@ -import BaseEventListener from '#main/core/BaseEventListener.js'; -import Logger from '#utils/Logger.js'; - -export default class ShardReady extends BaseEventListener<'shardReady'> { - readonly name = 'shardReady'; - - execute(shardId: number, unavailableGuilds: Set): void { - if (unavailableGuilds) { - Logger.warn( - `Shard ${shardId} is ready but ${unavailableGuilds.size} guilds are unavailable.`, - ); - } - else { - Logger.info(`Shard ${shardId} is ready!`); - } - } -} diff --git a/src/managers/VoteManager.ts b/src/managers/VoteManager.ts index c2aeeed87..c6fff3478 100644 --- a/src/managers/VoteManager.ts +++ b/src/managers/VoteManager.ts @@ -1,7 +1,7 @@ import Constants, { emojis } from '#main/config/Constants.js'; import UserDbManager from '#main/managers/UserDbManager.js'; import Scheduler from '#main/modules/SchedulerService.js'; -import Logger from '#main/utils/Logger.js'; +import Logger from '#utils/Logger.js'; import type { WebhookPayload } from '#types/topgg.d.ts'; import db from '#utils/Db.js'; import { getOrdinalSuffix } from '#utils/Utils.js'; diff --git a/src/utils/CustomID.ts b/src/utils/CustomID.ts index 16aa33a6d..74d12046a 100644 --- a/src/utils/CustomID.ts +++ b/src/utils/CustomID.ts @@ -32,6 +32,8 @@ export class CustomID { * @param values - The value to add as an argument. * @returns CustomID - The CustomID instance for method chaining. */ + + // TODO: Rename this to set args and add a new method for adding args addArgs(...values: string[]): CustomID { if (!values) return this; diff --git a/src/utils/moderation/blockedWords.ts b/src/utils/moderation/blockedWords.ts new file mode 100644 index 000000000..8af98362d --- /dev/null +++ b/src/utils/moderation/blockedWords.ts @@ -0,0 +1,98 @@ +import { emojis, numberEmojis } from '#main/config/Constants.js'; +import { CustomID } from '#utils/CustomID.js'; +import { InfoEmbed } from '#utils/EmbedUtils.js'; +import { MessageBlockList } from '@prisma/client'; +import { stripIndents } from 'common-tags'; +import { + codeBlock, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; + +export function createRegexFromWords(words: string | string[]) { + if (Array.isArray(words)) return createRegexFromWords(words.join(',')); + + const formattedWords = words.split(',').map((w) => `\\b${w}\\b`); + return new RegExp(formattedWords.join('|'), 'gi'); +} + +export const sanitizeWords = (words: string) => + words + // Escape special regex characters, except '*' and ',' + .replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') + // Replace '*' with '.*' for wildcards + .replace(/\\\*/g, '.*') + .split(',') + .map((word) => word.trim()) + .join(','); + +export const buildBlockWordsListEmbed = (rules: MessageBlockList[]) => + new InfoEmbed() + .removeTitle() + .setDescription( + stripIndents` + ### ${emojis.exclamation} Blocked Words + This hub has **${rules.length}**/2 blocked word rules. + `, + ) + .addFields( + rules.map(({ words, name }, index) => ({ + name: `${numberEmojis[index + 1]}: ${name}`, + value: codeBlock(words.replace(/\.\*/g, '*')), + })), + ) + .setFooter({ text: 'Click the button below to add more words' }); + +export const buildModifyBlockedWordsBtn = (hubId: string, rules: MessageBlockList[]) => + new ActionRowBuilder().addComponents( + rules.map(({ id, name }, index) => + new ButtonBuilder() + .setCustomId(new CustomID('blockwordsButton:modify', [hubId, id]).toString()) + .setLabel(`Modify ${name}`) + .setEmoji(numberEmojis[index + 1]) + .setStyle(ButtonStyle.Secondary), + ), + ); + +export const buildBlockWordsModal = (hubId: string, opts?: { presetRule: MessageBlockList }) => { + const customId = new CustomID('blockwordsModal', [hubId]); + const modal = new ModalBuilder() + .setTitle(opts?.presetRule ? `Edit Block Rule ${opts.presetRule.name}` : 'Add Block Word Rule') + .setCustomId(customId.toString()) + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('name') + .setStyle(TextInputStyle.Short) + .setLabel('Rule Name') + .setMinLength(3) + .setMaxLength(40) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('words') + .setStyle(TextInputStyle.Paragraph) + .setLabel( + opts?.presetRule + ? 'Edit words (leave empty to delete)' + : 'Words seperated by comma (use * for wildcard)', + ) + .setPlaceholder('word1, *word2*, *word3, word4*') + .setMinLength(3) + .setRequired(!opts?.presetRule), + ), + ); + + if (opts?.presetRule) { + modal.setCustomId(customId.addArgs(hubId, opts.presetRule.id).toString()); + modal.components[0].components[0].setValue(opts.presetRule.name); + modal.components[1].components[0].setValue(opts.presetRule.words.replace(/\.\*/g, '*')); + } + + return modal; +}; diff --git a/src/utils/network/Types.d.ts b/src/utils/network/Types.d.ts index 57e8c2e1e..a39393b85 100644 --- a/src/utils/network/Types.d.ts +++ b/src/utils/network/Types.d.ts @@ -1,4 +1,5 @@ -import type { originalMessages, broadcastedMessages, userData } from '@prisma/client'; +import type { Broadcast, OriginalMessage } from '#main/utils/network/messageUtils.js'; +import type { UserData } from '@prisma/client'; import type { User, Message, @@ -10,11 +11,9 @@ import type { } from 'discord.js'; export interface ReferredMsgData { - dbReferrence: - | (originalMessages & { broadcastMsgs: Collection }) - | null; + dbReferrence: (OriginalMessage & { broadcastMsgs: Collection }) | null; referredAuthor: User | null; - dbReferredAuthor: userData | null; + dbReferredAuthor: UserData | null; referredMessage?: Message; }