diff --git a/locales/de.json b/locales/de.json index 320d9b3e..b368b040 100644 --- a/locales/de.json +++ b/locales/de.json @@ -256,7 +256,27 @@ "edit-xp-value-description": "Neue Erfahrungspunktemenge des Nutzers", "edit-xp-description": "Betrüge deine Community und bearbeite die Erfahrungspunkte eines Nutzers", "random-messages-enabled-but-non-configured": "Zufällige Nachrichten sind aktiviert, allerdings wurden keine zufälligen Nachrichten festgelegt. Ignoriere Anweisung.", - "granted-rewards-audit-log": "Rollen aktualisiert um sicherzustellen, dass der/die Nutzer:in die benötigten Rollen hat" + "granted-rewards-audit-log": "Rollen aktualisiert um sicherzustellen, dass der/die Nutzer:in die benötigten Rollen hat", + "rewards-command-description": "Level-Belohnungen verwalten", + "rewards-add-description": "Rollen zu einer Level-Belohnung hinzufügen", + "rewards-set-description": "Rollen für eine Level-Belohnung setzen", + "rewards-remove-description": "Eine Rolle aus einer Level-Belohnung entfernen", + "rewards-clear-description": "Alle Belohnungen für ein Level entfernen", + "rewards-list-description": "Konfigurierte Level-Belohnungen anzeigen", + "rewards-level-description": "Level zum Konfigurieren", + "rewards-role-description": "Rolle, die vergeben wird", + "rewards-replace-description": "Ersetzt vorherige ersetzbare Belohnungen", + "rewards-replace-on": "ersetzbar", + "rewards-replace-off": "behalten", + "rewards-none": "keine", + "rewards-added": "Level %l Belohnungen: %roles (%replace)", + "rewards-set": "Level %l Belohnungen gesetzt: %roles (%replace)", + "rewards-removed": "%role aus Level %l Belohnungen entfernt", + "rewards-cleared": "Belohnungen für Level %l entfernt", + "rewards-level-not-found": "Keine Belohnungen für Level %l konfiguriert", + "rewards-list-empty": "Noch keine Level-Belohnungen konfiguriert", + "rewards-list-one": "Level %l: %roles (%replace)", + "rewards-list-line": "Level %l: %roles (%replace)" }, "partner-list": { "could-not-give-role": "%u konnte keine Rolle gegeben werden", @@ -505,6 +525,7 @@ "moderate-ban-command-description": "Bannt einen Nutzer von deinem Server", "moderate-reason-description": "Grund für die Aktion", "moderate-duration-description": "Dauer der Aktion (Standard: Permanent)", + "moderate-only-target-description": "Nur auf den ausgewaehlten Account anwenden (nicht auf verknuepfte Accounts spiegeln)", "mute-max-duration": "Discord begrenzt die Höchstdauer eines Timeouts auf 28 Tage. Bitte gib einen Wert an, der niedriger oder gleich ist", "moderate-quarantine-command-description": "Versetzt einen Nurzer auf deinem Server in Quarantäne", "moderate-unquarantine-command-description": "Entfernt einen Nutzer aus der Quarantäne", @@ -526,6 +547,8 @@ "moderate-note-id-description": "ID einer deiner Notizen, die du bearbeiten willst (leer lassen, um neu zu erstellen)", "moderate-warnid-description": "ID einer Verwarnung (führe /moderate actions aus um diese herauszufinden)", "moderate-actions-command-description": "Zeigt alle Aktionen gegen einen Nutzer", + "moderate-clear-punishments-command-description": "Alle Moderationsaktionen eines Nutzers loeschen", + "moderate-clear-punishments-confirm-description": "Gib CONFIRM ein, um fortzufahren", "report-command-description": "Meldet einen Nutzer und sendet einen Ausschnitt des Chats an das Serverteam", "report-reason-description": "Bitte beschreibe was der Nutzer falsch gemacht hat", "report-user-description": "Nutzer, den du melden willst", @@ -578,10 +601,57 @@ "warning-not-found": "Verwarnung konnte nicht gefunden werden. Bitte stelle sicher, dass du eine VerwarnungsID und keine NutzerID verwendest.", "can-not-report-mod": "Du kannst Moderatoren nicht melden.", "action-description-format": "%reason\nvon %u am %t", + "action-reason-line": "> Grund: %r", + "action-by-line": "> Von: %u", + "action-at-line": "> Am: %t", + "action-expires-line": "> Laeuft ab am: %d", + "action-automod-line": "> AutoMod: %a", "no-actions-title": "Nicht gefunden", "no-actions-value": "Es wurden keine Aktionen gegen %u gefunden.", "actions-embed-title": "Mod-Aktionen gegen %u - Seite %i", "actions-embed-description": "Du kannst jede Aktion gegen %u hier sehen.", + "clear-punishments-disabled": "Strafen loeschen ist in der Konfiguration deaktiviert.", + "clear-punishments-done": "%n Aktionen fuer %u geloescht.", + "clear-punishments-confirm-required": "Bitte CONFIRM eingeben, um den Befehl auszufuehren.", + "clear-punishments-reason": "Alle Strafen geloescht", + "automod-log-line": "%d %a %r", + "moderate-actions-show-notes": "Notizen in der Akte anzeigen", + "actions-channel-not-allowed": "Dieser Befehl ist auf bestimmte Kanaele beschraenkt.", + "dossier-subtitle": "**Dies ist die Akte von %m**", + "dossier-joined": "**Gejoint:** %d", + "dossier-created": "**Account alter:** %d", + "dossier-counts": "%b **ban** %q **quarantaene** %m **mute** %w **warn**", + "dossier-separator": "----------------", + "dossier-notes-title": "**Notizen**", + "dossier-notes-empty": "Keine Notizen vorhanden.", + "dossier-linked-title": "**Verlinkte Zweitaccounts**", + "dossier-actions-title": "**Sanktionen:**", + "dossier-note-alt-inline": "**Alt-Acc %u**", + "dossier-action-alt-prefix": "-# Alt-Acc %u:", + "action-alt-line": "> -# Alt-Acc %u", + "dossier-note-line": "**#%i: %t von %author:**\n> %c%altInfo", + "action-header": "**#%i: %t**", + "action-block": "%a", + "linked-accounts-command-description": "Verknuepfte Accounts verwalten", + "linked-accounts-link-description": "Einen oder mehrere Accounts mit einem Hauptaccount verknuepfen (bis zu 5 pro Befehl)", + "linked-accounts-unlink-description": "Einen Account entkoppeln", + "linked-accounts-clear-description": "Alle Verknuepfungen fuer einen Hauptaccount entfernen", + "linked-accounts-list-description": "Verknuepfte Accounts fuer einen Nutzer anzeigen", + "linked-accounts-main-description": "Hauptaccount", + "linked-accounts-account-description": "Verknuepfter Account", + "linked-accounts-user-description": "Nutzer zum Anzeigen/Entkoppeln", + "linked-accounts-disabled": "Verknuepfte Accounts sind in der Konfiguration deaktiviert.", + "linked-accounts-no-accounts": "Bitte mindestens einen Account zum Verknuepfen angeben.", + "linked-accounts-linked": "Hauptaccount %m verknuepft mit: %a", + "linked-accounts-unlinked": "%u wurde entkoppelt", + "linked-accounts-cleared": "Verknuepfung fuer %m entfernt", + "linked-accounts-none-for-user": "Keine verknuepften Accounts fuer %u gefunden", + "linked-accounts-list": "Hauptaccount: %m | Verknuepft: %a", + "linked-accounts-log-field": "Verknuepfte Accounts", + "automod-log-field": "AutoMod Aktionen", + "linked-accounts-single-reason": "Verknuepft mit Hauptaccount %m", + "linked-accounts-none": "keine", + "unknown": "Unbekannt", "report-embed-title": "Neue Meldung", "report-embed-description": "Ein Nutzer hat einen anderen Nutzer gemeldet. Bitte bearbeite den Fall und führe, wenn nötig, Aktionen aus.", "reported-user": "Gemeldeter Nutzer", diff --git a/locales/en.json b/locales/en.json index 4cf4437e..49627816 100644 --- a/locales/en.json +++ b/locales/en.json @@ -245,7 +245,27 @@ "role-factors-total": "Multiplied together, the user receives **%f times more XP** for every message.", "edit-level-description": "Betrays your community and edits a user's levels", "random-messages-enabled-but-non-configured": "You have random messages enabled, but have non configured. Ignoring config.randomMessages configuration.", - "granted-rewards-audit-log": "Updated roles to make sure, they have the level role they need" + "granted-rewards-audit-log": "Updated roles to make sure, they have the level role they need", + "rewards-command-description": "Manage level reward roles", + "rewards-add-description": "Add roles to a level reward", + "rewards-set-description": "Set roles for a level reward", + "rewards-remove-description": "Remove a role from a level reward", + "rewards-clear-description": "Remove all rewards for a level", + "rewards-list-description": "List configured level rewards", + "rewards-level-description": "Level to configure", + "rewards-role-description": "Role to grant", + "rewards-replace-description": "Replace previous replaceable rewards", + "rewards-replace-on": "replaceable", + "rewards-replace-off": "kept", + "rewards-none": "none", + "rewards-added": "Level %l rewards: %roles (%replace)", + "rewards-set": "Level %l rewards set to: %roles (%replace)", + "rewards-removed": "Removed %role from level %l rewards", + "rewards-cleared": "Cleared rewards for level %l", + "rewards-level-not-found": "No rewards configured for level %l", + "rewards-list-empty": "No level rewards configured yet", + "rewards-list-one": "Level %l: %roles (%replace)", + "rewards-list-line": "Level %l: %roles (%replace)" }, "team-list": { "channel-not-found": "Could not find channel with ID %c or the channel has a wrong type (only text-channels supported)", @@ -560,6 +580,7 @@ "anti-grief-reason": "Too many actions of type \"%type\" in the last %h hours. Maximum amount allowed: %n", "anti-grief-user-message": "Sorry, but it seems like you are abusing your moderative powers. We've taken actions to prevent this from happening.", "moderate-duration-description": "Duration of the action (max: 28 days, default: 14 days)", + "moderate-only-target-description": "Apply only to the selected account (do not mirror to linked accounts)", "mute-max-duration": "Discord limits the maximal duration of a timeout to 28 days. Please enter an amount equal or less than this", "moderate-quarantine-command-description": "Quarantine a user on your server", "moderate-unquarantine-command-description": "Removes a user from the quarantine", @@ -583,6 +604,8 @@ "moderate-note-id-description": "ID of one of your notes you want to edit (leave blank to create a new one)", "moderate-warnid-description": "ID of a warn (run /moderate actions to get it)", "moderate-actions-command-description": "Show all recorded actions against a user", + "moderate-clear-punishments-command-description": "Clear all moderation actions for a user", + "moderate-clear-punishments-confirm-description": "Type CONFIRM to proceed", "report-command-description": "Reports a user and sends a snapshot of the chat to server staff", "report-reason-description": "Please describe what the user did wrong", "report-user-description": "User you want to report", @@ -638,10 +661,57 @@ "warning-not-found": "I could not find this warning. Please make sure you are actually using a warning-id and not a userid.", "can-not-report-mod": "You can not report moderators.", "action-description-format": "%reason\nby %u on %t", + "action-reason-line": "> Reason: %r", + "action-by-line": "> By: %u", + "action-at-line": "> At: %t", + "action-expires-line": "> Expires: %d", + "action-automod-line": "> AutoMod: %a", "no-actions-title": "None found", "no-actions-value": "No actions against %u found.", "actions-embed-title": "Mod-Actions against %u - Site %i", "actions-embed-description": "You can find every action against %u here.", + "clear-punishments-disabled": "Clear punishments is disabled in the configuration.", + "clear-punishments-done": "Cleared %n actions for %u.", + "clear-punishments-confirm-required": "Please type CONFIRM to run this command.", + "clear-punishments-reason": "Cleared all punishments", + "automod-log-line": "%d %a %r", + "moderate-actions-show-notes": "Show notes in the dossier", + "actions-channel-not-allowed": "This command is restricted to specific channels.", + "dossier-subtitle": "**This is the dossier of %m**", + "dossier-joined": "**Joined:** %d", + "dossier-created": "**Account age:** %d", + "dossier-counts": "%b **ban** %q **quarantine** %m **mute** %w **warn**", + "dossier-separator": "----------------", + "dossier-notes-title": "**Notes**", + "dossier-notes-empty": "No notes available.", + "dossier-linked-title": "**Linked accounts**", + "dossier-actions-title": "**Sanctions:**", + "dossier-note-alt-inline": "**alt account %u**", + "dossier-action-alt-prefix": "-# Alt acc %u:", + "action-alt-line": "> -# Alt acc %u", + "dossier-note-line": "**#%i: %t from %author:**\n> %c%altInfo", + "action-header": "**#%i: %t**", + "action-block": "%a", + "linked-accounts-command-description": "Manage linked accounts", + "linked-accounts-link-description": "Link one or more accounts to a main account (up to 5 per command)", + "linked-accounts-unlink-description": "Unlink a single account", + "linked-accounts-clear-description": "Clear all links for a main account", + "linked-accounts-list-description": "Show linked accounts for a user", + "linked-accounts-main-description": "Main account", + "linked-accounts-account-description": "Linked account", + "linked-accounts-user-description": "User to check/unlink", + "linked-accounts-disabled": "Linked accounts are disabled in the configuration.", + "linked-accounts-no-accounts": "Please provide at least one account to link.", + "linked-accounts-linked": "Linked main %m with: %a", + "linked-accounts-unlinked": "Unlinked %u", + "linked-accounts-cleared": "Cleared linked accounts for %m", + "linked-accounts-none-for-user": "No linked accounts found for %u", + "linked-accounts-list": "Main: %m | Linked: %a", + "linked-accounts-log-field": "Linked accounts", + "automod-log-field": "AutoMod actions", + "linked-accounts-single-reason": "Linked to main account %m", + "linked-accounts-none": "none", + "unknown": "Unknown", "report-embed-title": "New report", "report-embed-description": "A user reported another user. Please review the case and take actions if needed.", "reported-user": "Reported user", @@ -1018,4 +1088,4 @@ "owner-cannot-be-renamed": "The owner of the server (%u) cannot be renamed.", "nickname-error": "An error occurred while trying to change the nickname of %u: %e" } -} \ No newline at end of file +} diff --git a/modules/levels/commands/manage-levels.js b/modules/levels/commands/manage-levels.js index b58d69b5..21f99d69 100644 --- a/modules/levels/commands/manage-levels.js +++ b/modules/levels/commands/manage-levels.js @@ -1,6 +1,7 @@ const {registerNeededEdit} = require('../leaderboardChannel'); const {localize} = require('../../../src/functions/localize'); const {formatDiscordUserName} = require('../../../src/functions/helpers'); +const {getReplaceableRewardRoleIds, getRewardForLevel} = require('../rewards'); async function runXPAction(interaction, newXP) { const member = interaction.options.getMember('user'); @@ -26,13 +27,14 @@ async function runXPAction(interaction, newXP) { const nextLevelXp = user.level * 750 + ((user.level - 1) * 500); if (nextLevelXp <= user.xp) { user.level = user.level + 1; - if (interaction.client.configurations.levels.config.reward_roles[user.level.toString()]) { - if (interaction.client.configurations.levels.config.reward_roles[user.level.toString()]) { - for (const role of Object.values(interaction.client.configurations.levels.config.reward_roles)) { + const rewardConfig = getRewardForLevel(interaction.client, user.level); + if (rewardConfig) { + if (rewardConfig.replacePrevious) { + for (const role of getReplaceableRewardRoleIds(interaction.client)) { if (member.roles.cache.has(role)) member.roles.remove(role, '[levels] ' + localize('levels', 'granted-rewards-audit-log')).catch(); } } - member.roles.add(interaction.client.configurations.levels.config.reward_roles[user.level.toString()]); + member.roles.add(rewardConfig.roles); } runXPCheck(); } @@ -81,13 +83,14 @@ async function runLevelAction(interaction, newLevel) { content: '⚠️ ' + localize('levels', 'negative-level') }); user.xp = (user.level - 1) * 750 + ((user.level - 2) * 500); - if (interaction.client.configurations.levels.config.reward_roles[user.level.toString()]) { - if (interaction.client.configurations.levels.config.reward_roles[user.level.toString()]) { - for (const role of Object.values(interaction.client.configurations.levels.config.reward_roles)) { + const rewardConfig = getRewardForLevel(interaction.client, user.level); + if (rewardConfig) { + if (rewardConfig.replacePrevious) { + for (const role of getReplaceableRewardRoleIds(interaction.client)) { if (member.roles.cache.has(role)) member.roles.remove(role, '[levels] ' + localize('levels', 'granted-rewards-audit-log')).catch(); } } - member.roles.add(interaction.client.configurations.levels.config.reward_roles[user.level.toString()]); + member.roles.add(rewardConfig.roles); } @@ -349,4 +352,4 @@ module.exports.config = { } return array; } -}; \ No newline at end of file +}; diff --git a/modules/levels/configs/reward-roles.json b/modules/levels/configs/reward-roles.json new file mode 100644 index 00000000..594ae7c3 --- /dev/null +++ b/modules/levels/configs/reward-roles.json @@ -0,0 +1,60 @@ +{ + "description": { + "en": "Configure reward roles per level", + "de": "Belohnungsrollen pro Level konfigurieren" + }, + "humanName": { + "en": "Reward roles", + "de": "Belohnungsrollen" + }, + "filename": "reward-roles.json", + "configElements": true, + "content": [ + { + "name": "level", + "humanName": { + "en": "Level", + "de": "Level" + }, + "default": { + "en": "" + }, + "description": { + "en": "Level at which the reward should be granted", + "de": "Level, bei dem die Belohnung vergeben wird" + }, + "type": "integer" + }, + { + "name": "roles", + "humanName": { + "en": "Reward roles", + "de": "Belohnungsrollen" + }, + "default": { + "en": [] + }, + "description": { + "en": "Roles that should be granted at this level", + "de": "Rollen, die bei diesem Level vergeben werden" + }, + "type": "array", + "content": "roleID" + }, + { + "name": "replacePrevious", + "humanName": { + "en": "Replace previous reward roles", + "de": "Vorherige Belohnungsrollen ersetzen" + }, + "default": { + "en": false + }, + "description": { + "en": "If enabled, previous reward roles will be removed when this reward is granted", + "de": "Wenn aktiviert, werden vorherige Belohnungsrollen entfernt" + }, + "type": "boolean" + } + ] +} diff --git a/modules/levels/events/messageCreate.js b/modules/levels/events/messageCreate.js index c9cd2803..46a34b52 100644 --- a/modules/levels/events/messageCreate.js +++ b/modules/levels/events/messageCreate.js @@ -6,6 +6,7 @@ const { } = require('../../../src/functions/helpers'); const {registerNeededEdit} = require('../leaderboardChannel'); const {localize} = require('../../../src/functions/localize'); +const {getReplaceableRewardRoleIds, getRewardForLevel} = require('../rewards'); const cooldown = new Set(); let currentlyLevelingUp = []; @@ -57,7 +58,8 @@ module.exports.run = async (client, msg) => { const channel = client.channels.cache.find(c => c.id === moduleConfig.level_up_channel_id); const specialMessage = client.configurations['levels']['special-levelup-messages'].find(m => m.level === user.level); - const isRewardMessage = !!moduleConfig.reward_roles[user.level.toString()]; + const rewardConfig = getRewardForLevel(client, user.level); + const isRewardMessage = !!rewardConfig; const randomMessages = client.configurations['levels']['random-levelup-messages'].filter(m => m.type === (isRewardMessage ? 'with-reward' : 'normal')); let messageToSend = moduleStrings.level_up_message; @@ -68,13 +70,15 @@ module.exports.run = async (client, msg) => { else if (randomMessages.length !== 0) messageToSend = randomElementFromArray(randomMessages).message; } - if (isRewardMessage) { - if (moduleConfig.onlyTopLevelRole) { - for (const role of Object.values(moduleConfig.reward_roles)) { - if (msg.member.roles.cache.has(role)) await msg.member.roles.remove(role, '[levels] ' + localize('levels', 'granted-rewards-audit-log')).catch(); + if (rewardConfig) { + if (rewardConfig.replacePrevious) { + for (const role of getReplaceableRewardRoleIds(client)) { + if (msg.member.roles.cache.has(role)) { + await msg.member.roles.remove(role, '[levels] ' + localize('levels', 'granted-rewards-audit-log')).catch(); + } } } - await msg.member.roles.add(moduleConfig.reward_roles[user.level.toString()], '[levels]' + localize('levels', 'granted-rewards-audit-log')).catch(); + await msg.member.roles.add(rewardConfig.roles, '[levels]' + localize('levels', 'granted-rewards-audit-log')).catch(); } if (specialMessage) messageToSend = specialMessage.message; @@ -83,7 +87,7 @@ module.exports.run = async (client, msg) => { '%avatarURL%': msg.author.avatarURL() || msg.author.defaultAvatarURL, '%username%': msg.author.username, '%newLevel%': user.level, - '%role%': isRewardMessage ? `<@&${moduleConfig.reward_roles[user.level.toString()]}>` : localize('levels', 'no-role'), + '%role%': rewardConfig ? rewardConfig.roles.map(r => `<@&${r}>`).join(', ') : localize('levels', 'no-role'), '%tag%': formatDiscordUserName(msg.author) }, {allowedMentions: {parse: ['users']}})); currentlyLevelingUp = currentlyLevelingUp.filter(f => f !== msg.author.id); @@ -105,4 +109,4 @@ module.exports.run = async (client, msg) => { cooldown.delete(msg.author.id); }, moduleConfig.cooldown); await user.save(); -}; \ No newline at end of file +}; diff --git a/modules/levels/module.json b/modules/levels/module.json index 4fbb00c6..ca46531b 100644 --- a/modules/levels/module.json +++ b/modules/levels/module.json @@ -14,6 +14,7 @@ "models-dir": "/models", "config-example-files": [ "configs/config.json", + "configs/reward-roles.json", "configs/strings.json", "configs/random-levelup-messages.json", "configs/special-levelup-messages.json" @@ -25,4 +26,4 @@ "en": "Easy to use levelsystem with a lot of customization!", "de": "Einfaches Level-System mit vielen Anpassungsmöglichkeiten!" } -} \ No newline at end of file +} diff --git a/modules/levels/rewards.js b/modules/levels/rewards.js new file mode 100644 index 00000000..714be139 --- /dev/null +++ b/modules/levels/rewards.js @@ -0,0 +1,45 @@ +function getRewardEntries(client) { + const rewardEntries = client.configurations?.levels?.['reward-roles']; + return Array.isArray(rewardEntries) ? rewardEntries : []; +} + +function getReplaceableRewardRoleIds(client) { + const moduleConfig = client.configurations['levels']['config']; + const rewardEntries = getRewardEntries(client); + const roles = new Set(); + if (rewardEntries.length !== 0) { + for (const entry of rewardEntries) { + if (!entry.replacePrevious) continue; + if (!Array.isArray(entry.roles)) continue; + for (const roleId of entry.roles) roles.add(roleId); + } + } else if (moduleConfig.reward_roles) { + for (const roleId of Object.values(moduleConfig.reward_roles)) roles.add(roleId); + } + return [...roles]; +} + +function getRewardForLevel(client, level) { + const moduleConfig = client.configurations['levels']['config']; + const rewardEntries = getRewardEntries(client); + const entry = rewardEntries.find(r => parseInt(r.level) === level); + if (entry) { + const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : []; + if (roles.length === 0) return null; + return { + roles, + replacePrevious: !!entry.replacePrevious + }; + } + const legacyRole = moduleConfig.reward_roles ? moduleConfig.reward_roles[level.toString()] : null; + if (!legacyRole) return null; + return { + roles: [legacyRole], + replacePrevious: !!moduleConfig.onlyTopLevelRole + }; +} + +module.exports = { + getReplaceableRewardRoleIds, + getRewardForLevel +}; diff --git a/modules/moderation/commands/moderate.js b/modules/moderation/commands/moderate.js index ea32e192..ce60c7d7 100644 --- a/modules/moderation/commands/moderate.js +++ b/modules/moderation/commands/moderate.js @@ -4,6 +4,7 @@ const { sendMultipleSiteButtonMessage, truncate, formatDiscordUserName } = require('../../../src/functions/helpers'); const {moderationAction} = require('../moderationActions'); +const {getLinkedGroup, linkAccounts, unlinkAccount, unlinkGroup} = require('../linkedAccounts'); const durationParser = require('parse-duration'); const {MessageEmbed} = require('discord.js'); const {Op} = require('sequelize'); @@ -11,7 +12,16 @@ let guildBanCache; module.exports.beforeSubcommand = async function (interaction) { if (interaction.options.getUser('user')) { - interaction.memberToExecuteUpon = interaction.options.getMember('user'); + const targetUser = interaction.options.getUser('user'); + const sub = interaction.options.getSubcommand(false); + const group = interaction.options.getSubcommandGroup(false); + if (sub === 'actions' || sub === 'clear-punishments' || group === 'notes') { + interaction.memberToExecuteUpon = interaction.guild.members.cache.get(targetUser.id) || { + user: targetUser, + id: targetUser.id, + notFound: true + }; + } else interaction.memberToExecuteUpon = interaction.options.getMember('user'); if (!interaction.memberToExecuteUpon) { if (interaction.options['_subcommand'] !== 'ban') return interaction.reply({ ephemeral: true, @@ -20,8 +30,8 @@ module.exports.beforeSubcommand = async function (interaction) { else { interaction.userNotOnServer = true; interaction.memberToExecuteUpon = { - user: interaction.options.getUser('user'), - id: interaction.options.getUser('user').id, + user: targetUser, + id: targetUser.id, notFound: true }; } @@ -65,6 +75,31 @@ async function fetchNotesUser(interaction) { return notesUser; } +function collectLinkedUsers(interaction) { + const users = [ + interaction.options.getUser('account'), + interaction.options.getUser('account2'), + interaction.options.getUser('account3'), + interaction.options.getUser('account4'), + interaction.options.getUser('account5') + ].filter(Boolean); + const unique = new Map(); + for (const user of users) unique.set(user.id, user); + return Array.from(unique.values()); +} + +function formatUserMentions(userIDs) { + if (!userIDs || userIDs.length === 0) return localize('moderation', 'linked-accounts-none'); + return userIDs.map(id => `<@${id}>`).join(' '); +} + +function formatNoteAuthor(userID, interaction) { + const user = (interaction.guild.members.cache.get(userID) || {user: {tag: userID}}).user; + let name = formatDiscordUserName(user); + if (name.startsWith('@')) name = name.slice(1); + return name; +} + module.exports.subcommands = { 'notes': { 'view': async function (interaction) { @@ -176,14 +211,98 @@ module.exports.subcommands = { }); } }, + 'accounts': { + 'link': async function (interaction) { + if (!checkRoles(interaction, 3)) return; + const config = interaction.client.configurations['moderation']['config']; + if (!config['linked_accounts_enabled']) return interaction.editReply({ + content: '⚠️ ' + localize('moderation', 'linked-accounts-disabled') + }); + const main = interaction.options.getUser('main', true); + const accounts = collectLinkedUsers(interaction); + if (accounts.length === 0) return interaction.editReply({ + content: '⚠️ ' + localize('moderation', 'linked-accounts-no-accounts') + }); + const userIDs = [main.id, ...accounts.map(a => a.id)]; + await linkAccounts(interaction.client, main.id, userIDs, interaction.user.id); + + if (config['linked_accounts_mode'] === 'single') { + const actionType = config['linked_accounts_single_action']; + if (actionType && actionType !== 'none') { + for (const account of accounts) { + if (account.id === main.id) continue; + let member = await interaction.guild.members.fetch(account.id).catch(() => null); + if (!member && actionType !== 'ban') continue; + if (!member && actionType === 'ban') member = {id: account.id, notFound: true, user: {id: account.id, tag: account.id}}; + let additionalData = {}; + if (actionType === 'quarantine' && member.roles) { + additionalData = {roles: Array.from(member.roles.cache.keys())}; + } + await moderationAction(interaction.client, actionType, interaction.member, member, localize('moderation', 'linked-accounts-single-reason', {m: formatDiscordUserName(main)}), additionalData); + } + } + } + + return interaction.editReply({ + content: localize('moderation', 'linked-accounts-linked', { + m: `<@${main.id}>`, + a: formatUserMentions(accounts.map(a => a.id)) + }) + }); + }, + 'unlink': async function (interaction) { + if (!checkRoles(interaction, 3)) return; + const config = interaction.client.configurations['moderation']['config']; + if (!config['linked_accounts_enabled']) return interaction.editReply({ + content: '⚠️ ' + localize('moderation', 'linked-accounts-disabled') + }); + const user = interaction.options.getUser('user', true); + await unlinkAccount(interaction.client, user.id); + return interaction.editReply({ + content: localize('moderation', 'linked-accounts-unlinked', {u: `<@${user.id}>`}) + }); + }, + 'clear': async function (interaction) { + if (!checkRoles(interaction, 3)) return; + const config = interaction.client.configurations['moderation']['config']; + if (!config['linked_accounts_enabled']) return interaction.editReply({ + content: '⚠️ ' + localize('moderation', 'linked-accounts-disabled') + }); + const main = interaction.options.getUser('main', true); + await unlinkGroup(interaction.client, main.id); + return interaction.editReply({ + content: localize('moderation', 'linked-accounts-cleared', {m: `<@${main.id}>`}) + }); + }, + 'list': async function (interaction) { + if (!checkRoles(interaction, 3)) return; + const config = interaction.client.configurations['moderation']['config']; + if (!config['linked_accounts_enabled']) return interaction.editReply({ + content: '⚠️ ' + localize('moderation', 'linked-accounts-disabled') + }); + const user = interaction.options.getUser('user', true); + const group = await getLinkedGroup(interaction.client, user.id); + if (!group) return interaction.editReply({ + content: localize('moderation', 'linked-accounts-none-for-user', {u: `<@${user.id}>`}) + }); + const linked = group.userIDs.filter(id => id !== user.id); + return interaction.editReply({ + content: localize('moderation', 'linked-accounts-list', { + m: `<@${group.mainID}>`, + a: formatUserMentions(linked) + }) + }); + } + }, 'ban': function (interaction) { if (interaction.replied) return; if (!interaction.userNotOnServer) if (!checkRoles(interaction, 4)) return; + const disableLinkedMirror = !!interaction.options.getBoolean('only-target') && interaction.client.configurations['moderation']['config']['linked_accounts_enabled']; const parseDuration = interaction.options.getString('duration') ? new Date(new Date().getTime() + durationParser(interaction.options.getString('duration'))) : null; if (interaction.options.getInteger('days')) if (interaction.options.getInteger('days') < 0 || interaction.options.getInteger('days') > 7) return interaction.editReply({ content: '⚠️ ' + localize('moderation', 'invalid-days') }); - moderationAction(interaction.client, 'ban', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {days: interaction.options.getInteger('days')}, parseDuration, interaction.options.getAttachment('proof')).then(r => { + moderationAction(interaction.client, 'ban', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {days: interaction.options.getInteger('days')}, parseDuration, interaction.options.getAttachment('proof'), {disableLinkedMirror}).then(r => { guildBanCache = null; if (r) { if (parseDuration) interaction.editReply({ @@ -203,7 +322,8 @@ module.exports.subcommands = { 'unban': async function (interaction) { if (interaction.replied) return; if (!checkRoles(interaction, 4)) return; - moderationAction(interaction.client, 'unban', interaction.member, interaction.options.getString('id'), interaction.options.getString('reason')).then(r => { + const disableLinkedMirror = !!interaction.options.getBoolean('only-target') && interaction.client.configurations['moderation']['config']['linked_accounts_enabled']; + moderationAction(interaction.client, 'unban', interaction.member, interaction.options.getString('id'), interaction.options.getString('reason'), {}, null, null, {disableLinkedMirror}).then(r => { guildBanCache = null; if (r) interaction.editReply({ content: localize('moderation', 'action-done', {i: r.actionID}) @@ -228,8 +348,11 @@ module.exports.subcommands = { 'quarantine': function (interaction) { if (interaction.replied) return; if (!checkRoles(interaction, 3)) return; + const disableLinkedMirror = !!interaction.options.getBoolean('only-target') && interaction.client.configurations['moderation']['config']['linked_accounts_enabled']; const parseDuration = interaction.options.getString('duration') ? new Date(new Date().getTime() + durationParser(interaction.options.getString('duration'))) : null; - moderationAction(interaction.client, 'quarantine', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {roles: Array.from(interaction.options.getMember('user').roles.cache.keys())}, parseDuration).then(r => { + const quarantineRoleId = interaction.client.configurations['moderation']['config']['quarantine-role-id']; + const roles = Array.from(interaction.memberToExecuteUpon.roles.cache.keys()).filter(r => r !== quarantineRoleId); + moderationAction(interaction.client, 'quarantine', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {roles}, parseDuration, null, {disableLinkedMirror}).then(r => { if (r) { if (parseDuration) interaction.editReply({ content: localize('moderation', 'expiring-action-done', { @@ -248,6 +371,7 @@ module.exports.subcommands = { 'unquarantine': async function (interaction) { if (interaction.replied) return; if (!checkRoles(interaction, 3)) return; + const disableLinkedMirror = !!interaction.options.getBoolean('only-target') && interaction.client.configurations['moderation']['config']['linked_accounts_enabled']; const lastAction = await interaction.client.models['moderation']['ModerationAction'].findOne({ where: { victimID: interaction.memberToExecuteUpon.user.id, @@ -260,7 +384,7 @@ module.exports.subcommands = { content: '⚠️ ' + localize('moderation', 'no-quarantine-action-found') }); if (!(lastAction.additionalData.roles instanceof Array)) lastAction.additionalData.roles = []; - moderationAction(interaction.client, 'unquarantine', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {roles: lastAction.additionalData.roles || []}).then(r => { + moderationAction(interaction.client, 'unquarantine', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {roles: lastAction.additionalData.roles || []}, null, null, {disableLinkedMirror}).then(r => { if (r) { interaction.editReply({content: localize('moderation', 'action-done', {i: r.actionID})}); } else interaction.editReply({content: '⚠️ ' + r}); @@ -271,7 +395,8 @@ module.exports.subcommands = { 'kick': async function (interaction) { if (interaction.replied) return; if (!checkRoles(interaction, 3)) return; - moderationAction(interaction.client, 'kick', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {}, null, interaction.options.getAttachment('proof')).then(r => { + const disableLinkedMirror = !!interaction.options.getBoolean('only-target') && interaction.client.configurations['moderation']['config']['linked_accounts_enabled']; + moderationAction(interaction.client, 'kick', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {}, null, interaction.options.getAttachment('proof'), {disableLinkedMirror}).then(r => { if (r) interaction.editReply({ content: localize('moderation', 'action-done', {i: r.actionID}) }); @@ -283,12 +408,13 @@ module.exports.subcommands = { 'mute': async function (interaction) { if (interaction.replied) return; if (!checkRoles(interaction, 2)) return; + const disableLinkedMirror = !!interaction.options.getBoolean('only-target') && interaction.client.configurations['moderation']['config']['linked_accounts_enabled']; const parseDuration = new Date(new Date().getTime() + durationParser(interaction.options.getString('duration'))); if (durationParser(interaction.options.getString('duration')) > 2419200000) return interaction.editReply({ ephemeral: true, content: '⚠️ ' + localize('moderation', 'mute-max-duration') }); - moderationAction(interaction.client, 'mute', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {}, parseDuration, interaction.options.getAttachment('proof')).then(r => { + moderationAction(interaction.client, 'mute', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {}, parseDuration, interaction.options.getAttachment('proof'), {disableLinkedMirror}).then(r => { if (r) interaction.editReply({ content: localize('moderation', 'action-done', {i: r.actionID}) }); @@ -300,7 +426,8 @@ module.exports.subcommands = { 'unmute': async function (interaction) { if (interaction.replied) return; if (!checkRoles(interaction, 2)) return; - moderationAction(interaction.client, 'unmute', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason')).then(r => { + const disableLinkedMirror = !!interaction.options.getBoolean('only-target') && interaction.client.configurations['moderation']['config']['linked_accounts_enabled']; + moderationAction(interaction.client, 'unmute', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {}, null, null, {disableLinkedMirror}).then(r => { if (r) interaction.editReply({ content: localize('moderation', 'action-done', {i: r.actionID}) }); @@ -312,7 +439,8 @@ module.exports.subcommands = { 'warn': async function (interaction) { if (interaction.replied) return; if (!checkRoles(interaction, 1)) return; - moderationAction(interaction.client, 'warn', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {}, null, interaction.options.getAttachment('proof')).then(r => { + const disableLinkedMirror = !!interaction.options.getBoolean('only-target') && interaction.client.configurations['moderation']['config']['linked_accounts_enabled']; + moderationAction(interaction.client, 'warn', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {}, null, interaction.options.getAttachment('proof'), {disableLinkedMirror}).then(r => { if (r) interaction.editReply({ content: localize('moderation', 'action-done', {i: r.actionID}) }); @@ -324,7 +452,8 @@ module.exports.subcommands = { 'channel-mute': async function (interaction) { if (interaction.replied) return; if (!checkRoles(interaction, 2)) return; - moderationAction(interaction.client, 'channel-mute', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {channel: interaction.channel}, null, interaction.options.getAttachment('proof')).then(r => { + const disableLinkedMirror = !!interaction.options.getBoolean('only-target') && interaction.client.configurations['moderation']['config']['linked_accounts_enabled']; + moderationAction(interaction.client, 'channel-mute', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {channel: interaction.channel}, null, interaction.options.getAttachment('proof'), {disableLinkedMirror}).then(r => { if (r) interaction.editReply({ content: localize('moderation', 'action-done', {i: r.actionID}) }); @@ -336,7 +465,8 @@ module.exports.subcommands = { 'remove-channel-mute': async function (interaction) { if (interaction.replied) return; if (!checkRoles(interaction, 2)) return; - moderationAction(interaction.client, 'unchannel-mute', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {channel: interaction.channel}).then(r => { + const disableLinkedMirror = !!interaction.options.getBoolean('only-target') && interaction.client.configurations['moderation']['config']['linked_accounts_enabled']; + moderationAction(interaction.client, 'unchannel-mute', interaction.member, interaction.memberToExecuteUpon, interaction.options.getString('reason'), {channel: interaction.channel}, null, null, {disableLinkedMirror}).then(r => { if (r) interaction.editReply({ content: localize('moderation', 'action-done', {i: r.actionID}) }); @@ -373,57 +503,180 @@ module.exports.subcommands = { 'actions': async function (interaction) { if (interaction.replied) return; if (!checkRoles(interaction, 1)) return; + const moduleConfig = interaction.client.configurations['moderation']['config']; + if (moduleConfig['actions_restrict_channels']) { + const allowed = moduleConfig['actions_allowed_channels'] || []; + if (!allowed.includes(interaction.channel.id)) { + return interaction.editReply({ + content: '⚠️ ' + localize('moderation', 'actions-channel-not-allowed') + }); + } + } + const targetMember = interaction.memberToExecuteUpon; + const targetUser = interaction.memberToExecuteUpon.user; + let linkedGroup = null; + if (moduleConfig['linked_accounts_enabled']) { + linkedGroup = await getLinkedGroup(interaction.client, targetUser.id); + } + const includeAltActions = !!moduleConfig['dossier_include_alt_actions']; + const victimIDs = (linkedGroup && includeAltActions) ? linkedGroup.userIDs : [targetUser.id]; const actions = await interaction.client.models['moderation']['ModerationAction'].findAll({ - where: { - victimID: interaction.memberToExecuteUpon.id - }, + where: {victimID: victimIDs}, order: [['createdAt', 'DESC']] }); - const sites = []; - let fieldCount = 0; - let fieldCache = []; - actions.forEach(action => { - fieldCount++; - fieldCache.push({ - name: `#${action.actionID}: ${action.type}`, - value: localize('moderation', 'action-description-format', { - reason: action.reason, - u: action.memberID, - t: dateToDiscordTimestamp(new Date(action.createdAt)) - }) - }); - if (fieldCount % 3 === 0) { - addSite(fieldCache); - fieldCache = []; + const autoModBatchIds = new Set( + actions + .filter(a => a.type === 'warn' && a.additionalData && a.additionalData.autoModBatchId) + .map(a => a.additionalData.autoModBatchId) + ); + const visibleActions = actions.filter(a => { + if (a.additionalData && a.additionalData.autoModBatchId && a.type !== 'warn') { + return !autoModBatchIds.has(a.additionalData.autoModBatchId); } + return true; }); - if (fieldCache.length !== 0) addSite(fieldCache); - if (sites.length === 0) addSite([{ - name: localize('moderation', 'no-actions-title'), - value: localize('moderation', 'no-actions-title', {u: formatDiscordUserName(interaction.memberToExecuteUpon.user)}) - }]); + const joinedAt = (targetMember && targetMember.joinedAt) ? dateToDiscordTimestamp(new Date(targetMember.joinedAt), 'D') : localize('moderation', 'unknown'); + const createdAt = targetUser.createdAt ? dateToDiscordTimestamp(new Date(targetUser.createdAt), 'D') : localize('moderation', 'unknown'); + const counts = { + ban: actions.filter(a => a.type === 'ban').length, + quarantine: actions.filter(a => a.type === 'quarantine').length, + mute: actions.filter(a => a.type === 'mute').length, + warn: actions.filter(a => a.type === 'warn').length + }; + const notesLines = []; + const notesLimit = 10; + const showNotes = moduleConfig['dossier_show_notes'] && (!moduleConfig['dossier_notes_require_opt_in'] || interaction.options.getBoolean('show-notes')); + if (showNotes) { + const notesRecord = await interaction.client.models['moderation']['UserNotes'].findOne({ + where: {userID: targetUser.id} + }); + const notes = (notesRecord ? notesRecord.notes : []).filter(n => n.content && n.content !== '[deleted]').sort((a, b) => b.lastUpdateAt - a.lastUpdateAt); + for (const note of notes) { + if (notesLines.length >= notesLimit) break; + notesLines.push(localize('moderation', 'dossier-note-line', { + i: note.id, + t: dateToDiscordTimestamp(new Date(note.lastUpdateAt), 'R'), + author: `<@${note.authorID}>`, + c: note.content.replaceAll('\n', ' '), + altInfo: '' + })); + } + } + + const showLinkedAccounts = moduleConfig['dossier_show_linked_accounts'] && showNotes; + let linkedText = null; + if (linkedGroup && showLinkedAccounts) { + const linked = linkedGroup.userIDs.filter(id => id !== targetUser.id); + if (linked.length !== 0) linkedText = formatUserMentions(linked); + } + if (linkedGroup && showNotes && moduleConfig['dossier_include_alt_notes']) { + const linked = linkedGroup.userIDs.filter(id => id !== targetUser.id); + for (const linkedID of linked) { + if (notesLines.length >= notesLimit) break; + const linkedNotes = await interaction.client.models['moderation']['UserNotes'].findOne({ + where: {userID: linkedID} + }); + const ln = (linkedNotes ? linkedNotes.notes : []).filter(n => n.content && n.content !== '[deleted]').sort((a, b) => b.lastUpdateAt - a.lastUpdateAt); + for (const note of ln) { + if (notesLines.length >= notesLimit) break; + notesLines.push(localize('moderation', 'dossier-note-line', { + i: note.id, + t: dateToDiscordTimestamp(new Date(note.lastUpdateAt), 'R'), + author: `<@${note.authorID}>`, + c: note.content.replaceAll('\n', ' '), + altInfo: `\n> ${localize('moderation', 'dossier-note-alt-inline', {u: `<@${linkedID}>`})}` + })); + } + } + } + const lines = [ + localize('moderation', 'dossier-subtitle', {u: formatDiscordUserName(targetUser), m: `<@${targetUser.id}>`}), + localize('moderation', 'dossier-joined', {d: joinedAt}), + localize('moderation', 'dossier-created', {d: createdAt}), + localize('moderation', 'dossier-counts', { + b: counts.ban, + q: counts.quarantine, + m: counts.mute, + w: counts.warn + }) + ]; + + if (showLinkedAccounts) { + lines.push(localize('moderation', 'dossier-separator')); + lines.push(localize('moderation', 'dossier-linked-title')); + if (linkedText) lines.push(linkedText); + else lines.push(localize('moderation', 'linked-accounts-none')); + } + + if (showNotes) { + lines.push(localize('moderation', 'dossier-separator')); + lines.push(localize('moderation', 'dossier-notes-title')); + if (notesLines.length === 0) lines.push(localize('moderation', 'dossier-notes-empty')); + else lines.push(...notesLines); + } + if (visibleActions.length === 0) { + lines.push(localize('moderation', 'dossier-separator')); + lines.push(localize('moderation', 'no-actions-value', {u: `<@${interaction.memberToExecuteUpon.user.id}>`})); + } else { + lines.push(localize('moderation', 'dossier-separator')); + lines.push(localize('moderation', 'dossier-actions-title')); + for (const action of visibleActions) { + const isAlt = action.victimID !== targetUser.id; + const actionLines = [ + localize('moderation', 'action-header', {i: action.actionID, t: action.type}), + localize('moderation', 'action-reason-line', {r: action.reason}), + localize('moderation', 'action-by-line', {u: action.memberID ? `<@${action.memberID}>` : localize('moderation', 'unknown')}), + localize('moderation', 'action-at-line', {t: dateToDiscordTimestamp(new Date(action.createdAt))}) + ]; + if (action.expiresOn) actionLines.push(localize('moderation', 'action-expires-line', {d: dateToDiscordTimestamp(new Date(action.expiresOn))})); + if (action.type === 'warn' && action.additionalData && action.additionalData.autoModActions && action.additionalData.autoModActions.length > 0) { + const autoMods = action.additionalData.autoModActions.map((entry) => { + if (typeof entry === 'string') return entry; + const d = entry.duration || localize('moderation', 'unknown'); + return localize('moderation', 'automod-log-line', {d, a: entry.type, r: entry.reason || ''}).trim(); + }); + actionLines.push(localize('moderation', 'action-automod-line', {a: autoMods.join(' | ')})); + } + if (isAlt) actionLines.push(localize('moderation', 'action-alt-line', {u: `<@${action.victimID}>`})); + lines.push(localize('moderation', 'action-block', {a: actionLines.join('\n')})); + } + lines.push(localize('moderation', 'dossier-separator')); + } + + const descriptionPages = []; + const maxLen = 1400; + let buffer = ''; + for (const line of lines) { + const add = (buffer.length === 0 ? line : `\n${line}`); + if ((buffer + add).length > maxLen) { + descriptionPages.push(buffer); + buffer = line; + } else buffer += add; + } + if (buffer.length !== 0) descriptionPages.push(buffer); + if (descriptionPages.length === 0) descriptionPages.push(localize('moderation', 'no-actions-value', {u: `<@${interaction.memberToExecuteUpon.user.id}>`})); /** * Adds a new site * @private * @param fs */ - function addSite(fs) { + function addSite(description, index, total) { const embed = new MessageEmbed() .setColor('YELLOW') .setAuthor({name: interaction.client.user.username, iconURL: interaction.client.user.avatarURL()}) .setTitle(localize('moderation', 'actions-embed-title', { u: formatDiscordUserName(interaction.memberToExecuteUpon.user), - i: sites.length + 1 + i: index + 1 })) - .setDescription(localize('moderation', 'actions-embed-description', {u: formatDiscordUserName(interaction.memberToExecuteUpon.user)})) + .setDescription(description) .setThumbnail(interaction.memberToExecuteUpon.user.avatarURL()) - .setFooter({text: interaction.client.strings.footer, iconURL: interaction.client.strings.footerImgUrl}) - .addFields(fs); - sites.push(embed); + .setFooter({text: interaction.client.strings.footer, iconURL: interaction.client.strings.footerImgUrl}); + return embed; } - sendMultipleSiteButtonMessage(interaction.channel, sites, [interaction.user.id], interaction); + const embedSites = descriptionPages.map((d, i) => addSite(d, i, descriptionPages.length)); + sendMultipleSiteButtonMessage(interaction.channel, embedSites, [interaction.user.id], interaction); }, 'revoke-warn': async function (interaction) { if (interaction.replied) return; @@ -449,6 +702,59 @@ module.exports.subcommands = { interaction.editReply({content: '⚠️ ' + r}); }); } + , + 'clear-punishments': async function (interaction) { + if (interaction.replied) return; + if (!checkRoles(interaction, 4)) return; + const moduleConfig = interaction.client.configurations['moderation']['config']; + if (!moduleConfig['debug_clear_punishments_enabled']) { + return interaction.editReply({ + content: '⚠️ ' + localize('moderation', 'clear-punishments-disabled') + }); + } + const confirm = interaction.options.getString('confirm', true); + if (confirm !== 'CONFIRM') { + return interaction.editReply({ + content: '⚠️ ' + localize('moderation', 'clear-punishments-confirm-required') + }); + } + const targetUser = interaction.options.getUser('user', true); + const targetMember = interaction.memberToExecuteUpon && !interaction.memberToExecuteUpon.notFound + ? interaction.memberToExecuteUpon + : null; + const reason = localize('moderation', 'clear-punishments-reason'); + const quarantineRoleId = moduleConfig['quarantine-role-id']; + + if (targetMember) { + if (targetMember.isCommunicationDisabled()) { + await moderationAction(interaction.client, 'unmute', interaction.member, targetMember, reason, {}, null, null, {suppressLog: true}); + } + if (quarantineRoleId && targetMember.roles.cache.get(quarantineRoleId)) { + const lastAction = await interaction.client.models['moderation']['ModerationAction'].findOne({ + where: { + victimID: targetUser.id, + type: 'quarantine' + }, + order: [['createdAt', 'DESC']] + }); + const roles = (lastAction && lastAction.additionalData && lastAction.additionalData.roles instanceof Array) + ? lastAction.additionalData.roles + : []; + await moderationAction(interaction.client, 'unquarantine', interaction.member, targetMember, reason, {roles}, null, null, {suppressLog: true}); + } + } + + await moderationAction(interaction.client, 'unban', interaction.member, targetUser.id, reason, {}, null, null, {suppressLog: true}).catch(() => { + }); + + const deleted = await interaction.client.models['moderation']['ModerationAction'].destroy({ + where: {victimID: targetUser.id} + }); + + return interaction.editReply({ + content: localize('moderation', 'clear-punishments-done', {u: `<@${targetUser.id}>`, n: deleted}) + }); + } }; module.exports.autoComplete = { @@ -531,6 +837,25 @@ module.exports.config = { defaultMemberPermissions: ['MODERATE_MEMBERS'], options: [ + { + type: 'SUB_COMMAND', + name: 'clear-punishments', + description: localize('moderation', 'moderate-clear-punishments-command-description'), + options: [ + { + type: 'USER', + name: 'user', + required: true, + description: localize('moderation', 'moderate-user-description') + }, + { + type: 'STRING', + name: 'confirm', + required: true, + description: localize('moderation', 'moderate-clear-punishments-confirm-description') + } + ] + }, { type: 'SUB_COMMAND_GROUP', name: 'notes', @@ -648,6 +973,12 @@ module.exports.config = { name: 'days', required: false, description: localize('moderation', 'moderate-days-description') + }, + { + type: 'BOOLEAN', + name: 'only-target', + required: false, + description: localize('moderation', 'moderate-only-target-description') } ]; } @@ -674,6 +1005,12 @@ module.exports.config = { name: 'duration', required: false, description: localize('moderation', 'moderate-duration-description') + }, + { + type: 'BOOLEAN', + name: 'only-target', + required: false, + description: localize('moderation', 'moderate-only-target-description') } ]; } @@ -695,6 +1032,12 @@ module.exports.config = { name: 'reason', required: client.configurations['moderation']['config']['require_reason'], description: localize('moderation', 'moderate-reason-description') + }, + { + type: 'BOOLEAN', + name: 'only-target', + required: false, + description: localize('moderation', 'moderate-only-target-description') } ]; } @@ -715,6 +1058,12 @@ module.exports.config = { name: 'reason', required: client.configurations['moderation']['config']['require_reason'], description: localize('moderation', 'moderate-reason-description') + }, + { + type: 'BOOLEAN', + name: 'only-target', + required: false, + description: localize('moderation', 'moderate-only-target-description') } ]; } @@ -753,6 +1102,12 @@ module.exports.config = { name: 'proof', required: (client.configurations['moderation']['config']['require_proof'] && client.configurations['moderation']['config']['require_reason']), description: localize('moderation', 'moderate-proof-description') + }, + { + type: 'BOOLEAN', + name: 'only-target', + required: false, + description: localize('moderation', 'moderate-only-target-description') } ]; } @@ -785,6 +1140,12 @@ module.exports.config = { name: 'proof', required: (client.configurations['moderation']['config']['require_proof'] && client.configurations['moderation']['config']['require_reason']), description: localize('moderation', 'moderate-proof-description') + }, + { + type: 'BOOLEAN', + name: 'only-target', + required: false, + description: localize('moderation', 'moderate-only-target-description') } ]; } @@ -805,6 +1166,12 @@ module.exports.config = { name: 'reason', required: client.configurations['moderation']['config']['require_reason'], description: localize('moderation', 'moderate-reason-description') + }, + { + type: 'BOOLEAN', + name: 'only-target', + required: false, + description: localize('moderation', 'moderate-only-target-description') } ]; } @@ -831,6 +1198,12 @@ module.exports.config = { name: 'proof', required: (client.configurations['moderation']['config']['require_proof'] && client.configurations['moderation']['config']['require_reason']), description: localize('moderation', 'moderate-proof-description') + }, + { + type: 'BOOLEAN', + name: 'only-target', + required: false, + description: localize('moderation', 'moderate-only-target-description') } ]; } @@ -857,6 +1230,12 @@ module.exports.config = { name: 'proof', required: (client.configurations['moderation']['config']['require_proof'] && client.configurations['moderation']['config']['require_reason']), description: localize('moderation', 'moderate-proof-description') + }, + { + type: 'BOOLEAN', + name: 'only-target', + required: false, + description: localize('moderation', 'moderate-only-target-description') } ]; } @@ -877,10 +1256,105 @@ module.exports.config = { name: 'reason', required: client.configurations['moderation']['config']['require_reason'], description: localize('moderation', 'moderate-reason-description') + }, + { + type: 'BOOLEAN', + name: 'only-target', + required: false, + description: localize('moderation', 'moderate-only-target-description') } ]; } }, + { + type: 'SUB_COMMAND_GROUP', + name: 'accounts', + description: localize('moderation', 'linked-accounts-command-description'), + options: [ + { + type: 'SUB_COMMAND', + name: 'link', + description: localize('moderation', 'linked-accounts-link-description'), + options: [ + { + type: 'USER', + name: 'main', + required: true, + description: localize('moderation', 'linked-accounts-main-description') + }, + { + type: 'USER', + name: 'account', + required: true, + description: localize('moderation', 'linked-accounts-account-description') + }, + { + type: 'USER', + name: 'account2', + required: false, + description: localize('moderation', 'linked-accounts-account-description') + }, + { + type: 'USER', + name: 'account3', + required: false, + description: localize('moderation', 'linked-accounts-account-description') + }, + { + type: 'USER', + name: 'account4', + required: false, + description: localize('moderation', 'linked-accounts-account-description') + }, + { + type: 'USER', + name: 'account5', + required: false, + description: localize('moderation', 'linked-accounts-account-description') + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'unlink', + description: localize('moderation', 'linked-accounts-unlink-description'), + options: [ + { + type: 'USER', + name: 'user', + required: true, + description: localize('moderation', 'linked-accounts-user-description') + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'clear', + description: localize('moderation', 'linked-accounts-clear-description'), + options: [ + { + type: 'USER', + name: 'main', + required: true, + description: localize('moderation', 'linked-accounts-main-description') + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'list', + description: localize('moderation', 'linked-accounts-list-description'), + options: [ + { + type: 'USER', + name: 'user', + required: true, + description: localize('moderation', 'linked-accounts-user-description') + } + ] + } + ] + }, { type: 'SUB_COMMAND', name: 'actions', @@ -890,6 +1364,12 @@ module.exports.config = { name: 'user', required: true, description: localize('moderation', 'moderate-user-description') + }, + { + type: 'BOOLEAN', + name: 'show-notes', + required: false, + description: localize('moderation', 'moderate-actions-show-notes') } ] }, @@ -934,4 +1414,4 @@ module.exports.config = { description: localize('moderation', 'moderate-unlock-command-description') } ] -}; \ No newline at end of file +}; diff --git a/modules/moderation/configs/config.json b/modules/moderation/configs/config.json index 481ce819..99ba1e8b 100644 --- a/modules/moderation/configs/config.json +++ b/modules/moderation/configs/config.json @@ -146,6 +146,271 @@ "type": "array", "content": "roleID" }, + { + "name": "linked_accounts_enabled", + "humanName": { + "en": "Linked Accounts", + "de": "Verknuepfte Accounts" + }, + "default": { + "en": false, + "de": false + }, + "description": { + "en": "Enable linking multiple accounts to a single dossier", + "de": "Erlaubt das Verknuepfen mehrerer Accounts zu einer Akte" + }, + "type": "boolean" + }, + { + "name": "linked_accounts_mode", + "dependsOn": "linked_accounts_enabled", + "humanName": { + "en": "Linked Account Mode", + "de": "Modus fuer verknuepfte Accounts" + }, + "default": { + "en": "mirror", + "de": "mirror" + }, + "description": { + "en": "single: only one account allowed; mirror: actions are mirrored across linked accounts", + "de": "single: nur ein Account erlaubt; mirror: Aktionen werden auf verknuepfte Accounts gespiegelt" + }, + "type": "select", + "content": [ + "single", + "mirror" + ] + }, + { + "name": "linked_accounts_single_action", + "dependsOn": "linked_accounts_enabled", + "humanName": { + "en": "Action for secondary accounts (single mode)", + "de": "Aktion fuer Zweitaccounts (single Modus)" + }, + "default": { + "en": "none", + "de": "none" + }, + "description": { + "en": "Action applied to non-main accounts when linking in single mode", + "de": "Aktion, die bei Verknuepfung im single Modus auf Zweitaccounts angewendet wird" + }, + "type": "select", + "content": [ + "none", + "warn", + "mute", + "kick", + "quarantine", + "ban" + ] + }, + { + "name": "linked_accounts_mirror_actions", + "dependsOn": "linked_accounts_enabled", + "humanName": { + "en": "Mirror actions", + "de": "Aktionen spiegeln" + }, + "default": { + "en": [ + "warn", + "mute", + "kick", + "quarantine", + "unquarantine", + "ban", + "channel-mute" + ], + "de": [ + "warn", + "mute", + "kick", + "quarantine", + "unquarantine", + "ban", + "channel-mute" + ] + }, + "description": { + "en": "Actions that should be mirrored to linked accounts in mirror mode", + "de": "Aktionen, die im mirror Modus auf verknuepfte Accounts uebertragen werden" + }, + "type": "array", + "content": "string" + }, + { + "name": "linked_accounts_suppress_log_channel", + "dependsOn": "linked_accounts_enabled", + "humanName": { + "en": "Suppress log channel for mirrored actions", + "de": "Log-Kanal fuer gespiegelte Aktionen unterdruecken" + }, + "default": { + "en": true, + "de": true + }, + "description": { + "en": "If enabled, mirrored actions won't be sent to the moderation log channel", + "de": "Wenn aktiviert, werden gespiegelte Aktionen nicht im Log-Kanal gesendet" + }, + "type": "boolean" + }, + { + "name": "linked_accounts_group_log", + "dependsOn": "linked_accounts_enabled", + "humanName": { + "en": "Group linked accounts in log", + "de": "Verknuepfte Accounts im Log gruppieren" + }, + "default": { + "en": true, + "de": true + }, + "description": { + "en": "If enabled, a single log entry includes all linked accounts for mirrored actions", + "de": "Wenn aktiviert, werden verknuepfte Accounts bei gespiegelten Aktionen in einem Log-Eintrag zusammengefasst" + }, + "type": "boolean" + }, + { + "name": "linked_accounts_group_log_show_linked", + "dependsOn": "linked_accounts_group_log", + "humanName": { + "en": "Show linked accounts in grouped log", + "de": "Verknuepfte Accounts im Gruppen-Log anzeigen" + }, + "default": { + "en": false, + "de": false + }, + "description": { + "en": "If enabled, the grouped log entry lists linked accounts", + "de": "Wenn aktiviert, listet der gruppierte Log-Eintrag verknuepfte Accounts" + }, + "type": "boolean" + }, + { + "name": "dossier_show_linked_accounts", + "dependsOn": "linked_accounts_enabled", + "humanName": { + "en": "Show linked accounts in dossier", + "de": "Verlinkte Accounts in Akte anzeigen" + }, + "default": { + "en": true, + "de": true + }, + "description": { + "en": "Show linked accounts section in /moderate actions", + "de": "Zeigt verlinkte Accounts in /moderate actions an" + }, + "type": "boolean" + }, + { + "name": "dossier_show_notes", + "humanName": { + "en": "Show notes in dossier", + "de": "Notizen in Akte anzeigen" + }, + "default": { + "en": true, + "de": true + }, + "description": { + "en": "Show notes section in /moderate actions", + "de": "Zeigt Notizen in /moderate actions an" + }, + "type": "boolean" + }, + { + "name": "dossier_notes_require_opt_in", + "dependsOn": "dossier_show_notes", + "humanName": { + "en": "Require show-notes parameter", + "de": "show-notes Parameter erforderlich" + }, + "default": { + "en": true, + "de": true + }, + "description": { + "en": "If enabled, notes are only shown when show-notes is set in /moderate actions", + "de": "Wenn aktiviert, werden Notizen nur angezeigt, wenn show-notes in /moderate actions gesetzt ist" + }, + "type": "boolean" + }, + { + "name": "dossier_include_alt_notes", + "dependsOn": "linked_accounts_enabled", + "humanName": { + "en": "Include alt account notes", + "de": "Notizen von Alt-Accounts einbeziehen" + }, + "default": { + "en": true, + "de": true + }, + "description": { + "en": "If enabled, notes from linked accounts are listed", + "de": "Wenn aktiviert, werden Notizen verlinkter Accounts angezeigt" + }, + "type": "boolean" + }, + { + "name": "actions_restrict_channels", + "humanName": { + "en": "Restrict /moderate actions to channels", + "de": "/moderate actions auf Kanaele begrenzen" + }, + "default": { + "en": false, + "de": false + }, + "description": { + "en": "If enabled, /moderate actions can only be used in allowed channels", + "de": "Wenn aktiviert, kann /moderate actions nur in erlaubten Kanaelen genutzt werden" + }, + "type": "boolean" + }, + { + "name": "actions_allowed_channels", + "dependsOn": "actions_restrict_channels", + "humanName": { + "en": "Allowed channels for /moderate actions", + "de": "Erlaubte Kanaele fuer /moderate actions" + }, + "default": { + "en": [], + "de": [] + }, + "description": { + "en": "Channels where /moderate actions is allowed", + "de": "Kanaele, in denen /moderate actions erlaubt ist" + }, + "type": "array", + "content": "channelID" + }, + { + "name": "dossier_include_alt_actions", + "dependsOn": "linked_accounts_enabled", + "humanName": { + "en": "Include alt account actions", + "de": "Aktionen von Alt-Accounts einbeziehen" + }, + "default": { + "en": false, + "de": false + }, + "description": { + "en": "If enabled, actions from linked accounts are listed in /moderate actions", + "de": "Wenn aktiviert, werden Aktionen verlinkter Accounts in /moderate actions angezeigt" + }, + "type": "boolean" + }, { "name": "roles-to-ping-on-report", "humanName": { @@ -404,19 +669,56 @@ } ] }, + { + "name": "debug_clear_punishments_enabled", + "humanName": { + "de": "Debug: Strafen loeschen", + "en": "Debug: Clear punishments" + }, + "default": { + "en": false, + "de": false + }, + "description": { + "en": "Enable the debug command to clear all moderation actions for a user", + "de": "Aktiviert den Debug-Befehl zum Loeschen aller Moderationsaktionen eines Nutzers" + }, + "type": "boolean" + }, + { + "name": "automod_enabled", + "humanName": { + "de": "Warn-Automod aktiv", + "en": "Warn automod enabled" + }, + "default": { + "en": true, + "de": true + }, + "description": { + "en": "If enabled, actions defined in Automod will be executed when warn thresholds are reached", + "de": "Wenn aktiviert, werden die in Automod definierten Aktionen beim Erreichen der Warn-Grenzen ausgefuehrt" + }, + "type": "boolean" + }, { "name": "automod", + "dependsOn": "automod_enabled", "humanName": { "de": "Automod", "en": "Automod" }, "default": { - "en": {}, - "de": {} + "en": { + "7": "quarantine:2d" + }, + "de": { + "7": "quarantine:2d" + } }, "description": { - "en": "You can define here what should happen (options: mute, kick, ban, quarantine) when someone gets x warns. Specify duration by writing : after the action.", - "de": "Du kannst hier festlegen, was passieren soll (optionen: mute, kick, ban), wenn jemand x Verwarnungen bekommt. Länge festlegen, indem : hinter die Aktion geschrieben wird." + "en": "Define what should happen (options: mute, kick, ban, quarantine) when someone reaches x warns. Specify duration by writing : after the action (e.g. 3:mute:5h).", + "de": "Lege fest, was passieren soll (Optionen: mute, kick, ban, quarantine), wenn jemand x Verwarnungen erreicht. Dauer mit : angeben (z.B. 3:mute:5h)." }, "type": "keyed", "content": { @@ -424,6 +726,23 @@ "value": "string" } }, + { + "name": "automod_reason", + "dependsOn": "automod_enabled", + "humanName": { + "de": "Automod Begruendung", + "en": "Automod reason" + }, + "default": { + "en": "User exceeded the warn limit of %w. Action: %a.", + "de": "User hat die Warn-Grenze von %w ueberschritten. Aktion: %a." + }, + "description": { + "en": "Reason template for automod actions. Placeholders: %w (warn count), %a (action).", + "de": "Begruendungstext fuer Automod-Aktionen. Platzhalter: %w (Warn-Anzahl), %a (Aktion)." + }, + "type": "string" + }, { "name": "warnsExpire", "humanName": { @@ -456,4 +775,4 @@ "type": "string" } ] -} \ No newline at end of file +} diff --git a/modules/moderation/events/botReady.js b/modules/moderation/events/botReady.js index 12c018d2..66cbac6a 100644 --- a/modules/moderation/events/botReady.js +++ b/modules/moderation/events/botReady.js @@ -68,7 +68,17 @@ exports.run = async (client) => { */ async function updateCache(client) { const moduleConfig = client.configurations['moderation']['config']; - memberCache['quarantine'] = (await (await client.guilds.fetch(client.guildID)).members.fetch()).filter(m => !!m.roles.cache.get(moduleConfig['quarantine-role-id'])); + const guild = await client.guilds.fetch(client.guildID); + const roleId = moduleConfig['quarantine-role-id']; + let members; + if (guild.members && typeof guild.members.fetch === 'function') { + members = await guild.members.fetch().catch(() => null); + } + if (!members) { + memberCache['quarantine'] = new Map(); + return; + } + memberCache['quarantine'] = members.filter(m => !!m.roles.cache.get(roleId)); } /** @@ -92,4 +102,4 @@ async function deleteExpiredWarns(client) { } module.exports.updateCache = updateCache; -module.exports.memberCache = memberCache; \ No newline at end of file +module.exports.memberCache = memberCache; diff --git a/modules/moderation/linkedAccounts.js b/modules/moderation/linkedAccounts.js new file mode 100644 index 00000000..ad49c268 --- /dev/null +++ b/modules/moderation/linkedAccounts.js @@ -0,0 +1,79 @@ +const {Op} = require('sequelize'); + +async function getLinkedGroup(client, userID) { + const record = await client.models['moderation']['LinkedAccount'].findOne({ + where: {userID} + }); + if (!record) return null; + const mainID = record.mainID || record.userID; + const entries = await client.models['moderation']['LinkedAccount'].findAll({ + where: {mainID} + }); + const userIDs = entries.map(e => e.userID); + return {mainID, userIDs, entries}; +} + +async function linkAccounts(client, mainID, userIDs, linkedBy) { + const now = new Date(); + const model = client.models['moderation']['LinkedAccount']; + const inputIDs = new Set([mainID, ...(userIDs || [])]); + const inputList = Array.from(inputIDs); + if (inputList.length === 0) return; + + const existing = await model.findAll({ + where: {userID: {[Op.in]: inputList}} + }); + const existingMainIDs = new Set(); + for (const entry of existing) { + existingMainIDs.add(entry.mainID || entry.userID); + } + + const groupIDs = new Set(inputIDs); + if (existingMainIDs.size > 0) { + const mainList = Array.from(existingMainIDs); + const groupEntries = await model.findAll({ + where: {mainID: {[Op.in]: mainList}} + }); + for (const entry of groupEntries) groupIDs.add(entry.userID); + } + + let canonicalMainID = mainID; + const preferred = existing.find(entry => entry.userID === mainID); + if (preferred) canonicalMainID = preferred.mainID || preferred.userID; + else if (existing.length > 0) canonicalMainID = existing[0].mainID || existing[0].userID; + + if (!groupIDs.has(canonicalMainID)) { + const first = groupIDs.values().next().value; + if (first) canonicalMainID = first; + } + + const upserts = []; + for (const userID of groupIDs) { + upserts.push(model.upsert({ + userID, + mainID: canonicalMainID, + linkedBy, + linkedAt: now + })); + } + await Promise.all(upserts); +} + +async function unlinkAccount(client, userID) { + await client.models['moderation']['LinkedAccount'].destroy({ + where: {userID} + }); +} + +async function unlinkGroup(client, mainID) { + await client.models['moderation']['LinkedAccount'].destroy({ + where: {mainID} + }); +} + +module.exports = { + getLinkedGroup, + linkAccounts, + unlinkAccount, + unlinkGroup +}; diff --git a/modules/moderation/models/LinkedAccount.js b/modules/moderation/models/LinkedAccount.js new file mode 100644 index 00000000..aa763465 --- /dev/null +++ b/modules/moderation/models/LinkedAccount.js @@ -0,0 +1,24 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class LinkedAccount extends Model { + static init(sequelize) { + return super.init({ + userID: { + type: DataTypes.STRING, + primaryKey: true + }, + mainID: DataTypes.STRING, + linkedBy: DataTypes.STRING, + linkedAt: DataTypes.DATE + }, { + tableName: 'moderation_LinkedAccounts', + timestamps: false, + sequelize + }); + } +}; + +module.exports.config = { + name: 'LinkedAccount', + module: 'moderation' +}; diff --git a/modules/moderation/moderationActions.js b/modules/moderation/moderationActions.js index a2ca1f9d..d35d7269 100644 --- a/modules/moderation/moderationActions.js +++ b/modules/moderation/moderationActions.js @@ -4,6 +4,7 @@ const {MessageEmbed} = require('discord.js'); const {localize} = require('../../src/functions/localize'); const durationParser = require('parse-duration'); const {Op} = require('sequelize'); +const {getLinkedGroup} = require('./linkedAccounts'); /** * Performs a mod action @@ -17,13 +18,48 @@ const {Op} = require('sequelize'); * @param {MessageAttachment} proof Message-Attachment containing proof * @return {Promise} */ -async function moderationAction(client, type, user, victim, reason, additionalData = {}, expiringAt = null, proof = null) { +async function moderationAction(client, type, user, victim, reason, additionalData = {}, expiringAt = null, proof = null, options = {}) { const moduleConfig = client.configurations['moderation']['config']; const moduleStrings = client.configurations['moderation']['strings']; const antiGriefConfig = client.configurations['moderation']['antiGrief']; if (!reason) reason = localize('moderation', 'no-reason'); return new Promise(async (resolve, reject) => { + try { const guild = await client.guilds.fetch(client.guildID); + const now = new Date(); + let activeAction = null; + if (expiringAt && victim && victim.id) { + activeAction = await client.models['moderation']['ModerationAction'].findOne({ + where: { + victimID: victim.id, + type, + expiresOn: { + [Op.gt]: now + } + }, + order: [['createdAt', 'DESC']] + }); + if (activeAction) { + const undone = await client.models['moderation']['ModerationAction'].findOne({ + where: { + victimID: victim.id, + type: 'un' + type, + createdAt: { + [Op.gte]: activeAction.createdAt + } + } + }); + if (undone) activeAction = null; + } + } + if (activeAction && expiringAt && activeAction.expiresOn) { + const extendMs = expiringAt.getTime() - now.getTime(); + if (extendMs > 0) expiringAt = new Date(new Date(activeAction.expiresOn).getTime() + extendMs); + if (type === 'quarantine') { + const savedRoles = (activeAction.additionalData || {}).roles; + if (savedRoles instanceof Array) additionalData = {...additionalData, roles: savedRoles}; + } + } const quarantineRole = await guild.roles.fetch(moduleConfig['quarantine-role-id']).catch(() => { }); if (!quarantineRole && (type === 'quarantine' || type === 'unquarantine')) { @@ -60,7 +96,7 @@ async function moderationAction(client, type, user, victim, reason, additionalDa '%user%': formatDiscordUserName(user.user), '%date%': expiringAt ? formatDate(expiringAt) : null })); - if (moduleConfig['changeNicknameOnQuarantine']) await victim.setNickname(moduleConfig['changeNicknameOnMute'].split('%nickname%').join(victim.nickname ? victim.nickname : victim.user.username), '[moderation] ' + localize('moderation', 'mute-audit-log-reason', { + if (moduleConfig['changeNicknames']) await victim.setNickname(moduleConfig['changeNicknameOnMute'].split('%nickname%').join(victim.nickname ? victim.nickname : victim.user.username), '[moderation] ' + localize('moderation', 'mute-audit-log-reason', { u: formatDiscordUserName(user.user), r: reason })).catch(() => { @@ -75,7 +111,7 @@ async function moderationAction(client, type, user, victim, reason, additionalDa '%reason%': reason, '%user%': formatDiscordUserName(user.user) })); - if (moduleConfig['changeNicknameOnQuarantine']) await victim.setNickname(victim.user.username, '[moderation] ' + localize('moderation', 'unmute-audit-log-reason', { + if (moduleConfig['changeNicknames']) await victim.setNickname(victim.user.username, '[moderation] ' + localize('moderation', 'unmute-audit-log-reason', { u: formatDiscordUserName(user.user), r: reason })); @@ -117,7 +153,7 @@ async function moderationAction(client, type, user, victim, reason, additionalDa u: formatDiscordUserName(user.user), r: reason })); - if (moduleConfig['changeNicknameOnQuarantine']) await victim.setNickname(moduleConfig['changeNicknameOnQuarantine'].split('%nickname%').join(victim.nickname ? victim.nickname : victim.user.username), '[moderation] ' + localize('moderation', 'quarantine-audit-log-reason', { + if (moduleConfig['changeNicknames']) await victim.setNickname(moduleConfig['changeNicknameOnQuarantine'].split('%nickname%').join(victim.nickname ? victim.nickname : victim.user.username), '[moderation] ' + localize('moderation', 'quarantine-audit-log-reason', { u: formatDiscordUserName(user.user), r: reason })).catch(() => { @@ -142,7 +178,7 @@ async function moderationAction(client, type, user, victim, reason, additionalDa '%reason%': reason, '%user%': formatDiscordUserName(user.user) })); - if (moduleConfig['changeNicknameOnQuarantine']) await victim.setNickname(victim.user.username).catch(() => { + if (moduleConfig['changeNicknames']) await victim.setNickname(victim.user.username).catch(() => { }); break; case 'kick': @@ -195,11 +231,52 @@ async function moderationAction(client, type, user, victim, reason, additionalDa type: 'warn' } }); - if (moduleConfig['automod'][warns.length + 1]) { + const warnCount = warns.length + 1; + if (moduleConfig['automod_enabled'] && moduleConfig['automod'] && moduleConfig['automod'][warnCount]) { const roles = []; victim.roles.cache.forEach(role => roles.push(role.id)); - moderationAction(client, moduleConfig['automod'][warns.length + 1].split(':')[0], {user: client.user}, victim, `[${localize('moderation', 'auto-mod')}]: ${localize('moderation', 'reached-warns', {w: warns.length + 1})}`, {roles: roles}, moduleConfig['automod'][warns.length + 1].includes(':') ? new Date(new Date().getTime() + durationParser(moduleConfig['automod'][warns.length + 1].split(':')[1])) : null).then(() => { - }); + const actionConfig = String(moduleConfig['automod'][warnCount]); + const autoReasonTemplate = moduleConfig['automod_reason'] || `[${localize('moderation', 'auto-mod')}]: ${localize('moderation', 'reached-warns', {w: warnCount})}`; + const actionSpecs = actionConfig.split(/[|,]/).map(s => s.trim()).filter(Boolean); + const autoModBatchId = `${victim.id}-${Date.now()}-${warnCount}`; + additionalData.autoModBatchId = autoModBatchId; + additionalData.autoModActions = []; + for (const spec of actionSpecs) { + const parts = spec.split(':'); + let actionType = (parts.shift() || '').trim().toLowerCase(); + if (actionType === 'timeout') actionType = 'mute'; + const durationPart = parts.join(':').trim() || null; + if (!['mute', 'kick', 'ban', 'quarantine'].includes(actionType)) { + client.logger.warn(`[moderation] Invalid automod action "${actionType}" for warn ${warnCount}.`); + continue; + } + if (durationPart) { + const durationMs = durationParser(durationPart); + if (!durationMs || Number.isNaN(durationMs)) { + client.logger.warn(`[moderation] Invalid automod duration "${durationPart}" for warn ${warnCount}.`); + continue; + } + } + const autoReason = autoReasonTemplate + .split('%w').join(warnCount.toString()) + .split('%a').join(actionType); + additionalData.autoModActions.push({type: actionType, duration: durationPart, reason: autoReason}); + try { + await moderationAction( + client, + actionType, + {user: client.user}, + victim, + autoReason, + {roles: roles, autoModBatchId}, + durationPart ? new Date(new Date().getTime() + durationParser(durationPart)) : null, + null, + {suppressLog: true} + ); + } catch (e) { + client.logger.warn('[moderation] Automod action failed', e); + } + } } break; case 'channel-mute': @@ -256,19 +333,90 @@ async function moderationAction(client, type, user, victim, reason, additionalDa default: return reject('Option not found'); } + const memberID = user.id || (user.user ? user.user.id : null); const modAction = await client.models['moderation']['ModerationAction'].create({ victimID: victim.id, - memberID: user.id, + memberID, reason, type: type, additionalData: additionalData, expiresOn: expiringAt }); if (expiringAt) await planExpiringAction(expiringAt, modAction, guild); + + let logVictimIDs = [victim.id]; + let logLinkedIDs = []; + const groupLogEnabled = moduleConfig['linked_accounts_group_log'] !== false; + const showGroupedLinked = moduleConfig['linked_accounts_group_log_show_linked'] === true; + if (moduleConfig['linked_accounts_enabled'] && !options.isMirrored && !options.disableLinkedMirror && moduleConfig['linked_accounts_mode'] === 'mirror') { + const mirrorList = new Set(moduleConfig['linked_accounts_mirror_actions'] || []); + if (mirrorList.has('quarantine') && !mirrorList.has('unquarantine')) mirrorList.add('unquarantine'); + if (mirrorList.has(type)) { + const linkedGroup = await getLinkedGroup(client, victim.id); + if (linkedGroup && linkedGroup.userIDs.length > 1) { + const linkedIDs = linkedGroup.userIDs.filter(id => id !== victim.id); + if (groupLogEnabled && showGroupedLinked) { + logLinkedIDs = linkedIDs; + } + for (const linkedID of linkedGroup.userIDs) { + if (linkedID === victim.id) continue; + let linkedVictim = await guild.members.fetch(linkedID).catch(() => null); + if (!linkedVictim) { + if (type === 'ban') { + linkedVictim = {id: linkedID, notFound: true, user: {id: linkedID, tag: linkedID}}; + } else if (type === 'unban') { + linkedVictim = linkedID; + } else { + continue; + } + } + let mirrorAdditionalData = additionalData; + if (type === 'quarantine' && linkedVictim.roles) { + const quarantineRoleId = moduleConfig['quarantine-role-id']; + if (linkedVictim.roles.cache.get(quarantineRoleId)) { + const linkedLastAction = await client.models['moderation']['ModerationAction'].findOne({ + where: { + victimID: linkedID, + type: 'quarantine' + }, + order: [['createdAt', 'DESC']] + }); + const linkedRoles = (linkedLastAction && linkedLastAction.additionalData && linkedLastAction.additionalData.roles instanceof Array) + ? linkedLastAction.additionalData.roles + : []; + mirrorAdditionalData = {roles: linkedRoles}; + } else { + mirrorAdditionalData = {roles: Array.from(linkedVictim.roles.cache.keys()).filter(r => r !== quarantineRoleId)}; + } + } + if (type === 'unquarantine') { + const linkedLastAction = await client.models['moderation']['ModerationAction'].findOne({ + where: { + victimID: linkedID, + type: 'quarantine' + }, + order: [['createdAt', 'DESC']] + }); + const linkedRoles = (linkedLastAction && linkedLastAction.additionalData && linkedLastAction.additionalData.roles instanceof Array) + ? linkedLastAction.additionalData.roles + : []; + mirrorAdditionalData = {roles: linkedRoles}; + } + await moderationAction(client, type, user, linkedVictim, reason, mirrorAdditionalData, expiringAt, proof, { + isMirrored: true, + suppressLog: !!moduleConfig['linked_accounts_suppress_log_channel'] || groupLogEnabled, + skipCacheUpdate: true + }); + } + } + } + } let channel = guild.channels.cache.get(moduleConfig['logchannel-id']); if (!channel) channel = client.logChannel; - if (!channel) { - client.error('[moderation] ' + localize('moderation', 'missing-logchannel')); + if (options.suppressLog) { + // Skip log channel for mirrored actions if configured + } else if (!channel) { + client.logger.error('[moderation] ' + localize('moderation', 'missing-logchannel')); } else { const fields = []; if (expiringAt) fields.push({ @@ -286,6 +434,30 @@ async function moderationAction(client, type, user, victim, reason, additionalDa value: additionalData.channel.toString(), inline: true }); + if (type === 'warn' && additionalData.autoModActions && additionalData.autoModActions.length > 0) { + const autoModLines = additionalData.autoModActions.map((entry) => { + if (typeof entry === 'string') { + const parts = entry.split(':'); + const t = parts[0]; + const d = parts.slice(1).join(':') || localize('moderation', 'unknown'); + return localize('moderation', 'automod-log-line', {d, a: t, r: ''}).trim(); + } + const t = entry.type; + const d = entry.duration || localize('moderation', 'unknown'); + return localize('moderation', 'automod-log-line', {d, a: t, r: entry.reason || ''}).trim(); + }); + fields.push({ + name: localize('moderation', 'automod-log-field'), + value: autoModLines.join('\n') + }); + } + const victimMentions = logVictimIDs.map(id => `<@${id}>`).join(', '); + if (logLinkedIDs.length > 0 && groupLogEnabled && showGroupedLinked) { + fields.push({ + name: localize('moderation', 'linked-accounts-log-field'), + value: logLinkedIDs.map(id => `<@${id}>`).join(', ') + }); + } await channel.send({ // eslint-disable-next-line embeds: [new MessageEmbed().setColor(expiringAt ? 0xf1c40f : (type.includes('un') ? 0x2ecc71 : 0xe74c3c)).setFooter({ @@ -295,13 +467,21 @@ async function moderationAction(client, type, user, victim, reason, additionalDa name: formatDiscordUserName(client.user), iconURL: client .user.avatarURL() - }).setTitle(`${localize('moderation', 'case')} #${modAction.actionID}`).setThumbnail(client.user.avatarURL()).addField(localize('moderation', 'victim'), `${formatDiscordUserName(victim.user)}\n\`${victim.user.id}\``, true) - .addField('User', `${formatDiscordUserName(user.user)}\n\`${user.user.id}\``, true).addField(localize('moderation', 'action'), expiringAt ? `tmp-${type}` : type, true).addFields(fields).addField(localize('moderation', 'reason'), reason)] + }).setTitle(`${localize('moderation', 'case')} #${modAction.actionID}`).setThumbnail(client.user.avatarURL()).addField(localize('moderation', 'victim'), victimMentions, true) + .addField('User', `<@${user.user.id}>`, true).addField(localize('moderation', 'action'), expiringAt ? `tmp-${type}` : type, true).addFields(fields).addField(localize('moderation', 'reason'), reason)] + }); + } + if (!options.skipCacheUpdate) { + const {updateCache} = require('./events/botReady'); + updateCache(client).catch((e) => { + client.logger.warn('[moderation] updateCache failed', e); }); } - const {updateCache} = require('./events/botReady'); - await updateCache(client); resolve(modAction); + } catch (e) { + client.logger.error('[moderation] moderationAction failed', e); + reject(e); + } }); } @@ -329,6 +509,25 @@ function sendMessage(user, content) { async function planExpiringAction(expiringDate, action, guild) { if (!expiringDate) return; guild.client.jobs.push(scheduleJob(expiringDate, async () => { + const now = new Date(); + const actionRecord = await guild.client.models['moderation']['ModerationAction'].findOne({ + where: {actionID: action.actionID} + }); + if (actionRecord && actionRecord.expiresOn && new Date(actionRecord.expiresOn) > now) return; + const newerAction = await guild.client.models['moderation']['ModerationAction'].findOne({ + where: { + victimID: action.victimID, + type: action.type, + createdAt: { + [Op.gt]: action.createdAt + }, + expiresOn: { + [Op.gt]: now + } + }, + order: [['createdAt', 'DESC']] + }); + if (newerAction) return; const undoAction = 'un' + action.type; const undoneModAction = await guild.client.models['moderation']['ModerationAction'].findOne({ where: { @@ -350,4 +549,4 @@ async function planExpiringAction(expiringDate, action, guild) { })); } -module.exports.planExpiringAction = planExpiringAction; \ No newline at end of file +module.exports.planExpiringAction = planExpiringAction; diff --git a/src/functions/helpers.js b/src/functions/helpers.js index f42f2fd4..6adb1f84 100644 --- a/src/functions/helpers.js +++ b/src/functions/helpers.js @@ -319,12 +319,14 @@ async function sendMultipleSiteButtonMessage(channel, sites = [], allowedUserIDs await interaction.update({ components: [{type: 'ACTION_ROW', components: getButtons(nextSite)}], embeds: [sites[nextSite - 1]] + }).catch(() => { }); }); c.on('end', () => { m.edit({ components: [{type: 'ACTION_ROW', components: getButtons(currentSite, true)}], embeds: [sites[currentSite - 1]] + }).catch(() => { }); }); @@ -559,4 +561,4 @@ module.exports.formatNumber = function (number) { */ module.exports.hashMD5 = function (string) { return crypto.createHash('md5').update(string).digest('hex'); -}; \ No newline at end of file +};