From 610e5ea946af10e4aead895906a333bfbd2dcbcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl?= <35407363+EniumRaphael@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:25:03 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20v0.1.0-alpha=20=E2=80=93=20Prote?= =?UTF-8?q?ction=20System,=20Anti-Raid=20&=20Core=20Stability=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(branch/dev): now have a dev branch to test my update * feat(logs/disable): adding the options to disable the logs - Adding the option to the logs command * feat(logs/disable): adding the options to disable the logs - Adding the option to the logs command * feat(logs/disable): finishing the disable command for logs - suppressing all the data stored in database (concerning the logs) - suppressing all the channels on the logs categories * feat(logs/bot): Sending a message when ready to all the buyer - The message is send just the first time after it's just an edit - The message is an embed * style(logs/bot): embed for ready updated - Color is now turned to green (no color saved in database for all the bot) * feat(logs/bot): Sending a message when ready to all the buyer - The message is send just the first time after it's just an edit - The message is an embed * style(logs/bot): embed for ready updated - Color is now turned to green (no color saved in database for all the bot) * style(ready/msg): now embed use timestamp to give the last restart * style(ready/msg): norming stuff to be coherent with the code - Give the backline like the other option * feat(logs/collect): The collector is now timed out - If the user pass more than 1 minutes to select the roles the message will be updated and the collector disable. * feat(logs/guildCreate): now joining a server is sending a message - All the buyer will recieve a message when the bot is added to a new guild. - The information give by the message is invitation / member count / owner id * feat(logs/guildDelete): adding the private message when leaving a server - An embed with the owner id / guild name / member count * 🧪 Alpha v0.0.3 – Buyer Logs, Guild Events & UX Polish 💌 Buyer Notifications – Now Real-Time • 🟢 On Bot Ready → Sends a DM to all buyers when the bot boots up. → Reuses the same message for future startups by editing the last one. → Includes a timestamp ⏱️ to track the latest restart. → Fresh new green embed style for clarity ✅ • ➕ On Guild Join → Buyers are notified when the bot joins a new server. → Embed includes: • Server name & ID • Member count 👥 • Owner ID 👑 • Instant invite link 🔗 • ➖ On Guild Leave → Buyers also receive a DM when the bot is removed from a server. → Embed includes: • Guild name • Owner ID • Member count at time of removal ⸻ 🔐 Interaction UX Improvements • 🕒 Timed Role Collector for /logs → The role selection menu now times out after 1 minute. → Message is updated to reflect the timeout, and the collector is disabled to avoid confusion. ⸻ 🎨 Style & Code Coherence • ✅ Ready embeds now use consistent layout (backline spacing, timestamps). • 🎨 Unified color scheme: green embeds for success/startup (no per-bot color in DB yet). • 🧼 Normalization pass for embed construction and messaging consistency. ⸻ 📌 This alpha lays the foundation for clean bot lifecycle monitoring, robust buyer communication, and better UI behavior for commands. Next in the pipe? Maybe logging settings persistence or webhook dispatches 👀 * feat(moderation/nuke): adding the command to nuke channel textual - The command have to goal to delete and recreate a clone of the channel * fix(moderation/nuke): selecting the people who can nuke channels - Now only people with the whitelisted status (or more) can renew / nuke the channel * fix(moderation/nuke): selecting the people who can nuke channels - Now only people with the whitelisted status (or more) can renew / nuke the channel feat(logs/guildUpdate): now guildUpdate event is catch and send a message to guildlogs - logging the diff between: - premiumTier - contentFilter - locale - name - afk * fix(moderation/nuke): selecting the people who can nuke channels - Now only people with the whitelisted status (or more) can renew / nuke the channel feat(logs/guildUpdate): now guildUpdate event is catch and send a message to guildlogs - logging the diff between: - premiumTier - contentFilter - locale - name - afk * fix(logs/guildCreate): fixing typo on the final console log - The console logs was pasted from the index.ts and now is updating to use the interaction.guild instead the counter of the database * style(logs/guildUpdate): updating the messages instead using integer - now the verification level will send the same message wrote in the rules - the explicit content level will send a message instead just a single index * refractor(logs/guildUpdate): adding the subfolder 'guild' in event * refractor(admin/deletecat): adding the deleteCategories in administration part * refractor(event/guildUpdate): typing the function parameters - Adding the update for guild update event, (oldGuild / newGuild) * refactor(event/guildDelete): typing the parameter of the function - Typing the execute function of the event guildDelete (in user cat) * refactor(event/interaction): adding the type on interaction create * feat(lib/perm): now isWhitelisted can be import on commands - The command is exportable including the lib/perm.ts. Starting to have really utils (and so function) on this project * refactor(client/ready): eslint is now respecting - The eslint is now respect. * docs(utils/perm): Adding documentation for utils - Doxygen will be now required for all the utils * core(bun/package): update all the package to latest version - There is now a warning when running the bot cause by an electron update waiting to discord.js * refactor(event/ready): format following the eslint - The code is now setup like the eslint is configurated * style(event/guildDelete): adding footer to delete guild - The footer by default for the server is now added when suppressed from the server * refactor(commands): starting to adding type on the command - Now with the lsp configurated is easier to see the type error (interger instead number) / Guild -> GuildPrisma * fix(moderation/nuke): correcting the type guildText - The type was not correct * fix(client/guildCreate): typing many thing to respect the Type - The type was not correct * fix(client/guildUpdate): typing many thing to respect the Type - The type was not correct * fix(client/interactino): typing many thing to respect the Type - The type was not correct * fix(lib/permission): typing many thing to respect the Type - The type was not correct * feat(event/messages): adding the MessageDelete logs * core: updating packages * fix(lib/perm): correcting the type integer to number * fix(internal/deploycommand): adding type to command* variable * feat(events/messagesDelete): adding type and now sending the log message * feat(events/messagesBulkDelete): adding the event management * fix(event/client): adding types on guildCreate - Adding prisma types * fix(event/client): adding types on guildDelete - Adding prisma types * fix(cmd/administration): adding types on deletecat - Adding prisma types * fix(command/rank): adding type on whitelist * fix(command/rank): import CommandInteraction on whiltelist * feat(event/messages): adding the MessageCreate event - No logging stuff but a level system from scratch * style(event/message): adding the pipe like in other messages. * style(action/bun): removing the space * feat(flake/tmux): adding tmux project configuration - Adding the tmux-setup command how load my own tmux configuration to work on this project * fix(flake/tmux): patching the zplug issues when loading to many time - just adding a exec zsh * feat(event/messages): adding title on MessageBulkDelete * feat(event/messages): adding title on MessageDelete * feat(event/messages): adding logs for MessageUpdate * fix(event/messages): Fixing the prisma call - Using the old variable name message become oldMessage / newMessage * style(event/messages): Adding Italic style if content cannot be load * feat(cmd/admin): adding protect command * feat(events/channel): adding channelCreate event - The event contains: - logs for user whitelisted / Owner / Buyer - Antiraid for others * feat(events/channel): adding channelDelete event - The event contains: - logs for user whitelisted / Owner / Buyer - Antiraid for others * feat(events/channel): adding channeUpdate event - The event contains: - logs for users * feat(src/lib): adding the a function to choose the correction mention --- .github/workflows/bun.yml | 2 +- flake.nix | 77 +++++--- package.json | 14 +- prisma/schema.prisma | 8 + .../deleteCategories.ts | 7 +- src/commands/administration/logs.ts | 18 +- src/commands/administration/protect.ts | 184 ++++++++++++++++++ src/commands/custom/set.ts | 4 +- src/commands/moderation/nuke.ts | 44 +++++ src/commands/rank/whitelist.ts | 8 +- src/events/channel/channelCreate.ts | 75 +++++++ src/events/channel/channelDelete.ts | 77 ++++++++ src/events/channel/channelUpdate.ts | 84 ++++++++ src/events/client/guildCreate.ts | 133 ++++++++++--- src/events/client/guildDelete.ts | 51 +++++ src/events/client/guildUpdate.ts | 55 ++++++ src/events/client/ready.ts | 57 +++++- src/events/guild/guildUpdate.ts | 70 +++++++ src/events/interaction/interaction.ts | 6 +- src/events/messages/messageBulkDelete.ts | 60 ++++++ src/events/messages/messageCreate.ts | 73 +++++++ src/events/messages/messageDelete.ts | 43 ++++ src/events/messages/messageUpdate.ts | 44 +++++ src/internal/deploy-command.ts | 12 +- src/lib/mention.ts | 16 ++ src/lib/perm.ts | 26 +++ 26 files changed, 1156 insertions(+), 92 deletions(-) rename src/commands/{utils => administration}/deleteCategories.ts (87%) create mode 100644 src/commands/administration/protect.ts create mode 100644 src/commands/moderation/nuke.ts create mode 100644 src/events/channel/channelCreate.ts create mode 100644 src/events/channel/channelDelete.ts create mode 100644 src/events/channel/channelUpdate.ts create mode 100644 src/events/client/guildDelete.ts create mode 100644 src/events/client/guildUpdate.ts create mode 100644 src/events/guild/guildUpdate.ts create mode 100644 src/events/messages/messageBulkDelete.ts create mode 100644 src/events/messages/messageCreate.ts create mode 100644 src/events/messages/messageDelete.ts create mode 100644 src/events/messages/messageUpdate.ts create mode 100644 src/lib/mention.ts create mode 100644 src/lib/perm.ts diff --git a/.github/workflows/bun.yml b/.github/workflows/bun.yml index 09106b8..e05f3f6 100644 --- a/.github/workflows/bun.yml +++ b/.github/workflows/bun.yml @@ -6,7 +6,7 @@ on: - "**" pull_request: branches: - - "**" + - "**" permissions: contents: read diff --git a/flake.nix b/flake.nix index 3d2e984..83fde3a 100644 --- a/flake.nix +++ b/flake.nix @@ -1,33 +1,52 @@ { - description = "Flake de développement pour un bot Discord en TypeScript avec Bun"; + description = "Flake de développement pour un bot Discord en TypeScript avec Bun"; - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - flake-utils.url = "github:numtide/flake-utils"; - }; + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; - outputs = { self, nixpkgs, flake-utils }: - flake-utils.lib.eachDefaultSystem (system: - let - pkgs = import nixpkgs { - inherit system; - }; - in { - devShells.default = pkgs.mkShell { - name = "discord-bot-bun-ts"; - buildInputs = with pkgs; [ - bun - git - curl - wget - nodejs_latest - mariadb - ]; - shellHook = '' - export NIX_SHOW_STATS=0 - export NIX_HIDE_STATS=1 - printf "\n\033[0;90m Typescript + Bun env loaded for: \033[38;5;220m${system}\033[0m\n" - ''; - }; - }); + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + tmux-setup = pkgs.writeShellScriptBin "tmux-setup" '' + #!/usr/bin/env sh + SESSION="TTY" + DIR=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + if ! tmux has-session -t $SESSION 2>/dev/null; then + tmux new-session -d -s $SESSION -c "$DIR" -n dev + tmux send-keys -t $SESSION:0 'vim' C-m + tmux split-window -h -p 30 -t $SESSION:0 -c "$DIR" + tmux send-keys -t $SESSION:0.2 'exec zsh' C-m + tmux split-window -v -p 30 -t $SESSION:0.1 -c "$DIR" + tmux send-keys -t $SESSION:0.2 'watch -n0.5 bunx eslint ./src' C-m + tmux split-window -v -p 50 -t $SESSION:0.2 -c "$DIR" + tmux send-keys -t $SESSION:0.3 'bunx prisma studio' C-m + tmux new-window -t $SESSION:1 -n git -c "$DIR" + tmux send-keys -t $SESSION:1 'lazygit' C-m + fi + tmux select-window -t $SESSION:0 + tmux select-pane -t $SESSION:0.0 + tmux attach -t $SESSION + ''; + in { + devShells.default = pkgs.mkShell { + name = "discord-bot-bun-ts"; + buildInputs = with pkgs; [ + bun + git + curl + wget + nodejs_latest + mariadb + tmux-setup + ]; + shellHook = '' + export NIX_SHOW_STATS=0 + export NIX_HIDE_STATS=1 + printf "\n\033[0;90m Typescript + Bun env loaded for: \033[38;5;220m${system}\033[0m\n" + ''; + }; + }); } diff --git a/package.json b/package.json index 5e9208b..c580f4c 100644 --- a/package.json +++ b/package.json @@ -15,22 +15,22 @@ }, "devDependencies": { "@types/bun": "latest", - "eslint": "^9.33.0", + "eslint": "^9.36.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", "husky": "^9.1.7", - "lint-staged": "^16.1.5", + "lint-staged": "^16.2.3", "prettier": "^3.6.2", - "prisma": "^6.14.0", - "typescript-eslint": "^8.39.1" + "prisma": "^6.16.2", + "typescript-eslint": "^8.45.0" }, "peerDependencies": { "typescript": "^5.8.3" }, "dependencies": { - "@prisma/client": "^6.14.0", - "discord.js": "^14.21.0", - "dotenv": "^17.2.1", + "@prisma/client": "^6.16.2", + "discord.js": "^14.22.1", + "dotenv": "^17.2.2", "mariadb": "^3.4.5" } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4ef7a4a..7cd7f51 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -46,6 +46,14 @@ model Guild { logMsg String? logServer String? + protectEnabled Boolean @default(false) + protectAntiChannel Boolean @default(false) + protectAntiRank Boolean @default(false) + protectAntiPerm Boolean @default(false) + protectAntiMassban Boolean @default(false) + protectAntiMassMention Boolean @default(false) + protectAntiBot Boolean @default(false) + footer String @default("© EniumTeam ~ 2025") color String @default("#000000") diff --git a/src/commands/utils/deleteCategories.ts b/src/commands/administration/deleteCategories.ts similarity index 87% rename from src/commands/utils/deleteCategories.ts rename to src/commands/administration/deleteCategories.ts index 528d96c..49b2d25 100644 --- a/src/commands/utils/deleteCategories.ts +++ b/src/commands/administration/deleteCategories.ts @@ -1,6 +1,7 @@ -import { MessageFlags, ChannelType, SlashCommandBuilder } from 'discord.js'; +import { MessageFlags, ChannelType, SlashCommandBuilder, CommandInteraction } from 'discord.js'; import emoji from '../../../assets/emoji.json' assert { type: 'json' }; import { prisma } from '../../lib/prisma.ts'; +import { User as UserPrisma } from '@prisma/client'; export default { data: new SlashCommandBuilder() @@ -14,7 +15,7 @@ export default { .addChannelTypes(ChannelType.GuildCategory), ), async execute(interaction: CommandInteraction) { - let userData: User; + let userData: UserPrisma; try { userData = await prisma.user.findUnique({ where: { @@ -40,7 +41,7 @@ export default { }); return; } - const category: GuildCategory = interaction.options.getChannel( + const category: ChannelType.GuildCategory = interaction.options.getChannel( 'category', true, ); diff --git a/src/commands/administration/logs.ts b/src/commands/administration/logs.ts index ce801f1..a4319d1 100644 --- a/src/commands/administration/logs.ts +++ b/src/commands/administration/logs.ts @@ -10,8 +10,8 @@ import { StringSelectMenuOptionBuilder, SlashCommandBuilder, MessageFlags, - SlashCommandBuilder, EmbedBuilder, + CommandInteraction, } from 'discord.js'; import emoji from '../../../assets/emoji.json' assert { type: 'json' }; @@ -72,11 +72,6 @@ export default { } catch (err) { throw `\t⚠️ | Cannot get the database connection!\n\t\t(${err}).`; - await interaction.reply({ - content: `${emoji.answer.error} | Cannot connect to the database`, - flags: MessageFlags.Ephemeral, - }); - return; } const choice: string = interaction.options.getString('action'); switch (choice) { @@ -150,7 +145,7 @@ export default { ), ); - const roleSelection = + const roleSelection: ActionRowBuilder = new ActionRowBuilder().addComponents(menu); const permSelector: EmbedBuilder = new EmbedBuilder() @@ -170,6 +165,15 @@ export default { time: 60_000, max: 25, }); + collector.on('end', async (collected) => { + if (collected.size === 0) { + await interaction.editReply({ + content: '⏰ | Too many time to select roles allowed to see the logs', + embeds: [], + components: [], + }); + } + }); collector.on( 'collect', async (selectInteraction: StringSelectMenuInteraction) => { diff --git a/src/commands/administration/protect.ts b/src/commands/administration/protect.ts new file mode 100644 index 0000000..046fb97 --- /dev/null +++ b/src/commands/administration/protect.ts @@ -0,0 +1,184 @@ +import { + SlashCommandBuilder, + ActionRowBuilder, + StringSelectMenuBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + ComponentType, + ChatInputCommandInteraction, + MessageFlags, +} from 'discord.js'; +import { prisma } from '../../lib/prisma'; +import { Guild as GuildPrisma } from '@prisma/client'; + +const modules = { + 'anti-channel': 'Block channel creation/deletion', + 'anti-rank': 'Block dangerous rank modifications', + 'anti-perm': 'Block dangerous permissions on roles', + 'anti-massban': 'Prevent mass bans', + 'anti-mass-mention': 'Prevent mass mentions', + 'anti-bot': 'Prevent unauthorized bots', +}; + +function camel(str: string): string { + return str + .split('-') + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(''); +} + +function getEmbed(guildData: GuildPrisma): EmbedBuilder { + let description: string = '\n'; + for (const [key, label] of Object.entries(modules)) { + const field = `protect${camel(key)}`; + const enabled = guildData[field as keyof typeof guildData] as boolean; + description += `- **${label}**: ${enabled ? '✅' : '❌'}\n`; + } + const baseEmbed = new EmbedBuilder() + .setTitle('🛡️ | Protection Manager') + .setDescription(description) + .setFooter({ + text: guildData.footer, + }) + .setColor(guildData.color); + return baseEmbed; +} + +function getButton(selected: string, active: boolean): ActionRowBuilder { + const button = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`enable_${selected}`) + .setLabel('Enable') + .setEmoji('✅') + .setDisabled(!active) + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`disable_${selected}`) + .setLabel('Disable') + .setEmoji('❌') + .setDisabled(!active) + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('return') + .setLabel('Return') + .setEmoji('↩️') + .setStyle(ButtonStyle.Secondary), + ); + return button; +} + +export default { + data: new SlashCommandBuilder() + .setName('protect') + .setDescription('Manage guild protections interactively'), + + async execute(interaction: ChatInputCommandInteraction) { + const guildId: string | null = interaction.guildId!; + let guildData: GuildPrisma = await prisma.guild.findUnique({ + where: { + id: guildId, + }, + }); + if (!guildData) { + guildData = await prisma.guild.create({ + data: { + id: guildId, + }, + }); + } + const menu: StringSelectMenuBuilder = new StringSelectMenuBuilder() + .setCustomId('select_protect') + .setPlaceholder('Select a module') + .addOptions( + Object.entries(modules).map(([value, label]) => ({ + label, + value, + })), + ); + const msg = await interaction.reply({ + embeds: [getEmbed(guildData)], + components: [new ActionRowBuilder().addComponents(menu)], + flags: MessageFlags.Ephemeral, + }); + const collector = msg.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + time: 5 * 60 * 1000, + }); + collector.on('collect', async (selectInteraction) => { + if (selectInteraction.user.id !== interaction.user.id) { + return selectInteraction.reply({ + content: '❌ You cannot use this menu.', + flags: MessageFlags.Ephemeral, + }); + } + const selected: string = selectInteraction.values[0] as keyof typeof modules; + const enabled = guildData![`protect${camel(selected)}`]; + const moduleEmbed = new EmbedBuilder() + .setTitle(`⚙️ | Manage ${modules[selected]}`) + .setFooter({ + text: guildData.footer, + }) + .setDescription( + `This module is currently: **${enabled ? 'Enabled ✅' : 'Disabled ❌'}**`, + ) + .setColor(enabled ? '#00ff00' : '#ff0000'); + await selectInteraction.update({ + embeds: [moduleEmbed], + components: [getButton(selected, true)], + }); + }); + const buttonCollector = msg.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 5 * 60 * 1000, + }); + buttonCollector.on('collect', async (btnInteraction) => { + if (btnInteraction.user.id !== interaction.user.id) { + return btnInteraction.reply({ + content: '❌ | You cannot use these buttons.', + flags: MessageFlags.Ephemeral, + }); + } + if (btnInteraction.customId === 'return') { + return btnInteraction.update({ + embeds: [getEmbed(guildData)], + components: [ + new ActionRowBuilder().addComponents(menu), + ], + }); + } + const [action, moduleName] = btnInteraction.customId.split('_'); + const field = `protect${camel(moduleName)}`; + await prisma.guild.update({ + where: { + id: guildId, + }, + data: { + [field]: action === 'enable', + }, + }); + guildData = await prisma.guild.findUnique({ + where: { + id: guildId, + }, + }); + await btnInteraction.update({ + embeds: [ + new EmbedBuilder() + .setTitle(`⚙️ | Manage ${modules[moduleName as keyof typeof modules]}`) + .setFooter({ + text: guildData.footer, + }) + .setDescription( + `This module is now: **${ + action === 'enable' ? '✅ Enabled' : '❌ Disabled' + }**`, + ) + .setColor(action === 'enable' ? '#00ff00' : '#ff0000'), + ], + components: [getButton(null, false)], + }); + }); + }, +}; + diff --git a/src/commands/custom/set.ts b/src/commands/custom/set.ts index e88c04a..cb478a5 100644 --- a/src/commands/custom/set.ts +++ b/src/commands/custom/set.ts @@ -4,8 +4,10 @@ import { PresenceUpdateStatus, MessageFlags, SlashCommandBuilder, + CommandInteraction, } from 'discord.js'; import emoji from '../../../assets/emoji.json' assert { type: 'json' }; +import { User as UserPrisma } from '@prisma/client'; export default { data: new SlashCommandBuilder() @@ -108,7 +110,7 @@ export default { ), ), async execute(interaction: CommandInteraction) { - let userData: User; + let userData: UserPrisma | null; try { userData = await prisma.user.findUnique({ where: { diff --git a/src/commands/moderation/nuke.ts b/src/commands/moderation/nuke.ts new file mode 100644 index 0000000..b9fc92c --- /dev/null +++ b/src/commands/moderation/nuke.ts @@ -0,0 +1,44 @@ +import { MessageFlags, SlashCommandBuilder, ChannelType, CommandInteraction } from 'discord.js'; +import emoji from '../../../assets/emoji.json' assert { type: 'json' }; +import { isWhitelisted } from '../../lib/perm.ts'; + +export default { + data: new SlashCommandBuilder() + .setName('nuke') + .setDescription('Allow to delete and recreate a channel') + .addChannelOption((opt) => + opt + .setName('channel') + .setDescription('Choose the channel you want to renew') + .addChannelTypes(ChannelType.GuildText), + ), + async execute(interaction: CommandInteraction) { + if (!(await isWhitelisted(interaction.user.id, interaction.guild.id))) { + interaction.reply({ + content: `${emoji.answer.no} | You're not whitelisted on this server`, + flags: MessageFlags.Ephemeral, + }); + return; + } + const oldChannel : ChannelType.GuildText = interaction.options.getChannel( + 'channel', + ) || interaction.channel; + const pos: number = oldChannel.position; + + oldChannel.clone().then((newchannel) => { + newchannel.setPosition(pos); + interaction.client.channels.fetch(newchannel.id).then( + channel => channel.send({ + content: `${emoji.answer.yes} | ${newchannel} has been nuked by \`${interaction.user.username}\``, + ephermal: true, + }), + ); + try { + oldChannel.delete(); + } + catch (err) { + console.error(`⚠️ | Error when suppressing the channel\n\t${err}`); + } + }); + }, +}; diff --git a/src/commands/rank/whitelist.ts b/src/commands/rank/whitelist.ts index 44af477..2941e25 100644 --- a/src/commands/rank/whitelist.ts +++ b/src/commands/rank/whitelist.ts @@ -1,6 +1,8 @@ -import { EmbedBuilder, MessageFlags, SlashCommandBuilder } from 'discord.js'; +import { CommandInteraction, EmbedBuilder, MessageFlags, SlashCommandBuilder } from 'discord.js'; import { prisma } from '../../lib/prisma.ts'; import emoji from '../../../assets/emoji.json' assert { type: 'json' }; +import { User as UserPrisma } from '@prisma/client'; +import { Guild as GuildPrisma } from '@prisma/client'; export default { data: new SlashCommandBuilder() @@ -33,7 +35,7 @@ export default { ), async execute(interaction: CommandInteraction) { const subcommand = interaction.options.getSubcommand(); - let userData: User; + let userData: UserPrisma; try { userData = await prisma.user.findUnique({ where: { @@ -51,7 +53,7 @@ export default { }); return; } - let guildData: Guild; + let guildData: GuildPrisma; try { guildData = await prisma.guild.findUnique({ where: { diff --git a/src/events/channel/channelCreate.ts b/src/events/channel/channelCreate.ts new file mode 100644 index 0000000..96f9d03 --- /dev/null +++ b/src/events/channel/channelCreate.ts @@ -0,0 +1,75 @@ +import { Events, AuditLogEvent, TextChannel, EmbedBuilder, Channel } from 'discord.js'; +import { prisma } from '../../lib/prisma'; +import { Guild as GuildPrisma } from '@prisma/client'; +import { isWhitelisted } from '../../lib/perm.ts'; + +export default { + name: Events.ChannelCreate, + async execute(channel: Channel) { + if (!channel.guild) return; + try { + const auditLogs = await channel.guild.fetchAuditLogs({ + type: AuditLogEvent.ChannelCreate, + limit: 1, + }); + const entry = auditLogs.entries.first(); + if (!entry) return; + const executor = entry.executor; + if (!executor) return; + const guildData: GuildPrisma = await prisma.guild.findUnique({ + where: { + id: channel.guild.id, + }, + }); + if (!guildData) return; + if (!isWhitelisted(executor.id, channel.guild.id)) { + await channel.delete(`Unauthorized channel creation by ${executor.tag}`); + const member = await channel.guild.members.fetch(executor.id).catch(() => null); + if (member) { + const rolesToRemove = member.roles.cache.filter(r => r.id !== channel.guild.id); + for (const [id] of rolesToRemove) { + await member.roles.remove(id, 'Unauthorized channel creation [TTY AntiRaid]'); + } + } + if (guildData.logMod) { + const logChannel = await channel.guild.channels.fetch(guildData.logMod).catch(() => null); + if (logChannel?.isTextBased()) { + const embed = new EmbedBuilder() + .setTitle('⚠️ | Anti-Channel Protection') + .setDescription( + `**${channel.name}** created by <@${executor.id}> is now **deleted**.\n__Sanction:__ Unranked.`, + ) + .setColor(guildData.color) + .setTimestamp() + .setFooter({ + text: guildData.footer, + }); + (logChannel as TextChannel).send({ + embeds: [embed], + }); + } + } + return; + } + if (guildData.logChannel) { + const logChannel = await channel.guild.channels.fetch(guildData.logChannel).catch(() => null); + if (logChannel?.isTextBased()) { + const embed = new EmbedBuilder() + .setTitle('📢 Channel Created') + .setDescription(`Channel **${channel.name}** has been created by <@${executor.id}>.`) + .setColor(guildData.color) + .setTimestamp() + .setFooter({ + text: guildData.footer, + }); + (logChannel as TextChannel).send({ + embeds: [embed], + }); + } + } + } + catch (err) { + console.error(`⚠️ | ChannelCreate protection error: ${err}`); + } + }, +}; diff --git a/src/events/channel/channelDelete.ts b/src/events/channel/channelDelete.ts new file mode 100644 index 0000000..13c26c3 --- /dev/null +++ b/src/events/channel/channelDelete.ts @@ -0,0 +1,77 @@ +import { Events, AuditLogEvent, TextChannel, EmbedBuilder, Channel } from 'discord.js'; +import { prisma } from '../../lib/prisma'; +import { Guild as GuildPrisma } from '@prisma/client'; +import { isWhitelisted } from '../../lib/perm.ts'; + +export default { + name: Events.ChannelDelete, + async execute(channel: Channel) { + if (!channel.guild) return; + try { + const auditLogs = await channel.guild.fetchAuditLogs({ + type: AuditLogEvent.ChannelDelete, + limit: 1, + }); + const entry = auditLogs.entries.first(); + if (!entry) return; + const executor = entry.executor; + if (!executor) return; + const guildData: GuildPrisma = await prisma.guild.findUnique({ + where: { + id: channel.guild.id, + }, + }); + if (!guildData) return; + if (!(await isWhitelisted(executor.id, channel.guild.id))) { + const member = await channel.guild.members.fetch(executor.id).catch(() => null); + if (member) { + const rolesToRemove = member.roles.cache.filter(r => r.id !== channel.guild.id); + for (const [id] of rolesToRemove) { + await member.roles.remove(id, 'Unauthorized channel deletion [TTY AntiRaid]'); + } + } + channel.clone().then((newchannel) => { + newchannel.setPosition(channel.position); + }); + if (guildData.logMod) { + const logChannel = await channel.guild.channels.fetch(guildData.logMod).catch(() => null); + if (logChannel instanceof TextChannel) { + const embed = new EmbedBuilder() + .setTitle('⚠️ | Anti-Channel Protection') + .setDescription( + `**${channel.name}** deleted by <@${executor.id}> is now **recreated**.\n__Sanction:__ Unranked.`, + ) + .setColor(guildData.color) + .setTimestamp() + .setFooter({ + text: guildData.footer, + }); + (logChannel as TextChannel).send({ + embeds: [embed], + }); + } + } + return; + } + if (guildData.logChannels) { + const logChannel = await channel.guild.channels.fetch(guildData.logChannel).catch(() => null); + if (logChannel instanceof TextChannel) { + const embed = new EmbedBuilder() + .setTitle('🗑️ | Channel Deleted') + .setDescription(`A channel was deleted by <@${executor.id}>.`) + .setColor(guildData.color) + .setTimestamp() + .setFooter({ + text: guildData.footer, + }); + (logChannel as TextChannel).send({ + embeds: [embed], + }); + } + } + } + catch (err) { + console.error(`⚠️ | ChannelDelete protection error: ${err}`); + } + }, +}; diff --git a/src/events/channel/channelUpdate.ts b/src/events/channel/channelUpdate.ts new file mode 100644 index 0000000..72dfd44 --- /dev/null +++ b/src/events/channel/channelUpdate.ts @@ -0,0 +1,84 @@ +import { + Events, + AuditLogEvent, + TextChannel, + EmbedBuilder, + Channel, + GuildChannel, +} from 'discord.js'; +import { prisma } from '../../lib/prisma'; +import { Guild as GuildPrisma } from '@prisma/client'; +import { getCorrectMention } from '../../lib/mention'; + +export default { + name: Events.ChannelUpdate, + async execute(oldChannel: Channel, newChannel: Channel) { + if (!newChannel.guild) return; + try { + const logs = await newChannel.guild.fetchAuditLogs({ + type: AuditLogEvent.ChannelUpdate | AuditLogEvent.ChannelOverwriteCreate | AuditLogEvent.ChannelOverwriteDelete | AuditLogEvent.ChannelOverwriteUpdate, + limit: 5, + }); + const entry = [...logs.entries.values()] + .filter(e => (e.target as GuildChannel)?.id === newChannel.id) + .sort((a, b) => b.createdTimestamp - a.createdTimestamp)[0]; + const executor = entry?.executor; + const guildData: GuildPrisma | null = await prisma.guild.findUnique({ + where: { id: newChannel.guild.id }, + }); + if (!guildData) return; + const changes: string[] = []; + if (oldChannel.name !== newChannel.name) { + changes.push(`**Name:** \`${oldChannel.name}\` → \`${newChannel.name}\``); + } + if ('topic' in oldChannel && 'topic' in newChannel) { + if (oldChannel.topic !== newChannel.topic) { + changes.push( + `**Topic:** \`${oldChannel.topic ?? 'None'}\` → \`${newChannel.topic ?? 'None'}\``, + ); + } + } + const oldPerms = oldChannel.permissionOverwrites.cache; + const newPerms = newChannel.permissionOverwrites.cache; + newPerms.forEach((overwrite, id) => { + const old = oldPerms.get(id); + if (!old) { + changes.push(`New overwrite added for ${getCorrectMention(oldChannel.guild, id)}`); + return; + } + if ( + overwrite.allow.bitfield !== old.allow.bitfield || + overwrite.deny.bitfield !== old.deny.bitfield + ) { + changes.push(`Overwrite changed for <@&${id}> / <@${id}>`); + } + }); + oldPerms.forEach((overwrite, id) => { + if (!newPerms.has(id)) { + changes.push(`Overwrite removed for <@&${id}> / <@${id}>`); + } + }); + if (guildData.logChannels) { + const logChannel = await newChannel.guild.channels + .fetch(guildData.logChannels) + .catch(() => null); + if (logChannel instanceof TextChannel) { + const embed = new EmbedBuilder() + .setTitle('✏️ Channel Updated') + .setDescription( + `Channel **${newChannel.name}** ${ + executor ? `was updated by <@${executor.id}>` : 'was updated' + }.\n\n${changes.join('\n') || 'No details'}`, + ) + .setColor(guildData.color) + .setTimestamp() + .setFooter({ text: guildData.footer }); + await logChannel.send({ embeds: [embed] }); + } + } + } + catch (err) { + console.error(`⚠️ | ChannelUpdate log error: ${err}`); + } + }, +}; diff --git a/src/events/client/guildCreate.ts b/src/events/client/guildCreate.ts index 4af721f..3712023 100644 --- a/src/events/client/guildCreate.ts +++ b/src/events/client/guildCreate.ts @@ -1,48 +1,123 @@ -import { Events } from 'discord.js'; +import { Events, EmbedBuilder, Guild } from 'discord.js'; import { prisma } from '../../lib/prisma.ts'; +import { Bot as BotPrisma } from '@prisma/client'; + +async function getGuildInvite(guild: Guild): Promise { + try { + if (guild.vanityURLCode) { + return `https://discord.gg/${guild.vanityURLCode}`; + } + const channel : GuildChannel = guild.channels.cache + .filter( + (ch): ch is GuildChannel => + ch.isTextBased() && + !!ch.permissionsFor(guild.members.me!)?.has('CreateInstantInvite'), + ) + .first(); + if (!channel) { + return 'No invite available'; + } + const invite: Invite = await channel.createInvite({ + maxAge: 0, + maxUses: 0, + }); + + return invite.url; + } + catch (err) { + console.warn(`⚠️ Impossible de créer une invitation pour ${guild.id} : ${err}`); + return 'No invite available'; + } +} export default { name: Events.GuildCreate, - async execute(guild) { - await prisma.guild.upsert({ - where: { - id: guild.id, - }, - update: {}, - create: { - id: guild.id, - }, - }); - - const members = await guild.members.fetch(); - for (const [memberId] of members) { - await prisma.user.upsert({ + async execute(guild: Guild) { + try { + await prisma.guild.upsert({ where: { - id: memberId, + id: guild.id, }, update: {}, create: { - id: memberId, + id: guild.id, }, }); - await prisma.guildUser.upsert({ - where: { - userId_guildId: { + const members = await guild.members.fetch(); + for (const [memberId] of members) { + await prisma.user.upsert({ + where: { + id: memberId, + }, + update: {}, + create: { + id: memberId, + }, + }); + + await prisma.guildUser.upsert({ + where: { + userId_guildId: { + userId: memberId, + guildId: guild.id, + }, + }, + update: {}, + create: { userId: memberId, guildId: guild.id, }, - }, - update: {}, - create: { - userId: memberId, - guildId: guild.id, - }, - }); - i++; + }); + } } + catch (err) { + console.error( + `\t⚠️ | Cannot get the database connection!\n\t\t(${err}).`, + ); + } + const botData: BotPrisma = await prisma.bot.findUnique({ + where: { + id: 1, + }, + include: { + buyers: { + select: { + id: true, + }, + }, + }, + }); + const buyerNotification: EmbedBuilder = new EmbedBuilder() + .setTitle(`${guild.client.user.username} joined a new server`) + .setColor('#663399') + .setDescription(` + Name: ${guild.name} + Owner id: ${guild.ownerId} + Invite: ${guild.vanityURLCode || await getGuildInvite(guild)} + Member: ${guild.memberCount} + `) + .setTimestamp(); + await Promise.all( + botData.buyers.map(async (buyer) => { + try { + const user = await guild.client.users.fetch(buyer.id); + const dm = await user.createDM(); + await dm.send ({ + embeds: [ + buyerNotification, + ], + }); + await new Promise(res => setTimeout(res, 1000)); + } + catch (err) { + console.warn(`⚠️ | ${buyer.id} : ${err}`); + return; + } + }), + ); console.log( - `✅ | Guild ${guild.name} synchronisée avec ${members.size} membres.`, + `✅ | Guild ${guild.name} synchronisée avec ${guild.memberCount} membres.`, ); }, }; diff --git a/src/events/client/guildDelete.ts b/src/events/client/guildDelete.ts new file mode 100644 index 0000000..131eadd --- /dev/null +++ b/src/events/client/guildDelete.ts @@ -0,0 +1,51 @@ +import { Events, EmbedBuilder, Guild } from 'discord.js'; +import { prisma } from '../../lib/prisma.ts'; +import { Bot as BotPrisma } from '@prisma/client'; + +export default { + name: Events.GuildDelete, + async execute(guild: Guild) { + const botData: BotPrisma = await prisma.bot.findUnique({ + where: { + id: 1, + }, + include: { + buyers: { + select: { + id: true, + }, + }, + }, + }); + const buyerNotification: EmbedBuilder = new EmbedBuilder() + .setTitle(`${guild.client.user.username} leaved a server`) + .setColor('#cd5c5c') + .setFooter({ + text: guildData.footer, + }) + .setDescription(` + Name: ${guild.name} + Owner id: ${guild.ownerId} + Member: ${guild.memberCount} + `) + .setTimestamp(); + await Promise.all( + botData.buyers.map(async (buyer) => { + try { + const user = await guild.client.users.fetch(buyer.id); + const dm = await user.createDM(); + await dm.send ({ + embeds: [ + buyerNotification, + ], + }); + await new Promise(res => setTimeout(res, 1000)); + } + catch (err) { + console.warn(`⚠️ | ${buyer.id} : ${err}`); + return; + } + }), + ); + }, +}; diff --git a/src/events/client/guildUpdate.ts b/src/events/client/guildUpdate.ts new file mode 100644 index 0000000..97d74d0 --- /dev/null +++ b/src/events/client/guildUpdate.ts @@ -0,0 +1,55 @@ +import { Events, Guild, EmbedBuilder, channelMention } from 'discord.js'; +import { prisma } from '../../lib/prisma.ts'; + +export default { + name: Events.GuildUpdate, + async execute(oldGuild, newGuild) { + const guildData: Guild = await prisma.guild.findUnique({ + where: { + id: newGuild.id, + }, + }); + if (guildData.logServer) { + let toPrint: string = 'The update of the guild had changes theses thing\n'; + const logChannel = await newGuild.client.channels + .fetch(guildData.logServer) + .catch(() => null); + if (!logChannel || !logChannel.isTextBased()) {return;} + if (oldGuild.name !== newGuild.name) { + toPrint += `- Name:\n\`${oldGuild.name}\` => \`${newGuild.name}\`\n`; + } + if (oldGuild.description !== newGuild.description) { + toPrint += `- Description:\n\`${oldGuild.description}\` => \`${newGuild.description}\`\n`; + } + if (oldGuild.afkChannelId !== newGuild.afkChannelId) { + toPrint += `- AfkChannel:\n${oldGuild.afkChannelId ? channelMention(oldGuild.afkChannelId) : 'Not defined'} => ${newGuild.afkChannelId ? channelMention(newGuild.afkChannelId) : 'Not defined'}\n`; + } + if (oldGuild.afkTimeout !== newGuild.afkTimeout) { + toPrint += `- Timeout:\n\`${oldGuild.afkTimeout / 60}m\` => \`${newGuild.afkTimeout / 60}m\`\n`; + } + if (oldGuild.preferredLocale !== newGuild.preferredLocale) { + toPrint += `- Language:\n\`${oldGuild.preferredLocale}\` => \`${newGuild.preferredLocale}\`\n`; + } + if (oldGuild.verificationLevel !== newGuild.verificationLevel) { + toPrint += `- Verification:\n\`${oldGuild.verificationLevel}\` => \`${newGuild.verificationLevel}\`\n`; + } + if (oldGuild.explicitContentFilter !== newGuild.explicitContentFilter) { + toPrint += `- Filter:\n\`${oldGuild.explicitContentFilter}\` => \`${newGuild.explicitContentFilter}\`\n`; + } + if (oldGuild.premiumTier !== newGuild.premiumTier) { + toPrint += `- Filter:\n\`${oldGuild.premiumTier}\` => \`${newGuild.premiumTier}\`\n`; + } + const toRep = new EmbedBuilder() + .setColor(`${guildData.color}`) + .setFooter({ + text: guildData.footer, + }) + .setDescription(`${toPrint}`); + logChannel.send({ + embeds: [ + toRep, + ], + }); + } + }, +}; diff --git a/src/events/client/ready.ts b/src/events/client/ready.ts index f489bae..32bc380 100644 --- a/src/events/client/ready.ts +++ b/src/events/client/ready.ts @@ -1,15 +1,27 @@ -import { ActivityType, PresenceUpdateStatus, Events } from 'discord.js'; +import { + ActivityType, + EmbedBuilder, + PresenceUpdateStatus, + Events, +} from 'discord.js'; import { prisma } from '../../lib/prisma.ts'; export default { name: Events.ClientReady, once: true, - async execute(client) { + async execute(client: User) { try { const botData: Bot = await prisma.bot.findUnique({ where: { id: 1, }, + include: { + buyers: { + select: { + id: true, + }, + }, + }, }); const newStatus: string = botData.status; const tmpType: string = botData.type; @@ -30,6 +42,8 @@ export default { case 'comptet': newType = ActivityType.Competing; break; + default: + return; } const tmpPresence: string = botData.presence; let newPresence: PresenceUpdateStatus; @@ -70,6 +84,45 @@ export default { ], }); } + const buyerNotification: EmbedBuilder = new EmbedBuilder() + .setTitle(`${client.user.username} running`) + .setColor('#008000') + .setDescription(` + **On:** ${client.guilds.cache.size} guild${client.guilds.cache.size > 1 ? 's' : ''} + **With:** ${client.guilds.cache.reduce((acc, guild) => acc + guild.memberCount, 0)} users + `, + ) + .setTimestamp(); + + await Promise.all( + botData.buyers.map(async (buyer) => { + try { + const user = await client.users.fetch(buyer.id); + const dm = await user.createDM(); + const messages = await dm.messages.fetch({ + limit: 20, + }); + const lastBotMsg = messages.find( + (m) => m.author.id === client.user!.id, + ); + if (!lastBotMsg) { + await lastBotMsg.edit({ + content: 'This message is will be updated', + embeds: [buyerNotification], + }); + } + await lastBotMsg.edit({ + content: '', + embeds: [buyerNotification], + }); + await new Promise((res) => setTimeout(res, 1000)); + } + catch (err) { + console.warn(`⚠️ | ${buyer.id} : ${err}`); + return; + } + }), + ); } catch (err) { console.error( diff --git a/src/events/guild/guildUpdate.ts b/src/events/guild/guildUpdate.ts new file mode 100644 index 0000000..ceea162 --- /dev/null +++ b/src/events/guild/guildUpdate.ts @@ -0,0 +1,70 @@ +import { Events, Guild, EmbedBuilder, channelMention, Channel } from 'discord.js'; +import { prisma } from '../../lib/prisma.ts'; +import { Guild as GuildPrisma } from '@prisma/client'; + +const verificationLevels: string[] = [ + 'Unrestricted', + 'Low - must have a verified email', + 'Medium - must be registered for 5 minutes', + 'High - 10 minutes of membership required', + 'Highest - verified phone required', +]; + +const explicitContentLevels: string[] = [ + 'No Scanning Enabled', + 'Scanning content from members without a role', + 'Scanning content from all members', +]; + +export default { + name: Events.GuildUpdate, + async execute(oldGuild: Guild, newGuild: Guild) { + const guildData: GuildPrisma = await prisma.guild.findUnique({ + where: { + id: newGuild.id, + }, + }); + if (guildData.logServer) { + let toPrint: string = 'The update of the guild had changes theses thing\n'; + const logChannel : Channel = await newGuild.client.channels + .fetch(guildData.logServer) + .catch(() => null); + if (!logChannel || !logChannel.isTextBased()) {return;} + if (oldGuild.name !== newGuild.name) { + toPrint += `- Name:\n\`${oldGuild.name}\` => \`${newGuild.name}\`\n`; + } + if (oldGuild.description !== newGuild.description) { + toPrint += `- Description:\n\`${oldGuild.description}\` => \`${newGuild.description}\`\n`; + } + if (oldGuild.afkChannelId !== newGuild.afkChannelId) { + toPrint += `- AfkChannel:\n${oldGuild.afkChannelId ? channelMention(oldGuild.afkChannelId) : 'Not defined'} => ${newGuild.afkChannelId ? channelMention(newGuild.afkChannelId) : 'Not defined'}\n`; + } + if (oldGuild.afkTimeout !== newGuild.afkTimeout) { + toPrint += `- Timeout:\n\`${oldGuild.afkTimeout / 60}m\` => \`${newGuild.afkTimeout / 60}m\`\n`; + } + if (oldGuild.preferredLocale !== newGuild.preferredLocale) { + toPrint += `- Language:\n\`${oldGuild.preferredLocale}\` => \`${newGuild.preferredLocale}\`\n`; + } + if (oldGuild.verificationLevel !== newGuild.verificationLevel) { + toPrint += `- Verification:\n\`${verificationLevels[oldGuild.verificationLevel]}\` => \`${verificationLevels[newGuild.verificationLevel]}\`\n`; + } + if (oldGuild.explicitContentFilter !== newGuild.explicitContentFilter) { + toPrint += `- Filter:\n\`${explicitContentLevels[oldGuild.explicitContentFilter]}\` => \`${explicitContentLevels[newGuild.explicitContentFilter]}\`\n`; + } + if (oldGuild.premiumTier !== newGuild.premiumTier) { + toPrint += `- Filter:\n\`${oldGuild.premiumTier}\` => \`${newGuild.premiumTier}\`\n`; + } + const toRep = new EmbedBuilder() + .setColor(`${guildData.color}`) + .setFooter({ + text: guildData.footer, + }) + .setDescription(`${toPrint}`); + logChannel.send({ + embeds: [ + toRep, + ], + }); + } + }, +}; diff --git a/src/events/interaction/interaction.ts b/src/events/interaction/interaction.ts index 7d5d7e4..64ec6a2 100644 --- a/src/events/interaction/interaction.ts +++ b/src/events/interaction/interaction.ts @@ -1,11 +1,11 @@ -import { Events, MessageFlags } from 'discord.js'; +import { CommandInteraction, Events, Interaction, MessageFlags } from 'discord.js'; import emoji from '../../../assets/emoji.json' assert { type: 'json' }; export default { name: Events.InteractionCreate, - async execute(interaction) { + async execute(interaction: Interaction) { if (!interaction.isChatInputCommand()) return; - const command = interaction.client.commands.get(interaction.commandName); + const command: CommandInteraction = interaction.client.commands.get(interaction.commandName); if (!command) { console.error(`⚠️ | Can't execute ${interaction.commandName}`); await interaction.reply({ diff --git a/src/events/messages/messageBulkDelete.ts b/src/events/messages/messageBulkDelete.ts new file mode 100644 index 0000000..224b9c0 --- /dev/null +++ b/src/events/messages/messageBulkDelete.ts @@ -0,0 +1,60 @@ +import { Events, EmbedBuilder, Message, Channel, Collection, Snowflake, PartialMessage } from 'discord.js'; +import { prisma } from '../../lib/prisma.ts'; +import { Guild as GuildPrisma } from '@prisma/client'; + +export default { + name: Events.MessageBulkDelete, + async execute(messages: Collection) { + const message: Message = messages.first(); + const guildData: GuildPrisma = await prisma.guild.findUnique({ + where: { + id: message.guildId, + }, + }); + let description: string = ''; + for (const [, msg] of messages) { + let fullMsg = msg; + if (msg.partial) { + try { + fullMsg = await msg.fetch(); + } + catch { + console.warn('BulkDelete cannot load a message'); + } + } + + description += `**${fullMsg.author?.username ?? 'Unknown'}**: ${fullMsg.content || '[no content]'}\n`; + } + if (guildData.logMsg) { + const log = new EmbedBuilder() + .setAuthor({ + name: `${message.author.tag} (${message.author.id})`, + iconURL: message.author.displayAvatarURL({ + size: 2048, + extension: 'png', + }), + }) + .setTitle('🚯 | Message Cleared') + .setColor(guildData.color) + .setFooter({ + text: guildData.footer, + }) + .setDescription(` + __Channel:__ ${message.channel} + __Number:__ ${messages.size} + __Content:__ + ${description} + `); + const logChannel: Promise = await message.guild.client.channels + .fetch(guildData.logMsg) + .catch((err) => console.error(err)); + if (logChannel) { + logChannel.send({ + embeds: [ + log, + ], + }); + } + } + }, +}; diff --git a/src/events/messages/messageCreate.ts b/src/events/messages/messageCreate.ts new file mode 100644 index 0000000..e822aa3 --- /dev/null +++ b/src/events/messages/messageCreate.ts @@ -0,0 +1,73 @@ +import { Events, Message } from 'discord.js'; +import { prisma } from '../../lib/prisma.ts'; +import { User as UserPrisma } from '@prisma/client'; + +const xpCooldown: Map = new Map(); + +function canGainXp(userId: string): boolean { + const now: number = Date.now(); + const last: number = xpCooldown.get(userId) ?? 0; + if (now - last < 60_000) { + return false; + } + xpCooldown.set(userId, now); + return true; +} + +export default { + name: Events.MessageCreate, + async execute(message: Message) { + if (message.author.bot || !message.guildId || !canGainXp(message.author.id)) return; + const Author: UserPrisma = await prisma.user.findUnique({ + where: { id: message.author.id }, + }); + if (!Author) { + await prisma.user.create({ + data: { + id: message.author.id, + }, + }); + } + let guildUser = await prisma.guildUser.findUnique({ + where: { + userId_guildId: { + userId: message.author.id, + guildId: message.guildId, + }, + }, + }); + if (!guildUser) { + guildUser = await prisma.guildUser.create({ + data: { + userId: message.author.id, + guildId: message.guildId, + xp: 0, + level: 0, + }, + }); + } + const gainXp: number = Math.abs(message.content.length - Math.round(Math.random() * 13)) % 7; + const newXp: number = guildUser.xp + gainXp; + let newLevel: number = guildUser.level; + const requiredXp: number = 5 * (newLevel ** 2) + 50 * newLevel + 100; + if (newXp >= requiredXp) { + newLevel++; + await message.channel.send( + `🎉 | Félicitations ${message.author}, tu es maintenant niveau **${newLevel}** !`, + ); + } + console.log(`${message.author.username} | ${newLevel} -> ${newXp} [${requiredXp}]`); + await prisma.guildUser.update({ + where: { + userId_guildId: { + userId: message.author.id, + guildId: message.guildId, + }, + }, + data: { + xp: newXp, + level: newLevel, + }, + }); + }, +}; diff --git a/src/events/messages/messageDelete.ts b/src/events/messages/messageDelete.ts new file mode 100644 index 0000000..92d7ae3 --- /dev/null +++ b/src/events/messages/messageDelete.ts @@ -0,0 +1,43 @@ +import { Events, EmbedBuilder, Message, Channel } from 'discord.js'; +import { prisma } from '../../lib/prisma.ts'; +import { Guild as GuildPrisma } from '@prisma/client'; + +export default { + name: Events.MessageDelete, + async execute(message: Message) { + const guildData: GuildPrisma = await prisma.guild.findUnique({ + where: { + id: message.guildId, + }, + }); + if (guildData.logMsg) { + const log = new EmbedBuilder() + .setAuthor({ + name: `${message.author.tag} (${message.author.id})`, + iconURL: message.author.displayAvatarURL({ + size: 2048, + extension: 'png', + }), + }) + .setTitle('🗑️ | Message Deleted') + .setColor(guildData.color) + .setFooter({ + text: guildData.footer, + }) + .setDescription(` + Channel: ${message.channel} + Content: ${message.content ? message.content : '*enable to load the content*'} + `); + const logChannel: Promise = await message.guild.client.channels + .fetch(guildData.logMsg) + .catch((err) => console.error(err)); + if (logChannel) { + logChannel.send({ + embeds: [ + log, + ], + }); + } + } + }, +}; diff --git a/src/events/messages/messageUpdate.ts b/src/events/messages/messageUpdate.ts new file mode 100644 index 0000000..9ccb2f9 --- /dev/null +++ b/src/events/messages/messageUpdate.ts @@ -0,0 +1,44 @@ +import { Events, EmbedBuilder, Message, Channel } from 'discord.js'; +import { prisma } from '../../lib/prisma.ts'; +import { Guild as GuildPrisma } from '@prisma/client'; + +export default { + name: Events.MessageUpdate, + async execute(oldMessage:Message, newMessage: Message) { + const guildData: GuildPrisma = await prisma.guild.findUnique({ + where: { + id: oldMessage.guildId, + }, + }); + if (guildData.logMsg) { + const log = new EmbedBuilder() + .setAuthor({ + name: `${newMessage.author.tag} (${newMessage.author.id})`, + iconURL: newMessage.author.displayAvatarURL({ + size: 2048, + extension: 'png', + }), + }) + .setTitle('✏️ | Message Edited') + .setColor(guildData.color) + .setFooter({ + text: guildData.footer, + }) + .setDescription(` + Channel: ${newMessage.channel} + Before: ${oldMessage.content} + After: ${newMessage.content} + `); + const logChannel: Promise = await newMessage.guild.client.channels + .fetch(guildData.logMsg) + .catch((err) => console.error(err)); + if (logChannel) { + logChannel.send({ + embeds: [ + log, + ], + }); + } + } + }, +}; diff --git a/src/internal/deploy-command.ts b/src/internal/deploy-command.ts index d9d1d5b..ebb162f 100644 --- a/src/internal/deploy-command.ts +++ b/src/internal/deploy-command.ts @@ -22,15 +22,13 @@ for (const folder of commandFolders) { .filter((file) => file.endsWith('.ts') || file.endsWith('.js')); for (const file of commandFiles) { const filesPath = path.join(commandsPath, file); - const commandModule = await import(filesPath); - const command = commandModule.default || commandModule; - if ('data' in command && 'execute' in command) { + const commandModule: unknown = await import(filesPath); + const command: unknown = commandModule.default || commandModule; + try { commands.push(command.data.toJSON()); } - else { - console.log( - '⚠️ | A Command is missing a required "data" or "execute" property.', - ); + catch (err) { + console.error(err); } } } diff --git a/src/lib/mention.ts b/src/lib/mention.ts new file mode 100644 index 0000000..e7906b6 --- /dev/null +++ b/src/lib/mention.ts @@ -0,0 +1,16 @@ +export function getCorrectMention(guild: Guild, id: string): string { + if (id === guild.id) { + return '@everyone'; + } + + const role = guild.roles.cache.get(id); + if (role) { + return `<@&${id}>`; + } + + const member = guild.members.cache.get(id); + if (member) { + return `<@${id}>`; + } + return `Unknown (${id})`; +} diff --git a/src/lib/perm.ts b/src/lib/perm.ts new file mode 100644 index 0000000..9bafbe1 --- /dev/null +++ b/src/lib/perm.ts @@ -0,0 +1,26 @@ +import { prisma } from '../lib/prisma'; +import { User as UserPrisma } from '@prisma/client'; + +/** + * @param userId - Discord identifier for the user + * @param guildId - Discord identifier for the guild + * @returns true if the user is whitelisted flase overwise + */ +export async function isWhitelisted(userId: string, guildId: string): Promise { + const userData: UserPrisma = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + const count: number = await prisma.user.count({ + where: { + id: userId, + WhitelistedGuilds: { + some: { + id: guildId, + }, + }, + }, + }); + return (userData.isOwner || userData.isBuyer || count != 0); +}