From 047689749c564d4b41125c1ab025040bc5572701 Mon Sep 17 00:00:00 2001 From: Ar Rakin Date: Sun, 13 Oct 2024 17:27:24 +0600 Subject: [PATCH 1/8] feat: spam protection settings in `/setup` command Signed-off-by: Ar Rakin --- .vscode/settings.json | 22 +- .../typescript/services/GuildSetupService.ts | 386 +++++++++++++++++- 2 files changed, 405 insertions(+), 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 179417d0e..2e9961247 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -123,5 +123,25 @@ "typescript.inlayHints.variableTypes.enabled": true, "typescript.inlayHints.propertyDeclarationTypes.enabled": true, "typescript.inlayHints.variableTypes.suppressWhenTypeMatchesName": true, - "todo-tree.tree.showBadges": true + "todo-tree.tree.showBadges": true, + "files.exclude": { + "**/tmp/**": true, + "**/node_modules/**": true, + "**/.git/objects/**": true + }, + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true, + "**/node_modules/**": true, + "**/tmp/**": true, + "**/dist/**": true + }, + "search.exclude": { + "**/node_modules/**": true, + "**/dist/**": true, + "**/tmp/**": true, + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true + }, + "search.followSymlinks": false } diff --git a/src/main/typescript/services/GuildSetupService.ts b/src/main/typescript/services/GuildSetupService.ts index 66e0c50e5..08113446d 100644 --- a/src/main/typescript/services/GuildSetupService.ts +++ b/src/main/typescript/services/GuildSetupService.ts @@ -8,6 +8,7 @@ import { fetchChannel, fetchMember } from "@framework/utils/entities"; import { Colors } from "@main/constants/Colors"; import { AIAutoModSchema } from "@main/schemas/AIAutoModSchema"; import { LogEventType } from "@main/schemas/LoggingSchema"; +import { ModerationActionType } from "@main/schemas/ModerationActionSchema"; import type ConfigurationManager from "@main/services/ConfigurationManager"; import { emoji } from "@main/utils/emoji"; import { @@ -35,7 +36,8 @@ import { enum SetupOption { Prefix = "prefix", Logging = "logging", - AIBasedAutoMod = "ai_automod" + AIBasedAutoMod = "ai_automod", + SpamProtection = "spam_protection" } type SetupState = { @@ -50,7 +52,8 @@ class GuildSetupService extends Service implements HasEventListeners { private static readonly handlers: Record = { [SetupOption.Prefix]: "handlePrefixSetup", [SetupOption.Logging]: "handleLoggingSetup", - [SetupOption.AIBasedAutoMod]: "handleAIAutoModSetup" + [SetupOption.AIBasedAutoMod]: "handleAIAutoModSetup", + [SetupOption.SpamProtection]: "handleSpamProtectionSetup" }; private readonly inactivityTimeout: number = 120_000; private readonly setupState: Map<`${string}::${string}::${string}`, SetupState> = new Map(); @@ -143,6 +146,12 @@ class GuildSetupService extends Service implements HasEventListeners { value: SetupOption.AIBasedAutoMod, emoji: "🛡️", description: "Configure AI-powered automatic moderation for this server." + }, + { + label: "Spam Protection", + value: SetupOption.SpamProtection, + emoji: "🛡️", + description: "Configure AI-powered automatic moderation for this server." } ]) .setMinValues(1) @@ -443,6 +452,14 @@ class GuildSetupService extends Service implements HasEventListeners { interaction ); break; + case "spam_protection_modal": + await this.handleSpamProtectionThresholdsUpdate( + guildId, + interaction.user.id, + message.id, + interaction + ); + break; } return; @@ -471,6 +488,18 @@ class GuildSetupService extends Service implements HasEventListeners { message.id, interaction ); + + return; + } + + if (id === "spam_protection" && subId === "actions_select") { + await this.handleSpamProtectionActionsUpdate( + guildId, + interaction.user.id, + message.id, + interaction + ); + return; } } @@ -530,6 +559,29 @@ class GuildSetupService extends Service implements HasEventListeners { } break; + case "spam_protection": + switch (subId) { + case "actions": + await this.handleSpamProtectionActionsUpdateRequest( + guildId, + interaction.user.id, + message.id, + interaction + ); + break; + case "limits": + await this.handleSpamProtectionThresholdsUpdateRequest( + guildId, + interaction.user.id, + message.id, + interaction + ); + break; + default: + done = false; + } + break; + default: done = false; } @@ -1115,6 +1167,336 @@ class GuildSetupService extends Service implements HasEventListeners { ] }); } + + private spamProtectionButtons(guildId: string, enable = true) { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`setup::${guildId}::spam_protection::actions`) + .setLabel("Actions") + .setStyle(ButtonStyle.Secondary) + .setDisabled(!enable), + new ButtonBuilder() + .setCustomId(`setup::${guildId}::spam_protection::limits`) + .setLabel("Thresholds") + .setStyle(ButtonStyle.Secondary) + .setDisabled(!enable) + ); + } + + public async handleSpamProtectionSetup( + guildId: string, + id: string, + messageId: string, + interaction: StringSelectMenuInteraction + ) { + await this.defer(interaction); + this.resetState(guildId, id, messageId); + + if (this.configManager.config[guildId]?.antispam?.enabled) { + await this.pushState(guildId, id, messageId, { + embeds: [ + this.embed(["Spam Protection"], "Spam protection is already enabled!", { + color: Colors.Danger + }) + ], + components: [ + this.selectMenu(guildId, true), + this.buttonRow(guildId, id, messageId, { + back: true, + cancel: true, + finish: false + }) + ] + }); + + return; + } + + await this.pushState(guildId, id, messageId, { + embeds: [ + this.embed(["Spam Protection"], "Please configure the following options.", { + color: Colors.Primary + }) + ], + components: [ + this.spamProtectionButtons(guildId), + this.selectMenu(guildId, true), + this.buttonRow(guildId, id, messageId, { + back: true, + cancel: true, + finish: false + }) + ] + }); + } + + private async handleSpamProtectionActionsUpdate( + guildId: string, + id: string, + messageId: string, + interaction: StringSelectMenuInteraction + ) { + await this.defer(interaction); + this.resetState(guildId, id, messageId); + + const actions = interaction.values as Array<"mute" | "kick" | "ban" | "clear">; + + if (!actions.length) { + await this.pushState(guildId, id, messageId, { + embeds: [ + this.embed( + ["Spam Protection", "Actions"], + `${emoji(this.application, "error")} Please select at least one action.`, + { + color: Colors.Danger + } + ) + ], + components: [ + this.spamProtectionButtons(guildId, false), + this.selectMenu(guildId, true), + this.buttonRow(guildId, id, messageId, { + back: true, + cancel: true, + finish: false + }) + ] + }); + + return; + } + + const state = this.setupState.get(`${guildId}::${id}::${messageId}`); + + if (state) { + state.finishable = true; + } + + this.configManager.config[guildId]!.antispam ??= { + enabled: true, + actions: [ + { + type: "mute", + duration: 2 * 60 * 60 * 1000, + notify: true, + reason: "AutoMod: Spam Detected" + } + ], + limit: 5, + timeframe: 10000, + channels: { list: [], mode: "exclude" } + }; + + this.configManager.config[guildId]!.antispam.actions = actions.map(action => ({ + type: action, + duration: action === "mute" ? 2 * 60 * 60 * 1000 : undefined, + notify: true, + reason: "AutoMod: Spam Detected" + })) as ModerationActionType[]; + + await this.configManager.write({ guild: true, system: false }); + + await this.pushState(guildId, id, messageId, { + embeds: [ + this.embed( + ["Spam Protection", "Actions"], + `${emoji(this.application, "check")} Successfully updated the spam protection actions.`, + { + color: Colors.Success + } + ) + ], + components: [ + this.spamProtectionButtons(guildId, true), + this.selectMenu(guildId, true), + this.buttonRow(guildId, id, messageId, { + back: true, + cancel: true, + finish: true + }) + ] + }); + } + + private async handleSpamProtectionActionsUpdateRequest( + guildId: string, + id: string, + messageId: string, + interaction: ButtonInteraction + ) { + await this.defer(interaction); + this.resetState(guildId, id, messageId); + + await this.pushState(guildId, id, messageId, { + embeds: [ + this.embed(["Spam Protection", "Actions"], "Please select the actions.", { + color: Colors.Primary + }) + ], + components: [ + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId(`setup::${guildId}::spam_protection::actions_select`) + .setPlaceholder("Select actions") + .setMinValues(1) + .setMaxValues(4) + .addOptions([ + { + label: "Mute for 2 hours", + value: "mute" + }, + { + label: "Kick", + value: "kick" + }, + { + label: "Ban", + value: "ban" + }, + { + label: "Clear Messages", + value: "clear" + } + ]) + ), + this.spamProtectionButtons(guildId, false), + this.selectMenu(guildId, true), + this.buttonRow(guildId, id, messageId, { + back: true, + cancel: true, + finish: false + }) + ] + }); + } + + private async handleSpamProtectionThresholdsUpdateRequest( + guildId: string, + id: string, + messageId: string, + interaction: ButtonInteraction + ) { + const state = this.setupState.get(`${guildId}::${id}::${messageId}`); + + if (!state) { + return; + } + + const modal = new ModalBuilder() + .setTitle("Spam Protection Thresholds") + .setCustomId(`setup::${guildId}::spam_protection_modal`) + .setComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setLabel("Threshold") + .setCustomId("threshold") + .setPlaceholder("Enter a threshold") + .setRequired(true) + .setStyle(TextInputStyle.Short) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setLabel("Timeframe") + .setCustomId("timeframe") + .setPlaceholder("Timeframe in milliseconds") + .setRequired(true) + .setStyle(TextInputStyle.Short) + ) + ); + + await interaction.showModal(modal).catch(this.application.logger.error); + } + + private async handleSpamProtectionThresholdsUpdate( + guildId: string, + id: string, + messageId: string, + interaction: ModalSubmitInteraction + ) { + await this.defer(interaction); + this.resetState(guildId, id, messageId); + + const threshold = interaction.fields.getTextInputValue("threshold"); + const timeframe = interaction.fields.getTextInputValue("timeframe"); + + if ( + !threshold || + !timeframe || + isNaN(+threshold) || + isNaN(+timeframe) || + -(-threshold) <= 0 || + -(-timeframe) <= 0 + ) { + await this.pushState(guildId, id, messageId, { + embeds: [ + this.embed( + ["Spam Protection", "Thresholds"], + `${emoji(this.application, "error")} Please provide a valid threshold and timeframe.`, + { + color: Colors.Danger + } + ) + ], + components: [ + this.spamProtectionButtons(guildId, false), + this.selectMenu(guildId, true), + this.buttonRow(guildId, id, messageId, { + back: true, + cancel: true, + finish: false + }) + ] + }); + + return; + } + + const state = this.setupState.get(`${guildId}::${id}::${messageId}`); + + if (state) { + state.finishable = true; + } + + this.configManager.config[guildId]!.antispam ??= { + enabled: true, + actions: [ + { + type: "mute", + duration: 2 * 60 * 60 * 1000, + notify: true, + reason: "AutoMod: Spam Detected" + } + ], + limit: +threshold, + timeframe: +timeframe, + channels: { list: [], mode: "exclude" } + }; + + this.configManager.config[guildId]!.antispam.limit = +threshold; + this.configManager.config[guildId]!.antispam.timeframe = +timeframe; + await this.configManager.write({ guild: true, system: false }); + + await this.pushState(guildId, id, messageId, { + embeds: [ + this.embed( + ["Spam Protection", "Thresholds"], + `${emoji(this.application, "check")} Successfully updated the spam protection thresholds.`, + { + color: Colors.Success + } + ) + ], + components: [ + this.spamProtectionButtons(guildId, true), + this.selectMenu(guildId, true), + this.buttonRow(guildId, id, messageId, { + back: true, + cancel: true, + finish: true + }) + ] + }); + } } export default GuildSetupService; From f3113d143edcd89a5b62a45253dbc0460d884cf4 Mon Sep 17 00:00:00 2001 From: Ar Rakin Date: Sun, 13 Oct 2024 17:35:49 +0600 Subject: [PATCH 2/8] chore: update versions Signed-off-by: Ar Rakin --- .bun-version | 2 +- .node-version | 2 +- blaze/wrapper/blaze_wrapper.properties | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bun-version b/.bun-version index 5166d134a..40e713d59 100644 --- a/.bun-version +++ b/.bun-version @@ -1 +1 @@ -1.1.26 +1.1.30 diff --git a/.node-version b/.node-version index c2aa6d768..728f7de5c 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -22.4.1 +22.9.0 diff --git a/blaze/wrapper/blaze_wrapper.properties b/blaze/wrapper/blaze_wrapper.properties index 4ee3c7c87..246371e2b 100644 --- a/blaze/wrapper/blaze_wrapper.properties +++ b/blaze/wrapper/blaze_wrapper.properties @@ -1,4 +1,4 @@ -node.version=22.4.1 -bun.version=1.1.26 +node.version=22.9.0 +bun.version=1.1.30 blaze.srcpath=blazebuild -blaze.version=1.0.0-beta.1 +blaze.version=1.0.0-beta.2 From 7b2c884f6e6535501062beba0a075672fc37cb9b Mon Sep 17 00:00:00 2001 From: Ar Rakin Date: Sun, 13 Oct 2024 17:38:27 +0600 Subject: [PATCH 3/8] deps: update Signed-off-by: Ar Rakin --- package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index f506b046e..58c317041 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "discord.js": "^14.16.3", "dot-object": "^2.1.5", "dotenv": "^16.4.5", - "drizzle-orm": "^0.32.1", + "drizzle-orm": "^0.34.1", "express": "^4.19.2", "express-rate-limit": "^7.3.1", "figlet": "^1.7.0", @@ -104,14 +104,14 @@ "devDependencies": { "@commitlint/cli": "^19.3.0", "@commitlint/config-conventional": "^19.2.2", - "@faker-js/faker": "^8.4.1", + "@faker-js/faker": "^9.0.3", "@onesoftnet/pm2-config": "^0.0.7", "@types/archiver": "^6.0.2", "@types/bcrypt": "^5.0.2", "@types/bun": "latest", "@types/cors": "^2.8.17", "@types/dot-object": "^2.1.6", - "@types/express": "^4.17.21", + "@types/express": "^5.0.0", "@types/figlet": "^1.5.8", "@types/glob": "^8.1.0", "@types/jsonwebtoken": "^9.0.6", @@ -123,7 +123,7 @@ "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.16.1", "@typescript-eslint/parser": "^7.16.1", - "@vitest/coverage-v8": "^1.6.0", + "@vitest/coverage-v8": "^2.1.2", "eslint": "^8.57.0", "husky": "^9.1.3", "prettier": "^3.3.3", @@ -132,7 +132,7 @@ "typedoc-plugin-rename-defaults": "^0.7.1", "typescript": "^5.5.3", "typescript-eslint": "^7.16.1", - "vitest": "^1.6.0", + "vitest": "^2.1.2", "zod-to-json-schema": "^3.23.1" } } From 0345614f9536dddc1688b455f7ee2b0b5b845f53 Mon Sep 17 00:00:00 2001 From: Ar Rakin Date: Sun, 13 Oct 2024 17:41:32 +0600 Subject: [PATCH 4/8] chore: remove lockfiles Signed-off-by: Ar Rakin --- blazebuild/.gitignore | 1 + blazebuild/bun.lockb | Bin 54910 -> 0 bytes 2 files changed, 1 insertion(+) delete mode 100755 blazebuild/bun.lockb diff --git a/blazebuild/.gitignore b/blazebuild/.gitignore index 9b1ee42e8..c2ca96e2d 100644 --- a/blazebuild/.gitignore +++ b/blazebuild/.gitignore @@ -24,6 +24,7 @@ pids _.pid _.seed *.pid.lock +/bun.lockb # Directory for instrumented libs generated by jscoverage/JSCover diff --git a/blazebuild/bun.lockb b/blazebuild/bun.lockb deleted file mode 100755 index 7c2c3143411251d9a0df250a26dd7c1f76658c12..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54910 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 G_xTTC(tY>< From 1e91824dc8b1d9a413d8143293e82ff3dabadc9f Mon Sep 17 00:00:00 2001 From: Ar Rakin Date: Wed, 16 Oct 2024 17:50:17 +0600 Subject: [PATCH 5/8] fix: allow modifications of settings even after setup Signed-off-by: Ar Rakin --- .../typescript/services/GuildSetupService.ts | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/src/main/typescript/services/GuildSetupService.ts b/src/main/typescript/services/GuildSetupService.ts index 08113446d..5cbe00ea4 100644 --- a/src/main/typescript/services/GuildSetupService.ts +++ b/src/main/typescript/services/GuildSetupService.ts @@ -1192,26 +1192,6 @@ class GuildSetupService extends Service implements HasEventListeners { await this.defer(interaction); this.resetState(guildId, id, messageId); - if (this.configManager.config[guildId]?.antispam?.enabled) { - await this.pushState(guildId, id, messageId, { - embeds: [ - this.embed(["Spam Protection"], "Spam protection is already enabled!", { - color: Colors.Danger - }) - ], - components: [ - this.selectMenu(guildId, true), - this.buttonRow(guildId, id, messageId, { - back: true, - cancel: true, - finish: false - }) - ] - }); - - return; - } - await this.pushState(guildId, id, messageId, { embeds: [ this.embed(["Spam Protection"], "Please configure the following options.", { From bf14d00b2ff39bd336cfabba014d08ea41f355f6 Mon Sep 17 00:00:00 2001 From: Ar Rakin Date: Fri, 18 Oct 2024 20:43:24 +0600 Subject: [PATCH 6/8] chore: update tsconfig and build tasks Signed-off-by: Ar Rakin --- build_src/src/main/typescript/tasks/CompileTask.ts | 5 ----- .../main/typescript/tasks/CompileTypeScriptTask.ts | 11 +++++++++++ tsconfig.json | 11 ++++------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/build_src/src/main/typescript/tasks/CompileTask.ts b/build_src/src/main/typescript/tasks/CompileTask.ts index 572f84bc8..4662d43ae 100644 --- a/build_src/src/main/typescript/tasks/CompileTask.ts +++ b/build_src/src/main/typescript/tasks/CompileTask.ts @@ -8,7 +8,6 @@ import { files, type Awaitable } from "blazebuild"; -import { $ } from "bun"; import path from "path"; @Task({ @@ -24,10 +23,6 @@ class CompileTask extends AbstractTask { if (!buildOutputDirectory) { throw new Error("buildOutputDirectory is not defined in project properties"); } - - await $`mv ${buildOutputDirectory}/out/src ${buildOutputDirectory}/out.tmp`; - await $`rm -rf ${buildOutputDirectory}/out`; - await $`mv ${buildOutputDirectory}/out.tmp ${buildOutputDirectory}/out`; } @TaskDependencyGenerator diff --git a/build_src/src/main/typescript/tasks/CompileTypeScriptTask.ts b/build_src/src/main/typescript/tasks/CompileTypeScriptTask.ts index 0e4967250..6de861a57 100644 --- a/build_src/src/main/typescript/tasks/CompileTypeScriptTask.ts +++ b/build_src/src/main/typescript/tasks/CompileTypeScriptTask.ts @@ -21,6 +21,17 @@ class CompileTypeScriptTask extends AbstractTask { protected override async run(): Promise { IO.newline(); await $`bun x tsc`; + + const buildOutputDirectory = + this.blaze.projectManager.properties.structure?.buildOutputDirectory; + + if (!buildOutputDirectory) { + throw new Error("buildOutputDirectory is not defined in project properties"); + } + + await $`mv ${buildOutputDirectory}/out/src ${buildOutputDirectory}/out.tmp`; + await $`rm -rf ${buildOutputDirectory}/out`; + await $`mv ${buildOutputDirectory}/out.tmp ${buildOutputDirectory}/out`; } @TaskInputGenerator diff --git a/tsconfig.json b/tsconfig.json index 277a5d2fe..99f0ee5b8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,12 +33,7 @@ "@root/*": ["./*"] } }, - "include": [ - "src/main/typescript/types/global/globals.d.ts", - "src/**/*.ts", - "drizzle.config.ts", - "commitlint.config.ts" - ], + "include": ["src/main/typescript/types/global/globals.d.ts", "src/**/*.ts"], "exclude": [ "./backup", "./tmp", @@ -58,6 +53,8 @@ "./build_src/**", "./node_modules", "./storage", - "build.blaze.ts" + "build.blaze.ts", + "drizzle.config.ts", + "commitlint.config.ts" ] } From ee6817efeadf2e05baf3e3a3f22bd5358c667119 Mon Sep 17 00:00:00 2001 From: Ar Rakin Date: Sun, 20 Oct 2024 22:45:39 +0600 Subject: [PATCH 7/8] feat: add message moderation rule setup option [1/2] Signed-off-by: Ar Rakin --- .../typescript/services/GuildSetupService.ts | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/main/typescript/services/GuildSetupService.ts b/src/main/typescript/services/GuildSetupService.ts index 0b50caf86..f4670e158 100644 --- a/src/main/typescript/services/GuildSetupService.ts +++ b/src/main/typescript/services/GuildSetupService.ts @@ -37,7 +37,8 @@ enum SetupOption { Prefix = "prefix", Logging = "logging", AIBasedAutoMod = "ai_automod", - SpamProtection = "spam_protection" + SpamProtection = "spam_protection", + ModerationRules = "moderation_rules" } type SetupState = { @@ -53,7 +54,8 @@ class GuildSetupService extends Service implements HasEventListeners { [SetupOption.Prefix]: "handlePrefixSetup", [SetupOption.Logging]: "handleLoggingSetup", [SetupOption.AIBasedAutoMod]: "handleAIAutoModSetup", - [SetupOption.SpamProtection]: "handleSpamProtectionSetup" + [SetupOption.SpamProtection]: "handleSpamProtectionSetup", + [SetupOption.ModerationRules]: "handleModerationRulesSetup" }; private readonly inactivityTimeout: number = 120_000; private readonly setupState: Map<`${string}::${string}::${string}`, SetupState> = new Map(); @@ -152,6 +154,12 @@ class GuildSetupService extends Service implements HasEventListeners { value: SetupOption.SpamProtection, emoji: "🛡️", description: "Configure AI-powered automatic moderation for this server." + }, + { + label: "Moderation Rules", + value: SetupOption.ModerationRules, + emoji: "🛠️", + description: "Configure message moderation rules for this server." } ]) .setMinValues(1) @@ -1184,6 +1192,38 @@ class GuildSetupService extends Service implements HasEventListeners { ); } + public async handleModerationRulesSetup( + guildId: string, + id: string, + messageId: string, + interaction: StringSelectMenuInteraction + ) { + await this.defer(interaction); + this.resetState(guildId, id, messageId); + + // TODO + + await this.pushState(guildId, id, messageId, { + embeds: [ + this.embed( + ["Message Moderation Rules"], + "Please configure the following options.", + { + color: Colors.Primary + } + ) + ], + components: [ + this.selectMenu(guildId, true), + this.buttonRow(guildId, id, messageId, { + back: true, + cancel: true, + finish: false + }) + ] + }); + } + public async handleSpamProtectionSetup( guildId: string, id: string, From 44530a7f0337e81df3091a8c15cb34530c4c5235 Mon Sep 17 00:00:00 2001 From: Ar Rakin Date: Mon, 21 Oct 2024 13:43:20 +0000 Subject: [PATCH 8/8] feat(guildsetup): basic word blocking settings Signed-off-by: GitHub --- .../typescript/services/GuildSetupService.ts | 368 +++++++++++++++++- 1 file changed, 365 insertions(+), 3 deletions(-) diff --git a/src/main/typescript/services/GuildSetupService.ts b/src/main/typescript/services/GuildSetupService.ts index f4670e158..d4c490401 100644 --- a/src/main/typescript/services/GuildSetupService.ts +++ b/src/main/typescript/services/GuildSetupService.ts @@ -469,6 +469,14 @@ class GuildSetupService extends Service implements HasEventListeners { interaction ); break; + case "rule_moderation_word_modal": + await this.handleModerationRuleAddWordModal( + guildId, + interaction.user.id, + message.id, + interaction + ); + break; } return; @@ -511,6 +519,17 @@ class GuildSetupService extends Service implements HasEventListeners { return; } + + if (id === "rule_moderation" && subId === "actions_select") { + await this.handleModerationRuleActionsUpdate( + guildId, + interaction.user.id, + message.id, + interaction + ); + + return; + } } if (interaction.isButton() && interaction.customId.startsWith("setup::")) { @@ -591,6 +610,31 @@ class GuildSetupService extends Service implements HasEventListeners { } break; + case "rule_moderation": + switch (subId) { + case "words": + await this.handleModerationRuleWordAdd( + guildId, + interaction.user.id, + message.id, + interaction + ); + break; + + case "create": + await this.handleModerationRuleCreate( + guildId, + interaction.user.id, + message.id, + interaction + ); + break; + default: + done = false; + } + + break; + default: done = false; } @@ -1201,19 +1245,336 @@ class GuildSetupService extends Service implements HasEventListeners { await this.defer(interaction); this.resetState(guildId, id, messageId); - // TODO - await this.pushState(guildId, id, messageId, { embeds: [ this.embed( ["Message Moderation Rules"], - "Please configure the following options.", + "Configure moderation rules for this server.", { color: Colors.Primary } ) ], components: [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`setup::${guildId}::rule_moderation::create`) + .setLabel("Create keyword rule") + .setStyle(ButtonStyle.Secondary) + ), + this.selectMenu(guildId, true), + this.buttonRow(guildId, id, messageId, { + back: true, + cancel: true, + finish: false + }) + ] + }); + } + + public async handleModerationRuleAddWordModal( + guildId: string, + id: string, + messageId: string, + interaction: ModalSubmitInteraction + ) { + await this.defer(interaction); + this.resetState(guildId, id, messageId); + + const keywords = + interaction.fields.getTextInputValue("keywords")?.split(/\s+/)?.filter(Boolean) || []; + + if (keywords.length === 0) { + await this.pushState(guildId, id, messageId, { + embeds: [ + this.embed( + ["Message Moderation Rules", "Create Rule", "Add Keywords"], + `${emoji(this.application, "error")} Please provide at least one keyword.`, + { + color: Colors.Danger + } + ) + ], + components: [ + ...this.moderationRuleCreateActionRow(guildId, true), + this.selectMenu(guildId, true), + this.buttonRow(guildId, id, messageId, { + back: true, + cancel: true + }) + ] + }); + + return; + } + + const state = this.setupState.get(`${guildId}::${id}::${messageId}`); + + if (state) { + state.finishable = true; + + this.configManager.config[guildId]!.rule_moderation ??= { + enabled: true, + rules: [], + global_disabled_channels: [] + }; + + const firstWordFilterIndex = this.configManager.config[ + guildId + ]!.rule_moderation.rules.findIndex(rule => rule.type === "word_filter"); + + if (firstWordFilterIndex !== -1) { + ( + this.configManager.config[guildId]!.rule_moderation.rules[ + firstWordFilterIndex + ] as { words: string[] } + ).words = keywords; + } else { + this.configManager.config[guildId]!.rule_moderation.rules.push({ + type: "word_filter", + words: keywords, + actions: [], + bail: false, + bypasses: [], + enabled: true, + is_bypasser: false, + name: "Word Filter", + mode: "normal", + normalize: true, + tokens: [], + exceptions: {}, + for: {} + }); + } + + await this.configManager.write({ guild: true, system: false }); + await this.configManager.load(); + + await this.pushState(guildId, id, messageId, { + embeds: [ + this.embed( + ["Message Moderation Rules", "Create Rule", "Add Keywords"], + `${emoji(this.application, "check")} Successfully added keywords for this rule.`, + { + color: Colors.Success + } + ) + ], + components: [ + ...this.moderationRuleCreateActionRow(guildId), + this.selectMenu(guildId, true), + this.buttonRow(guildId, id, messageId, { + back: true, + cancel: true, + finish: true + }) + ] + }); + } + } + + public async handleModerationRuleActionsUpdate( + guildId: string, + id: string, + messageId: string, + interaction: StringSelectMenuInteraction + ) { + await this.defer(interaction); + this.resetState(guildId, id, messageId); + + const actions = interaction.values as Array< + "delete_message" | "mute" | "kick" | "ban" | "clear" + >; + + if (!actions.length) { + await this.pushState(guildId, id, messageId, { + embeds: [ + this.embed( + ["Message Moderation Rules", "Create Rule", "Actions"], + `${emoji(this.application, "error")} Please select at least one action.`, + { + color: Colors.Danger + } + ) + ], + components: [ + ...this.moderationRuleCreateActionRow(guildId, true), + this.selectMenu(guildId, true), + this.buttonRow(guildId, id, messageId, { + back: true, + cancel: true + }) + ] + }); + + return; + } + + const state = this.setupState.get(`${guildId}::${id}::${messageId}`); + + if (state) { + state.finishable = true; + + this.configManager.config[guildId]!.rule_moderation ??= { + enabled: true, + rules: [], + global_disabled_channels: [] + }; + + const finalActions = actions.map(action => ({ + type: action, + duration: action === "mute" ? 2 * 60 * 60 * 1000 : undefined, + notify: true, + reason: "AutoMod: Posted a blocked word" + })) as ModerationActionType[]; + + const firstWordFilterIndex = this.configManager.config[ + guildId + ]!.rule_moderation.rules.findIndex(rule => rule.type === "word_filter"); + + if (firstWordFilterIndex !== -1) { + this.configManager.config[guildId]!.rule_moderation.rules[ + firstWordFilterIndex + ].actions = finalActions; + } else { + this.configManager.config[guildId]!.rule_moderation.rules.push({ + type: "word_filter", + words: [], + actions: finalActions, + bail: false, + bypasses: [], + enabled: true, + is_bypasser: false, + name: "Word Filter", + mode: "normal", + normalize: true, + tokens: [], + exceptions: {}, + for: {} + }); + } + + await this.configManager.write({ guild: true, system: false }); + await this.configManager.load(); + + await this.pushState(guildId, id, messageId, { + embeds: [ + this.embed( + ["Message Moderation Rules", "Create Rule", "Actions"], + `${emoji(this.application, "check")} Successfully added actions for this rule.`, + { + color: Colors.Success + } + ) + ], + components: [ + ...this.moderationRuleCreateActionRow(guildId), + this.selectMenu(guildId, true), + this.buttonRow(guildId, id, messageId, { + back: true, + cancel: true, + finish: true + }) + ] + }); + } + } + + public async handleModerationRuleWordAdd( + guildId: string, + id: string, + messageId: string, + interaction: ButtonInteraction + ) { + const state = this.setupState.get(`${guildId}::${id}::${messageId}`); + + if (!state) { + return; + } + + this.ping(guildId, id, messageId); + + const modal = new ModalBuilder() + .setTitle("Add Keywords") + .setCustomId(`setup::${guildId}::rule_moderation_word_modal`) + .setComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setLabel("Keywords") + .setCustomId("keywords") + .setPlaceholder("Enter keywords to add (separate with spaces)") + .setMinLength(1) + .setRequired(true) + .setStyle(TextInputStyle.Paragraph) + ) + ); + + await interaction.showModal(modal).catch(this.application.logger.error); + } + + private moderationRuleCreateActionRow(guildId: string, disabled = false) { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`setup::${guildId}::rule_moderation::words`) + .setLabel("Add keywords") + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled) + ), + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId(`setup::${guildId}::rule_moderation::actions_select`) + .setPlaceholder("Select actions") + .setMinValues(1) + .setMaxValues(4) + .addOptions([ + { + label: "Delete Flagged Message", + value: "delete_message" + }, + { + label: "Mute for 2 hours", + value: "mute" + }, + { + label: "Kick", + value: "kick" + }, + { + label: "Ban", + value: "ban" + }, + { + label: "Clear Messages", + value: "clear" + } + ]) + .setDisabled(disabled) + ) + ]; + } + + public async handleModerationRuleCreate( + guildId: string, + id: string, + messageId: string, + interaction: ButtonInteraction + ) { + await this.defer(interaction); + this.resetState(guildId, id, messageId); + + await this.pushState(guildId, id, messageId, { + embeds: [ + this.embed( + ["Message Moderation Rules", "Create Rule"], + "Configure moderation rules for this server.", + { + color: Colors.Primary + } + ) + ], + components: [ + ...this.moderationRuleCreateActionRow(guildId), this.selectMenu(guildId, true), this.buttonRow(guildId, id, messageId, { back: true, @@ -1316,6 +1677,7 @@ class GuildSetupService extends Service implements HasEventListeners { })) as ModerationActionType[]; await this.configManager.write({ guild: true, system: false }); + await this.configManager.load(); await this.pushState(guildId, id, messageId, { embeds: [