diff --git a/locales/en.json b/locales/en.json index 35cbc72..e2e01e4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -928,5 +928,57 @@ "nicknames": { "owner-cannot-be-renamed": "The owner of the server (%u) cannot be renamed.", "nickname-error": "An error occurred while trying to change the nickname of %u: %e" - } + }, + "ping-protection": { + "not-a-member": "Punishment failed: The pinger is not a member.", + "punish-role-error": "Punishment failed: I cannot punish %tag because their role is higher than or equal to my highest role.", + "log-mute-success": "Muted %tag for %dur minutes because they exceeded the ping limit.", + "log-kick-success": "Kicked %tag because they exceeded the ping limit.", + "log-mute-error": "Punishment failed: I cannot mute %tag: %e", + "log-kick-error": "Punishment failed: I cannot kick %tag: %e", + "log-manual-delete": "All data for <@%u> (%u) has been deleted successfully.", + "log-manual-delete-logs": "All data for user with ID %u has been deleted successfully.", + "reason-basic": "User reached %c pings in the last %w weeks.", + "reason-advanced": "User reached %c pings in the last %d days (Custom timeframe).", + "cmd-desc-module": "Ping protection related commands", + "cmd-desc-group-user": "Every command related to the users", + "cmd-desc-history": "View the ping history of a user", + "cmd-opt-user": "The user to check", + "cmd-desc-actions": "View the moderation action history of a user", + "cmd-desc-panel": "Admin: Open the user management panel", + "cmd-desc-group-list": "Lists protected or whitelisted entities", + "cmd-desc-list-protected": "List all protected users and roles", + "cmd-desc-list-wl": "List all whitelisted roles and channels", + "embed-history-title": "Ping history of %u", + "embed-leaver-warning": "This user left the server at %t. These logs will stay until automatic deletion.", + "no-data-found": "No logs found for this user.", + "embed-actions-title": "Moderation history of %u", + "label-reason": "Reason", + "actions-retention-note": "Note: Moderation actions are retained for 1 - 12 months based on the configuration.", + "no-permission": "You don't have sufficient permissions to use this command.", + "panel-title": "User Panel: %u", + "panel-description": "Manage and view data for %u (%i). View a quick recap of their ping and moderation history, or delete all data stored for this user (Risky).", + "btn-history": "Ping history", + "btn-actions": "Actions history", + "btn-delete": "Delete all data (Risky)", + "list-protected-title": "Protected Users and Roles", + "list-protected-desc": "View all protected users and roles here. When someone pings one of these protected user(s)/role(s), a warning will be sent. Exceptions are whitelisted roles, and when sent in a whitelisted channel.", + "field-prot-users": "Protected Users", + "field-prot-roles": "Protected Roles", + "list-whitelist-title": "Whitelisted Roles and Channels", + "list-whitelist-desc": "View all whitelisted roles and channels here. Whitelisted roles will not get a warning for pinging a protected entity, and pings will be ignored in whitelisted channels.", + "field-wl-roles": "Whitelisted Roles", + "field-wl-channels": "Whitelisted Channels", + "list-none": "None are configured.", + "modal-title": "Confirm data deletion for this user", + "modal-label": "Confirm data deletion by typing this phrase:", + "modal-phrase": "I understand that all data of this user will be deleted and that this action cannot be undone.", + "modal-failed": "The phrase you entered is incorrect. Data deletion cancelled.", + "field-quick-history": "Quick history view (Last %w weeks)", + "field-quick-desc": "Pings history amount: %p\nModeration actions amount: %m", + "history-disabled": "History logging has been disabled by a bot-configurator.\nAre you (one of) the bot-configurators? You can enable history logging in the \"storage\" tab in the 'ping-protection' module ^^", + "leaver-warning-long": "User left at %d. These logs will stay until automatic deletion.", + "leaver-warning-short": "User left at %d.", + "warning-mod-disabled": "⚠️ **Moderation Actions are disabled!**\nYou can enable them in the dashboard under 'Moderation' to start logging punishments automatically." + } } \ No newline at end of file diff --git a/modules/ping-protection/commands/ping-protection.js b/modules/ping-protection/commands/ping-protection.js new file mode 100644 index 0000000..a2f68c8 --- /dev/null +++ b/modules/ping-protection/commands/ping-protection.js @@ -0,0 +1,206 @@ +const { + fetchModHistory, + getPingCountInWindow, + generateHistoryResponse, + generateActionsResponse +} = require('../ping-protection'); +const { localize } = require('../../../src/functions/localize'); +const { ActionRowBuilder, ButtonBuilder, EmbedBuilder, ButtonStyle, MessageFlags } = require('discord.js'); + +module.exports.run = async function (interaction) { + const group = interaction.options.getSubcommandGroup(false); + const sub = interaction.options.getSubcommand(false); + + if (group) { + return module.exports.subcommands[group][sub](interaction); + } + return module.exports.subcommands[sub](interaction); +}; + +// Handles subcommands +module.exports.subcommands = { + 'user': { + 'history': async function (interaction) { + const user = interaction.options.getUser('user'); + const payload = await generateHistoryResponse(interaction.client, user.id, 1); + await interaction.reply(payload); + }, + 'actions-history': async function (interaction) { + const user = interaction.options.getUser('user'); + const payload = await generateActionsResponse(interaction.client, user.id, 1); + await interaction.reply(payload); + }, + 'panel': async function (interaction) { + const isAdmin = interaction.member.permissions.has('Administrator') || + (interaction.client.config.admins || []).includes(interaction.user.id); + + if (!isAdmin) return interaction.reply({ + content: localize('ping-protection', 'no-permission'), + flags: MessageFlags.Ephemeral + }); + + const user = interaction.options.getUser('user'); + const pingerId = user.id; + const storageConfig = interaction.client.configurations['ping-protection']['storage']; + const retentionWeeks = (storageConfig && storageConfig.pingHistoryRetention) + ? storageConfig.pingHistoryRetention + : 12; + const timeframeDays = retentionWeeks * 7; + + const pingCount = await getPingCountInWindow(interaction.client, pingerId, timeframeDays); + const modData = await fetchModHistory(interaction.client, pingerId, 1, 1000); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`ping-protection_history_${user.id}`) + .setLabel(localize('ping-protection', 'btn-history')) + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId(`ping-protection_actions_${user.id}`) + .setLabel(localize('ping-protection', 'btn-actions')) + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId(`ping-protection_delete_${user.id}`) + .setLabel(localize('ping-protection', 'btn-delete')) + .setStyle(ButtonStyle.Danger) + ); + + const embed = new EmbedBuilder() + .setTitle(localize('ping-protection', 'panel-title', { u: user.tag })) + .setDescription(localize('ping-protection', 'panel-description', { u: user.toString(), i: user.id })) + .setColor('Blue') + .setThumbnail(user.displayAvatarURL({ dynamic: true })) + .addFields([{ + name: localize('ping-protection', 'field-quick-history', { w: retentionWeeks }), + value: localize('ping-protection', 'field-quick-desc', { p: pingCount, m: modData.total }), + inline: false + }]); + + await interaction.reply({ embeds: [embed.toJSON()], components: [row.toJSON()] }); + } + }, + 'list': { + 'protected': async function (interaction) { + await listHandler(interaction, 'protected'); + }, + 'whitelisted': async function (interaction) { + await listHandler(interaction, 'whitelisted'); + } + } +}; + +// Handles list subcommands +async function listHandler(interaction, type) { + const config = interaction.client.configurations['ping-protection']['configuration']; + const embed = new EmbedBuilder() + .setColor('Green') + .setFooter({ + text: interaction.client.strings.footer, + iconURL: interaction.client.strings.footerImgUrl + }); + + if (!interaction.client.strings.disableFooterTimestamp) embed.setTimestamp(); + + if (type === 'protected') { + embed.setTitle(localize('ping-protection', 'list-protected-title')); + embed.setDescription(localize('ping-protection', 'list-protected-desc')); + + const usersList = config.protectedUsers.length > 0 + ? config.protectedUsers.map(id => `<@${id}>`).join('\n') + : localize('ping-protection', 'list-none'); + + const rolesList = config.protectedRoles.length > 0 + ? config.protectedRoles.map(id => `<@&${id}>`).join('\n') + : localize('ping-protection', 'list-none'); + + embed.addFields([ + { name: localize('ping-protection', 'field-prot-users'), value: usersList, inline: true }, + { name: localize('ping-protection', 'field-prot-roles'), value: rolesList, inline: true } + ]); + + } else if (type === 'whitelisted') { + embed.setTitle(localize('ping-protection', 'list-whitelist-title')); + embed.setDescription(localize('ping-protection', 'list-whitelist-desc')); + + const rolesList = config.ignoredRoles.length > 0 + ? config.ignoredRoles.map(id => `<@&${id}>`).join('\n') + : localize('ping-protection', 'list-none'); + + const channelsList = config.ignoredChannels.length > 0 + ? config.ignoredChannels.map(id => `<#${id}>`).join('\n') + : localize('ping-protection', 'list-none'); + + embed.addFields([ + { name: localize('ping-protection', 'field-wl-roles'), value: rolesList, inline: true }, + { name: localize('ping-protection', 'field-wl-channels'), value: channelsList, inline: true } + ]); + } + + await interaction.reply({ embeds: [embed.toJSON()] }); +} + +module.exports.config = { + name: 'ping-protection', + description: localize('ping-protection', 'cmd-desc-module'), + usage: '/ping-protection', + type: 'slash', + options: [ + { + type: 'SUB_COMMAND_GROUP', + name: 'user', + description: localize('ping-protection', 'cmd-desc-group-user'), + options: [ + { + type: 'SUB_COMMAND', + name: 'history', + description: localize('ping-protection', 'cmd-desc-history'), + options: [{ + type: 'USER', + name: 'user', + description: localize('ping-protection', 'cmd-opt-user'), + required: true + }] + }, + { + type: 'SUB_COMMAND', + name: 'actions-history', + description: localize('ping-protection', 'cmd-desc-actions'), + options: [{ + type: 'USER', + name: 'user', + description: localize('ping-protection', 'cmd-opt-user'), + required: true + }] + }, + { + type: 'SUB_COMMAND', + name: 'panel', + description: localize('ping-protection', 'cmd-desc-panel'), + options: [{ + type: 'USER', + name: 'user', + description: localize('ping-protection', 'cmd-opt-user'), + required: true + }] + } + ] + }, + { + type: 'SUB_COMMAND_GROUP', + name: 'list', + description: localize('ping-protection', 'cmd-desc-group-list'), + options: [ + { + type: 'SUB_COMMAND', + name: 'protected', + description: localize('ping-protection', 'cmd-desc-list-protected') + }, + { + type: 'SUB_COMMAND', + name: 'whitelisted', + description: localize('ping-protection', 'cmd-desc-list-wl') + } + ] + } + ] +}; \ No newline at end of file diff --git a/modules/ping-protection/configs/configuration.json b/modules/ping-protection/configs/configuration.json new file mode 100644 index 0000000..871f695 --- /dev/null +++ b/modules/ping-protection/configs/configuration.json @@ -0,0 +1,197 @@ +{ + "filename": "configuration.json", + "humanName": { + "en": "General Configuration" + }, + "commandsWarnings": { + "normal": [ + "/ping-protection user history", + "/ping-protection user actions-history", + "/ping-protection list roles", + "/ping-protection list users", + "/ping-protection list whitelisted" + ] + }, + "description": { + "en": "Configure protected users/roles, whitelisted roles/members, ignored channels and the notification message." + }, + "content": [ + { + "name": "protectedRoles", + "humanName": { + "en": "Protected Roles" + }, + "description": { + "en": "Members with these roles will trigger protection when pinged." + }, + "type": "array", + "content": "roleID", + "default": { + "en": [] + } + }, + { + "name": "protectedUsers", + "humanName": { + "en": "Protected Users" + }, + "description": { + "en": "Specific users who are protected from pings." + }, + "type": "array", + "content": "userID", + "default": { + "en": [] + } + }, + { + "name": "ignoredRoles", + "humanName": { + "en": "Whitelisted Roles" + }, + "description": { + "en": "Roles allowed to ping protected members or roles." + }, + "type": "array", + "content": "roleID", + "default": { + "en": [] + } + }, + { + "name": "ignoredChannels", + "humanName": { + "en": "Whitelisted Channels" + }, + "description": { + "en": "Pings in these channels are ignored." + }, + "type": "array", + "content": "channelID", + "default": { + "en": [] + } + }, + { + "name": "ignoredUsers", + "humanName": { + "en": "Whitelisted Users" + }, + "description": { + "en": "Pings from these users are ignored." + }, + "type": "array", + "content": "userID", + "default": { + "en": [] + } + }, + { + "name": "allowReplyPings", + "humanName": { + "en": "Allow Reply Pings" + }, + "description": { + "en": "If enabled, replying to a protected user (with mention ON) is allowed." + }, + "type": "boolean", + "default": { + "en": false + } + }, + { + "name": "allowSelfPing", + "humanName": { + "en": "Allow protected users to ping themselves" + }, + "description": { + "en": "If enabled, a protected user is able to ping themselves without getting the chance to be embarrassed." + }, + "type": "boolean", + "default": { + "en": true + } + }, + { + "name": "enableAutomod", + "humanName": { + "en": "Enable automod" + }, + "description": { + "en": "If enabled, the bot will utilise Discord's native AutoMod to block the message with a ping of a protected user/role." + }, + "type": "boolean", + "default": { + "en": true + } + }, + { + "name": "autoModLogChannel", + "humanName": { + "en": "AutoMod Log Channel" + }, + "description": { + "en": "Channel where AutoMod alerts are sent." + }, + "type": "array", + "content": "channelID", + "default": { + "en": [] + }, + "dependsOn": "enableAutomod" + }, + { + "name": "autoModBlockMessage", + "humanName": { + "en": "AutoMod custom message for message block" }, + "description": { + "en": "Custom text shown to the user when blocked (Max 150 characters)." + }, + "type": "string", + "maxLength": 150, + "default": { + "en": "Protected User Ping: Your message was blocked but the content was sent to the log channel." + }, + "dependsOn": "enableAutomod" + }, + { + "name": "pingWarningMessage", + "humanName": { + "en": "Warning Message" + }, + "description": { + "en": "The message that gets sent to the user when they ping someone." + }, + "type": "string", + "allowEmbed": true, + "params": [ + { + "name": "target-name", + "description": { + "en": "Name of the pinged user/role" + } + }, + { + "name": "target-mention", + "description": { + "en": "Mention of the pinged user/role" + } + }, + { + "name": "target-id", + "description": { + "en": "ID of the pinged user/role" + } + } + ], + "default": { + "en": { + "title": "You are not allowed to ping %target-name%!", + "description": "<@%user-id%>, You are not allowed to ping %target-mention% due to your role. You can view which roles/members you are not allowed to ping by using the ping-protection list protected` command.\n\nIf you were replying, make sure to turn off the mention in the reply.", + "image": "https://c.tenor.com/zRR5cYKSIV0AAAAd/tenor.gif", + "color": "Red" + } + } + } + ] +} diff --git a/modules/ping-protection/configs/moderation.json b/modules/ping-protection/configs/moderation.json new file mode 100644 index 0000000..447cae1 --- /dev/null +++ b/modules/ping-protection/configs/moderation.json @@ -0,0 +1,110 @@ +{ + "filename": "moderation.json", + "humanName": { + "en": "Moderation Actions" + }, + "description": { + "en": "Define triggers for punishments." + }, + "configElements": true, + "content": [ + { + "name": "enableModeration", + "humanName": { + "en": "Enable Moderation Actions" + }, + "description": { + "en": "If enabled, members who ping protected users/roles repeatedly will be punished. NOTE: THIS WILL NOT WORK WHEN PINGS HISTORY LOGGING IS DISABLED!" + }, + "type": "boolean", + "default": { + "en": false + }, + "elementToggle": true + }, + { + "name": "useCustomTimeframe", + "humanName": { + "en": "Use a custom timeframe" + }, + "description": { + "en": "If enabled, you can choose your own custom timeframe and the basic configuration will be ignored." + }, + "type": "boolean", + "default": { + "en": false + } + }, + { + "name": "pingsCountBasic", + "humanName": { + "en": "Pings to trigger moderation" + }, + "description": { + "en": "The amount of pings required to trigger a moderation action (Uses 'Ping History Retention' timeframe)." + }, + "type": "integer", + "default": { + "en": 10 + } + }, + { + "name": "pingsCountAdvanced", + "humanName": { + "en": "Pings to trigger (Advanced)" + }, + "description": { + "en": "The amount of pings required in the custom timeframe below." + }, + "type": "integer", + "default": { + "en": 5 + }, + "dependsOn": "useCustomTimeframe" + }, + { + "name": "timeframeDays", + "humanName": { + "en": "Timeframe (Days)" + }, + "description": { + "en": "In how many days must these pings occur?" + }, + "type": "integer", + "default": { + "en": 7 + }, + "dependsOn": "useCustomTimeframe" + }, + { + "name": "actionType", + "humanName": { + "en": "Action" + }, + "description": { + "en": "What punishment should be applied?" + }, + "type": "select", + "content": [ + "MUTE", + "KICK" + ], + "default": { + "en": "MUTE" + } + }, + { + "name": "muteDuration", + "humanName": { + "en": "Mute Duration (only if action type is MUTE)" + }, + "description": { + "en": "How long to mute the user? (in minutes)" + }, + "type": "integer", + "default": { + "en": 60 + } + } + ] +} \ No newline at end of file diff --git a/modules/ping-protection/configs/storage.json b/modules/ping-protection/configs/storage.json new file mode 100644 index 0000000..976bc5a --- /dev/null +++ b/modules/ping-protection/configs/storage.json @@ -0,0 +1,97 @@ +{ + "filename": "storage.json", + "humanName": { + "en": "Data Storage" + }, + "description": { + "en": "Configure how long moderation logs and leaver data are kept." + }, + "content": [ + { + "name": "enablePingHistory", + "humanName": { + "en": "Enable Ping History" + }, + "description": { + "en": "If enabled, the bot will keep a history of pings to enforce moderation actions." + }, + "type": "boolean", + "default": { + "en": true + } + }, + { + "name": "pingHistoryRetention", + "humanName": { + "en": "Ping History Retention" + }, + "description": { + "en": "Decides on how long to keep ping logs. Minimum is 4 weeks (1 month) with a maximum of 24 weeks (6 months). This is the length factor of the 'Basic' punishment timeframe." + }, + "type": "integer", + "default": { + "en": 12 + }, + "minValue": "4", + "maxValue": "24", + "dependsOn": "enablePingHistory" + }, + { + "name": "DeleteAllPingHistoryAfterTimeframe", + "humanName": { + "en": "Delete all the pings in history after the timeframe?" + }, + "description": { + "en": "If enabled, the bot will delete ALL the pings history of an user after the timeframe instead of only the ping(s) exceeding the timeframe in the history." + }, + "type": "boolean", + "default": { + "en": false + } + }, + { + "name": "modLogRetention", + "humanName": { + "en": "Moderation Log Retention (Months)" + }, + "description": { + "en": "How long to keep records of punishments (1-12 Months). This is applied when moderation actions are enabled." + }, + "type": "integer", + "default": { + "en": 6 + }, + "minValue": "1", + "maxValue": "12" + }, + { + "name": "enableLeaverDataRetention", + "humanName": { + "en": "Keep user logs after they leave" + }, + "description": { + "en": "If enabled, the bot will keep a history of the user after they leave." + }, + "type": "boolean", + "default": { + "en": true + } + }, + { + "name": "leaverRetention", + "humanName": { + "en": "Leaver Data Retention (Days)" + }, + "description": { + "en": "How long to keep data after a user leaves (1-7 Days)." + }, + "type": "integer", + "default": { + "en": 1 + }, + "minValue": "1", + "maxValue": "7", + "dependsOn": "enableLeaverDataRetention" + } + ] +} \ No newline at end of file diff --git a/modules/ping-protection/events/autoModerationActionExecution.js b/modules/ping-protection/events/autoModerationActionExecution.js new file mode 100644 index 0000000..e7aabad --- /dev/null +++ b/modules/ping-protection/events/autoModerationActionExecution.js @@ -0,0 +1,60 @@ +const { + addPing, + getPingCountInWindow, + executeAction +} = require('../ping-protection'); +const { localize } = require('../../../src/functions/localize'); + +// Handles auto mod actions +module.exports.run = async function (client, execution) { + if (execution.ruleTriggerType !== 'KEYWORD') return; + + const config = client.configurations['ping-protection']['configuration']; + const storageConfig = client.configurations['ping-protection']['storage']; + const moderationRules = client.configurations['ping-protection']['moderation']; + + if (!config) return; + if (config.ignoredUsers && config.ignoredUsers.includes(execution.userId)) return; + + const matchedKeyword = execution.matchedKeyword || ""; + const rawId = matchedKeyword.replace(/\*/g, ''); + + const isProtected = config.protectedRoles.includes(rawId) || config.protectedUsers.includes(rawId); + if (!isProtected) return; + + let pingCount = 0; + let timeframeDays = 84; + let rule1 = (moderationRules && Array.isArray(moderationRules) && moderationRules.length > 0) ? moderationRules[0] : null; + + if (!!storageConfig && !!storageConfig.enablePingHistory) { + const mockAuthor = { id: execution.userId }; + const mockMessage = { author: mockAuthor, url: 'Blocked by AutoMod' }; + const mockTarget = { id: rawId }; + + try { + await addPing(client, mockMessage, mockTarget); + if (rule1 && !!rule1.useCustomTimeframe) { + timeframeDays = rule1.timeframeDays || 7; + } else { + const retentionWeeks = (storageConfig && storageConfig.pingHistoryRetention) ? storageConfig.pingHistoryRetention : 12; + timeframeDays = retentionWeeks * 7; + } + pingCount = await getPingCountInWindow(client, execution.userId, timeframeDays); + } catch (e) { + } + } + + if (!rule1 || !rule1.enableModeration) return; + + let requiredCount = (rule1.useCustomTimeframe) ? rule1.pingsCountAdvanced : rule1.pingsCountBasic; + let generatedReason = (rule1.useCustomTimeframe) + ? localize('ping-protection', 'reason-advanced', { c: pingCount, d: rule1.timeframeDays }) + : localize('ping-protection', 'reason-basic', { c: pingCount, w: (storageConfig.pingHistoryRetention || 12) }); + + if (pingCount >= requiredCount) { + const memberToPunish = await execution.guild.members.fetch(execution.userId).catch(() => null); + if (memberToPunish) { + await executeAction(client, memberToPunish, rule1, generatedReason, storageConfig); + } + } +}; \ No newline at end of file diff --git a/modules/ping-protection/events/botReady.js b/modules/ping-protection/events/botReady.js new file mode 100644 index 0000000..6e43412 --- /dev/null +++ b/modules/ping-protection/events/botReady.js @@ -0,0 +1,14 @@ +const { enforceRetention, syncNativeAutoMod } = require('../ping-protection'); +const schedule = require('node-schedule'); + +module.exports.run = async function (client) { + await enforceRetention(client); + await syncNativeAutoMod(client); + + // Daily job + const job = schedule.scheduleJob('0 3 * * *', async () => { + await enforceRetention(client); + await syncNativeAutoMod(client); + }); + client.jobs.push(job); +}; \ No newline at end of file diff --git a/modules/ping-protection/events/guildMemberAdd.js b/modules/ping-protection/events/guildMemberAdd.js new file mode 100644 index 0000000..8420f99 --- /dev/null +++ b/modules/ping-protection/events/guildMemberAdd.js @@ -0,0 +1,12 @@ +/** + * Checks when a member rejoins the server and updates their leaver status + */ + +const { markUserAsRejoined } = require('../ping-protection'); + +module.exports.run = async function (client, member) { + if (!client.botReadyAt) return; + if (member.guild.id !== client.guildID) return; + + await markUserAsRejoined(client, member.id); +}; \ No newline at end of file diff --git a/modules/ping-protection/events/guildMemberRemove.js b/modules/ping-protection/events/guildMemberRemove.js new file mode 100644 index 0000000..58fa770 --- /dev/null +++ b/modules/ping-protection/events/guildMemberRemove.js @@ -0,0 +1,18 @@ +/** + * Checks when a member leaves the server and handles data retention and/or deletion + */ + +const { markUserAsLeft, deleteAllUserData } = require('../ping-protection'); + +module.exports.run = async function (client, member) { + if (!client.botReadyAt) return; + if (member.guild.id !== client.guildID) return; + + const storageConfig = client.configurations['ping-protection']['storage']; + + if (storageConfig && storageConfig.enableLeaverDataRetention) { + await markUserAsLeft(client, member.id); + } else { + await deleteAllUserData(client, member.id); + } +}; \ No newline at end of file diff --git a/modules/ping-protection/events/interactionCreate.js b/modules/ping-protection/events/interactionCreate.js new file mode 100644 index 0000000..e1bd6ec --- /dev/null +++ b/modules/ping-protection/events/interactionCreate.js @@ -0,0 +1,88 @@ +const { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, MessageFlags } = require('discord.js'); +const { deleteAllUserData, generateHistoryResponse, generateActionsResponse } = require('../ping-protection'); +const { localize } = require('../../../src/functions/localize'); + +// Interaction handler +module.exports.run = async function (client, interaction) { + if (!client.botReadyAt) return; + + if (interaction.isButton() && interaction.customId.startsWith('ping-protection_')) { + + // Ping history pagination + if (interaction.customId.startsWith('ping-protection_hist-page_')) { + const parts = interaction.customId.split('_'); + const userId = parts[2]; + const targetPage = parseInt(parts[3]); + + const replyOptions = await generateHistoryResponse(client, userId, targetPage); + await interaction.update(replyOptions); + return; + } + + if (interaction.customId.startsWith('ping-protection_mod-page_')) { + const parts = interaction.customId.split('_'); + const userId = parts[2]; + const targetPage = parseInt(parts[3]); + + const replyOptions = await generateActionsResponse(client, userId, targetPage); + await interaction.update(replyOptions); + return; + } + + // Panel buttons + const [prefix, action, userId] = interaction.customId.split('_'); + + const isAdmin = interaction.member.permissions.has('Administrator') || + (client.config.admins || []).includes(interaction.user.id); + + if (['history', 'actions', 'delete'].includes(action)) { + if (!isAdmin) return interaction.reply({ + content: localize('ping-protection', 'no-permission'), + flags: MessageFlags.Ephemeral }); + } + + if (action === 'history') { + const replyOptions = await generateHistoryResponse(client, userId, 1); + await interaction.reply(replyOptions); + } + + else if (action === 'actions') { + const replyOptions = await generateActionsResponse(client, userId, 1); + await interaction.reply(replyOptions); + } + else if (action === 'delete') { + const modal = new ModalBuilder() + .setCustomId(`ping-protection_confirm-delete_${userId}`) + .setTitle(localize('ping-protection', 'modal-title')); + + const input = new TextInputBuilder() + .setCustomId('confirmation_text') + .setLabel(localize('ping-protection', 'modal-label')) + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder(localize('ping-protection', 'modal-phrase')) + .setRequired(true); + + const row = new ActionRowBuilder().addComponents(input); + modal.addComponents(row); + + await interaction.showModal(modal); + } + } + + if (interaction.isModalSubmit() && interaction.customId.startsWith('ping-protection_confirm-delete_')) { + const userId = interaction.customId.split('_')[2]; + const userInput = interaction.fields.getTextInputValue('confirmation_text'); + const requiredPhrase = localize('ping-protection', 'modal-phrase', { locale: interaction.locale }); + + if (userInput === requiredPhrase) { + await deleteAllUserData(client, userId); + await interaction.reply({ + content: `✅ ${localize('ping-protection', 'log-manual-delete', {u: userId})}`, + flags: MessageFlags.Ephemeral }); + } else { + await interaction.reply({ + content: `❌ ${localize('ping-protection', 'modal-failed')}`, + flags: MessageFlags.Ephemeral }); + } + } +}; \ No newline at end of file diff --git a/modules/ping-protection/events/messageCreate.js b/modules/ping-protection/events/messageCreate.js new file mode 100644 index 0000000..9c65193 --- /dev/null +++ b/modules/ping-protection/events/messageCreate.js @@ -0,0 +1,177 @@ +const { + addPing, + getPingCountInWindow, + executeAction, + sendPingWarning +} = require('../ping-protection'); +const { Op } = require('sequelize'); +const { localize } = require('../../../src/functions/localize'); + +// Tracks the last meme to prevent many duplicates +const lastMemeMap = new Map(); +// Tracks ping counts for the grind message +const selfPingCountMap = new Map(); + +// Handles messages +module.exports.run = async function (client, message) { + if (!client.botReadyAt) return; + if (!message.guild) return; + if (message.guild.id !== client.guildID) return; + + const config = client.configurations['ping-protection']['configuration']; + const storageConfig = client.configurations['ping-protection']['storage']; + const moderationRules = client.configurations['ping-protection']['moderation']; + + if (!config) return; + + if (message.author.bot) return; + + if (config.ignoredChannels.includes(message.channel.id)) return; + if (config.ignoredUsers.includes(message.author.id)) return; + if (message.member.roles.cache.some(role => config.ignoredRoles.includes(role.id))) return; + + // Check for protected pings + const pingedProtectedRole = message.mentions.roles.some(role => config.protectedRoles.includes(role.id)); + let protectedMentions = message.mentions.users.filter(user => config.protectedUsers.includes(user.id)); + + // Handles reply pings + if (config.allowReplyPings && message.mentions.repliedUser) { + const repliedId = message.mentions.repliedUser.id; + + if (protectedMentions.has(repliedId)) { + const manualMentionRegex = new RegExp(`<@!?${repliedId}>`); + const isManualPing = manualMentionRegex.test(message.content); + + if (!isManualPing) { + protectedMentions.delete(repliedId); + } + } + } + // Determines if any protected entities were pinged + const pingedProtectedUser = protectedMentions.size > 0; + + if (!pingedProtectedRole && !pingedProtectedUser) return; + + let target = null; + if (pingedProtectedUser) { + target = protectedMentions.first(); + } else if (pingedProtectedRole) { + target = message.mentions.roles.find(r => config.protectedRoles.includes(r.id)); + } + + if (!target) return; + + // Funny easter egg when they ping themselves + if (target.id === message.author.id && config.selfPingConfiguration === "Allowed, and ignored") return; + if (target.id === message.author.id && config.selfPingConfiguration === "Allowed, but with fun easter eggs") { + const secretChance = 0.01; // Secret for a reason.. (1% chance) + const standardMemes = [ + localize('ping-protection', 'meme-why'), + localize('ping-protection', 'meme-played'), + localize('ping-protection', 'meme-spider') + ]; + const secretMeme = localize('ping-protection', 'meme-rick'); + const currentCount = (selfPingCountMap.get(message.author.id) || 0) + 1; + selfPingCountMap.set(message.author.id, currentCount); + + setTimeout(() => { + selfPingCountMap.delete(message.author.id); + }, 300000); + + const roll = Math.random(); + let content = ''; + + if (roll < secretChance) { + content = secretMeme; + lastMemeMap.set(message.author.id, -1); + selfPingCountMap.delete(message.author.id); + } else if (currentCount === 5) { + content = localize('ping-protection', 'meme-grind'); + } else { + const lastIndex = lastMemeMap.get(message.author.id); + + let possibleMemes = standardMemes.map((_, index) => index); + if (lastIndex !== undefined && lastIndex !== -1 && standardMemes.length > 1) { + possibleMemes = possibleMemes.filter(i => i !== lastIndex); + } + + const randomIndex = possibleMemes[Math.floor(Math.random() * possibleMemes.length)]; + content = standardMemes[randomIndex]; + lastMemeMap.set(message.author.id, randomIndex); + } + await message.reply({ content: content }).catch(() => {}); + return; + } + + let pingCount = 0; + const pingerId = message.author.id; + let timeframeDays = 84; + let rule1 = null; + if (moderationRules && Array.isArray(moderationRules) && moderationRules.length > 0) { + rule1 = moderationRules[0]; + } + + if (!!storageConfig && !!storageConfig.enablePingHistory) { + try { + const isRole = !target.username; + await addPing(client, pingerId, message.url, target.id, isRole); + + if (rule1 && !!rule1.useCustomTimeframe) { + timeframeDays = rule1.timeframeDays; + } else { + const retentionWeeks = (storageConfig && storageConfig.pingHistoryRetention) + ? storageConfig.pingHistoryRetention + : 12; + timeframeDays = retentionWeeks * 7; + } + pingCount = await getPingCountInWindow(client, pingerId, timeframeDays); + } catch (e) {} + } + + // Send warning if enabled and moderation actions + await sendPingWarning(client, message, target, config); + + if (!rule1 || !rule1.enableModeration) return; + + let requiredCount = 0; + let generatedReason = ""; + + if (!!rule1.useCustomTimeframe) { + requiredCount = rule1.pingsCountAdvanced; + generatedReason = localize('ping-protection', 'reason-advanced', { + c: pingCount, + d: rule1.timeframeDays + }); + } else { + requiredCount = rule1.pingsCountBasic; + const retentionWeeks = (storageConfig && storageConfig.pingHistoryRetention) + ? storageConfig.pingHistoryRetention + : 12; + + generatedReason = localize('ping-protection', 'reason-basic', { + c: pingCount, + w: retentionWeeks + }); + } + + if (pingCount >= requiredCount) { + const oneMinuteAgo = new Date(new Date() - 60000); + try { + const recentLog = await client.models['ping-protection']['ModerationLog'].findOne({ + where: { + victimID: message.author.id, + createdAt: { [Op.gt]: oneMinuteAgo } + } + }); + if (recentLog) return; + } catch (e) {} + + let memberToPunish = message.member; + if (!memberToPunish) { + try { + memberToPunish = await message.guild.members.fetch(message.author.id); + } catch (e) { return; } + } + await executeAction(client, memberToPunish, rule1, generatedReason, storageConfig); + } +}; \ No newline at end of file diff --git a/modules/ping-protection/models/LeaverData.js b/modules/ping-protection/models/LeaverData.js new file mode 100644 index 0000000..1727dcf --- /dev/null +++ b/modules/ping-protection/models/LeaverData.js @@ -0,0 +1,25 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class PingProtectionLeaverData extends Model { + static init(sequelize) { + return super.init({ + userId: { + type: DataTypes.STRING, + primaryKey: true + }, + leftAt: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW + } + }, { + tableName: 'ping_protection_leaver_data', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'LeaverData', + module: 'ping-protection' +}; \ No newline at end of file diff --git a/modules/ping-protection/models/ModerationLog.js b/modules/ping-protection/models/ModerationLog.js new file mode 100644 index 0000000..c90099f --- /dev/null +++ b/modules/ping-protection/models/ModerationLog.js @@ -0,0 +1,39 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class PingProtectionModerationLog extends Model { + static init(sequelize) { + return super.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + victimID: { + type: DataTypes.STRING, + allowNull: false + }, + type: { + type: DataTypes.STRING, + allowNull: false + }, + reason: { + type: DataTypes.STRING, + allowNull: true + }, + actionDuration: { + type: DataTypes.INTEGER, + allowNull: true + }, + }, { + tableName: 'ping_protection_mod_log', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + 'name': 'ModerationLog', + 'module': 'ping-protection' +}; \ No newline at end of file diff --git a/modules/ping-protection/models/PingHistory.js b/modules/ping-protection/models/PingHistory.js new file mode 100644 index 0000000..4e1a2b0 --- /dev/null +++ b/modules/ping-protection/models/PingHistory.js @@ -0,0 +1,33 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class PingProtectionPingHistory extends Model { + static init(sequelize) { + return super.init({ + userId: { + type: DataTypes.STRING, + allowNull: false + }, + messageUrl: { + type: DataTypes.STRING, + allowNull: false + }, + targetId: { + type: DataTypes.STRING, + allowNull: true + }, + isRole: { + type: DataTypes.BOOLEAN, + defaultValue: false + } + }, { + tableName: 'ping_protection_history', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'PingHistory', + module: 'ping-protection' +}; \ No newline at end of file diff --git a/modules/ping-protection/module.json b/modules/ping-protection/module.json new file mode 100644 index 0000000..b945a1c --- /dev/null +++ b/modules/ping-protection/module.json @@ -0,0 +1,28 @@ +{ + "name": "ping-protection", + "author": { + "scnxOrgID": "148", + "name": "Kevin", + "link": "https://github.com/Kevinking500" + }, + "openSourceURL": "https://github.com/Kevinking500/CustomDCBot/tree/main/modules/ping-protection", + "commands-dir": "/commands", + "events-dir": "/events", + "models-dir": "/models", + "config-example-files": [ + "configs/configuration.json", + "configs/moderation.json", + "configs/storage.json" + ], + "tags": [ + "moderation" + ], + "humanReadableName": { + "en": "Ping-Protection", + "de": "Ping-Schutz" + }, + "description": { + "en": "Powerful and highly customizable ping-protection module to protect members/roles from unwanted mentions with moderation capabilities.", + "de": "Leistungsstarkes und hochgradig anpassbares Ping-Schutz-Modul zum Schutz von Mitgliedern/Rollen vor unerwünschten Erwähnungen mit Moderationsfunktionen." + } +} \ No newline at end of file diff --git a/modules/ping-protection/ping-protection.js b/modules/ping-protection/ping-protection.js new file mode 100644 index 0000000..3e92c31 --- /dev/null +++ b/modules/ping-protection/ping-protection.js @@ -0,0 +1,474 @@ +/** + * Logic for the Ping Protection module + * @module ping-protection + * @author itskevinnn + */ +const { Op } = require('sequelize'); +const { ActionRowBuilder, ButtonBuilder, EmbedBuilder, ButtonStyle, resolveColor } = require('discord.js'); +const { embedType, embedTypeV2, formatDate } = require('../../src/functions/helpers'); +const { localize } = require('../../src/functions/localize'); + +// Data handling +async function addPing(client, userId, messageUrl, targetId, isRole) { + await client.models['ping-protection']['PingHistory'].create({ + userId: userId, + messageUrl: messageUrl || 'Blocked by AutoMod', + targetId: targetId, + isRole: isRole + }); +} + +async function getPingCountInWindow(client, userId, days) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - days); + + return await client.models['ping-protection']['PingHistory'].count({ + where: { + userId: userId, + createdAt: { [Op.gt]: cutoffDate } + } + }); +} + +async function fetchPingHistory(client, userId, page = 1, limit = 8) { + const offset = (page - 1) * limit; + const { count, rows } = await client.models['ping-protection']['PingHistory'].findAndCountAll({ + where: { userId: userId }, + order: [['createdAt', 'DESC']], + limit: limit, + offset: offset + }); + return { total: count, history: rows }; +} + +async function fetchModHistory(client, userId, page = 1, limit = 8) { + if (!client.models['ping-protection'] || !client.models['ping-protection']['ModerationLog']) return { total: 0, history: [] }; + try { + const offset = (page - 1) * limit; + const { count, rows } = await client.models['ping-protection']['ModerationLog'].findAndCountAll({ + where: { victimID: userId }, + order: [['createdAt', 'DESC']], + limit: limit, + offset: offset + }); + return { total: count, history: rows }; + } catch (e) { + return { total: 0, history: [] }; + } +} + +async function getLeaverStatus(client, userId) { + return await client.models['ping-protection']['LeaverData'].findByPk(userId); +} + +// Makes sure the channel ID from config is valid for Discord +function getSafeChannelId(configValue) { + if (!configValue) return null; + let rawId = null; + if (Array.isArray(configValue) && configValue.length > 0) rawId = configValue[0]; + else if (typeof configValue === 'string') rawId = configValue; + + if (rawId && (typeof rawId === 'string' || typeof rawId === 'number')) { + const finalId = rawId.toString(); + if (finalId.length > 5) return finalId; + } + return null; +} + +async function sendPingWarning(client, message, target, moduleConfig) { + const warningMsg = moduleConfig.pingWarningMessage; + if (!warningMsg) return; + + let warnMsg = { ...warningMsg }; + if (warnMsg.color) { + try { + safeMsg.color = resolveColor(warnMsg.color); + } catch (err) { + delete warnMsg.color; + } + } + + const placeholders = { + '%target-name%': target.name || target.tag || target.username || 'Unknown', + '%target-mention%': target.toString(), + '%target-id%': target.id, + '%user-id%': message.author.id + }; + + try { + let messageOptions = await embedTypeV2(warnMsg, placeholders); + return message.reply(messageOptions).catch(async () => { + return message.channel.send(messageOptions).catch(() => {}); + }); + } catch (e) { + client.logger.warn(`[Ping Protection] ${error.message}`); + } +} + +// Syncs the native AutoMod rule based on configuration +async function syncNativeAutoMod(client) { + const config = client.configurations['ping-protection']['configuration']; + + try { + const guild = await client.guilds.fetch(client.guildID); + const rules = await guild.autoModerationRules.fetch(); + const existingRule = rules.find(r => r.name === 'SCNX Ping Protection'); + + // Logic to disable/delete the rule + if (!config || !config.enableAutomod) { + if (existingRule) { + await existingRule.delete().catch(() => {}); + } + return; + } + + const protectedIds = [...(config.protectedRoles || []), ...(config.protectedUsers || [])]; + + // Deletes the rule if there are no protected IDs + if (protectedIds.length === 0) { + if (existingRule) { + await existingRule.delete().catch(() => {}); + } + return; + } + + // AutoMod rule data + const actions = []; + const blockMetadata = {}; + if (config.autoModBlockMessage) { + blockMetadata.customMessage = config.autoModBlockMessage; + } + actions.push({ type: 1, metadata: blockMetadata }); + + const alertChannelId = getSafeChannelId(config.autoModLogChannel); + if (alertChannelId) { + actions.push({ + type: 2, + metadata: { channel: alertChannelId } + }); + } + + const ruleData = { + name: 'SCNX Ping Protection', + eventType: 1, + triggerType: 1, + triggerMetadata: { + keywordFilter: protectedIds.map(id => `*${id}*`) + }, + actions: actions, + enabled: true, + exemptRoles: config.ignoredRoles || [], + exemptChannels: config.ignoredChannels || [] + }; + + if (existingRule) { + await guild.autoModerationRules.edit(existingRule.id, ruleData); + } else { + await guild.autoModerationRules.create(ruleData); + } + } catch (e) { + client.logger.error(`[ping-protection] AutoMod Sync/Cleanup Failed: ${error.message}`); + } +} + +// Generates history response +async function generateHistoryResponse(client, userId, page = 1) { + const storageConfig = client.configurations['ping-protection']['storage']; + const limit = 8; + const isEnabled = !!storageConfig.enablePingHistory; + + let total = 0, history = [], totalPages = 1; + + if (isEnabled) { + const data = await fetchPingHistory(client, userId, page, limit); + total = data.total; + history = data.history; + totalPages = Math.ceil(total / limit) || 1; + } + + const user = await client.users.fetch(userId).catch(() => ({ + username: 'Unknown User', + displayAvatarURL: () => null + })); + + const leaverData = await getLeaverStatus(client, userId); + let description = ""; + + if (leaverData) { + const dateStr = formatDate(leaverData.leftAt); + const warningKey = history.length > 0 + ? 'leaver-warning-long' + : 'leaver-warning-short'; + description += `⚠️ ${localize('ping-protection', warningKey, { d: dateStr })}\n\n`; + } + + if (!isEnabled) { + description += localize('ping-protection', 'history-disabled'); + } else if (history.length === 0) { + description += localize('ping-protection', 'no-data-found'); + } else { + const lines = history.map((entry, index) => { + const timeString = formatDate(entry.createdAt); + + let targetString = "Detected"; + if (entry.targetId) { + targetString = entry.isRole ? `<@&${entry.targetId}>` : `<@${entry.targetId}>`; + } + return `${(page - 1) * limit + index + 1}. **Pinged ${targetString}** at ${timeString}\n[Jump to Message](${entry.messageUrl})`; + }); + description += lines.join('\n\n'); + } + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`ping-protection_hist-page_${userId}_${page - 1}`) + .setLabel('Back') + .setStyle(ButtonStyle.Primary) + .setDisabled(page <= 1), + new ButtonBuilder() + .setCustomId('ping_protection_page_count') + .setLabel(`${page}/${totalPages}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`ping-protection_hist-page_${userId}_${page + 1}`) + .setLabel('Next') + .setStyle(ButtonStyle.Primary) + .setDisabled(page >= totalPages || !isEnabled) + ); + + const embed = new EmbedBuilder() + .setTitle(localize('ping-protection', 'embed-history-title', { + u: user.username + })) + .setThumbnail(user.displayAvatarURL({ + dynamic: true + })) + .setDescription(description) + .setColor('Orange') + .setFooter({ + text: client.strings.footer, + iconURL: client.strings.footerImgUrl + }); + + if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +// Generates actions response +async function generateActionsResponse(client, userId, page = 1) { + const moderationConfig = client.configurations['ping-protection']['moderation']; + const limit = 8; + + const rule1 = (moderationConfig && Array.isArray(moderationConfig) && moderationConfig.length > 0) + ? moderationConfig[0] + : null; + + const isEnabled = rule1 + ? rule1.enableModeration + : false; + + let total = 0, history = [], totalPages = 1; + + const data = await fetchModHistory(client, userId, page, limit); + total = data.total; + history = data.history; + totalPages = Math.ceil(total / limit) || 1; + + const user = await client.users.fetch(userId).catch(() => ({ + username: 'Unknown User', + displayAvatarURL: () => null + })); + + let description = ""; + + if (!isEnabled) { + description += `${localize('ping-protection', 'warning-mod-disabled')}\n\n`; + } + + if (history.length === 0) { + description += localize('ping-protection', 'no-data-found'); + } else { + const lines = history.map((entry, index) => { + const duration = entry.actionDuration ? ` (${entry.actionDuration}m)` : ''; + const reasonText = entry.reason || localize('ping-protection', 'no-reason') || 'No reason'; + return `${(page - 1) * limit + index + 1}. **${entry.type}${duration}** - ${formatDate(entry.createdAt)}\n${localize('ping-protection', 'label-reason')}: ${reasonText}`; + }); + description += lines.join('\n\n') + `\n\n*${localize('ping-protection', 'actions-retention-note')}*`; + } + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`ping-protection_mod-page_${userId}_${page - 1}`) + .setLabel('Back') + .setStyle(ButtonStyle.Primary) + .setDisabled(page <= 1), + new ButtonBuilder() + .setCustomId('ping_protection_page_count') + .setLabel(`${page}/${totalPages}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`ping-protection_mod-page_${userId}_${page + 1}`) + .setLabel('Next') + .setStyle(ButtonStyle.Primary) + .setDisabled(page >= totalPages || (!isEnabled && history.length === 0)) + ); + + const embed = new EmbedBuilder() + .setTitle(localize('ping-protection', 'embed-actions-title', { u: user.username })) + .setThumbnail(user.displayAvatarURL({ dynamic: true })) + .setDescription(description) + .setColor(isEnabled + ? 'Red' + : 'Grey' + ) + .setFooter({ text: client.strings.footer, iconURL: client.strings.footerImgUrl }); + + if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +// Handles data deletion +async function deleteAllUserData(client, userId) { + await client.models['ping-protection']['PingHistory'].destroy({ + where: { userId: userId } + }); + await client.models['ping-protection']['ModerationLog'].destroy({ + where: { victimID: userId } + }); + await client.models['ping-protection']['LeaverData'].destroy({ + where: { userId: userId } + }); + client.logger.info('[ping-protection] ' + localize('ping-protection', 'log-manual-delete-logs', { + u: userId + })); +} +async function markUserAsLeft(client, userId) { + await client.models['ping-protection']['LeaverData'].upsert({ + userId: userId, + leftAt: new Date() + }); +} +async function markUserAsRejoined(client, userId) { + await client.models['ping-protection']['LeaverData'].destroy({ where: { userId: userId } }); +} +async function enforceRetention(client) { + const storageConfig = client.configurations['ping-protection']['storage']; + if (!storageConfig) return; + if (storageConfig.enablePingHistory) { + const historyCutoff = new Date(); + const retentionWeeks = storageConfig.pingHistoryRetention || 12; + historyCutoff.setDate(historyCutoff.getDate() - (retentionWeeks * 7)); + if (storageConfig.DeleteAllPingHistoryAfterTimeframe) { + const usersWithExpiredData = await client.models['ping-protection']['PingHistory'].findAll({ + where: { createdAt: { [Op.lt]: historyCutoff } }, + attributes: ['userId'], + group: ['userId'] + }); + + const userIdsToWipe = usersWithExpiredData.map(entry => entry.userId); + if (userIdsToWipe.length > 0) { + await client.models['ping-protection']['PingHistory'].destroy({ + where: { userId: userIdsToWipe } + }); + } + } + else { + await client.models['ping-protection']['PingHistory'].destroy({ + where: { createdAt: { [Op.lt]: historyCutoff } } + }); + } + } + if (storageConfig.modLogRetention) { + const modCutoff = new Date(); + modCutoff.setMonth(modCutoff.getMonth() - (storageConfig.modLogRetention || 6)); + await client.models['ping-protection']['ModerationLog'].destroy({ + where: { + createdAt: { [Op.lt]: modCutoff } + } + }); + } + if (storageConfig.enableLeaverDataRetention) { + const leaverCutoff = new Date(); + leaverCutoff.setDate(leaverCutoff.getDate() - (storageConfig.leaverRetention || 1)); + const leaversToDelete = await client.models['ping-protection']['LeaverData'].findAll({ + where: { + leftAt: { [Op.lt]: leaverCutoff } + } + }); + for (const leaver of leaversToDelete) { + await deleteAllUserData(client, leaver.userId); + await leaver.destroy(); + } + } +} + +async function executeAction(client, member, rule, reason, storageConfig) { + const actionType = rule.actionType; + if (!member) { + client.logger.debug('[Ping Protection] ' + localize('ping-protection', 'not-a-member')); + return false; + } + const botMember = await member.guild.members.fetch(client.user.id); + if (botMember.roles.highest.position <= member.roles.highest.position) { + client.logger.warn('[Ping Protection] ' + localize('ping-protection', 'punish-role-error', { + tag: member.user.tag + })); + return false; + } + const logDb = async (type, duration = null) => { + try { + await client.models['ping-protection']['ModerationLog'].create({ + victimID: member.id, type, actionDuration: duration, reason + }); + } catch (dbError) {} + }; + if (actionType === 'MUTE') { + const durationMs = rule.muteDuration * 60000; + await logDb('MUTE', rule.muteDuration); + try { + await member.timeout(durationMs, reason); + client.logger.info('[Ping Protection] ' + localize('ping-protection', 'log-mute-success', {tag: member.user.tag, dur: rule.muteDuration})); + return true; + } catch (error) { + client.logger.warn('[Ping Protection] ' + localize('ping-protection', 'log-mute-error', {tag: member.user.tag, e: error.message})); + return false; + } + } else if (actionType === 'KICK') { + await logDb('KICK'); + try { + await member.kick(reason); + client.logger.info('[Ping Protection] ' + localize('ping-protection', 'log-kick-success', {tag: member.user.tag})); + return true; + } catch (error) { + client.logger.warn('[Ping Protection] ' + localize('ping-protection', 'log-kick-error', {tag: member.user.tag, e: error.message})); + return false; + } + } + return false; +} + +module.exports = { + addPing, + getPingCountInWindow, + sendPingWarning, + syncNativeAutoMod, + fetchPingHistory, + fetchModHistory, + executeAction, + deleteAllUserData, + getLeaverStatus, + markUserAsLeft, + markUserAsRejoined, + enforceRetention, + generateHistoryResponse, + generateActionsResponse, + getSafeChannelId +}; \ No newline at end of file