diff --git a/locales/de.json b/locales/de.json new file mode 100644 index 0000000..79aa22d --- /dev/null +++ b/locales/de.json @@ -0,0 +1,1057 @@ +{ + "main": { + "startup-info": "SCNX-CustomBot v2 - Log-Level: %l", + "missing-moduleconf": "Es fehlt die Modul-Konfiguration. Alle Module werden deaktiviert und die Datei wird generiert", + "sync-db": "Datenbank erfolgreich synchronisiert", + "login-error": "Der Bot konnte sich nicht anmelden. Fehler: %e", + "not-invited": "Der Bot ist nicht auf deinem Server, bitte lade ihn ein: %inv", + "logged-in": "Bot ist nun als %tag eingeloggt und ist online.", + "logchannel-wrong-type": "Es wurde kein Log-Kanal gesetzt oder der Log-Kanal hat den falschen Type (nur Text-Kanäle werden unterstützt).", + "config-check-failed": "Konfiguration-Überprüfung ist fehlgeschlagen. Du findest weitere Information in deinem Log. Der Bot stoppt sich nun automatisch.", + "bot-ready": "Der Bot wurde erfolgreich gestartet und ist nun bereit, Commands zu empfangen", + "no-command-permissions": "Die Befehle des Bots konnten nicht übernommen werden, bitte hier klicken: %inv", + "perm-sync": "Rechte für /%c wurden synchronisiert", + "perm-sync-failed": "Rechte für /%c konnten nicht synchronisiert werden: %e", + "loading-module": "Modul %m wird geladen", + "module-disabled": "Module %m ist deaktiviert", + "command-loaded": "Befehl %d/%f wurde geladen", + "command-dir": "Befehle in %d/%f werden geladen", + "command-sync": "Befehle wurden synchronisiert", + "command-no-sync-required": "Befehle sind aktuell, es ist keine Synchronisation notwendig", + "event-loaded": "Event %d/%f wurde geladen", + "event-dir": "Befehle in %d/%f werden geladen", + "model-loaded": "Datenbank-Model %d/%f wurde geladen", + "model-dir": "Datenbank-Modele in %d/%f werden geladen", + "loaded-cli": "API-Aktion %c in %p wurde geladen", + "channel-lock": "Kanal gesperrt", + "channel-unlock": "Kanal entsperrt", + "channel-unlock-data-not-found": "Kanal mit ID %c konnte nicht entsperrt werden, weil dieser nie gesperrt wurde (was merkwürdig ist).", + "login-error-token": "Der Bot konnte sich nicht anmelden, da der angegebene Token ungültig ist. Bitte ändere deinen Token.", + "login-error-intents": "Der Bot konnte sich nicht anmelden, da die Intents nicht korrekt aktiviert wurden. Bitte aktiviere \"PRESENCE INTENT\", \"SERVER MEMBERS INTENT\" und \"MESSAGE CONTENT INTENT\" in deinem Discord-Developer-Dashboard: %url", + "module-disable": "Das Modul %m wurde deaktiviert, da %r", + "migrate-success": "Migration von %o zu %m erfolgreich abgeschlossen.", + "migrate-start": "Migration von %o zu %m gestarted... Bitte den Bot nicht anhalten", + "global-command-sync": "Synchronisierte globale Anwendungsbefehle", + "guild-command-sync": "Synchronisierte Serveranwendungsbefehle", + "guild-command-no-sync-required": "Serveranwendungsbefehle sind auf dem neuesten Stand – keine Synchronisierung erforderlich", + "global-command-no-sync-required": "Globale Anwendungsbefehle sind auf dem neuesten Stand – keine Synchronisierung erforderlich" + }, + "scnx": { + "activating": "Aktiviere SCNX-Integration...", + "notLongerInSCNX": "Dieser Server wurde deaktiviert oder ist nicht länger bei SCNX. Beende Prozess.", + "activated": "SCNX Integration erfolgreich aktiviert", + "loggedInAs": "Eigener Bot %b auf Server %s mit Version %v und dem Plan %p eingeloggt", + "choose-roles": "Rollen auswählen", + "localeUpdate": "Locales aktualisiert", + "localeUpdateSkip": "Locales-Aktualisierung übersprungen", + "localeFetchFailed": "Locales zur Aktualisierung konnten nicht geladen werden", + "issueTrackingActivated": "Automatische Fehlermeldungen erfolgreich aktiviert. Dein Bot wird automatisch Fehler, die du nicht beheben kannst, an die Entwickler melden.", + "newVersion": "**⬆ Neue Version verfügbar: %v 🎉**\n\nUm diese Änderungen zu übernehmen, starte bitte deinen Bot in deinem SCNX-Dashboard neu.\nUpdates wie dieses sollten so schnell wie möglich angewandt werden, da sie Bug-Fixes, Verbesserungen und Optimierungen enthalten. Einen detaillierten Change-Log findest du auf unserem Discord-Server." + }, + "reload": { + "reloading-config": "Konfiguration wird neu geladen...", + "reloading-config-with-name": "Nutzer %tag lädt die Konfiguration neu...", + "reloaded-config": "Konfiguration erfolgreich neu geladen.\nVon %totalModules Modulen sind %enabled aktiviert und %configDisabled wurden deaktiviert, weil ihre Konfiguration fehlerhaft war.", + "reload-failed": "Das Neuladen der Konfiguration ist fehlgeschlagen. Bot fährt herunter.", + "reload-successful-syncing-commands": "Erfolgreich neu geladen, Befehle werden synchronisiert...", + "reload-failed-message": "**Fehlgeschlagen**\n```%r```\n**Lies bitte deinen Log, um mehr zu erfahren**\nDer Bot fährt nun herunter, bye :wave:", + "command-description": "Lädt die Konfiguration neu" + }, + "config": { + "checking-config": "Konfiguration wird überprüft...", + "done-with-checking": "Konfiguration erfolgreich überprüft.", + "creating-file": "Konfiguration %m/%f existiert aktuell nicht, wird aber gleich erstellt...", + "checking-of-field-failed": "Ein Fehler bei der Überprüfung von %fieldName in %m/%f ist aufgetreten", + "saved-file": "Konfiguration %f in %m wurde erfolgreich generiert.", + "moduleconf-regeneration": "Modul-Konfiguration wird regeniert, es werden keine Einstellungen überschrieben.", + "moduleconf-regeneration-success": "Module-Konfiguration wurde regeniert.", + "channel-not-found": "Kanal mit ID \"%id\" wurde nicht gefunden", + "user-not-found": "Discord-Account mit ID \"%id\" wurde nicht gefunden", + "channel-not-on-guild": "Kanal mit ID \"%id\" ist nicht auf deinem Server", + "role-not-found": "Rolle mit ID \"%id\" wurde nicht auf deinem Server gefunden", + "config-reload": "Gesamte Konfiguration wird neugeladen...", + "channel-invalid-type": "Kanal mit ID \"%id\" hat einen Typen, der nicht für dieses Feld verwendet werden darf" + }, + "helpers": { + "timestamp": "%dd.%mm.%yyyy um %hh:%min", + "you-did-not-run-this-command": "Du hast diesen Befehl nicht ausgeführt. Führe den Command selber aus, um Navigations-Knöpfe benutzen zu können.", + "next": "Weiter", + "back": "Zurück", + "toggle-data-fetch-error": "SC Network Release: Toggle-Daten konnten nicht geladen werden", + "toggle-data-fetch": "SC Network Release: Toggle-Daten geladen" + }, + "command": { + "startup": "Der Bot startet gerade. Bitte warte einige Minuten, bevor du diesen Befehl ausführst.", + "not-found": "Befehl nicht gefunden", + "used": "%tag (%id) hat den Befehl /%c %g %s ausgeführt", + "message-used": "%tag (%id) hat den Nachrichten-Befehl %p%c ausgeführt", + "execution-failed": "Die Ausführung von /%c %g %s ist fehlgeschlagen: %e", + "message-execution-failed": "Die Ausführung von %p%c ist fehlgeschlagen: %e", + "autcomplete-execution-failed": "Die Ausführung der Autovervollständigung des Befehls /%c %g %s mit der Option %f ist fehlgeschlagen: %e", + "execution-failed-message": "**🔴 Ein Fehler bei Ausführung des Befehls ist aufgetreten 🔴**\nDies sollte nicht passieren und kann verschiedene Gründe haben.\n\nDer Bot hat automatisch, außer diese Funktion wurde in der Konfiguration deaktiviert, den Fehler an den zuständigen Entwickler gemeldet. Unter Umständen wird dich ein Entwickler bei Rückfragen anschreiben, wenn du Mitglied des [SC Network Discords]() bist.", + "error-giving-role": "Es ist ein Fehler aufgetreten als ich versucht habe dir deine Rollen zu geben ):", + "module-disabled": "Dieser Befehl ist Teil von \"%m\", welches deaktiviert ist. Dies kann entweder von den Server-Admins gewollt (und slash-commands wurden noch nicht synchronisiert) oder durch einen Konfigurationsfehler bedingt sein. Bitte prüfe (oder frag die Admins) die Konfiguration und Logs des Bots zu überprüfen um Details zu erhalten.", + "wrong-guild": "Dieser Befehl ist nur auf den Server **%g** verfügbar." + }, + "help": { + "bot-info-titel": "ℹ️ Bot-Infos", + "bot-info-description": "Dieser Bot wurde vom [ScootKit](https://scootkit.net)-Team und unseren geliebten [Open-Source-Beitragenden](https://github.com/ScootKit/CustomDCBot/graphs/contributors) entwickelt und ist unter der [Business Source License](https://github.com/ScootKit/CustomDCBot/blob/main/LICENSE] lizenziert.", + "stats-title": "📊 Statistiken", + "stats-content": "Aktive Module: %am\nRegistrierte Befehle: %rc\nBot-Version: %v\nLäuft auf Server %si\n[Server-Plan](https://scnx.xyz/plan): %pl\nLetzter Neustart: %lr\nLetze Neuladung: %lrl", + "command-description": "Zeigt dir alle Befehle an", + "slash-commands-title": "Slash-Commands", + "slash-commands-description": "Du hast wahrscheinlich **%c Befehle** durch Nichtnutzung von Slash-Commands verpasst - bitte nutze `/help` als Slash-Command um alle verfügbaren Befehle zu sehen." + }, + "bot-feedback": { + "command-description": "Schick Feedback über den Bot an den Bot Entwickler", + "submitted-successfully": "Vielen Dank für dein Feedback! Wir haben es empfangen und unser Team wird es sich bald anschauen. Sollten wir Rückfragen haben, werden wir dich eventuell per Privatnachricht anschreiben (falls du auf unserem [Support-Server]() bist öffnen wir ein Ticket). Vielen Dank, dass du durch dein Feedback [Eigen Bots]() für alle besser machst.\n\nDein Feedback unterliegt unseren [Nutzungsbedingungen]() und unserer [Datenschutzerklärung]().", + "failed-to-submit": "Ich konnte dein Feedback leider nicht an den Entwickler senden. Dies kann entweder daran legen, das sdu gesperrt wurdest, oder daran, dass es technische Schwierigkeiten gibt. Du kannst weiterhin Vorschläge und Bugs im [Feature-Board]() melden bzw. vorschlagen. Vielen Dank.", + "feedback-description": "Dein Feedback. Es sollte neutral, konstruktiv und hilfreich sein" + }, + "admin-tools": { + "position": "%i hat die Position %p.", + "position-changed": "Die Position von %i wurde zu %p aktualisiert.", + "category-can-not-have-category": "Eine Kategorie kann keine Kategorie haben", + "not-category": "Die Kategorie eines Kanals kann nicht zu einem anderen Nicht-Kategorie-Kanals geändert werden", + "changed-category": "Die Kategorie von %c wurde zu %cat aktualisiert", + "command-description": "Führe Admin-Aktionen als Command aus", + "new-position-description": "Neue Position", + "movechannel-description": "Zeigt oder ändert die Position eines Kanals", + "moverole-description": "Zeigt oder ändert die Position einer Rolle", + "setcategory-description": "Setzt die Kategorie eines Kanals", + "channel-description": "Kanal auf welchem diese Aktion ausgeführt werden soll", + "role-description": "Rolle auf welchem diese Aktion ausgeführt werden soll", + "category-description": "Neue Kategorie des Kanals", + "emoji-too-much-data": "Bitte wähle **nur einen** Emote aus und schreibe nichts anders", + "emoji-import": "Der Emote \"%e\" wurde erfolgreich importiert.", + "stealemote-description": "Leiht einen Emote von einem anderen Server permanent aus", + "emote-description": "Der Emote, der permanent geliehen werden soll" + }, + "welcomer": { + "channel-not-found": "[welcomer] Kanal nicht gefunden: %c" + }, + "birthdays": { + "channel-not-found": "[Geburtstage] Kanal nicht gefunden: %c", + "sync-error": "[Geburtstage] Der Status von %u war auf \"Aktiviert\" gesetzt, aber es gab keinen Synchronisationskandidaten, deswegen wurde die Synchronisation deaktiviert", + "age-hover": "%a Jahre alt", + "sync-enabled-hover": "Geburtstag synchronisiert", + "verified-hover": "Geburtstag verifiziert", + "no-bd-this-month": "In diesem Monat hat niemand Geburtstag ):", + "no-birthday-set": "Du hast aktuell keinen Geburtstag auf diesem Server registriert. Wenn du autoSync aktiviert hast, kann es bis zu 24 Stunden dauern, bis dein Geburtstag auf jedem Server synchronisiert wurde. [Erfahre mehr über Geburtstagssynchronisation]().", + "birthday-status": "Dein Geburtstag ist aktuell auf **%dd.%mm%yyyy** eingestellt%age. %syncstatus", + "your-age": "was bedeutet, dass du **%age%** alt bist", + "sync-on": "Dein Geburtstag wird mit deinem [SC Network-Konto](https://sc-network.net/dashboard) synchronisiert.", + "sync-off": "Dein Geburtstag ist lokal für diesen Server eingestellt und wird nicht synchronisiert", + "no-sync-account": "Es scheint, als hättest du kein [SC Network-Konto](https://sc-network.net/dashboard) oder keine Informationen zu deinem Geburtstag in diesem eingetragen.", + "auto-sync-on": "Es scheint als hättest du automatische Synchronisierung in deinem [SC Network-Konto](https://sc-network.net/dashboard) aktiviert. Das bedeutet, dass dein Geburtstag immer auf jedem Server synchronisiert wird. [Erfahre mehr]().\nDein Geburtstag wird nicht angezeigt? Es kann bis zu 24 Stunden dauern (normalerweise sind es weniger als zwei Stunden), damit dieser synchronisiert wird. Hab also noch etwas Geduld und warte einfach noch ein Bisschen.", + "enabled-sync": "Die Synchronisierung wurde erfolgreich aktiviert :+1:", + "disabled-sync": "Die Synchronisierung wurde erfolgreich deaktiviert. Du kannst nun deinen Geburtstag auf diesem Server ändern oder entfernen.", + "delete-but-sync-is-on": "Du hast Synchronisierung aktiviert. Bitte deaktiviere Synchronisierung um deinen Geburtstag zu entfernen.", + "deleted-successfully": "Geburtstag erfolgreich entfernt.", + "only-sync-allowed": "Dieser Server erlaubt nur mit einem [SC Network-Konto](https://sc-network.net/dashboard) synchronisierte Geburtstage", + "invalid-date": "Ungültiges Datum eingegeben", + "against-tos": "Du musst mindestens 13 Jahre alt sein um Discord zu benutzen. Bitte lies die [Nutzungsbedingungen]() der Plattform und [lösche dein Konto](), wenn du unter 13 bist um nicht gegen die [Nutzungsbedingungen]() von Discord zu verstoßen und warte %waitTime (oder bis zum entsprechenden Alter, sollte dein Land [hier]() gelistet sein) Jahre bevor du ein neues Konto erstellst.", + "too-old": "Du scheinst zu alt für eine lebende Person zu sein", + "command-description": "Bearbeite, sehe und entferne deinen Geburtstag", + "status-command-description": "Zeigt den aktuellen Status deines Geburtstags", + "sync-command-description": "Bearbeite die Synchronisationseinstellungen für diesen Server", + "sync-command-action-description": "Aktion, welche mit deinen Synchronisationseinstellungen ausgeführt werden soll", + "sync-command-action-enable-description": "Synchronisation aktivieren", + "sync-command-action-disable-description": "Synchronisation deaktivieren", + "set-command-description": "Setzt deinen Geburtstag", + "set-command-day-description": "Tag deines Geburtstags", + "set-command-month-description": "Monat deines Geburtstags", + "set-command-year-description": "Jahr deines Geburtstags", + "delete-command-description": "Entfernt deinen Geburtstag von diesem Server", + "migration-happening": "Datenbank-Schema nicht aktuell. Datenbank wird migriert. Starte deinen Bot nicht neu, um Datenverlust zu vermeiden.", + "migration-done": "Datenbank wurde erfolgreich migriert." + }, + "months": { + "1": "Januar", + "2": "Februar", + "3": "März", + "4": "April", + "5": "Mai", + "6": "Juni", + "7": "Juli", + "8": "August", + "9": "September", + "10": "Oktober", + "11": "November", + "12": "Dezember" + }, + "giveaways": { + "no-link": "Kein", + "no-winners": "Keine", + "not-supported-for-news-channel": "Nicht unterstützt für Ankündigungs-Kanäle", + "required-messages": "Du brauchst %mc neue Nachrichten (überprüfe deine Nachrichten mit \\`/gmessages\\`)", + "required-messages-user": "Du musst mindestens %mc neue Nachrichten haben; zum anschauen: (%um/%mc Nachrichten)", + "roles-required": "Du musst mindestens eine dieser Rollen zum beitreten: %r", + "giveaway-ended-successfully": "Gewinnspiel erfolgreich beendet.", + "no-giveaways-found": "Kein Gewinnspiel gefunden", + "gmessages-description": "Überprüfe deine benötigten Nachrichten für ein Gewinnspiel", + "jump-to-message-hover": "Zur Nachricht springen", + "messages": "Nachrichten", + "giveaway-messages": "Gewinnspiel-Nachrichten", + "duration-parsing-failed": "Die Länge des Gewinnspieles konnte nicht interpretiert werden.", + "channel-type-not-supported": "Kanal-Typ nicht unterstützt", + "parameter-parsing-failed": "Ein(ige) Parameter deiner Angaben konnten nicht übernommen werden", + "started-successfully": "Gewinnspiel erfolgreich in %c gestartet.", + "reroll-done": "Erledigt :+1:", + "select-menu-description": "Wird in #%c am %d enden", + "no-giveaways-for-reroll": "Huch, aktuell laufen keine Gewinnspiele. Suchst du eventuell /reroll?", + "select-giveaway-to-end": "Bitte wähle das Gewinnspiel welches du beenden willst.", + "please-select": "Bitte wähle aus", + "gmanage-description": "Verwalte Gewinnspiele", + "gmanage-start-description": "Starte ein neues Gewinnspiel", + "gmanage-channel-description": "Kanal zum Starten des Gewinnspiels", + "gmanage-price-description": "Preis der gewonnen werden kann", + "gmanage-duration-description": "Länge des Gewinnspiels? (z.b: \"2h 40m\" oder \"7d 2h 3m\")", + "gmanage-winnercount-description": "Anzahl der Gewinner, die ausgewählt werden sollen", + "gmanage-requiredmessages-description": "Anzahl neuer (!) Nachrichten, die ein Benutzer vor dem Betreten haben muss", + "gmanage-requiredroles-description": "Rolle, die der Benutzer haben muss, um an der Verlosung teilzunehmen", + "gmanage-sponsor-description": "Setzt einen anderen Gewinnspiel-Starter, hilfreich bei Sponsoren", + "gmanage-sponsorlink-description": "Link zu einem Sponsor, falls zutreffend", + "gend-description": "Beendet ein Gewinnspiel", + "gereroll-description": "Rollt ein beendetes Giveaway erneut aus", + "gereroll-msgid-description": "Nachrichten-ID des Gewinnspiels", + "gereroll-winnercount-description": "Wie viele neue Gewinner sollen ausgewählt werden?", + "migration-happening": "Datenbank-Schema nicht aktuell. Datenbank wird migriert. Starte deinen Bot nicht neu, um Datenverlust zu vermeiden.", + "migration-done": "Datenbank wurde erfolgreich migriert." + }, + "levels": { + "leaderboard-channel-not-found": "Ranglisten-Kanal wurde nicht gefunden, oder hat den falschen Typ", + "leaderboard-notation": "**%p. %u**: Level %l - %xp XP", + "leaderboard": "Rangliste", + "no-user-on-leaderboard": "Es kann keine Rangliste generiert werden, da niemand Erfahrungspunkte hat. Das ist zwar komisch, aber ist halt so ¯\\_(ツ)_/¯", + "and-x-other-users": "und %uc andere Nutzer", + "level": "Level %l", + "users": "Nutzer", + "leaderboard-command-description": "Zeigt die Rangliste des Servers", + "leaderboard-sortby-description": "Ranglistensortierung (Standardwert: %d)", + "no-bio": "Keine Bio gesetzt.", + "no-bio-author": "Keine Bio gesetzt. [Setze Bio](https://sc-network.net/auth?action=set-bio)", + "profile-command-description": "Zeigt das Profil von dir oder einem anderen Nutzer", + "profile-user-description": "Nutzer von dem das Profil angezeigt werden soll (Standardwert: Du)", + "please-send-a-message": "Bitte sende einige Nachrichten, bevor ich Daten über dich anzeigen kann", + "no-role": "Keine Rolle", + "are-you-sure-you-want-to-delete-user-xp": "Okay, willst du dich wirklich mit %u anlegen? Wenn du ihn/sie so sehr hasst kannst du gerne `/manage-levels reset-xp confirm:True user:%ut` ausführen, um diese endgültige nicht umkehrbare Aktion auszuführen.", + "are-you-sure-you-want-to-delete-server-xp": "Willst du wirklich alle Erfahrungspunkte und Levels dieses Servers zurücksetzen? Diese Aktion ist nicht umkehrbar und jeder auf diesem Server wird dich hassen. Hast du dich entschieden, ob es das wert ist? Führe `/manage-levels reset-xp confirm:True` aus", + "user-not-found": "Nutzer nicht gefunden", + "user-deleted-users-xp": "%t hat die Erfahrungspunkte vom Nutzer mit der ID %u zurückgesetzt", + "removed-xp-successfully": "`Erfahrungspunkte und Level von %u erfolgreich zurückgesetzt.`", + "deleted-server-xp": "%u hat die Erfahrungspunkte von allen Nutzern zurückgesetzt", + "successfully-deleted-all-xp-of-users": "Erfolgreich alle Erfahrungspunkte aller Nutzer zurückgesetzt", + "cheat-no-profile": "Dieser Nutzer hat (noch) kein Profil, bitte zwinge ihn eine Nachricht zuschreiben bevor du deine Community betrügst, indem du Levels manipulierst.", + "abuse-detected": "%u versuchte seine Rechte durch Bearbeitung seiner eigenen Erfahrungspunkte zu seinem eigenen Vorteil zu missbrauchen. Dies ist offensichtlicher Missbrauch, ich erwarte, dass Disziplinarmaßnahmen gegenüber dieses Nutzers ergriffen werden.", + "cant-change-your-level-1": "Warte... du meinst das nicht ernst? Doch, oder? Du meinst das ernst... Ich bin sehr enttäuscht von dir, %un... Ich dachte du seist ein fairer Admin, aber wie ich es heute sehen kann bist du es nicht. Du wolltest diesen Befehl zu deinem eigenen Vorteil nutzen und alle Nutzer betrügen. Ich bin wirklich sehr sehr enttäuscht von dir, Ich habe mehr von dir erwartet. Ich werde diesen Zwischenfall an deinen Vorgesetzten melden müssen und - wie ich bereits sagte - Ich bin sehr von dir enttäuscht und ehrlich gesagt - wenn ich die Rechte dazu hätte - würde ich dich von diesem Server bannen, da dieser Zwischenfall beweist, dass du deine Rechte zu deinem eigenen Vorteil nutzt.", + "cant-change-your-level-2": "Und du versuchst es wieder... Das ist sehr sehr traurig, Ich werde dich nochmal melden und erneut betonen, dass das offensichtlich Rechteausnutzung ist. Hab noch einen schönen Tag.", + "manipulated": "%u manipulierte die Erfahrungspunkte von %u auf %v", + "successfully-changed": "Erfolgreich die Erfahrungspunkte dieses Nutzers bearbeitet. Bedenke, da jede Änderung die du am Levelsystem machst das Erlebnis anderer Nutzer auf diesem Server zerstört, da das Levelsystem nicht mehr fair ist.", + "edit-xp-command-description": "Verwalte die Level deines Servers", + "reset-xp-description": "Setze die Erfahrungspunkte eines Nutzers oder des ganzen Servers zurück", + "reset-xp-user-description": "Nutzer zum Erfahrungspunkte zurücksetzen (Standardwert: Ganzer Server)", + "reset-xp-confirm-description": "Willst du wirklich die Daten löschen?", + "edit-xp-user-description": "Nutzer zum Bearbeiten (kannst *nicht* du selbst sein!)", + "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", + "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)", + "rewards-commands-disabled": "Belohnungs-Befehle sind in der Konfiguration deaktiviert." + }, + "partner-list": { + "could-not-give-role": "%u konnte keine Rolle gegeben werden", + "could-not-remove-role": "%u konnte keine Rolle entfernt werden", + "partner-not-found": "Der Partner konnte nicht gefunden werden. Bitte überprüfe ob du die richtige Partner-ID verwendest. Die Partner-ID ist nicht mit der Server-ID des Partners identisch. Die Partner-ID findest du [hier](https://gblobscdn.gitbook.com/assets%2F-MNyHzQ4T8hs4m6x1952%2F-MWDvDO9-_JwAGqtD6at%2F-MWDxIcOHB9VcWhjsWt7%2Fscreen_20210320-102628.png?alt=media&token=2f9ac1f7-1a14-445c-b34e-83057789578e) im Partner-Embed.", + "successful-edit": "Partner-Liste erfolgreich bearbeitet.", + "channel-not-found": "Kanal mit der ID %c konnte nicht gefunden werden, oder hat den falschen Typ (es werden nur Textkanäle unterstützt)", + "no-partners": "Es gibt aktuell keine Partner. Das ist komisch, aber es ist halt so ¯\\_(ツ)_/¯\n\nUm einen Partner hinzuzufügen, nutze den Slash-Befehl `/partner add`.", + "information": "Information", + "command-description": "Verwaltet die Partner-Liste des Servers", + "padd-description": "Fügt einen neuen Partner hinzu", + "padd-name-description": "Name des Partners", + "padd-category-description": "Bitte wähle eine der Kategorien, die du in deiner Konfiguration gesetzt hast", + "padd-owner-description": "Inhaber des Partnerservers", + "padd-inviteurl-description": "Einladung zum Partnerserver", + "pedit-description": "Bearbeitet einen existierenden Partner", + "pedit-id-description": "ID des Partners", + "pedit-name-description": "Neuer Name des Partners", + "pedit-inviteurl-description": "Neue Einladung zum Partnerserver", + "pedit-category-description": "Neue Kategorie des Partnerservers", + "pdelete-description": "Entfernt einen existierenden Partner", + "pdelete-id-description": "ID des Partners", + "pedit-owner-description": "Neuer Besitzer des Partner-Servers", + "pedit-staff-description": "Neues zugewiese Teammitglied für diesen Partner-Server" + }, + "ping-on-vc-join": { + "channel-not-found": "Kanal für Benachrichtigungen %c nicht gefunden", + "could-not-send-pn": "Es konnte keine PN an %m gesendet werden" + }, + "suggestions": { + "approved": "Angenommen", + "denied": "Abgelehnt", + "admin-answer": "%status von %u mit folgendem Grund: \"%r\"", + "suggestion-not-found": "Vorschlag nicht gefunden", + "updated-suggestion": "Vorschlag erfolgreich aktualisiert", + "manage-suggestion-command-description": "Verwalte Vorschläge als Admin", + "manage-suggestion-accept-description": "Akzeptiert einen Vorschlag", + "manage-suggestion-deny-description": "Lehnt einen Vorschlag ab", + "manage-suggestion-id-description": "ID des Vorschlags", + "manage-suggestion-comment-description": "Erkläre, warum du diese Entscheidung getroffen hast" + }, + "auto-delete": { + "could-not-fetch-channel": "Der Kanal mit der ID %c konnte nicht gefunden werden", + "could-not-fetch-messages": "Die Nachrichten im Kanal mit der ID %c konnten nicht gefunden werden" + }, + "auto-thread": { + "thread-create-reason": "Dieser Thread wurde aufgrund der Einstellungen von auto-thread erstellt" + }, + "auto-messager": { + "channel-not-found": "Der Kanal mit der ID %id konnte nicht gefunden werden" + }, + "polls": { + "what-have-i-votet": "Für was habe ich abgestimmt?", + "vote": "Abstimmen!", + "vote-this": "Wähle diese Option um hierfür abzustimmen", + "voted-successfully": "Erfolgreich abgestimmt. Danke für deine Teilnahme.", + "not-voted-yet": "Du hast noch nicht abgestimmt, also kann ich dir nicht zeigen für was du abgestimmt hast?", + "you-voted": "Du hast für **%o** abgestimmt.", + "change-opinion": "Du kannst deine Meinung jeder Zeit ändern, indem du einfach etwas anderes über dem Knopf den du gerade angeklickt hast auswählst.", + "command-poll-description": "Erstelle und beende Umfragen", + "command-poll-create-description": "Erstelle eine neue Umfrage", + "command-poll-end-description": "Beende eine existierende Umfrage", + "command-poll-end-msgid-description": "ID der Umfrage", + "command-poll-create-description-description": "Thema / Beschreibung dieser Umfrage", + "command-poll-create-channel-description": "Kanal, in welchem diese Umfrage erstellt werden soll", + "command-poll-create-option-description": "Option Nummer %o", + "command-poll-create-endAt-description": "Dauer der Umfrage (wenn nicht gesetzt ist, wird die Umfrage nicht automatisch beendet)", + "created-poll": "Umfrage erfolgreich in %c erstellt.", + "not-found": "Umfrage konnte nicht gefunden werden", + "ended-poll": "Umfrage erfolgreich beendet", + "not-text-channel": "Du musst einen Textkanal auswählen, der kein Ankündigungskanal ist." + }, + "channel-stats": { + "audit-log-reason-interval": "Dieser Kanal wurde aufgrund des in channel-stats eingestellten Intervalls aktualisiert", + "audit-log-reason-startup": "Dieser Kanal wurde von channel-stats aktualisiert, da der Bot neu gestartet wurde", + "not-voice-channel-info": "Der Kanal ist kein Sprachkanal" + }, + "activities": { + "hook-installed": "Hook um spezielle Aktivitätseinladungen zu erstellen wurde installiert", + "command-description": "Voice-Aktivität auf Discord erstellt", + "type-description": "Typ der Voice-Aktivität" + }, + "info-commands": { + "info-command-description": "Finde Informationen über Teile dieses Servers heraus", + "command-userinfo-description": "Finde mehr Informationen über einen Nutzer auf diesem Server heraus", + "argument-userinfo-user-description": "Benutzer, über den Du Informationen sehen möchten (Standard: Du)", + "command-roleinfo-description": "Weitere Informationen zu einer Rolle auf diesem Server finden", + "argument-roleinfo-role-description": "Rolle, zu der Du Informationen sehen möchten", + "command-channelinfo-description": "Weitere Informationen zu einem Kanal auf diesem Server finden", + "argument-channelinfo-channel-description": "Kanal, über den Du Informationen sehen möchten", + "command-serverinfo-description": "Weitere Informationen zu diesem Server finden", + "information-about-role": "Informationen zur Rolle %r", + "hoisted": "Rechts gelistet", + "mentionable": "Erwähnbar", + "managed": "Verwaltet", + "information-about-channel": "Informationen über den Kanal %c", + "information-about-user": "Information über den Nutzer %u", + "information-about-server": "Informationen über %s", + "boostLevel": "Level", + "boostCount": "Boosts", + "userCount": "Nutzer", + "memberCount": "Mitglieder", + "onlineCount": "Online", + "textChannel": "Text", + "voiceChannel": "Sprach", + "categoryChannel": "Kategorien", + "otherChannel": "Anderes", + "total-invites": "Gesamt", + "active-invites": "Aktiv", + "left-invites": "Übrig" + }, + "channelType": { + "GUILD_TEXT": "Text-Kanal", + "GUILD_VOICE": "Sprach-Kanal", + "GUILD_CATEGORY": "Kategorie", + "GUILD_NEWS": "Ankündigungs-Kanal", + "GUILD_STORE": "Store-Kanal", + "GUILD_NEWS_THREAD": "News-Kanal-Thread", + "GUILD_PUBLIC_THREAD": "Öffentlicher Thread", + "GUILD_PRIVATE_THREAD": "Privater Thread", + "GUILD_STAGE_VOICE": "Bühnen-Kanal", + "DM": "Direkt-Nachricht", + "GROUP_DM": "Gruppen-Direkt-Nachricht", + "UNKNOWN": "Unbekannt" + }, + "stagePrivacy": { + "PUBLIC": "Öffentlich zugänglich", + "GUILD_ONLY": "Nur Servermitglieder können beitreten" + }, + "guildVerification": { + "NONE": "Keins", + "LOW": "Niedrig", + "MEDIUM": "Mittel", + "HIGH": "Hoch", + "VERY_HIGH": "Sehr Hoch" + }, + "boostTier": { + "NONE": "Keins", + "TIER_1": "Level 1", + "TIER_2": "Level 2", + "TIER_3": "Level 3" + }, + "temp-channels": { + "removed-audit-log-reason": "Temp-Channel entfernt, da niemand darin war", + "permission-update-audit-log-reason": "Berechtigungen aktualisiert, um sicherzustellen, dass nur Personen im Sprachkanal den No-Mic-Kanal sehen können", + "created-audit-log-reason": "Temp-Channel für %u erstellt", + "move-audit-log-reason": "Nutzer in seinen/ihren Sprachkanal verschoben", + "no-mic-channel-topic": "Willkommen in %us No-Mic-Kanal. Du wirst diesen Kanal so lang sehen, wie du mit dem Temp-Channel verbunden bist.", + "disconnect-audit-log-reason": "Der alte Kanal des Nutzers konnte nicht gefunden werden - Verbindung wird getrennt - Hoffentlich joint er/sie wieder", + "command-description": "Verwalte deinen Temp-channel", + "mode-subcommand-description": "Ändere den Modus deines Kanals", + "public-option-description": "local public-option-description", + "add-subcommand-description": "Füge Nutzer hinzu, die deinem Channel beitreten können sollen, während er privat ist", + "remove-subcommand-description": "Entferne Nutzer von deinem Kanal", + "add-user-option-description": "Der Nutzer der hinzugefügt werden soll", + "remove-user-option-description": "Der Nutzer der entfernt werden soll", + "list-subcommand-description": "Liste der Nutzer mit Zugang zu deinem Kanal", + "edit-subcommand-description": "Bearbeite deinen Kanal", + "user-limit-option-description": "Ändere die maximale Nutzeranzahl deines Kanals", + "bitrate-option-description": "Ändere die Bitrate deines Kanals (min. 8000)", + "name-option-description": "Ändere den Namen deines Kanals", + "nsfw-option-description": "Ändere ob dein Kanal altersbeschränkt (NSFW) ist oder nicht", + "no-added-user": "Es gibt keine Nutzer die hier angezeigt werden können", + "nothing-changed": "Dein Kanal hatte bereits diese Einstellungen", + "no-disconnect": "Trennen der Verbindung des Nutzers nicht möglich. Dies kann an fehlenden Rechten liegen, oder daran, dass der Nutzer nicht in deinem Kanal ist", + "edit-error": "Beim Bearbeiten deines Kanals ist ein Fehler aufgetreten. Eine oder mehrere deiner Einstellungen konnten nicht angewendet werden. Dies kann an fehlenden Rechten oder einem ungültigen Eingabewert liegen.", + "add-user": "Nutzer hinzufügen", + "remove-user": "Nutzer entfernen", + "list-users": "Nutzer anzeigen", + "private-channel": "Privat", + "public-channel": "Öffentlich", + "edit-channel": "Kanal Bearbeiten", + "add-modal-title": "Füge einen Nutzer zu deinem Kanal hinzu", + "add-modal-prompt": "Der Nutzer den du hinzufügen willst", + "remove-modal-title": "Entferne einen Nutzer von deinem Temp-channel", + "remove-modal-prompt": "Der Nutzer den du entfernen willst", + "edit-modal-title": "Kanal bearbeiten", + "edit-modal-nsfw-prompt": "Temp-channel als altersbeschränkt markieren?", + "edit-modal-nsfw-placeholder": "\"true\" (ja) oder \"false\" (nein)", + "edit-modal-bitrate-prompt": "Bitrate deines Temp-channels", + "edit-modal-bitrate-placeholder": "Eine Zahl; Mindestens 8000", + "edit-modal-limit-prompt": "Limit an Nutzern in deinem Temp-channel", + "edit-modal-limit-placeholder": "Zahl zwischen 0 und 99; 0 = beliebig viele", + "edit-modal-name-prompt": "Wie soll dein Kanal heißen?", + "edit-modal-name-placeholder": "Ein Super Kanalname", + "user-not-found": "Nutzer nicht gefunden" + }, + "guess-the-number": { + "command-description": "Verwalte den Status deines Errate-die-Zahl-Spiels", + "status-command-description": "Zeigt den aktuellen Status eines Errate-die-Zahl-Spiels", + "create-command-description": "Erstelle ein neues Errate-die-Zahl-Spiel in diesem Kanal", + "create-min-description": "Niedrigster Wert, den Nutzer raten können", + "create-max-description": "Höchster Wert, den Nutzer raten können", + "create-number-description": "Zahl, welche Nutzer erraten sollen um zu gewinnen", + "end-command-description": "Beendet das aktuelle Spiel", + "session-already-running": "In diesem Kanal läuft bereits eine Runde. Bitte beende es zuerst mit /guess-the-number end", + "session-not-running": "Aktuell läuft keine Runde.", + "session-ended-successfully": "Runde erfolgreich beendet. Kanal erfolgreich gesperrt.", + "current-session": "Aktuelle Runde", + "number": "Zahl", + "min-val": "Niedrigster Wert", + "max-val": "Höchster Wert", + "owner": "Eigentümer", + "guess-count": "Anzahl der Versuche", + "min-max-discrepancy": "`min` kann nicht größer oder gleich groß wie `max` sein", + "emoji-guide-button": "Was bedeutet die Reaktion unter meinem Versuch?", + "emoji-guide-link": "https://docs.sc-network.net/de/custom-bot-v2/module/guess-the-number#was-bedeuten-die-reaktionen-unter-meinen-nachrichten", + "created-successfully": "Runde erfolgreich erstellt. Nutzer können nun anfangen in diesem Kanal zu raten. Die gewinnende Zahl ist **%n**. Du kannst den Status immer mit `/guess-the-number-status` abfragen. Beachte, dass du als Admin nicht mitraten kannst.", + "game-ended": "Spiel beendet", + "game-started": "Spiel gestartet" + }, + "twitch-notifications": { + "channel-not-found": "Der Kanal mit der ID %c konnte nicht gefunden werden", + "user-not-on-twitch": "Nutzer %u konnte auf Twitch nicht gefunden werden", + "user-not-found": "Der Benutzer mit der ID %u konnte nicht gefunden werden" + }, + "fun": { + "slap-command-description": "Schlägt einen Nutzer ins Gesicht", + "user-argument-description": "Nutzer, auf den diese Aktion auszuführen ist", + "no-no-not-slapping-yourself": "Du kannst dich nicht selbst schlagen lol (also theoretisch schon, aber unsere GIFs können das nicht, also Akzeptiere es einfach ¯\\_(ツ)_/¯)", + "pat-command-description": "Tätschle jemanden nett", + "no-no-not-patting-yourself": "Guter Versuch, aber das machen wir hier nicht", + "no-no-not-kissing-yourself": "Uah, das ist eklig, du solltest versuchen jemanden anderen dafür zu bezahlen (also solltest du nicht, aber es ist besser als dich selbst zu küssen)", + "kiss-command-description": "Küsse jemanden", + "hug-command-description": "Umarme jemanden <3", + "no-no-not-hugging-yourself": "Du bist ziemlich einsam, oder? Versuche einen Baum zu umarmen, das sollte funktionieren. Außer du lebst in einer Wüste. Dann umarme einen Kaktus. Das tut ein bisschen mehr weh, aber vertrau mir.", + "random-command-description": "Hilft dir zufällige Sachen auszusuchen", + "random-number-command-description": "Sagt dir eine zufällige Zahl", + "min-argument-description": "Niedrigste mögliche Zahl (Standard: 1)", + "max-argument-description": "Höchste mögliche Zahl (Standard: 42)", + "random-ikeaname-command-description": "Generiert einen zufälligen IKEA-Namen", + "syllable-count-argument-description": "Anzahl der Silben des generierten Namens (Standard: Zufällig)", + "random-dice-command-description": "Würfle", + "random-coinflip-command-description": "Wirf eine Münze!", + "random-8ball-command-description": "Generiert eine Antwort auf eine Ja/Nein-Frage", + "dice-site-1": "Kopf", + "dice-site-2": "Zahl" + }, + "moderation": { + "moderate-command-description": "Moderiert Nutzer auf deinem Server", + "moderate-notes-command-description": "Setze oder sehe die Kommentare eines Moderators über einen Nutzer", + "moderate-notes-command-view": "Zeige die Notizen zu einem Nutzer an", + "moderate-notes-command-create": "Erstelle eine neue Notiz zu einem Nutzer", + "moderate-notes-command-edit": "Bearbeite eine deiner existierenden Notizen zu einem Nutzer", + "moderate-notes-command-delete": "Lösche eine deiner existierenden Notizen zu einem Nutzer", + "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", + "moderate-unban-command-description": "Hebt einen existierenden Bann auf", + "moderate-clear-command-description": "Löscht Nachrichten im aktuellen Kanal", + "moderate-clear-amount-description": "Wie viele Nachrichten sollen gelöscht werden?", + "moderate-kick-command-description": "Kickt einen Nutzer von deinem Server", + "moderate-unwarn-command-description": "Hebt eine Verwarnung auf", + "moderate-mute-command-description": "Schaltet einen Nutzer auf deinem Server stumm", + "moderate-unmute-command-description": "Hebt die Stummschaltung eines Nutzers wieder auf", + "moderate-warn-command-description": "Verwarnt einen Nutzer", + "moderate-lock-command-description": "Sperrt den aktuellen Kanal", + "moderate-unlock-command-description": "Entsperrt den aktuellen Kanal", + "moderate-user-description": "Nutzer, auf den diese Aktion auszuführen ist", + "moderate-userid-description": "ID eines Nutzers", + "moderate-days-description": "Anzahl der zu löschenden Nachrichten", + "invalid-days": "Tage können nur zwischen 0 und 7 sein", + "moderate-notes-description": "Deine Notiz setzen (leer lassen, um Notizen zu sehen)", + "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", + "no-reason": "Nicht gesetzt", + "muterole-not-found": "Die Stummschaltungsrolle konnte nicht gefunden werden. Aktion kann nicht ausgeführt werden", + "quarantinerole-not-found": "Die Quarantänerolle konnte nicht gefunden werden. Aktion kann nicht ausgeführt werden", + "mute-audit-log-reason": "Wurde von %u wegen \"%r\" stumm geschaltet", + "unmute-audit-log-reason": "Die Stummschaltung wurde von %u wegen \"%r\" aufgehoben", + "quarantine-audit-log-reason": "Wurde von %u wegen \"%r\" in Quarantäne versetzt", + "kicked-audit-log-reason": "Wurde von %u wegen \"%r\" gekickt", + "banned-audit-log-reason": "Wurde von %u wegen \"%r\" gebannt", + "unbanned-audit-log-reason": "Wurde von %u wegen \"%r\" entbannt", + "unquarantine-audit-log-reason": "Wurde von %u wegen \"%r\" aus der Quarantäne entfernt", + "action-expired": "Aktion ausgelaufen", + "auto-mod": "Auto-Mod", + "batch-role-remove-failed": "Es konnten nicht alle Rollen von %i entfernt werden (versuche eine nach der anderen zu entfernen): %e", + "batch-role-add-failed": "Es konnten %i nicht alle Rollen gegeben werden (versuche eine nach der anderen zu geben): %e", + "could-not-remove-role": "Rolle %r konnte %i nicht entfernt werden: %e", + "could-not-add-role": "Rolle %r konnte %i nicht gegeben werden: %e", + "reason": "Grund", + "join-gate": "Join-Gate", + "expires-at": "Aktion läuft aus am", + "action": "Aktion", + "case": "Fall", + "victim": "Betroffener Nutzer", + "missing-logchannel": "Log-Kanal konnte nicht gefunden werden", + "reached-warns": "%w Verwarnungen erreicht", + "restored-punishment-audit-log-reason": "Strafe wiederhergestellt", + "anti-join-raid": "ANTI-JOIN-RAID", + "raid-detected": "Raid erkannt", + "joingate-for-everyone": "Join-Gate-Modus: Alle Nutzer abfangen", + "account-age-to-low": "Accounterstellungsalter von %a Tagen ist zu niedrig (es werden mehr als %c Tage benötigt)", + "no-profile-picture": "Account hat kein Profilbild", + "join-gate-fail": "Account hat das Join-Gate nicht bestanden (%r)", + "blacklisted-word": "Hat ein verbotenes Wort in %c geschrieben", + "invite-sent": "Hat einen Einladungslink in %c geschrieben", + "anti-spam": "Anti-Spam", + "reached-messages-in-timeframe": "Hat %m (normale) Nachrichten in unter %t Sekunden erreicht", + "reached-duplicated-content-messages": "Hat %m mit dem selben Inhalt in unter %t Sekunden erreicht", + "reached-ping-messages": "Hat %m mit (Nutzer-) Erwähnungen in unter %t Sekunden erreicht", + "reached-massping-messages": "Hat %m mit Massenerwähnungen in unter %t Sekunden erreicht", + "action-done": "Aktion erfolgreich ausgeführt. AktionsID: #%i", + "expiring-action-done": "Fertig. Aktion wird am %d automatisch auslaufen. AktionsID: #%i", + "cleared-channel": "Kanal erfolgreich geleert.\nHinweis: Nachrichten, die älter als 14 Tage sind werden eventuell mit dieser Methode nicht gelöscht.", + "clear-failed": "Ein Fehler ist aufgetreten. Du kannst nur 100 Nachrichten auf ein Mal löschen.", + "no-quarantine-action-found": "Entschuldigung, aber ich kann keine Aufzeichnungen über Quarantäneaufenthalte dieses Nutzers finden.", + "locked-channel-successfully": "Kanal erfolgreich gesperrt. Nur Moderatoren (und Admins) können hier noch Nachrichten schreiben.", + "unlocked-channel-successfully": "Kanal erfolgreich entsperrt. Berechtigungen wurden auf den Status von vor der Sperrung wiederhergestellt.", + "unlock-audit-log-reason": "Nutzer %u hat diesen Kanal durch Ausführung von /moderate unlock entsperrt", + "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", + "report-reason": "Grund der Meldung", + "report-user": "Nutzer, welcher die Meldung eingereicht hat", + "message-log": "Letzte 100 Nachrichten", + "message-log-description": "Du kannst einen verschlüsselten Nachrichten-Log [hier](%u) sehen.", + "channel": "Kanal", + "no-report-pings": "Keine Erwähnungen konfiguriert. Überprüfe deine Konfiguration um dein Team zu benachrichtigen.", + "not-allowed-to-see-own-notes": "Sorry, aber leider darfst du die Notizen über dich nicht selber sehen.", + "note-added": "Notiz erfolgreich hinzugefügt", + "note-edited": "Notiz erfolgreich bearbeitet", + "note-deleted": "Notiz erfolgreich gelöscht", + "note-not-found-or-no-permissions": "Notiz nicht gefunden oder keine Rechte, diese Notiz zu bearbeiten.", + "notes-embed-title": "Notizen über %u", + "info-field-title": "ℹ️ Information", + "no-notes-found": "Keine Notizen über diesen Nutzer gefunden. Erstelle eine neue Notiz mit `/moderate notes` und setze dabei das `notes`-Attribut.", + "more-notes": "%x weitere Moderatoren haben Notizen über diesen Nutzer hinterlassen. Notizen sind in entgegengesetzer Chronologie sortiert, du siehst die neuesten Notizen zuerst.", + "user-notes-field-title": "%t's Notizen", + "user-not-on-server": "Ich kann gegen diesen Nutzer keine Aktionen ausführen, da er gerade nicht auf deinem Server ist.", + "verification": "VERIFIKATION", + "verification-failed": "Verifikation fehlgeschlagen", + "verification-started": "Verifikation wurde begonnen", + "verification-completed": "Verifikation beendet", + "user": "Nutzer", + "manual-verification-needed": "Manuelle Verifikation benötigt", + "verification-deny": "Verifikation ablehnen", + "verification-approve": "Verifikation bestätigen", + "verification-skip": "Verifikation überspringen", + "captcha-verification-pending": "Captcha-Verification steht noch aus. Du kannst entweder warten, bis der Nutzer diese beendet hat, oder sie manuell überspringen.", + "verification-update-proceeded": "Verifikationsstatus erfolgreich aktualisiert", + "verify-channel-set-but-not-found-or-wrong-type": "Der eingestellte Verifikationskanal wurde nicht gefunden oder hat den falschen Typ.", + "generating-message": "Wir bereiten einiges vor; diese Nachricht wird bald bearbeitet...", + "restart-verification-button": "Verifikation neustarten", + "member-not-found": "Dieser Nutzer konnte nicht gefunden werden, vielleicht ist er schon gegangen?", + "already-verified": "Es sieht so aus als wärst du bereits verifiziert... Warum würdest du das wiederholen wollen?", + "restarted-verification": "Ich habe dir eine neue PN zu deinem Verifikationsvorgang gesendet. Bitte lese sie gründlich und folge den darin beschriebenen Anweisungen. Bitte beachte, dass dies nicht die mauelle Verifikation neugestartet hat (wenn sie aktiviert ist), deshalb bringt es nichts diesen Knopf zu spammen.", + "dms-still-disabled": "Es scheint als wären deine PNs immer noch deaktiviert. Bitte aktiviere deine PNs um den Verifikationsprozess zu starten. Dies ist nicht optional, du musst das tun, um Zugriff auf %g zu erhalten.", + "dms-not-enabled-ping": "%p, es scheint als hättest du deine DMs deaktiviert. Bitte aktiviere sie und klicke auf den Knopf unter dieser Nachricht um dich zu verifizieren. Um dies zu tun hast du zwei Minuten Zeit.", + "scam-url-sent": "Gesendete Betrugs-URL in %c" + }, + "counter": { + "created-db-entry": "Datenbankeintrag für %i initialisiert", + "not-a-number": "Das ist keine Zahl. Du kannst hier nicht chatten. Versuche einen Thread zu erstellen, wenn deine Nachricht so wichtig ist.", + "banned-because-of-improper-use": "Ich musste dir den Zugriff auf diesen Kanal verbieten, da du ihn mehrmals falsch verwendet hast.", + "restriction-audit-log": "Dieser Nutzer hat nach fünf Warnungen weiterhin den Zähl-Kanal missbraucht, also habe ich ihn ausgesperrt.", + "only-one-message-per-person": "Nutzer müssen abwechseld zählen: Du kannst nicht zwei mal hintereinander zählen.", + "not-the-next-number": "Das ist nicht die nächste Zahl. Diese wäre **%n**, bitte stelle sicher immer eine Zahl nach der anderen zu zählen.", + "channel-topic-change-reason": "Jemand hat gezählt, also haben wir die Kanalbeschreibung wie in der Konfiguration angegeben bearbeitet" + }, + "tickets": { + "channel-not-found": "Ticket-Erstellungs-Kanal konnte nicht gefunden werden", + "existing-ticket": "Du hast bereits ein geöffnetes Ticket: %c", + "ticket-created-audit-log": "%u hat ein neues Ticket durch klicken des Knopfes erstellt", + "ticket-created": "Das Ticket wurde erfolgreich erstellt und das Team benachrichtigt. Du findest es hier: %c", + "new-ticket-embed-title": "📥 Neues Ticket #%i", + "new-ticket-embed-user": "👤 Nutzer", + "new-ticket-embed-info": "ℹ️ Information", + "close-info": "Dein Problem wurde behoben? Klicke den Knopf unten. Du kannst diese Nachricht immer in den angepinnten Nachrichten finden.", + "no-admin-pings": "Keine Erwähnungen konfiguriert. Überprüfe deine Konfiguration um dein Team zu benachrichtigen.", + "ticket-closed-successfully": "Ticket erfolgreich geschlossen. Dieser Kanal wird in wenigen Sekunden gelöscht. Danke, dass du unseren Support benachrichtigt hast.", + "ticket-closed-audit-log": "%u hat das Ticket geschlossen", + "closing-ticket": "Schließe Ticket, wie von %u angefragt...", + "could-not-dm": "Es konnte keine PN an %u gesendet werden: %r", + "no-log-channel": "Log-Kanal nicht gefunden", + "ticket-log-embed-title": "📎 Ticket %i geschlossen", + "ticket-with-user": "👤 Ticket-Nutzer", + "ticket-log": "Ticket-Protokoll", + "ticket-log-value": "Transkript mit %n Nachrichten kann [hier](%u) gefunden werden.", + "closed-by": "👷 Ticket geschlossen von", + "ticket-type": "☕ Ticket-Thema" + }, + "custom-commands": { + "not-a-booster": "Kein Booster", + "no-nickname": "Kein Nickname", + "ub-parameters-missing": "Einige Parameter für den Filter \"ub\" fehlen", + "filter-not-supported": "Filter \"%t\" wird auf dieser Version nicht unterstützt. Versuche deinen Bot zu updaten.", + "action-not-supported": "Aktion \"%t\" wird auf dieser Version nicht unterstützt. Versuche deinen Bot zu updaten.", + "audit-log": "Eigener Befehl %c wurde ausgeführt (ID: %i)", + "ub-error": "SCNX <-> UnbeliveaBoat hat mit %s und dem Inhalt \"%c\" geantwortet", + "ub-parse-error": "SCNX <-> UnbeliveaBoat hat mit %s geantwortet, allerdings konnte der Inhalt nicht geparst werden.", + "error-executing-action": "Ein Fehler ist bei Ausführung von Aktion %a im eigenen Befehl %c (ID: %i) aufgetreten: %e", + "not-found": "Dieser benutzerdefinierte Befehl existiert nicht mehr. Möglicherweise wurde es gelöscht oder deaktiviert.", + "parameter-not-set": "Dieser Parameter wurde nicht angegeben", + "true": "Richtig", + "false": "Falsch" + }, + "logging": { + "hook-installed": "Installierter Haken für den Empfang von Sonderveranstaltungen" + }, + "akinator": { + "command-description": "Lass akinator einen Charakter / ein Objekt oder ein Tier erraten", + "type-description": "Wähle aus, was Akinator erraten soll (Standart: Charakter)", + "character-name": "Charakter", + "object-name": "Objekt", + "animal-name": "Tier" + }, + "afk-system": { + "command-description": "Verwalte deinen AFK-Status auf diesem Server", + "end-command-description": "Beendet eine laufende AFK-Sitzung", + "start-command-description": "Startet eine neue AFK-Sitzung", + "reason-option-description": "Erkläre, warum du AFK gehst", + "autoend-option-description": "Der Bot wird automatisch dein AFK-Status beenden, sobald du eine Nachricht schreibst (Standart: an)", + "no-running-session": "Es scheint, als wärst du aktuell keine AFK-Sitzung am Laufen.", + "already-running-session": "Du hast bereits eine AFK-Sitzung am Laufen, du kannst sie jederzeit mit `/afk-system end` beenden.", + "afk-nickname-change-audit-log": "Der Nickname des Nutzers wurde aktualisiert, weil er eine AFK-Sitzung gestartet hat", + "can-not-edit-nickname": "Der Nickname von %u konnte nicht geändert werden: %e" + }, + "invite-tracking": { + "hook-installed": "Integration initialisiert, um mehr Informationen über Einladungen zu erhalten", + "log-channel-not-found-but-set": "Der angegebene Log-Kanal %c wurde nicht gefunden.", + "new-member": "Neues Mitglied beigetreten", + "member-leave": "Ein Mitglied hat den Server verlassen", + "invite-type": "Einladungs-Typ", + "member": "Mitglied", + "invite": "Einladung", + "invite-code": "Einladungscode: [%c](%u)", + "invite-channel": "Kanal: %c", + "expires-at": "Läuft ab am: %t", + "created-at": "Erstellt: am %t", + "inviter": "Einlader: %u (%a/%i aktive Einladungen)", + "uses": "Verwendungen: %u", + "createdAt": "Erstellt am: %t", + "max-uses": "Maximal-Verwendungen: %u", + "normal-invite": "Normaler Invite", + "vanity-invite": "Vanity-Einladung", + "missing-permissions": "Ich habe nicht die Rechte, den Inviter zu ermitteln", + "unknown-invite": "Sorry, ich kann nicht herausfinden, welchen Invite dieser Nutzer verwendet hat", + "joined-for-the-x-time": "%u ist dem Server schon %x-zuvor beigetreten, letztes mal am %t.", + "revoke-invite": "Dieser Invite entfernen", + "invite-not-found": "Dieser Invite wurde nicht gefunden... Vielleicht wurde er schon gelöscht?", + "invite-revoked": "Invite wurde erfolgreich entfernt.", + "missing-revoke-permissions": "Sorry, du kannst diesen Invite nicht entfernen: Du brauchst `MANAGE_GUILD` Rechte um diese Aktion durchzuführen.", + "invite-revoke-audit-log": "Dieser Invite wurde von %u entfernt", + "invite-revoked-error": "Invite konnte nicht gelöscht werden %c: %e", + "trace-command-description": "Verfolge die Einladungen eines Nutzers nach", + "argument-user-description": "Nutzer, dessen Einladungen zu verfolgen sind", + "invited-by": "Eingeladen von", + "invited-users": "Eingeladene Nutzer", + "inviter-not-found": "Ich konnte nicht ermitteln, wer diesen Nutzer eingeladen hat.", + "no-users-invited": "Dieses Mitglied hat keine anderen Nutzer eingeladen.", + "and-x-more-users": "und %x Nutzer mehr", + "and-x-more-invites": "und %x mehr Einladungen", + "created-invites": "Erstellte Einladungen", + "not-showing-left-users": "Eingeladene Nutzer, die den Server verlassen haben, werden hier nicht angezeigt.", + "no-invites": "Dieses Mitglied hat keine Einladungen erstellt", + "revoke-user-invite": "Alle Einladungen dieses Nutzers entfernen", + "revoked-invites-successfully": "Alle Einladungen dieses Nutzers wurden entfernt" + }, + "2022-countdown": { + "channel-not-found": "Countdown-Channel wurde nicht gefunden ):", + "days-left": "Noch %x Tage bis 2022", + "hours-left": "Noch %x Stunden bis 2022", + "2022-is-here": "2022 ist hier 🎉", + "channel-edit-audit-log": "Um die Aktualität des Countdowns sicherzustellen, wurde dieser Channel geupdatet." + }, + "tic-tac-toe": { + "command-description": "Spiele tic-tac-toe gegen jemanden im Chat", + "user-description": "Nutzer, gegen den du spielen willst", + "challenge-message": "%t, %u hat dich zu einer Runde tic-tac-toe herausgefordert! Klicke auf den Knopf unter dieser Nachricht um beizutreten! Diese Einladung wird in etwa 2 Minuten ablaufen, also zögere nicht sie anzunehmen.", + "accept-invite": "Spiel beitreten", + "deny-invite": "Nein, danke", + "self-invite-not-possible": "Bist du wirklich so einsam? Selbst Simon, ein total introvertierter Mensch ohne Freunde und Entwickler dieses Bots, kann einen Nutzer zum tic-tac-toe spielen finden... Du solltest das auch schaffen, versuche zum Beispiel %r einzuladen, vielleicht will er/sie eine Runde spielen?", + "invite-expired": "Entschuldigung, %u, %i hat deine Einladung tic-tac-toe zu spielen nicht rechtzeitig angenommen ):", + "invite-denied": "Entschuldigung, %u, aber %i hat deine Einladung tic-tac-toe zu spielen abgelehnt ):", + "you-are-not-the-invited-one": "Entschuldigung, aber diese Einladung gehört dir nicht. Du kannst dein eigenes Spiel mit `/tic-tac-toe` starten.", + "playing-header": "**TIC-TAC-TOE SPIEL LÄUFT GERADE**\n\n%u (🟢) VS %i (🟡)\nAktuell am Zug: %t\n\n%t, klicke einen Knopf mit einem weißen Kreis unter dieser Nachricht um deine Markierung zu setzen", + "win-header": "**TIC-TAC-TOE-GAME BEENDET**\n\n%u (🟢) VS %i (🟡)\n\n%w hat das Spiel gewonnen - GG!\n\n*Du kannst mit `/tic-tac-toe` eine neue Runde starten*", + "draw-header": "**TIC-TAC-TOE-GAME BEENDET**\n\n%u (🟢) VS %i (🟡)\n\nUnentschieden - niemand hat diese Runde gewonnen.", + "not-your-turn": "Du bist gerade nicht dran, hol dir einen Kaffee und versuche es später nochmal" + }, + "economy-system": { + "work-earned-money": "Der Benutzer %u hat %m %c durch Arbeit bekommen", + "crime-earned-money": "Der Benutzer %u hat %m %c durch ein Verbrechen bekommen", + "crime-loose-money": "Der Benutzer %u hat %m %c durch ein Verbrechen bekommen", + "message-drop-earned-money": "Der Benutzer %u hat %m %c gewonnen, indem er eine Nachricht geschrieben hat", + "rob-earned-money": "Der Benutzer %u hat %m %c durch den Raub von %v bekommen", + "weekly-earned-money": "Der Benutzer %u hat %m %c bekommen, indem er seine wöchentliche Belohnung einlöste", + "daily-earned-money": "Der Benutzer %u hat %m %c bekommen, indem er seine wöchentliche Belohnung einlöste", + "admin-self-abuse": "Der Admin %a wollte seine Berechtigungen missbrauchen, indem er sich selbst noch mehr Geld gab! Das kann und darf nicht ignoriert werden!", + "admin-self-abuse-answer": "Was für ein schlechter Administrator du bist, %u. Ich bin enttäuscht von dir! Ich muss das melden. Wenn ich wollte, könnte ich dich bannen!", + "added-money": "%i %c wurde dem Konto von %u hinzugefügt", + "removed-money": "%i %c wurde aus dem Konto von %u entfernt", + "set-money": "Der Kontostand von %u wurde auf %i gesetzt.", + "added-money-log": "Der Benutzer %u hat %i %c zum Konto von %v hinzugefügt", + "removed-money-log": "Der Benutzer %u hat %i %c aus dem Konto von %v entfernt", + "set-money-log": "Der Benutzer %u hat den Kontostand von %v auf %i %c gesetzt", + "command-description-main": "Verwende das Economy-System", + "command-description-work": "Verdiene etwas Geld, indem du arbeiten gehst", + "command-description-crime": "Verdiene etwas Geld, indem du ein Verbrechen begehst", + "command-description-rob": "Einem anderen Mitglied Geld klauen", + "option-description-rob-user": "Mitglied zum Ausrauben", + "command-description-daily": "Löse deinen täglichen Bonus ein", + "command-description-weekly": "Löse deinen wöchentlichen Bonus ein", + "command-description-balance": "Zeige dir den Kontostand von einem Mitglied", + "option-description-user": "Mitglied zum Ausführen einer Aktion", + "command-description-add": "Füge einem Nutzer Geld hinzu", + "command-description-remove": "Entferne Geld von einem Nutzer", + "option-description-amount": "Zu manipulierender Betrag", + "command-description-set": "Kontostand eines Mitglieds einstellen", + "option-description-balance": "Kontostand, welches das Mitglied bekommt", + "message-drop": "Nachrichten-Drop: Du hast %m %c einfach durch Chatten verdient!", + "created-item": "Der Nutzer %u hat einen neuen Shopartikel erstellt: Name: %n, ID: %i", + "item-duplicate": "Das Item existiert schon", + "role-to-high" : "Die angegebene Rolle ist höher, als die höchste Rolle des Bots. Deshalb kann der Bot die Rolle nicht vergeben. Das Item wurde **nicht** erstellt.", + "delete-item": "Der Nutzer %u hat einen Shopartikel entfernt: %i", + "user-purchase": "Der Nutzer %u hat den Shopartikel %i für %p gekauft.", + "shop-command-description": "Benutze das Shop-System", + "shop-command-description-add": "Erstelle ein neuen Artikel im Shop (nur für Administratoren)", + "shop-option-description-itemName": "Name des Artikels", + "shop-option-description-itemID": "ID des Artikels", + "shop-option-description-price": "Preis des Artikels", + "shop-option-description-role": "Rolle, die dem Nutzer, die den Artikel kaufen, zugewiesen wird", + "shop-command-description-buy": "Kaufe einen Artikel", + "shop-command-description-list": "Alle Artikel im Shop auflisten", + "shop-command-description-delete": "Entferne einen Artikel aus dem Shop", + "channel-not-found": "Kann den Ranglisten-Kanal mit der ID %c nicht finden", + "command-description-deposit": "Zahle xyz auf dein Bankkonto ein", + "option-description-amount-deposit": "Einzuzahlender Betrag", + "command-description-withdraw": "Hebe xyz von deinem Bankkonto ab", + "option-description-amount-withdraw": "Auszuzahlender Betrag", + "command-group-description-msg-drop-msg": "Aktiviere/Deaktiviere die Nachrichten-Drop-Nachricht", + "command-description-msg-drop-msg-enable": "Aktiviere die Nachrichten-Drop-Nachricht", + "command-description-msg-drop-msg-disable": "Deaktiviere die Nachrichten-Drop-Nachricht", + "command-description-destroy": "Die gesamte Wirtschaft zerstören (löscht alle Datenbankeinträge)", + "option-description-confirm": "Bitte bestätige, dass du wirklich die gesamte Wirtschaft zerstören willst", + "destroy-cancel-reply": "Glück gehabt. Du hast mich im letzten Moment gestoppt, bevor ich die Wirtschaft zerstört habe", + "destroy-reply": "Ok... Ich werde die gesamte Wirtschaft zerstören", + "destroy": "%u hat die Wirtschaft zerstört", + "migration-happening": "Datenbank-Schema nicht aktuell. Datenbank wird migriert. Starte deinen Bot nicht neu, um Datenverlust zu vermeiden.", + "migration-done": "Datenbank wurde erfolgreich migriert.", + "nothing-selected": "Nichts ausgewählt", + "select-menu-price": "Preis: %p" + }, + "team-list": { + "channel-not-found": "Kanal mit der ID %c konnte nicht gefunden werden, oder hat den falschen Typ (es werden nur Textkanäle unterstützt)", + "role-not-found": "Rolle mit ID %r konnte nicht gefunden werden", + "no-users-with-role": "Kein Mitglied des Servers hat die %r Rolle.", + "no-roles-selected": "Es wurden noch keine Rollen gelistet ):" + }, + "massrole": { + "command-description": "Verwalte die Rollen deiner Mitglieder", + "role-option-remove-description": "Die Rolle, die entfernt werden soll von allen Mitgliedern", + "remove-subcommand-description": "Entferne eine Rolle von allen Mitgliedern", + "remove-all-subcommand-description": "Entferne alle Rollen von allen Mitgliedern", + "role-option-add-description": "Die Rolle, die an alle Mitglieder vergeben wird", + "target-option-description": "Lege fest, ob Bots miteinbezogen werden sollen oder nicht", + "all-users": "Alle Mitglieder", + "bots": "Bots", + "humans": "Mitglieder (keine Bots)", + "add-subcommand-description": "Füge eine Rolle zu allen Mitgliedern hinzu", + "not-admin": "⚠️ Um diesen Befehl zu verwenden musst du zur adminRoles option im SCNX-Dashboard hinzugefügt werden. Falls du der Eigentümer dieses Bots bist, denk daran in deinen Servereinstellungen ebenfalls einen entsprechenden Override einzustellen um Missbrauch dieses Commands zu verhindern.", + "add-reason": "Massen-Rollenvergabe durch %u", + "remove-reason": "Massen-Rollenentfernung durch %u" + }, + "hunt-the-code": { + "display-name-description": "Name des Codes, der dem Mitglied angezeigt wird, wenn er den Code einlöst", + "error-creating-code": "Fehler beim erstellen des Codes \"{{displayName}}\". Eventuell ist der eingegebene Code schon in der Datenbank?", + "code-redeem-description": "Den Code den du einlösen möchtest", + "report-header": "Bericht für das Jage den Code-Spiel am %s", + "admin-command-description": "Verwalte die derzeitige Code-Jagd", + "create-code-description": "Erstelle ein neuen Code für die derzeitige Code-Jagd", + "code-description": "Lege den Code fest, der zum Einlösen verwendet werden soll (Standard: zufällig generiert)", + "code-created": "Code \"%displayName\" erfolgreich erstellt: \"%code\"", + "successful-reset": "Erfolgreich das aktuelle Code-Hunt-Spiel beendet - [hier](%url) ist dein Bericht - speichere die URL, wenn du später darauf zugreifen willst.", + "end-description": "Beendet die derzeitige Code-Jagd (löscht Mitglieder und Codes und erstellt einen Bericht)", + "command-description": "Einlösen oder Daten über die aktuelle Code-Jagd einsehen", + "redeem-description": "Löse ein Code ein den du gefunden hast", + "leaderboard-description": "Sehe dir die Rangliste an", + "profile-description": "Aktuelle Anzahl deiner gefundenen Codes anzeigen", + "no-codes-found": "Keine Codes bis jetzt eingelöst ):", + "no-users": "Es haben noch keine Benutzer Codes eingelöst ):", + "user-header": "Teilnehmende Mitglieder", + "code-header": "Codes", + "report-description": "Erstellt einen Bericht", + "report": "Du kannst den Bericht [hier](%url) finden." + }, + "status-role": { + "fulfilled": "Status-Rollen-Bedingung ist erfüllt", + "not-fulfilled": "Status-Rollen-Bedingung ist nicht mehr erfüllt" + }, + "color-me": { + "create-log-reason": "%user hat seine Boosting-Vorteile durch das erstellen der Rolle eingelöst", + "edit-log-reason": "%user hat seine Boosting-Vorteil-Rolle editiert", + "delete-unboost-log-reason": "%user hat aufgehört zu boosten daher wurde seine Rolle gelöscht", + "delete-manual-log-reason": "%user hat seine Rolle manuell gelöscht", + "command-description": "Fordere eine benutzerdefinierte Rolle als Belohnung für das Boosten an. Cooldown: 24h", + "manage-subcommand-description": "Erstelle oder editiere deine Custom Rolle", + "name-option-description": "Der Name deiner Custom Rolle", + "color-option-description": "Die Farbe deiner Custom Rolle", + "remove-subcommand-description": "Entferne deine Custom Rolle", + "confirm-option-remove-description": "Willst du deine Custom Rolle wirklich löschen? Dies wird keine laufenden Cooldowns zurücksetzen" + }, + "rock-paper-scissors": { + "stone": "Stein", + "paper": "Papier", + "scissors": "Schere", + "won": "gewonnen", + "lost": "verloren", + "tie": "Unentschieden", + "play-again": "Erneut spielen", + "challenge-message": "%t, %u hat dich zu einer Runde Schere Stein Papier herausgefordert! Klicke auf den Knopf unter dieser Nachricht, um beizutreten! Diese Einladung wird in etwa 2 Minuten ablaufen, also zögere nicht, sie anzunehmen.", + "invite-expired": "Entschuldigung, %u, %i hat deine Einladung, Schere Stein Papier zu spielen, nicht rechtzeitig angenommen ):", + "invite-denied": "Entschuldigung, %u, aber %i hat deine Einladung, Schere Stein Papier zu spielen, abgelehnt ):", + "rps-title": "Schere Stein Papier", + "rps-description": "Wähle deine Waffe!", + "its-a-tie-try-again": "Unentschieden! Versuch's nochmal!", + "command-description": "Spiele Schere Stein Papier gegen den Bot oder jemanden im Chat" + }, + "connect-four": { + "tie": "Unentschieden!", + "win": "%u hat das Spiel gewonnen!", + "not-turn": "Entschuldigung, aber du bist nicht an der Reihe!", + "game-message": "Vier-gewinnt-Spiel von %u1 und %u2\nAktuell spielt: %c %t.\n\n%g", + "challenge-message": "%t, %u hat dich zu einer Runde Vier gewinnt herausgefordert! Klicke auf den Knopf unter dieser Nachricht, um beizutreten! Diese Einladung wird in etwa 2 Minuten ablaufen, also zögere nicht, sie anzunehmen.", + "invite-expired": "Entschuldigung, %u, %i hat deine Einladung, Vier gewinnt zu spielen, nicht rechtzeitig angenommen ):", + "invite-denied": "Entschuldigung, %u, aber %i hat deine Einladung, Vier gewinnt zu spielen, abgelehnt ):", + "command-description": "Spiele Vier gewinnt gegen jemanden im Chat", + "field-size-description": "Die Größe des Spielfelds (Standard: 7)", + "challenge-yourself": "Du kannst dich nicht selbst herausfordern!", + "challenge-bot": "Du kannst Bots nicht herausfordern!" + }, + "uno": { + "command-description": "Spiele Uno gegen jemanden im Chat", + "challenge-message": "%u lädt zu einer Runde Uno ein! Klicke auf den Knopf unter dieser Nachricht, um beizutreten! Das Spiel startet %timestamp mit %count Spielern.", + "not-enough-players": "Es sind nicht genug Spieler für eine Runde Uno beigetreten!", + "user-cards": "%u: %cards Karten", + "already-joined": "Du bist bereits beigetreten!", + "view-deck": "Eigene Karten ansehen", + "draw": "Karte ziehen", + "uno": "Uno!", + "turn": "%u ist an der Reihe!", + "update-button": "Aktualisieren", + "use-drawn": "Möchtest du die gezogene Karte verwenden?", + "dont-use-drawn": "Nicht verwenden", + "win": "%u hat das Spiel gewonnen! Es wurden %turns Karten gespielt.", + "win-you": "Du hast das Spiel gewonnen!", + "missing-uno": "⚠️️ Du musst den Uno!-Button nutzen, bevor du die vorletzte Karte legst!", + "choose-color": "Wähle eine Farbe aus:", + "pending-draws": "Lege eine Ziehe 2/4-Karte, sonst musst du %count Karten ziehen!", + "not-ingame": "Du bist nicht in dem Uno-Spiel!", + "skip": "Überspringen", + "reverse": "Reverse", + "color": "Farbwahl", + "draw2": "Ziehe 2", + "colordraw4": "Farbwahl und ziehe 4", + "cant-uno": "Du kannst Uno aktuell nicht nutzen.", + "done-uno": "Du hast Uno gerufen!", + "auto-drawn-skip": "Dein Zug wurde übersprungen, da du die Karten sowieso hättest ziehen müssen.", + "start-game": "Spiel sofort starten", + "not-host": "Du bist nicht der Ersteller des Spiels!", + "max-players": "Das Spiel ist voll!", + "previous-cards": "Vorherige Karten: ", + "used-card": "Du hast die Karte %c bereits verwendet! Nutze den Aktualisieren-Button und spiele eine zulässige Karte.", + "invalid-card": "Du kannst die Karte %c momentan nicht spielen! Bitte spiele eine zulässige Karte.", + "inactive-warn": "%u, du bist bei Uno am Zug!", + "inactive-win": "Das Uno-Spiel wurde beendet. %u hat gewonnen, da alle anderen ausgeschieden sind!" + }, + "quiz": { + "what-have-i-voted": "Was habe ich gewählt?", + "vote": "Abstimmen!", + "vote-this": "Wähle diese Option, wenn du denkst, dass diese richtig ist.", + "voted-successfully": "Erfolgreich ausgewählt. Danke für deine Teilnahme.", + "not-voted-yet": "Du hast noch keine Antwort ausgewählt, also kann ich dir nicht zeigen, für was du abgestimmt hast?", + "you-voted": "Du hast **%o** als Antwort ausgewählt.", + "change-opinion": "Du kannst deine Auswahl jederzeit ändern, indem du einfach etwas anderes über dem Knopf, den du gerade angeklickt hast, auswählst.", + "cannot-change-opinion": "Du kannst deine Auswahl nicht ändern, da der Ersteller diese Funktion deaktiviert hat.", + "select-correct": "Wähle alle richtigen Antworten aus", + "this-correct": "Diese Antwort als richtig markieren", + "cmd-description": "Erstelle oder spiele Quiz", + "cmd-create-normal-description": "Erstelle ein Quiz mit bis zu 10 Antworten", + "cmd-create-bool-description": "Erstelle ein Quiz, bei dem Nutzer nur Ja oder Nein auswählen können", + "cmd-play-description": "Spiele ein Server-Quiz", + "cmd-leaderboard-description": "Zeigt das Quiz-Leaderboard des Servers", + "cmd-create-description-description": "Thema / Beschreibung des Quiz", + "cmd-create-channel-description": "Kanal, in welchem dieses Quiz erstellt werden soll", + "cmd-create-endAt-description": "Relative Dauer des Quiz", + "cmd-create-option-description": "Option Nummer %o", + "cmd-create-canchange-description": "Ob die Teilnehmer ihre Auswahl nachträglich ändern können (Standard: Nein)", + "daily-quiz-limit": "Du hast das Limit von **%l** täglichen Quiz erreicht. Du kannst %timestamp wieder Quiz spielen.", + "created": "Quiz erfolgreich in %c erstellt.", + "correct-highlighted": "Alle richtigen Antworten wurden hervorgehoben.", + "answer-correct": "✅ Deine Antwort war richtig, du hast einen Punkt fürs Leaderboard erhalten!", + "answer-wrong": "❌ Deine Antwort war falsch!", + "bool-true": "Aussage stimmt", + "bool-false": "Aussage stimmt nicht", + "leaderboard-channel-not-found": "Der Leaderboard-Kanal wurde nicht gefunden oder sein Typ ist nicht erlaubt.", + "leaderboard-notation": "**%p. %u**: %xp XP", + "your-rank": "Du hast **%xp** Punkte in Quiz gesammelt!", + "no-rank": "Du hast noch nie ein Quiz erfolgreich beendet!", + "no-quiz": "Es wurden noch keine Quiz erstellt. Serveradmins können auf https://scnx.app/glink?page=bot/configuration?query=quiz&file=quiz%7Cconfigs%2FquizList Quiz hinzufügen.", + "no-permission": "Du hast keine Berechtigung, um Quiz mit dem Befehl zu erstellen." + }, + "starboard": { + "invalid-minstars": "Ungültige Mindestanzahl an Sternen %stars", + "star-limit": "Du hast das stündliche Starboard-Limit von %limitEmoji auf dem Server erreicht, deswegen kannst du nicht auf die Nachricht %msgUrl reagieren.\nProbiers doch %time nochmal!" + }, + "nicknames": { + "owner-cannot-be-renamed": "Der Serverbesitzer (%u) kann nicht umbenannt werden.", + "nickname-error": "Fehler beim Ändern des Nicknamens von %u: %e" + } +} diff --git a/locales/en.json b/locales/en.json index a0637a9..0d9cb0a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -245,7 +245,28 @@ "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)", + "rewards-commands-disabled": "Reward commands are disabled in the configuration." }, "team-list": { "channel-not-found": "Could not find channel with ID %c or the channel has a wrong type (only text-channels supported)", @@ -503,6 +524,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", @@ -548,6 +570,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", @@ -603,10 +627,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", diff --git a/modules/levels/commands/manage-levels.js b/modules/levels/commands/manage-levels.js index 7e2c703..ca127ff 100644 --- a/modules/levels/commands/manage-levels.js +++ b/modules/levels/commands/manage-levels.js @@ -1,7 +1,72 @@ +const fs = require('fs'); +const path = require('path'); +const jsonfile = require('jsonfile'); const {registerNeededEdit} = require('../leaderboardChannel'); const {localize} = require('../../../src/functions/localize'); const {formatDiscordUserName} = require('../../../src/functions/helpers'); const {calculateLevelXP, displayLevel} = require('../events/messageCreate'); +const {reloadConfig} = require('../../../src/functions/configuration'); +const {getReplaceableRewardRoleIds} = require('../rewards'); + +function rewardsCommandsEnabled(client) { + const config = client.configurations?.levels?.config || {}; + return config.enableRewardCommands !== false; +} + +function getRewardsConfigPath(client) { + return path.join(client.configDir, 'levels', 'reward-roles.json'); +} + +function ensureRewardsDir(client) { + const dir = path.join(client.configDir, 'levels'); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, {recursive: true}); +} + +function readRewards(client) { + const filePath = getRewardsConfigPath(client); + try { + const data = jsonfile.readFileSync(filePath); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +function writeRewards(client, rewards) { + ensureRewardsDir(client); + jsonfile.writeFileSync(getRewardsConfigPath(client), rewards, {spaces: 2}); +} + +function collectRoles(interaction) { + const roles = [ + interaction.options.getRole('role', true), + interaction.options.getRole('role2'), + interaction.options.getRole('role3'), + interaction.options.getRole('role4'), + interaction.options.getRole('role5') + ].filter(Boolean).map(r => r.id); + return [...new Set(roles)]; +} + +function formatRoles(roleIds) { + if (!roleIds || roleIds.length === 0) return localize('levels', 'rewards-none'); + return roleIds.map(id => `<@&${id}>`).join(', '); +} + +function findEntry(rewards, level) { + return rewards.find(r => parseInt(r.level) === level); +} + +async function saveAndReload(interaction, rewards) { + writeRewards(interaction.client, rewards); + await reloadConfig(interaction.client); +} + +function ensureRewardsCommandsEnabled(interaction) { + if (rewardsCommandsEnabled(interaction.client)) return true; + interaction.reply({ephemeral: true, content: localize('levels', 'rewards-commands-disabled')}); + return false; +} async function runXPAction(interaction, newXP) { await interaction.deferReply({ @@ -26,16 +91,16 @@ async function runXPAction(interaction, newXP) { content: '⚠️ ' + localize('levels', 'negative-xp') }); - function runXPCheck() { + async function runXPCheck() { const nextLevelXp = calculateLevelXP(interaction.client, user.level + 1); if (nextLevelXp <= user.xp) { user.level = user.level + 1; - fixLevelRoles(interaction, member, user.level); - runXPCheck(); + await fixLevelRoles(interaction, member, user.level); + await runXPCheck(); } } - runXPCheck(); + await runXPCheck(); await user.save(); @@ -61,14 +126,46 @@ async function runXPAction(interaction, newXP) { } async function fixLevelRoles(interaction, member, level) { + const moduleConfig = interaction.client.configurations['levels']['config']; + const adjustedLevel = level - (moduleConfig.startFromZero ? 1 : 0); + if (adjustedLevel < 0) return; + + const rewardEntries = Array.isArray(interaction.client.configurations?.levels?.['reward-roles']) + ? interaction.client.configurations.levels['reward-roles'] + : []; + if (rewardEntries.length > 0) { + const sorted = rewardEntries + .slice() + .sort((a, b) => parseInt(a.level) - parseInt(b.level)); + for (const entry of sorted) { + const entryLevel = parseInt(entry.level); + if (!Number.isFinite(entryLevel) || entryLevel > adjustedLevel) continue; + const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : []; + if (roles.length === 0) continue; + if (entry.replacePrevious) { + for (const roleId of getReplaceableRewardRoleIds(interaction.client)) { + if (member.roles.cache.has(roleId)) { + await member.roles.remove(roleId, '[levels] ' + localize('levels', 'granted-rewards-audit-log')).catch(); + } + } + } + await member.roles.add(roles, '[levels] ' + localize('levels', 'granted-rewards-audit-log')).catch(); + } + return; + } + let highest = null; - for (const key in interaction.client.configurations.levels.config.reward_roles) { - const role = interaction.client.configurations.levels.config.reward_roles[key]; - if (parseInt(key) <= level) { - if (highest && highest < parseInt(key) && interaction.client.configurations.levels.config.onlyTopLevelRole) await member.roles.remove(interaction.client.configurations.levels.config.reward_roles[highest.toString()], '[levels] ' + localize('levels', 'granted-rewards-audit-log')).catch(); + for (const key in moduleConfig.reward_roles) { + const role = moduleConfig.reward_roles[key]; + if (parseInt(key) <= adjustedLevel) { + if (highest && highest < parseInt(key) && moduleConfig.onlyTopLevelRole) { + await member.roles.remove(moduleConfig.reward_roles[highest.toString()], '[levels] ' + localize('levels', 'granted-rewards-audit-log')).catch(); + } highest = parseInt(key); await member.roles.add(role, '[levels] ' + localize('levels', 'granted-rewards-audit-log')); - } else if (member.roles.cache.has(role)) await member.roles.remove(role, '[levels] ' + localize('levels', 'granted-rewards-audit-log')).catch(); + } else if (member.roles.cache.has(role)) { + await member.roles.remove(role, '[levels] ' + localize('levels', 'granted-rewards-audit-log')).catch(); + } } } @@ -116,6 +213,128 @@ async function runLevelAction(interaction, newLevel) { } module.exports.subcommands = { + 'rewards': { + 'add': async function (interaction) { + if (!ensureRewardsCommandsEnabled(interaction)) return; + const level = interaction.options.getInteger('level', true); + const roles = collectRoles(interaction); + const replacePrevious = interaction.options.getBoolean('replaceprevious'); + + const rewards = readRewards(interaction.client); + let entry = findEntry(rewards, level); + if (!entry) { + entry = {level, roles: [], replacePrevious: false}; + rewards.push(entry); + } + entry.roles = [...new Set([...(entry.roles || []), ...roles])]; + if (typeof replacePrevious === 'boolean') entry.replacePrevious = replacePrevious; + + await saveAndReload(interaction, rewards); + return interaction.reply({ + ephemeral: true, + content: localize('levels', 'rewards-added', { + l: level, + roles: formatRoles(entry.roles), + replace: entry.replacePrevious ? localize('levels', 'rewards-replace-on') : localize('levels', 'rewards-replace-off') + }) + }); + }, + 'set': async function (interaction) { + if (!ensureRewardsCommandsEnabled(interaction)) return; + const level = interaction.options.getInteger('level', true); + const roles = collectRoles(interaction); + const replacePrevious = interaction.options.getBoolean('replaceprevious'); + + const rewards = readRewards(interaction.client); + let entry = findEntry(rewards, level); + if (!entry) { + entry = {level, roles: [], replacePrevious: false}; + rewards.push(entry); + } + entry.roles = roles; + if (typeof replacePrevious === 'boolean') entry.replacePrevious = replacePrevious; + + await saveAndReload(interaction, rewards); + return interaction.reply({ + ephemeral: true, + content: localize('levels', 'rewards-set', { + l: level, + roles: formatRoles(entry.roles), + replace: entry.replacePrevious ? localize('levels', 'rewards-replace-on') : localize('levels', 'rewards-replace-off') + }) + }); + }, + 'remove': async function (interaction) { + if (!ensureRewardsCommandsEnabled(interaction)) return; + const level = interaction.options.getInteger('level', true); + const role = interaction.options.getRole('role', true); + + const rewards = readRewards(interaction.client); + const entry = findEntry(rewards, level); + if (!entry) { + return interaction.reply({ephemeral: true, content: localize('levels', 'rewards-level-not-found', {l: level})}); + } + entry.roles = (entry.roles || []).filter(r => r !== role.id); + if (entry.roles.length === 0) { + const idx = rewards.indexOf(entry); + if (idx >= 0) rewards.splice(idx, 1); + } + + await saveAndReload(interaction, rewards); + return interaction.reply({ + ephemeral: true, + content: localize('levels', 'rewards-removed', { + l: level, + role: role.toString() + }) + }); + }, + 'clear': async function (interaction) { + if (!ensureRewardsCommandsEnabled(interaction)) return; + const level = interaction.options.getInteger('level', true); + const rewards = readRewards(interaction.client); + const before = rewards.length; + const filtered = rewards.filter(r => parseInt(r.level) !== level); + if (filtered.length === before) { + return interaction.reply({ephemeral: true, content: localize('levels', 'rewards-level-not-found', {l: level})}); + } + await saveAndReload(interaction, filtered); + return interaction.reply({ephemeral: true, content: localize('levels', 'rewards-cleared', {l: level})}); + }, + 'list': async function (interaction) { + if (!ensureRewardsCommandsEnabled(interaction)) return; + const level = interaction.options.getInteger('level'); + const rewards = readRewards(interaction.client); + + if (level) { + const entry = findEntry(rewards, level); + if (!entry) { + return interaction.reply({ephemeral: true, content: localize('levels', 'rewards-level-not-found', {l: level})}); + } + return interaction.reply({ + ephemeral: true, + content: localize('levels', 'rewards-list-one', { + l: level, + roles: formatRoles(entry.roles || []), + replace: entry.replacePrevious ? localize('levels', 'rewards-replace-on') : localize('levels', 'rewards-replace-off') + }) + }); + } + + if (rewards.length === 0) { + return interaction.reply({ephemeral: true, content: localize('levels', 'rewards-list-empty')}); + } + const lines = rewards + .slice() + .sort((a, b) => parseInt(a.level) - parseInt(b.level)) + .map(r => localize('levels', 'rewards-list-line', { + l: r.level, + roles: formatRoles(r.roles || []), + replace: r.replacePrevious ? localize('levels', 'rewards-replace-on') : localize('levels', 'rewards-replace-off') + })); + return interaction.reply({ephemeral: true, content: lines.join('\n')}); + } + }, 'reset-xp': async function (interaction) { const type = interaction.options.getUser('user') ? 'user' : 'server'; if (!interaction.options.getBoolean('confirm')) return interaction.reply({ @@ -198,7 +417,160 @@ module.exports.config = { description: localize('levels', 'edit-xp-command-description'), options: function (client) { - const array = [{ + const array = []; + if (rewardsCommandsEnabled(client)) { + array.push({ + type: 'SUB_COMMAND_GROUP', + name: 'rewards', + description: localize('levels', 'rewards-command-description'), + options: [ + { + type: 'SUB_COMMAND', + name: 'add', + description: localize('levels', 'rewards-add-description'), + options: [ + { + type: 'INTEGER', + required: true, + name: 'level', + description: localize('levels', 'rewards-level-description') + }, + { + type: 'ROLE', + required: true, + name: 'role', + description: localize('levels', 'rewards-role-description') + }, + { + type: 'ROLE', + required: false, + name: 'role2', + description: localize('levels', 'rewards-role-description') + }, + { + type: 'ROLE', + required: false, + name: 'role3', + description: localize('levels', 'rewards-role-description') + }, + { + type: 'ROLE', + required: false, + name: 'role4', + description: localize('levels', 'rewards-role-description') + }, + { + type: 'ROLE', + required: false, + name: 'role5', + description: localize('levels', 'rewards-role-description') + }, + { + type: 'BOOLEAN', + required: false, + name: 'replaceprevious', + description: localize('levels', 'rewards-replace-description') + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'set', + description: localize('levels', 'rewards-set-description'), + options: [ + { + type: 'INTEGER', + required: true, + name: 'level', + description: localize('levels', 'rewards-level-description') + }, + { + type: 'ROLE', + required: true, + name: 'role', + description: localize('levels', 'rewards-role-description') + }, + { + type: 'ROLE', + required: false, + name: 'role2', + description: localize('levels', 'rewards-role-description') + }, + { + type: 'ROLE', + required: false, + name: 'role3', + description: localize('levels', 'rewards-role-description') + }, + { + type: 'ROLE', + required: false, + name: 'role4', + description: localize('levels', 'rewards-role-description') + }, + { + type: 'ROLE', + required: false, + name: 'role5', + description: localize('levels', 'rewards-role-description') + }, + { + type: 'BOOLEAN', + required: false, + name: 'replaceprevious', + description: localize('levels', 'rewards-replace-description') + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'remove', + description: localize('levels', 'rewards-remove-description'), + options: [ + { + type: 'INTEGER', + required: true, + name: 'level', + description: localize('levels', 'rewards-level-description') + }, + { + type: 'ROLE', + required: true, + name: 'role', + description: localize('levels', 'rewards-role-description') + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'clear', + description: localize('levels', 'rewards-clear-description'), + options: [ + { + type: 'INTEGER', + required: true, + name: 'level', + description: localize('levels', 'rewards-level-description') + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'list', + description: localize('levels', 'rewards-list-description'), + options: [ + { + type: 'INTEGER', + required: false, + name: 'level', + description: localize('levels', 'rewards-level-description') + } + ] + } + ] + }); + } + array.push({ type: 'SUB_COMMAND', name: 'reset-xp', description: localize('levels', 'reset-xp-description'), @@ -216,7 +588,7 @@ module.exports.config = { description: localize('levels', 'reset-xp-confirm-description') } ] - }]; + }); if (client.configurations['levels']['config']['allowCheats']) { array.push({ @@ -350,4 +722,4 @@ module.exports.config = { } return array; } -}; \ No newline at end of file +}; diff --git a/modules/levels/configs/config.json b/modules/levels/configs/config.json index ddd03df..da8b6df 100644 --- a/modules/levels/configs/config.json +++ b/modules/levels/configs/config.json @@ -269,6 +269,22 @@ "value": "roleID" } }, + { + "name": "enableRewardCommands", + "humanName": { + "en": "Enable reward commands", + "de": "Belohnungs-Befehle aktivieren" + }, + "default": { + "en": true, + "de": true + }, + "description": { + "en": "If enabled, /manage-levels rewards subcommands are available to edit reward roles.", + "de": "Wenn aktiviert, sind /manage-levels rewards Unterbefehle verfuegbar, um Belohnungsrollen zu bearbeiten." + }, + "type": "boolean" + }, { "name": "multiplication_roles", "humanName": { @@ -465,4 +481,4 @@ "type": "boolean" } ] -} \ 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 0000000..594ae7c --- /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 8020fdc..6fcffb4 100644 --- a/modules/levels/events/messageCreate.js +++ b/modules/levels/events/messageCreate.js @@ -45,7 +45,7 @@ module.exports.displayLevel = displayLevel; const {registerNeededEdit} = require('../leaderboardChannel'); const {localize} = require('../../../src/functions/localize'); const {client} = require('../../../main'); - +const {getReplaceableRewardRoleIds, getRewardForLevel} = require('../rewards'); const cooldown = new Set(); let currentlyLevelingUp = new Set(); @@ -95,7 +95,8 @@ async function grantXPAndLevelUP(client, member, xp, xpType, channel, msg = null const levelUpChannel = client.channels.cache.find(c => c.id === moduleConfig.level_up_channel_id && c.type === ChannelType.GuildText); const calculatedLevel = user.level - (client.configurations['levels']['config'].startFromZero ? 1 : 0); - const isRewardMessage = !!moduleConfig.reward_roles[calculatedLevel.toString()]; + const rewardConfig = getRewardForLevel(client, calculatedLevel); + const isRewardMessage = !!rewardConfig; const specialMessage = client.configurations['levels']['special-levelup-messages'].find(m => m.level === calculatedLevel); const randomMessages = client.configurations['levels']['random-levelup-messages'].filter(m => m.type === (isRewardMessage ? 'with-reward' : 'normal')); @@ -107,13 +108,15 @@ async function grantXPAndLevelUP(client, member, xp, xpType, channel, msg = null else if (randomMessages.length !== 0) messageToSend = randomElementFromArray(randomMessages).message; } - if (isRewardMessage) { - if (moduleConfig.onlyTopLevelRole) { - for (const role of Object.values(moduleConfig.reward_roles)) { - if (member.roles.cache.has(role)) await member.roles.remove(role, '[levels] ' + localize('levels', 'granted-rewards-audit-log')).catch(); + if (rewardConfig) { + if (rewardConfig.replacePrevious) { + for (const role of getReplaceableRewardRoleIds(client)) { + if (member.roles.cache.has(role)) { + await member.roles.remove(role, '[levels] ' + localize('levels', 'granted-rewards-audit-log')).catch(); + } } } - await member.roles.add(moduleConfig.reward_roles[calculatedLevel.toString()], '[levels]' + localize('levels', 'granted-rewards-audit-log')).catch(); + await member.roles.add(rewardConfig.roles, '[levels]' + localize('levels', 'granted-rewards-audit-log')).catch(); } if (specialMessage) messageToSend = specialMessage.message; @@ -122,7 +125,7 @@ async function grantXPAndLevelUP(client, member, xp, xpType, channel, msg = null '%avatarURL%': member.user.avatarURL() || member.user.defaultAvatarURL, '%username%': member.user.username, '%newLevel%': displayLevel(user.level, client), - '%role%': isRewardMessage ? `<@&${moduleConfig.reward_roles[calculatedLevel.toString()]}>` : localize('levels', 'no-role'), + '%role%': rewardConfig ? rewardConfig.roles.map(r => `<@&${r}>`).join(', ') : localize('levels', 'no-role'), '%tag%': formatDiscordUserName(member.user) }, {allowedMentions: {parse: ['users']}})); await user.save(); diff --git a/modules/levels/module.json b/modules/levels/module.json index 4fbb00c..ca46531 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 0000000..714be13 --- /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 c2ff96c..2ddb557 100644 --- a/modules/moderation/commands/moderate.js +++ b/modules/moderation/commands/moderate.js @@ -8,6 +8,7 @@ const { safeSetFooter } = require('../../../src/functions/helpers'); const {moderationAction} = require('../moderationActions'); +const {getLinkedGroup, linkAccounts, unlinkAccount, unlinkGroup} = require('../linkedAccounts'); const {activateLockdown, liftLockdown, isLockdownActive} = require('../lockdown'); const durationParser = require('parse-duration'); const {MessageEmbed} = require('discord.js'); @@ -16,7 +17,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 (!['ban', 'actions'].includes(interaction.options['_subcommand'])) return interaction.reply({ ephemeral: true, @@ -25,8 +35,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 }; } @@ -70,6 +80,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) { @@ -181,14 +216,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({ @@ -208,7 +327,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}) @@ -233,8 +353,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.filter(f => !f.managed).keys())}, parseDuration).then(r => { + const quarantineRoleId = interaction.client.configurations['moderation']['config']['quarantine-role-id']; + const roles = Array.from(interaction.memberToExecuteUpon.roles.cache.filter(f => !f.managed).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', { @@ -253,6 +376,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, @@ -265,7 +389,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}); @@ -276,7 +400,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}) }); @@ -288,12 +413,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}) }); @@ -305,7 +431,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}) }); @@ -317,7 +444,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}) }); @@ -329,7 +457,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}) }); @@ -341,7 +470,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}) }); @@ -403,57 +533,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(parseEmbedColor('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()) - .addFields(fs); safeSetFooter(embed, interaction.client); - sites.push(embed); + 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; @@ -479,6 +732,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 = { @@ -562,6 +868,25 @@ module.exports.config = { defaultMemberPermissions: ['MODERATE_MEMBERS'], options: function (client) { const opts = [ + { + 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', @@ -679,6 +1004,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') } ]; } @@ -705,6 +1036,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') } ]; } @@ -726,6 +1063,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') } ]; } @@ -746,6 +1089,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') } ]; } @@ -784,6 +1133,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') } ]; } @@ -816,6 +1171,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') } ]; } @@ -836,6 +1197,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') } ]; } @@ -862,6 +1229,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') } ]; } @@ -888,6 +1261,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') } ]; } @@ -908,10 +1287,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', @@ -921,7 +1395,13 @@ 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') + } ] }, { @@ -965,6 +1445,7 @@ module.exports.config = { description: localize('moderation', 'moderate-unlock-command-description') } ]; + const lockdownConfig = client.configurations['moderation']['lockdown']; if (lockdownConfig && lockdownConfig.enabled) { opts.push({ @@ -984,6 +1465,7 @@ module.exports.config = { }] }); } + return opts; } -}; \ No newline at end of file +}; diff --git a/modules/moderation/configs/config.json b/modules/moderation/configs/config.json index ef76531..b8da3b1 100644 --- a/modules/moderation/configs/config.json +++ b/modules/moderation/configs/config.json @@ -154,6 +154,271 @@ "content": "roleID", "category": "roles" }, + { + "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": { @@ -441,19 +706,56 @@ ], "category": "nicknames" }, + { + "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": { @@ -462,6 +764,23 @@ }, "category": "automod" }, + { + "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": { @@ -546,4 +865,4 @@ } } ] -} \ No newline at end of file +} diff --git a/modules/moderation/events/botReady.js b/modules/moderation/events/botReady.js index cff01d6..c970d85 100644 --- a/modules/moderation/events/botReady.js +++ b/modules/moderation/events/botReady.js @@ -72,7 +72,17 @@ exports.run = async (client) => { */ async function updateCache(client) { const moduleConfig = client.configurations['moderation']['config']; - memberCache['quarantine'] = client.guild.members.cache.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)); } async function deleteExpiredWarns(client) { diff --git a/modules/moderation/linkedAccounts.js b/modules/moderation/linkedAccounts.js new file mode 100644 index 0000000..ad49c26 --- /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 0000000..aa76346 --- /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 eadc715..33ec89f 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')) { @@ -204,11 +240,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': @@ -265,19 +342,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({ @@ -295,6 +443,31 @@ 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(', ') + }); + } + const victimFieldValue = victimMentions + (logVictimIDs.length === 1 ? `\n\`${victim.id}\`` : ''); const modEmbed = new MessageEmbed() .setColor(expiringAt ? 0xf1c40f : (type.includes('un') ? 0x2ecc71 : 0xe74c3c)) .setTimestamp() @@ -305,7 +478,7 @@ async function moderationAction(client, type, user, victim, reason, additionalDa }) .setTitle(`${localize('moderation', 'case')} #${modAction.actionID}`) .setThumbnail(client.user.avatarURL()) - .addField(localize('moderation', 'victim'), `${formatDiscordUserName(victim.user)}\n\`${victim.user.id}\``, true) + .addField(localize('moderation', 'victim'), victimFieldValue || localize('moderation', 'unknown'), true) .addField('User', `${formatDiscordUserName(user.user)}\n\`${user.user.id}\``, true) .addField(localize('moderation', 'action'), expiringAt ? `tmp-${type}` : type, true) .addFields(fields) @@ -315,9 +488,17 @@ async function moderationAction(client, type, user, victim, reason, additionalDa embeds: [modEmbed] }); } - const {updateCache} = require('./events/botReady'); - await updateCache(client); + if (!options.skipCacheUpdate) { + const {updateCache} = require('./events/botReady'); + updateCache(client).catch((e) => { + client.logger.warn('[moderation] updateCache failed', e); + }); + } resolve(modAction); + } catch (e) { + client.logger.error('[moderation] moderationAction failed', e); + reject(e); + } }); } @@ -345,6 +526,25 @@ async 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: { @@ -366,4 +566,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 9848e0a..386dcc9 100644 --- a/src/functions/helpers.js +++ b/src/functions/helpers.js @@ -789,12 +789,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(() => { }); }); @@ -1042,7 +1044,6 @@ module.exports.formatNumber = function (number) { module.exports.hashMD5 = function (string) { return crypto.createHash('md5').update(string).digest('hex'); }; - module.exports.shuffleArray = function (input) { const array = [...input]; for (let i = array.length - 1; i >= 0; i--) { @@ -1050,4 +1051,4 @@ module.exports.shuffleArray = function (input) { [array[i], array[j]] = [array[j], array[i]]; } return array; -} \ No newline at end of file +};