diff --git a/locales/en.json b/locales/en.json index 77df8518..48a5db6c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -728,6 +728,7 @@ "log-punish-role-error": "[Ping Protection] Punishment failed: I cannot punish %tag because their role is higher than or equal to my highest role.", "log-mute-error": "[Ping Protection] Punishment failed: I cannot mute %tag: %e", "log-kick-error": "[Ping Protection] Punishment failed: I cannot kick %tag: %e", + "log-kick-dm-failed": "[Ping Protection] Failed to send kick DM to user %u: %e", "log-action-log-failed": "[Ping Protection] Punishment logging failed: %e", "log-data-deletion": "[Ping Protection] All data for the user with ID %u has been deleted successfully.", "log-automod-keyword-limit": "[Ping Protection] Automod keywords exceed 1000 characters limit. Keywords were truncated.", @@ -744,6 +745,7 @@ "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-toggle": "Temporarily toggle your ping protection on or off.", "cmd-desc-list-protected": "List of all the protected users and roles", "cmd-desc-list-wl": "List of all the whitelisted roles, channels and users", "embed-history-title": "Ping history of %u", @@ -764,13 +766,14 @@ "field-wl-channels": "Whitelisted Channels", "field-wl-users": "Whitelisted Users", "list-none": "None are configured.", - "modal-title": "Confirm data deletion for this user", - "fallback-modal-title": "Confirm data deletion", - "modal-label": "Confirm data deletion by typing this phrase:", - "fallback-modal-label": "Confirm by typing this phrase:", - "modal-phrase": "I understand that the data of this user will be deleted and that this action cannot be undone.", - "fallback-modal-phrase": "I confirm the data deletion of this user with risks.", - "modal-failed": "The phrase you entered is incorrect. Data deletion cancelled.", + "list-user-paused": "temporarily paused", + "del-modal-title": "Confirm data deletion", + "del-modal-label": "Confirm by typing this phrase:", + "del-conf-phrase": "I confirm the data deletion for this user.", + "modal-failed": "The phrase you entered is incorrect. Data deletion has been cancelled.", + "del-modal-timeframe-label": "Only delete data older than (Optional)", + "del-modal-timeframe-ph": "E.g. 7d to delete data older than 7 days. (Leave empty to delete all)", + "del-err-invalid-timeframe": "The timeframe you provided is invalid. Use a positive number of days (e.g. 7) or a format like 7d, 2w, or 24h.", "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 \"Data Storage\" tab in the 'ping-protection' module ^^", @@ -781,15 +784,20 @@ "meme-spider": "šŸ•·ļø [Is this you?]() - You just pinged yourself.", "meme-rick": "šŸŽµ [Never gonna give you up, never gonna let you down...]() You just Rick Rolled yourself. Also congrats you unlocked the secret easter egg that only has a 1% chance of appearing!!1!1!!", "meme-grind": "Why are you even pinging yourself 5 times in a row? Anyways continue some more to possibly get the secret meme\n-# (good luck grinding, only a 1% chance of getting it and during testing I had it once after 83 pings)", - "label-jump": "Jump to Message", + "label-jump": "Jump to message", + "ping-mention": "Mention", + "ping-reply": "Reply", + "ping-unknown": "Unknown", + "label-type": "Type of ping", "no-message-link": "This ping was blocked by AutoMod", - "list-entry-text": "%index. **Pinged %target** at %time\n%link", + "list-entry-text": "%index. **Pinged %target** at %time\n%type", "punish-log-docs-title": "Troubleshooting", "punish-log-docs-desc": "This issue is documented in the documentation - you can see how to fix this issue [in the documentation](https://docs.scnx.xyz/docs/custom-bot/modules/moderation/ping-protection/#troubleshooting). Please try the steps mentioned there before contacting support as it's very likely the steps mentioned will fix your issue ^^", "log-fetch-mod-history-failed": "[Ping Protection] Failed to fetch moderation history for user %u: %e", "log-warning-build-failed": "[Ping Protection] Failed to build the warning message: %e", "log-warning-reply-failed": "[Ping Protection] Failed to send the warning message as a reply: %e", "log-warning-send-failed": "[Ping Protection] Failed to send the fallback warning message in channel %c: %e", + "log-warning-delete-failed": "[Ping Protection] Failed to automatically delete the warning message: %e", "log-automod-channel-fetch-failed": "[Ping Protection] Failed to refresh the guild channel cache while syncing AutoMod: %e", "log-automod-rule-delete-failed": "[Ping Protection] Failed to delete the native AutoMod rule: %e", "log-automod-sync-failed": "[Ping Protection] AutoMod sync failed: %e", @@ -829,7 +837,16 @@ "view-moderation-history-description": "View this user's moderation history", "view-ping-history-description": "View this user's ping history", "log-automod-role-protection-skipped": "[Ping Protection] Skipped adding role-based protected users to the native AutoMod rule because the GuildMembers intent is not granted. Only explicitly configured protected users are included until GuildMembers is enabled.", - "no-reason": "No reason provided" + "no-reason": "No reason provided", + "toggle-disabled-by-admin": "Toggling your ping protection status is currently disabled by a bot administrator. Please contact them if you want to change your protection status.", + "toggle-err-not-protected": ":x: You are not a protected member, so you cannot toggle your protection status :/", + "toggle-err-failed": "An error occurred while updating your protection status. Please try again later or contact the bot's administrator.", + "toggle-succ-disabled": "Your ping protection is now paused until %time (24 hours). Members can mention you without triggering notices. You can reactivate it anytime using `/ping-protection toggle`.", + "toggle-succ-enabled": "Your ping protection has been reactivated. You are now protected from pings.", + "log-check-preference-failed": "[Ping Protection] Failed to find ping preference for user %u: %e", + "log-toggle-preference-failed": "[Ping Protection] Failed to toggle ping preference for user %u: %e", + "toggle-pref-failed": "An error occurred while toggling your ping protection preference. Please try again later or contact the bot's administrator.", + "toggle-not-protected": "You are not a protected member, so you cannot toggle your ping protection preference." }, "polls": { "what-have-i-votet": "What have I voted?", diff --git a/modules/ping-protection/commands/ping-protection.js b/modules/ping-protection/commands/ping-protection.js index 7d1539e1..36d06eef 100644 --- a/modules/ping-protection/commands/ping-protection.js +++ b/modules/ping-protection/commands/ping-protection.js @@ -2,24 +2,22 @@ const { generateHistoryResponse, generateActionsResponse, generateUserPanel -} = require('../ping-protection'); +} = require('../core/panels'); +const { + toggleUserProtection, + isProtectionToggledOff +} = require('../core/toggle'); const {localize} = require('../../../src/functions/localize'); -const {truncate, safeSetFooter} = require('../../../src/functions/helpers'); +const { + truncate, + safeSetFooter, + dateToDiscordTimestamp +} = require('../../../src/functions/helpers'); const { EmbedBuilder, 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': { @@ -40,6 +38,13 @@ module.exports.subcommands = { }); }, 'panel': async function (interaction) { + const isManager = interaction.member?.permissions.has('Administrator') || interaction.member?.permissions.has('ManageGuild'); + if (!isManager) { + return interaction.reply({ + content: localize('ping-protection', 'no-permission'), + flags: MessageFlags.Ephemeral + }) + } const user = interaction.options.getUser('user'); const payload = await generateUserPanel(interaction.client, user); await interaction.reply({ @@ -48,6 +53,45 @@ module.exports.subcommands = { }); } }, + 'toggle': async function (interaction) { + const generalConfig = interaction.client.configurations['ping-protection']?.['configuration'] || {}; + if (!generalConfig.allowProtectionToggle) { + return interaction.reply({ + content: localize('ping-protection', 'toggle-disabled-by-admin'), + flags: MessageFlags.Ephemeral + }); + } + + const targetMember = interaction.member || interaction.user; + const result = await toggleUserProtection(interaction.client, targetMember); + + if (!result.success) { + if (result.reason === 'not-protected') { + return interaction.reply({ + content: localize('ping-protection', 'toggle-err-not-protected'), + flags: MessageFlags.Ephemeral + }); + } + return interaction.reply({ + content: localize('ping-protection', 'toggle-err-failed'), + flags: MessageFlags.Ephemeral + }); + } + + if (result.state === 'disabled') { + return interaction.reply({ + content: localize('ping-protection', 'toggle-succ-disabled', { + time: dateToDiscordTimestamp(result.disabledUntil, 'R') + }), + flags: MessageFlags.Ephemeral + }); + } + + return interaction.reply({ + content: localize('ping-protection', 'toggle-succ-enabled'), + flags: MessageFlags.Ephemeral + }); + }, 'list': { 'protected': async function (interaction) { await listHandler(interaction, 'protected'); @@ -58,6 +102,7 @@ module.exports.subcommands = { } }; +// Handles list subcommands // Handles list subcommands async function listHandler(interaction, type) { const config = interaction.client.configurations['ping-protection']['configuration']; @@ -72,11 +117,23 @@ async function listHandler(interaction, type) { 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'); + let usersList = localize('ping-protection', 'list-none'); + if (Array.isArray(config.protectedUsers) && config.protectedUsers.length > 0) { + const formattedUsers = await Promise.all(config.protectedUsers.map(async (id) => { + const isOff = config.allowProtectionToggle + ? await isProtectionToggledOff(interaction.client, id) + : false; + + if (isOff) { + return `<@${id}> *(${localize('ping-protection', 'list-user-paused')})*`; + } + return `<@${id}>`; + })); - const rolesList = config.protectedRoles.length > 0 + usersList = formattedUsers.join('\n'); + } + + const rolesList = Array.isArray(config.protectedRoles) && config.protectedRoles.length > 0 ? config.protectedRoles.map(id => `<@&${id}>`).join('\n') : localize('ping-protection', 'list-none'); @@ -97,15 +154,15 @@ async function listHandler(interaction, type) { embed.setTitle(localize('ping-protection', 'list-whitelist-title')); embed.setDescription(localize('ping-protection', 'list-whitelist-desc')); - const rolesList = config.ignoredRoles.length > 0 + const rolesList = Array.isArray(config.ignoredRoles) && config.ignoredRoles.length > 0 ? config.ignoredRoles.map(id => `<@&${id}>`).join('\n') : localize('ping-protection', 'list-none'); - const channelsList = config.ignoredChannels.length > 0 + const channelsList = Array.isArray(config.ignoredChannels) && config.ignoredChannels.length > 0 ? config.ignoredChannels.map(id => `<#${id}>`).join('\n') : localize('ping-protection', 'list-none'); - const usersList = config.ignoredUsers.length > 0 + const usersList = Array.isArray(config.ignoredUsers) && config.ignoredUsers.length > 0 ? config.ignoredUsers.map(id => `<@${id}>`).join('\n') : localize('ping-protection', 'list-none'); @@ -140,48 +197,64 @@ module.exports.config = { usage: '/ping-protection', type: 'slash', defaultPermission: false, - 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 - }] - } - ] - }, - { + options: function (client) { + const array = []; + + const storageConfig = client.configurations['ping-protection']['storage'] || {}; + const generalConfig = client.configurations['ping-protection']['configuration'] || {}; + + if (storageConfig.enablePingHistory) { + array.push({ + 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 + }] + } + ] + }); + } + + if (generalConfig.allowProtectionToggle) { + array.push({ + type: 'SUB_COMMAND', + name: 'toggle', + description: localize('ping-protection', 'cmd-desc-toggle') + }); + } + + array.push({ type: 'SUB_COMMAND_GROUP', name: 'list', description: localize('ping-protection', 'cmd-desc-group-list'), @@ -197,6 +270,8 @@ module.exports.config = { description: localize('ping-protection', 'cmd-desc-list-wl') } ] - } - ] + }); + + return array; + } }; \ No newline at end of file diff --git a/modules/ping-protection/commands/view-moderation-history.js b/modules/ping-protection/commands/view-moderation-history.js index 468f80c8..f26e7c2f 100644 --- a/modules/ping-protection/commands/view-moderation-history.js +++ b/modules/ping-protection/commands/view-moderation-history.js @@ -1,5 +1,5 @@ const {localize} = require('../../../src/functions/localize'); -const {generateActionsResponse} = require('../ping-protection'); +const {generateActionsResponse} = require('../core/panels'); const {MessageFlags} = require('discord.js'); module.exports.config = { diff --git a/modules/ping-protection/commands/view-ping-history.js b/modules/ping-protection/commands/view-ping-history.js index 6d6b76fa..c989ed5e 100644 --- a/modules/ping-protection/commands/view-ping-history.js +++ b/modules/ping-protection/commands/view-ping-history.js @@ -1,5 +1,5 @@ const {localize} = require('../../../src/functions/localize'); -const {generateHistoryResponse} = require('../ping-protection'); +const {generateHistoryResponse} = require('../core/panels'); const {MessageFlags} = require('discord.js'); module.exports.config = { diff --git a/modules/ping-protection/configs/configuration.json b/modules/ping-protection/configs/configuration.json index 1d773f59..75a0d191 100644 --- a/modules/ping-protection/configs/configuration.json +++ b/modules/ping-protection/configs/configuration.json @@ -3,10 +3,7 @@ "humanName": "General Configuration", "commandsWarnings": { "normal": [ - "/ping-protection user history", - "/ping-protection user actions-history", - "/ping-protection list protected", - "/ping-protection list whitelisted" + "/ping-protection" ] }, "description": "Configure protected users/roles, whitelisted roles/members, ignored channels and the notification message.", @@ -77,15 +74,10 @@ "name": "ignoredChannels", "category": "whitelisted", "humanName": "Whitelisted Channels", - "description": "Pings in these channels are ignored.", + "description": "Pings in these channels are ignored. Supported channels: Text- and news channels, categories, threads and voice channels. Note: If a category is selected, all channels in that category will be whitelisted, even newly made channels.", "type": "array", "content": "channelID", - "default": [], - "channelTypes": [ - "GUILD_TEXT", - "GUILD_NEWS", - "GUILD_CATEGORY" - ] + "default": [] }, { "name": "ignoredUsers", @@ -96,6 +88,14 @@ "content": "userID", "default": [] }, + { + "name": "allowProtectionToggle", + "category": "rules", + "humanName": "Allow protected members to toggle their protection status temporarily", + "desription": "Enabling this allows protected members to temporarily toggle their protection status via tje `/ping-protection toggle` command. Useful for protected members if they don't mind being pinged for a short period of time.", + "type": "boolean", + "default": true + }, { "name": "allowReplyPings", "category": "rules", @@ -131,10 +131,10 @@ "humanName": "AutoMod Log Channel", "description": "Channel where AutoMod alerts are sent. It is recommended to keep these in a private channel.", "type": "channelID", - "default": "", - "channelTypes": [ + "content": [ "GUILD_TEXT" ], + "default": "", "dependsOn": "enableAutomod" }, { @@ -178,6 +178,50 @@ "image": "https://scnx-cdn.scootkit.net/1769198862209-rJfCVKzAuo6uQLhPUe9o2P6ArJkDBSVUCEyUQM6bqt5WFKWK.gif", "color": "#ed4245" } + }, + { + "name": "kickPunishmentMessage", + "category": "messages", + "humanName": "Warning Message", + "description": "The message that gets sent to the user's DM's when they are kicked for meeting a moderation action's treshold.'", + "type": "string", + "allowEmbed": true, + "params": [ + { + "name": "reason", + "description": "The reason why the user was kicked." + }, + { + "name": "pings", + "description": "The amount of pings that triggered the moderation action." + }, + { + "name": "timeframe", + "description": "The time frame in which the pings occurred." + }, + { + "name": "guild-name", + "description": "The name of the server." + } + ], + "default": "You have been kicked from **%guild-name%** for pinging protected users/roles %pings% times within %timeframe% days." + }, + { + "name": "autoDeleteWarningMessage", + "category": "messages", + "humanName": "Automatically delete warning message", + "description": "If enabled, you can set a custom time after which the warning message will automatically be deleted to keep the channel clean.", + "type": "boolean", + "default": true + }, + { + "name": "deleteWarningMessageTime", + "category": "messages", + "humanName": "Automatic message deletion time", + "description": "The amount of time after which the warning message will automatically be deleted, in seconds.", + "type": "integer", + "default": 30, + "minValue": "1" } ] } \ No newline at end of file diff --git a/modules/ping-protection/configs/moderation.json b/modules/ping-protection/configs/moderation.json index 82efd99f..c6a4a295 100644 --- a/modules/ping-protection/configs/moderation.json +++ b/modules/ping-protection/configs/moderation.json @@ -15,6 +15,18 @@ "type": "integer", "default": 10 }, + { + "name": "pingsType", + "humanName": "Type of ping", + "description": "The type of pings tht will trigger this moderation action", + "type": "select", + "content": [ + "Mentions", + "Reply pings", + "Both" + ], + "default": "Both" + }, { "name": "enableRolePingThresholds", "humanName": "Enable role-based ping thresholds", @@ -42,11 +54,11 @@ "default": false }, { - "name": "timeframeDays", - "humanName": "Timeframe (Days)", - "description": "In how many days must these pings occur?", - "type": "integer", - "default": 7, + "name": "customTimeFrame", + "humanName": "Custom timeframe", + "description": "In how much time must these pings occur? (use formats like 10s for 10 seconds, 1h for 1 hour or 1d for 1 day)", + "type": "string", + "default": "7d", "dependsOn": "useCustomTimeframe" }, { diff --git a/modules/ping-protection/configs/storage.json b/modules/ping-protection/configs/storage.json index 586ba025..3e826437 100644 --- a/modules/ping-protection/configs/storage.json +++ b/modules/ping-protection/configs/storage.json @@ -24,7 +24,7 @@ "name": "enablePingHistory", "category": "pings", "humanName": "Enable Ping History", - "description": "If enabled, the bot will keep a history of pings to enforce moderation actions.", + "description": "If enabled, the bot will keep a history of pings to enforce moderation actions. THIS IS REQUIRED TO USE MODERATION ACTIONS!", "type": "boolean", "default": true }, @@ -69,11 +69,11 @@ "name": "leaverRetention", "category": "leavers", "humanName": "Leaver Data Retention (Days)", - "description": "How long to keep data after a user leaves (1-7 Days).", + "description": "How long to keep data after a user left the server (1-60 Days).", "type": "integer", - "default": 1, + "default": 7, "minValue": "1", - "maxValue": "7", + "maxValue": "60", "dependsOn": "enableLeaverDataRetention" } ] diff --git a/modules/ping-protection/core/localHelpers.js b/modules/ping-protection/core/localHelpers.js new file mode 100644 index 00000000..c284f93a --- /dev/null +++ b/modules/ping-protection/core/localHelpers.js @@ -0,0 +1,177 @@ +/** + * Some local utility helpers + * @module ping-protection + * @author itskevinnn +**/ + +const EXEMPT_THRESHOLD = 'exempt'; +const PARTIAL_DELETION_COOLDOWN_HOURS = 24; +const FULL_DELETION_COOLDOWN_HOURS = 168; + +// Converts human timeframes (7d etc) into ms +function parseTimeframeToMs(input, defaultDays = 7) { + const fallbackMs = defaultDays * 86400000; + + if (typeof input === 'number') { + return Number.isFinite(input) && input > 0 + ? input * 86400000 + : fallbackMs; + } + + const clean = String(input).trim().toLowerCase(); + + // Fallback for old configs, plain numbers are treated as days + if (/^\d+$/.test(clean)) { + const parsedDays = parseInt(clean, 10); + return Number.isFinite(parsedDays) && parsedDays > 0 + ? parsedDays * 86400000 + : fallbackMs; + } + + const match = clean.match(/^(\d+)\s*(s|m|h|d|w)$/); + if (!match) { + return fallbackMs; + } + + const value = parseInt(match[1], 10); + const unit = match[2]; + + if (!Number.isFinite(value) || value <= 0) { + return fallbackMs; + } + + switch (unit) { + case 's': return value * 1000; + case 'm': return value * 60000; + case 'h': return value * 3600000; + case 'd': return value * 86400000; + case 'w': return value * 7 * 86400000; + default: return fallbackMs; + } +} + +function determinePingType(message, targetId, isRole = false) { + if (isRole || !message || !targetId) { + return 'MENTION'; + } + + const isReply = Boolean(message.reference && message.mentions?.repliedUser?.id === targetId); + if (!isReply) { + return 'MENTION'; + } + + const content = message.content || ''; + if (!content) { + return 'REPLY'; // Fallback if MessageContent intent is disabled + } + + const hasExplicitMention = content.includes(`<@${targetId}>`) || content.includes(`<@!${targetId}>`); + return hasExplicitMention + ? 'MENTION' + : 'REPLY'; +} + +// Satisfies Discord's snowflake requirements for channel IDs +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; +} + +function getWhitelistedChannelIds(channel) { + if (!channel) return []; + const ids = new Set(); + if (channel.id) ids.add(channel.id); + if (channel.parentId) ids.add(channel.parentId); + return [...ids]; +} + +function isWhitelistedChannel(config, channel) { + if (!channel || !config || !Array.isArray(config.ignoredChannels) || config.ignoredChannels.length === 0) { + return false; + } + const ignoredIds = new Set(config.ignoredChannels.map(id => id.toString())); + return getWhitelistedChannelIds(channel).some(id => ignoredIds.has(id.toString())); +} + +// Checks the ping count thresholds for a member based on their roles and the rule configuration +function getRequiredPingCountForMember(rule, member) { + const baseCount = + rule.pingsCount ?? + rule.pingsCountAdvanced ?? + rule.pingsCountBasic; + + if (typeof baseCount !== 'number' || !Number.isFinite(baseCount)) { + return null; + } + if (!rule.enableRolePingThresholds) { + return baseCount; + } + + const thresholds = rule.rolePingThresholds; + if (!thresholds || typeof thresholds !== 'object' || Array.isArray(thresholds)) { + return baseCount; + } + if (!member || !member.roles?.cache) { + return baseCount; + } + + const matchingRoles = member.roles.cache + .filter(role => Object.prototype.hasOwnProperty.call(thresholds, role.id)) + .sort((a, b) => b.position - a.position); + + if (matchingRoles.size === 0) { + return baseCount; + } + + for (const role of matchingRoles.values()) { + const parsedValue = Number(thresholds[role.id]); + if (!Number.isFinite(parsedValue)) continue; + + if (parsedValue === 0) { + return EXEMPT_THRESHOLD; + } + } + + const highestRole = matchingRoles.first(); + const highestRoleValue = Number(thresholds[highestRole.id]); + if (!Number.isFinite(highestRoleValue)) { + return baseCount; + } + + return highestRoleValue; +} + +function getDeletionCooldownHours(dataType) { + return dataType === 'del_all' + ? FULL_DELETION_COOLDOWN_HOURS + : PARTIAL_DELETION_COOLDOWN_HOURS; +} + +function getDeletionTypeLocaleKey(dataType) { + if (dataType === 'del_ping_history') return 'del-type-pings'; + if (dataType === 'del_moderation_history') return 'del-type-actions'; + if (dataType === 'del_all') return 'del-type-all'; + return 'del-type-unknown'; +} + +module.exports = { + EXEMPT_THRESHOLD, + PARTIAL_DELETION_COOLDOWN_HOURS, + FULL_DELETION_COOLDOWN_HOURS, + parseTimeframeToMs, + determinePingType, + getSafeChannelId, + getWhitelistedChannelIds, + isWhitelistedChannel, + getRequiredPingCountForMember, + getDeletionCooldownHours, + getDeletionTypeLocaleKey +}; \ No newline at end of file diff --git a/modules/ping-protection/core/moderation.js b/modules/ping-protection/core/moderation.js new file mode 100644 index 00000000..214b7a7e --- /dev/null +++ b/modules/ping-protection/core/moderation.js @@ -0,0 +1,432 @@ +/** + * Ping processing, punishments, and moderation logging + * @module ping-protection + * @author itskevinnn + */ + +const {Op} = require('sequelize'); +const {EmbedBuilder} = require('discord.js'); +const {embedTypeV2, safeSetFooter} = require('../../../src/functions/helpers'); +const {localize} = require('../../../src/functions/localize'); +const { + getSafeChannelId, + getRequiredPingCountForMember, + EXEMPT_THRESHOLD +} = require('./localHelpers'); +const {addPing, getPingCountInWindow} = require('./records'); +const {isProtectionToggledOff} = require('./toggle'); + +async function sendPingWarning(client, message, target, moduleConfig) { + const warningMsg = moduleConfig.pingWarningMessage; + if (!warningMsg) return null; + + if (target.id && moduleConfig.allowProtectionToggle) { + const isOff = await isProtectionToggledOff(client, target.id); + if (isOff) return null; + } + + let warnMsg = {...warningMsg}; + const placeholders = { + '%target-name%': target.name || target.tag || target.username || 'Unknown', + '%target-mention%': target.toString(), + '%target-id%': target.id, + '%pinger-id%': message.author.id + }; + + try { + const messageOptions = await embedTypeV2(warnMsg, placeholders); + let sentMessage = null; + + try { + sentMessage = await message.reply(messageOptions); + } catch (replyError) { + client.logger.warn(localize('ping-protection', 'log-warning-reply-failed', { + e: replyError.message + })); + + try { + sentMessage = await message.channel.send(messageOptions); + } catch (sendError) { + client.logger.warn(localize('ping-protection', 'log-warning-send-failed', { + c: message.channel.id, + e: sendError.message + })); + return null; + } + } + + if (sentMessage && moduleConfig.autoDeleteWarningMessage) { + setTimeout(() => { + sentMessage.delete().catch((deleteError) => { + client.logger.warn(localize('ping-protection', 'log-warning-delete-failed', { + e: deleteError.message + })); + }); + }, moduleConfig.deleteWarningMessageTime * 1000); + } + + return sentMessage; + } catch (error) { + client.logger.error(localize('ping-protection', 'log-warning-build-failed', { + e: error.message + })); + return null; + } +} + +async function syncNativeAutoMod(client) { + const config = client.configurations['ping-protection']['configuration']; + + try { + const guild = await client.guilds.fetch(client.guildID); + await guild.channels.fetch().catch((error) => { + client.logger.warn(localize('ping-protection', 'log-automod-channel-fetch-failed', { + e: error.message + })); + }); + + const rules = await guild.autoModerationRules.fetch(); + const existingRule = rules.find(r => r.name === 'Ping Protection System'); + + if (!config || !config.enableAutomod) { + if (existingRule) { + await existingRule.delete().catch((error) => { + client.logger.warn(localize('ping-protection', 'log-automod-rule-delete-failed', { + e: error.message + })); + }); + } + return; + } + + const keywords = []; + if (config.protectedRoles) { + config.protectedRoles.forEach(roleId => { + keywords.push(`<@&${roleId}>`); + }); + } + + const protectedIdsSet = new Set(config.protectedUsers || []); + if (config.protectAllUsersWithProtectedRole && config.protectedRoles && config.protectedRoles.length > 0) { + if ((guild.client._activeIntents || []).includes('GuildMembers')) { + guild.members.cache.forEach(member => { + if (member.roles.cache.some(r => config.protectedRoles.includes(r.id))) { + protectedIdsSet.add(member.id); + } + }); + } else { + client.logger.warn(localize('ping-protection', 'log-automod-role-protection-skipped')); + } + } + + // Exclude users who currently have their protection paused + for (const id of protectedIdsSet) { + if (config.allowProtectionToggle) { + const isToggledOff = await isProtectionToggledOff(client, id); + if (isToggledOff) continue; + } + keywords.push(`<@${id}>`); + keywords.push(`<@!${id}>`); + } + + if (keywords.length === 0) { + if (existingRule) { + await existingRule.delete().catch(() => {}); + } + return; + } + + if (keywords.length > 1000) { + client.logger.warn(localize('ping-protection', 'log-automod-keyword-limit')); + keywords.splice(1000); + } + + 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 exactIgnoredChannels = (config.ignoredChannels || []).filter(channelId => { + const channel = guild.channels.cache.get(channelId); + return channel && channel.type !== 4; + }); + + const ruleData = { + name: 'Ping Protection System', + eventType: 1, + triggerType: 1, + triggerMetadata: {keywordFilter: keywords}, + actions, + enabled: true, + exemptRoles: config.ignoredRoles || [], + exemptChannels: exactIgnoredChannels + }; + + if (existingRule) { + await guild.autoModerationRules.edit(existingRule.id, ruleData); + } else { + await guild.autoModerationRules.create(ruleData); + } + } catch (error) { + client.logger.error(localize('ping-protection', 'log-automod-sync-failed', { + e: error.message + })); + } +} + +// Executes moderation action +async function executeAction(client, member, rule, reason, storageConfig, originChannel = null, stats = {}) { + const actionType = rule.actionType; + + const sendActionLog = async () => { + if (!rule.enableActionLogging || !originChannel) return; + + const logMsgConfig = rule.actionLogMessage; + if (!logMsgConfig) return; + let safeMsg = {...logMsgConfig}; + + const placeholders = { + '%pinger-mention%': member.toString(), + '%pinger-name%': member.user.tag, + '%action%': rule.actionType, + '%duration%': rule.muteDuration || 'N/A', + '%pings%': stats.pingCount || 'N/A', + '%timeframe%': stats.customTimeFrame || 'N/A' + }; + + try { + let messageOptions = await embedTypeV2(safeMsg, placeholders); + await originChannel.send(messageOptions).catch(() => {}); + } catch (error) { + client.logger.warn(localize('ping-protection', 'log-action-log-failed', { + e: error.message + })); + } + }; + + // Sends the error message if punishment fails + const sendErrorLog = async (error) => { + if (!originChannel) return; + + const errorEmbed = new EmbedBuilder() + .setTitle(localize('ping-protection', 'punish-log-failed-title', { + u: member.user.tag + })) + .setDescription( + localize('ping-protection', 'punish-log-failed-desc', { + m: member.toString() + }) + + `\n${localize('ping-protection', 'punish-log-error', { + e: error.message + })}` + ) + .addFields({ + name: localize('ping-protection', 'punish-log-docs-title'), + value: localize('ping-protection', 'punish-log-docs-desc'), + inline: false + }) + .setColor('#ed4245'); + + safeSetFooter(errorEmbed, client); + if (!client.strings.disableFooterTimestamp) errorEmbed.setTimestamp(); + await originChannel.send({embeds: [errorEmbed.toJSON()]}).catch((sendError) => { + client.logger.warn(localize('ping-protection', 'log-punish-log-send-failed', { + e: sendError.message + })); + }); + }; + + if (!member) { + client.logger.debug(localize('ping-protection', 'log-not-a-member')); + return false; + } + + const botMember = await member.guild.members.fetch(client.user.id); + if (botMember.roles.highest.position <= member.roles.highest.position) { + await sendErrorLog({ + message: localize('ping-protection', 'punish-role-error', { + tag: member.user.tag + }) + }); + client.logger.warn(localize('ping-protection', 'log-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) { + client.logger.error(localize('ping-protection', 'log-modlog-create-failed', { + u: member.id, + e: dbError.message + })); + } + }; + + if (actionType === 'MUTE') { + const durationMs = rule.muteDuration * 60000; + await logDb('MUTE', rule.muteDuration); + try { + await member.timeout(durationMs, reason); + await sendActionLog(); + return true; + } catch (error) { + await sendErrorLog(error); + client.logger.warn(localize('ping-protection', 'log-mute-error', { + tag: member.user.tag, + e: error.message + })); + return false; + } + + } else if (actionType === 'KICK') { + const moduleConfig = client.configurations['ping-protection']['configuration']; + + if (moduleConfig?.kickPunishmentMessage) { + const placeholders = { + '%guild-name%': member.guild.name, + '%reason%': reason, + '%pings%': stats.pingCount || 'N/A', + '%timeframe%': stats.customTimeFrame || stats.timeframeDays || 'N/A' + }; + + try { + const dmPayload = await embedTypeV2(moduleConfig.kickPunishmentMessage, placeholders); + await member.send(dmPayload); + } catch (dmError) { + client.logger.warn(localize('ping-protection', 'log-kick-dm-failed', { + u: member.user.tag, + e: dmError.message + })); + } + } + + await logDb('KICK'); + try { + await member.kick(reason); + await sendActionLog(); + return true; + } catch (error) { + await sendErrorLog(error); + client.logger.warn(localize('ping-protection', 'log-kick-error', { + tag: member.user.tag, + e: error.message + })); + return false; + } + } + return false; +} + +// Processes a ping and applies moderation rules if thresholds are met +async function processPing(client, userId, targetId, isRole, messageUrl, originChannel, memberToPunish, pingType = 'MENTION') { + const config = client.configurations['ping-protection']['configuration']; + const storageConfig = client.configurations['ping-protection']['storage']; + const moderationRules = client.configurations['ping-protection']['moderation']; + + // Stops if the user toggled their protection off + if (!isRole && targetId && config.allowProtectionToggle) { + const isOff = await isProtectionToggledOff(client, targetId); + if (isOff) return; + } + + if (storageConfig?.enablePingHistory) { + try { + await addPing(client, userId, messageUrl, targetId, isRole, pingType); + } catch (e) { + client.logger.error(localize('ping-protection', 'log-ping-history-create-failed', { + u: userId, + e: e.message + })); + } + } + + if (!moderationRules || !Array.isArray(moderationRules) || moderationRules.length === 0) return; + + // Evaluate rules in reverse priority order (strictest first) + for (let i = moderationRules.length - 1; i >= 0; i--) { + const rule = moderationRules[i]; + const rulePingType = rule.pingsType || 'Both'; + + if (rulePingType === 'Mentions' && pingType === 'REPLY') continue; + if (rulePingType === 'Reply pings' && pingType === 'MENTION') continue; + + let countFilter = null; + if (rulePingType === 'Mentions') countFilter = 'MENTION'; + else if (rulePingType === 'Reply pings') countFilter = 'REPLY'; + + const retentionWeeks = storageConfig?.pingHistoryRetention || 12; + const timeframeRaw = rule.useCustomTimeframe + ? (rule.customTimeFrame || rule.timeframeDays || '7d') + : (retentionWeeks * 7); + + const timeframeDisplay = String(timeframeRaw); + const pingCount = await getPingCountInWindow(client, userId, timeframeRaw, countFilter); + const requiredCount = getRequiredPingCountForMember(rule, memberToPunish); + + if (requiredCount === EXEMPT_THRESHOLD || typeof requiredCount !== 'number' || !Number.isFinite(requiredCount)) { + continue; + } + + if (pingCount >= requiredCount) { + const oneMinuteAgo = new Date(Date.now() - 60000); + try { + const recentLog = await client.models['ping-protection']['ModerationLog'].findOne({ + where: { + victimID: userId, + createdAt: {[Op.gt]: oneMinuteAgo} + } + }); + if (recentLog) break; // Prevent punish spam within 60 seconds + } catch (e) { + client.logger.warn(localize('ping-protection', 'log-recent-mod-check-failed', { + u: userId, + e: e.message + })); + } + + const generatedReason = rule.useCustomTimeframe + ? localize('ping-protection', 'reason-advanced', {c: pingCount, d: timeframeDisplay}) + : localize('ping-protection', 'reason-basic', {c: pingCount, w: retentionWeeks}); + + if (memberToPunish) { + const success = await executeAction( + client, + memberToPunish, + rule, + generatedReason, + storageConfig, + originChannel, + { + pingCount, + timeframeDays: timeframeDisplay, + customTimeFrame: timeframeDisplay + } + ); + + if (success) break; + } + } + } +} + +module.exports = { + sendPingWarning, + syncNativeAutoMod, + executeAction, + processPing +}; \ No newline at end of file diff --git a/modules/ping-protection/core/panels.js b/modules/ping-protection/core/panels.js new file mode 100644 index 00000000..e78de58c --- /dev/null +++ b/modules/ping-protection/core/panels.js @@ -0,0 +1,324 @@ +/** + * User panel and history embeds makers + * @module ping-protection + * @author itskevinnn + */ + +const { + ActionRowBuilder, + ButtonBuilder, + EmbedBuilder, + ButtonStyle, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder +} = require('discord.js'); +const {formatDate, safeSetFooter} = require('../../../src/functions/helpers'); +const {localize} = require('../../../src/functions/localize'); +const {getDeletionTypeLocaleKey} = require('./localHelpers'); +const { + getPingCountInWindow, + fetchModHistory, + fetchPingHistory, + getLeaverStatus, + getDeletionCooldown +} = require('./records'); + +function buildPanelMenu(userId, selected = 'overview') { + const menu = new StringSelectMenuBuilder() + .setCustomId(`ping-protection_panel-menu_${userId}`) + .setPlaceholder(localize('ping-protection', 'panel-ph')) + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(localize('ping-protection', 'panel-opt-over')) + .setValue('overview') + .setEmoji('šŸ ') + .setDefault(selected === 'overview'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('ping-protection', 'panel-opt-hist')) + .setValue('history') + .setEmoji('šŸ“œ') + .setDefault(selected === 'history'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('ping-protection', 'panel-opt-actions')) + .setValue('actions') + .setEmoji('āš ļø') + .setDefault(selected === 'actions'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('ping-protection', 'panel-opt-delete')) + .setValue('deletion') + .setEmoji('šŸ—‘ļø') + .setDefault(selected === 'deletion') + ); + + return new ActionRowBuilder().addComponents(menu); +} + +function buildDeletionMenu(userId) { + const menu = new StringSelectMenuBuilder() + .setCustomId(`ping-protection_delete-menu_${userId}`) + .setPlaceholder(localize('ping-protection', 'panel-deletion-placeholder')) + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(localize('ping-protection', 'panel-opt-back')) + .setValue('back') + .setEmoji('ā—€ļø'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('ping-protection', 'panel-opt-del-pings')) + .setValue('del_ping_history') + .setEmoji('šŸ“œ'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('ping-protection', 'panel-opt-del-actions')) + .setValue('del_moderation_history') + .setEmoji('āš ļø'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('ping-protection', 'panel-opt-del-all')) + .setValue('del_all') + .setEmoji('šŸ’„') + ); + + return new ActionRowBuilder().addComponents(menu); +} + +async function generateUserPanel(client, targetUser) { + const storageConfig = client.configurations['ping-protection']['storage']; + const retentionWeeks = storageConfig?.pingHistoryRetention || 12; + const timeframeDays = retentionWeeks * 7; + + const pingCount = await getPingCountInWindow(client, targetUser.id, timeframeDays); + const modData = await fetchModHistory(client, targetUser.id, 1, 1); + + const embed = new EmbedBuilder() + .setTitle(localize('ping-protection', 'panel-title', { + u: targetUser.tag || targetUser.username + })) + .setDescription(localize('ping-protection', 'panel-description', { + u: targetUser.toString(), + i: targetUser.id + })) + .setColor('Blue') + .setThumbnail(targetUser.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 + }]); + + safeSetFooter(embed, client); + if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); + + return { + embeds: [embed.toJSON()], + components: [buildPanelMenu(targetUser.id, 'overview').toJSON()] + }; +} + +// Generates a paginated history embed +async function generateHistoryResponse(client, userOrId, page = 1, isPanel = false) { + const storageConfig = client.configurations['ping-protection']['storage']; + const limit = 5; + const isEnabled = Boolean(storageConfig?.enablePingHistory); + + const targetUser = typeof userOrId === 'string' + ? await client.users.fetch(userOrId).catch(() => ({id: userOrId, username: 'Unknown User', displayAvatarURL: () => null})) + : userOrId; + + const userId = targetUser.id; + let total = 0; + let history = []; + let 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 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}>`; + } + + const hasValidLink = entry.messageUrl && entry.messageUrl !== 'Blocked by AutoMod'; + const linkText = hasValidLink + ? `[${localize('ping-protection', 'label-jump')}](${entry.messageUrl})` + : localize('ping-protection', 'no-message-link'); + + let typeString; + if (entry.pingType === 'MENTION') typeString = localize('ping-protection', 'ping-mention'); + else if (entry.pingType === 'REPLY') typeString = localize('ping-protection', 'ping-reply'); + else typeString = localize('ping-protection', 'ping-unknown'); + + const typeLine = `• **${localize('ping-protection', 'label-type')}:** ${typeString} (${linkText})`; + return localize('ping-protection', 'list-entry-text', { + index: (page - 1) * limit + index + 1, + target: targetString, + time: timeString, + type: typeLine + }); + }); + description += lines.join('\n\n'); + } + + // Switch customId prefixes depending on standalone vs panel origin + const btnPrefix = isPanel ? `ping-protection_panel-hist_${userId}` : `ping-protection_hist-page_${userId}`; + const countId = isPanel ? 'ping_protection_panel_hist_count' : 'ping_protection_page_count'; + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`${btnPrefix}_${page - 1}`) + .setLabel(localize('helpers', 'back')) + .setStyle(ButtonStyle.Primary) + .setDisabled(page <= 1), + new ButtonBuilder() + .setCustomId(countId) + .setLabel(`${page}/${totalPages}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`${btnPrefix}_${page + 1}`) + .setLabel(localize('helpers', 'next')) + .setStyle(ButtonStyle.Primary) + .setDisabled(page >= totalPages || !isEnabled) + ); + + const embed = new EmbedBuilder() + .setTitle(localize('ping-protection', 'embed-history-title', {u: targetUser.username})) + .setThumbnail(targetUser.displayAvatarURL({dynamic: true})) + .setDescription(description) + .setColor('Orange'); + + safeSetFooter(embed, client); + if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); + + const components = isPanel + ? [buildPanelMenu(userId, 'history').toJSON(), row.toJSON()] + : [row.toJSON()]; + + return {embeds: [embed.toJSON()], components}; +} + +// Generates a paginated moderation actions embed +async function generateActionsResponse(client, userOrId, page = 1, isPanel = false) { + const moderationConfig = client.configurations['ping-protection']['moderation']; + const limit = 5; + const isEnabled = Array.isArray(moderationConfig) && moderationConfig.length > 0; + + const targetUser = typeof userOrId === 'string' + ? await client.users.fetch(userOrId).catch(() => ({id: userOrId, username: 'Unknown User', displayAvatarURL: () => null})) + : userOrId; + + const userId = targetUser.id; + const data = await fetchModHistory(client, userId, page, limit); + const total = data.total; + const history = data.history; + const totalPages = Math.ceil(total / limit) || 1; + + let description = ''; + 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 btnPrefix = isPanel ? `ping-protection_panel-actions_${userId}` : `ping-protection_mod-page_${userId}`; + const countId = isPanel ? 'ping_protection_panel_actions_count' : 'ping_protection_page_count'; + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`${btnPrefix}_${page - 1}`) + .setLabel(localize('helpers', 'back')) + .setStyle(ButtonStyle.Primary) + .setDisabled(page <= 1), + new ButtonBuilder() + .setCustomId(countId) + .setLabel(`${page}/${totalPages}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`${btnPrefix}_${page + 1}`) + .setLabel(localize('helpers', 'next')) + .setStyle(ButtonStyle.Primary) + .setDisabled(page >= totalPages || (!isEnabled && history.length === 0)) + ); + + const embed = new EmbedBuilder() + .setTitle(localize('ping-protection', 'embed-actions-title', {u: targetUser.username})) + .setThumbnail(targetUser.displayAvatarURL({dynamic: true})) + .setDescription(description) + .setColor(isEnabled ? 'Red' : 'Grey'); + + safeSetFooter(embed, client); + if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); + + const components = isPanel + ? [buildPanelMenu(userId, 'actions').toJSON(), row.toJSON()] + : [row.toJSON()]; + + return {embeds: [embed.toJSON()], components}; +} + +async function generatePanelDeletion(client, targetUser) { + const cooldown = await getDeletionCooldown(client, targetUser.id); + + let description = localize('ping-protection', 'panel-deletion-desc', { + u: targetUser.toString(), + i: targetUser.id + }); + + if (cooldown) { + description += `\n\nāš ļø ${localize('ping-protection', 'panel-deletion-cooldown-active', { + time: formatDate(new Date(cooldown.blockedUntil)), + type: localize('ping-protection', getDeletionTypeLocaleKey(cooldown.lastDeletionType)) + })}`; + } + + const embed = new EmbedBuilder() + .setTitle(localize('ping-protection', 'panel-deletion-title', { + u: targetUser.tag || targetUser.username + })) + .setDescription(description) + .setColor('DarkRed') + .setThumbnail(targetUser.displayAvatarURL({dynamic: true})); + + safeSetFooter(embed, client); + if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); + + return { + embeds: [embed.toJSON()], + components: [buildDeletionMenu(targetUser.id).toJSON()] + }; +} + +module.exports = { + buildPanelMenu, + buildDeletionMenu, + generateUserPanel, + generateHistoryResponse, + generateActionsResponse, + generatePanelDeletion +}; \ No newline at end of file diff --git a/modules/ping-protection/core/records.js b/modules/ping-protection/core/records.js new file mode 100644 index 00000000..1a94fa35 --- /dev/null +++ b/modules/ping-protection/core/records.js @@ -0,0 +1,236 @@ +/** + * Database storage, queries, data retention, and deletion logic + * @module ping-protection + * @author itskevinnn + */ + +const {Op} = require('sequelize'); +const {localize} = require('../../../src/functions/localize'); +const {parseTimeframeToMs, getDeletionCooldownHours} = require('./localHelpers'); + +const recentPings = new Set(); + +async function addPing(client, userId, messageUrl, targetId, isRole, pingType = 'MENTION') { + const config = client.configurations['ping-protection']['configuration']; + const duplicateWindow = config.enableAutomod ? 5000 : 2000; + const debounceKey = `${userId}_${targetId}`; + + // Avoids duplicate mentions being logged in a short time + if (recentPings.has(debounceKey)) return; + recentPings.add(debounceKey); + setTimeout(() => { + recentPings.delete(debounceKey); + }, duplicateWindow); + + const recentDuplicate = await client.models['ping-protection']['PingHistory'].findOne({ + where: { + userId: userId, + targetId: targetId, + createdAt: {[Op.gt]: new Date(Date.now() - duplicateWindow)} + } + }); + + if (recentDuplicate) return; + await client.models['ping-protection']['PingHistory'].create({ + userId: userId, + messageUrl: messageUrl || 'Blocked by AutoMod', + targetId: targetId, + isRole: isRole, + pingType: pingType || 'MENTION' + }); +} + +async function getPingCountInWindow(client, userId, timeframe, pingType = null) { + const timeframeMs = parseTimeframeToMs(timeframe); + const cutoffDate = new Date(Date.now() - timeframeMs); + + const whereClause = { + userId: userId, + createdAt: {[Op.gt]: cutoffDate} + }; + + if (pingType === 'MENTION' || pingType === 'REPLY') { + whereClause.pingType = pingType; + } + + return await client.models['ping-protection']['PingHistory'].count({ + where: whereClause + }); +} + +async function fetchPingHistory(client, userId, page = 1, limit = 5) { + 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 = 5) { + const model = client.models['ping-protection']?.['ModerationLog']; + if (!model) return {total: 0, history: []}; + + try { + const offset = (page - 1) * limit; + const {count, rows} = await model.findAndCountAll({ + where: {victimID: userId}, + order: [['createdAt', 'DESC']], + limit: limit, + offset: offset + }); + return {total: count, history: rows}; + } catch (e) { + client.logger.warn(localize('ping-protection', 'log-fetch-mod-history-failed', { + u: userId, + e: e.message + })); + return {total: 0, history: []}; + } +} + +async function getLeaverStatus(client, userId) { + return await client.models['ping-protection']['LeaverData'].findByPk(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 getDeletionCooldown(client, userId) { + const model = client.models['ping-protection']?.['DeletionCooldown']; + if (!model) return null; + + const cooldown = await model.findByPk(userId); + if (!cooldown) return null; + if (new Date(cooldown.blockedUntil) <= new Date()) { + await cooldown.destroy().catch(() => {}); + return null; + } + + return cooldown; +} + +async function setDeletionCooldown(client, userId, dataType, deletedBy = null) { + const model = client.models['ping-protection']?.['DeletionCooldown']; + if (!model) return null; + + const hours = getDeletionCooldownHours(dataType); + const blockedUntil = new Date(Date.now() + hours * 60 * 60 * 1000); + await model.upsert({ + userId, + blockedUntil, + lastDeletionType: dataType, + lastDeletedBy: deletedBy || null + }); + + return blockedUntil; +} + +async function executeDataDeletion(client, userId, dataType, olderThanMs = null) { + const models = client.models['ping-protection']; + const pingHistoryWhere = {userId}; + const modLogWhere = {victimID: userId}; + const leaverWhere = {userId}; + + if (olderThanMs && Number.isFinite(olderThanMs) && olderThanMs > 0) { + const cutoff = new Date(Date.now() - olderThanMs); + pingHistoryWhere.createdAt = {[Op.lt]: cutoff}; + modLogWhere.createdAt = {[Op.lt]: cutoff}; + leaverWhere.leftAt = {[Op.lt]: cutoff}; + } + + if (['del_ping_history', 'del_all'].includes(dataType)) { + await models.PingHistory.destroy({where: pingHistoryWhere}); + } + + if (['del_moderation_history', 'del_all'].includes(dataType)) { + await models.ModerationLog.destroy({where: modLogWhere}); + } + + if (dataType === 'del_all') { + await models.LeaverData.destroy({where: leaverWhere}); + } +} + +async function deleteAllUserData(client, userId) { + await executeDataDeletion(client, userId, 'del_all'); + client.logger.info(localize('ping-protection', 'log-data-deletion', {u: userId})); +} + +// Enforces data retention policies based on configuration settings +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 || 12)); + 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(); + } + } +} + +module.exports = { + addPing, + getPingCountInWindow, + fetchPingHistory, + fetchModHistory, + getLeaverStatus, + markUserAsLeft, + markUserAsRejoined, + getDeletionCooldown, + setDeletionCooldown, + executeDataDeletion, + deleteAllUserData, + enforceRetention +}; \ No newline at end of file diff --git a/modules/ping-protection/core/toggle.js b/modules/ping-protection/core/toggle.js new file mode 100644 index 00000000..fc846ee2 --- /dev/null +++ b/modules/ping-protection/core/toggle.js @@ -0,0 +1,125 @@ +/** + * Logic for protected users to toggle their protection status + * @module ping-protection + * @author itskevinnn + */ + +const {localize} = require('../../../src/functions/localize'); + +const toggleLocks = new Set(); + +// Checks if a target user has currently paused their ping protection +async function isProtectionToggledOff(client, userId) { + if (!userId) return false; + const model = client.models['ping-protection']?.['UserPingPreference']; + if (!model) return false; + + try { + const rawUserId = String(userId); + const preference = await model.findOne({where: {userId: rawUserId}}); + + if (!preference || !preference.disabledUntil) { + return false; + } + + const expiryTime = new Date(preference.disabledUntil).getTime(); + const now = Date.now(); + + if (Number.isFinite(expiryTime) && expiryTime > now) { + return true; + } + + // Cleanup for expired pause rows + await model.destroy({where: {userId: rawUserId}}).catch(() => {}); + return false; + } catch (error) { + client.logger.warn(localize('ping-protection', 'log-check-preference-failed', { + u: userId, + e: error.message + })); + return false; + } +} + +// Toggles a user's protection on or off +async function toggleUserProtection(client, member) { + const userId = member?.user?.id || member?.id; + if (!member || !userId) { + return {success: false, reason: 'not-member'}; + } + + const rawUserId = String(userId); + + // Guard against duplicate executions + if (toggleLocks.has(rawUserId)) { + return {success: false, reason: 'locked'}; + } + toggleLocks.add(rawUserId); + setTimeout(() => toggleLocks.delete(rawUserId), 1500); + + const config = client.configurations['ping-protection']?.['configuration'] || {}; + + const isExplicitlyProtected = Array.isArray(config.protectedUsers) && + config.protectedUsers.map(String).includes(rawUserId); + + const memberRoleIds = Array.isArray(member.roles) + ? member.roles.map(String) + : (member.roles?.cache ? [...member.roles.cache.keys()].map(String) : []); + + const hasProtectedRole = Boolean( + config.protectAllUsersWithProtectedRole && + Array.isArray(config.protectedRoles) && + memberRoleIds.some(rId => config.protectedRoles.map(String).includes(rId)) + ); + + if (!isExplicitlyProtected && !hasProtectedRole) { + return {success: false, reason: 'not-protected'}; + } + + const model = client.models['ping-protection']?.['UserPingPreference']; + if (!model) { + return {success: false, reason: 'no-model'}; + } + + try { + const pref = await model.findOne({where: {userId: rawUserId}}); + const now = Date.now(); + + const {syncNativeAutoMod} = require('./moderation'); + + if (pref && pref.disabledUntil) { + const expiryTime = new Date(pref.disabledUntil).getTime(); + if (Number.isFinite(expiryTime) && expiryTime > now) { + await model.destroy({where: {userId: rawUserId}}); + if (config.enableAutomod) { + await syncNativeAutoMod(client).catch(() => {}); + } + return {success: true, state: 'enabled'}; + } + } + + const disabledUntil = new Date(now + 24 * 60 * 60 * 1000); + + await model.upsert({ + userId: rawUserId, + disabledUntil + }); + + if (config.enableAutomod) { + await syncNativeAutoMod(client).catch(() => {}); + } + + return {success: true, state: 'disabled', disabledUntil}; + } catch (error) { + client.logger.error(localize('ping-protection', 'log-toggle-preference-failed', { + u: rawUserId, + e: error.message + })); + return {success: false, reason: 'db-error', error: error.message}; + } +} + +module.exports = { + isProtectionToggledOff, + toggleUserProtection +}; \ No newline at end of file diff --git a/modules/ping-protection/events/autoModerationActionExecution.js b/modules/ping-protection/events/autoModerationActionExecution.js index 77884f36..1f8257e8 100644 --- a/modules/ping-protection/events/autoModerationActionExecution.js +++ b/modules/ping-protection/events/autoModerationActionExecution.js @@ -1,7 +1,12 @@ const { - processPing, isWhitelistedChannel -} = require('../ping-protection'); +} = require('../core/localHelpers'); +const { + processPing +} = require('../core/moderation'); +const { + isProtectionToggledOff +} = require('../core/toggle'); // Handles auto mod actions module.exports.run = async function (client, execution) { @@ -13,6 +18,11 @@ module.exports.run = async function (client, execution) { const matchedKeyword = execution.matchedKeyword || ''; const rawId = matchedKeyword.replace(/[^0-9]/g, ''); + if (config.allowProtectionToggle && rawId) { + const isToggledOff = await isProtectionToggledOff(client, rawId); + if (isToggledOff) return; + } + let isProtected = config.protectedRoles.includes(rawId) || config.protectedUsers.includes(rawId); let originChannel = execution.channel; diff --git a/modules/ping-protection/events/botReady.js b/modules/ping-protection/events/botReady.js index 1599c573..8c8a97c3 100644 --- a/modules/ping-protection/events/botReady.js +++ b/modules/ping-protection/events/botReady.js @@ -1,5 +1,7 @@ const { - enforceRetention, + enforceRetention +} = require('../core/records'); +const { syncNativeAutoMod } = require('../ping-protection'); const schedule = require('node-schedule'); diff --git a/modules/ping-protection/events/guildMemberAdd.js b/modules/ping-protection/events/guildMemberAdd.js index 1cdb394f..ddb2429b 100644 --- a/modules/ping-protection/events/guildMemberAdd.js +++ b/modules/ping-protection/events/guildMemberAdd.js @@ -2,7 +2,7 @@ * Checks when a member rejoins the server and updates their leaver status */ -const {markUserAsRejoined} = require('../ping-protection'); +const {markUserAsRejoined} = require('../core/records'); module.exports.run = async function (client, member) { if (!client.botReadyAt) return; diff --git a/modules/ping-protection/events/guildMemberRemove.js b/modules/ping-protection/events/guildMemberRemove.js index e07fdb3a..50c8788a 100644 --- a/modules/ping-protection/events/guildMemberRemove.js +++ b/modules/ping-protection/events/guildMemberRemove.js @@ -5,7 +5,7 @@ const { markUserAsLeft, deleteAllUserData -} = require('../ping-protection'); +} = require('../core/records'); module.exports.run = async function (client, member) { if (!client.botReadyAt) return; diff --git a/modules/ping-protection/events/interactionCreate.js b/modules/ping-protection/events/interactionCreate.js index b54ea172..64c1f0a2 100644 --- a/modules/ping-protection/events/interactionCreate.js +++ b/modules/ping-protection/events/interactionCreate.js @@ -1,15 +1,18 @@ const { - generateHistoryResponse, - generateActionsResponse, - generateUserPanel, - generatePanelHistory, - generatePanelActions, - generatePanelDeletion, + parseTimeframeToMs, + getDeletionTypeLocaleKey +} = require('../core/localHelpers'); +const { executeDataDeletion, getDeletionCooldown, - setDeletionCooldown, - getDeletionTypeLocaleKey -} = require('../ping-protection'); + setDeletionCooldown +} = require('../core/records'); +const { + generateUserPanel, + generatePanelDeletion, + generateHistoryResponse, + generateActionsResponse +} = require('../core/panels'); const {localize} = require('../../../src/functions/localize'); const { safeSetFooter, @@ -31,9 +34,10 @@ const { module.exports.run = async function (client, interaction) { if (!client.botReadyAt) return; const isAdmin = interaction.member?.permissions?.has('Administrator'); + const isManager = isAdmin || interaction.member?.permissions?.has('ManageGuild'); if (interaction.isStringSelectMenu() && interaction.customId.startsWith('ping-protection_panel-menu_')) { - if (!isAdmin) { + if (!isManager) { return interaction.reply({ content: localize('ping-protection', 'no-permission'), flags: MessageFlags.Ephemeral @@ -50,11 +54,17 @@ module.exports.run = async function (client, interaction) { } const selection = interaction.values[0]; + if (selection === 'deletion' && !isAdmin) { // Deletion panel is only for administrators, the rest of the panel + return interaction.reply({ // is broader and for managers + content: localize('ping-protection', 'no-permission'), + flags: MessageFlags.Ephemeral + }); + } let payload; if (selection === 'overview') payload = await generateUserPanel(client, targetUser); - else if (selection === 'history') payload = await generatePanelHistory(client, targetUser, 1); - else if (selection === 'actions') payload = await generatePanelActions(client, targetUser, 1); + else if (selection === 'history') payload = await generateHistoryResponse(client, targetUser, 1, true); + else if (selection === 'actions') payload = await generateActionsResponse(client, targetUser, 1, true); else if (selection === 'deletion') payload = await generatePanelDeletion(client, targetUser); if (payload) return interaction.update(payload); @@ -103,21 +113,9 @@ module.exports.run = async function (client, interaction) { }); } - // Checks to ensure modal content fits Discord limits - let modalTitle = localize('ping-protection', 'modal-title'); - if (modalTitle.length > 45) { - modalTitle = localize('ping-protection', 'fallback-modal-title'); - } - - let modalLabel = localize('ping-protection', 'modal-label'); - if (modalLabel.length > 45) { - modalLabel = localize('ping-protection', 'fallback-modal-label'); - } - - let confirmationPhrase = localize('ping-protection', 'modal-phrase'); - if (confirmationPhrase.length > 100) { - confirmationPhrase = localize('ping-protection', 'fallback-modal-phrase'); - } + let modalTitle = localize('ping-protection', 'del-modal-title'); + let modalLabel = localize('ping-protection', 'del-modal-label'); + let confirmationPhrase = localize('ping-protection', 'del-conf-phrase'); const modal = new ModalBuilder() .setCustomId(`ping-protection_del-confirm_${targetId}_${selection}`) @@ -131,6 +129,14 @@ module.exports.run = async function (client, interaction) { .setStyle(TextInputStyle.Paragraph) .setPlaceholder(confirmationPhrase) .setRequired(true) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('timeframe') + .setLabel(localize('ping-protection', 'del-modal-timeframe-label')) + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder(localize('ping-protection', 'del-modal-timeframe-ph')) + .setRequired(false) ) ); @@ -149,10 +155,7 @@ module.exports.run = async function (client, interaction) { const targetId = parts[2]; const selection = parts.slice(3).join('_'); - let confirmPhrase = localize('ping-protection', 'modal-phrase'); - if (confirmPhrase.length > 100) { - confirmPhrase = localize('ping-protection', 'fallback-modal-phrase'); - } + let confirmPhrase = localize('ping-protection', 'del-conf-phrase'); if (interaction.fields.getTextInputValue('confirm').trim() !== confirmPhrase) { return interaction.reply({ @@ -161,6 +164,20 @@ module.exports.run = async function (client, interaction) { }); } + const rawTimeframe = interaction.fields.getTextInputValue('timeframe')?.trim(); + let olderThanMs = null; + + if (rawTimeframe) { + const parsedMs = parseTimeframeToMs(rawTimeframe, null); + if (!parsedMs || parsedMs <= 0) { + return interaction.reply({ + content: localize('ping-protection', 'del-err-invalid-timeframe'), + flags: MessageFlags.Ephemeral + }); + } + olderThanMs = parsedMs; + } + const cooldown = await getDeletionCooldown(client, targetId); if (cooldown) { return interaction.reply({ @@ -242,8 +259,8 @@ module.exports.run = async function (client, interaction) { } if (btnInt.customId.includes('confirm')) { - await executeDataDeletion(client, targetId, 'del_all'); - const blockedUntil = await setDeletionCooldown(client, targetId, 'del_all', btnInt.user.id); + await executeDataDeletion(client, targetId, selection, olderThanMs); + const blockedUntil = await setDeletionCooldown(client, targetId, selection, btnInt.user.id); client.logger.info(localize('ping-protection', 'log-del-all', { target: targetId, @@ -281,7 +298,7 @@ module.exports.run = async function (client, interaction) { return; } - await executeDataDeletion(client, targetId, selection); + await executeDataDeletion(client, targetId, selection, olderThanMs); const blockedUntil = await setDeletionCooldown(client, targetId, selection, interaction.user.id); client.logger.info(localize('ping-protection', 'log-del-type', { @@ -341,7 +358,7 @@ module.exports.run = async function (client, interaction) { }); } - const payload = await generatePanelHistory(client, targetUser, targetPage); + const payload = await generateHistoryResponse(client, targetUser, targetPage, true); return interaction.update(payload); } @@ -358,7 +375,7 @@ module.exports.run = async function (client, interaction) { }); } - const payload = await generatePanelActions(client, targetUser, targetPage); + const payload = await generateActionsResponse(client, targetUser, targetPage, true); return interaction.update(payload); } } diff --git a/modules/ping-protection/events/messageCreate.js b/modules/ping-protection/events/messageCreate.js index 3cc91ba3..049e41c7 100644 --- a/modules/ping-protection/events/messageCreate.js +++ b/modules/ping-protection/events/messageCreate.js @@ -1,8 +1,14 @@ const { - processPing, + isWhitelistedChannel, + determinePingType +} = require('../core/localHelpers'); +const { sendPingWarning, - isWhitelistedChannel -} = require('../ping-protection'); + processPing +} = require('../core/moderation'); +const { + isProtectionToggledOff +} = require('../core/toggle'); const {localize} = require('../../../src/functions/localize'); const {randomElementFromArray} = require('../../../src/functions/helpers'); @@ -56,9 +62,18 @@ module.exports.run = async function (client, message) { } } + // Filter out users who have temporarily toggled protection off + if (config.allowProtectionToggle && protectedMentions.size > 0) { + for (const userId of [...protectedMentions]) { + const isToggledOff = await isProtectionToggledOff(client, userId); + if (isToggledOff) { + protectedMentions.delete(userId); + } + } + } + // Determines if any protected entities were pinged const pingedProtectedUser = protectedMentions.size > 0; - if (!pingedProtectedRole && !pingedProtectedUser) return; let target = null; @@ -126,6 +141,8 @@ module.exports.run = async function (client, message) { } } + const pingType = determinePingType(message, target.id, isRole); + await processPing( client, message.author.id, @@ -133,6 +150,7 @@ module.exports.run = async function (client, message) { isRole, message.url, message.channel, - memberToPunish + message.member, + pingType ); }; \ No newline at end of file diff --git a/modules/ping-protection/migrations/ping-protection_PingHistory__V1.js b/modules/ping-protection/migrations/ping-protection_PingHistory__V1.js new file mode 100644 index 00000000..1c133542 --- /dev/null +++ b/modules/ping-protection/migrations/ping-protection_PingHistory__V1.js @@ -0,0 +1,38 @@ +const {DataTypes} = require('sequelize'); + +const TABLE = 'ping_protection_history'; + +module.exports = { + tables: [TABLE], + up: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + + if (!description.pingType) { + await queryInterface.addColumn(TABLE, 'pingType', { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null + }, {transaction}); + } + }); + }, + down: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (description.pingType) { + await queryInterface.removeColumn(TABLE, 'pingType', {transaction}); + } + }); + } +}; \ No newline at end of file diff --git a/modules/ping-protection/models/PingHistory.js b/modules/ping-protection/models/PingHistory.js index 709e26e1..4de6e6b1 100644 --- a/modules/ping-protection/models/PingHistory.js +++ b/modules/ping-protection/models/PingHistory.js @@ -21,6 +21,11 @@ module.exports = class PingProtectionPingHistory extends Model { isRole: { type: DataTypes.BOOLEAN, defaultValue: false + }, + pingType: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null } }, { tableName: 'ping_protection_history', diff --git a/modules/ping-protection/models/UserPingPreference.js b/modules/ping-protection/models/UserPingPreference.js new file mode 100644 index 00000000..edb47244 --- /dev/null +++ b/modules/ping-protection/models/UserPingPreference.js @@ -0,0 +1,29 @@ +const { + DataTypes, + Model +} = require('sequelize'); + +module.exports = class PingProtectionUserPingPreference extends Model { + static init(sequelize) { + return super.init({ + userId: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + }, + disabledUntil: { + type: DataTypes.DATE, + allowNull: false, + } + }, { + tableName: 'ping_protection_user_preferences', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'UserPingPreference', + module: 'ping-protection' +}; \ No newline at end of file diff --git a/modules/ping-protection/module.json b/modules/ping-protection/module.json index 7b485630..47f09247 100644 --- a/modules/ping-protection/module.json +++ b/modules/ping-protection/module.json @@ -17,9 +17,9 @@ "tags": [ "moderation" ], - "fa-icon": "fa-duotone fa-clock-alarm", + "fa-icon": "fa-duotone fa-solid fa-message-dot", "humanReadableName": "Ping-Protection", - "description": "Powerful and highly customizable ping-protection module to protect members/roles from unwanted mentions with moderation capabilities.", + "description": "A powerful and highly customizable module to protect members and roles from unwanted mentions with moderation capabilities.", "intents": [ "GuildMembers", "GuildMessages", @@ -31,7 +31,7 @@ "MessageContent" ], "intentReasons": { - "GuildMembers": "Enumerates the holders of protected roles to detect unwanted mentions.", + "GuildMembers": "Checks members to see if they are protected from pings and if they are allowed to ping such users.", "MessageContent": "Scans message content and mentions to catch pings of protected roles and users." } } diff --git a/modules/ping-protection/ping-protection.js b/modules/ping-protection/ping-protection.js deleted file mode 100644 index 2bf3f803..00000000 --- a/modules/ping-protection/ping-protection.js +++ /dev/null @@ -1,1198 +0,0 @@ -/** - * Logic for the Ping Protection module - * @module ping-protection - * @author itskevinnn - */ -const {Op} = require('sequelize'); -const { - ActionRowBuilder, - ButtonBuilder, - EmbedBuilder, - ButtonStyle, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder -} = require('discord.js'); -const { - embedType, - embedTypeV2, - formatDate, - safeSetFooter -} = require('../../src/functions/helpers'); -const {localize} = require('../../src/functions/localize'); -const recentPings = new Set(); - -// Data handling -async function addPing(client, userId, messageUrl, targetId, isRole) { - const config = client.configurations['ping-protection']['configuration']; - const duplicateWindow = config.enableAutomod ? 5000 : 2000; - const debounceKey = `${userId}_${targetId}`; - - if (recentPings.has(debounceKey)) return; - recentPings.add(debounceKey); - setTimeout(() => { - recentPings.delete(debounceKey); - }, duplicateWindow); - - const recentDuplicate = await client.models['ping-protection']['PingHistory'].findOne({ - where: { - userId: userId, - targetId: targetId, - createdAt: {[Op.gt]: new Date(Date.now() - duplicateWindow)} - } - }); - - if (recentDuplicate) return; - await client.models['ping-protection']['PingHistory'].create({ - userId: userId, - messageUrl: messageUrl || 'Blocked by AutoMod', - targetId: targetId, - isRole: isRole - }); -} - -// Gets ping count in timeframe -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} - } - }); -} - -// Fetches ping history -async function fetchPingHistory(client, userId, page = 1, limit = 5) { - 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 - }; -} - -// Fetches moderation history -async function fetchModHistory(client, userId, page = 1, limit = 5) { - 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) { - client.logger.warn(localize('ping-protection', 'log-fetch-mod-history-failed', { - u: userId, - e: e.message - })); - return { - total: 0, - history: [] - }; - } -} - -// Gets leaver status -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; -} - -function getWhitelistedChannelIds(channel) { - if (!channel) return []; - const ids = new Set(); - if (channel.id) ids.add(channel.id); - if (channel.parentId) ids.add(channel.parentId); - return [...ids]; -} - -function isWhitelistedChannel(config, channel) { - if (!channel || !config || !Array.isArray(config.ignoredChannels) || config.ignoredChannels.length === 0) { - return false; - } - const ignoredIds = new Set(config.ignoredChannels.map(id => id.toString())); - return getWhitelistedChannelIds(channel).some(id => ignoredIds.has(id.toString())); -} - -const EXEMPT_THRESHOLD = 'exempt'; -const PARTIAL_DELETION_COOLDOWN_HOURS = 24; -const FULL_DELETION_COOLDOWN_HOURS = 168; - -function getRequiredPingCountForMember(rule, member) { - const baseCount = - rule.pingsCount ?? - rule.pingsCountAdvanced ?? - rule.pingsCountBasic; - - if (typeof baseCount !== 'number' || !Number.isFinite(baseCount)) { - return null; - } - if (!rule.enableRolePingThresholds) { - return baseCount; - } - - const thresholds = rule.rolePingThresholds; - if (!thresholds || typeof thresholds !== 'object' || Array.isArray(thresholds)) { - return baseCount; - } - if (!member || !member.roles?.cache) { - return baseCount; - } - - const matchingRoles = member.roles.cache - .filter(role => Object.prototype.hasOwnProperty.call(thresholds, role.id)) - .sort((a, b) => b.position - a.position); - - if (matchingRoles.size === 0) { - return baseCount; - } - - for (const role of matchingRoles.values()) { - const parsedValue = Number(thresholds[role.id]); - if (!Number.isFinite(parsedValue)) continue; - - if (parsedValue === 0) { - return EXEMPT_THRESHOLD; - } - } - - const highestRole = matchingRoles.first(); - const highestRoleValue = Number(thresholds[highestRole.id]); - if (!Number.isFinite(highestRoleValue)) { - return baseCount; - } - - return highestRoleValue; -} - -function getDeletionCooldownHours(dataType) { - return dataType === 'del_all' - ? FULL_DELETION_COOLDOWN_HOURS - : PARTIAL_DELETION_COOLDOWN_HOURS; -} - -function getDeletionTypeLocaleKey(dataType) { - if (dataType === 'del_ping_history') return 'del-type-pings'; - if (dataType === 'del_moderation_history') return 'del-type-actions'; - if (dataType === 'del_all') return 'del-type-all'; - return 'del-type-unknown'; -} - -async function getDeletionCooldown(client, userId) { - const model = client.models['ping-protection']?.['DeletionCooldown']; - if (!model) return null; - - const cooldown = await model.findByPk(userId); - if (!cooldown) return null; - if (new Date(cooldown.blockedUntil) <= new Date()) { - await cooldown.destroy().catch(() => { - }); - return null; - } - - return cooldown; -} - -async function setDeletionCooldown(client, userId, dataType, deletedBy = null) { - const model = client.models['ping-protection']?.['DeletionCooldown']; - if (!model) return null; - - const hours = getDeletionCooldownHours(dataType); - const blockedUntil = new Date(Date.now() + hours * 60 * 60 * 1000); - await model.upsert({ - userId, - blockedUntil, - lastDeletionType: dataType, - lastDeletedBy: deletedBy || null - }); - - return blockedUntil; -} - -async function executeDataDeletion(client, userId, dataType) { - const models = client.models['ping-protection']; - - if (['del_ping_history', 'del_all'].includes(dataType)) { - await models.PingHistory.destroy({ - where: {userId} - }); - } - - if (['del_moderation_history', 'del_all'].includes(dataType)) { - await models.ModerationLog.destroy({ - where: {victimID: userId} - }); - } - - if (dataType === 'del_all') { - await models.LeaverData.destroy({ - where: {userId} - }); - } -} - -function buildPanelMenu(userId, selected = 'overview') { - const menu = new StringSelectMenuBuilder() - .setCustomId(`ping-protection_panel-menu_${userId}`) - .setPlaceholder(localize('ping-protection', 'panel-ph')) - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel(localize('ping-protection', 'panel-opt-over')) - .setValue('overview') - .setEmoji('šŸ ') - .setDefault(selected === 'overview'), - new StringSelectMenuOptionBuilder() - .setLabel(localize('ping-protection', 'panel-opt-hist')) - .setValue('history') - .setEmoji('šŸ“œ') - .setDefault(selected === 'history'), - new StringSelectMenuOptionBuilder() - .setLabel(localize('ping-protection', 'panel-opt-actions')) - .setValue('actions') - .setEmoji('āš ļø') - .setDefault(selected === 'actions'), - new StringSelectMenuOptionBuilder() - .setLabel(localize('ping-protection', 'panel-opt-delete')) - .setValue('deletion') - .setEmoji('šŸ—‘ļø') - .setDefault(selected === 'deletion') - ); - - return new ActionRowBuilder().addComponents(menu); -} - -function buildDeletionMenu(userId) { - const menu = new StringSelectMenuBuilder() - .setCustomId(`ping-protection_delete-menu_${userId}`) - .setPlaceholder(localize('ping-protection', 'panel-deletion-placeholder')) - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel(localize('ping-protection', 'panel-opt-back')) - .setValue('back') - .setEmoji('ā—€ļø'), - new StringSelectMenuOptionBuilder() - .setLabel(localize('ping-protection', 'panel-opt-del-pings')) - .setValue('del_ping_history') - .setEmoji('šŸ“œ'), - new StringSelectMenuOptionBuilder() - .setLabel(localize('ping-protection', 'panel-opt-del-actions')) - .setValue('del_moderation_history') - .setEmoji('āš ļø'), - new StringSelectMenuOptionBuilder() - .setLabel(localize('ping-protection', 'panel-opt-del-all')) - .setValue('del_all') - .setEmoji('šŸ’„') - ); - - return new ActionRowBuilder().addComponents(menu); -} - -async function generateUserPanel(client, targetUser) { - const storageConfig = client.configurations['ping-protection']['storage']; - const retentionWeeks = storageConfig?.pingHistoryRetention || 12; - const timeframeDays = retentionWeeks * 7; - - const pingCount = await getPingCountInWindow(client, targetUser.id, timeframeDays); - const modData = await fetchModHistory(client, targetUser.id, 1, 1); - - const embed = new EmbedBuilder() - .setTitle(localize('ping-protection', 'panel-title', { - u: targetUser.tag || targetUser.username - })) - .setDescription(localize('ping-protection', 'panel-description', { - u: targetUser.toString(), - i: targetUser.id - })) - .setColor('Blue') - .setThumbnail(targetUser.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 - }]); - - safeSetFooter(embed, client); - if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); - - return { - embeds: [embed.toJSON()], - components: [buildPanelMenu(targetUser.id, 'overview').toJSON()] - }; -} - -async function generatePanelHistory(client, targetUser, page = 1) { - const storageConfig = client.configurations['ping-protection']['storage']; - const limit = 5; - const isEnabled = !!storageConfig.enablePingHistory; - - let total = 0; - let history = []; - let totalPages = 1; - - if (isEnabled) { - const data = await fetchPingHistory(client, targetUser.id, page, limit); - total = data.total; - history = data.history; - totalPages = Math.ceil(total / limit) || 1; - } - - const leaverData = await getLeaverStatus(client, targetUser.id); - 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}>`; - } - - const hasValidLink = entry.messageUrl && entry.messageUrl !== 'Blocked by AutoMod'; - const linkText = hasValidLink - ? `[${localize('ping-protection', 'label-jump')}](${entry.messageUrl})` - : localize('ping-protection', 'no-message-link'); - - return localize('ping-protection', 'list-entry-text', { - index: (page - 1) * limit + index + 1, - target: targetString, - time: timeString, - link: linkText - }); - }); - - description += lines.join('\n\n'); - } - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`ping-protection_panel-hist_${targetUser.id}_${page - 1}`) - .setLabel(localize('helpers', 'back')) - .setStyle(ButtonStyle.Primary) - .setDisabled(page <= 1), - new ButtonBuilder() - .setCustomId('ping_protection_panel_hist_count') - .setLabel(`${page}/${totalPages}`) - .setStyle(ButtonStyle.Secondary) - .setDisabled(true), - new ButtonBuilder() - .setCustomId(`ping-protection_panel-hist_${targetUser.id}_${page + 1}`) - .setLabel(localize('helpers', 'next')) - .setStyle(ButtonStyle.Primary) - .setDisabled(page >= totalPages || !isEnabled) - ); - - const embed = new EmbedBuilder() - .setTitle(localize('ping-protection', 'embed-history-title', { - u: targetUser.username - })) - .setThumbnail(targetUser.displayAvatarURL({dynamic: true})) - .setDescription(description) - .setColor('Orange'); - - safeSetFooter(embed, client); - if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); - - return { - embeds: [embed.toJSON()], - components: [ - buildPanelMenu(targetUser.id, 'history').toJSON(), - row.toJSON() - ] - }; -} - -async function generatePanelActions(client, targetUser, page = 1) { - const moderationConfig = client.configurations['ping-protection']['moderation']; - const limit = 5; - const isEnabled = moderationConfig && Array.isArray(moderationConfig) && moderationConfig.length > 0; - - const data = await fetchModHistory(client, targetUser.id, page, limit); - const total = data.total; - const history = data.history; - const totalPages = Math.ceil(total / limit) || 1; - - let description = ''; - - 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_panel-actions_${targetUser.id}_${page - 1}`) - .setLabel(localize('helpers', 'back')) - .setStyle(ButtonStyle.Primary) - .setDisabled(page <= 1), - new ButtonBuilder() - .setCustomId('ping_protection_panel_actions_count') - .setLabel(`${page}/${totalPages}`) - .setStyle(ButtonStyle.Secondary) - .setDisabled(true), - new ButtonBuilder() - .setCustomId(`ping-protection_panel-actions_${targetUser.id}_${page + 1}`) - .setLabel(localize('helpers', 'next')) - .setStyle(ButtonStyle.Primary) - .setDisabled(page >= totalPages || (!isEnabled && history.length === 0)) - ); - - const embed = new EmbedBuilder() - .setTitle(localize('ping-protection', 'embed-actions-title', { - u: targetUser.username - })) - .setThumbnail(targetUser.displayAvatarURL({dynamic: true})) - .setDescription(description) - .setColor(isEnabled ? 'Red' : 'Grey'); - - safeSetFooter(embed, client); - if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); - - return { - embeds: [embed.toJSON()], - components: [ - buildPanelMenu(targetUser.id, 'actions').toJSON(), - row.toJSON() - ] - }; -} - -async function generatePanelDeletion(client, targetUser) { - const cooldown = await getDeletionCooldown(client, targetUser.id); - - let description = localize('ping-protection', 'panel-deletion-desc', { - u: targetUser.toString(), - i: targetUser.id - }); - - if (cooldown) { - description += `\n\nāš ļø ${localize('ping-protection', 'panel-deletion-cooldown-active', { - time: formatDate(new Date(cooldown.blockedUntil)), - type: localize('ping-protection', getDeletionTypeLocaleKey(cooldown.lastDeletionType)) - })}`; - } - - const embed = new EmbedBuilder() - .setTitle(localize('ping-protection', 'panel-deletion-title', { - u: targetUser.tag || targetUser.username - })) - .setDescription(description) - .setColor('DarkRed') - .setThumbnail(targetUser.displayAvatarURL({dynamic: true})); - - safeSetFooter(embed, client); - if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); - - return { - embeds: [embed.toJSON()], - components: [buildDeletionMenu(targetUser.id).toJSON()] - }; -} - -// Sends ping warning message -async function sendPingWarning(client, message, target, moduleConfig) { - const warningMsg = moduleConfig.pingWarningMessage; - if (!warningMsg) return; - - let warnMsg = {...warningMsg}; - const placeholders = { - '%target-name%': target.name || target.tag || target.username || 'Unknown', - '%target-mention%': target.toString(), - '%target-id%': target.id, - '%pinger-id%': message.author.id - }; - - try { - const messageOptions = await embedTypeV2(warnMsg, placeholders); - - try { - return await message.reply(messageOptions); - } catch (replyError) { - client.logger.warn(localize('ping-protection', 'log-warning-reply-failed', { - e: replyError.message - })); - - try { - return await message.channel.send(messageOptions); - } catch (sendError) { - client.logger.warn(localize('ping-protection', 'log-warning-send-failed', { - c: message.channel.id, - e: sendError.message - })); - return null; - } - } - } catch (error) { - client.logger.warn(localize('ping-protection', 'log-warning-build-failed', { - e: error.message - })); - return null; - } -} - -// 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); - await guild.channels.fetch().catch((error) => { - client.logger.warn(localize('ping-protection', 'log-automod-channel-fetch-failed', { - e: error.message - })); - }); - - const rules = await guild.autoModerationRules.fetch(); - const existingRule = rules.find(r => r.name === 'Ping Protection System'); - - // Logic to disable/delete the rule - if (!config || !config.enableAutomod) { - if (existingRule) { - await existingRule.delete().catch((error) => { - client.logger.warn(localize('ping-protection', 'log-automod-rule-delete-failed', { - e: error.message - })); - }); - } - return; - } - - const keywords = []; - if (config.protectedRoles) { - config.protectedRoles.forEach(roleId => { - keywords.push(`<@&${roleId}>`); - }); - } - - const protectedIdsSet = new Set(config.protectedUsers || []); - if (config.protectAllUsersWithProtectedRole && config.protectedRoles && config.protectedRoles.length > 0) { - - // Without GuildMembers the member cache is near-empty, so enumerating it would seed the - // native AutoMod rule with an incomplete protected-user list. Skip and warn instead; the - // real-time mention-based detection does not use the member cache and is unaffected. - if ((guild.client._activeIntents || []).includes('GuildMembers')) { - guild.members.cache.forEach(member => { - if (member.roles.cache.some(r => config.protectedRoles.includes(r.id))) { - protectedIdsSet.add(member.id); - } - }); - } else { - client.logger.warn(localize('ping-protection', 'log-automod-role-protection-skipped')); - } - } - - protectedIdsSet.forEach(id => { - keywords.push(`<@${id}>`); - keywords.push(`<@!${id}>`); - }); - - if (keywords.length === 0) { - if (existingRule) { - await existingRule.delete().catch(() => { - }); - } - return; - } - - if (keywords.length > 1000) { - client.logger.warn(localize('ping-protection', 'log-automod-keyword-limit')); - keywords.splice(1000); - } - - // 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 exactIgnoredChannels = (config.ignoredChannels || []).filter(channelId => { - const channel = guild.channels.cache.get(channelId); - return channel && channel.type !== 4; - }); - - const ruleData = { - name: 'Ping Protection System', - eventType: 1, - triggerType: 1, - triggerMetadata: { - keywordFilter: keywords - }, - actions, - enabled: true, - exemptRoles: config.ignoredRoles || [], - exemptChannels: exactIgnoredChannels - }; - - if (existingRule) { - await guild.autoModerationRules.edit(existingRule.id, ruleData); - } else { - await guild.autoModerationRules.create(ruleData); - } - } catch (error) { - client.logger.error(localize('ping-protection', 'log-automod-sync-failed', { - e: error.message - })); - } -} - -// Makes the history embed -async function generateHistoryResponse(client, userId, page = 1) { - const storageConfig = client.configurations['ping-protection']['storage']; - const limit = 5; - 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}>`; - } - - const hasValidLink = entry.messageUrl && entry.messageUrl !== 'Blocked by AutoMod'; - const linkText = hasValidLink - ? `[${localize('ping-protection', 'label-jump')}](${entry.messageUrl})` - : localize('ping-protection', 'no-message-link'); - - return localize('ping-protection', 'list-entry-text', { - index: (page - 1) * limit + index + 1, - target: targetString, - time: timeString, - link: linkText - }); - }); - description += lines.join('\n\n'); - } - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`ping-protection_hist-page_${userId}_${page - 1}`) - .setLabel(localize('helpers', '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(localize('helpers', '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'); - - safeSetFooter(embed, client); - - if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); - return { - embeds: [embed.toJSON()], - components: [row.toJSON()] - }; -} - -// Makes the moderation actions history embed -async function generateActionsResponse(client, userId, page = 1) { - const moderationConfig = client.configurations['ping-protection']['moderation']; - const limit = 5; - const isEnabled = moderationConfig && Array.isArray(moderationConfig) && moderationConfig.length > 0; - - 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 (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(localize('helpers', '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(localize('helpers', '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' - ); - - safeSetFooter(embed, client); - - if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); - return { - embeds: [embed.toJSON()], - components: [row.toJSON()] - }; -} - -// Handles data deletion -async function deleteAllUserData(client, userId) { - await executeDataDeletion(client, userId, 'del_all'); - client.logger.info(localize('ping-protection', 'log-data-deletion', { - 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} - }); -} - -// Enforces data retention -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 || 12)); - 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(); - } - } -} - -// Executes moderation action -async function executeAction(client, member, rule, reason, storageConfig, originChannel = null, stats = {}) { - const actionType = rule.actionType; - - // Sends action log if enabled - const sendActionLog = async () => { - if (!rule.enableActionLogging || !originChannel) return; - - const logMsgConfig = rule.actionLogMessage; - if (!logMsgConfig) return; - let safeMsg = {...logMsgConfig}; - - const placeholders = { - '%pinger-mention%': member.toString(), - '%pinger-name%': member.user.tag, - '%action%': rule.actionType, - '%duration%': rule.muteDuration || 'N/A', - '%pings%': stats.pingCount || 'N/A', - '%timeframe%': stats.timeframeDays || 'N/A' - }; - - try { - let messageOptions = await embedTypeV2(safeMsg, placeholders); - await originChannel.send(messageOptions).catch(() => { - }); - } catch (error) { - client.logger.warn(localize('ping-protection', 'log-action-log-failed', { - e: error.message - })); - } - }; - - // Sends error message if action fails - const sendErrorLog = async (error) => { - if (!originChannel) return; - - const errorEmbed = new EmbedBuilder() - .setTitle(localize('ping-protection', 'punish-log-failed-title', { - u: member.user.tag - })) - .setDescription( - localize('ping-protection', 'punish-log-failed-desc', { - m: member.toString() - }) + - `\n${localize('ping-protection', 'punish-log-error', { - e: error.message - })}` - ) - .addFields({ - name: localize('ping-protection', 'punish-log-docs-title'), - value: localize('ping-protection', 'punish-log-docs-desc'), - inline: false - }) - .setColor('#ed4245'); - - safeSetFooter(errorEmbed, client); - if (!client.strings.disableFooterTimestamp) errorEmbed.setTimestamp(); - await originChannel.send({embeds: [errorEmbed.toJSON()]}).catch((sendError) => { - client.logger.warn(localize('ping-protection', 'log-punish-log-send-failed', { - e: sendError.message - })); - }); - }; - - if (!member) { - client.logger.debug(localize('ping-protection', 'log-not-a-member')); - return false; - } - - const botMember = await member.guild.members.fetch(client.user.id); - if (botMember.roles.highest.position <= member.roles.highest.position) { - await sendErrorLog({ - message: localize('ping-protection', 'punish-role-error', { - tag: member.user.tag - }) - }); - client.logger.warn(localize('ping-protection', 'log-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) { - client.logger.error(localize('ping-protection', 'log-modlog-create-failed', { - u: member.id, - e: dbError.message - })); - } - }; - - if (actionType === 'MUTE') { - const durationMs = rule.muteDuration * 60000; - await logDb('MUTE', rule.muteDuration); - try { - await member.timeout(durationMs, reason); - await sendActionLog(); - return true; - } catch (error) { - await sendErrorLog(error); - client.logger.warn(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); - await sendActionLog(); - return true; - } catch (error) { - await sendErrorLog(error); - client.logger.warn(localize('ping-protection', 'log-kick-error', { - tag: member.user.tag, - e: error.message - })); - return false; - } - } - return false; -} - -// Processes a ping event -async function processPing(client, userId, targetId, isRole, messageUrl, originChannel, memberToPunish) { - const config = client.configurations['ping-protection']['configuration']; - const storageConfig = client.configurations['ping-protection']['storage']; - const moderationRules = client.configurations['ping-protection']['moderation']; - - if (storageConfig?.enablePingHistory) { - try { - await addPing(client, userId, messageUrl, targetId, isRole); - } catch (e) { - client.logger.error(localize('ping-protection', 'log-ping-history-create-failed', { - u: userId, - e: e.message - })); - } - } - - if (!moderationRules || !Array.isArray(moderationRules) || moderationRules.length === 0) return; - - for (let i = moderationRules.length - 1; i >= 0; i--) { - const rule = moderationRules[i]; - - const retentionWeeks = storageConfig?.pingHistoryRetention || 12; - const timeframeDays = rule.useCustomTimeframe - ? (rule.timeframeDays || 7) - : (retentionWeeks * 7); - - const pingCount = await getPingCountInWindow(client, userId, timeframeDays); - const requiredCount = getRequiredPingCountForMember(rule, memberToPunish); - - if (requiredCount === EXEMPT_THRESHOLD) { - continue; - } - - if (typeof requiredCount !== 'number' || !Number.isFinite(requiredCount)) { - continue; - } - - if (pingCount >= requiredCount) { - const oneMinuteAgo = new Date(Date.now() - 60000); - try { - const recentLog = await client.models['ping-protection']['ModerationLog'].findOne({ - where: { - victimID: userId, - createdAt: {[Op.gt]: oneMinuteAgo} - } - }); - if (recentLog) break; - } catch (e) { - client.logger.warn(localize('ping-protection', 'log-recent-mod-check-failed', { - u: userId, - e: e.message - })); - } - - const generatedReason = rule.useCustomTimeframe - ? localize('ping-protection', 'reason-advanced', { - c: pingCount, - d: timeframeDays - }) - : localize('ping-protection', 'reason-basic', { - c: pingCount, - w: retentionWeeks - }); - - if (memberToPunish) { - const success = await executeAction( - client, - memberToPunish, - rule, - generatedReason, - storageConfig, - originChannel, - { - pingCount, - timeframeDays - } - ); - - if (success) break; - } - } - } -} - -module.exports = { - addPing, - getPingCountInWindow, - getSafeChannelId, - isWhitelistedChannel, - getRequiredPingCountForMember, - EXEMPT_THRESHOLD, - sendPingWarning, - syncNativeAutoMod, - processPing, - fetchPingHistory, - fetchModHistory, - executeAction, - deleteAllUserData, - executeDataDeletion, - getDeletionCooldown, - setDeletionCooldown, - getDeletionTypeLocaleKey, - getLeaverStatus, - markUserAsLeft, - markUserAsRejoined, - enforceRetention, - generateHistoryResponse, - generateActionsResponse, - generateUserPanel, - generatePanelHistory, - generatePanelActions, - generatePanelDeletion -}; \ No newline at end of file diff --git a/tests/ping-protection/interactionCreate.test.js b/tests/ping-protection/interactionCreate.test.js index fe20b630..93ed8821 100644 --- a/tests/ping-protection/interactionCreate.test.js +++ b/tests/ping-protection/interactionCreate.test.js @@ -28,6 +28,7 @@ const mockG = { jest.mock('../../modules/ping-protection/ping-protection', () => mockG); const handler = require('../../modules/ping-protection/events/interactionCreate'); +const {localize} = require('../../src/functions/localize'); function makeClient({ user = { @@ -172,7 +173,7 @@ describe('del-confirm modal submit', () => { test('runs a partial deletion and sets the cooldown on the correct phrase', async () => { // the stub localize returns "ping-protection.modal-phrase"; confirm must equal it - const interaction = modalInteraction('ping-protection.modal-phrase'); + const interaction = modalInteraction(localize('ping-protection', 'del-conf-phrase')); await handler.run(makeClient(), interaction); expect(mockG.executeDataDeletion).toHaveBeenCalledWith(expect.anything(), 'target', 'del_ping_history'); expect(mockG.setDeletionCooldown).toHaveBeenCalledWith(expect.anything(), 'target', 'del_ping_history', 'admin1'); diff --git a/tests/ping-protection/messageCreate.test.js b/tests/ping-protection/messageCreate.test.js index 0ce59ac9..10ee0407 100644 --- a/tests/ping-protection/messageCreate.test.js +++ b/tests/ping-protection/messageCreate.test.js @@ -9,11 +9,13 @@ */ const mockProcessPing = jest.fn().mockResolvedValue(); const mockSendWarning = jest.fn().mockResolvedValue(); +const mockDeterminePingType = jest.fn(() => 'MENTION'); const mockIsWhitelisted = jest.fn(() => false); jest.mock('../../modules/ping-protection/ping-protection', () => ({ processPing: (...a) => mockProcessPing(...a), sendPingWarning: (...a) => mockSendWarning(...a), - isWhitelistedChannel: (...a) => mockIsWhitelisted(...a) + isWhitelistedChannel: (...a) => mockIsWhitelisted(...a), + determinePingType: (...a) => mockDeterminePingType(...a) })); const handler = require('../../modules/ping-protection/events/messageCreate'); @@ -91,6 +93,7 @@ beforeEach(() => { mockProcessPing.mockClear(); mockSendWarning.mockClear(); mockIsWhitelisted.mockClear(); + mockDeterminePingType.mockClear(); mockIsWhitelisted.mockReturnValue(false); }); @@ -165,7 +168,7 @@ describe('protected ping dispatch', () => { await handler.run(client, msg); expect(mockSendWarning).toHaveBeenCalledWith(client, msg, victimUser, expect.any(Object)); expect(mockProcessPing).toHaveBeenCalledWith( - client, 'pinger', 'victim', false, 'http://msg', msg.channel, msg.member + client, 'pinger', 'victim', false, 'http://msg', msg.channel, msg.member, 'MENTION' ); }); @@ -175,7 +178,7 @@ describe('protected ping dispatch', () => { const msg = makeMessage({roles: [role]}); await handler.run(client, msg); expect(mockProcessPing).toHaveBeenCalledWith( - client, 'pinger', 'roleP', true, 'http://msg', msg.channel, msg.member + client, 'pinger', 'roleP', true, 'http://msg', msg.channel, msg.member, 'MENTION' ); });