diff options
author | IRONM00N <64110067+IRONM00N@users.noreply.github.com> | 2021-10-12 20:27:37 -0400 |
---|---|---|
committer | IRONM00N <64110067+IRONM00N@users.noreply.github.com> | 2021-10-12 20:27:37 -0400 |
commit | ba2d7b7db0a627234ed08de9d6bec8cb675404a7 (patch) | |
tree | 9ade9ed549b52eac3f2966a5cee5478267eca7c4 | |
parent | cac6abf3efd563b83f8f0ce70ce4bcfa5ada1a27 (diff) | |
download | tanzanite-ba2d7b7db0a627234ed08de9d6bec8cb675404a7.tar.gz tanzanite-ba2d7b7db0a627234ed08de9d6bec8cb675404a7.tar.bz2 tanzanite-ba2d7b7db0a627234ed08de9d6bec8cb675404a7.zip |
revamp automod, refactoring, fixes
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | lib/badwords.json5 | 56 | ||||
-rw-r--r-- | package.json | 6 | ||||
-rw-r--r-- | src/commands/info/botInfo.ts | 2 | ||||
-rw-r--r-- | src/commands/moderation/ban.ts | 5 | ||||
-rw-r--r-- | src/commands/moderation/kick.ts | 3 | ||||
-rw-r--r-- | src/commands/moderation/modlog.ts | 2 | ||||
-rw-r--r-- | src/commands/moderation/mute.ts | 3 | ||||
-rw-r--r-- | src/commands/moderation/unmute.ts | 3 | ||||
-rw-r--r-- | src/commands/moderation/warn.ts | 3 | ||||
-rw-r--r-- | src/lib/badlinks.ts (renamed from lib/badlinks.json5) | 8 | ||||
-rw-r--r-- | src/lib/badwords.ts | 242 | ||||
-rw-r--r-- | src/lib/common/autoMod.ts | 236 | ||||
-rw-r--r-- | src/lib/common/moderation.ts | 181 | ||||
-rw-r--r-- | src/lib/extensions/discord-akairo/BushClientUtil.ts | 361 | ||||
-rw-r--r-- | src/lib/extensions/discord.js/BushGuild.ts | 22 | ||||
-rw-r--r-- | src/lib/extensions/discord.js/BushGuildMember.ts | 25 | ||||
-rw-r--r-- | src/lib/models/Guild.ts | 25 | ||||
-rw-r--r-- | src/listeners/client/interactionCreate.ts | 5 | ||||
-rw-r--r-- | src/listeners/message/automodCreate.ts | 113 | ||||
-rw-r--r-- | src/listeners/message/automodUpdate.ts | 4 | ||||
-rw-r--r-- | tsconfig.json | 29 | ||||
-rw-r--r-- | yarn.lock | 12 |
23 files changed, 860 insertions, 488 deletions
@@ -36,4 +36,4 @@ dist # Options and credentials for the bot src/config/options.ts -lib/badlinks-secret.json5 +src/lib/badlinks-secret.ts diff --git a/lib/badwords.json5 b/lib/badwords.json5 deleted file mode 100644 index eca13ca..0000000 --- a/lib/badwords.json5 +++ /dev/null @@ -1,56 +0,0 @@ -/* Severities: - - 0: Delete message - - 1: Delete message and warn user - - 2: Delete message and mute user for 15 minutes - - 3: Delete message and mute user permanently - */ -{ - /* Slurs */ - "faggot": 2, - "nigga": 3, - "nigger": 3, - "nigra": 3, - "retard": 2, - "retarted": 2, - "slut": 1, - "tar baby": 2, - "whore": 1, - "卍": 3, - - /* Steam Scams */ - 'Я в тильте, в кс дали статус "Ненадежный"': 1, // I'm on tilt, in the cop they gave the status "Unreliable" - "hello i am leaving cs:go": 3, - "hello! I'm done with csgo": 3, - "hi bro, i'm leaving this fucking game, take my skin": 3, - "hi friend, today i am leaving this fucking game": 3, - "hi guys, i'm leaving this fucking game, take my": 3, - "hi, bro h am leaving cs:go and giving away my skin": 3, - "hi, bro i am leaving cs:go and giving away my skin": 3, - "i confirm all exchanges, there won't be enough": 3, - "i quit csgo": 3, - "the first three who send a trade": 3, - "you can choose any skin for yourself": 3, - - /* Nitro Scams */ - "and there is discord hallween's giveaway": 3, - "discord nitro for free - steam store": 3, - "free 3 months of discord nitro": 3, - "free discord nitro airdrop": 3, - "get 3 months of discord nitro": 3, - "get discord nitro for free": 3, - "get free discord nitro from steam": 3, - "lol, jahjajha free discord nitro for 3 month!!": 3, - "steam is giving away 3 months of discord nitro for free to all no limited steam users": 3, - "Лол, бесплатный дискорд нитро на 1 месяц!": 3, // Lol, 1 month free discord nitro! - "Airdrop Discord FREE NITRO from Steam —": 3, - - /* Misc Scams */ - "found a cool software that improves the": 3, - - /* Frequently Advertised Discord Severs */ - "https://discord.gg/7CaCvDXs": 2, - - // 'tm5LcYN': 3, - // '5gwLaOZ.png': 3, - // 'hYKoQoU4bss': 3, -} diff --git a/package.json b/package.json index 084df05..22fb91b 100644 --- a/package.json +++ b/package.json @@ -167,9 +167,9 @@ }, { "files": [ - "badwords.json5", - "badlinks.json5", - "badlinks-secret.json5" + "badwords.ts", + "badlinks.ts", + "badlinks-secret.ts" ], "options": { "singleQuote": false, diff --git a/src/commands/info/botInfo.ts b/src/commands/info/botInfo.ts index 8f85fe6..257dc90 100644 --- a/src/commands/info/botInfo.ts +++ b/src/commands/info/botInfo.ts @@ -53,7 +53,7 @@ export default class BotInfoCommand extends BushCommand { ) .addField('**CPU Usage**', `${client.stats.cpu}%`, true) .addField('**Platform**', Platform[process.platform], true) - .addField('**Commands Used**', `${client.stats.commandsUsed}`, true) + .addField('**Commands Used**', `${client.stats.commandsUsed.toLocaleString()}`, true) .addField('**Servers**', client.guilds.cache.size.toLocaleString(), true) .addField('**Users**', client.users.cache.size.toLocaleString(), true) .addField('**Discord.js Version**', discordJSVersion, true) diff --git a/src/commands/moderation/ban.ts b/src/commands/moderation/ban.ts index b64330f..4c2b3d3 100644 --- a/src/commands/moderation/ban.ts +++ b/src/commands/moderation/ban.ts @@ -1,5 +1,6 @@ import { AllowedMentions, BushCommand, BushMessage, BushSlashMessage } from '@lib'; import { Snowflake, User } from 'discord.js'; +import { Moderation } from '../../lib/common/moderation'; export default class BanCommand extends BushCommand { public constructor() { @@ -103,9 +104,7 @@ export default class BanCommand extends BushCommand { const useForce = force && message.author.isOwner(); if (!message.member) throw new Error(`message.member is null`); - const canModerateResponse = member - ? await util.moderationPermissionCheck(message.member, member, 'ban', true, useForce) - : true; + const canModerateResponse = member ? await Moderation.permissionCheck(message.member, member, 'ban', true, useForce) : true; if (canModerateResponse !== true) { return await message.util.reply(canModerateResponse); diff --git a/src/commands/moderation/kick.ts b/src/commands/moderation/kick.ts index 07c25ab..715483a 100644 --- a/src/commands/moderation/kick.ts +++ b/src/commands/moderation/kick.ts @@ -1,4 +1,5 @@ import { AllowedMentions, BushCommand, BushGuildMember, BushMessage, BushSlashMessage, BushUser } from '@lib'; +import { Moderation } from '../../lib/common/moderation'; export default class KickCommand extends BushCommand { public constructor() { @@ -67,7 +68,7 @@ export default class KickCommand extends BushCommand { ); if (!message.member) throw new Error(`message.member is null`); const useForce = force && message.author.isOwner(); - const canModerateResponse = await util.moderationPermissionCheck(message.member, member, 'kick', true, useForce); + const canModerateResponse = await Moderation.permissionCheck(message.member, member, 'kick', true, useForce); if (canModerateResponse !== true) { return message.util.reply(canModerateResponse); diff --git a/src/commands/moderation/modlog.ts b/src/commands/moderation/modlog.ts index 0eb7392..d5c6f91 100644 --- a/src/commands/moderation/modlog.ts +++ b/src/commands/moderation/modlog.ts @@ -48,7 +48,7 @@ export default class ModlogCommand extends BushCommand { #generateModlogInfo(log: ModLog, showUser: boolean): string { const trim = (str: string): string => (str.endsWith('\n') ? str.substring(0, str.length - 1).trim() : str.trim()); - const modLog = [`**Case ID**: ${log.id}`, `**Type**: ${log.type.toLowerCase()}`]; + const modLog = [`**Case ID**: ${util.discord.escapeItalic(log.id)}`, `**Type**: ${log.type.toLowerCase()}`]; if (showUser) modLog.push(`**User**: <@!${log.user}>`); modLog.push(`**Moderator**: <@!${log.moderator}>`); if (log.duration) modLog.push(`**Duration**: ${util.humanizeDuration(log.duration)}`); diff --git a/src/commands/moderation/mute.ts b/src/commands/moderation/mute.ts index 9e68d63..942c0b0 100644 --- a/src/commands/moderation/mute.ts +++ b/src/commands/moderation/mute.ts @@ -1,4 +1,5 @@ import { AllowedMentions, BushCommand, BushMessage, BushSlashMessage, BushUser } from '@lib'; +import { Moderation } from '../../lib/common/moderation'; export default class MuteCommand extends BushCommand { public constructor() { @@ -73,7 +74,7 @@ export default class MuteCommand extends BushCommand { if (!message.member) throw new Error(`message.member is null`); const useForce = force && message.author.isOwner(); - const canModerateResponse = await util.moderationPermissionCheck(message.member, member, 'mute', true, useForce); + const canModerateResponse = await Moderation.permissionCheck(message.member, member, 'mute', true, useForce); const victimBoldTag = `**${member.user.tag}**`; if (canModerateResponse !== true) { diff --git a/src/commands/moderation/unmute.ts b/src/commands/moderation/unmute.ts index 680c7ba..3d592b7 100644 --- a/src/commands/moderation/unmute.ts +++ b/src/commands/moderation/unmute.ts @@ -1,4 +1,5 @@ import { AllowedMentions, BushCommand, BushGuildMember, BushMessage, BushSlashMessage, BushUser } from '@lib'; +import { Moderation } from '../../lib/common/moderation'; export default class UnmuteCommand extends BushCommand { public constructor() { @@ -67,7 +68,7 @@ export default class UnmuteCommand extends BushCommand { const useForce = force && message.author.isOwner(); - const canModerateResponse = await util.moderationPermissionCheck(message.member, member, 'unmute', true, useForce); + const canModerateResponse = await Moderation.permissionCheck(message.member, member, 'unmute', true, useForce); const victimBoldTag = `**${member.user.tag}**`; diff --git a/src/commands/moderation/warn.ts b/src/commands/moderation/warn.ts index b4bf74d..3df4b3b 100644 --- a/src/commands/moderation/warn.ts +++ b/src/commands/moderation/warn.ts @@ -1,4 +1,5 @@ import { BushCommand, BushGuildMember, BushMessage, BushSlashMessage, BushUser } from '@lib'; +import { Moderation } from '../../lib/common/moderation'; export default class WarnCommand extends BushCommand { public constructor() { @@ -63,7 +64,7 @@ export default class WarnCommand extends BushCommand { if (!member) return message.util.reply(`${util.emojis.error} I cannot warn users that are not in the server.`); const useForce = force && message.author.isOwner(); if (!message.member) throw new Error(`message.member is null`); - const canModerateResponse = await util.moderationPermissionCheck(message.member, member, 'warn', true, useForce); + const canModerateResponse = await Moderation.permissionCheck(message.member, member, 'warn', true, useForce); const victimBoldTag = `**${member.user.tag}**`; if (canModerateResponse !== true) { diff --git a/lib/badlinks.json5 b/src/lib/badlinks.ts index 8fcfec0..67f9679 100644 --- a/lib/badlinks.json5 +++ b/src/lib/badlinks.ts @@ -1,5 +1,7 @@ -/* Links in this file are treated as severity 3 offences. */ -[ +/* Links in this file are treated as severity 3 offences. + +made in part possible by https://github.com/nacrt/SkyblockClient-REPO/blob/main/files/scamlinks.json */ +export default [ "acercup.com", "affix-cup.ru", "affix-sport.ru", @@ -405,4 +407,4 @@ "winskins.top", "wintheskin.xyz", "xgamercup.com", -] +]; diff --git a/src/lib/badwords.ts b/src/lib/badwords.ts new file mode 100644 index 0000000..c5fbf2d --- /dev/null +++ b/src/lib/badwords.ts @@ -0,0 +1,242 @@ +import { BadWords, Severity } from "./common/automod"; + +export default { + /* -------------------------------------------------------------------------- */ + /* Slurs */ + /* -------------------------------------------------------------------------- */ + "faggot": { + severity: Severity.TEMP_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "racial slur", + }, + "nigga": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "racial slur", + }, + "nigger": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "racial slur", + }, + "nigra": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "racial slur", + }, + "retard": { + severity: Severity.TEMP_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "ableist slur", + }, + "retarted": { + severity: Severity.TEMP_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "ableist slur", + }, + "slut": { + severity: Severity.WARN, + ignoreSpaces: false, + ignoreCapitalization: true, + reason: "derogatory term", + }, + "tar baby": { + severity: Severity.TEMP_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "racial slur", + }, + "whore": { + severity: Severity.WARN, + ignoreSpaces: false, + ignoreCapitalization: true, + reason: "derogatory term", + }, + "卍": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "racist symbol", + }, + + /* -------------------------------------------------------------------------- */ + /* Steam Scams */ + /* -------------------------------------------------------------------------- */ + 'Я в тильте, в кс дали статус "Ненадежный"': { + //? I'm on tilt, in the cop they gave the status "Unreliable" + severity: Severity.WARN, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + }, + "hello i am leaving cs:go": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + }, + "hello! I'm done with csgo": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + }, + "hi bro, i'm leaving this fucking game, take my skin": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + }, + "hi friend, today i am leaving this fucking game": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + }, + "hi guys, i'm leaving this fucking game, take my": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + }, + "hi, bro h am leaving cs:go and giving away my skin": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + }, + "hi, bro i am leaving cs:go and giving away my skin": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + }, + "i confirm all exchanges, there won't be enough": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + }, + "i quit csgo": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + }, + "the first three who send a trade": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + }, + "you can choose any skin for yourself": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + }, + + /* -------------------------------------------------------------------------- */ + /* Nitro Scams */ + /* -------------------------------------------------------------------------- */ + "and there is discord hallween's giveaway": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + }, + "discord nitro for free - steam store": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + }, + "free 3 months of discord nitro": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + }, + "free discord nitro airdrop": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + }, + "get 3 months of discord nitro": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + }, + "get discord nitro for free": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + }, + "get free discord nitro from steam": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + }, + "lol, jahjajha free discord nitro for 3 month!!": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + }, + "steam is giving away 3 months of discord nitro for free to all no limited steam users": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + }, + "Лол, бесплатный дискорд нитро на 1 месяц!": { + //? Lol, 1 month free discord nitro! + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + }, + "Airdrop Discord FREE NITRO from Steam —": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + }, + + /* -------------------------------------------------------------------------- */ + /* Misc Scams */ + /* -------------------------------------------------------------------------- */ + "found a cool software that improves the": { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "misc. scam phrase", + }, + "there is a possible chance tomorrow there will be a cyber-attack event where on all social networks including Discord there will be people trying": + { + severity: Severity.WARN, + ignoreSpaces: false, + ignoreCapitalization: true, + reason: "annoying copy pasta", + }, + + /* -------------------------------------------------------------------------- */ + /* Frequently Advertised Discord Severs */ + /* -------------------------------------------------------------------------- */ + "https://discord.gg/7CaCvDXs": { + severity: Severity.TEMP_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "blacklisted server link", + }, +} as BadWords; diff --git a/src/lib/common/autoMod.ts b/src/lib/common/autoMod.ts new file mode 100644 index 0000000..10bccba --- /dev/null +++ b/src/lib/common/autoMod.ts @@ -0,0 +1,236 @@ +import { Formatters, MessageActionRow, MessageButton, MessageEmbed, TextChannel } from 'discord.js'; +import badLinksArray from '../../lib/badlinks'; +import badLinksSecretArray from '../../lib/badlinks-secret'; // I cannot make this public so just make a new file that export defaults an empty array +import badWords from '../../lib/badwords'; +import { BushButtonInteraction } from '../extensions/discord.js/BushButtonInteraction'; +import { BushMessage } from '../extensions/discord.js/BushMessage'; + +export class AutoMod { + private message: BushMessage; + + public constructor(message: BushMessage) { + this.message = message; + void this.handle(); + } + + private async handle(): Promise<void> { + if (this.message.channel.type === 'DM' || !this.message.guild) return; + if (!(await this.message.guild.hasFeature('automod'))) return; + + const customAutomodPhrases = (await this.message.guild.getSetting('autoModPhases')) ?? {}; + const badLinks: BadWords = {}; + const badLinksSecret: BadWords = {}; + + badLinksArray.forEach((link) => { + badLinks[link] = { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: 'malicious link' + }; + }); + badLinksSecretArray.forEach((link) => { + badLinks[link] = { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: 'malicious link' + }; + }); + + const result = { + ...this.checkWords(customAutomodPhrases), + ...this.checkWords((await this.message.guild.hasFeature('excludeDefaultAutomod')) ? {} : badWords), + ...this.checkWords( + (await this.message.guild.hasFeature('excludeAutomodScamLinks')) ? {} : { ...badLinks, ...badLinksSecret } + ) + }; + + if (Object.keys(result).length === 0) return; + + const highestOffence = Object.entries(result) + .map(([key, value]) => ({ word: key, ...value })) + .sort((a, b) => b.severity - a.severity)[0]; + + if (highestOffence.severity === undefined || highestOffence.severity === null) + void this.message.guild.sendLogChannel('error', { + embeds: [ + { + title: 'AutoMod Error', + description: `Unable to find severity information for ${Formatters.inlineCode( + util.discord.escapeInlineCode(highestOffence.word) + )}`, + color: util.colors.error + } + ] + }); + else { + const color = this.punish(highestOffence); + void this.log(highestOffence, color, result); + } + } + + private checkWords(words: BadWords): BadWords { + if (Object.keys(words).length === 0) return {}; + + const matchedWords: BadWords = {}; + for (const word in words) { + const wordOptions = words[word]; + if (this.format(this.message.content, wordOptions) === this.format(word, wordOptions)) { + } + } + return matchedWords; + } + + private format(string: string, wordOptions: BadWordDetails) { + const temp = wordOptions.ignoreCapitalization ? string.toLowerCase() : string; + return wordOptions.ignoreSpaces ? temp.replace(/ /g, '') : temp; + } + + private punish(highestOffence: BadWordDetails & { word: string }) { + let color; + switch (highestOffence.severity) { + case Severity.DELETE: { + color = util.colors.lightGray; + void this.message.delete().catch((e) => deleteError.bind(this, e)); + break; + } + case Severity.WARN: { + color = util.colors.yellow; + void this.message.delete().catch((e) => deleteError.bind(this, e)); + void this.message.member?.warn({ + moderator: this.message.guild!.me!, + reason: `[AutoMod] ${highestOffence.reason}` + }); + break; + } + case Severity.TEMP_MUTE: { + color = util.colors.orange; + void this.message.delete().catch((e) => deleteError.bind(this, e)); + void this.message.member?.mute({ + moderator: this.message.guild!.me!, + reason: `[AutoMod] ${highestOffence.reason}`, + duration: 900_000 // 15 minutes + }); + break; + } + case Severity.PERM_MUTE: { + color = util.colors.red; + void this.message.delete().catch((e) => deleteError.bind(this, e)); + void this.message.member?.mute({ + moderator: this.message.guild!.me!, + reason: `[AutoMod] ${highestOffence.reason}`, + duration: 900_000 // 15 minutes + }); + break; + } + default: { + throw new Error('Invalid severity'); + } + } + + return color; + + async function deleteError(this: AutoMod, e: Error | any) { + this.message.guild?.sendLogChannel('error', { + embeds: [ + { + title: 'AutoMod Error', + description: `Unable to delete triggered message.`, + fields: [{ name: 'Error', value: await util.codeblock(`${e.stack ?? e}`, 1024, 'js', true) }], + color: util.colors.error + } + ] + }); + } + } + + private async log(highestOffence: BadWordDetails & { word: string }, color: `#${string}`, offences: BadWords) { + void client.console.info( + 'autoMod', + `Severity <<${highestOffence.severity}>> action performed on <<${this.message.author.tag}>> (<<${ + this.message.author.id + }>>) in <<#${(this.message.channel as TextChannel).name}>> in <<${this.message.guild!.name}>>` + ); + + return await this.message.guild!.sendLogChannel('automod', { + embeds: [ + new MessageEmbed() + .setTitle(`[Severity ${highestOffence.severity}] Automod Action Performed`) + .setDescription( + `**User:** ${this.message.author} (${this.message.author.tag})\n**Sent From**: <#${ + this.message.channel.id + }> [Jump to context](${this.message.url})\n**Blacklisted Words:** ${util + .surroundArray(Object.keys(offences), '`') + .join(', ')}` + ) + .addField('Message Content', `${await util.codeblock(this.message.content, 1024)}`) + .setColor(color) + .setTimestamp() + ], + components: + highestOffence.severity >= 2 + ? [ + new MessageActionRow().addComponents( + new MessageButton() + .setStyle('DANGER') + .setLabel('Ban User') + .setCustomId(`automod;ban;${this.message.author.id};${highestOffence.reason}`) + ) + ] + : undefined + }); + } + + public static async handleInteraction(interaction: BushButtonInteraction) { + if (!interaction.memberPermissions?.has('BAN_MEMBERS')) + return interaction.reply({ + content: `${util.emojis.error} You are missing the **Ban Members** permission.`, + ephemeral: true + }); + const [action, userId, reason] = interaction.customId.replace('automod;', '').split(';'); + switch (action) { + case 'ban': { + await interaction.deferReply(); + const result = await interaction.guild?.bushBan({ user: userId, reason, moderator: interaction.user.id }); + + if (result === 'success') + return interaction.reply({ + content: `${util.emojis.success} Successfully banned **${ + interaction.guild?.members.cache.get(userId)?.user.tag ?? userId + }**.`, + ephemeral: true + }); + else + return interaction.reply({ + content: `${util.emojis.error} Could not ban **${ + interaction.guild?.members.cache.get(userId)?.user.tag ?? userId + }**: \`${result}\` .`, + ephemeral: true + }); + } + } + } +} + +export const enum Severity { + /** Delete message */ + DELETE, + /** Delete message and warn user */ + WARN, + /** Delete message and mute user for 15 minutes */ + TEMP_MUTE, + /** Delete message and mute user permanently */ + PERM_MUTE +} + +interface BadWordDetails { + severity: Severity; + ignoreSpaces: boolean; + ignoreCapitalization: boolean; + reason: string; +} + +export interface BadWords { + [key: string]: BadWordDetails; +} diff --git a/src/lib/common/moderation.ts b/src/lib/common/moderation.ts new file mode 100644 index 0000000..4af6ec2 --- /dev/null +++ b/src/lib/common/moderation.ts @@ -0,0 +1,181 @@ +import { Snowflake } from 'discord-api-types'; +import { + ActivePunishment, + ActivePunishmentType, + BushGuildMember, + BushGuildMemberResolvable, + BushGuildResolvable, + Guild, + ModLog, + ModLogType +} from '..'; + +export class Moderation { + /** + * Checks if a moderator can perform a moderation action on another user. + * @param moderator - The person trying to perform the action. + * @param victim - The person getting punished. + * @param type - The type of punishment - used to format the response. + * @param checkModerator - Whether or not to check if the victim is a moderator. + */ + public static async permissionCheck( + moderator: BushGuildMember, + victim: BushGuildMember, + type: 'mute' | 'unmute' | 'warn' | 'kick' | 'ban' | 'unban' | 'add a punishment role to' | 'remove a punishment role from', + checkModerator = true, + force = false + ): Promise<true | string> { + if (force) return true; + + // If the victim is not in the guild anymore it will be undefined + if ((!victim || !victim.guild) && !['ban', 'unban'].includes(type)) return true; + + if (moderator.guild.id !== victim.guild.id) { + throw new Error('moderator and victim not in same guild'); + } + + const isOwner = moderator.guild.ownerId === moderator.id; + if (moderator.id === victim.id && !type.startsWith('un')) { + return `${util.emojis.error} You cannot ${type} yourself.`; + } + if ( + moderator.roles.highest.position <= victim.roles.highest.position && + !isOwner && + !(type.startsWith('un') && moderator.id === victim.id) + ) { + return `${util.emojis.error} You cannot ${type} **${victim.user.tag}** because they have higher or equal role hierarchy as you do.`; + } + if ( + victim.roles.highest.position >= victim.guild.me!.roles.highest.position && + !(type.startsWith('un') && moderator.id === victim.id) + ) { + return `${util.emojis.error} You cannot ${type} **${victim.user.tag}** because they have higher or equal role hierarchy as I do.`; + } + if (checkModerator && victim.permissions.has('MANAGE_MESSAGES') && !(type.startsWith('un') && moderator.id === victim.id)) { + if (await moderator.guild.hasFeature('modsCanPunishMods')) { + return true; + } else { + return `${util.emojis.error} You cannot ${type} **${victim.user.tag}** because they are a moderator.`; + } + } + return true; + } + + public static async createModLogEntry( + options: { + type: ModLogType; + user: BushGuildMemberResolvable; + moderator: BushGuildMemberResolvable; + reason: string | undefined | null; + duration?: number; + guild: BushGuildResolvable; + pseudo?: boolean; + }, + getCaseNumber = false + ): Promise<{ log: ModLog | null; caseNum: number | null }> { + const user = (await util.resolveNonCachedUser(options.user))!.id; + const moderator = (await util.resolveNonCachedUser(options.moderator))!.id; + const guild = client.guilds.resolveId(options.guild)!; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const duration = options.duration || undefined; + + // If guild does not exist create it so the modlog can reference a guild. + await Guild.findOrCreate({ + where: { + id: guild + }, + defaults: { + id: guild + } + }); + + const modLogEntry = ModLog.build({ + type: options.type, + user, + moderator, + reason: options.reason, + duration: duration, + guild, + pseudo: options.pseudo ?? false + }); + const saveResult: ModLog | null = await modLogEntry.save().catch(async (e) => { + await util.handleError('createModLogEntry', e); + return null; + }); + + if (!getCaseNumber) return { log: saveResult, caseNum: null }; + + const caseNum = (await ModLog.findAll({ where: { type: options.type, user: user, guild: guild, hidden: 'false' } })) + ?.length; + return { log: saveResult, caseNum }; + } + + public static async createPunishmentEntry(options: { + type: 'mute' | 'ban' | 'role' | 'block'; + user: BushGuildMemberResolvable; + duration: number | undefined; + guild: BushGuildResolvable; + modlog: string; + extraInfo?: Snowflake; + }): Promise<ActivePunishment | null> { + const expires = options.duration ? new Date(+new Date() + options.duration ?? 0) : undefined; + const user = (await util.resolveNonCachedUser(options.user))!.id; + const guild = client.guilds.resolveId(options.guild)!; + const type = this.#findTypeEnum(options.type)!; + + const entry = ActivePunishment.build( + options.extraInfo + ? { user, type, guild, expires, modlog: options.modlog, extraInfo: options.extraInfo } + : { user, type, guild, expires, modlog: options.modlog } + ); + return await entry.save().catch(async (e) => { + await util.handleError('createPunishmentEntry', e); + return null; + }); + } + + public static async removePunishmentEntry(options: { + type: 'mute' | 'ban' | 'role' | 'block'; + user: BushGuildMemberResolvable; + guild: BushGuildResolvable; + extraInfo?: Snowflake; + }): Promise<boolean> { + const user = await util.resolveNonCachedUser(options.user); + const guild = client.guilds.resolveId(options.guild); + const type = this.#findTypeEnum(options.type); + + if (!user || !guild) return false; + + let success = true; + + const entries = await ActivePunishment.findAll({ + // finding all cases of a certain type incase there were duplicates or something + where: options.extraInfo + ? { user: user.id, guild: guild, type, extraInfo: options.extraInfo } + : { user: user.id, guild: guild, type } + }).catch(async (e) => { + await util.handleError('removePunishmentEntry', e); + success = false; + }); + if (entries) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + entries.forEach(async (entry) => { + await entry.destroy().catch(async (e) => { + await util.handleError('removePunishmentEntry', e); + }); + success = false; + }); + } + return success; + } + + static #findTypeEnum(type: 'mute' | 'ban' | 'role' | 'block') { + const typeMap = { + ['mute']: ActivePunishmentType.MUTE, + ['ban']: ActivePunishmentType.BAN, + ['role']: ActivePunishmentType.ROLE, + ['block']: ActivePunishmentType.BLOCK + }; + return typeMap[type]; + } +} diff --git a/src/lib/extensions/discord-akairo/BushClientUtil.ts b/src/lib/extensions/discord-akairo/BushClientUtil.ts index a50cd61..448eaf3 100644 --- a/src/lib/extensions/discord-akairo/BushClientUtil.ts +++ b/src/lib/extensions/discord-akairo/BushClientUtil.ts @@ -3,16 +3,10 @@ import { BushCache, BushClient, BushConstants, - BushGuildMember, - BushGuildMemberResolvable, - BushGuildResolvable, BushMessage, BushSlashMessage, BushUser, Global, - Guild, - ModLog, - ModLogType, Pronoun, PronounCode } from '@lib'; @@ -54,7 +48,6 @@ import _ from 'lodash'; import moment from 'moment'; import { inspect, InspectOptions, promisify } from 'util'; import CommandErrorListener from '../../../listeners/commands/commandError'; -import { ActivePunishment, ActivePunishmentType } from '../../models/ActivePunishment'; import { BushNewsChannel } from '../discord.js/BushNewsChannel'; import { BushTextChannel } from '../discord.js/BushTextChannel'; import { BushSlashEditMessageType, BushSlashSendMessageType, BushUserResolvable } from './BushClient'; @@ -1079,174 +1072,6 @@ export class BushClientUtil extends ClientUtil { return { duration, contentWithoutTime }; } - /** - * Checks if a moderator can perform a moderation action on another user. - * @param moderator - The person trying to perform the action. - * @param victim - The person getting punished. - * @param type - The type of punishment - used to format the response. - * @param checkModerator - Whether or not to check if the victim is a moderator. - */ - public async moderationPermissionCheck( - moderator: BushGuildMember, - victim: BushGuildMember, - type: 'mute' | 'unmute' | 'warn' | 'kick' | 'ban' | 'unban' | 'add a punishment role to' | 'remove a punishment role from', - checkModerator = true, - force = false - ): Promise<true | string> { - if (force) return true; - - // If the victim is not in the guild anymore it will be undefined - if ((!victim || !victim.guild) && !['ban', 'unban'].includes(type)) return true; - - if (moderator.guild.id !== victim.guild.id) { - throw new Error('moderator and victim not in same guild'); - } - - const isOwner = moderator.guild.ownerId === moderator.id; - if (moderator.id === victim.id && !type.startsWith('un')) { - return `${util.emojis.error} You cannot ${type} yourself.`; - } - if ( - moderator.roles.highest.position <= victim.roles.highest.position && - !isOwner && - !(type.startsWith('un') && moderator.id === victim.id) - ) { - return `${util.emojis.error} You cannot ${type} **${victim.user.tag}** because they have higher or equal role hierarchy as you do.`; - } - if ( - victim.roles.highest.position >= victim.guild.me!.roles.highest.position && - !(type.startsWith('un') && moderator.id === victim.id) - ) { - return `${util.emojis.error} You cannot ${type} **${victim.user.tag}** because they have higher or equal role hierarchy as I do.`; - } - if (checkModerator && victim.permissions.has('MANAGE_MESSAGES') && !(type.startsWith('un') && moderator.id === victim.id)) { - if (await moderator.guild.hasFeature('modsCanPunishMods')) { - return true; - } else { - return `${util.emojis.error} You cannot ${type} **${victim.user.tag}** because they are a moderator.`; - } - } - return true; - } - - public async createModLogEntry( - options: { - type: ModLogType; - user: BushGuildMemberResolvable; - moderator: BushGuildMemberResolvable; - reason: string | undefined | null; - duration?: number; - guild: BushGuildResolvable; - pseudo?: boolean; - }, - getCaseNumber = false - ): Promise<{ log: ModLog | null; caseNum: number | null }> { - const user = (await util.resolveNonCachedUser(options.user))!.id; - const moderator = (await util.resolveNonCachedUser(options.moderator))!.id; - const guild = client.guilds.resolveId(options.guild)!; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const duration = options.duration || undefined; - - // If guild does not exist create it so the modlog can reference a guild. - await Guild.findOrCreate({ - where: { - id: guild - }, - defaults: { - id: guild - } - }); - - const modLogEntry = ModLog.build({ - type: options.type, - user, - moderator, - reason: options.reason, - duration: duration, - guild, - pseudo: options.pseudo ?? false - }); - const saveResult: ModLog | null = await modLogEntry.save().catch(async (e) => { - await util.handleError('createModLogEntry', e); - return null; - }); - - if (!getCaseNumber) return { log: saveResult, caseNum: null }; - - const caseNum = (await ModLog.findAll({ where: { type: options.type, user: user, guild: guild, hidden: 'false' } })) - ?.length; - return { log: saveResult, caseNum }; - } - - public async createPunishmentEntry(options: { - type: 'mute' | 'ban' | 'role' | 'block'; - user: BushGuildMemberResolvable; - duration: number | undefined; - guild: BushGuildResolvable; - modlog: string; - extraInfo?: Snowflake; - }): Promise<ActivePunishment | null> { - const expires = options.duration ? new Date(+new Date() + options.duration ?? 0) : undefined; - const user = (await util.resolveNonCachedUser(options.user))!.id; - const guild = client.guilds.resolveId(options.guild)!; - const type = this.#findTypeEnum(options.type)!; - - const entry = ActivePunishment.build( - options.extraInfo - ? { user, type, guild, expires, modlog: options.modlog, extraInfo: options.extraInfo } - : { user, type, guild, expires, modlog: options.modlog } - ); - return await entry.save().catch(async (e) => { - await util.handleError('createPunishmentEntry', e); - return null; - }); - } - - public async removePunishmentEntry(options: { - type: 'mute' | 'ban' | 'role' | 'block'; - user: BushGuildMemberResolvable; - guild: BushGuildResolvable; - extraInfo?: Snowflake; - }): Promise<boolean> { - const user = await util.resolveNonCachedUser(options.user); - const guild = client.guilds.resolveId(options.guild); - const type = this.#findTypeEnum(options.type); - - if (!user || !guild) return false; - - let success = true; - - const entries = await ActivePunishment.findAll({ - // finding all cases of a certain type incase there were duplicates or something - where: options.extraInfo - ? { user: user.id, guild: guild, type, extraInfo: options.extraInfo } - : { user: user.id, guild: guild, type } - }).catch(async (e) => { - await util.handleError('removePunishmentEntry', e); - success = false; - }); - if (entries) { - // eslint-disable-next-line @typescript-eslint/no-misused-promises - entries.forEach(async (entry) => { - await entry.destroy().catch(async (e) => { - await util.handleError('removePunishmentEntry', e); - }); - success = false; - }); - } - return success; - } - - #findTypeEnum(type: 'mute' | 'ban' | 'role' | 'block') { - const typeMap = { - ['mute']: ActivePunishmentType.MUTE, - ['ban']: ActivePunishmentType.BAN, - ['role']: ActivePunishmentType.ROLE, - ['block']: ActivePunishmentType.BLOCK - }; - return typeMap[type]; - } - public humanizeDuration(duration: number, largest?: number): string { if (largest) return humanizeDuration(duration, { language: 'en', maxDecimalPoints: 2, largest }); else return humanizeDuration(duration, { language: 'en', maxDecimalPoints: 2 }); @@ -1315,6 +1140,99 @@ export class BushClientUtil extends ClientUtil { return string.charAt(0)?.toUpperCase() + string.slice(1); } + /** + * Wait an amount in seconds. + */ + public async sleep(s: number): Promise<unknown> { + return new Promise((resolve) => setTimeout(resolve, s * 1000)); + } + + public async handleError(context: string, error: Error) { + await client.console.error(_.camelCase(context), `An error occurred:\n${error?.stack ?? (error as any)}`, false); + await client.console.channelError({ + embeds: [await CommandErrorListener.generateErrorEmbed({ type: 'unhandledRejection', error: error, context })] + }); + } + + public async resolveNonCachedUser(user: UserResolvable | undefined | null): Promise<BushUser | undefined> { + if (!user) return undefined; + const id = + user instanceof User || user instanceof GuildMember || user instanceof ThreadMember + ? user.id + : user instanceof Message + ? user.author.id + : typeof user === 'string' + ? user + : undefined; + if (!id) return undefined; + else return await client.users.fetch(id).catch(() => undefined); + } + + public async getPronounsOf(user: User | Snowflake): Promise<Pronoun | undefined> { + const _user = await this.resolveNonCachedUser(user); + if (!_user) throw new Error(`Cannot find user ${user}`); + const apiRes = (await got + .get(`https://pronoundb.org/api/v1/lookup?platform=discord&id=${_user.id}`) + .json() + .catch(() => undefined)) as { pronouns: PronounCode } | undefined; + + if (!apiRes) return undefined; + if (!apiRes.pronouns) throw new Error('apiRes.pronouns is undefined'); + + return client.constants.pronounMapping[apiRes.pronouns]; + } + + // modified from https://stackoverflow.com/questions/31054910/get-functions-methods-of-a-class + // answer by Bruno Grieder + public getMethods(_obj: any): string { + let props: string[] = []; + let obj: any = new Object(_obj); + + do { + const l = Object.getOwnPropertyNames(obj) + .concat(Object.getOwnPropertySymbols(obj).map((s) => s.toString())) + .sort() + .filter( + (p, i, arr) => + typeof Object.getOwnPropertyDescriptor(obj, p)?.['get'] !== 'function' && // ignore getters + typeof Object.getOwnPropertyDescriptor(obj, p)?.['set'] !== 'function' && // ignore setters + typeof obj[p] === 'function' && //only the methods + p !== 'constructor' && //not the constructor + (i == 0 || p !== arr[i - 1]) && //not overriding in this prototype + props.indexOf(p) === -1 //not overridden in a child + ); + + const reg = /\(([\s\S]*?)\)/; + props = props.concat( + l.map( + (p) => + `${obj[p] && obj[p][Symbol.toStringTag] === 'AsyncFunction' ? 'async ' : ''}function ${p}(${ + reg.exec(obj[p].toString())?.[1] + ? reg + .exec(obj[p].toString())?.[1] + .split(', ') + .map((arg) => arg.split('=')[0].trim()) + .join(', ') + : '' + });` + ) + ); + } while ( + (obj = Object.getPrototypeOf(obj)) && //walk-up the prototype chain + Object.getPrototypeOf(obj) //not the the Object prototype methods (hasOwnProperty, etc...) + ); + + return props.join('\n'); + } + + /** + * Removes all characters in a string that are either control characters or change the direction of text etc. + */ + public sanitizeWtlAndControl(str: string) { + // eslint-disable-next-line no-control-regex + return str.replace(/[\u0000-\u001F\u007F-\u009F\u200B]/g, ''); + } + get arg() { return class Arg { /** @@ -1435,99 +1353,6 @@ export class BushClientUtil extends ClientUtil { } /** - * Wait an amount in seconds. - */ - public async sleep(s: number): Promise<unknown> { - return new Promise((resolve) => setTimeout(resolve, s * 1000)); - } - - public async handleError(context: string, error: Error) { - await client.console.error(_.camelCase(context), `An error occurred:\n${error?.stack ?? (error as any)}`, false); - await client.console.channelError({ - embeds: [await CommandErrorListener.generateErrorEmbed({ type: 'unhandledRejection', error: error, context })] - }); - } - - public async resolveNonCachedUser(user: UserResolvable | undefined | null): Promise<BushUser | undefined> { - if (!user) return undefined; - const id = - user instanceof User || user instanceof GuildMember || user instanceof ThreadMember - ? user.id - : user instanceof Message - ? user.author.id - : typeof user === 'string' - ? user - : undefined; - if (!id) return undefined; - else return await client.users.fetch(id).catch(() => undefined); - } - - public async getPronounsOf(user: User | Snowflake): Promise<Pronoun | undefined> { - const _user = await this.resolveNonCachedUser(user); - if (!_user) throw new Error(`Cannot find user ${user}`); - const apiRes = (await got - .get(`https://pronoundb.org/api/v1/lookup?platform=discord&id=${_user.id}`) - .json() - .catch(() => undefined)) as { pronouns: PronounCode } | undefined; - - if (!apiRes) return undefined; - if (!apiRes.pronouns) throw new Error('apiRes.pronouns is undefined'); - - return client.constants.pronounMapping[apiRes.pronouns]; - } - - // modified from https://stackoverflow.com/questions/31054910/get-functions-methods-of-a-class - // answer by Bruno Grieder - public getMethods(_obj: any): string { - let props: string[] = []; - let obj: any = new Object(_obj); - - do { - const l = Object.getOwnPropertyNames(obj) - .concat(Object.getOwnPropertySymbols(obj).map((s) => s.toString())) - .sort() - .filter( - (p, i, arr) => - typeof Object.getOwnPropertyDescriptor(obj, p)?.['get'] !== 'function' && // ignore getters - typeof Object.getOwnPropertyDescriptor(obj, p)?.['set'] !== 'function' && // ignore setters - typeof obj[p] === 'function' && //only the methods - p !== 'constructor' && //not the constructor - (i == 0 || p !== arr[i - 1]) && //not overriding in this prototype - props.indexOf(p) === -1 //not overridden in a child - ); - - const reg = /\(([\s\S]*?)\)/; - props = props.concat( - l.map( - (p) => - `${obj[p] && obj[p][Symbol.toStringTag] === 'AsyncFunction' ? 'async ' : ''}function ${p}(${ - reg.exec(obj[p].toString())?.[1] - ? reg - .exec(obj[p].toString())?.[1] - .split(', ') - .map((arg) => arg.split('=')[0].trim()) - .join(', ') - : '' - });` - ) - ); - } while ( - (obj = Object.getPrototypeOf(obj)) && //walk-up the prototype chain - Object.getPrototypeOf(obj) //not the the Object prototype methods (hasOwnProperty, etc...) - ); - - return props.join('\n'); - } - - /** - * Removes all characters in a string that are either control characters or change the direction of text etc. - */ - public sanitizeWtlAndControl(str: string) { - // eslint-disable-next-line no-control-regex - return str.replace(/[\u0000-\u001F\u007F-\u009F\u200B]/g, ''); - } - - /** * Discord.js's Util class */ get discord() { diff --git a/src/lib/extensions/discord.js/BushGuild.ts b/src/lib/extensions/discord.js/BushGuild.ts index 256b9dc..3a2ae51 100644 --- a/src/lib/extensions/discord.js/BushGuild.ts +++ b/src/lib/extensions/discord.js/BushGuild.ts @@ -1,5 +1,6 @@ -import { Guild, UserResolvable } from 'discord.js'; +import { Guild, MessageOptions, UserResolvable } from 'discord.js'; import { RawGuildData } from 'discord.js/typings/rawDataTypes'; +import { Moderation } from '../../common/moderation'; import { Guild as GuildDB, GuildFeatures, GuildLogType, GuildModel } from '../../models/Guild'; import { ModLogType } from '../../models/ModLog'; import { BushClient, BushUserResolvable } from '../discord-akairo/BushClient'; @@ -96,7 +97,7 @@ export class BushGuild extends Guild { if (!banSuccess) return 'error banning'; // add modlog entry - const { log: modlog } = await util.createModLogEntry({ + const { log: modlog } = await Moderation.createModLogEntry({ type: options.duration ? ModLogType.TEMP_BAN : ModLogType.PERM_BAN, user: user, moderator: moderator.id, @@ -108,7 +109,7 @@ export class BushGuild extends Guild { caseID = modlog.id; // add punishment entry so they can be unbanned later - const punishmentEntrySuccess = await util.createPunishmentEntry({ + const punishmentEntrySuccess = await Moderation.createPunishmentEntry({ type: 'ban', user: user, guild: this, @@ -161,7 +162,7 @@ export class BushGuild extends Guild { if (!unbanSuccess) return 'error unbanning'; // add modlog entry - const { log: modlog } = await util.createModLogEntry({ + const { log: modlog } = await Moderation.createModLogEntry({ type: ModLogType.UNBAN, user: user.id, moderator: moderator.id, @@ -172,7 +173,7 @@ export class BushGuild extends Guild { caseID = modlog.id; // remove punishment entry - const removePunishmentEntrySuccess = await util.removePunishmentEntry({ + const removePunishmentEntrySuccess = await Moderation.removePunishmentEntry({ type: 'ban', user: user.id, guild: this @@ -192,4 +193,15 @@ export class BushGuild extends Guild { client.emit('bushUnban', user, moderator, this, options.reason ?? undefined, caseID!, dmSuccessEvent!); return ret; } + + /** + * Sends a message to the guild's specified logging channel. + */ + public async sendLogChannel(logType: GuildLogType, message: MessageOptions) { + const logChannel = await this.getLogChannel(logType); + if (!logChannel || logChannel.type !== 'GUILD_TEXT') return; + if (!logChannel.permissionsFor(this.me!.id)?.has(['VIEW_CHANNEL', 'SEND_MESSAGES', 'EMBED_LINKS'])) return; + + return await logChannel.send(message).catch(() => null); + } } diff --git a/src/lib/extensions/discord.js/BushGuildMember.ts b/src/lib/extensions/discord.js/BushGuildMember.ts index 8e855f7..b4c136c 100644 --- a/src/lib/extensions/discord.js/BushGuildMember.ts +++ b/src/lib/extensions/discord.js/BushGuildMember.ts @@ -1,5 +1,6 @@ import { GuildMember, MessageEmbed, Partialize, Role } from 'discord.js'; import { RawGuildMemberData } from 'discord.js/typings/rawDataTypes'; +import { Moderation } from '../../common/moderation'; import { ModLogType } from '../../models/ModLog'; import { BushClient } from '../discord-akairo/BushClient'; import { BushGuild } from './BushGuild'; @@ -111,7 +112,7 @@ export class BushGuildMember extends GuildMember { const ret = await (async () => { // add modlog entry - const result = await util.createModLogEntry( + const result = await Moderation.createModLogEntry( { type: ModLogType.WARN, user: this, @@ -145,7 +146,7 @@ export class BushGuildMember extends GuildMember { const ret = await (async () => { if (options.addToModlog || options.duration) { - const { log: modlog } = await util.createModLogEntry({ + const { log: modlog } = await Moderation.createModLogEntry({ type: options.duration ? ModLogType.TEMP_PUNISHMENT_ROLE : ModLogType.PERM_PUNISHMENT_ROLE, guild: this.guild, moderator: moderator.id, @@ -158,7 +159,7 @@ export class BushGuildMember extends GuildMember { caseID = modlog.id; if (options.addToModlog || options.duration) { - const punishmentEntrySuccess = await util.createPunishmentEntry({ + const punishmentEntrySuccess = await Moderation.createPunishmentEntry({ type: 'role', user: this, guild: this.guild, @@ -198,7 +199,7 @@ export class BushGuildMember extends GuildMember { const ret = await (async () => { if (options.addToModlog) { - const { log: modlog } = await util.createModLogEntry({ + const { log: modlog } = await Moderation.createModLogEntry({ type: ModLogType.REMOVE_PUNISHMENT_ROLE, guild: this.guild, moderator: moderator.id, @@ -209,7 +210,7 @@ export class BushGuildMember extends GuildMember { if (!modlog) return 'error creating modlog entry'; caseID = modlog.id; - const punishmentEntrySuccess = await util.removePunishmentEntry({ + const punishmentEntrySuccess = await Moderation.removePunishmentEntry({ type: 'role', user: this, guild: this.guild, @@ -281,7 +282,7 @@ export class BushGuildMember extends GuildMember { if (!muteSuccess) return 'error giving mute role'; // add modlog entry - const { log: modlog } = await util.createModLogEntry({ + const { log: modlog } = await Moderation.createModLogEntry({ type: options.duration ? ModLogType.TEMP_MUTE : ModLogType.PERM_MUTE, user: this, moderator: moderator.id, @@ -294,7 +295,7 @@ export class BushGuildMember extends GuildMember { caseID = modlog.id; // add punishment entry so they can be unmuted later - const punishmentEntrySuccess = await util.createPunishmentEntry({ + const punishmentEntrySuccess = await Moderation.createPunishmentEntry({ type: 'mute', user: this, guild: this.guild, @@ -351,7 +352,7 @@ export class BushGuildMember extends GuildMember { if (!muteSuccess) return 'error removing mute role'; //remove modlog entry - const { log: modlog } = await util.createModLogEntry({ + const { log: modlog } = await Moderation.createModLogEntry({ type: ModLogType.UNMUTE, user: this, moderator: moderator.id, @@ -363,7 +364,7 @@ export class BushGuildMember extends GuildMember { caseID = modlog.id; // remove mute entry - const removePunishmentEntrySuccess = await util.removePunishmentEntry({ + const removePunishmentEntrySuccess = await Moderation.removePunishmentEntry({ type: 'mute', user: this, guild: this.guild @@ -402,7 +403,7 @@ export class BushGuildMember extends GuildMember { if (!kickSuccess) return 'error kicking'; // add modlog entry - const { log: modlog } = await util.createModLogEntry({ + const { log: modlog } = await Moderation.createModLogEntry({ type: ModLogType.KICK, user: this, moderator: moderator.id, @@ -439,7 +440,7 @@ export class BushGuildMember extends GuildMember { if (!banSuccess) return 'error banning'; // add modlog entry - const { log: modlog } = await util.createModLogEntry({ + const { log: modlog } = await Moderation.createModLogEntry({ type: options.duration ? ModLogType.TEMP_BAN : ModLogType.PERM_BAN, user: this, moderator: moderator.id, @@ -451,7 +452,7 @@ export class BushGuildMember extends GuildMember { caseID = modlog.id; // add punishment entry so they can be unbanned later - const punishmentEntrySuccess = await util.createPunishmentEntry({ + const punishmentEntrySuccess = await Moderation.createPunishmentEntry({ type: 'ban', user: this, guild: this.guild, diff --git a/src/lib/models/Guild.ts b/src/lib/models/Guild.ts index 997be6a..f1ea43b 100644 --- a/src/lib/models/Guild.ts +++ b/src/lib/models/Guild.ts @@ -1,5 +1,6 @@ import { Snowflake } from 'discord.js'; import { DataTypes, Sequelize } from 'sequelize'; +import { BadWords } from '../common/automod'; import { BushClient } from '../extensions/discord-akairo/BushClient'; import { BaseModel } from './BaseModel'; import { jsonArrayInit, jsonParseGet, jsonParseSet, NEVER_USED } from './__helpers'; @@ -109,6 +110,14 @@ export const guildFeaturesObj = asGuildFeature({ name: 'Automod', description: 'Deletes offensive content as well as phishing links.' }, + excludeDefaultAutomod: { + name: 'Exclude Default Automod', + description: 'Opt out of using the default automod options.' + }, + excludeAutomodScamLinks: { + name: 'Exclude Automod Scam Links', + description: 'Opt out of having automod delete scam links.' + }, autoPublish: { name: 'Auto Publish', description: 'Publishes messages in configured announcement channels.' @@ -159,6 +168,10 @@ export const guildLogsObj = { report: { description: 'Logs user reports.', configurable: true + }, + error: { + description: 'Logs errors that occur with the bot.', + configurable: true } }; export type GuildLogType = keyof typeof guildLogsObj; @@ -181,7 +194,7 @@ export interface GuildModel { punishmentEnding: string; disabledCommands: string[]; lockdownChannels: Snowflake[]; - autoModPhases: { [word: string]: 0 | 1 | 2 | 3 }; + autoModPhases: BadWords; enabledFeatures: GuildFeatures[]; joinRoles: Snowflake[]; logChannels: LogChannelDB; @@ -202,7 +215,7 @@ export interface GuildModelCreationAttributes { punishmentEnding?: string; disabledCommands?: string[]; lockdownChannels?: Snowflake[]; - autoModPhases?: { [word: string]: 0 | 1 | 2 | 3 }; + autoModPhases?: BadWords; enabledFeatures?: GuildFeatures[]; joinRoles?: Snowflake[]; logChannels?: LogChannelDB; @@ -316,10 +329,10 @@ export class Guild extends BaseModel<GuildModel, GuildModelCreationAttributes> i /** * Custom automod phases */ - public get autoModPhases(): { [word: string]: 0 | 1 | 2 | 3 } { + public get autoModPhases(): BadWords { throw new Error(NEVER_USED); } - public set autoModPhases(_: { [word: string]: 0 | 1 | 2 | 3 }) { + public set autoModPhases(_: BadWords) { throw new Error(NEVER_USED); } @@ -424,10 +437,10 @@ export class Guild extends BaseModel<GuildModel, GuildModelCreationAttributes> i lockdownChannels: jsonArrayInit('lockdownChannels'), autoModPhases: { type: DataTypes.TEXT, - get: function (): { [level: number]: Snowflake } { + get: function (): BadWords { return jsonParseGet.call(this, 'autoModPhases'); }, - set: function (val: { [level: number]: Snowflake }) { + set: function (val: BadWords) { return jsonParseSet.call(this, 'autoModPhases', val); }, allowNull: false, diff --git a/src/listeners/client/interactionCreate.ts b/src/listeners/client/interactionCreate.ts index d07a084..52ad2ec 100644 --- a/src/listeners/client/interactionCreate.ts +++ b/src/listeners/client/interactionCreate.ts @@ -1,4 +1,5 @@ -import { BushListener } from '@lib'; +import { BushButtonInteraction, BushListener } from '@lib'; +import { AutoMod } from '../../lib/common/automod'; import { BushClientEvents } from '../../lib/extensions/discord.js/BushClientEvents'; export default class InteractionCreateListener extends BushListener { @@ -22,6 +23,8 @@ export default class InteractionCreateListener extends BushListener { return; } else if (interaction.isButton()) { if (interaction.customId.startsWith('paginate_') || interaction.customId.startsWith('command_')) return; + else if (interaction.customId.startsWith('automod;')) + void AutoMod.handleInteraction(interaction as BushButtonInteraction); return await interaction.reply({ content: 'Buttons go brrr', ephemeral: true }); } else if (interaction.isSelectMenu()) { if (interaction.customId.startsWith('command_')) return; diff --git a/src/listeners/message/automodCreate.ts b/src/listeners/message/automodCreate.ts index 01fc803..3e98117 100644 --- a/src/listeners/message/automodCreate.ts +++ b/src/listeners/message/automodCreate.ts @@ -1,11 +1,5 @@ -import { BushListener, BushMessage } from '@lib'; -// @ts-expect-error: ts doesn't recognize json5 -import _badLinks from '@root/lib/badlinks'; // partially uses https://github.com/nacrt/SkyblockClient-REPO/blob/main/files/scamlinks.json -// @ts-expect-error: ts doesn't recognize json5 -import _badLinksSecret from '@root/lib/badlinks-secret'; // shhhh -// @ts-expect-error: ts doesn't recognize json5 -import _badWords from '@root/lib/badwords'; -import { MessageEmbed } from 'discord.js'; +import { BushListener } from '@lib'; +import { AutoMod } from '../../lib/common/automod'; import { BushClientEvents } from '../../lib/extensions/discord.js/BushClientEvents'; export default class AutomodMessageCreateListener extends BushListener { @@ -18,107 +12,6 @@ export default class AutomodMessageCreateListener extends BushListener { } public override async exec(...[message]: BushClientEvents['messageCreate']): Promise<unknown> { - return await AutomodMessageCreateListener.automod(message); - } - - public static async automod(message: BushMessage): Promise<unknown> { - if (message.channel.type === 'DM' || !message.guild) return; - if (!(await message.guild.hasFeature('automod'))) return; - - const customAutomodPhrases = (await message.guild.getSetting('autoModPhases')) ?? {}; - - const badLinks: { [key: string]: 0 | 1 | 2 | 3 } = {}; - let temp = _badLinks; - if (_badLinksSecret) temp = temp.concat(_badLinksSecret); - - temp.forEach((link: string) => { - badLinks[link] = 3; - }); - const badWords: { [key: string]: 0 | 1 | 2 | 3 } = _badWords; - - const wordMap = { ...badWords, ...badLinks, ...customAutomodPhrases }; - const wordKeys = Object.keys(wordMap); - const offences: { [key: string]: 0 | 1 | 2 | 3 } = {}; - - const cleanMessageContent = message.content?.toLowerCase().replace(/ /g, ''); - for (const word of wordKeys) { - const cleanWord = word.toLowerCase().replace(/ /g, ''); - - if (cleanMessageContent.includes(cleanWord)) { - if (cleanWord === 'whore' && !message.content?.toLowerCase().includes(cleanWord)) return; - if (!offences[word]) offences[word] = wordMap[word as keyof typeof wordMap]; - } - } - - if (!Object.keys(offences)?.length) return; - - const highestOffence = Object.values(offences).sort((a, b) => b - a)[0]; - - switch (highestOffence) { - case 0: { - void message.delete().catch(() => {}); - break; - } - case 1: { - void message.delete().catch(() => {}); - void message.member?.warn({ - moderator: message.guild.me!, - reason: '[AutoMod] blacklisted word' - }); - - break; - } - case 2: { - void message.delete().catch(() => {}); - void message.member?.mute({ - moderator: message.guild.me!, - reason: '[AutoMod] blacklisted word', - duration: 900_000 // 15 minutes - }); - break; - } - case 3: { - void message.delete().catch(() => {}); - void message.member?.mute({ - moderator: message.guild.me!, - reason: '[AutoMod] blacklisted word', - duration: 0 // perm - }); - break; - } - } - - void client.console.info( - 'autoMod', - `Severity <<${highestOffence}>> action performed on <<${message.author.tag}>> (<<${message.author.id}>>) in <<#${message.channel.name}>> in <<${message.guild.name}>>` - ); - - const color = - highestOffence === 0 - ? util.colors.lightGray - : highestOffence === 1 - ? util.colors.yellow - : highestOffence === 2 - ? util.colors.orange - : util.colors.red; - - const automodChannel = await message.guild.getLogChannel('automod'); - if (!automodChannel) return; - - if (automodChannel.permissionsFor(message.guild.me!.id)?.has(['VIEW_CHANNEL', 'SEND_MESSAGES', 'EMBED_LINKS'])) - void automodChannel.send({ - embeds: [ - new MessageEmbed() - .setTitle(`[Severity ${highestOffence}] Automod Action Performed`) - .setDescription( - `**User:** ${message.author} (${message.author.tag})\n**Sent From**: <#${message.channel.id}> [Jump to context](${ - message.url - })\n**Blacklisted Words:** ${util.surroundArray(Object.keys(offences), '`').join(', ')}` - ) - .addField('Message Content', `${await util.codeblock(message.content, 1024)}`) - .setColor(color) - .setTimestamp() - ] - }); + return new AutoMod(message); } } diff --git a/src/listeners/message/automodUpdate.ts b/src/listeners/message/automodUpdate.ts index 9ef229e..86411b2 100644 --- a/src/listeners/message/automodUpdate.ts +++ b/src/listeners/message/automodUpdate.ts @@ -1,6 +1,6 @@ import { BushListener, BushMessage } from '@lib'; +import { AutoMod } from '../../lib/common/automod'; import { BushClientEvents } from '../../lib/extensions/discord.js/BushClientEvents'; -import AutomodMessageCreateListener from './automodCreate'; export default class AutomodMessageUpdateListener extends BushListener { public constructor() { @@ -13,6 +13,6 @@ export default class AutomodMessageUpdateListener extends BushListener { public override async exec(...[_, newMessage]: BushClientEvents['messageUpdate']): Promise<unknown> { const fullMessage = newMessage.partial ? await newMessage.fetch() : (newMessage as BushMessage); - return await AutomodMessageCreateListener.automod(fullMessage); + return new AutoMod(fullMessage); } } diff --git a/tsconfig.json b/tsconfig.json index f3fa5ba..4495f03 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,14 @@ "module": "commonjs", "target": "esnext", "outDir": "dist", - "lib": ["esnext", "esnext.array", "esnext.asyncIterable", "esnext.intl", "esnext.symbol", "DOM"], + "lib": [ + "esnext", + "esnext.array", + "esnext.asyncIterable", + "esnext.intl", + "esnext.symbol", + "DOM" + ], "sourceMap": false, "inlineSourceMap": true, "inlineSources": true, @@ -19,11 +26,21 @@ "baseUrl": "./", "useUnknownInCatchVariables": false, "paths": { - "src/*": ["./src/*"], - "@lib": ["./src/lib"], - "@root": ["."] + "src/*": [ + "./src/*" + ], + "@lib": [ + "./src/lib" + ], + "@root": [ + "." + ] } }, - "include": ["src/**/*.ts", "ecosystem.config.js"], + "include": [ + "src/**/*.ts", + "lib/**/*.ts", + "ecosystem.config.js" + ], "exclude": [] -} +}
\ No newline at end of file @@ -1066,13 +1066,13 @@ __metadata: linkType: hard "discord-akairo@npm:@notenoughupdates/discord-akairo@dev": - version: 9.0.3-dev.1633868201.8b5b67e - resolution: "@notenoughupdates/discord-akairo@npm:9.0.3-dev.1633868201.8b5b67e" + version: 9.0.5-dev.1633983022.8a77c5b + resolution: "@notenoughupdates/discord-akairo@npm:9.0.5-dev.1633983022.8a77c5b" dependencies: discord-akairo-message-util: "npm:@notenoughupdates/discord-akairo-message-util@latest" lodash: ^4.17.21 source-map-support: ^0.5.20 - checksum: f196cdd5debca9752bbee703dcd878f14407ede1d12e0d5587c4eb0213a287b0582032d6d4482b59b749454165073ef807583c0d3b4dd8f81b9557b7c8a2ae86 + checksum: b36461d1cb6232e2dc4f5f6a5ef93d68a92deed3305f18e39ab42ab6f50301783a70cd0e71f464dd62c6546a6ba08b4ae1ddba7a71f66a3391fc0ac9106130eb languageName: node linkType: hard @@ -1098,8 +1098,8 @@ __metadata: linkType: hard "discord.js@npm:@notenoughupdates/discord.js@dev": - version: 13.3.0-dev.1633867636.c462423 - resolution: "@notenoughupdates/discord.js@npm:13.3.0-dev.1633867636.c462423" + version: 13.3.0-dev.1633954072.c462423 + resolution: "@notenoughupdates/discord.js@npm:13.3.0-dev.1633954072.c462423" dependencies: "@discordjs/builders": ^0.6.0 "@discordjs/collection": ^0.2.1 @@ -1110,7 +1110,7 @@ __metadata: discord-api-types: ^0.23.1 node-fetch: ^2.6.1 ws: ^8.2.3 - checksum: 3f035abe71c6de7aabef20818f50c131b14dd2d979d0f505a3d0cb1406abd2887db342503140edba608afd26e4179597e61f0b032c50f453de94cd5dbffe2e22 + checksum: ba59cf4327dafc73c88c0814b901dce3c3b45df50d6d85c362d3ec3bd902ee5414a9e11e79b25a471190b6645afbe64add7afaf38020a4b4d19156902c6b10bc languageName: node linkType: hard |