From eaa079dcc676ad4b8bdff5f1d3b89bad3db3ece2 Mon Sep 17 00:00:00 2001 From: Kath <55346310+Kathund@users.noreply.github.com> Date: Tue, 6 Aug 2024 20:36:06 +0800 Subject: [PATCH] Automod (#28) * automod permit * unpermit a user * infractions * fix oversite * fix oversite v2 * Squashed commit of the following: commit a30f30cd8139961ad33db9f3dc8d127cc46dd420 Author: Kath Date: Thu Aug 1 08:51:50 2024 +0800 update renovate config commit 550d72a354e22d3b940e344731f6ccfbdbc59fd1 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Aug 1 08:37:43 2024 +0800 chore(deps): update dependency typescript-eslint to v8 (#18) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 922806ce8279e2363d5344c4067b12d87ac0c7d2 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Aug 1 08:36:45 2024 +0800 chore(deps): Update dependency @types/node to ^22.0.2 (#22) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 4fa77896bdcc9d2e1ece0cc98b146804ac720c41 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jul 31 20:36:20 2024 +0800 chore(deps): Update dependency tsx to ^4.16.3 (#21) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 8c9c7172abfc7615189b6a9be51ca9d9d7854cd5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jul 31 20:35:47 2024 +0800 chore(deps): Update dependency mongoose to ^8.5.2 (#20) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * mute/unmute * Automod System * Fix automod url regex and logic (#25) * fix(allowed-urls): update regex and update logic * update(testfile): add new code to test file * simply logic * delete test file * remove else * Class based code * Fix urlTest being flipped * Move auto-link to mongo * Refractor automod * Save attachments when antilink is triggered * Buttons --------- Co-authored-by: Zickles <76439587+Zickles@users.noreply.github.com> --- .gitignore | 3 +- config.example.json | 5 +- eslint.config.js | 7 +- index.ts | 23 +-- package.json | 4 + pnpm-lock.yaml | 36 ++++ src/DiscordManager.ts | 65 ++++++++ src/commands/automod.ts | 222 +++++++++++++++++++++++++ src/commands/docs.ts | 11 +- src/commands/tag.ts | 257 +++++++++++------------------ src/commands/user.ts | 202 +++++++++++++++++++++++ src/events/interactionCreate.ts | 69 -------- src/events/ready.ts | 15 -- src/functions/deployCommands.ts | 34 ---- src/functions/deployEvents.ts | 24 --- src/handlers/InteractionHandler.ts | 111 +++++++++++++ src/handlers/MessageHandler.ts | 111 +++++++++++++ src/types/main.d.ts | 5 + src/utils/CheckPermits.ts | 21 +++ src/utils/Infraction.ts | 206 +++++++++++++++++++++++ src/{functions => utils}/logger.ts | 9 +- src/{functions => utils}/mongo.ts | 84 ++++++++-- src/utils/regex.ts | 5 + src/utils/user.ts | 30 ++++ 24 files changed, 1197 insertions(+), 362 deletions(-) create mode 100644 src/DiscordManager.ts create mode 100755 src/commands/automod.ts create mode 100644 src/commands/user.ts delete mode 100644 src/events/interactionCreate.ts delete mode 100644 src/events/ready.ts delete mode 100644 src/functions/deployCommands.ts delete mode 100644 src/functions/deployEvents.ts create mode 100644 src/handlers/InteractionHandler.ts create mode 100644 src/handlers/MessageHandler.ts create mode 100644 src/utils/CheckPermits.ts create mode 100644 src/utils/Infraction.ts rename src/{functions => utils}/logger.ts (95%) rename src/{functions => utils}/mongo.ts (52%) create mode 100644 src/utils/regex.ts create mode 100644 src/utils/user.ts diff --git a/.gitignore b/.gitignore index e54f688..f1f792c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /config.json node_modules/ logs/ -build/ \ No newline at end of file +build/ +data/ \ No newline at end of file diff --git a/config.example.json b/config.example.json index 093ba23..5f3987d 100644 --- a/config.example.json +++ b/config.example.json @@ -4,5 +4,8 @@ "teamRole": "TEAM_ROLE_ID", "devRole": "DEV_ROLE_ID", "contributorsRole": "CONTRIBUTORS_ROLE_ID", - "supportCategory": "SUPPORT_CATEGORY_ID" + "supportCategory": "SUPPORT_CATEGORY_ID", + "autoModBypassRole": "AUTOMOD_BYPASS_ROLE_ID", + "serverId": "SERVER_ID", + "infractionLogchannel": "INFRACTION_LOG_CHANNEL" } diff --git a/eslint.config.js b/eslint.config.js index 0bbbd74..461c61e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,7 +12,8 @@ export default [ sourceType: 'module', globals: { ...globals.es2022, - ...globals.node + ...globals.node, + guild: 'writable' } }, rules: { @@ -44,6 +45,7 @@ export default [ 'default-case-last': 'warn', 'no-self-compare': 'error', 'no-new-wrappers': 'error', + 'no-fallthrough': 'error', 'no-lone-blocks': 'error', 'no-undef-init': 'error', 'no-else-return': 'warn', @@ -56,11 +58,10 @@ export default [ 'no-multi-str': 'warn', 'no-lonely-if': 'warn', 'no-new-func': 'error', - 'no-console': 'error', camelcase: 'warn', 'no-var': 'warn', eqeqeq: 'warn', semi: 'error' } } -]; \ No newline at end of file +]; diff --git a/index.ts b/index.ts index d7e3d7d..edb3c86 100644 --- a/index.ts +++ b/index.ts @@ -1,21 +1,4 @@ -import { Client, Events, GatewayIntentBits } from 'discord.js'; -import deployCommands from './src/functions/deployCommands'; -import { execute } from './src/events/ready'; -import { token } from './config.json'; +import DiscordManager from './src/DiscordManager'; +const discord = new DiscordManager(); -const client: Client = new Client({ - intents: [ - GatewayIntentBits.MessageContent, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildMembers, - GatewayIntentBits.Guilds - ] -}); - -deployCommands(client); - -client.on(Events.ClientReady, () => { - execute(client); -}); - -client.login(token); +discord.connect(); diff --git a/package.json b/package.json index ed96b0a..57f2712 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,15 @@ "discord.js": "^14.15.3", "discord.js-docs": "^0.3.0", "mongoose": "^8.5.2", + "ms": "^2.1.3", + "node-cron": "^3.0.3", "winston": "^3.13.1" }, "devDependencies": { "@eslint/js": "^9.8.0", "@types/eslint": "^9.6.0", + "@types/ms": "^0.7.34", + "@types/node-cron": "^3.0.11", "@types/node": "^22.1.0", "eslint": "^9.8.0", "eslint-config-prettier": "^9.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60cefde..bc0de0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: mongoose: specifier: ^8.5.2 version: 8.5.2 + ms: + specifier: ^2.1.3 + version: 2.1.3 + node-cron: + specifier: ^3.0.3 + version: 3.0.3 winston: specifier: ^3.13.1 version: 3.13.1 @@ -27,9 +33,15 @@ importers: '@types/eslint': specifier: ^9.6.0 version: 9.6.0 + '@types/ms': + specifier: ^0.7.34 + version: 0.7.34 '@types/node': specifier: ^22.1.0 version: 22.1.0 + '@types/node-cron': + specifier: ^3.0.11 + version: 3.0.11 eslint: specifier: ^9.8.0 version: 9.8.0 @@ -297,6 +309,12 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/ms@0.7.34': + resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + + '@types/node-cron@3.0.11': + resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} + '@types/node@22.1.0': resolution: {integrity: sha512-AOmuRF0R2/5j1knA3c6G3HOk523Ga+l+ZXltX8SF1+5oqcXijjfTd8fY3XRZqSihEu9XhtQnKYLmkFaoxgsJHw==} @@ -780,6 +798,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-cron@3.0.3: + resolution: {integrity: sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==} + engines: {node: '>=6.0.0'} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -978,6 +1000,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -1222,6 +1248,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/ms@0.7.34': {} + + '@types/node-cron@3.0.11': {} + '@types/node@22.1.0': dependencies: undici-types: 6.13.0 @@ -1753,6 +1783,10 @@ snapshots: natural-compare@1.4.0: {} + node-cron@3.0.3: + dependencies: + uuid: 8.3.2 + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -1912,6 +1946,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@8.3.2: {} + webidl-conversions@3.0.1: {} webidl-conversions@7.0.0: {} diff --git a/src/DiscordManager.ts b/src/DiscordManager.ts new file mode 100644 index 0000000..8ffec0c --- /dev/null +++ b/src/DiscordManager.ts @@ -0,0 +1,65 @@ +import { Client, GatewayIntentBits, Collection, REST, Routes } from 'discord.js'; +import InteractionHandler from './handlers/InteractionHandler'; +import MessageHandler from './handlers/MessageHandler'; +import { token, serverId } from '../config.json'; +import CheckPermits from './utils/CheckPermits'; +import { SlashCommand } from './types/main'; +import { connectDB } from './utils/mongo'; +import { readdirSync } from 'fs'; +import cron from 'node-cron'; + +class DiscordManager { + interactionHandler: InteractionHandler; + messageHandler: MessageHandler; + client?: Client; + constructor() { + this.interactionHandler = new InteractionHandler(this); + this.messageHandler = new MessageHandler(this); + } + + connect(): void { + this.client = new Client({ + intents: [ + GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.Guilds + ] + }); + + this.deployCommands(); + this.client.on('ready', () => this.ready()); + this.client.on('messageCreate', (message) => this.messageHandler.onMessage(message)); + this.client.on('interactionCreate', (interaction) => this.interactionHandler.onInteraction(interaction)); + + this.client.login(token).catch((e) => console.log(e)); + } + + async ready() { + if (!this.client) return; + console.log(`Logged in as ${this.client.user?.username} (${this.client.user?.id})!`); + global.guild = await this.client.guilds.fetch(serverId); + cron.schedule(`* * * * *`, () => CheckPermits()); + connectDB(); + } + + async deployCommands(): Promise { + if (!this.client) return; + this.client.commands = new Collection(); + const commandFiles = readdirSync('./src/commands'); + const commands = []; + for (const file of commandFiles) { + const command = await import(`./commands/${file}`); + commands.push(command.data.toJSON()); + if (command.data.name) { + this.client.commands.set(command.data.name, command); + } + } + const rest = new REST({ version: '10' }).setToken(token); + const clientID = Buffer.from(token.split('.')[0], 'base64').toString('ascii'); + await rest.put(Routes.applicationCommands(clientID), { body: commands }); + console.log(`Successfully reloaded ${commands.length} application command(s).`); + } +} + +export default DiscordManager; diff --git a/src/commands/automod.ts b/src/commands/automod.ts new file mode 100755 index 0000000..0356219 --- /dev/null +++ b/src/commands/automod.ts @@ -0,0 +1,222 @@ +import { + getAllowedDomainInfo, + getAllowedDomains, + getAntiLinkState, + removeAllowedURL, + toggleAntiLinks, + addAllowedURL +} from '../utils/mongo'; +import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder } from 'discord.js'; +import { autoModBypassRole } from '../../config.json'; +import { readFileSync, writeFileSync } from 'fs'; +import ms from 'ms'; + +export const data = new SlashCommandBuilder() + .setName('automod') + .setDescription('Manage AutoMod') + .addSubcommandGroup((subgroup) => + subgroup + .setName('user') + .setDescription('Manage Automod for a user') + .addSubcommand((subcommand) => + subcommand + .setName('permit') + .setDescription('Allow someone to bypass automod') + .addUserOption((option) => option.setName('user').setDescription('The user to permit').setRequired(true)) + .addStringOption((option) => + option.setName('time').setDescription('How long to permit a user').setRequired(false) + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName('unpermit') + .setDescription('Remove someones automod bypass') + .addUserOption((option) => option.setName('user').setDescription('The user to remove').setRequired(true)) + ) + ) + .addSubcommandGroup((subGroup) => + subGroup + .setName('anti-link') + .setDescription('Manage AutoMod Anti-Link') + .addSubcommand((subcommand) => subcommand.setName('disable').setDescription('Disable AutoMod Anti-Link')) + .addSubcommand((subcommand) => subcommand.setName('enable').setDescription('Enable AutoMod Anti-Link')) + .addSubcommand((subcommand) => subcommand.setName('toggle').setDescription('Toggle AutoMod Anti-Link')) + .addSubcommand((subcommand) => subcommand.setName('info').setDescription('Info about AutoMod Anti-Link')) + .addSubcommand((subcommand) => + subcommand + .setName('add') + .setDescription('Add a bypass url to AutoMod Anti-Link') + .addStringOption((option) => option.setName('url').setDescription('Url you want to bypass').setRequired(true)) + ) + .addSubcommand((subcommand) => + subcommand + .setName('remove') + .setDescription('Remove a bypass url to AutoMod Anti-Link') + .addStringOption((option) => option.setName('url').setDescription('Url you want to remove').setRequired(true)) + ) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + .setDMPermission(false); + +export interface UserPermit { + id: string; + removeTime: number; +} + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + try { + if (!interaction.guild) return; + const subgroup = interaction.options.getSubcommandGroup(); + const subCommand = interaction.options.getSubcommand(); + switch (subgroup) { + case 'user': { + const commandUser = interaction.options.getUser('user'); + if (!commandUser) { + await interaction.reply({ content: 'Please provide a valid user', ephemeral: true }); + return; + } + const user = await interaction.guild.members.fetch(commandUser.id); + if (!user) { + await interaction.reply({ content: 'Please provide a valid user', ephemeral: true }); + return; + } + const permitData = readFileSync('data/permit.json'); + if (!permitData) { + await interaction.reply({ + content: 'The linked data file does not exist. Please contact an administrator.', + ephemeral: true + }); + } + let permit = JSON.parse(permitData.toString()); + if (!permit) { + await interaction.reply({ + content: 'The linked data file is malformed. Please contact an administrator.', + ephemeral: true + }); + } + switch (subCommand) { + case 'permit': { + const time = interaction.options.getString('time') || '10m'; + const msTime = ms(time); + const removeTime = Math.floor((new Date().getTime() + msTime) / 1000); + permit.push({ id: user.id, removeTime }); + writeFileSync('data/permit.json', JSON.stringify(permit)); + user.roles.add(autoModBypassRole); + await interaction.reply({ + content: `${user} has been permitted to ()` + }); + break; + } + case 'unpermit': { + const permitUser = permit.find((data: UserPermit) => data.id === user.id); + if (permitUser === undefined) { + await interaction.reply({ content: 'User is not permited', ephemeral: true }); + return; + } + user.roles.remove(autoModBypassRole); + permit = permit.filter((data: UserPermit) => data.id !== user.id); + writeFileSync('data/permit.json', JSON.stringify(permit)); + await interaction.reply({ content: `${user} is no longer permited` }); + break; + } + default: { + await interaction.reply({ + content: 'Invalid subcommand Please provide a valid subcommand', + ephemeral: true + }); + } + } + break; + } + case 'anti-link': { + switch (subCommand) { + case 'disable': { + const state = await toggleAntiLinks(false); + await interaction.reply({ + content: `Anti-Link has been ${state ? 'enabled' : 'disabled'}`, + ephemeral: true + }); + break; + } + case 'enable': { + const state = await toggleAntiLinks(true); + await interaction.reply({ + content: `Anti-Link has been ${state ? 'enabled' : 'disabled'}`, + ephemeral: true + }); + break; + } + case 'toggle': { + const state = await toggleAntiLinks(); + await interaction.reply({ + content: `Anti-Link has been ${state ? 'enabled' : 'disabled'}`, + ephemeral: true + }); + break; + } + case 'info': { + const allowedUrls = await getAllowedDomains(); + const domains: string[] = []; + allowedUrls.forEach(async (url) => { + const info = await getAllowedDomainInfo(url); + if (!info) return; + domains.push( + `Url: \`${info.url}\`\nAdded By: <@${info.user.id}>\nTimestamp: ()` + ); + }); + const state = await getAntiLinkState(); + const embed = new EmbedBuilder() + .setColor(0xff8c00) + .setTitle('Anti-Link Info') + .setDescription(`**${state ? 'Enabled' : 'Disabled'}**\n\n**Domains:**\n${domains.join('\n\n')}`); + await interaction.reply({ embeds: [embed], ephemeral: true }); + break; + } + case 'add': { + const url = interaction.options.getString('url'); + if (!url) { + await interaction.reply({ content: 'Please provide a valid url', ephemeral: true }); + return; + } + const check = await addAllowedURL(url, { id: interaction.user.id, staff: true, bot: interaction.user.bot }); + if (false === check.set) { + await interaction.reply({ content: `Something went wrong \`${check.info}\``, ephemeral: true }); + return; + } + await interaction.reply({ content: `\`${url}\` has been added to the bypass list`, ephemeral: true }); + break; + } + case 'remove': { + const url = interaction.options.getString('url'); + if (!url) { + await interaction.reply({ content: 'Please provide a valid url', ephemeral: true }); + return; + } + removeAllowedURL(url); + await interaction.reply({ content: `\`${url}\` has been removed to the bypass list`, ephemeral: true }); + break; + } + default: { + await interaction.reply({ + content: 'Invalid subcommand Please provide a valid subcommand', + ephemeral: true + }); + } + } + break; + } + default: { + await interaction.reply({ content: 'Invalid subcommand Please provide a valid subcommand', ephemeral: true }); + } + } + } catch (error) { + console.log(error); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: 'Something went wrong. Please try again later.', ephemeral: true }); + return; + } + await interaction.reply({ content: 'Something went wrong. Please try again later.', ephemeral: true }); + } +} diff --git a/src/commands/docs.ts b/src/commands/docs.ts index 3ae467c..81036cd 100644 --- a/src/commands/docs.ts +++ b/src/commands/docs.ts @@ -24,18 +24,11 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise ); await interaction.reply({ embeds: [docs.resolveEmbed(query)] }); } catch (error) { - // eslint-disable-next-line no-console console.log(error); if (interaction.replied || interaction.deferred) { - await interaction.followUp({ - content: 'Something went wrong. Please try again later.', - ephemeral: true - }); + await interaction.followUp({ content: 'Something went wrong. Please try again later.', ephemeral: true }); return; } - await interaction.reply({ - content: 'Something went wrong. Please try again later.', - ephemeral: true - }); + await interaction.reply({ content: 'Something went wrong. Please try again later.', ephemeral: true }); } } diff --git a/src/commands/tag.ts b/src/commands/tag.ts index 8da9aee..da8c31c 100644 --- a/src/commands/tag.ts +++ b/src/commands/tag.ts @@ -2,7 +2,7 @@ import { ModalActionRowComponentBuilder, ChatInputCommandInteraction, AutocompleteInteraction, - GuildMemberRoleManager, + PermissionFlagsBits, SlashCommandBuilder, TextInputBuilder, ActionRowBuilder, @@ -11,8 +11,8 @@ import { EmbedBuilder, ChannelType } from 'discord.js'; -import { contributorsRole, teamRole, devRole, supportCategory } from '../../config.json'; -import { deleteTag, getTag, getTagNames } from '../functions/mongo'; +import { deleteTag, getTag, getTagNames } from '../utils/mongo'; +import { supportCategory } from '../../config.json'; export const data = new SlashCommandBuilder() .setName('tag') @@ -47,7 +47,9 @@ export const data = new SlashCommandBuilder() .addStringOption((option) => option.setName('message-link').setDescription('The Message link to reply with the tag').setRequired(false) ) - ); + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + .setDMPermission(false); export async function autoComplete(interaction: AutocompleteInteraction): Promise { const focusedOption = interaction.options.getFocused(true); @@ -71,88 +73,55 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise try { if (!interaction.member) return; const subCommand = interaction.options.getSubcommand(); - const memberRoles = (interaction.member.roles as GuildMemberRoleManager).cache.map((role) => role.id); switch (subCommand) { case 'add': { - if (memberRoles.some((role) => [contributorsRole, teamRole, devRole].includes(role))) { - const modal = new ModalBuilder().setCustomId('tagForm').setTitle('Please enter the tag information'); - - const tagFormName = new TextInputBuilder() - .setStyle(TextInputStyle.Short) - .setCustomId('tagFormName') - .setRequired(true) - .setLabel('Name'); - - const tagFormContent = new TextInputBuilder() - .setStyle(TextInputStyle.Paragraph) - .setCustomId('tagFormContent') - .setLabel('Tag Content') - .setRequired(true); - - const tagFormNameReason = new ActionRowBuilder().addComponents(tagFormName); - const tagFormContentReason = new ActionRowBuilder().addComponents( - tagFormContent - ); - modal.addComponents(tagFormNameReason, tagFormContentReason); - await interaction.showModal(modal); - } else { - await interaction.reply({ - content: 'You do not have permission to use this command', - ephemeral: true - }); - } + const modal = new ModalBuilder().setCustomId('tagForm').setTitle('Please enter the tag information'); + const tagFormName = new TextInputBuilder() + .setStyle(TextInputStyle.Short) + .setCustomId('tagFormName') + .setRequired(true) + .setLabel('Name'); + const tagFormContent = new TextInputBuilder() + .setStyle(TextInputStyle.Paragraph) + .setCustomId('tagFormContent') + .setLabel('Tag Content') + .setRequired(true); + const tagFormNameReason = new ActionRowBuilder().addComponents(tagFormName); + const tagFormContentReason = new ActionRowBuilder().addComponents( + tagFormContent + ); + modal.addComponents(tagFormNameReason, tagFormContentReason); + await interaction.showModal(modal); break; } case 'edit': { let name = interaction.options.getString('name'); if (!name) return; name = name.toLowerCase(); - if (memberRoles.some((role) => [teamRole, devRole].includes(role))) { - const modal = new ModalBuilder() - .setCustomId(`t.e.${name}`) - .setTitle('Please enter the updated tag information'); - - const tagFormContent = new TextInputBuilder() - .setStyle(TextInputStyle.Paragraph) - .setCustomId('tagFormUpdatedContent') - .setLabel('New Tag Content') - .setRequired(true); - - const tagFormContentReason = new ActionRowBuilder().addComponents( - tagFormContent - ); - modal.addComponents(tagFormContentReason); - await interaction.showModal(modal); - } else { - await interaction.reply({ - content: 'You do not have permission to use this command', - ephemeral: true - }); - } + const modal = new ModalBuilder() + .setCustomId(`t.e.${name}`) + .setTitle('Please enter the updated tag information'); + const tagFormContent = new TextInputBuilder() + .setStyle(TextInputStyle.Paragraph) + .setCustomId('tagFormUpdatedContent') + .setLabel('New Tag Content') + .setRequired(true); + const tagFormContentReason = new ActionRowBuilder().addComponents( + tagFormContent + ); + modal.addComponents(tagFormContentReason); + await interaction.showModal(modal); break; } case 'delete': { - if (memberRoles.some((role) => [teamRole, devRole].includes(role))) { - const name = interaction.options.getString('name'); - if (!name) return; - const inputTag = await deleteTag(name.toLowerCase()); - if (inputTag.success) { - await interaction.reply({ - content: 'Tag deleted successfully', - ephemeral: true - }); - return; - } - await interaction.reply({ - content: 'Tag not found', - ephemeral: true - }); + const name = interaction.options.getString('name'); + if (!name) return; + const inputTag = await deleteTag(name.toLowerCase()); + if (inputTag.success) { + await interaction.reply({ content: 'Tag deleted successfully', ephemeral: true }); return; } - await interaction.reply({ - content: 'You do not have permission to use this command', - ephemeral: true - }); + await interaction.reply({ content: 'Tag not found', ephemeral: true }); return; } case 'send': { @@ -161,89 +130,58 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise let messageLink = interaction.options.getString('message-link') || null; const user = interaction.options.getUser('user'); const inputTag = await getTag(name.toLowerCase()); - if (inputTag.success) { - if (!inputTag.tag?.status) return; - await interaction.deferReply({ ephemeral: true }); - if (messageLink) { - if (!messageLink.includes('discord.com/channels/')) { - await interaction.followUp({ - content: 'Invalid message link', - ephemeral: true - }); - return; - } - if (!messageLink.includes('https://')) { - await interaction.followUp({ - content: 'Invalid message link', - ephemeral: true - }); - return; - } - if (messageLink.startsWith('https://canary.discord.com')) { - messageLink = messageLink.replace('canary.', ''); - } - if (messageLink.startsWith('https://ptb.discord.com')) { - messageLink = messageLink.replace('ptb.', ''); - } - const split = messageLink.split('https://discord.com/channels/')[1].split('/'); - const channel = await interaction.client.channels.fetch(split[1]); - if (!channel) { - await interaction.followUp({ - content: 'Channel not found', - ephemeral: true - }); - return; - } - if (channel.type !== ChannelType.GuildText) { - await interaction.followUp({ - content: 'Invalid channel type', - ephemeral: true - }); - return; - } - if (channel.parentId !== supportCategory) { - await interaction.followUp({ - content: 'Tags can only be sent in support channels', - ephemeral: true - }); - return; - } - const message = await channel.messages.fetch(split[2]); - if (!message) { - await interaction.followUp({ - content: 'Message not found', - ephemeral: true - }); - return; - } - message.reply({ - content: user ? `${user.toString()}\n\n${inputTag.tag.content}` : inputTag.tag.content - }); - } else { - if (!interaction.channel) return; - if (interaction.channel.type !== ChannelType.GuildText) { - return; - } - if (interaction.channel.parentId !== supportCategory) { - await interaction.followUp({ - content: 'Tags can only be sent in support channels', - ephemeral: true - }); - return; - } - interaction.channel.send({ - content: user ? `${user.toString()}\n\n${inputTag.tag.content}` : inputTag.tag.content - }); + if (!inputTag.success) await interaction.reply({ content: 'Tag not found', ephemeral: true }); + if (!inputTag.tag?.status) return; + await interaction.deferReply({ ephemeral: true }); + if (messageLink) { + if (!messageLink.includes('discord.com/channels/')) { + await interaction.followUp({ content: 'Invalid message link', ephemeral: true }); + return; } - - const embed = new EmbedBuilder().setTitle('Tag sent').setDescription(`Tag \`${name}\` sent`); - await interaction.followUp({ embeds: [embed] }); - return; + if (!messageLink.includes('https://')) { + await interaction.followUp({ content: 'Invalid message link', ephemeral: true }); + return; + } + if (messageLink.startsWith('https://canary.discord.com')) { + messageLink = messageLink.replace('canary.', ''); + } + if (messageLink.startsWith('https://ptb.discord.com')) { + messageLink = messageLink.replace('ptb.', ''); + } + const split = messageLink.split('https://discord.com/channels/')[1].split('/'); + const channel = await interaction.client.channels.fetch(split[1]); + if (!channel) { + await interaction.followUp({ content: 'Channel not found', ephemeral: true }); + return; + } + if (channel.type !== ChannelType.GuildText) { + await interaction.followUp({ content: 'Invalid channel type', ephemeral: true }); + return; + } + if (channel.parentId !== supportCategory) { + await interaction.followUp({ content: 'Tags can only be sent in support channels', ephemeral: true }); + return; + } + const message = await channel.messages.fetch(split[2]); + if (!message) { + await interaction.followUp({ content: 'Message not found', ephemeral: true }); + return; + } + message.reply({ content: user ? `${user.toString()}\n\n${inputTag.tag.content}` : inputTag.tag.content }); + } else { + if (!interaction.channel) return; + if (interaction.channel.type !== ChannelType.GuildText) { + return; + } + if (interaction.channel.parentId !== supportCategory) { + await interaction.followUp({ content: 'Tags can only be sent in support channels', ephemeral: true }); + return; + } + interaction.channel.send({ + content: user ? `${user.toString()}\n\n${inputTag.tag.content}` : inputTag.tag.content + }); } - await interaction.reply({ - content: 'Tag not found', - ephemeral: true - }); + await interaction.followUp({ content: `Tag \`${name}\` sent` }); return; } default: { @@ -254,18 +192,11 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise } } } catch (error) { - // eslint-disable-next-line no-console console.log(error); if (interaction.replied || interaction.deferred) { - await interaction.followUp({ - content: 'Something went wrong. Please try again later.', - ephemeral: true - }); + await interaction.followUp({ content: 'Something went wrong. Please try again later.', ephemeral: true }); return; } - await interaction.reply({ - content: 'Something went wrong. Please try again later.', - ephemeral: true - }); + await interaction.reply({ content: 'Something went wrong. Please try again later.', ephemeral: true }); } } diff --git a/src/commands/user.ts b/src/commands/user.ts new file mode 100644 index 0000000..bb0397b --- /dev/null +++ b/src/commands/user.ts @@ -0,0 +1,202 @@ +import { + ChatInputCommandInteraction, + SlashCommandBuilder, + PermissionFlagsBits, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle +} from 'discord.js'; +import Infraction, { getUserInfractions } from '../utils/Infraction'; +import { getInfractionEmbed, getUserInfoEmbed } from '../utils/user'; +import ms from 'ms'; + +export const data = new SlashCommandBuilder() + .setName('user') + .setDescription('Manage Users') + .addSubcommand((subcommand) => + subcommand + .setName('info') + .setDescription('Get info of a user') + .addUserOption((option) => option.setName('user').setDescription('The user to get info').setRequired(true)) + ) + .addSubcommand((subcommand) => + subcommand + .setName('infractions') + .setDescription('Get user Infractions') + .addUserOption((option) => option.setName('user').setDescription('The user to get infractions').setRequired(true)) + ) + .addSubcommand((subcommand) => + subcommand + .setName('warn') + .setDescription('Warn a user') + .addUserOption((option) => option.setName('user').setDescription('The user to warn').setRequired(true)) + .addStringOption((option) => + option.setName('reason').setDescription('The reason for the warn').setRequired(false) + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName('kick') + .setDescription('Kick a user') + .addUserOption((option) => option.setName('user').setDescription('The user to kick').setRequired(true)) + .addStringOption((option) => + option.setName('reason').setDescription('The reason for the kick').setRequired(false) + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName('mute') + .setDescription('Mute a user') + .addUserOption((option) => option.setName('user').setDescription('The user to mute').setRequired(true)) + .addStringOption((option) => option.setName('time').setDescription('How long to mute').setRequired(true)) + .addStringOption((option) => + option.setName('reason').setDescription('The reason for the mute').setRequired(false) + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName('unmute') + .setDescription('unmute a user') + .addUserOption((option) => option.setName('user').setDescription('The user to mute').setRequired(true)) + .addStringOption((option) => + option.setName('reason').setDescription('The reason for the mute').setRequired(false) + ) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + .setDMPermission(false); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + try { + if (!interaction.guild) return; + const subCommand = interaction.options.getSubcommand(); + const commandUser = interaction.options.getUser('user'); + if (!commandUser) { + await interaction.reply({ content: 'Please provide a valid user', ephemeral: true }); + return; + } + const user = await interaction.guild.members.fetch(commandUser.id); + if (!user) { + await interaction.reply({ content: 'Please provide a valid user', ephemeral: true }); + return; + } + switch (subCommand) { + case 'info': { + await interaction.reply({ + embeds: [getUserInfoEmbed(user)], + components: [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`infractions.${user.id}`) + .setLabel('View Infractions') + .setStyle(ButtonStyle.Secondary) + ) + ], + ephemeral: true + }); + break; + } + case 'infractions': { + const data = await getUserInfractions(commandUser.id); + if (false === data.success) { + await interaction.reply({ content: data.info, ephemeral: true }); + return; + } + await interaction.reply({ + embeds: [getInfractionEmbed(commandUser.id, data.info, data.infractions)], + ephemeral: true + }); + break; + } + case 'warn': { + const reason = interaction.options.getString('reason') || 'No reason provided'; + new Infraction({ + automatic: false, + reason: reason, + type: 'WARN', + long: null, + user: { id: commandUser.id, staff: false, bot: commandUser.bot }, + staff: { id: interaction.user.id, staff: true, bot: interaction.user.bot }, + timestamp: Date.now(), + extraInfo: { url: '', messageId: '', channelId: '' } + }) + .log() + .save(); + await interaction.reply({ content: `<@${commandUser.id}> has been warned`, ephemeral: true }); + break; + } + case 'kick': { + const reason = interaction.options.getString('reason') || 'No reason provided'; + new Infraction({ + automatic: false, + reason: reason, + type: 'KICK', + long: null, + user: { id: commandUser.id, staff: false, bot: commandUser.bot }, + staff: { id: interaction.user.id, staff: true, bot: interaction.user.bot }, + timestamp: Date.now(), + extraInfo: { url: '', messageId: '', channelId: '' } + }) + .log() + .save(); + await interaction.reply({ content: `<@${commandUser.id}> has been kicked`, ephemeral: true }); + break; + } + case 'mute': { + const time = interaction.options.getString('time'); + const reason = interaction.options.getString('reason') || 'No reason provided'; + if (!time) { + await interaction.reply({ content: 'Please provide a time', ephemeral: true }); + return; + } + const long = ms(time); + if (2419200000 < long) { + await interaction.reply({ content: 'You cannot mute someone for longer then 28d', ephemeral: true }); + return; + } + user.timeout(long, reason); + new Infraction({ + automatic: false, + reason: reason, + type: 'MUTE', + long, + user: { id: commandUser.id, staff: false, bot: commandUser.bot }, + staff: { id: interaction.user.id, staff: true, bot: interaction.user.bot }, + timestamp: Date.now(), + extraInfo: { url: '', messageId: '', channelId: '' } + }) + .log() + .save(); + await interaction.reply({ content: `<@${commandUser.id}> has been muted`, ephemeral: true }); + break; + } + case 'unmute': { + const reason = interaction.options.getString('reason') || 'No reason provided'; + user.timeout(null, reason); + new Infraction({ + automatic: false, + reason: reason, + type: 'UNMUTE', + long: null, + user: { id: commandUser.id, staff: false, bot: commandUser.bot }, + staff: { id: interaction.user.id, staff: true, bot: interaction.user.bot }, + timestamp: Date.now(), + extraInfo: { url: '', messageId: '', channelId: '' } + }) + .log() + .save(); + await interaction.reply({ content: `<@${commandUser.id}> has been unmuted`, ephemeral: true }); + break; + } + default: { + await interaction.reply({ content: 'Invalid subcommand Please provide a valid subcommand', ephemeral: true }); + } + } + } catch (error) { + console.log(error); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: 'Something went wrong. Please try again later.', ephemeral: true }); + return; + } + await interaction.reply({ content: 'Something went wrong. Please try again later.', ephemeral: true }); + } +} diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts deleted file mode 100644 index 1d5f41d..0000000 --- a/src/events/interactionCreate.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* eslint-disable no-console */ -import { Interaction, Events, InteractionType, EmbedBuilder, GuildMemberRoleManager } from 'discord.js'; -import { teamRole, devRole } from '../../config.json'; -import { Tag, modifyTag } from '../functions/mongo'; -import { eventMessage } from '../functions/logger'; - -export const name = Events.InteractionCreate; -export async function execute(interaction: Interaction): Promise { - try { - if (!interaction.member || !interaction.channel || !interaction.guild) return; - const memberRoles = (interaction.member.roles as GuildMemberRoleManager).cache.map((role) => role.id); - if (interaction.isChatInputCommand()) { - const command = interaction.client.commands.get(interaction.commandName); - try { - eventMessage( - `Interaction Event trigged by ${interaction.user.username} (${ - interaction.user.id - }) ran command ${interaction.commandName} in ${interaction.guild.id} in ${interaction.channel.id}` - ); - await command.execute(interaction); - } catch (error) { - console.log(error); - } - } else if (interaction.isAutocomplete()) { - const command = interaction.client.commands.get(interaction.commandName); - - if (!command) { - console.error(`No command matching ${interaction.commandName} was found.`); - return; - } - - try { - await command.autoComplete(interaction); - } catch (error) { - console.error(error); - } - } else if (interaction.type === InteractionType.ModalSubmit) { - if ('tagForm' === interaction.customId) { - const name = interaction.fields.getTextInputValue('tagFormName').toLowerCase(); - const content = interaction.fields.getTextInputValue('tagFormContent'); - - new Tag(name, content, interaction.user.id, 'approved').save(); - const embed = new EmbedBuilder() - .setTitle('Tag added') - .setDescription(`The tag \`${name}\` has been added successfully`); - await interaction.reply({ embeds: [embed], ephemeral: true }); - } - if (interaction.customId.startsWith('t.e.')) { - if (memberRoles.some((role) => [teamRole, devRole].includes(role))) return; - const name = interaction.customId.split('.')[2]; - const content = interaction.fields.getTextInputValue('tagFormUpdatedContent'); - - const updatedTag = await modifyTag(name, new Tag(name, content, interaction.user.id, 'approved')); - if (updatedTag.success) { - const embed = new EmbedBuilder() - .setTitle('Tag Updated') - .setDescription(`The tag \`${name}\` has been added successfully`); - await interaction.reply({ embeds: [embed], ephemeral: true }); - } else if (false === updatedTag.success && 'Tag not found' === updatedTag.info) { - await interaction.reply({ content: 'This tag does not exist!', ephemeral: true }); - } else { - await interaction.reply({ content: 'An error occurred', ephemeral: true }); - } - } - } - } catch (error) { - console.log(error); - } -} diff --git a/src/events/ready.ts b/src/events/ready.ts deleted file mode 100644 index 7158d0d..0000000 --- a/src/events/ready.ts +++ /dev/null @@ -1,15 +0,0 @@ -import deployEvents from '../functions/deployEvents'; -import { eventMessage } from '../functions/logger'; -import { connectDB } from '../functions/mongo'; -import { Client } from 'discord.js'; - -export function execute(client: Client): void { - try { - eventMessage(`Logged in as ${client.user?.username} (${client.user?.id})!`); - deployEvents(client); - connectDB(); - } catch (error) { - // eslint-disable-next-line no-console - console.log(error); - } -} diff --git a/src/functions/deployCommands.ts b/src/functions/deployCommands.ts deleted file mode 100644 index 1493a01..0000000 --- a/src/functions/deployCommands.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Client, Collection, REST, Routes } from 'discord.js'; -import { eventMessage, errorMessage } from './logger'; -import { SlashCommand } from '../types/main'; -import { token } from '../../config.json'; -import { readdirSync } from 'fs'; - -export default async function (client: Client): Promise { - try { - client.commands = new Collection(); - const commandFiles = readdirSync('./src/commands'); - const commands = []; - for (const file of commandFiles) { - const command = await import(`../commands/${file}`); - commands.push(command.data.toJSON()); - if (command.data.name) { - client.commands.set(command.data.name, command); - } - } - const rest = new REST({ version: '10' }).setToken(token); - (async () => { - try { - const clientID = Buffer.from(token.split('.')[0], 'base64').toString('ascii'); - await rest.put(Routes.applicationCommands(clientID), { body: commands }); - eventMessage(`Successfully reloaded ${commands.length} application command(s).`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - errorMessage(error); - } - })(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - errorMessage(error); - } -} diff --git a/src/functions/deployEvents.ts b/src/functions/deployEvents.ts deleted file mode 100644 index 957e726..0000000 --- a/src/functions/deployEvents.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { errorMessage, eventMessage } from './logger'; -import { Client } from 'discord.js'; -import { readdirSync } from 'fs'; - -export default async function (client: Client): Promise { - try { - const eventFiles = readdirSync('./src/events/'); - let count = eventFiles.length; - for (const file of eventFiles) { - if (file.toLowerCase().includes('disabled')) { - count--; - continue; - } - const event = await import(`../events/${file}`); - const name = file.split('.')[0]; - client.on(name, event.execute.bind(null)); - eventMessage(`Successfully loaded ${name}`); - } - eventMessage(`Successfully loaded ${count} event(s).`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - errorMessage(error); - } -} diff --git a/src/handlers/InteractionHandler.ts b/src/handlers/InteractionHandler.ts new file mode 100644 index 0000000..89f0841 --- /dev/null +++ b/src/handlers/InteractionHandler.ts @@ -0,0 +1,111 @@ +import { + ChatInputCommandInteraction, + AutocompleteInteraction, + GuildMemberRoleManager, + ModalSubmitInteraction, + ButtonInteraction, + BaseInteraction, + ChannelType +} from 'discord.js'; +import { getUserInfractions } from '../utils/Infraction'; +import { teamRole, devRole } from '../../config.json'; +import { getInfractionEmbed } from '../utils/user'; +import { modifyTag, Tag } from '../utils/mongo'; +import DiscordManager from '../DiscordManager'; + +class InteractionHandler { + discord: DiscordManager; + constructor(discordManager: DiscordManager) { + this.discord = discordManager; + } + + onInteraction(interaction: BaseInteraction) { + if (interaction.isChatInputCommand()) this.commandInteraction(interaction); + if (interaction.isAutocomplete()) this.autoCompleteInteraction(interaction); + if (interaction.isModalSubmit()) this.modalSubmitInteraction(interaction); + if (interaction.isButton()) this.buttonInteraction(interaction); + } + + async commandInteraction(interaction: ChatInputCommandInteraction): Promise { + if (!interaction.member || !interaction.channel || !interaction.guild) return; + const command = interaction.client.commands.get(interaction.commandName); + try { + console.log( + `Interaction Event trigged by ${interaction.user.username} (${interaction.user.id}) ran command ${ + interaction.commandName + } in ${interaction.guild.id} in ${interaction.channel.id}` + ); + await command.execute(interaction); + } catch (error) { + console.log(error); + } + } + + async autoCompleteInteraction(interaction: AutocompleteInteraction) { + const command = interaction.client.commands.get(interaction.commandName); + if (!command) { + console.error(`No command matching ${interaction.commandName} was found.`); + return; + } + try { + await command.autoComplete(interaction); + } catch (error) { + console.error(error); + } + } + + async modalSubmitInteraction(interaction: ModalSubmitInteraction) { + if (!interaction.member) return; + if ('tagForm' === interaction.customId) { + const name = interaction.fields.getTextInputValue('tagFormName').toLowerCase(); + const content = interaction.fields.getTextInputValue('tagFormContent'); + new Tag(name, content, interaction.user.id, 'approved').save(); + await interaction.reply({ content: `The tag \`${name}\` has been added successfully`, ephemeral: true }); + } else if (interaction.customId.startsWith('t.e.')) { + const memberRoles = (interaction.member.roles as GuildMemberRoleManager).cache.map((role) => role.id); + if (memberRoles.some((role) => [teamRole, devRole].includes(role))) return; + const name = interaction.customId.split('.')[2]; + const content = interaction.fields.getTextInputValue('tagFormUpdatedContent'); + const updatedTag = await modifyTag(name, new Tag(name, content, interaction.user.id, 'approved')); + if (updatedTag.success) { + await interaction.reply({ content: `The tag \`${name}\` has been updated successfully`, ephemeral: true }); + } else if (false === updatedTag.success && 'Tag not found' === updatedTag.info) { + await interaction.reply({ content: 'This tag does not exist!', ephemeral: true }); + } else { + await interaction.reply({ content: 'An error occurred', ephemeral: true }); + } + } + } + + async buttonInteraction(interaction: ButtonInteraction): Promise { + if (!interaction.channel || !interaction.guild) return; + await interaction.deferReply({ ephemeral: true }); + console.log( + `Interaction Event trigged by ${interaction.user.username} (${interaction.user.id}) clicked button ${ + interaction.customId + } in ${interaction.guild.id} in ${interaction.channel.id}` + ); + if (interaction.customId.startsWith('infractions.')) { + const userId = interaction.customId.split('.')[1]; + const data = await getUserInfractions(userId); + if (false === data.success) { + await interaction.followUp({ content: data.info }); + return; + } + await interaction.followUp({ embeds: [getInfractionEmbed(userId, data.info, data.infractions)] }); + } else if (interaction.customId.startsWith('messageDelete.')) { + const [channelId, messageId] = [interaction.customId.split('.')[1], interaction.customId.split('.')[2]]; + const channel = interaction.guild.channels.cache.get(channelId); + if (!channel || channel.type !== ChannelType.GuildText) { + await interaction.followUp({ content: 'Message not found' }); + return; + } + const message = await channel.messages.fetch(messageId); + if (!message) await interaction.followUp({ content: 'Message not found' }); + await message.delete(); + await interaction.followUp({ content: 'Message has been deleted' }); + } + } +} + +export default InteractionHandler; diff --git a/src/handlers/MessageHandler.ts b/src/handlers/MessageHandler.ts new file mode 100644 index 0000000..a0f42bd --- /dev/null +++ b/src/handlers/MessageHandler.ts @@ -0,0 +1,111 @@ +import { ChannelType, GuildMemberRoleManager, Message, TextChannel, Webhook, WebhookType } from 'discord.js'; +import { DiscordInviteRegex, HypixelAPIKeyRegex, IPAddressPattern, URLRegex } from '../utils/regex'; +import { getAllowedDomains, getAntiLinkState } from '../utils/mongo'; +import { autoModBypassRole } from '../../config.json'; +import DiscordManager from '../DiscordManager'; +import Infraction from '../utils/Infraction'; +import { writeFileSync } from 'fs'; + +class MessageHandler { + discord: DiscordManager; + allowedDomains?: string[]; + constructor(discordManager: DiscordManager) { + this.discord = discordManager; + this.updateAllowedDomains(); + } + + async updateAllowedDomains() { + this.allowedDomains = await getAllowedDomains(); + } + + async onMessage(message: Message) { + if (!message.member) return; + const memberRoles = (message.member.roles as GuildMemberRoleManager).cache.map((role) => role.id); + if (memberRoles.includes(autoModBypassRole)) return; + this.updateAllowedDomains(); + const hypixelKeyTest = HypixelAPIKeyRegex.test(message.content); + const ipTest = IPAddressPattern.test(message.content); + const urlTest = await this.isUrlAllowed(message.content); + const discordTest = DiscordInviteRegex.test(message.content); + if ((ipTest || false === urlTest || discordTest || hypixelKeyTest) && false === message.author.bot) { + this.AutoModPickup( + message, + hypixelKeyTest ? "Hey thats your Hypixel API Key. Please **don't** post that." : undefined + ); + } + } + + async getWebhook( + channel: TextChannel + ): Promise | undefined> { + const webhooks = await channel.fetchWebhooks(); + + if (0 === webhooks.size) { + channel.createWebhook({ + name: channel.client.user.globalName ?? channel.client.user.username, + avatar: channel.client.user.avatarURL() + }); + + await this.getWebhook(channel); + } + + return webhooks.first(); + } + + async isUrlAllowed(url: string): Promise { + const antiLinkState = await getAntiLinkState(); + if (false === antiLinkState) return false; + if (!this.allowedDomains) return false; + const isValidUrl = URLRegex.test(url); + if (!isValidUrl) return true; + const match = url.match(URLRegex); + if (!match) return false; + const domain: string = match[3]; + if (this.allowedDomains.some((pattern) => pattern === domain) && !match[2]) { + return true; + } else if (!this.allowedDomains.some((pattern) => pattern === domain)) { + return ( + this.allowedDomains.some((pattern) => pattern === `*.${domain}`) || + this.allowedDomains.some((pattern) => pattern === match[2] + domain) + ); + } + return false; + } + + async AutoModPickup(message: Message, alertMessage?: string) { + if (message.channel.type !== ChannelType.GuildText || !message.member) return; + const webhook = await this.getWebhook(message.channel); + if (!webhook) return; + const filteredContent = message.content + .replace(HypixelAPIKeyRegex, '[API Key Removed]') + .replace(IPAddressPattern, '[Content Removed]') + .replace(URLRegex, '[Content Removed]') + .replace(DiscordInviteRegex, '[Content Removed]'); + const webHookMsg = await webhook.send({ + username: message.member.nickname ?? message.author.globalName ?? message.author.username, + avatarURL: message.member.avatarURL() ?? message.author.avatarURL() ?? undefined, + content: filteredContent, + files: message.attachments.map((attachment) => attachment.url) + }); + if (alertMessage !== undefined) { + const alert = await message.reply({ content: alertMessage }); + setTimeout(() => alert.delete(), 10000); + } + writeFileSync('data/message.txt', `${message.content}`); + message.delete(); + new Infraction({ + automatic: true, + reason: 'Automod Pickup', + long: null, + type: 'AutoMod', + user: { id: message.author.id, staff: false, bot: message.author.bot }, + staff: { id: message.client.user.id, staff: true, bot: message.client.user.bot }, + timestamp: Date.now(), + extraInfo: { url: message.url, messageId: webHookMsg.id, channelId: message.channel.id } + }) + .log() + .save(); + } +} + +export default MessageHandler; diff --git a/src/types/main.d.ts b/src/types/main.d.ts index f189b70..c10c0bd 100644 --- a/src/types/main.d.ts +++ b/src/types/main.d.ts @@ -1,4 +1,5 @@ import { Collection, SlashCommandBuilder, ChatInputCommandInteraction } from 'discord'; +import { Guild } from 'discord.js'; export interface SlashCommand { command: SlashCommandBuilder; @@ -10,3 +11,7 @@ declare module 'discord.js' { commands: Collection; } } + +declare global { + var guild: Guild; +} diff --git a/src/utils/CheckPermits.ts b/src/utils/CheckPermits.ts new file mode 100644 index 0000000..7d24ca9 --- /dev/null +++ b/src/utils/CheckPermits.ts @@ -0,0 +1,21 @@ +import { autoModBypassRole } from '../../config.json'; +import { readFileSync, writeFileSync } from 'fs'; +import { UserPermit } from '../commands/automod'; + +export default function CheckPermits() { + const permitData = readFileSync('data/permit.json'); + if (permitData === undefined) return; + const permit = JSON.parse(permitData.toString()); + if (permit === undefined) return; + const currentTime = Math.floor(new Date().getTime() / 1000); + permit.forEach((user: UserPermit) => { + if (user.removeTime < currentTime) { + const guildMember = guild.members.cache.get(user.id); + if (guildMember) { + guildMember.roles.remove(autoModBypassRole); + } + permit.splice(permit.indexOf(user), 1); + } + }); + writeFileSync('data/permit.json', JSON.stringify(permit)); +} diff --git a/src/utils/Infraction.ts b/src/utils/Infraction.ts new file mode 100644 index 0000000..a7630cd --- /dev/null +++ b/src/utils/Infraction.ts @@ -0,0 +1,206 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, EmbedBuilder } from 'discord.js'; +import { infractionLogchannel } from '../../config.json'; +import { model, Schema } from 'mongoose'; +import { unlinkSync } from 'fs'; +import ms from 'ms'; + +export interface User { + id: string; + staff: boolean; + bot: boolean; +} + +export type InfractionType = 'AutoMod' | 'WARN' | 'KICK' | 'BAN' | 'MUTE' | 'UNMUTE'; + +export interface InfractionExtraInfo { + url: string; + messageId: string; + channelId: string; +} + +export interface InfractionInfomation { + automatic: boolean; + reason: string; + long: number | null; + type: InfractionType; + user: User; + staff: User; + timestamp: number; + extraInfo: InfractionExtraInfo; +} + +export const UserSchema = new Schema({ id: String, staff: Boolean, bot: Boolean }); +export const ExtraInfoSchema = new Schema({ url: String, messageId: String, channelId: String }); + +const InfractionSchema = new Schema({ + automatic: Boolean, + reason: String, + long: { type: Number, default: null }, + type: String, + user: UserSchema, + staff: UserSchema, + timestamp: Number, + extraInfo: ExtraInfoSchema +}); + +const InfractionModel = model('infraction', InfractionSchema); + +class Infraction { + private infraction: InfractionInfomation; + constructor(infraction: InfractionInfomation) { + this.infraction = infraction; + } + + public save() { + return new InfractionModel({ + automatic: this.infraction.automatic, + reason: this.infraction.reason, + long: this.infraction.long, + type: this.infraction.type, + user: this.infraction.user, + staff: this.infraction.staff, + timestamp: this.infraction.timestamp, + extraInfo: this.infraction.extraInfo + }).save(); + } + + public setAutomatic(automatic: boolean): this { + this.infraction.automatic = automatic; + return this; + } + + public setReason(reason: string): this { + this.infraction.reason = reason; + return this; + } + + public setLong(long: number | null): this { + this.infraction.long = long; + return this; + } + + public setType(type: InfractionType): this { + this.infraction.type = type; + return this; + } + + public setUser(user: User): this { + this.infraction.user = user; + return this; + } + + public setStaff(staff: User): this { + this.infraction.staff = staff; + return this; + } + + public setTimestamp(timestamp: number): this { + this.infraction.timestamp = timestamp; + return this; + } + + public setExtraInfo(extraInfo: InfractionExtraInfo): this { + this.infraction.extraInfo = extraInfo; + return this; + } + + public getAutomatic(): boolean { + return this.infraction.automatic; + } + + public getReason(): string { + return this.infraction.reason; + } + + public getLong(): number | null { + return this.infraction.long; + } + + public getType(): InfractionType { + return this.infraction.type; + } + + public getUser(): User { + return this.infraction.user; + } + + public getStaff(): User { + return this.infraction.staff; + } + + public getTimestamp(): number { + return this.infraction.timestamp; + } + + public getExtraInfo(): InfractionExtraInfo { + return this.infraction.extraInfo; + } + + public toString(): string { + return `**Infraction:** ${this.infraction.reason}\n**Type:** ${this.infraction.type}\n**User:** <@${ + this.infraction.user.id + }>\n**Staff:** <@${this.infraction.staff.id}>\n**Timestamp:** ()\n**Automatic:** ${ + this.infraction.automatic ? 'Yes' : 'No' + }\n${null !== this.infraction.long ? `**How long:** ${ms(86400000, { long: true })}` : ''}`; + } + + public log(): this { + const channel = guild.channels.cache.get(infractionLogchannel); + if (!channel || channel.type !== ChannelType.GuildText) return this; + const embed = new EmbedBuilder() + .setColor(0xff8c00) + .setTitle(`Infraction | ${this.infraction.type}`) + .addFields( + { name: 'User', value: `<@${this.infraction.user.id}>` }, + { name: 'Reason', value: this.infraction.reason }, + { name: 'Staff', value: `<@${this.infraction.staff.id}>` }, + { name: 'Automatic', value: this.infraction.automatic ? 'Yes' : 'No' }, + { + name: 'Timestamp', + value: ` ()` + } + ); + if (null !== this.infraction.long) embed.addFields({ name: 'How long', value: `${ms(86400000, { long: true })}` }); + const components: ActionRowBuilder[] = []; + if (true === this.infraction.automatic && 'AutoMod' === this.infraction.type) { + components.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setURL(this.infraction.extraInfo.url) + .setLabel('Jump To Message') + .setStyle(ButtonStyle.Link), + new ButtonBuilder() + .setLabel('Delete Message') + .setStyle(ButtonStyle.Danger) + .setCustomId(`messageDelete.${this.infraction.extraInfo.channelId}.${this.infraction.extraInfo.messageId}`), + new ButtonBuilder() + .setCustomId(`infractions.${this.infraction.user.id}`) + .setLabel('View User Infractions') + .setStyle(ButtonStyle.Secondary) + ) + ); + setTimeout(() => channel.send({ embeds: [embed], components: components, files: ['data/message.txt'] }), 1000); + setTimeout(() => unlinkSync('data/message.txt'), 3000); + } else { + channel.send({ embeds: [embed] }); + } + return this; + } +} +export async function getUserInfractions( + id: string +): Promise<{ success: boolean; info: string; infractions: Infraction[] }> { + const userInfractions = await InfractionModel.find({ 'user.id': id }); + if (!userInfractions) return { success: false, info: 'No infractions found', infractions: [] }; + const foundInfraction: Infraction[] = []; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + userInfractions.forEach((infraction) => foundInfraction.push(new Infraction(infraction))); + return { success: true, info: `User has ${foundInfraction.length} infractions`, infractions: foundInfraction }; +} + +export default Infraction; diff --git a/src/functions/logger.ts b/src/utils/logger.ts similarity index 95% rename from src/functions/logger.ts rename to src/utils/logger.ts index 28040de..d09af6c 100644 --- a/src/functions/logger.ts +++ b/src/utils/logger.ts @@ -1,9 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable no-console */ -// Credits https://github.com/DuckySoLucky/hypixel-discord-chat-bridge/blob/f8a8a8e1e1c469127b8fcd03e6553b43f22b8250/src/Logger.js (Edited) +/* Credits https://github.com/DuckySoLucky/hypixel-discord-chat-bridge/blob/f8a8a8e1e1c469127b8fcd03e6553b43f22b8250/src/Logger.js (Edited) */ const customLevels = { event: 0, error: 1, other: 2, max: 3 }; import { createLogger, format, transports } from 'winston'; - const timezone = () => { return new Date().toLocaleString('en-US', { year: 'numeric', @@ -15,12 +13,10 @@ const timezone = () => { hour12: false }); }; - const eventTransport = new transports.File({ level: 'event', filename: './logs/event.log' }); const errorTransport = new transports.File({ level: 'error', filename: './logs/error.log' }); const otherTransport = new transports.File({ level: 'other', filename: './logs/other.log' }); const combinedTransport = new transports.File({ level: 'max', filename: './logs/combined.log' }); - const eventLogger = createLogger({ level: 'event', levels: customLevels, @@ -32,7 +28,6 @@ const eventLogger = createLogger({ ), transports: [eventTransport, combinedTransport] }); - const errorLogger = createLogger({ level: 'error', levels: customLevels, @@ -55,7 +50,6 @@ const otherLogger = createLogger({ ), transports: [otherTransport, combinedTransport] }); - const logger = { event: (message: any) => { eventLogger.log('event', message); @@ -70,7 +64,6 @@ const logger = { console.log(message); } }; - export const eventMessage = logger.event; export const errorMessage = logger.error; export const otherMessage = logger.other; diff --git a/src/functions/mongo.ts b/src/utils/mongo.ts similarity index 52% rename from src/functions/mongo.ts rename to src/utils/mongo.ts index c9d5692..4910ccc 100644 --- a/src/functions/mongo.ts +++ b/src/utils/mongo.ts @@ -1,26 +1,20 @@ import { Schema, connect, model } from 'mongoose'; +import { User, UserSchema } from './Infraction'; import { mongoURL } from '../../config.json'; export function connectDB(): void { connect(mongoURL).then(() => { - // eslint-disable-next-line no-console console.log('Connected to MongoDB'); }); } - export interface TagType { content: string; status: string; name: string; id: string; } -const tagSchema = new Schema({ - content: String, - status: String, - name: String, - id: String -}); +const tagSchema = new Schema({ content: String, status: String, name: String, id: String }); const TagModel = model('Tag', tagSchema); export class Tag { @@ -34,14 +28,8 @@ export class Tag { this.id = id; this.status = status; } - save() { - new TagModel({ - content: this.content, - status: this.status, - name: this.name, - id: this.id - }).save(); + new TagModel({ content: this.content, status: this.status, name: this.name, id: this.id }).save(); } } @@ -108,3 +96,69 @@ export async function getTagNames(): Promise { return { success: false, info: 'An error occurred', error: error }; } } + +const antiLinkSchema = new Schema({ + url: String, + timestamp: Number, + reason: String, + user: UserSchema, + admin: Boolean, + enabled: Boolean +}); +const antiLinkModel = model('AntiLink', antiLinkSchema); + +export async function getAllowedDomains(): Promise { + const links = await antiLinkModel.find(); + if (!links) return []; + const allowedLinks: string[] = []; + links + .filter((link) => true !== link.admin) + .filter((link) => 'string' === typeof link.url) + .forEach((link) => allowedLinks.push(link.url as string)); + return allowedLinks; +} + +export async function getAllowedDomainInfo( + url: string +): Promise<{ url: string; timestamp: number; user: User } | null> { + const urlInfo = await antiLinkModel.findOne({ url: url }); + if (!urlInfo) return null; + return { + url: urlInfo?.url || '', + timestamp: urlInfo?.timestamp || 0, + user: { + id: urlInfo.user?.id || '', + staff: urlInfo.user?.staff || false, + bot: urlInfo.user?.bot || false + } + }; +} + +export async function getAntiLinkState(): Promise { + const status = await antiLinkModel.findOne({ admin: true }); + if (!status) return false; + return status.enabled as boolean; +} + +export async function toggleAntiLinks(state?: boolean): Promise { + let status = await antiLinkModel.findOne({ admin: true }); + if (!status) return false; + if (state === undefined) state = !status.enabled; + await antiLinkModel.findOneAndUpdate({ admin: true }, { enabled: state }); + status = await antiLinkModel.findOne({ admin: true }); + if (!status) return false; + return status.enabled as boolean; +} + +export async function addAllowedURL(url: string, user: User): Promise<{ set: boolean; info: string }> { + const check = await antiLinkModel.findOne({ url: url }); + if (check) { + return { set: false, info: 'URL already allowed' }; + } + new antiLinkModel({ url: url, timestamp: Date.now(), user: user, admin: false, enabled: false }).save(); + return { set: true, info: url }; +} + +export function removeAllowedURL(url: string): void { + antiLinkModel.findOneAndDelete({ url: url }); +} diff --git a/src/utils/regex.ts b/src/utils/regex.ts new file mode 100644 index 0000000..16d82c9 --- /dev/null +++ b/src/utils/regex.ts @@ -0,0 +1,5 @@ +export const HypixelAPIKeyRegex = /[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}/g; +export const IPAddressPattern = /((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.|$)){4}/g; +export const URLRegex = + /https?:\/\/(www\.)?([-a-zA-Z0-9]{1,63}\.)*([-a-zA-Z0-9]{1,63}\.[a-zA-Z]{2,6})([-a-zA-Z0-9()@:%_\+.~#?&\/\/=]*)/; +export const DiscordInviteRegex = /(?:https:\/\/)?discord\.gg\/[a-zA-Z0-9]+/g; diff --git a/src/utils/user.ts b/src/utils/user.ts new file mode 100644 index 0000000..f9b2278 --- /dev/null +++ b/src/utils/user.ts @@ -0,0 +1,30 @@ +import { EmbedBuilder, GuildMember } from 'discord.js'; +import { serverId } from '../../config.json'; +import Infraction from './Infraction'; + +export function getUserInfoEmbed(user: GuildMember): EmbedBuilder { + return new EmbedBuilder() + .setTitle('User Infomation') + .setTimestamp() + .setColor(0xff8c00) + .setDescription( + `<@${user.id}>\n\nBot: ${user.user.bot}\nID: ${user.id}\n Created: ()\nJoined: ()\nRoles: ${user.roles.cache + .map((role) => `<@&${role.id}>`) + .filter((role) => role !== `<@&${serverId}>`) + .join(', ')}` + ); +} + +export function getInfractionEmbed(userId: string, info: string, infractions: Infraction[]): EmbedBuilder { + return new EmbedBuilder() + .setTitle(`User Infractions`) + .setDescription( + `### <@${userId}>\n${info}\n\n${infractions?.map((infraction) => infraction.toString()).join('\n\n')}` + ) + .setColor(0xff8c00) + .setTimestamp(); +}