commit 9505750e296517e92d58ce0c5ef864fa1fed0cee Author: Mira <56395159+TheXorog@users.noreply.github.com> Date: Mon Jan 27 17:17:53 2025 +0100 refactor: Initial release diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dfe07704 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitconfig b/.gitconfig new file mode 100644 index 00000000..a5440100 --- /dev/null +++ b/.gitconfig @@ -0,0 +1,2 @@ +[core] + hooksPath = hooks diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..4e2473b8 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,134 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available +at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml new file mode 100644 index 00000000..68beb209 --- /dev/null +++ b/.github/workflows/dev.yml @@ -0,0 +1,71 @@ +name: Test Dev Branch +on: + push: + branches: [ "dev" ] + workflow_dispatch: +jobs: + secretscan: + name: Scan for Secrets + runs-on: self-hosted + steps: + - name: Clone Repository + uses: actions/checkout@v4 + with: + ref: dev + + - name: Secret Scan + uses: max/secret-scan@master + with: + exclude_path: 'SecretsIgnore.txt' + spellcheck: + name: Clone Repository + runs-on: self-hosted + steps: + - name: Checkout Actions Repository + uses: actions/checkout@v4 + with: + ref: dev + + - name: Check spelling + uses: crate-ci/typos@master + testbuild: + name: Build Project + runs-on: self-hosted + steps: + - name: Cleanup before build + run: rm -rf ProjectMakotoTest/ + + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 9.x + + - name: "Clone Repository" + uses: actions/checkout@v4 + with: + submodules: recursive + path: ProjectMakotoTest + token: ${{ secrets.Access_token }} + ref: dev + + - name: Install dependencies + run: dotnet restore + working-directory: ProjectMakotoTest/ProjectMakoto/ + + - name: Mark .sh files as executable + run: find . -type f -name "*.sh" -exec chmod +x {} + + working-directory: ProjectMakotoTest/OfficialPlugins/ + + - name: Prepare Makoto Plugin Build + run: sh update_deps.sh + working-directory: ProjectMakotoTest/OfficialPlugins/ + + - name: Test Build Makoto + run: dotnet publish --configuration RELEASE --runtime linux-x64 --no-self-contained --framework net9.0 + working-directory: ProjectMakotoTest/ProjectMakoto/ + timeout-minutes: 5 + + - name: Test Build Makoto Plugins + run: sh build_all.sh 1 + working-directory: ProjectMakotoTest/OfficialPlugins/ \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..99f8f981 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,119 @@ +name: Deploy to Production Server +on: + push: + branches: [ main ] + workflow_dispatch: + +permissions: write-all + +jobs: + build: + name: Build Project and Upload to Server + runs-on: self-hosted + steps: + - name: Cleanup + run: rm -rf ProjectMakoto/ + + - name: Install SSH Key + if: ${{ !github.event.act }} # skip during local actions testing + uses: shimataro/ssh-key-action@v2 + with: + key: ${{ secrets.SSH_TOKEN }} + known_hosts: ${{ secrets.SSH_KNOWN_HOST }} + + - name: Setup .NET Core SDK 9.x + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.x' + + - name: "Clone Repository" + uses: actions/checkout@v4 + with: + ref: main + submodules: recursive + path: ProjectMakoto + token: ${{ secrets.Access_token }} + + - name: Install Dependencies + run: dotnet restore + working-directory: ProjectMakoto/ProjectMakoto/ + + - name: Mark .sh files as executable + run: find . -type f -name "*.sh" -exec chmod +x {} + + working-directory: ProjectMakoto/OfficialPlugins/ + + - name: Prepare Makoto Plugin Build + run: sh update_deps.sh + working-directory: ProjectMakoto/OfficialPlugins/ + + - name: Build Makoto + run: dotnet publish --configuration RELEASE --runtime linux-x64 --no-self-contained --output "build" --property:PublishDir="build" --framework net9.0 + working-directory: ProjectMakoto/ProjectMakoto/ + timeout-minutes: 5 + + - name: Create Version File + run: git rev-parse --short HEAD > "ProjectMakoto/build/LatestGitPush.cfg" && git branch --show-current >> ProjectMakoto/build/LatestGitPush.cfg && echo $(date +%d.%m.%y) >> ProjectMakoto/build/LatestGitPush.cfg && echo $(date +%H:%M:%S,00) >> ProjectMakoto/build/LatestGitPush.cfg + working-directory: ProjectMakoto/ + + - name: Build Makoto Plugins + run: sh build_all.sh + working-directory: ProjectMakoto/OfficialPlugins/ + + - name: Create plugins directory + run: mkdir Plugins + working-directory: ProjectMakoto/ProjectMakoto/build + + - name: Move plugins into directory + run: mv ../OfficialPlugins/*.pmpl build/Plugins/ + working-directory: ProjectMakoto/ProjectMakoto/ + + - name: Commit new trusted hashes + working-directory: ProjectMakoto/ + run: | + git clone https://github.com/Fortunevale/ProjectMakoto.TrustedPlugins + cd ProjectMakoto.TrustedPlugins + mv ../OfficialPlugins/trusted_manifests/*.json hashes/ + git config --global user.name 'Project Makoto' + git config --global user.email 'ichigo@aitsys.dev' + git remote set-url origin https://x-access-token:${{ secrets.PROJECT_MAKOTO_ACCESS_TOKEN }}@github.com/Fortunevale/ProjectMakoto.TrustedPlugins + git add -A + git commit -am "Add new hashes" + git push + + - name: Sleep for 10 seconds + run: sleep 10 + shell: bash + + - name: Deploy to Production Server + if: ${{ !github.event.act }} # skip during local actions testing + run: rsync -avz --delete --force -e "ssh -p ${{ secrets.SSH_PORT }}" . ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_SERVER }}:/home/${{ secrets.SSH_USERNAME }}/Bots/latestProjectIchigo/ + working-directory: ProjectMakoto/ProjectMakoto/build/ + + - name: Send Update Signal to Production Client + if: ${{ !github.event.act }} # skip during local actions testing + run: ssh ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_SERVER }} -p ${{ secrets.SSH_PORT }} touch /home/${{ secrets.SSH_USERNAME }}/Bots/ProjectIchigo/updated + + - name: Create Zip File from Build + run: zip -r Release.zip build + working-directory: ProjectMakoto/ProjectMakoto/ + + - name: Truncate String + uses: 2428392/gh-truncate-string-action@v1.3.0 + id: truncatedString + with: + stringToTruncate: ${{ github.sha }} + maxLength: 10 + + - name: Create Release + if: ${{ !github.event.act }} # skip during local actions testing + uses: ncipollo/release-action@v1.14.0 + with: + artifacts: "ProjectMakoto/ProjectMakoto/Release.zip" + generateReleaseNotes: true + prerelease: false + tag: ${{ steps.truncatedString.outputs.string }} + commit: ${{ github.sha }} + + - name: Cleanup + run: rm -rf ProjectMakoto/ + if: always() \ No newline at end of file diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..175be6a7 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,148 @@ +name: Deploy to Preview Server +on: + push: + branches: [ preview ] + workflow_dispatch: + +permissions: write-all + +jobs: + secretscan: + if: ${{ !github.event.act }} # skip during local actions testing + name: Scan for Secrets + runs-on: self-hosted + steps: + - name: Clone Repository + uses: actions/checkout@v4 + with: + ref: preview + + - name: Secret Scan + uses: max/secret-scan@master + with: + exclude_path: 'SecretsIgnore.txt' + + spellcheck: + if: ${{ !github.event.act }} # skip during local actions testing + name: Spell Check with Typos + runs-on: self-hosted + steps: + - name: Clone Repository + uses: actions/checkout@v4 + with: + ref: preview + + - name: Check spelling + uses: crate-ci/typos@master + + preview_deploy: + name: Build Project and Upload to Server + runs-on: self-hosted + steps: + - name: Cleanup + run: rm -rf ProjectMakotoPreview/ + + - name: Install SSH Key + if: ${{ !github.event.act }} # skip during local actions testing + uses: shimataro/ssh-key-action@v2 + with: + key: ${{ secrets.SSH_TOKEN }} + known_hosts: ${{ secrets.SSH_KNOWN_HOST }} + + - name: Setup .NET Core SDK 8.x + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 9.x + + - name: "Clone Repository" + uses: actions/checkout@v4 + with: + ref: preview + submodules: recursive + path: ProjectMakotoPreview + token: ${{ secrets.Access_token }} + + - name: Install Dependencies + run: dotnet restore + working-directory: ProjectMakotoPreview/ProjectMakoto/ + + - name: Mark .sh files as executable + run: find . -type f -name "*.sh" -exec chmod +x {} + + working-directory: ProjectMakotoPreview/OfficialPlugins/ + + - name: Prepare Makoto Plugin Build + run: sh update_deps.sh + working-directory: ProjectMakotoPreview/OfficialPlugins/ + + - name: Build Makoto + run: dotnet publish --configuration RELEASE --runtime linux-x64 --no-self-contained --output "build" --property:PublishDir="build" --framework net9.0 + working-directory: ProjectMakotoPreview/ProjectMakoto/ + timeout-minutes: 5 + + - name: Create Version File + run: git rev-parse --short HEAD > "ProjectMakoto/build/LatestGitPush.cfg" && git branch --show-current >> ProjectMakoto/build/LatestGitPush.cfg && echo $(date +%d.%m.%y) >> ProjectMakoto/build/LatestGitPush.cfg && echo $(date +%H:%M:%S,00) >> ProjectMakoto/build/LatestGitPush.cfg + working-directory: ProjectMakotoPreview/ + + - name: Build Makoto Plugins + run: sh build_all.sh 1 + working-directory: ProjectMakotoPreview/OfficialPlugins/ + + - name: Create plugins directory + run: mkdir Plugins + working-directory: ProjectMakotoPreview/ProjectMakoto/build + + - name: Move plugins into directory + run: mv ../OfficialPlugins/*.pmpl build/Plugins/ + working-directory: ProjectMakotoPreview/ProjectMakoto/ + + - name: Commit new trusted hashes + working-directory: ProjectMakotoPreview/ + run: | + git clone https://github.com/Fortunevale/ProjectMakoto.TrustedPlugins + cd ProjectMakoto.TrustedPlugins + mv ../OfficialPlugins/trusted_manifests/*.json hashes/ + git config --global user.name 'Project Makoto' + git config --global user.email 'ichigo@aitsys.dev' + git remote set-url origin https://x-access-token:${{ secrets.PROJECT_MAKOTO_ACCESS_TOKEN }}@github.com/Fortunevale/ProjectMakoto.TrustedPlugins + git add -A + git commit -am "Add new preview hashes" + git push + + - name: Sleep for 10 seconds + run: sleep 10 + shell: bash + + - name: Deploy to Preview Server + if: ${{ !github.event.act }} # skip during local actions testing + run: rsync -avz --delete --force -e "ssh -p ${{ secrets.SSH_PORT }}" . ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_SERVER }}:/home/${{ secrets.SSH_USERNAME }}/Bots/latestProjectIchigoPreview/ + working-directory: ProjectMakotoPreview/ProjectMakoto/build/ + + - name: Send Update Signal to Preview Client + if: ${{ !github.event.act }} # skip during local actions testing + run: ssh ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_SERVER }} -p ${{ secrets.SSH_PORT }} touch /home/${{ secrets.SSH_USERNAME }}/Bots/ProjectIchigoPreview/updated + + - name: Create Zip File from Build + run: zip -r Release.zip build + working-directory: ProjectMakotoPreview/ProjectMakoto/ + + - name: Truncate String + uses: 2428392/gh-truncate-string-action@v1.3.0 + id: truncatedString + with: + stringToTruncate: ${{ github.sha }} + maxLength: 10 + + - name: Create Release + if: ${{ !github.event.act }} # skip during local actions testing + uses: ncipollo/release-action@v1.14.0 + with: + artifacts: "ProjectMakotoPreview/ProjectMakoto/Release.zip" + generateReleaseNotes: true + prerelease: true + tag: ${{ steps.truncatedString.outputs.string }}-pre + commit: ${{ github.sha }} + + - name: Cleanup + run: rm -rf ProjectMakotoPreview/ + if: always() \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a22b5467 --- /dev/null +++ b/.gitignore @@ -0,0 +1,348 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +.vscode/* +ProjectMakoto/Properties/launchSettings.json +ProjectMakoto/Properties/ +launchSettings.json + +!.vscode/Snippets.code-snippets +!.vscode/tasks.json +!.vscode/extensions.json +!.vscode/settings.json +ProjectMakoto/Plugins/* +OfficialPlugins/deps/ +OfficialPlugins/trusted_manifests/ +*.pmpl \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..9411d4c7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,33 @@ +[submodule "Xorog.UniversalExtensions"] + path = Dependencies/Xorog.UniversalExtensions + url = https://github.com/Fortunevale/Xorog.UniversalExtensions + branch = main +[submodule "quickchart-csharp"] + path = Dependencies/quickchart-csharp + url = https://github.com/TheXorog/quickchart-csharp + branch = applied-editorconfig +[submodule "DisCatSharp"] + path = Dependencies/DisCatSharp + url = https://github.com/Aiko-IT-Systems/DisCatSharp + branch = main +[submodule "OfficialPlugins/Example"] + path = OfficialPlugins/Example + url = https://github.com/Fortunevale/ProjectMakoto.Plugins.Example + branch = main +[submodule "OfficialPlugins/Social"] + path = OfficialPlugins/Social + url = https://github.com/Fortunevale/ProjectMakoto.Plugins.Social + branch = main +[submodule "OfficialPlugins/Translations"] + path = OfficialPlugins/Translations + url = https://github.com/Fortunevale/ProjectMakoto.Plugins.Translations + branch = main +[submodule "Tools"] + path = Tools + url = https://github.com/Fortunevale/ProjectMakoto.Tools +[submodule "OfficialPlugins/ProjectMakoto.Plugins.Music"] + path = OfficialPlugins/Music + url = https://github.com/Fortunevale/ProjectMakoto.Plugins.Music +[submodule "ScoreSaber"] + path = OfficialPlugins/ScoreSaber + url = https://github.com/Fortunevale/ProjectMakoto.Plugins.ScoreSaber diff --git a/.vscode/Snippets.code-snippets b/.vscode/Snippets.code-snippets new file mode 100644 index 00000000..6fb5d397 --- /dev/null +++ b/.vscode/Snippets.code-snippets @@ -0,0 +1,97 @@ +{ + // Place your ProjectIchigo workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and + // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope + // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is + // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: + // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. + // Placeholders with the same ids are connected. + // Example: + // "Print to console": { + // "scope": "javascript,typescript", + // "prefix": "log", + // "body": [ + // "console.log('$1');", + // "$2" + // ], + // "description": "Log output to console" + // } + "FileHeader": { + "prefix": "header", + "body": [ + "Project Makoto", + "Copyright (C) 2024 Fortunevale", + "This program is free software: you can redistribute it and/or modify", + "it under the terms of the GNU General Public License as published by", + "the Free Software Foundation, either version 3 of the License, or", + "(at your option) any later version.", + "This program is distributed in the hope that it will be useful,", + "but WITHOUT ANY WARRANTY; without even the implied warranty of", + "MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the", + "GNU General Public License for more details.", + "You should have received a copy of the GNU General Public License", + "along with this program. If not, see ." + ] + }, + "LogFatal": { + "prefix": "fatal", + "body": [ + "Log.Fatal($0$TM_SELECTED_TEXT);" + ] + }, + "LogError": { + "prefix": "error", + "body": [ + "Log.Error($0$TM_SELECTED_TEXT);" + ] + }, + "LogWarn": { + "prefix": "warn", + "body": [ + "Log.Warning($0$TM_SELECTED_TEXT);" + ] + }, + "LogInfo": { + "prefix": "info", + "body": [ + "Log.Information($0$TM_SELECTED_TEXT);" + ] + }, + "LogDebug": { + "prefix": "debug", + "body": [ + "Log.Debug($0$TM_SELECTED_TEXT);" + ] + }, + "LogVerbose": { + "prefix": "verbose", + "body": [ + "Log.Verbose($0$TM_SELECTED_TEXT);" + ] + }, + "AsyncTask": { + "prefix": "asynctask", + "body": [ + "$0_ = Task.Run(async () =>", + "{", + "\t$TM_SELECTED_TEXT", + "});" + ] + }, + "Task": { + "prefix": "task", + "body": [ + "$0_ = Task.Run(() =>", + "{", + "\t$TM_SELECTED_TEXT", + "});" + ] + }, + "Braces": { + "prefix": "braces", + "body": [ + "$0{", + "\t$TM_SELECTED_TEXT", + "}" + ] + } +} \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..be1557ab --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "ms-dotnettools.vscode-dotnet-runtime", + "ms-dotnettools.csharp", + "tdallau-csharpextensions.csharpextensions", + "anseki.vscode-color", + "peterschmalfeldt.explorer-exclude", + "christian-kohler.path-intellisense", + "ms-vscode.vs-keybindings" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..c754a380 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,28 @@ +{ + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/Thumbs.db": false, + "OfficialPlugins/deps": true, + "OfficialPlugins/trusted_manifests": true, + "hooks": true, + "_typos.toml": true, + "InitSubmodules.sh": true, + ".gitattributes": true, + ".gitconfig": true, + "LICENSE": true, + "renovate.json": true, + "ResetDevToPreview.sh": true, + "ResetPreviewToMain.sh": true, + "SetupGit.sh": true, + "TestRun.sh": true, + "UpdateSubmodules.sh": true, + "**/*.pmpl": true, + "event.json": true + }, + "explorerExclude.backup": {}, + "dotnet.defaultSolution": "ProjectMakoto/ProjectMakoto.sln" +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..237cd71e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/ProjectMakoto/ProjectMakoto.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/ProjectMakoto/ProjectMakoto.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/ProjectMakoto/ProjectMakoto.sln" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..fd392c53 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +

Makoto

+

+

A feature packed discord bot!

+ +

+

+

+ +

+ +## Step by Step Guide on how to set up a Development Environment + +1. Install the following applications. These should get you started with a basic environment for C# development. + - [Git CLI](https://www.git-scm.com/downloads) + - [Visual Studio 2022](https://visualstudio.microsoft.com/vs/) + - Select .NET desktop development +2. Log into your Github Account with the Git CLI and clone the following repository: + - `Fortunevale/ProjectMakoto` + - To clone `ProjectMakoto` with it's submodules run: `git clone --recurse-submodules "https://github.com/Fortunevale/ProjectMakoto.git"` + - _You can skip this step if you're developing a plugin._ +3. With this completed, you can already start developing for Makoto. To be able to debug Makoto, follow the guide below. + +## Running/Debugging Makoto with all necessary dependencies + +1. Install the following: + - [MariaDB Server](https://mariadb.org/download/) + - After installing the MariaDB Server, create 2 new databases: One for the main tables (guilds, users, scam_urls, etc.) and one for server members. + - You'll need a third database if you're using plugins. + +2. Create an account on the following sites: + - [Discord](https://discord.com) + - Create a new [Discord Team](https://discord.com/developers/teams) and add a new [Discord Application](https://discord.com/developers/applications/) to the previously created team. + - Add a bot to the application and note down the bot token. + - Makoto currently requires the `Presence`, `Server Members` and `Message Content` Intents. + - I recommend disabling the `Public Bot` Option so no one can add your development client to their server. + - [AbuseIPDB API Key](https://www.abuseipdb.com/account) + - After creating your account, you can create an api key [here](https://www.abuseipdb.com/account/api). + - [Github](https://github.com/) + - Create a [Personal Access Token](https://github.com/settings/tokens) to your Github Account. + - The bot needs to be able to create issues and read your repository. +3. Build and run Makoto until the console says something like "Config reloaded". +4. Open the `config.json` in the path you built Makoto in (usually `bin/Debug/`) and put in all values. +5. You're all set. + +## Useful resources for development + +- [DisCatSharp Documentation](https://docs.dcs.aitsys.dev/articles/preamble.html) +- [Translation Documentation](TRANSLATING.md) +- [Plugin Documentation](PLUGINS.md) \ No newline at end of file diff --git a/DeleteDrafts.sh b/DeleteDrafts.sh new file mode 100644 index 00000000..21a51142 --- /dev/null +++ b/DeleteDrafts.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# GitHub repository details (replace these with your repo owner and name) +REPO_OWNER="Fortunevale" +REPO_NAME="ProjectMakoto" + +# Check if dry-run switch is passed +dry_run=false +if [[ "$1" == "--dry-run" ]]; then + dry_run=true +fi + +# List all draft releases associated with the repository +echo "Fetching all draft releases for repository $REPO_OWNER/$REPO_NAME..." + +draft_releases=$(gh release list --repo "$REPO_OWNER/$REPO_NAME" --limit 100 | grep "Draft") + +if [[ -z "$draft_releases" ]]; then + echo "No draft releases found." + exit 0 +fi + +# Iterate through each draft release +echo "$draft_releases" | while read -r release; do + release_tag=$(echo "$release" | awk '{print $1}') + echo "Found draft release: $release_tag" + + if [[ "$dry_run" == true ]]; then + echo "[DRY RUN] Draft release '$release_tag' would be deleted." + else + echo "Deleting draft release '$release_tag'..." + gh release delete "$release_tag" --repo "$REPO_OWNER/$REPO_NAME" --yes + echo "Draft release '$release_tag' deleted." + fi +done diff --git a/DeleteOldTags.sh b/DeleteOldTags.sh new file mode 100644 index 00000000..ba341236 --- /dev/null +++ b/DeleteOldTags.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Check if dry-run switch is passed +dry_run=false +if [[ "$1" == "--dry-run" ]]; then + dry_run=true +fi + +# Get current date in seconds since the epoch +current_date=$(date +%s) + +# Find tags older than a month (30 days) +month_in_seconds=$((30 * 24 * 60 * 60)) + +# Get the list of tags +tags=$(git tag) + +echo "Checking for tags older than one month..." + +for tag in $tags; do + # Get the date of the tag in seconds since the epoch + tag_date=$(git log -1 --format=%at "$tag") + + # Calculate the age of the tag + tag_age=$((current_date - tag_date)) + + # Check if the tag is older than a month + if [[ $tag_age -ge $month_in_seconds ]]; then + if [[ "$dry_run" == true ]]; then + echo "[DRY RUN] Tag '$tag' would be deleted (Created: $(date -d @$tag_date))" + else + echo "Deleting tag '$tag' (Created: $(date -d @$tag_date))" + git tag -d "$tag" + git push --delete origin "$tag" + fi + fi +done diff --git a/Dependencies/DisCatSharp b/Dependencies/DisCatSharp new file mode 160000 index 00000000..5f4f5c46 --- /dev/null +++ b/Dependencies/DisCatSharp @@ -0,0 +1 @@ +Subproject commit 5f4f5c46f808fd2199a6cc8b6b9dabaa646b5d86 diff --git a/Dependencies/Xorog.UniversalExtensions b/Dependencies/Xorog.UniversalExtensions new file mode 160000 index 00000000..dadeb77e --- /dev/null +++ b/Dependencies/Xorog.UniversalExtensions @@ -0,0 +1 @@ +Subproject commit dadeb77e1667b6785ee8368eb92b2bbe1efba06e diff --git a/Dependencies/quickchart-csharp b/Dependencies/quickchart-csharp new file mode 160000 index 00000000..0b60f2e3 --- /dev/null +++ b/Dependencies/quickchart-csharp @@ -0,0 +1 @@ +Subproject commit 0b60f2e32db9d79dcc45311ee77b38b7a32ef776 diff --git a/DocAssets/DownloadProject1.png b/DocAssets/DownloadProject1.png new file mode 100644 index 00000000..0979e569 Binary files /dev/null and b/DocAssets/DownloadProject1.png differ diff --git a/DocAssets/DownloadRelease1.png b/DocAssets/DownloadRelease1.png new file mode 100644 index 00000000..26151bef Binary files /dev/null and b/DocAssets/DownloadRelease1.png differ diff --git a/DocAssets/ExamplePluginInfo1.png b/DocAssets/ExamplePluginInfo1.png new file mode 100644 index 00000000..ae25f45b Binary files /dev/null and b/DocAssets/ExamplePluginInfo1.png differ diff --git a/InitSubmodules.sh b/InitSubmodules.sh new file mode 100644 index 00000000..272af333 --- /dev/null +++ b/InitSubmodules.sh @@ -0,0 +1,5 @@ +echo "Downloading submodules.." +git submodule update --recursive +echo "Done" +sleep 10 +exit \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..e62ec04c --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ +GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/OfficialPlugins/Example b/OfficialPlugins/Example new file mode 160000 index 00000000..03899f8e --- /dev/null +++ b/OfficialPlugins/Example @@ -0,0 +1 @@ +Subproject commit 03899f8e5bc3d3b2807be7f90a518c28a830a0bb diff --git a/OfficialPlugins/Music b/OfficialPlugins/Music new file mode 160000 index 00000000..79e7cffb --- /dev/null +++ b/OfficialPlugins/Music @@ -0,0 +1 @@ +Subproject commit 79e7cffb4e38d68d2af71d321d8698fd799b3e59 diff --git a/OfficialPlugins/ScoreSaber b/OfficialPlugins/ScoreSaber new file mode 160000 index 00000000..b0318f56 --- /dev/null +++ b/OfficialPlugins/ScoreSaber @@ -0,0 +1 @@ +Subproject commit b0318f5602b29482638a71dae7184b075bc58b5a diff --git a/OfficialPlugins/Social b/OfficialPlugins/Social new file mode 160000 index 00000000..4e795fdd --- /dev/null +++ b/OfficialPlugins/Social @@ -0,0 +1 @@ +Subproject commit 4e795fddc75a59f2512243f59c02de3d0823cdd2 diff --git a/OfficialPlugins/Translations b/OfficialPlugins/Translations new file mode 160000 index 00000000..ea04fd27 --- /dev/null +++ b/OfficialPlugins/Translations @@ -0,0 +1 @@ +Subproject commit ea04fd278535c33a3b78d056adf6c25a5cb687bc diff --git a/OfficialPlugins/build_all.cmd b/OfficialPlugins/build_all.cmd new file mode 100644 index 00000000..e4bc8a91 --- /dev/null +++ b/OfficialPlugins/build_all.cmd @@ -0,0 +1,29 @@ +@echo off + +set "current_dir=%CD%" +call update_deps.cmd +cd /d "%current_dir%" + +del /q *.pmpl + +for /D %%i in (*) do ( + if /I "%%i" neq "deps" ( + if /I "%%i" neq "Example" ( + if exist "%%i\.build.cmd" ( + pushd "%%i" + echo Running .build.cmd in %%i + call .\.build.cmd + popd + + rem Move pmpl files to parent directory + move "%%i\*.pmpl" . + ) + ) + ) +) + +rmdir /s /q trusted_manifests +mkdir trusted_manifests + +cd deps +dotnet ProjectMakoto.dll --build-manifests .. --output-manifests ../trusted_manifests \ No newline at end of file diff --git a/OfficialPlugins/build_all.sh b/OfficialPlugins/build_all.sh new file mode 100644 index 00000000..e6b0c39a --- /dev/null +++ b/OfficialPlugins/build_all.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +current_dir=$(pwd) +./update_deps.sh + +if [ "$1" -ne 1 ]; then + ./update_deps.sh +fi + +if [ $? -ne 0 ]; then + echo "Error: update_deps.sh script failed. Exiting." + exit 1 +fi + +cd "$current_dir" +rm -f *.pmpl + +for i in */; do + # Exclude 'deps' and 'Example' directories + if [ "$i" != "deps/" ] && [ "$i" != "Example/" ]; then + # Check if .build.sh file exists and is executable + if [ -x "$i.build.sh" ]; then + cd "$i" + echo "Running .build.sh in $i" + ./.build.sh + + if [ $? -ne 0 ]; then + echo "Error: Build failed." + exit 1 + fi + + cd .. + + # Move pmpl files to parent directory + mv "$i"/*.pmpl . + fi + fi +done + +rm -rf trusted_manifests +mkdir trusted_manifests + +cd deps +dotnet ProjectMakoto.dll --build-manifests .. --output-manifests ../trusted_manifests \ No newline at end of file diff --git a/OfficialPlugins/move_all.cmd b/OfficialPlugins/move_all.cmd new file mode 100644 index 00000000..f0cf7ec8 --- /dev/null +++ b/OfficialPlugins/move_all.cmd @@ -0,0 +1,7 @@ +@echo off +echo Cleaning up old versions.. +rmdir \s \q "..\ProjectMakoto\bin\x64\Debug\net8.0\Plugins\" +mkdir "..\ProjectMakoto\bin\x64\Debug\net8.0\Plugins\" + +echo Moving new plugins.. +move "*.pmpl" "..\ProjectMakoto\bin\x64\Debug\net8.0\Plugins\" \ No newline at end of file diff --git a/OfficialPlugins/move_all.sh b/OfficialPlugins/move_all.sh new file mode 100644 index 00000000..16768d5a --- /dev/null +++ b/OfficialPlugins/move_all.sh @@ -0,0 +1,7 @@ +#!/bin/bash +echo "Cleaning up old versions.." +rm -rf "../ProjectMakoto/bin/x64/Debug/net8.0/Plugins/" +mkdir -p "../ProjectMakoto/bin/x64/Debug/net8.0/Plugins/" + +echo "Moving new plugins.." +mv *.pmpl "../ProjectMakoto/bin/x64/Debug/net8.0/Plugins/" diff --git a/OfficialPlugins/update_deps.cmd b/OfficialPlugins/update_deps.cmd new file mode 100644 index 00000000..515bfcdb --- /dev/null +++ b/OfficialPlugins/update_deps.cmd @@ -0,0 +1,34 @@ +@echo off + +rmdir /S /Q deps +dotnet publish ..\ProjectMakoto\ProjectMakoto.csproj --configuration RELEASE --runtime linux-x64 --no-self-contained --property:PublishDir="deps" --framework net9.0 +move ..\ProjectMakoto\deps deps + +set "original_dir=%CD%" +cd /d "..\Dependencies" + +for /d /r %%i in (*deps*) do ( + rd /s /q "%%i" +) + +cd /d "%original_dir%" + +git submodule update --init --depth 0 + +for /D %%i in (*) do ( + if /I "%%i" neq "deps" ( + if exist "%%i\.build.cmd" ( + echo Creating symlink in %%i to deps + if not exist "%%i\deps" ( + mklink /d "%%i\deps" "..\deps" + ) + + cd "%%i" + + echo Syncing git submodules in %%i + git submodule update --init --depth 0 + + cd .. + ) + ) +) diff --git a/OfficialPlugins/update_deps.sh b/OfficialPlugins/update_deps.sh new file mode 100644 index 00000000..4c538417 --- /dev/null +++ b/OfficialPlugins/update_deps.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# Remove existing deps directory +rm -rf deps + +# Publish ProjectMakoto +dotnet publish ../ProjectMakoto/ProjectMakoto.csproj --configuration RELEASE --runtime linux-x64 --no-self-contained --property:PublishDir="deps" --framework net9.0 +mv ../ProjectMakoto/deps deps + +# Change to Dependencies directory +original_dir=$(pwd) +cd ../Dependencies + +# Remove all directories with "*deps*" recursively +find . -type d -name "*deps*" -exec rm -rf {} \; + +# Change back to the original directory +cd "$original_dir" + +# Update git submodules in the current directory +git submodule update --init --depth 0 + +# Iterate over subdirectories +for i in */; do + # Exclude 'deps' directory + if [ "$i" != "deps/" ]; then + # Check if .build.cmd file exists + if [ -f "$i.build.cmd" ]; then + echo "Creating symlink in $i to deps" + + # Create symlink to deps directory + if [ ! -e "$i/deps" ]; then + # Check the operating system type + if [[ "$OSTYPE" == "msys" ]]; then + # MSYS (Git Bash) system + echo "mklink /d \"$i\\deps\" \"..\\deps\"" > temp.bat + c:/windows/system32/cmd.exe //c temp.bat + rm temp.bat + else + # Assume Linux + ln -s "../deps" "$i/deps" + fi + fi + + # Change to subdirectory + cd "$i" + + echo "Syncing git submodules in $i" + git submodule update --init --depth 0 + + # Change back to the original directory + cd "$original_dir" + fi + fi +done diff --git a/PLUGINS.md b/PLUGINS.md new file mode 100644 index 00000000..95c3dd5f --- /dev/null +++ b/PLUGINS.md @@ -0,0 +1,37 @@ +

Makoto

+

+

A feature packed discord bot!

+ +

+

+

+ +

+ +## Developing Plugins + +1. Download the latest version of Makoto [here](https://github.com/Fortunevale/ProjectMakoto/releases). + +

+ +2. Download the example plugin's source code [here](https://github.com/Fortunevale/ProjectMakoto.Plugins.Example). + +

+ +3. Create a folder called `deps` in the root directory of the example plugin. + +4. Drop all files of release zip archive into the `deps` folder. + +5. Open the project. + +6. Specify your Plugin's Name, Author and other details in `ExamplePlugin.cs`. + - The comments should help you get started. + - You can rename this file, project and everything else, inheriting the `BasePlugin` is what matters for Makoto to find and load your plugin. + +

+ +## Testing your plugin + +You need to set up Makoto ([Guide](CONTRIBUTING.md#running-makoto-with-all-necessary-dependencies)). Running/Debugging Makoto with all necessary dependencies. + +To run Makoto, you can instead use `dotnet run ProjectMakoto.dll` in the folder you saved Makoto to in Step 1 of Developing. \ No newline at end of file diff --git a/ProjectMakoto/.editorconfig b/ProjectMakoto/.editorconfig new file mode 100644 index 00000000..1bf01318 --- /dev/null +++ b/ProjectMakoto/.editorconfig @@ -0,0 +1,110 @@ +[*.cs] + +# CS8601: Possible null reference assignment. +dotnet_diagnostic.CS8601.severity = none + +# CS1998: Async method lacks 'await' operators and will run synchronously +dotnet_diagnostic.CS1998.severity = silent + +# CA1822: Mark members as static +dotnet_diagnostic.CA1822.severity = silent + +# IDE1006: Naming Styles +dotnet_diagnostic.IDE1006.severity = silent + +# IDE0044: Add readonly modifier +dotnet_diagnostic.IDE0044.severity = silent + +# IDE0017: Simplify object initialization +dotnet_diagnostic.CS8981.severity = silent +dotnet_diagnostic.IDE0017.severity = silent +csharp_indent_labels = one_less_than_current +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = false:suggestion +csharp_prefer_braces = true:silent +csharp_style_namespace_declarations = file_scoped:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_space_around_binary_operators = before_and_after + +# SYSLIB1045: Convert to 'GeneratedRegexAttribute'. +dotnet_diagnostic.SYSLIB1045.severity = silent + +# DCS0200: [Discord] Requires Features +dotnet_diagnostic.DCS0200.severity = silent + +# IDE0079: Remove unnecessary suppression +dotnet_diagnostic.IDE0079.severity = silent + +[*.{cs,vb}] +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 +end_of_line = lf +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = false:silent +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +file_header_template = Project Makoto\nCopyright (C) 2024 Fortunevale\nThis program is free software: you can redistribute it and/or modify\nit under the terms of the GNU General Public License as published by\nthe Free Software Foundation, either version 3 of the License, or\n(at your option) any later version.\nThis program is distributed in the hope that it will be useful,\nbut WITHOUT ANY WARRANTY; without even the implied warranty of\nMERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\nGNU General Public License for more details.\nYou should have received a copy of the GNU General Public License\nalong with this program. If not, see . + +# IDE0060: Remove unused parameter +dotnet_diagnostic.IDE0060.severity = silent diff --git a/ProjectMakoto/Assets/1.png b/ProjectMakoto/Assets/1.png new file mode 100644 index 00000000..578ff2a6 Binary files /dev/null and b/ProjectMakoto/Assets/1.png differ diff --git a/ProjectMakoto/Assets/ASCII.txt b/ProjectMakoto/Assets/ASCII.txt new file mode 100644 index 00000000..faeaa213 --- /dev/null +++ b/ProjectMakoto/Assets/ASCII.txt @@ -0,0 +1,44 @@ +r g?:? +r g:#J#g~? +r g:#YYY#~g?? g~7#JJJY? +r g?^YYYYJ7^g??????????~JYYYY#5g? +r g?7Y#!^:p????:::::???g^#YYYY#5g? +r g?!^p?????????????????g!JJYYJgY +r g??p???????????????????g!GYJ#5g# +r g?:p?????g?p??????g?p??::???g~PGYYGg: +r g?p??????g???p????g???p???????g^!!7g^ +r g?p??????????????????? ?????g? +r g?p:????????????????:??????g? +r g??p?????g:!!~?p?????????:?g?? +r g??p????g:^:p????::::g??? +r g???p???????????????g??? +r g??p::????????????????g? +r g??p:???????????????????::g? +r g?p?:???????????????????????:g? +r g?:p???????????????????????????g? +r g?p??????????????????????????????:g? +r g?:?p????g?^b!~~^^::g?p???????????????:g? +r g?::g?p?????g?:!bJ#####!g:?p??????????:::g? +r g:!b###!:g?p?????g?~b####~:g?p???g?p????????g?? +r g:!b#######~g?p?????g?b7#7:g?p????g?p???????g? +r g~b##########!g?p????g?b7#g~ ?p???g?:~!7~?p?:g? +r g!b############!:g?p??~b###!~g^~!7b#####g~?p:g? +r g~b###############77#################g^p?g? +r g?g:b###################################7:g? +r g??g^b####################################:g? +r g?:??g^b####################################:g? +r g??p?????g:b###################################7?:g^ +r g?p:!!^???g?b~######################g777b#########g^p^YPg^ +r g?p#GG5^???g:b!###################7g:p???b:~######~?~JJ#g? g?:^^^:? +r g?p:~^:?????g:b!##################g:p?~#!:g?b!####~g??p:YGGg# g?p~#5PGGGGGPJg: +r g?p????????:?g?b^7###############7g?p^PGG7g?:b##!g:p???~GGGGg: g~p5PGGGGGGGGGgg5 +r g?p???????????g?b^!#############7g?p:^^::?g?b^g^p????:JGGGGg! g?p?!5PGGgP5YYY55Y~ +r g?p????????????g?:b^!7##########g:p???????????:!5GGGGGg~ g?p??:YGY~g? +r g?p:??????::::g??? g?p^^g~~~~~~~g:p???????????7GGGGGGPg? g?p:???~7g^ +r g??:???????? ?p????????????????????~5GGGGPg^ g?p?:????g? +r g?:p???????:?????????:??^!#Jg#: g?p:???:?g? +r g??p???????????:::??:?g??? g?p?:???:g? +r g??p:?????? ??? g? g?p?:???:?g? +r g??::p?:???????g??????:p::::??g? +r g????:p:::::::::::???g?? +r g????????????? \ No newline at end of file diff --git a/ProjectMakoto/Assets/AddToServer.pdn b/ProjectMakoto/Assets/AddToServer.pdn new file mode 100644 index 00000000..892299c1 Binary files /dev/null and b/ProjectMakoto/Assets/AddToServer.pdn differ diff --git a/ProjectMakoto/Assets/AddToServer.png b/ProjectMakoto/Assets/AddToServer.png new file mode 100644 index 00000000..9a920f04 Binary files /dev/null and b/ProjectMakoto/Assets/AddToServer.png differ diff --git a/ProjectMakoto/Assets/Countries.json b/ProjectMakoto/Assets/Countries.json new file mode 100644 index 00000000..4ce947e1 --- /dev/null +++ b/ProjectMakoto/Assets/Countries.json @@ -0,0 +1,1262 @@ +[ + [ + "Andorra", + "EU", + "AD" + ], + [ + "United Arab Emirates", + "AS", + "AE" + ], + [ + "Afghanistan", + "AS", + "AF" + ], + [ + "Antigua and Barbuda", + "NA", + "AG" + ], + [ + "Anguilla", + "NA", + "AI" + ], + [ + "Albania", + "EU", + "AL" + ], + [ + "Armenia", + "AS", + "AM" + ], + [ + "Angola", + "AF", + "AO" + ], + [ + "Antarctica", + "AN", + "AQ" + ], + [ + "Argentina", + "SA", + "AR" + ], + [ + "American Samoa", + "OC", + "AS" + ], + [ + "Austria", + "EU", + "AT" + ], + [ + "Australia", + "OC", + "AU" + ], + [ + "Aruba", + "NA", + "AW" + ], + [ + "Aland Islands", + "EU", + "AX" + ], + [ + "Azerbaijan", + "AS", + "AZ" + ], + [ + "Bosnia and Herzegovina", + "EU", + "BA" + ], + [ + "Barbados", + "NA", + "BB" + ], + [ + "Bangladesh", + "AS", + "BD" + ], + [ + "Belgium", + "EU", + "BE" + ], + [ + "Burkina Faso", + "AF", + "BF" + ], + [ + "Bulgaria", + "EU", + "BG" + ], + [ + "Bahrain", + "AS", + "BH" + ], + [ + "Burundi", + "AF", + "BI" + ], + [ + "Benin", + "AF", + "BJ" + ], + [ + "Saint Barthelemy", + "NA", + "BL" + ], + [ + "Bermuda", + "NA", + "BM" + ], + [ + "Brunei", + "AS", + "BN" + ], + [ + "Bolivia", + "SA", + "BO" + ], + [ + "Bonaire, Saint Eustatius and Saba ", + "NA", + "BQ" + ], + [ + "Brazil", + "SA", + "BR" + ], + [ + "Bahamas", + "NA", + "BS" + ], + [ + "Bhutan", + "AS", + "BT" + ], + [ + "Bouvet Island", + "AN", + "BV" + ], + [ + "Botswana", + "AF", + "BW" + ], + [ + "Belarus", + "EU", + "BY" + ], + [ + "Belize", + "NA", + "BZ" + ], + [ + "Canada", + "NA", + "CA" + ], + [ + "Cocos Islands", + "AS", + "CC" + ], + [ + "Democratic Republic of the Congo", + "AF", + "CD" + ], + [ + "Central African Republic", + "AF", + "CF" + ], + [ + "Republic of the Congo", + "AF", + "CG" + ], + [ + "Switzerland", + "EU", + "CH" + ], + [ + "Ivory Coast", + "AF", + "CI" + ], + [ + "Cook Islands", + "OC", + "CK" + ], + [ + "Chile", + "SA", + "CL" + ], + [ + "Cameroon", + "AF", + "CM" + ], + [ + "China", + "AS", + "CN" + ], + [ + "Colombia", + "SA", + "CO" + ], + [ + "Costa Rica", + "NA", + "CR" + ], + [ + "Cuba", + "NA", + "CU" + ], + [ + "Cabo Verde", + "AF", + "CV" + ], + [ + "Curacao", + "NA", + "CW" + ], + [ + "Christmas Island", + "OC", + "CX" + ], + [ + "Cyprus", + "EU", + "CY" + ], + [ + "Czechia", + "EU", + "CZ" + ], + [ + "Germany", + "EU", + "DE" + ], + [ + "Djibouti", + "AF", + "DJ" + ], + [ + "Denmark", + "EU", + "DK" + ], + [ + "Dominica", + "NA", + "DM" + ], + [ + "Dominican Republic", + "NA", + "DO" + ], + [ + "Algeria", + "AF", + "DZ" + ], + [ + "Ecuador", + "SA", + "EC" + ], + [ + "Estonia", + "EU", + "EE" + ], + [ + "Egypt", + "AF", + "EG" + ], + [ + "Western Sahara", + "AF", + "EH" + ], + [ + "Eritrea", + "AF", + "ER" + ], + [ + "Spain", + "EU", + "ES" + ], + [ + "Ethiopia", + "AF", + "ET" + ], + [ + "Finland", + "EU", + "FI" + ], + [ + "Fiji", + "OC", + "FJ" + ], + [ + "Falkland Islands", + "SA", + "FK" + ], + [ + "Micronesia", + "OC", + "FM" + ], + [ + "Faroe Islands", + "EU", + "FO" + ], + [ + "France", + "EU", + "FR" + ], + [ + "Gabon", + "AF", + "GA" + ], + [ + "United Kingdom", + "EU", + "GB" + ], + [ + "Grenada", + "NA", + "GD" + ], + [ + "Georgia", + "AS", + "GE" + ], + [ + "French Guiana", + "SA", + "GF" + ], + [ + "Guernsey", + "EU", + "GG" + ], + [ + "Ghana", + "AF", + "GH" + ], + [ + "Gibraltar", + "EU", + "GI" + ], + [ + "Greenland", + "NA", + "GL" + ], + [ + "Gambia", + "AF", + "GM" + ], + [ + "Guinea", + "AF", + "GN" + ], + [ + "Guadeloupe", + "NA", + "GP" + ], + [ + "Equatorial Guinea", + "AF", + "GQ" + ], + [ + "Greece", + "EU", + "GR" + ], + [ + "South Georgia and the South Sandwich Islands", + "AN", + "GS" + ], + [ + "Guatemala", + "NA", + "GT" + ], + [ + "Guam", + "OC", + "GU" + ], + [ + "Guinea-Bissau", + "AF", + "GW" + ], + [ + "Guyana", + "SA", + "GY" + ], + [ + "Hong Kong", + "AS", + "HK" + ], + [ + "Heard Island and McDonald Islands", + "AN", + "HM" + ], + [ + "Honduras", + "NA", + "HN" + ], + [ + "Croatia", + "EU", + "HR" + ], + [ + "Haiti", + "NA", + "HT" + ], + [ + "Hungary", + "EU", + "HU" + ], + [ + "Indonesia", + "AS", + "ID" + ], + [ + "Ireland", + "EU", + "IE" + ], + [ + "Israel", + "AS", + "IL" + ], + [ + "Isle of Man", + "EU", + "IM" + ], + [ + "India", + "AS", + "IN" + ], + [ + "British Indian Ocean Territory", + "AS", + "IO" + ], + [ + "Iraq", + "AS", + "IQ" + ], + [ + "Iran", + "AS", + "IR" + ], + [ + "Iceland", + "EU", + "IS" + ], + [ + "Italy", + "EU", + "IT" + ], + [ + "Jersey", + "EU", + "JE" + ], + [ + "Jamaica", + "NA", + "JM" + ], + [ + "Jordan", + "AS", + "JO" + ], + [ + "Japan", + "AS", + "JP" + ], + [ + "Kenya", + "AF", + "KE" + ], + [ + "Kyrgyzstan", + "AS", + "KG" + ], + [ + "Cambodia", + "AS", + "KH" + ], + [ + "Kiribati", + "OC", + "KI" + ], + [ + "Comoros", + "AF", + "KM" + ], + [ + "Saint Kitts and Nevis", + "NA", + "KN" + ], + [ + "North Korea", + "AS", + "KP" + ], + [ + "South Korea", + "AS", + "KR" + ], + [ + "Kosovo", + "EU", + "XK" + ], + [ + "Kuwait", + "AS", + "KW" + ], + [ + "Cayman Islands", + "NA", + "KY" + ], + [ + "Kazakhstan", + "AS", + "KZ" + ], + [ + "Laos", + "AS", + "LA" + ], + [ + "Lebanon", + "AS", + "LB" + ], + [ + "Saint Lucia", + "NA", + "LC" + ], + [ + "Liechtenstein", + "EU", + "LI" + ], + [ + "Sri Lanka", + "AS", + "LK" + ], + [ + "Liberia", + "AF", + "LR" + ], + [ + "Lesotho", + "AF", + "LS" + ], + [ + "Lithuania", + "EU", + "LT" + ], + [ + "Luxembourg", + "EU", + "LU" + ], + [ + "Latvia", + "EU", + "LV" + ], + [ + "Libya", + "AF", + "LY" + ], + [ + "Morocco", + "AF", + "MA" + ], + [ + "Monaco", + "EU", + "MC" + ], + [ + "Moldova", + "EU", + "MD" + ], + [ + "Montenegro", + "EU", + "ME" + ], + [ + "Saint Martin", + "NA", + "MF" + ], + [ + "Madagascar", + "AF", + "MG" + ], + [ + "Marshall Islands", + "OC", + "MH" + ], + [ + "North Macedonia", + "EU", + "MK" + ], + [ + "Mali", + "AF", + "ML" + ], + [ + "Myanmar", + "AS", + "MM" + ], + [ + "Mongolia", + "AS", + "MN" + ], + [ + "Macao", + "AS", + "MO" + ], + [ + "Northern Mariana Islands", + "OC", + "MP" + ], + [ + "Martinique", + "NA", + "MQ" + ], + [ + "Mauritania", + "AF", + "MR" + ], + [ + "Montserrat", + "NA", + "MS" + ], + [ + "Malta", + "EU", + "MT" + ], + [ + "Mauritius", + "AF", + "MU" + ], + [ + "Maldives", + "AS", + "MV" + ], + [ + "Malawi", + "AF", + "MW" + ], + [ + "Mexico", + "NA", + "MX" + ], + [ + "Malaysia", + "AS", + "MY" + ], + [ + "Mozambique", + "AF", + "MZ" + ], + [ + "Namibia", + "AF", + "NA" + ], + [ + "New Caledonia", + "OC", + "NC" + ], + [ + "Niger", + "AF", + "NE" + ], + [ + "Norfolk Island", + "OC", + "NF" + ], + [ + "Nigeria", + "AF", + "NG" + ], + [ + "Nicaragua", + "NA", + "NI" + ], + [ + "Netherlands", + "EU", + "NL" + ], + [ + "Norway", + "EU", + "NO" + ], + [ + "Nepal", + "AS", + "NP" + ], + [ + "Nauru", + "OC", + "NR" + ], + [ + "Niue", + "OC", + "NU" + ], + [ + "New Zealand", + "OC", + "NZ" + ], + [ + "Oman", + "AS", + "OM" + ], + [ + "Panama", + "NA", + "PA" + ], + [ + "Peru", + "SA", + "PE" + ], + [ + "French Polynesia", + "OC", + "PF" + ], + [ + "Papua New Guinea", + "OC", + "PG" + ], + [ + "Philippines", + "AS", + "PH" + ], + [ + "Pakistan", + "AS", + "PK" + ], + [ + "Poland", + "EU", + "PL" + ], + [ + "Saint Pierre and Miquelon", + "NA", + "PM" + ], + [ + "Pitcairn", + "OC", + "PN" + ], + [ + "Puerto Rico", + "NA", + "PR" + ], + [ + "Palestinian Territory", + "AS", + "PS" + ], + [ + "Portugal", + "EU", + "PT" + ], + [ + "Palau", + "OC", + "PW" + ], + [ + "Paraguay", + "SA", + "PY" + ], + [ + "Qatar", + "AS", + "QA" + ], + [ + "Reunion", + "AF", + "RE" + ], + [ + "Romania", + "EU", + "RO" + ], + [ + "Serbia", + "EU", + "RS" + ], + [ + "Russia", + "EU", + "RU" + ], + [ + "Rwanda", + "AF", + "RW" + ], + [ + "Saudi Arabia", + "AS", + "SA" + ], + [ + "Solomon Islands", + "OC", + "SB" + ], + [ + "Seychelles", + "AF", + "SC" + ], + [ + "Sudan", + "AF", + "SD" + ], + [ + "South Sudan", + "AF", + "SS" + ], + [ + "Sweden", + "EU", + "SE" + ], + [ + "Singapore", + "AS", + "SG" + ], + [ + "Saint Helena", + "AF", + "SH" + ], + [ + "Slovenia", + "EU", + "SI" + ], + [ + "Svalbard and Jan Mayen", + "EU", + "SJ" + ], + [ + "Slovakia", + "EU", + "SK" + ], + [ + "Sierra Leone", + "AF", + "SL" + ], + [ + "San Marino", + "EU", + "SM" + ], + [ + "Senegal", + "AF", + "SN" + ], + [ + "Somalia", + "AF", + "SO" + ], + [ + "Suriname", + "SA", + "SR" + ], + [ + "Sao Tome and Principe", + "AF", + "ST" + ], + [ + "El Salvador", + "NA", + "SV" + ], + [ + "Sint Maarten", + "NA", + "SX" + ], + [ + "Syria", + "AS", + "SY" + ], + [ + "Eswatini", + "AF", + "SZ" + ], + [ + "Turks and Caicos Islands", + "NA", + "TC" + ], + [ + "Chad", + "AF", + "TD" + ], + [ + "French Southern Territories", + "AN", + "TF" + ], + [ + "Togo", + "AF", + "TG" + ], + [ + "Thailand", + "AS", + "TH" + ], + [ + "Tajikistan", + "AS", + "TJ" + ], + [ + "Tokelau", + "OC", + "TK" + ], + [ + "Timor Leste", + "OC", + "TL" + ], + [ + "Turkmenistan", + "AS", + "TM" + ], + [ + "Tunisia", + "AF", + "TN" + ], + [ + "Tonga", + "OC", + "TO" + ], + [ + "Turkey", + "AS", + "TR" + ], + [ + "Trinidad and Tobago", + "NA", + "TT" + ], + [ + "Tuvalu", + "OC", + "TV" + ], + [ + "Taiwan", + "AS", + "TW" + ], + [ + "Tanzania", + "AF", + "TZ" + ], + [ + "Ukraine", + "EU", + "UA" + ], + [ + "Uganda", + "AF", + "UG" + ], + [ + "United States Minor Outlying Islands", + "OC", + "UM" + ], + [ + "United States", + "NA", + "US" + ], + [ + "Uruguay", + "SA", + "UY" + ], + [ + "Uzbekistan", + "AS", + "UZ" + ], + [ + "Vatican", + "EU", + "VA" + ], + [ + "Saint Vincent and the Grenadines", + "NA", + "VC" + ], + [ + "Venezuela", + "SA", + "VE" + ], + [ + "British Virgin Islands", + "NA", + "VG" + ], + [ + "U.S. Virgin Islands", + "NA", + "VI" + ], + [ + "Vietnam", + "AS", + "VN" + ], + [ + "Vanuatu", + "OC", + "VU" + ], + [ + "Wallis and Futuna", + "OC", + "WF" + ], + [ + "Samoa", + "OC", + "WS" + ], + [ + "Yemen", + "AS", + "YE" + ], + [ + "Mayotte", + "AF", + "YT" + ], + [ + "South Africa", + "AF", + "ZA" + ], + [ + "Zambia", + "AF", + "ZM" + ], + [ + "Zimbabwe", + "AF", + "ZW" + ], + [ + "Serbia and Montenegro", + "EU", + "CS" + ], + [ + "Netherlands Antilles", + "NA", + "AN" + ] +] \ No newline at end of file diff --git a/ProjectMakoto/Assets/Dev.pdn b/ProjectMakoto/Assets/Dev.pdn new file mode 100644 index 00000000..16757841 Binary files /dev/null and b/ProjectMakoto/Assets/Dev.pdn differ diff --git a/ProjectMakoto/Assets/Dev.png b/ProjectMakoto/Assets/Dev.png new file mode 100644 index 00000000..3053b23b Binary files /dev/null and b/ProjectMakoto/Assets/Dev.png differ diff --git a/ProjectMakoto/Assets/DevSmall.png b/ProjectMakoto/Assets/DevSmall.png new file mode 100644 index 00000000..ddf062f8 Binary files /dev/null and b/ProjectMakoto/Assets/DevSmall.png differ diff --git a/ProjectMakoto/Assets/DiscordMessages.html b/ProjectMakoto/Assets/DiscordMessages.html new file mode 100644 index 00000000..ffd91544 --- /dev/null +++ b/ProjectMakoto/Assets/DiscordMessages.html @@ -0,0 +1,136 @@ + + + + + <!-- Title --> + + + + + + + + + + + + + + + +

messages deleted in on .

+

In this file, the content of messages is modified in order to properly format the messages. Want to view the raw messages? Click here.

+

Automatically generated by at .

+ + \ No newline at end of file diff --git a/ProjectMakoto/Assets/Emojis/Editing Files/Channel.pdn b/ProjectMakoto/Assets/Emojis/Editing Files/Channel.pdn new file mode 100644 index 00000000..21242f1a Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Editing Files/Channel.pdn differ diff --git a/ProjectMakoto/Assets/Emojis/Editing Files/CheckboxTickedBlue.pdn b/ProjectMakoto/Assets/Emojis/Editing Files/CheckboxTickedBlue.pdn new file mode 100644 index 00000000..abf7b0b1 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Editing Files/CheckboxTickedBlue.pdn differ diff --git a/ProjectMakoto/Assets/Emojis/Editing Files/CheckboxUntickedBlue.pdn b/ProjectMakoto/Assets/Emojis/Editing Files/CheckboxUntickedBlue.pdn new file mode 100644 index 00000000..c3892f10 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Editing Files/CheckboxUntickedBlue.pdn differ diff --git a/ProjectMakoto/Assets/Emojis/Editing Files/Guild.pdn b/ProjectMakoto/Assets/Emojis/Editing Files/Guild.pdn new file mode 100644 index 00000000..591a83f2 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Editing Files/Guild.pdn differ diff --git a/ProjectMakoto/Assets/Emojis/Editing Files/Invite.pdn b/ProjectMakoto/Assets/Emojis/Editing Files/Invite.pdn new file mode 100644 index 00000000..eb24dd16 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Editing Files/Invite.pdn differ diff --git a/ProjectMakoto/Assets/Emojis/Editing Files/Message.pdn b/ProjectMakoto/Assets/Emojis/Editing Files/Message.pdn new file mode 100644 index 00000000..01a3f292 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Editing Files/Message.pdn differ diff --git a/ProjectMakoto/Assets/Emojis/Editing Files/MessageCommand.pdn b/ProjectMakoto/Assets/Emojis/Editing Files/MessageCommand.pdn new file mode 100644 index 00000000..3c4772ff Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Editing Files/MessageCommand.pdn differ diff --git a/ProjectMakoto/Assets/Emojis/Editing Files/PillOff.pdn b/ProjectMakoto/Assets/Emojis/Editing Files/PillOff.pdn new file mode 100644 index 00000000..0ce1cd81 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Editing Files/PillOff.pdn differ diff --git a/ProjectMakoto/Assets/Emojis/Editing Files/PillOn.pdn b/ProjectMakoto/Assets/Emojis/Editing Files/PillOn.pdn new file mode 100644 index 00000000..34a73f24 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Editing Files/PillOn.pdn differ diff --git a/ProjectMakoto/Assets/Emojis/Editing Files/PrefixCommandDisabled.pdn b/ProjectMakoto/Assets/Emojis/Editing Files/PrefixCommandDisabled.pdn new file mode 100644 index 00000000..f55061ea Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Editing Files/PrefixCommandDisabled.pdn differ diff --git a/ProjectMakoto/Assets/Emojis/Editing Files/PrefixCommandEnabled.pdn b/ProjectMakoto/Assets/Emojis/Editing Files/PrefixCommandEnabled.pdn new file mode 100644 index 00000000..c15e234b Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Editing Files/PrefixCommandEnabled.pdn differ diff --git a/ProjectMakoto/Assets/Emojis/Editing Files/SlashCommand.pdn b/ProjectMakoto/Assets/Emojis/Editing Files/SlashCommand.pdn new file mode 100644 index 00000000..7c60ecde Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Editing Files/SlashCommand.pdn differ diff --git a/ProjectMakoto/Assets/Emojis/Editing Files/User.pdn b/ProjectMakoto/Assets/Emojis/Editing Files/User.pdn new file mode 100644 index 00000000..115b9cbb Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Editing Files/User.pdn differ diff --git a/ProjectMakoto/Assets/Emojis/Editing Files/UserCommand.pdn b/ProjectMakoto/Assets/Emojis/Editing Files/UserCommand.pdn new file mode 100644 index 00000000..2c3d09a2 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Editing Files/UserCommand.pdn differ diff --git a/ProjectMakoto/Assets/Emojis/Editing Files/VoiceStateUser.pdn b/ProjectMakoto/Assets/Emojis/Editing Files/VoiceStateUser.pdn new file mode 100644 index 00000000..1c4e6640 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Editing Files/VoiceStateUser.pdn differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/Channel.png b/ProjectMakoto/Assets/Emojis/Upload/Channel.png new file mode 100644 index 00000000..fdeb6c72 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/Channel.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/CheckboxTicked.png b/ProjectMakoto/Assets/Emojis/Upload/CheckboxTicked.png new file mode 100644 index 00000000..a6840538 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/CheckboxTicked.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/CheckboxUnticked.png b/ProjectMakoto/Assets/Emojis/Upload/CheckboxUnticked.png new file mode 100644 index 00000000..0056abf9 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/CheckboxUnticked.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/DisabledPause.png b/ProjectMakoto/Assets/Emojis/Upload/DisabledPause.png new file mode 100644 index 00000000..a41ea426 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/DisabledPause.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/DisabledPlay.png b/ProjectMakoto/Assets/Emojis/Upload/DisabledPlay.png new file mode 100644 index 00000000..71eaa69d Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/DisabledPlay.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/DisabledRepeat.png b/ProjectMakoto/Assets/Emojis/Upload/DisabledRepeat.png new file mode 100644 index 00000000..67646fe8 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/DisabledRepeat.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/DisabledShuffle.png b/ProjectMakoto/Assets/Emojis/Upload/DisabledShuffle.png new file mode 100644 index 00000000..88c6b254 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/DisabledShuffle.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/Dot.png b/ProjectMakoto/Assets/Emojis/Upload/Dot.png new file mode 100644 index 00000000..e45f7bdb Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/Dot.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/Error.png b/ProjectMakoto/Assets/Emojis/Upload/Error.png new file mode 100644 index 00000000..f2cdf9de Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/Error.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/Guild.png b/ProjectMakoto/Assets/Emojis/Upload/Guild.png new file mode 100644 index 00000000..d91d4a51 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/Guild.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/In.png b/ProjectMakoto/Assets/Emojis/Upload/In.png new file mode 100644 index 00000000..93d34040 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/In.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/Invite.png b/ProjectMakoto/Assets/Emojis/Upload/Invite.png new file mode 100644 index 00000000..a21d35d8 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/Invite.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/Loading.gif b/ProjectMakoto/Assets/Emojis/Upload/Loading.gif new file mode 100644 index 00000000..3136e2f2 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/Loading.gif differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/Message.png b/ProjectMakoto/Assets/Emojis/Upload/Message.png new file mode 100644 index 00000000..30295870 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/Message.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/MessageCommand.png b/ProjectMakoto/Assets/Emojis/Upload/MessageCommand.png new file mode 100644 index 00000000..a6c56fa7 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/MessageCommand.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/Paused.gif b/ProjectMakoto/Assets/Emojis/Upload/Paused.gif new file mode 100644 index 00000000..05769c74 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/Paused.gif differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/PillOff.png b/ProjectMakoto/Assets/Emojis/Upload/PillOff.png new file mode 100644 index 00000000..7018420a Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/PillOff.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/PillOn.png b/ProjectMakoto/Assets/Emojis/Upload/PillOn.png new file mode 100644 index 00000000..ae5e68e0 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/PillOn.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/PrefixCommandDisabled.png b/ProjectMakoto/Assets/Emojis/Upload/PrefixCommandDisabled.png new file mode 100644 index 00000000..c8278222 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/PrefixCommandDisabled.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/PrefixCommandEnabled.png b/ProjectMakoto/Assets/Emojis/Upload/PrefixCommandEnabled.png new file mode 100644 index 00000000..5727db49 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/PrefixCommandEnabled.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/QuestionMark.png b/ProjectMakoto/Assets/Emojis/Upload/QuestionMark.png new file mode 100644 index 00000000..251b93e8 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/QuestionMark.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/SlashCommand.png b/ProjectMakoto/Assets/Emojis/Upload/SlashCommand.png new file mode 100644 index 00000000..c721ef8d Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/SlashCommand.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/Soundcloud.png b/ProjectMakoto/Assets/Emojis/Upload/Soundcloud.png new file mode 100644 index 00000000..3b301b87 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/Soundcloud.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/Spotify.png b/ProjectMakoto/Assets/Emojis/Upload/Spotify.png new file mode 100644 index 00000000..648b27b3 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/Spotify.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/User.png b/ProjectMakoto/Assets/Emojis/Upload/User.png new file mode 100644 index 00000000..2a7f6534 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/User.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/UserCommand.png b/ProjectMakoto/Assets/Emojis/Upload/UserCommand.png new file mode 100644 index 00000000..d5bbd50b Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/UserCommand.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/VoiceState.png b/ProjectMakoto/Assets/Emojis/Upload/VoiceState.png new file mode 100644 index 00000000..8f26d0f6 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/VoiceState.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/YouTube.png b/ProjectMakoto/Assets/Emojis/Upload/YouTube.png new file mode 100644 index 00000000..b772efb0 Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/YouTube.png differ diff --git a/ProjectMakoto/Assets/Emojis/Upload/abuseipdb.png b/ProjectMakoto/Assets/Emojis/Upload/abuseipdb.png new file mode 100644 index 00000000..d7323c1f Binary files /dev/null and b/ProjectMakoto/Assets/Emojis/Upload/abuseipdb.png differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/BanRemoved.pdn b/ProjectMakoto/Assets/Icons/Editing Files/BanRemoved.pdn new file mode 100644 index 00000000..d2151683 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/BanRemoved.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/ChannelAdded.pdn b/ProjectMakoto/Assets/Icons/Editing Files/ChannelAdded.pdn new file mode 100644 index 00000000..1bf3097b Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/ChannelAdded.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/ChannelRemoved.pdn b/ProjectMakoto/Assets/Icons/Editing Files/ChannelRemoved.pdn new file mode 100644 index 00000000..5e4cf571 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/ChannelRemoved.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/ChannelUpdated.pdn b/ProjectMakoto/Assets/Icons/Editing Files/ChannelUpdated.pdn new file mode 100644 index 00000000..6deeb161 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/ChannelUpdated.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/GuildUpdated.pdn b/ProjectMakoto/Assets/Icons/Editing Files/GuildUpdated.pdn new file mode 100644 index 00000000..a9b378fa Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/GuildUpdated.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/InviteAdded.pdn b/ProjectMakoto/Assets/Icons/Editing Files/InviteAdded.pdn new file mode 100644 index 00000000..e62c1c22 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/InviteAdded.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/InviteRemoved.pdn b/ProjectMakoto/Assets/Icons/Editing Files/InviteRemoved.pdn new file mode 100644 index 00000000..5c981f81 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/InviteRemoved.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/MessageRemoved.pdn b/ProjectMakoto/Assets/Icons/Editing Files/MessageRemoved.pdn new file mode 100644 index 00000000..6077178d Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/MessageRemoved.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/MessageUpdated.pdn b/ProjectMakoto/Assets/Icons/Editing Files/MessageUpdated.pdn new file mode 100644 index 00000000..929dd242 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/MessageUpdated.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/UserAdded.pdn b/ProjectMakoto/Assets/Icons/Editing Files/UserAdded.pdn new file mode 100644 index 00000000..a19f24cf Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/UserAdded.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/UserBanned.pdn b/ProjectMakoto/Assets/Icons/Editing Files/UserBanned.pdn new file mode 100644 index 00000000..3838e844 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/UserBanned.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/UserKicked.pdn b/ProjectMakoto/Assets/Icons/Editing Files/UserKicked.pdn new file mode 100644 index 00000000..610eec89 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/UserKicked.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/UserRemoved.pdn b/ProjectMakoto/Assets/Icons/Editing Files/UserRemoved.pdn new file mode 100644 index 00000000..8eb1a69f Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/UserRemoved.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/UserUpdated.pdn b/ProjectMakoto/Assets/Icons/Editing Files/UserUpdated.pdn new file mode 100644 index 00000000..a4e65455 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/UserUpdated.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/UserWarned.pdn b/ProjectMakoto/Assets/Icons/Editing Files/UserWarned.pdn new file mode 100644 index 00000000..c64ae41e Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/UserWarned.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/VoiceStateUserJoined.pdn b/ProjectMakoto/Assets/Icons/Editing Files/VoiceStateUserJoined.pdn new file mode 100644 index 00000000..f2eb1dba Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/VoiceStateUserJoined.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/VoiceStateUserLeft.pdn b/ProjectMakoto/Assets/Icons/Editing Files/VoiceStateUserLeft.pdn new file mode 100644 index 00000000..c4f8015f Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/VoiceStateUserLeft.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Editing Files/VoiceStateUserUpdated.pdn b/ProjectMakoto/Assets/Icons/Editing Files/VoiceStateUserUpdated.pdn new file mode 100644 index 00000000..263e0c82 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Editing Files/VoiceStateUserUpdated.pdn differ diff --git a/ProjectMakoto/Assets/Icons/Upload/BanRemoved.png b/ProjectMakoto/Assets/Icons/Upload/BanRemoved.png new file mode 100644 index 00000000..cf9c9ac1 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/BanRemoved.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/ChannelAdded.png b/ProjectMakoto/Assets/Icons/Upload/ChannelAdded.png new file mode 100644 index 00000000..52649f30 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/ChannelAdded.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/ChannelRemoved.png b/ProjectMakoto/Assets/Icons/Upload/ChannelRemoved.png new file mode 100644 index 00000000..c72a03a3 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/ChannelRemoved.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/ChannelUpdated.png b/ProjectMakoto/Assets/Icons/Upload/ChannelUpdated.png new file mode 100644 index 00000000..d518cc79 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/ChannelUpdated.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/CheckMark Icon.png b/ProjectMakoto/Assets/Icons/Upload/CheckMark Icon.png new file mode 100644 index 00000000..77b23624 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/CheckMark Icon.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/Error Icon.png b/ProjectMakoto/Assets/Icons/Upload/Error Icon.png new file mode 100644 index 00000000..f0846ec7 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/Error Icon.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/GuildUpdated.png b/ProjectMakoto/Assets/Icons/Upload/GuildUpdated.png new file mode 100644 index 00000000..d3d60f98 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/GuildUpdated.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/InviteAdded.png b/ProjectMakoto/Assets/Icons/Upload/InviteAdded.png new file mode 100644 index 00000000..777d4d76 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/InviteAdded.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/InviteRemoved.png b/ProjectMakoto/Assets/Icons/Upload/InviteRemoved.png new file mode 100644 index 00000000..6f915441 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/InviteRemoved.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/MessageRemoved.png b/ProjectMakoto/Assets/Icons/Upload/MessageRemoved.png new file mode 100644 index 00000000..252f9a17 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/MessageRemoved.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/MessageUpdated.png b/ProjectMakoto/Assets/Icons/Upload/MessageUpdated.png new file mode 100644 index 00000000..a280a7d6 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/MessageUpdated.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/UserAdded.png b/ProjectMakoto/Assets/Icons/Upload/UserAdded.png new file mode 100644 index 00000000..ef6c163c Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/UserAdded.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/UserBanned.png b/ProjectMakoto/Assets/Icons/Upload/UserBanned.png new file mode 100644 index 00000000..a1e6cafc Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/UserBanned.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/UserKicked.png b/ProjectMakoto/Assets/Icons/Upload/UserKicked.png new file mode 100644 index 00000000..262c09b6 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/UserKicked.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/UserRemoved.png b/ProjectMakoto/Assets/Icons/Upload/UserRemoved.png new file mode 100644 index 00000000..f415d01f Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/UserRemoved.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/UserUpdated.png b/ProjectMakoto/Assets/Icons/Upload/UserUpdated.png new file mode 100644 index 00000000..37c00c20 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/UserUpdated.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/UserWarned.png b/ProjectMakoto/Assets/Icons/Upload/UserWarned.png new file mode 100644 index 00000000..82e9e99c Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/UserWarned.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/VoiceStateUserJoined.png b/ProjectMakoto/Assets/Icons/Upload/VoiceStateUserJoined.png new file mode 100644 index 00000000..64f7cd12 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/VoiceStateUserJoined.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/VoiceStateUserLeft.png b/ProjectMakoto/Assets/Icons/Upload/VoiceStateUserLeft.png new file mode 100644 index 00000000..cc7013eb Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/VoiceStateUserLeft.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/VoiceStateUserUpdated.png b/ProjectMakoto/Assets/Icons/Upload/VoiceStateUserUpdated.png new file mode 100644 index 00000000..212d01fb Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/VoiceStateUserUpdated.png differ diff --git a/ProjectMakoto/Assets/Icons/Upload/Warning.png b/ProjectMakoto/Assets/Icons/Upload/Warning.png new file mode 100644 index 00000000..24c549b4 Binary files /dev/null and b/ProjectMakoto/Assets/Icons/Upload/Warning.png differ diff --git a/ProjectMakoto/Assets/Languages.json b/ProjectMakoto/Assets/Languages.json new file mode 100644 index 00000000..ae7bb6e0 --- /dev/null +++ b/ProjectMakoto/Assets/Languages.json @@ -0,0 +1,422 @@ +[ + [ + "auto", + "Auto Detect" + ], + [ + "en", + "English" + ], + [ + "de", + "German" + ], + [ + "af", + "Afrikaans" + ], + [ + "sq", + "Albanian" + ], + [ + "am", + "Amharic" + ], + [ + "ar", + "Arabic" + ], + [ + "hy", + "Armenian" + ], + [ + "az", + "Azerbaijani" + ], + [ + "eu", + "Basque" + ], + [ + "be", + "Belarusian" + ], + [ + "bn", + "Bengali" + ], + [ + "bs", + "Bosnian" + ], + [ + "bg", + "Bulgarian" + ], + [ + "ca", + "Catalan" + ], + [ + "ceb", + "Cebuano" + ], + [ + "ny", + "Chichewa" + ], + [ + "zh-cn", + "Chinese Simplified" + ], + [ + "zh-tw", + "Chinese Traditional" + ], + [ + "co", + "Corsican" + ], + [ + "hr", + "Croatian" + ], + [ + "cs", + "Czech" + ], + [ + "da", + "Danish" + ], + [ + "nl", + "Dutch" + ], + [ + "eo", + "Esperanto" + ], + [ + "et", + "Estonian" + ], + [ + "tl", + "Filipino" + ], + [ + "fi", + "Finnish" + ], + [ + "fr", + "French" + ], + [ + "fy", + "Frisian" + ], + [ + "gl", + "Galician" + ], + [ + "ka", + "Georgian" + ], + [ + "el", + "Greek" + ], + [ + "gu", + "Gujarati" + ], + [ + "ht", + "Haitian Creole" + ], + [ + "ha", + "Hausa" + ], + [ + "haw", + "Hawaiian" + ], + [ + "iw", + "Hebrew" + ], + [ + "hi", + "Hindi" + ], + [ + "hmn", + "Hmong" + ], + [ + "hu", + "Hungarian" + ], + [ + "is", + "Icelandic" + ], + [ + "ig", + "Igbo" + ], + [ + "id", + "Indonesian" + ], + [ + "ga", + "Irish" + ], + [ + "it", + "Italian" + ], + [ + "ja", + "Japanese" + ], + [ + "jw", + "Javanese" + ], + [ + "kn", + "Kannada" + ], + [ + "kk", + "Kazakh" + ], + [ + "km", + "Khmer" + ], + [ + "ko", + "Korean" + ], + [ + "ku", + "Kurdish (Kurmanji)" + ], + [ + "ky", + "Kyrgyz" + ], + [ + "lo", + "Lao" + ], + [ + "la", + "Latin" + ], + [ + "lv", + "Latvian" + ], + [ + "lt", + "Lithuanian" + ], + [ + "lb", + "Luxembourgish" + ], + [ + "mk", + "Macedonian" + ], + [ + "mg", + "Malagasy" + ], + [ + "ms", + "Malay" + ], + [ + "ml", + "Malayalam" + ], + [ + "mt", + "Maltese" + ], + [ + "mi", + "Maori" + ], + [ + "mr", + "Marathi" + ], + [ + "mn", + "Mongolian" + ], + [ + "my", + "Myanmar (Burmese)" + ], + [ + "ne", + "Nepali" + ], + [ + "no", + "Norwegian" + ], + [ + "ps", + "Pashto" + ], + [ + "fa", + "Persian" + ], + [ + "pl", + "Polish" + ], + [ + "pt", + "Portuguese" + ], + [ + "ma", + "Punjabi" + ], + [ + "ro", + "Romanian" + ], + [ + "ru", + "Russian" + ], + [ + "sm", + "Samoan" + ], + [ + "gd", + "Scots Gaelic" + ], + [ + "sr", + "Serbian" + ], + [ + "st", + "Sesotho" + ], + [ + "sn", + "Shona" + ], + [ + "sd", + "Sindhi" + ], + [ + "si", + "Sinhala" + ], + [ + "sk", + "Slovak" + ], + [ + "sl", + "Slovenian" + ], + [ + "so", + "Somali" + ], + [ + "es", + "Spanish" + ], + [ + "su", + "Sundanese" + ], + [ + "sw", + "Swahili" + ], + [ + "sv", + "Swedish" + ], + [ + "tg", + "Tajik" + ], + [ + "ta", + "Tamil" + ], + [ + "te", + "Telugu" + ], + [ + "th", + "Thai" + ], + [ + "tr", + "Turkish" + ], + [ + "uk", + "Ukrainian" + ], + [ + "ur", + "Urdu" + ], + [ + "uz", + "Uzbek" + ], + [ + "vi", + "Vietnamese" + ], + [ + "cy", + "Welsh" + ], + [ + "xh", + "Xhosa" + ], + [ + "yi", + "Yiddish" + ], + [ + "yo", + "Yoruba" + ], + [ + "zu", + "Zulu" + ] +] \ No newline at end of file diff --git a/ProjectMakoto/Assets/Original.png b/ProjectMakoto/Assets/Original.png new file mode 100644 index 00000000..b9339e21 Binary files /dev/null and b/ProjectMakoto/Assets/Original.png differ diff --git a/ProjectMakoto/Assets/Pre.png b/ProjectMakoto/Assets/Pre.png new file mode 100644 index 00000000..b1e33eae Binary files /dev/null and b/ProjectMakoto/Assets/Pre.png differ diff --git a/ProjectMakoto/Assets/PreSmall.png b/ProjectMakoto/Assets/PreSmall.png new file mode 100644 index 00000000..e5eeab40 Binary files /dev/null and b/ProjectMakoto/Assets/PreSmall.png differ diff --git a/ProjectMakoto/Assets/Prod.png b/ProjectMakoto/Assets/Prod.png new file mode 100644 index 00000000..71be3a76 Binary files /dev/null and b/ProjectMakoto/Assets/Prod.png differ diff --git a/ProjectMakoto/Assets/ProdSmall.png b/ProjectMakoto/Assets/ProdSmall.png new file mode 100644 index 00000000..041b38f6 Binary files /dev/null and b/ProjectMakoto/Assets/ProdSmall.png differ diff --git a/ProjectMakoto/Bot.cs b/ProjectMakoto/Bot.cs new file mode 100644 index 00000000..9dcf4ae0 --- /dev/null +++ b/ProjectMakoto/Bot.cs @@ -0,0 +1,558 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using Octokit; +using GenHTTP.Engine; +using GenHTTP.Modules.IO; +using GenHTTP.Modules.Practices; +using GenHTTP.Api.Infrastructure; +using GenHTTP.Modules.StaticWebsites; +using Serilog; +using Microsoft.Extensions.Logging; +using Serilog.Events; +using Serilog.Core; +using ProjectMakoto.Entities.LoggingEnrichers; +using Status = ProjectMakoto.Entities.Status; +using Microsoft.CodeAnalysis; +using GenHTTP.Engine.Internal; + +namespace ProjectMakoto; + +public sealed class Bot +{ + #region Clients + + internal DatabaseClient DatabaseClient { get; set; } + + public DiscordShardedClient DiscordClient { get; internal set; } + + public ThreadJoinClient ThreadJoinClient { get; internal set; } + public AbuseIpDbClient AbuseIpDbClient { get; internal set; } + public MonitorClient MonitorClient { get; internal set; } + public ChartGeneration ChartsClient { get; set; } + public TokenInvalidatorRepository TokenInvalidator { get; internal set; } + public OfficialPluginRepository OfficialPlugins { get; internal set; } + internal GitHubClient GithubClient { get; set; } + + internal IServerHost WebServer { get; set; } + + #endregion Clients + + #region Plugins + public IReadOnlyDictionary Plugins => this._Plugins.AsReadOnly(); + internal Dictionary _Plugins { get; set; } = []; + + public IReadOnlyDictionary> PluginCommandModules => this._PluginCommandModules.AsReadOnly(); + internal Dictionary> _PluginCommandModules { get; set; } = new(); + + public IReadOnlyList CommandModules => this._CommandModules.AsReadOnly(); + internal List _CommandModules { get; set; } = new(); + #endregion + + #region Util + + public Translations LoadedTranslations { get; set; } + + public CountryCodes CountryCodes { get; internal set; } + public LanguageCodes LanguageCodes { get; internal set; } + internal IReadOnlyList ProfanityList { get; set; } + + internal BumpReminderHandler BumpReminder { get; set; } + internal ExperienceHandler ExperienceHandler { get; set; } + public TaskWatcher Watcher { get; internal set; } = new(); + + internal DatabaseDictionary PhishingHosts { get; set; } + internal DatabaseDictionary SubmittedHosts { get; set; } + + #endregion Util + + + #region Bans + + internal DatabaseList objectedUsers { get; set; } + internal DatabaseDictionary bannedUsers { get; set; } + internal DatabaseDictionary bannedGuilds { get; set; } + + internal DatabaseDictionary globalBans { get; set; } + internal SelfFillingDatabaseDictionary globalNotes { get; set; } + + #endregion Bans + + public Status status = new(); + public SelfFillingDatabaseDictionary Guilds { get; internal set; } = null; + public SelfFillingDatabaseDictionary Users { get; internal set; } = null; + + internal string RawFetchedPrivacyPolicy = ""; + internal string Prefix { get; private set; } = ";;"; + + internal ILoggerFactory msLoggerFactory; + internal Microsoft.Extensions.Logging.ILogger msLogger; + internal LoggingLevelSwitch loggingLevel; + + internal async Task Init(string[] args) + { + var sink = new LogsSink(this); + + loggingLevel = new LoggingLevelSwitch(); + + var loggingTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{Level:u3}] {Message:lj}{NewLine}{Exception}{ExceptionData:j}{BadRequestException:j}"; + + Log.Logger = new LoggerConfiguration() + .MinimumLevel.ControlledBy(this.loggingLevel) + .WriteTo.Console(outputTemplate: loggingTemplate) + .WriteTo.File($"logs/{DateTime.UtcNow.Ticks}.log", LogEventLevel.Debug, outputTemplate: loggingTemplate, retainedFileTimeLimit: TimeSpan.FromDays(7)) + .WriteTo.Sink(sink) + .Enrich.With() + .Enrich.With() + .CreateLogger(); + + this.msLoggerFactory = new LoggerFactory().AddSerilog(); + this.msLogger = msLoggerFactory.CreateLogger("ms"); + + ScheduledTaskExtensions.TaskStarted += this.TaskStarted; + UniversalExtensions.AttachLogger(msLogger); + + RenderAsciiArt(); + + if (args.Contains("--verbose")) + this.loggingLevel.MinimumLevel = LogEventLevel.Verbose; + else if (args.Contains("--debug")) + this.loggingLevel.MinimumLevel = LogEventLevel.Debug; + + Log.Debug("Environment Details\n\n" + + "Dotnet Version: {Version}\n" + + "OS & Version: {OSVersion}\n\n" + + "OS 64x: {Is64BitOperatingSystem}\n" + + "Process 64x: {Is64BitProcess}\n\n" + + "MachineName: {MachineName}\n" + + "UserName: {UserName}\n" + + "UserDomain: {UserDomainName}\n\n" + + "Current Directory: {CurrentDirectory}\n" + + "Commandline: {Commandline}\n", + Environment.Version, + Environment.OSVersion, + Environment.Is64BitOperatingSystem, + Environment.Is64BitProcess, + Environment.MachineName, + Environment.UserName, + Environment.UserDomainName, + Environment.CurrentDirectory, + Regex.Replace(Environment.CommandLine, @"(--token \S*)", "")); + + if (args.Contains("--build-manifests")) + { + await ManifestBuilder.BuildPluginManifests(this, args); + return; + } + + this.status.RunningVersion = (File.Exists("LatestGitPush.cfg") ? await File.ReadAllLinesAsync("LatestGitPush.cfg") : new string[] { "Development-Build" })[0].Trim(); + Log.Information("Starting up Makoto {RunningVersion}..\n", this.status.RunningVersion); + + var loadDatabase = Task.Run(async () => + { + try + { + await Util.Initializers.ConfigLoader.Load(this); + this.ThreadJoinClient = new ThreadJoinClient(); + this.MonitorClient = new MonitorClient(this); + this.AbuseIpDbClient = new AbuseIpDbClient(this); + this.TokenInvalidator = new TokenInvalidatorRepository(this); + this.OfficialPlugins = new OfficialPluginRepository(this); + this.ChartsClient = new ChartGeneration(this); + this.GithubClient = new GitHubClient(new ProductHeaderValue("ProjectMakoto", this.status.RunningVersion)) + { + Credentials = new Credentials(this.status.LoadedConfig.Secrets.Github.Token) + }; + + await Task.WhenAll(Util.Initializers.ListLoader.Load(this), + Util.Initializers.TranslationLoader.Load(this), + Util.Initializers.DependencyLoader.Load(this), + Task.Run(() => + { + UniversalExtensions.LoadAllReferencedAssemblies(AppDomain.CurrentDomain); + })); + + Util.Initializers.CommandCompiler.AssemblyReferences = AppDomain.CurrentDomain.GetAssemblies() + .Where(x => !x.IsDynamic && !x.Location.IsNullOrWhiteSpace()) + .Select(x => MetadataReference.CreateFromFile(x.Location)) + .ToList(); + + await Util.Initializers.PluginLoader.LoadPlugins(this); + _ = await DatabaseClient.InitializeDatabase(this); + _ = BasePlugin.RaiseDatabaseInitialized(this); + + _ = Directory.CreateDirectory("WebServer"); + + this.WebServer = await Host.Create() + .Port(this.status.LoadedConfig.WebServer.Port) + .Console() + .Defaults( + compression: true, + secureUpgrade: false, + strictTransport: false, + clientCaching: true, + rangeSupport: false, + preventSniffing: false) + .Handler(StaticWebsite.From(ResourceTree.FromDirectory("WebServer"))) + .StartAsync(); + + this.objectedUsers = new(this.DatabaseClient, "objected_users", "id", false); + + this.PhishingHosts = new(this.DatabaseClient, "scam_urls", "url", this.DatabaseClient.mainDatabaseConnection, (id) => + { + return new PhishingUrlEntry(this, id); + }); + + this.SubmittedHosts = new(this.DatabaseClient, "active_url_submissions", "messageid", this.DatabaseClient.mainDatabaseConnection, (id) => + { + return new SubmittedUrlEntry(this, id); + }); + + this.Users = new(this.DatabaseClient, "users", "userid", this.DatabaseClient.mainDatabaseConnection, (id) => + { + return new Entities.User(this, id); + }); + + this.Guilds = new(this.DatabaseClient, "guilds", "serverid", this.DatabaseClient.mainDatabaseConnection, (id) => + { + return new Entities.Guild(this, id); + }); + + this.globalNotes = new(this.DatabaseClient, "globalnotes", "id", this.DatabaseClient.mainDatabaseConnection, (id) => + { + return new Entities.GlobalNote(this, id); + }); + + this.bannedUsers = new(this.DatabaseClient, "banned_users", "id", this.DatabaseClient.mainDatabaseConnection, (id) => + { + return new BanDetails(this, "banned_users", id); + }); + + this.bannedGuilds = new(this.DatabaseClient, "banned_guilds", "id", this.DatabaseClient.mainDatabaseConnection, (id) => + { + return new BanDetails(this, "banned_guilds", id); + }); + + this.globalBans = new(this.DatabaseClient, "globalbans", "id", this.DatabaseClient.mainDatabaseConnection, (id) => + { + return new BanDetails(this, "globalbans", id); + }); + + this.BumpReminder = new(this); + } + catch (Exception ex) + { + Log.Fatal(ex, "An exception occurred while initializing data"); + await Task.Delay(5000); + Environment.Exit((int)ExitCodes.FailedDatabaseLogin); + } + + _ = new PhishingUrlHandler(this).UpdatePhishingUrlDatabase(); + }).Add(this).IsVital(); + + await loadDatabase.Task.WaitAsync(TimeSpan.FromSeconds(600)); + + var logInToDiscord = Task.Run(async () => + { + _ = Task.Delay(60000).ContinueWith(t => + { + if (!this.status.DiscordInitialized) + { + Log.Error("An exception occurred while trying to log into discord: {0}", "The log in took longer than 60 seconds"); + Environment.FailFast("An exception occurred while trying to log into discord: The log in took longer than 60 seconds"); + return; + } + }); + + await Util.Initializers.DisCatSharpExtensionsLoader.Load(this); + + Log.Information("Connecting and authenticating with Discord.."); + await this.DiscordClient.StartAsync(); + await Task.Delay(2000); + Log.Information("Connected and authenticated with Discord as {User}.", this.DiscordClient.CurrentUser.GetUsernameWithIdentifier()); + + this.status.DiscordInitialized = true; + await BasePlugin.RaiseConnected(this); + + await Util.Initializers.PostLoginTaskLoader.Load(this); + + foreach (var plugin in this.Plugins) + _ = plugin.Value.PostLoginInternalInit().Add(this); + + //foreach (var guild in this.DiscordClient.GetGuilds().Values) + // await this.DiscordClient.GetShard(guild.Id).BulkOverwriteGuildApplicationCommandsAsync(guild.Id, Array.Empty()).ConfigureAwait(false); + + //await this.DiscordClient.GetFirstShard().BulkOverwriteGlobalApplicationCommandsAsync(Array.Empty()).ConfigureAwait(false); + + _ = Task.Run(async () => + { + if (this.status.LoadedConfig.DontModify.LastStartedVersion == this.status.RunningVersion) + return; + + this.status.LoadedConfig.DontModify.LastStartedVersion = this.status.RunningVersion; + this.status.LoadedConfig.Save(); + + var channel = await this.DiscordClient.GetFirstShard().GetChannelAsync(this.status.LoadedConfig.Channels.GithubLog); + _ = await channel.SendMessageAsync(new DiscordEmbedBuilder + { + Color = EmbedColors.Success, + Title = $"Successfully updated to `{this.status.RunningVersion}`." + }); + }); + + _ = Task.Run(async () => + { + try + { + this.status.TeamOwner = this.DiscordClient.CurrentApplication.Team.Owner.Id; + Log.Information("Set {TeamOwner} as owner of the bot", this.status.TeamOwner); + + this.status._TeamMembers.AddRange(this.DiscordClient.CurrentApplication.Team.Members.Select(x => x.User.Id)); + Log.Information("Added {Count} users to administrator list", this.status.TeamMembers.Count); + } + catch (Exception ex) + { + Log.Error(ex, "An exception occurred trying to add team members to administrator list. Is the current bot registered in a team?"); + } + + try + { + if (this.DiscordClient.CurrentApplication.PrivacyPolicyUrl.IsNullOrWhiteSpace()) + throw new Exception("No privacy policy was defined."); + + this.RawFetchedPrivacyPolicy = await new HttpClient().GetStringAsync(this.DiscordClient.CurrentApplication.PrivacyPolicyUrl); + } + catch (Exception ex) + { + Log.Error(ex, "An exception occurred while trying to fetch the privacy policy"); + } + }); + + _ = this.ProcessDeletionRequests().Add(this); + }).Add(this).IsVital(); + + while (!loadDatabase.Task.IsCompleted || !logInToDiscord.Task.IsCompleted) + await Task.Delay(100); + + if (!loadDatabase.Task.IsCompletedSuccessfully) + { + Log.Fatal("An uncaught exception occurred while initializing the database.", loadDatabase.Task.Exception); + await Task.Delay(1000); + Environment.Exit((int)ExitCodes.FailedDatabaseLoad); + } + + if (!logInToDiscord.Task.IsCompletedSuccessfully) + { + Log.Fatal("An uncaught exception occurred while initializing the discord client.", logInToDiscord.Task.Exception); + await Task.Delay(1000); + Environment.Exit((int)ExitCodes.FailedDiscordLogin); + } + + AppDomain.CurrentDomain.ProcessExit += delegate + { + this.ExitApplication(true).Wait(); + }; + + Console.CancelKeyPress += delegate + { + Log.Information("Exiting, please wait.."); + this.ExitApplication().Wait(); + }; + + + _ = Task.Run(async () => + { + while (true) + { + if (File.Exists("updated")) + { + File.Delete("updated"); + await this.ExitApplication(); + return; + } + + await Task.Delay(1000); + } + }).Add(this).IsVital(); + + await Task.Delay(-1); + } + + private static void RenderAsciiArt() + { + try + { + var ASCII = File.ReadAllText("Assets/ASCII.txt"); + Console.WriteLine(); + foreach (var b in ASCII) + { + switch (b) + { + case 'g': + Console.ForegroundColor = ConsoleColor.DarkGray; + break; + case 'b': + Console.ForegroundColor = ConsoleColor.Blue; + break; + case 'r': + Console.ForegroundColor = ConsoleColor.White; + break; + case 'p': + Console.ForegroundColor = ConsoleColor.DarkBlue; + break; + default: + Console.Write(b); + break; + } + } + Console.WriteLine("\n\n"); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to render ASCII art"); + } + + Console.ResetColor(); + } + + internal Task GetPrefix(DiscordMessage message) + { + return Task.Run(() => + { + //if (IsDev) + // if (!_status.TeamMembers.Any(x => x == message.Author.Id)) + // return -1; + + var currentPrefix = this.Guilds.TryGetValue(message.GuildId ?? 0, out var guild) ? guild.PrefixSettings.Prefix : this.Prefix; + + var CommandStart = -1; + + if (!(guild?.PrefixSettings.PrefixDisabled ?? false)) + CommandStart = CommandsNextUtilities.GetStringPrefixLength(message, currentPrefix); + + if (CommandStart == -1) + CommandStart = CommandsNextUtilities.GetMentionPrefixLength(message, this.DiscordClient.CurrentUser); + + return CommandStart; + }); + } + bool ExitCalled = false; + + internal async Task ExitApplication(bool Immediate = false) + { + _ = Task.Delay(Immediate ? TimeSpan.FromSeconds(10) : TimeSpan.FromMinutes(5)).ContinueWith(async x => + { + if (x.IsCompletedSuccessfully) + { + Environment.Exit((int)ExitCodes.ExitTasksTimeout); // Fail-Safe in case the shutdown tasks lock up + await Task.Delay(5000); + Environment.FailFast(null); + } + }); + + if (this.DatabaseClient.Disposed || this.ExitCalled) // When the Database Client has been disposed, the Exit Call has already been made. + return; + + this.ExitCalled = true; + + Log.Information("Preparing to shut down Makoto.."); + + _ = await this.WebServer.StopAsync(); + + foreach (var b in this.Plugins) + { + try + { + Log.Information("Shutting down '{0}'..", b.Value.Name); + await b.Value.Shutdown(); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to shutdown", b.Value.Name); + } + } + + if (this.status.DiscordInitialized && !Immediate) + { + try + { + Stopwatch sw = new(); + sw.Start(); + + if (!this.status.DiscordCommandsRegistered) + Log.Warning("Startup is incomplete. Waiting for Startup to finish to shutdown.."); + + while (!this.status.DiscordCommandsRegistered && sw.ElapsedMilliseconds < TimeSpan.FromMinutes(5).TotalMilliseconds) + await Task.Delay(500); + + await Util.Initializers.SyncTasks.ExecuteSyncTasks(this, this.DiscordClient); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to run sync tasks"); + } + + try + { + Log.Information("Closing Discord Client.."); + + await this.DiscordClient.UpdateStatusAsync(userStatus: UserStatus.Offline); + await this.DiscordClient.StopAsync(); + + Log.Debug("Closed Discord Client."); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to close Discord Client gracefully."); + } + } + + await Task.Delay(500); + Log.Information("Goodbye!"); + + await Task.Delay(500); + Environment.Exit(0); + await Task.Delay(10000); + Environment.FailFast("Failed to exit"); + } + + private async Task ProcessDeletionRequests() + { + _ = new Func(async () => + { + _ = this.ProcessDeletionRequests().Add(this); + }).CreateScheduledTask(DateTime.UtcNow.AddHours(24)); + + lock (this.Users) + { + foreach (var b in this.Users) + { + if ((b.Value?.Data?.DeletionRequested ?? false) && b.Value?.Data?.DeletionRequestDate.GetTimespanUntil() < TimeSpan.Zero) + { + Log.Information("Deleting profile of '{Key}'", b.Key); + + _ = this.Users.Remove(b.Key); + this.objectedUsers.Add(b.Key); + foreach (var c in this.DiscordClient.GetGuilds().Where(x => x.Value.OwnerId == b.Key)) + { + try + { Log.Information("Leaving guild '{guild}'..", c.Key); _ = c.Value.LeaveAsync().Add(this); } + catch { } + } + } + } + } + } + + internal Task GuildDownloadCompleted(DiscordClient sender, GuildDownloadCompletedEventArgs e) + => Util.Initializers.SyncTasks.GuildDownloadCompleted(this, sender, e); + + internal void TaskStarted(object? sender, Xorog.UniversalExtensions.EventArgs.ScheduledTaskStartedEventArgs e) + => this.Watcher.TaskStarted(this, sender, e); +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/AutocompleteProviders.cs b/ProjectMakoto/Commands/AutocompleteProviders.cs new file mode 100644 index 00000000..d3e515f9 --- /dev/null +++ b/ProjectMakoto/Commands/AutocompleteProviders.cs @@ -0,0 +1,84 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ProjectMakoto.Commands; +public class AutocompleteProviders +{ + public sealed class HelpAutoComplete : IAutocompleteProvider + { + private static readonly string[] separator = new string[] { "-", "_" }; + + public async Task> Provider(AutocompleteContext ctx) + { + try + { + var bot = ((Bot)ctx.Services.GetService(typeof(Bot))); + + var filteredCommands = bot.DiscordClient.GetShard(ctx.Guild).GetCommandList(bot) + .Where(x => x.Name.Contains(ctx.FocusedOption.Value.ToString(), StringComparison.InvariantCultureIgnoreCase)) + .Where(x => !x.DefaultMemberPermissions.HasValue || ctx.Member.Permissions.HasPermission(x.DefaultMemberPermissions.Value)) + .Where(x => x.Type == ApplicationCommandType.ChatInput) + .Take(25); + + var options = filteredCommands + .Select(x => new DiscordApplicationCommandAutocompleteChoice(string.Join("-", x.Name.Split(separator, StringSplitOptions.None) + .Select(x => x.FirstLetterToUpper())), x.Name)) + .ToList(); + return options.AsEnumerable(); + } + catch (Exception) + { + return new List().AsEnumerable(); + } + } + } + + public sealed class ReportTranslationAutoComplete : IAutocompleteProvider + { + public async Task> Provider(AutocompleteContext ctx) + { + try + { + switch ((ReportTranslationType)Enum.Parse(typeof(ReportTranslationType), ctx.Options.First(x => x.Name == "affected_type").RawValue)) + { + case ReportTranslationType.Miscellaneous: + return new List(); + case ReportTranslationType.Command: + return await new HelpAutoComplete().Provider(ctx); + case ReportTranslationType.Event: + { + var filteredTypes = Assembly.GetAssembly(this.GetType()).GetTypes() + .Where(t => String.Equals(t.Namespace, "ProjectMakoto.Events", StringComparison.Ordinal)) + .Where(t => !t.Name.StartsWith('<')) + .Where(x => x.Name.Contains(ctx.FocusedOption.Value.ToString(), StringComparison.InvariantCultureIgnoreCase)) + .Take(25); + + var options = filteredTypes + .Select(x => new DiscordApplicationCommandAutocompleteChoice(x.Name.Replace("Events", ""), x.FullName)) + .ToList(); + + return options; + } + default: + return new List(); + } + } + catch (Exception) + { + return new List(); + } + } + } +} diff --git a/ProjectMakoto/Commands/BaseCommand.cs b/ProjectMakoto/Commands/BaseCommand.cs new file mode 100644 index 00000000..285ad09b --- /dev/null +++ b/ProjectMakoto/Commands/BaseCommand.cs @@ -0,0 +1,1714 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using DisCatSharp.Extensions.TwoFactorCommands.Enums; + +namespace ProjectMakoto.Commands; + +public abstract class BaseCommand +{ + public SharedCommandContext ctx { private get; set; } + public Translations t { get; set; } + + #region Execution + public virtual async Task BeforeExecution(SharedCommandContext ctx) + { + return true; + } + + public abstract Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments = null); + + public async Task TransferCommand(SharedCommandContext ctx, Dictionary arguments = null) + { + this.t = ctx.Bot.LoadedTranslations; + this.ctx = ctx; + + ctx.Transferred = true; + + if (await this.BasePreExecutionCheck()) + await this.ExecuteCommand(this.ctx, arguments).Add(ctx.Bot, this.ctx); + } + + public async Task ExecuteCommand(CommandContext ctx, Bot _bot, Dictionary arguments = null) + { + this.ctx = new SharedCommandContext(this, ctx, _bot); + this.t = _bot.LoadedTranslations; + + if (await this.BasePreExecutionCheck()) + await this.ExecuteCommand(this.ctx, arguments).Add(_bot, this.ctx); + } + + public async Task ExecuteCommand(InteractionContext ctx, Bot _bot, Dictionary arguments = null, bool Ephemeral = true, bool InitiateInteraction = true, bool InteractionInitiated = false) + { + this.ctx = new SharedCommandContext(this, ctx, _bot); + this.t = _bot.LoadedTranslations; + + await Task.Run(async () => + { + if (InitiateInteraction) + await ctx.CreateResponseAsync(InteractionResponseType.DeferredChannelMessageWithSource, new DiscordInteractionResponseBuilder() + { + IsEphemeral = Ephemeral + }); + + this.ctx.RespondedToInitial = InitiateInteraction; + + if (InteractionInitiated) + this.ctx.RespondedToInitial = true; + + if (await this.BasePreExecutionCheck()) + await this.ExecuteCommand(this.ctx, arguments).Add(_bot, this.ctx); + }).Add(_bot, this.ctx); + } + + public async Task ExecuteCommandWith2FA(InteractionContext ctx, Bot _bot, Dictionary arguments = null) + { + this.ctx = new SharedCommandContext(this, ctx, _bot); + this.t = _bot.LoadedTranslations; + + await Task.Run(async () => + { + this.ctx.RespondedToInitial = false; + + if (!this.ctx.Bot.status.LoadedConfig.IsDev) + if (!ctx.Client.CheckTwoFactorEnrollmentFor(ctx.User.Id)) + { + _ = ctx.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder().AddEmbed(new DiscordEmbedBuilder() + { + Description = "`Please enroll in Two Factor Authentication via 'Enroll2FA'.`" + }.AsError(this.ctx)).AsEphemeral()); + return; + } + else + { + if (_bot.Users[ctx.User.Id].LastSuccessful2FA.GetTimespanSince() > TimeSpan.FromMinutes(3)) + { + this.ctx.RespondedToInitial = true; + var tfa = await ctx.RequestTwoFactorAsync(); + + if (tfa.Result is TwoFactorResult.ValidCode or TwoFactorResult.InvalidCode) + await this.SwitchToEvent(tfa.ComponentInteraction); + + if (tfa.Result != TwoFactorResult.ValidCode) + { + _ = this.RespondOrEdit(new DiscordMessageBuilder().WithContent("Invalid Code.")); + return; + } + _bot.Users[ctx.User.Id].LastSuccessful2FA = DateTime.UtcNow; + } + } + + if (!this.ctx.RespondedToInitial) + await ctx.CreateResponseAsync(InteractionResponseType.DeferredChannelMessageWithSource, new DiscordInteractionResponseBuilder() + { + IsEphemeral = true + }); + + if (await this.BasePreExecutionCheck()) + await this.ExecuteCommand(this.ctx, arguments).Add(_bot, this.ctx); + }).Add(_bot, this.ctx); + } + + public async Task ExecuteCommand(ContextMenuContext ctx, Bot _bot, Dictionary arguments = null, bool Ephemeral = true, bool InitiateInteraction = true, bool InteractionInitiated = false) + { + this.ctx = new SharedCommandContext(this, ctx, _bot); + this.t = _bot.LoadedTranslations; + + await Task.Run(async () => + { + if (InitiateInteraction) + await ctx.CreateResponseAsync(InteractionResponseType.DeferredChannelMessageWithSource, new DiscordInteractionResponseBuilder() + { + IsEphemeral = Ephemeral + }); + + this.ctx.RespondedToInitial = InitiateInteraction; + + if (InteractionInitiated) + this.ctx.RespondedToInitial = true; + + if (await this.BasePreExecutionCheck()) + await this.ExecuteCommand(this.ctx, arguments).Add(_bot, this.ctx); + }).Add(_bot, this.ctx); + } + + public async Task ExecuteCommand(ComponentInteractionCreateEventArgs ctx, DiscordClient client, string commandName, Bot _bot, Dictionary arguments = null, bool Ephemeral = true, bool InitiateInteraction = true, bool InteractionInitiated = false) + { + this.ctx = new SharedCommandContext(this, ctx, client, commandName, _bot); + this.t = _bot.LoadedTranslations; + + await Task.Run(async () => + { + if (InitiateInteraction) + await ctx.Interaction.CreateResponseAsync(InteractionResponseType.DeferredChannelMessageWithSource, new DiscordInteractionResponseBuilder() + { + IsEphemeral = Ephemeral + }); + + this.ctx.RespondedToInitial = InitiateInteraction; + + if (InteractionInitiated) + this.ctx.RespondedToInitial = true; + + if (await this.BasePreExecutionCheck()) + await this.ExecuteCommand(this.ctx, arguments).Add(_bot, this.ctx); + }).Add(_bot, ctx); + } + + private async Task BasePreExecutionCheck() + { + if (this.t is null) + { + Log.Warning($"The translation were not set before the BasePreExecutionCheck()!"); + this.t = this.ctx.Bot.LoadedTranslations; + } + + if (this.ctx.Bot.Users.ContainsKey(this.ctx.User.Id) && !this.ctx.User.Locale.IsNullOrWhiteSpace() && this.ctx.DbUser.CurrentLocale != this.ctx.User.Locale) + { + this.ctx.DbUser.CurrentLocale = this.ctx.User.Locale; + Log.Debug("Updated language for User '{User}' to '{Locale}'", this.ctx.User.Id, this.ctx.User.Locale); + } + + if (this.ctx.Bot.status.LoadedConfig.Discord.DisabledCommands.Contains(this.ctx.ParentCommandName)) + { + this.SendDisabledCommandError(this.ctx.ParentCommandName); + return false; + } + + if (this.ctx.Bot.status.LoadedConfig.Discord.DisabledCommands.Contains(this.ctx.CommandName)) + { + this.SendDisabledCommandError(this.ctx.CommandName); + return false; + } + + if (!this.ctx.Channel.IsPrivate) + { + if (this.ctx.Bot.Guilds.ContainsKey(this.ctx.Guild.Id) && !this.ctx.Guild.PreferredLocale.IsNullOrWhiteSpace() && this.ctx.Bot.Guilds[this.ctx.Guild.Id].CurrentLocale != this.ctx.Guild.PreferredLocale) + { + this.ctx.Bot.Guilds[this.ctx.Guild.Id].CurrentLocale = this.ctx.Guild.PreferredLocale; + Log.Debug("Updated language for Guild '{Guild}' to '{Locale}'", this.ctx.Guild.Id, this.ctx.Guild.PreferredLocale); + } + + if (!(await this.CheckOwnPermissions(Permissions.SendMessages))) + return false; + + if (!(await this.CheckOwnPermissions(Permissions.EmbedLinks))) + return false; + + if (!(await this.CheckOwnPermissions(Permissions.AddReactions))) + return false; + + if (!(await this.CheckOwnPermissions(Permissions.AccessChannels))) + return false; + + if (!(await this.CheckOwnPermissions(Permissions.AttachFiles))) + return false; + + if (!(await this.CheckOwnPermissions(Permissions.ManageMessages))) + return false; + + if (!(await this.BeforeExecution(this.ctx))) + return false; + } + + if ((this.ctx.Bot.objectedUsers.Contains(this.ctx.User.Id) || this.ctx.DbUser.Data.DeletionRequested) && this.ctx.CommandName != "data" && this.ctx.CommandName != "delete") + { + this.SendDataError(); + return false; + } + + if (this.ctx.Bot.bannedUsers.TryGetValue(this.ctx.User.Id, out var blacklistedUserDetails)) + { + this.SendUserBanError(blacklistedUserDetails); + return false; + } + + if (this.ctx.Bot.bannedGuilds.TryGetValue(this.ctx.Guild?.Id ?? 0, out var blacklistedGuildDetails)) + { + this.SendGuildBanError(blacklistedGuildDetails); + return false; + } + + + return !this.ctx.User.IsBot; + } + #endregion + + public async Task SwitchToEvent(ComponentInteractionCreateEventArgs e) + { + await e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredChannelMessageWithSource, new DiscordInteractionResponseBuilder() + { + IsEphemeral = true + }); + this.ctx.RespondedToInitial = true; + this.ctx.OriginalComponentInteractionCreateEventArgs = e; + this.ctx.CommandType = Enums.CommandType.Event; + } + + #region RespondOrEdit + public Task RespondOrEdit(DiscordEmbed embed) + => this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed)); + + public Task RespondOrEdit(DiscordEmbedBuilder embed) + => this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.Build())); + + public Task RespondOrEdit(string content) + => this.RespondOrEdit(new DiscordMessageBuilder().WithContent(content)); + + public async Task RespondOrEdit(DiscordMessageBuilder discordMessageBuilder) + { + switch (this.ctx.CommandType) + { + case Enums.CommandType.ApplicationCommand: + { + DiscordWebhookBuilder discordWebhookBuilder = new(); + + var files = new Dictionary(); + + foreach (var b in discordMessageBuilder.Files) + files.Add(b.Filename, b.Stream); + + _ = discordWebhookBuilder.AddComponents(discordMessageBuilder.Components); + _ = discordWebhookBuilder.AddEmbeds(discordMessageBuilder.Embeds); + _ = discordWebhookBuilder.AddFiles(files); + discordWebhookBuilder.Content = discordMessageBuilder.Content; + + var msg = await this.ctx.OriginalInteractionContext.EditResponseAsync(discordWebhookBuilder); + this.ctx.ResponseMessage = msg; + return msg; + } + + case Enums.CommandType.ContextMenu: + { + DiscordWebhookBuilder discordWebhookBuilder = new(); + + var files = new Dictionary(); + + foreach (var b in discordMessageBuilder.Files) + files.Add(b.Filename, b.Stream); + + _ = discordWebhookBuilder.AddComponents(discordMessageBuilder.Components); + _ = discordWebhookBuilder.AddEmbeds(discordMessageBuilder.Embeds); + _ = discordWebhookBuilder.AddFiles(files); + discordWebhookBuilder.Content = discordMessageBuilder.Content; + + var msg = await this.ctx.OriginalContextMenuContext.EditResponseAsync(discordWebhookBuilder); + this.ctx.ResponseMessage = msg; + return msg; + } + + case Enums.CommandType.Event: + { + DiscordWebhookBuilder discordWebhookBuilder = new(); + + var files = new Dictionary(); + + foreach (var b in discordMessageBuilder.Files) + files.Add(b.Filename, b.Stream); + + _ = discordWebhookBuilder.AddComponents(discordMessageBuilder.Components); + _ = discordWebhookBuilder.AddEmbeds(discordMessageBuilder.Embeds); + _ = discordWebhookBuilder.AddFiles(files); + discordWebhookBuilder.Content = discordMessageBuilder.Content; + + var msg = await this.ctx.OriginalComponentInteractionCreateEventArgs.Interaction.EditOriginalResponseAsync(discordWebhookBuilder); + this.ctx.ResponseMessage = msg; + return msg; + } + + case Enums.CommandType.PrefixCommand: + case Enums.CommandType.Custom: + { + if (this.ctx.ResponseMessage is not null) + { + if ((discordMessageBuilder.Files?.Count ?? 0) > 0) + _ = discordMessageBuilder.KeepAttachments(false); + + _ = await this.ctx.ResponseMessage.ModifyAsync(discordMessageBuilder); + this.ctx.ResponseMessage = await this.ctx.ResponseMessage.Refetch(); + + return this.ctx.ResponseMessage; + } + + var msg = await this.ctx.Channel.SendMessageAsync(discordMessageBuilder); + + this.ctx.ResponseMessage = msg; + + return msg; + } + } + + throw new NotImplementedException(); + } + #endregion + + #region GetString + TVar[] GetDefaultVars() + => new TVar[] + { + new("CurrentCommand", this.ctx.Prefix + this.ctx.CommandName, false), + new("Bot", this.ctx.CurrentUser.Mention, false), + new("BotName", this.ctx.Client.CurrentApplication.Name, false), + new("FullBot", this.ctx.CurrentUser.GetUsernameWithIdentifier(), false), + new("BotDisplayName", this.ctx.CurrentUser.GetUsernameWithIdentifier(), false), + new("User", this.ctx.User.Mention, false), + new("UserName", this.ctx.User.GetUsername(), false), + new("FullUser", this.ctx.User.GetUsernameWithIdentifier(), false), + new("UserDisplayName", this.ctx.Member?.DisplayName ?? this.ctx.User.GetUsername(), false), + }; + + public string GetString(SingleTranslationKey key) + => this.GetString(key, false, Array.Empty()); + + public string GetString(SingleTranslationKey key, params TVar[] vars) + => this.GetString(key, false, vars); + + public string GetString(SingleTranslationKey key, bool Code = false, params TVar[] vars) + => key.Get(this.ctx.DbUser).Build(Code, vars.Concat(this.GetDefaultVars()).ToArray()); + + + + public string GetString(MultiTranslationKey key) + => this.GetString(key, false, false, Array.Empty()); + + public string GetString(MultiTranslationKey key, params TVar[] vars) + => this.GetString(key, false, false, vars); + + public string GetString(MultiTranslationKey key, bool Code = false, params TVar[] vars) + => this.GetString(key, Code, false, vars); + + public string GetString(MultiTranslationKey key, bool Code = false, bool UseBoldMarker = false, params TVar[] vars) + => key.Get(this.ctx.DbUser).Build(Code, UseBoldMarker, vars.Concat(this.GetDefaultVars()).ToArray()); + + + + public string GetGuildString(SingleTranslationKey key) + => this.GetGuildString(key, false, Array.Empty()); + + public string GetGuildString(SingleTranslationKey key, params TVar[] vars) + => this.GetGuildString(key, false, vars); + + public string GetGuildString(SingleTranslationKey key, bool Code = false, params TVar[] vars) + => key.Get(this.ctx.DbGuild).Build(Code, vars.Concat(this.GetDefaultVars()).ToArray()); + + + + public string GetGuildString(MultiTranslationKey key) + => this.GetGuildString(key, false, false, Array.Empty()); + + public string GetGuildString(MultiTranslationKey key, params TVar[] vars) + => this.GetGuildString(key, false, false, vars); + + public string GetGuildString(MultiTranslationKey key, bool Code = false, params TVar[] vars) + => this.GetGuildString(key, Code, false, vars); + + public string GetGuildString(MultiTranslationKey key, bool Code = false, bool UseBoldMarker = false, params TVar[] vars) + => key.Get(this.ctx.DbGuild).Build(Code, UseBoldMarker, vars.Concat(this.GetDefaultVars()).ToArray()); + #endregion + + #region Selections + public async Task> PromptRoleSelection(RolePromptConfiguration configuration = null, TimeSpan? timeOutOverride = null) + { + configuration ??= new(); + timeOutOverride ??= TimeSpan.FromSeconds(120); + + var CreateNewButton = new DiscordButtonComponent(ButtonStyle.Secondary, Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Common.Prompts.CreateRoleForMe), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("➕"))); + var DisableButton = new DiscordButtonComponent(ButtonStyle.Secondary, Guid.NewGuid().ToString(), configuration.DisableOption ?? this.GetString(this.t.Commands.Common.Prompts.Disable), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("❌"))); + var EveryoneButton = new DiscordButtonComponent(ButtonStyle.Secondary, Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Common.Prompts.SelectEveryone), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👥"))); + var ConfirmSelectionButton = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Common.Prompts.ConfirmSelection), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✅"))); + + + var SelectionInteractionId = Guid.NewGuid().ToString(); + + DiscordRole FinalSelection = null; + + var Selected = ""; + + var FinishedSelection = false; + var ExceptionOccurred = false; + Exception ThrownException = null; + + async Task RefreshMessage() + { + var dropdown = new DiscordRoleSelectComponent(this.GetString(this.t.Commands.Common.Prompts.SelectARole), SelectionInteractionId, 1, 1, false); + var builder = new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder(this.ctx.ResponseMessage.Embeds[0]).AsAwaitingInput(this.ctx)).AddComponents(dropdown).WithContent(this.ctx.ResponseMessage.Content); + + if (Selected.IsNullOrWhiteSpace()) + _ = ConfirmSelectionButton.Disable(); + else + _ = ConfirmSelectionButton.Enable(); + + List components = new(); + + if (!configuration.CreateRoleOption.IsNullOrWhiteSpace()) + components.Add(CreateNewButton); + + if (!configuration.DisableOption.IsNullOrWhiteSpace()) + components.Add(DisableButton); + + if (configuration.IncludeEveryone) + components.Add(EveryoneButton); + + if (components.Count != 0) + _ = builder.AddComponents(components); + + _ = builder.AddComponents(MessageComponents.GetCancelButton(this.ctx.DbUser, this.ctx.Bot), ConfirmSelectionButton); + + _ = await this.RespondOrEdit(builder); + } + + _ = RefreshMessage(); + + Stopwatch sw = new(); + sw.Start(); + + async Task RunInteraction(DiscordClient s, ComponentInteractionCreateEventArgs e) + { + _ = Task.Run(async () => + { + try + { + if (e.Message?.Id == this.ctx.ResponseMessage.Id && e.User.Id == this.ctx.User.Id) + { + sw.Restart(); + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == SelectionInteractionId) + { + Selected = e.Values[0]; + + try + { + var role = this.ctx.Guild.GetRole(Convert.ToUInt64(Selected)); + + if (role.IsManaged || this.ctx.Member.GetRoleHighestPosition() <= role.Position) + { + Selected = ""; + _ = e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder().AsEphemeral().WithContent($"❌ {this.GetString(this.t.Commands.Common.Prompts.SelectedRoleUnavailable, true)}")); + } + } + catch { } + + await RefreshMessage(); + } + if (e.GetCustomId() == DisableButton.CustomId) + { + FinalSelection = null; + FinishedSelection = true; + } + if (e.GetCustomId() == CreateNewButton.CustomId) + { + FinalSelection = await this.ctx.Guild.CreateRoleAsync(configuration.CreateRoleOption); + FinishedSelection = true; + } + if (e.GetCustomId() == EveryoneButton.CustomId) + { + FinalSelection = this.ctx.Guild.EveryoneRole; + FinishedSelection = true; + } + else if (e.GetCustomId() == ConfirmSelectionButton.CustomId) + { + this.ctx.Client.ComponentInteractionCreated -= RunInteraction; + + FinalSelection = this.ctx.Guild.GetRole(Convert.ToUInt64(Selected)); + FinishedSelection = true; + } + else if (e.GetCustomId() == MessageComponents.GetCancelButton(this.ctx.DbUser, this.ctx.Bot).CustomId) + throw new CancelException(); + } + } + catch (Exception ex) + { + ThrownException = ex; + ExceptionOccurred = true; + FinishedSelection = true; + } + }); + } + + this.ctx.Client.ComponentInteractionCreated += RunInteraction; + + while (!FinishedSelection && sw.Elapsed <= timeOutOverride) + { + await Task.Delay(100); + } + + this.ctx.Client.ComponentInteractionCreated -= RunInteraction; + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(this.ctx.ResponseMessage.Embeds[0]).WithContent(this.ctx.ResponseMessage.Content)); + + if (ExceptionOccurred) + return new InteractionResult(ThrownException); + + return sw.Elapsed >= timeOutOverride + ? new InteractionResult(new TimedOutException()) + : new InteractionResult(FinalSelection); + } + + public Task> PromptChannelSelection(ChannelType? channelType = null, ChannelPromptConfiguration configuration = null, TimeSpan? timeOutOverride = null) + => this.PromptChannelSelection(((channelType is null || !channelType.HasValue) ? null : new ChannelType[] { channelType.Value }), configuration, timeOutOverride); + + public async Task> PromptChannelSelection(ChannelType[]? channelTypes = null, ChannelPromptConfiguration configuration = null, TimeSpan? timeOutOverride = null) + { + configuration ??= new(); + timeOutOverride ??= TimeSpan.FromSeconds(120); + + List FetchedChannels = new(); + + var CreateNewButton = new DiscordButtonComponent(ButtonStyle.Secondary, Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Common.Prompts.CreateChannelForMe), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("➕"))); + var DisableButton = new DiscordButtonComponent(ButtonStyle.Secondary, Guid.NewGuid().ToString(), configuration.DisableOption ?? this.GetString(this.t.Commands.Common.Prompts.Disable), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("❌"))); + var ConfirmSelectionButton = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Common.Prompts.ConfirmSelection), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✅"))); + + var SelectionInteractionId = Guid.NewGuid().ToString(); + + DiscordChannel FinalSelection = null; + + var Selected = ""; + + var FinishedSelection = false; + var ExceptionOccurred = false; + Exception ThrownException = null; + + async Task RefreshMessage() + { + var dropdown = new DiscordChannelSelectComponent(this.GetString(this.t.Commands.Common.Prompts.SelectAChannel), channelTypes, SelectionInteractionId); + var builder = new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder(this.ctx.ResponseMessage.Embeds[0]).AsAwaitingInput(this.ctx)).AddComponents(dropdown).WithContent(this.ctx.ResponseMessage.Content); + + if (Selected.IsNullOrWhiteSpace()) + _ = ConfirmSelectionButton.Disable(); + else + _ = ConfirmSelectionButton.Enable(); + + List components = new(); + + if (configuration.CreateChannelOption is not null) + components.Add(CreateNewButton); + + if (!configuration.DisableOption.IsNullOrWhiteSpace()) + components.Add(DisableButton); + + if (components.Count > 0) + _ = builder.AddComponents(components); + + _ = builder.AddComponents(MessageComponents.GetCancelButton(this.ctx.DbUser, this.ctx.Bot), ConfirmSelectionButton); + + _ = await this.RespondOrEdit(builder); + } + + _ = RefreshMessage(); + + Stopwatch sw = new(); + sw.Start(); + + async Task RunInteraction(DiscordClient s, ComponentInteractionCreateEventArgs e) + { + _ = Task.Run(async () => + { + try + { + if (e.Message?.Id == this.ctx.ResponseMessage.Id && e.User.Id == this.ctx.User.Id) + { + sw.Restart(); + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == SelectionInteractionId) + { + Selected = e.Values.First(); + FetchedChannels = FetchedChannels.Select(x => new DiscordStringSelectComponentOption(x.Label, x.Value, x.Description, (x.Value == Selected), x.Emoji)).ToList(); + + await RefreshMessage(); + } + else if (e.GetCustomId() == CreateNewButton.CustomId) + { + FinalSelection = await this.ctx.Guild.CreateChannelAsync(configuration.CreateChannelOption.Name, configuration.CreateChannelOption.ChannelType); + FinishedSelection = true; + } + else if (e.GetCustomId() == DisableButton.CustomId) + { + FinalSelection = null; + FinishedSelection = true; + } + else if (e.GetCustomId() == ConfirmSelectionButton.CustomId) + { + this.ctx.Client.ComponentInteractionCreated -= RunInteraction; + + FinalSelection = this.ctx.Guild.GetChannel(Convert.ToUInt64(Selected)); + FinishedSelection = true; + } + else if (e.GetCustomId() == MessageComponents.GetCancelButton(this.ctx.DbUser, this.ctx.Bot).CustomId) + throw new CancelException(); + } + } + catch (Exception ex) + { + ThrownException = ex; + ExceptionOccurred = true; + FinishedSelection = true; + } + }); + } + + this.ctx.Client.ComponentInteractionCreated += RunInteraction; + + while (!FinishedSelection && sw.Elapsed <= timeOutOverride) + { + await Task.Delay(100); + } + + this.ctx.Client.ComponentInteractionCreated -= RunInteraction; + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(this.ctx.ResponseMessage.Embeds[0]).WithContent(this.ctx.ResponseMessage.Content)); + + if (ExceptionOccurred) + return new InteractionResult(ThrownException); + + return sw.Elapsed >= timeOutOverride + ? new InteractionResult(new TimedOutException()) + : new InteractionResult(FinalSelection); + } + + public async Task> PromptCustomSelection(IEnumerable options, string? CustomPlaceHolder = null, TimeSpan? timeOutOverride = null) + { + timeOutOverride ??= TimeSpan.FromSeconds(120); + CustomPlaceHolder ??= this.GetString(this.t.Commands.Common.Prompts.SelectAnOption); + + var ConfirmSelectionButton = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Common.Prompts.ConfirmSelection), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✅"))); + + var PrevPageButton = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(this.t.Common.PreviousPage), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("◀"))); + var NextPageButton = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(this.t.Common.NextPage), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("▶"))); + + var CurrentPage = 0; + var SelectionInteractionId = Guid.NewGuid().ToString(); + + string FinalSelection = null; + + var Selected = options.FirstOrDefault(x => x.Default, null)?.Value ?? ""; + + var FinishedSelection = false; + var ExceptionOccurred = false; + Exception ThrownException = null; + + while (!Selected.IsNullOrWhiteSpace() && !options.Skip(CurrentPage * 25).Take(25).Any(x => x.Value == Selected)) + { + if (!options.Skip(CurrentPage * 25).Take(25).Any()) + { + CurrentPage = 0; + break; + } + + CurrentPage++; + } + + async Task RefreshMessage() + { + var dropdown = new DiscordStringSelectComponent(CustomPlaceHolder, options.Skip(CurrentPage * 25).Take(25).Select(x => new DiscordStringSelectComponentOption(x.Label, x.Value, x.Description, (x.Value == Selected), x.Emoji)), SelectionInteractionId); + var builder = new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder(this.ctx.ResponseMessage.Embeds[0]).AsAwaitingInput(this.ctx)).AddComponents(dropdown).WithContent(this.ctx.ResponseMessage.Content); + + _ = NextPageButton.SetState(options.Skip(CurrentPage * 25).Count() <= 25); + _ = PrevPageButton.SetState(CurrentPage == 0); + _ = builder.AddComponents(PrevPageButton, NextPageButton); + + if (Selected.IsNullOrWhiteSpace()) + _ = ConfirmSelectionButton.Disable(); + else + _ = ConfirmSelectionButton.Enable(); + + _ = builder.AddComponents(MessageComponents.GetCancelButton(this.ctx.DbUser, this.ctx.Bot), ConfirmSelectionButton); + + _ = await this.RespondOrEdit(builder); + } + + _ = RefreshMessage(); + + Stopwatch sw = new(); + sw.Start(); + + async Task RunInteraction(DiscordClient s, ComponentInteractionCreateEventArgs e) + { + _ = Task.Run(async () => + { + try + { + if (e.Message?.Id == this.ctx.ResponseMessage.Id && e.User.Id == this.ctx.User.Id) + { + sw.Restart(); + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == SelectionInteractionId) + { + Selected = e.Values.First(); + await RefreshMessage(); + } + else if (e.GetCustomId() == ConfirmSelectionButton.CustomId) + { + this.ctx.Client.ComponentInteractionCreated -= RunInteraction; + + FinalSelection = Selected; + + FinishedSelection = true; + } + else if (e.GetCustomId() == PrevPageButton.CustomId) + { + CurrentPage--; + await RefreshMessage(); + } + else if (e.GetCustomId() == NextPageButton.CustomId) + { + CurrentPage++; + await RefreshMessage(); + } + else if (e.GetCustomId() == MessageComponents.GetCancelButton(this.ctx.DbUser, this.ctx.Bot).CustomId) + throw new CancelException(); + } + } + catch (Exception ex) + { + ThrownException = ex; + ExceptionOccurred = true; + FinishedSelection = true; + } + }); + } + + this.ctx.Client.ComponentInteractionCreated += RunInteraction; + + while (!FinishedSelection && sw.Elapsed <= timeOutOverride) + { + await Task.Delay(100); + } + + this.ctx.Client.ComponentInteractionCreated -= RunInteraction; + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(this.ctx.ResponseMessage.Embeds[0]).WithContent(this.ctx.ResponseMessage.Content)); + + if (ExceptionOccurred) + return new InteractionResult(ThrownException); + + return sw.Elapsed >= timeOutOverride + ? new InteractionResult(new TimedOutException()) + : new InteractionResult(FinalSelection); + } + #endregion + + #region Modals + public Task> PromptModalWithRetry(DiscordInteraction interaction, DiscordInteractionModalBuilder builder, bool ResetToOriginalEmbed = false, TimeSpan? timeOutOverride = null) + => this.PromptModalWithRetry(interaction, builder, null, ResetToOriginalEmbed, timeOutOverride); + + public async Task> PromptModalWithRetry(DiscordInteraction interaction, DiscordInteractionModalBuilder builder, DiscordEmbedBuilder customEmbed = null, bool ResetToOriginalEmbed = false, TimeSpan? timeOutOverride = null, bool open = true) + { + timeOutOverride ??= TimeSpan.FromMinutes(15); + + var oriEmbed = this.ctx.ResponseMessage.Embeds[0]; + + var ReOpen = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Common.Prompts.ReOpenModal), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🔄"))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(customEmbed ?? new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Common.Prompts.WaitingForModalResponse, true) + }.AsAwaitingInput(this.ctx)).AddComponents(new List { ReOpen, MessageComponents.GetCancelButton(this.ctx.DbUser, this.ctx.Bot) })); + + ComponentInteractionCreateEventArgs FinishedInteraction = null; + + var FinishedSelection = false; + var ExceptionOccurred = false; + var Cancelled = false; + Exception ThrownException = null; + + if (open) + await interaction.CreateInteractionModalResponseAsync(builder); + + this.ctx.Client.ComponentInteractionCreated += RunInteraction; + + async Task RunInteraction(DiscordClient s, ComponentInteractionCreateEventArgs e) + { + _ = Task.Run(async () => + { + try + { + if (e.Message?.Id == this.ctx.ResponseMessage.Id && e.User.Id == this.ctx.User.Id) + { + if (e.GetCustomId() == builder.CustomId) + { + this.ctx.Client.ComponentInteractionCreated -= RunInteraction; + + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + FinishedInteraction = e; + FinishedSelection = true; + } + else if (e.GetCustomId() == ReOpen.CustomId) + { + await e.Interaction.CreateInteractionModalResponseAsync(builder); + } + else if (e.GetCustomId() == MessageComponents.GetCancelButton(this.ctx.DbUser, this.ctx.Bot).CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + throw new CancelException(); + } + } + } + catch (Exception ex) + { + ThrownException = ex; + ExceptionOccurred = true; + FinishedSelection = true; + } + }).Add(this.ctx.Bot, this.ctx); + } + + var TimeoutSeconds = (int)(timeOutOverride.Value.TotalSeconds * 2); + + while (!FinishedSelection && !ExceptionOccurred && !Cancelled && TimeoutSeconds >= 0) + { + await Task.Delay(500); + TimeoutSeconds--; + } + + this.ctx.Client.ComponentInteractionCreated -= RunInteraction; + + if (ResetToOriginalEmbed) + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(oriEmbed)); + + if (ExceptionOccurred) + return new InteractionResult(ThrownException); + + return TimeoutSeconds <= 0 + ? new InteractionResult(new TimeoutException()) + : new InteractionResult(FinishedInteraction); + } + + + public async Task> PromptForTimeSpan(TimeSpan? MaxTime = null, TimeSpan? MinTime = null, TimeSpan? DefaultTime = null, bool ResetToOriginalEmbed = true, TimeSpan? timeOutOverride = null) + { + MinTime ??= TimeSpan.Zero; + MaxTime ??= TimeSpan.FromDays(356); + DefaultTime ??= TimeSpan.FromSeconds(30); + timeOutOverride ??= TimeSpan.FromSeconds(300); + + if (DefaultTime > MaxTime) + DefaultTime = MaxTime; + + if (DefaultTime < MinTime) + DefaultTime = MinTime; + + var originalEmbed = ResetToOriginalEmbed ? this.ctx.ResponseMessage.Embeds : null; + + var removeSeconds = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), "10s", false, "➖".UnicodeToEmoji().ToComponent()); + var removeSecond = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), "1s", false, "➖".UnicodeToEmoji().ToComponent()); + var addSecond = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), "1s", false, "➕".UnicodeToEmoji().ToComponent()); + var addSeconds = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), "10s", false, "➕".UnicodeToEmoji().ToComponent()); + + var removeMinutes = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), "10m", false, "➖".UnicodeToEmoji().ToComponent()); + var removeMinute = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), "1m", false, "➖".UnicodeToEmoji().ToComponent()); + var addMinute = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), "1m", false, "➕".UnicodeToEmoji().ToComponent()); + var addMinutes = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), "10m", false, "➕".UnicodeToEmoji().ToComponent()); + + var removeHours = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), "10h", false, "➖".UnicodeToEmoji().ToComponent()); + var removeHour = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), "1h", false, "➖".UnicodeToEmoji().ToComponent()); + var addHour = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), "1h", false, "➕".UnicodeToEmoji().ToComponent()); + var addHours = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), "10h", false, "➕".UnicodeToEmoji().ToComponent()); + + var removeDays = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), "10d", false, "➖".UnicodeToEmoji().ToComponent()); + var removeDay = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), "1d", false, "➖".UnicodeToEmoji().ToComponent()); + var addDay = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), "1d", false, "➕".UnicodeToEmoji().ToComponent()); + var addDays = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), "10d", false, "➕".UnicodeToEmoji().ToComponent()); + + var setExact = new DiscordButtonComponent(ButtonStyle.Secondary, Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Common.Prompts.ManuallyDefineTimespan), false, "🕒".UnicodeToEmoji().ToComponent()); + var confirmSelection = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Common.Prompts.ConfirmSelection), false, "✅".UnicodeToEmoji().ToComponent()); + + var previousSuccessSelected = DefaultTime!.Value; + var currentSelectedTime = DefaultTime!.Value; + + Task UpdateMessage() + { + if (currentSelectedTime > MaxTime || MinTime > currentSelectedTime) + currentSelectedTime = previousSuccessSelected; + + previousSuccessSelected = currentSelectedTime; + + foreach (var button in new List() + { + removeSeconds, removeSecond, addSecond, addSeconds, + removeMinutes, removeMinute, addMinute, addMinutes, + removeHours, removeHour, addHour, addHours, + removeDays, removeDay, addDay, addDays + }) + _ = button.Enable(); + + if (currentSelectedTime - TimeSpan.FromSeconds(10) < MinTime) + _ = removeSeconds.Disable(); + + if (currentSelectedTime - TimeSpan.FromSeconds(1) < MinTime) + _ = removeSecond.Disable(); + + if (currentSelectedTime - TimeSpan.FromMinutes(10) < MinTime) + _ = removeMinutes.Disable(); + + if (currentSelectedTime - TimeSpan.FromMinutes(1) < MinTime) + _ = removeMinute.Disable(); + + if (currentSelectedTime - TimeSpan.FromHours(10) < MinTime) + _ = removeHours.Disable(); + + if (currentSelectedTime - TimeSpan.FromHours(1) < MinTime) + _ = removeHour.Disable(); + + if (currentSelectedTime - TimeSpan.FromDays(10) < MinTime) + _ = removeDays.Disable(); + + if (currentSelectedTime - TimeSpan.FromDays(1) < MinTime) + _ = removeDay.Disable(); + + if (currentSelectedTime + TimeSpan.FromSeconds(10) > MaxTime) + _ = addSeconds.Disable(); + + if (currentSelectedTime + TimeSpan.FromSeconds(1) > MaxTime) + _ = addSecond.Disable(); + + if (currentSelectedTime + TimeSpan.FromMinutes(10) > MaxTime) + _ = addMinutes.Disable(); + + if (currentSelectedTime + TimeSpan.FromMinutes(1) > MaxTime) + _ = addMinute.Disable(); + + if (currentSelectedTime + TimeSpan.FromHours(10) > MaxTime) + _ = addHours.Disable(); + + if (currentSelectedTime + TimeSpan.FromHours(1) > MaxTime) + _ = addHour.Disable(); + + if (currentSelectedTime + TimeSpan.FromDays(10) > MaxTime) + _ = addDays.Disable(); + + if (currentSelectedTime + TimeSpan.FromDays(1) > MaxTime) + _ = addDay.Disable(); + + var embed = new DiscordEmbedBuilder() + .WithDescription($"`{this.GetString(this.t.Commands.Common.Prompts.CurrentTimespan)}`: `{currentSelectedTime.GetHumanReadable(TimeFormat.Days, TranslationUtil.GetTranslatedHumanReadableConfig(this.ctx.DbUser, this.ctx.Bot, true))}`") + .AsAwaitingInput(this.ctx); + + + + return this.RespondOrEdit(new DiscordMessageBuilder() + .AddEmbed(embed) + .AddComponents(removeSeconds, removeSecond, addSecond, addSeconds) + .AddComponents(removeMinutes, removeMinute, addMinute, addMinutes) + .AddComponents(removeHours, removeHour, addHour, addHours) + .AddComponents(removeDays, removeDay, addDay, addDays) + .AddComponents(setExact, MessageComponents.GetCancelButton(this.ctx.DbUser, this.ctx.Bot), confirmSelection)); + } + await UpdateMessage(); + + var Finished = false; + var Cancelled = false; + var timeOut = Stopwatch.StartNew(); + + async Task Interaction(DiscordClient sender, ComponentInteractionCreateEventArgs e) + { + _ = Task.Run(async () => + { + if (e.Message?.Id == this.ctx.ResponseMessage?.Id) + { + timeOut.Restart(); + + if (e.Id == removeSecond.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Subtract(TimeSpan.FromSeconds(1)); + await UpdateMessage(); + } + else if (e.Id == removeSeconds.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Subtract(TimeSpan.FromSeconds(10)); + await UpdateMessage(); + } + else if (e.Id == addSecond.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Add(TimeSpan.FromSeconds(1)); + await UpdateMessage(); + } + else if (e.Id == addSeconds.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Add(TimeSpan.FromSeconds(10)); + await UpdateMessage(); + } + else if (e.Id == removeMinute.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Subtract(TimeSpan.FromMinutes(1)); + await UpdateMessage(); + } + else if (e.Id == removeMinutes.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Subtract(TimeSpan.FromMinutes(10)); + await UpdateMessage(); + } + else if (e.Id == addMinute.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Add(TimeSpan.FromMinutes(1)); + await UpdateMessage(); + } + else if (e.Id == addMinutes.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Add(TimeSpan.FromMinutes(10)); + await UpdateMessage(); + } + else if (e.Id == removeHour.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Subtract(TimeSpan.FromHours(1)); + await UpdateMessage(); + } + else if (e.Id == removeHours.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Subtract(TimeSpan.FromHours(10)); + await UpdateMessage(); + } + else if (e.Id == addHour.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Add(TimeSpan.FromHours(1)); + await UpdateMessage(); + } + else if (e.Id == addHours.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Add(TimeSpan.FromHours(10)); + await UpdateMessage(); + } + else if (e.Id == removeDay.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Subtract(TimeSpan.FromDays(1)); + await UpdateMessage(); + } + else if (e.Id == removeDays.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Subtract(TimeSpan.FromDays(10)); + await UpdateMessage(); + } + else if (e.Id == addDay.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Add(TimeSpan.FromDays(1)); + await UpdateMessage(); + } + else if (e.Id == addDays.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Add(TimeSpan.FromDays(10)); + await UpdateMessage(); + } + else if (e.Id == confirmSelection.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + Finished = true; + } + else if (e.Id == MessageComponents.CancelButtonId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + Cancelled = true; + } + else if (e.Id == setExact.CustomId) + { + + var modal = new DiscordInteractionModalBuilder().WithTitle(this.GetString(this.t.Commands.Common.Prompts.SelectATimeSpan)).WithCustomId(Guid.NewGuid().ToString()); + + if (MaxTime.Value.TotalDays >= 1) + _ = modal.AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "days", this.GetString(this.t.Commands.Common.Prompts.TimespanDays) + .Build(new TVar("Max", ((int)MaxTime.Value.TotalDays))), "0", 1, 3, true, $"{currentSelectedTime.Days}")); + + if (MaxTime.Value.TotalHours >= 1) + _ = modal.AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "hours", this.GetString(this.t.Commands.Common.Prompts.TimespanHours) + .Build(new TVar("Max", (MaxTime.Value.TotalHours >= 24 ? "23" : $"{((int)MaxTime.Value.TotalHours)}"))), "0", 1, 2, true, $"{currentSelectedTime.Hours}")); + + if (MaxTime.Value.TotalMinutes >= 1) + _ = modal.AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "minutes", this.GetString(this.t.Commands.Common.Prompts.TimespanMinutes) + .Build(new TVar("Max", (MaxTime.Value.TotalMinutes >= 60 ? "59" : $"{((int)MaxTime.Value.TotalMinutes)}"))), $"0", 1, 2, true, $"{currentSelectedTime.Minutes}")); + + _ = modal.AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "seconds", this.GetString(this.t.Commands.Common.Prompts.TimespanSeconds) + .Build(new TVar("Max", 59)), "0", 1, 2, true, $"{currentSelectedTime.Seconds}")); + + var ModalResult = await this.PromptModalWithRetry(e.Interaction, modal, true, timeOutOverride.Value.Subtract(timeOut.Elapsed)); + + if (!ModalResult.Failed) + { + try + { + var Response = ModalResult.Result; + var modalLength = TimeSpan.FromSeconds(0); + + if ((Response.Interaction.Data.Components.Any(x => x.CustomId == "seconds") && !Response.Interaction.Data.Components.First(x => x.CustomId == "seconds").Value.IsDigitsOnly()) || + (Response.Interaction.Data.Components.Any(x => x.CustomId == "minutes") && !Response.Interaction.Data.Components.First(x => x.CustomId == "minutes").Value.IsDigitsOnly()) || + (Response.Interaction.Data.Components.Any(x => x.CustomId == "hours") && !Response.Interaction.Data.Components.First(x => x.CustomId == "hours").Value.IsDigitsOnly()) || + (Response.Interaction.Data.Components.Any(x => x.CustomId == "days") && !Response.Interaction.Data.Components.First(x => x.CustomId == "days").Value.IsDigitsOnly())) + throw new InvalidOperationException("Invalid TimeSpan"); + var seconds = Response.Interaction.Data.Components.Any(x => x.CustomId == "seconds") ? Convert.ToDouble(Convert.ToUInt32(Response.Interaction.Data.Components.First(x => x.CustomId == "seconds").Value)) : 0; + var minutes = Response.Interaction.Data.Components.Any(x => x.CustomId == "minutes") ? Convert.ToDouble(Convert.ToUInt32(Response.Interaction.Data.Components.First(x => x.CustomId == "minutes").Value)) : 0; + var hours = Response.Interaction.Data.Components.Any(x => x.CustomId == "hours") ? Convert.ToDouble(Convert.ToUInt32(Response.Interaction.Data.Components.First(x => x.CustomId == "hours").Value)) : 0; + var days = Response.Interaction.Data.Components.Any(x => x.CustomId == "days") ? Convert.ToDouble(Convert.ToUInt32(Response.Interaction.Data.Components.First(x => x.CustomId == "days").Value)) : 0; + modalLength = modalLength.Add(TimeSpan.FromSeconds(seconds)); + modalLength = modalLength.Add(TimeSpan.FromMinutes(minutes)); + modalLength = modalLength.Add(TimeSpan.FromHours(hours)); + modalLength = modalLength.Add(TimeSpan.FromDays(days)); + + currentSelectedTime = modalLength; + } + catch { } + } + + await UpdateMessage(); + } + } + }).Add(this.ctx.Bot, this.ctx); + } + + this.ctx.Client.ComponentInteractionCreated += Interaction; + + while (!Finished && !Cancelled && timeOut.ElapsedMilliseconds < timeOutOverride.Value.TotalMilliseconds) + await Task.Delay(1000); + + this.ctx.Client.ComponentInteractionCreated -= Interaction; + + if (!Finished && !Cancelled && timeOut.ElapsedMilliseconds < timeOutOverride.Value.TotalMilliseconds) + return new InteractionResult(new TimedOutException()); + + if (Cancelled) + return new InteractionResult(new CancelException()); + + if (ResetToOriginalEmbed) + _ = await this.RespondOrEdit(new DiscordMessageBuilder().AddEmbeds(originalEmbed)); + + return currentSelectedTime > MaxTime || currentSelectedTime < MinTime + ? new InteractionResult(new InvalidOperationException("Invalid TimeSpan")) + : new InteractionResult(currentSelectedTime); + } + + public async Task> PromptModalForDateTime(DateTime? defaultTime = null, bool ResetToOriginalEmbed = true, TimeSpan? timeOutOverride = null) + { + timeOutOverride ??= TimeSpan.FromMinutes(2); + defaultTime ??= DateTime.UtcNow; + + var originalEmbed = ResetToOriginalEmbed ? this.ctx.ResponseMessage.Embeds : null; + + var removeMinutes = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), "10m", false, "➖".UnicodeToEmoji().ToComponent()); + var removeMinute = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), "1m", false, "➖".UnicodeToEmoji().ToComponent()); + var addMinute = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), "1m", false, "➕".UnicodeToEmoji().ToComponent()); + var addMinutes = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), "10m", false, "➕".UnicodeToEmoji().ToComponent()); + + var removeHours = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), "10h", false, "➖".UnicodeToEmoji().ToComponent()); + var removeHour = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), "1h", false, "➖".UnicodeToEmoji().ToComponent()); + var addHour = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), "1h", false, "➕".UnicodeToEmoji().ToComponent()); + var addHours = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), "10h", false, "➕".UnicodeToEmoji().ToComponent()); + + var removeDays = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), "10d", false, "➖".UnicodeToEmoji().ToComponent()); + var removeDay = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), "1d", false, "➖".UnicodeToEmoji().ToComponent()); + var addDay = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), "1d", false, "➕".UnicodeToEmoji().ToComponent()); + var addDays = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), "10d", false, "➕".UnicodeToEmoji().ToComponent()); + + var setExact = new DiscordButtonComponent(ButtonStyle.Secondary, Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Common.Prompts.ManuallyDefineDateTime), false, "🕒".UnicodeToEmoji().ToComponent()); + var changeTimezone = new DiscordButtonComponent(ButtonStyle.Secondary, Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Common.Prompts.SelectTimezone), false, "🌐".UnicodeToEmoji().ToComponent()); + var confirmSelection = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Common.Prompts.ConfirmSelection), false, "✅".UnicodeToEmoji().ToComponent()); + + var currentSelectedTime = defaultTime!.Value; + + Task UpdateMessage() + { + var embed = new DiscordEmbedBuilder() + .WithDescription($"`{this.GetString(this.t.Commands.Common.Prompts.CurrentDateTime)}`: {currentSelectedTime.ToTimestamp()} ({currentSelectedTime.ToTimestamp(TimestampFormat.LongDateTime)})") + .AsAwaitingInput(this.ctx); + + return this.RespondOrEdit(new DiscordMessageBuilder() + .AddEmbed(embed) + .AddComponents(removeMinutes, removeMinute, addMinute, addMinutes) + .AddComponents(removeHours, removeHour, addHour, addHours) + .AddComponents(removeDays, removeDay, addDay, addDays) + .AddComponents(changeTimezone, setExact) + .AddComponents(MessageComponents.GetCancelButton(this.ctx.DbUser, this.ctx.Bot), confirmSelection)); + } + await UpdateMessage(); + + var Finished = false; + var Cancelled = false; + var timeOut = Stopwatch.StartNew(); + + async Task Interaction(DiscordClient sender, ComponentInteractionCreateEventArgs e) + { + _ = Task.Run(async () => + { + if (e.Message?.Id == this.ctx.ResponseMessage?.Id) + { + timeOut.Restart(); + + if (e.Id == removeMinute.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Subtract(TimeSpan.FromMinutes(1)); + } + else if (e.Id == removeMinutes.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Subtract(TimeSpan.FromMinutes(10)); + } + else if (e.Id == addMinute.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Add(TimeSpan.FromMinutes(1)); + } + else if (e.Id == addMinutes.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Add(TimeSpan.FromMinutes(10)); + } + else if (e.Id == removeHour.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Subtract(TimeSpan.FromHours(1)); + } + else if (e.Id == removeHours.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Subtract(TimeSpan.FromHours(10)); + } + else if (e.Id == addHour.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Add(TimeSpan.FromHours(1)); + } + else if (e.Id == addHours.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Add(TimeSpan.FromHours(10)); + } + else if (e.Id == removeDay.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Subtract(TimeSpan.FromDays(1)); + } + else if (e.Id == removeDays.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Subtract(TimeSpan.FromDays(10)); + } + else if (e.Id == addDay.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Add(TimeSpan.FromDays(1)); + } + else if (e.Id == addDays.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + currentSelectedTime = currentSelectedTime.Add(TimeSpan.FromDays(10)); + } + else if (e.Id == confirmSelection.CustomId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + Finished = true; + return; + } + else if (e.Id == MessageComponents.CancelButtonId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + Cancelled = true; + return; + } + else if (e.Id == changeTimezone.CustomId) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Common.Prompts.SelectTimezonePrompt, true)).AsAwaitingInput(this.ctx)); + + var promptResult = await this.PromptCustomSelection(TimeZoneInfo.GetSystemTimeZones() + .Select(x => new DiscordStringSelectComponentOption(x.DisplayName, x.Id, null, x.Id == (this.ctx.DbUser.Timezone ?? "UTC"))), null, timeOutOverride.Value.Subtract(timeOut.Elapsed)); + + if (promptResult.Failed) + { + await UpdateMessage(); + return; + } + + this.ctx.DbUser.Timezone = promptResult.Result; + } + else if (e.Id == setExact.CustomId) + { + var modalInteraction = e.Interaction; + + if (this.ctx.DbUser.Timezone.IsNullOrWhiteSpace() || !TimeZoneInfo.GetSystemTimeZones().Any(x => x.Id == this.ctx.DbUser.Timezone)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Common.Prompts.SelectTimezonePrompt, true)).AsAwaitingInput(this.ctx)); + + var promptResult = await this.PromptCustomSelection(TimeZoneInfo.GetSystemTimeZones() + .Select(x => new DiscordStringSelectComponentOption(x.DisplayName, x.Id, null, x.Id == (this.ctx.DbUser.Timezone ?? "UTC"))), null, timeOutOverride.Value.Subtract(timeOut.Elapsed)); + + if (promptResult.Failed) + { + await UpdateMessage(); + return; + } + + this.ctx.DbUser.Timezone = promptResult.Result; + modalInteraction = null; + } + + var userTimezone = TimeZoneInfo.FindSystemTimeZoneById(this.ctx.DbUser.Timezone); + var userTime = TimeZoneInfo.ConvertTimeFromUtc(currentSelectedTime, userTimezone); + + var modal = new DiscordInteractionModalBuilder().WithTitle(this.GetString(this.t.Commands.Common.Prompts.SelectADateTime)).WithCustomId(Guid.NewGuid().ToString()); + + _ = modal.AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "minute", this.GetString(this.t.Commands.Common.Prompts.DateTimeMinute), this.GetString(this.t.Commands.Common.Prompts.DateTimeMinute), 1, 2, true, $"{userTime.Minute}")); + _ = modal.AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "hour", this.GetString(this.t.Commands.Common.Prompts.DateTimeHour), this.GetString(this.t.Commands.Common.Prompts.DateTimeHour), 1, 2, true, $"{userTime.Hour}")); + _ = modal.AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "day", this.GetString(this.t.Commands.Common.Prompts.DateTimeDay), this.GetString(this.t.Commands.Common.Prompts.DateTimeDay), 1, 2, true, $"{userTime.Day}")); + _ = modal.AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "month", this.GetString(this.t.Commands.Common.Prompts.DateTimeMonth), this.GetString(this.t.Commands.Common.Prompts.DateTimeMonth), 1, 2, true, $"{userTime.Month}")); + _ = modal.AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "year", this.GetString(this.t.Commands.Common.Prompts.DateTimeYear), this.GetString(this.t.Commands.Common.Prompts.DateTimeYear), 1, 4, true, $"{userTime.Year}")); + + var ModalResult = await this.PromptModalWithRetry(modalInteraction, modal, null, false, timeOutOverride.Value.Subtract(timeOut.Elapsed), modalInteraction != null); + + if (ModalResult.Errored) + { + await UpdateMessage(); + return; + } + + InteractionCreateEventArgs Response = ModalResult.Result; + + DateTime dateTime; + + try + { + if ((Response.Interaction.Data.Components.Any(x => x.CustomId == "hour") && !Response.Interaction.Data.Components.First(x => x.CustomId == "hour").Value.IsDigitsOnly()) || + (Response.Interaction.Data.Components.Any(x => x.CustomId == "minute") && !Response.Interaction.Data.Components.First(x => x.CustomId == "minute").Value.IsDigitsOnly()) || + (Response.Interaction.Data.Components.Any(x => x.CustomId == "day") && !Response.Interaction.Data.Components.First(x => x.CustomId == "day").Value.IsDigitsOnly()) || + (Response.Interaction.Data.Components.Any(x => x.CustomId == "month") && !Response.Interaction.Data.Components.First(x => x.CustomId == "month").Value.IsDigitsOnly()) || + (Response.Interaction.Data.Components.Any(x => x.CustomId == "year") && !Response.Interaction.Data.Components.First(x => x.CustomId == "year").Value.IsDigitsOnly())) + throw new ArgumentException("Invalid date time"); + + var hour = Convert.ToInt32(Response.Interaction.GetModalValueByCustomId("hour")); + var minute = Convert.ToInt32(Response.Interaction.GetModalValueByCustomId("minute")); + var day = Convert.ToInt32(Response.Interaction.GetModalValueByCustomId("day")); + var month = Convert.ToInt32(Response.Interaction.GetModalValueByCustomId("month")); + var year = Convert.ToInt32(Response.Interaction.GetModalValueByCustomId("year")); + + dateTime = TimeZoneInfo.ConvertTimeToUtc(new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Unspecified), userTimezone); + } + catch (Exception) + { + await UpdateMessage(); + return; + } + + currentSelectedTime = dateTime; + } + else + { return; } + + await UpdateMessage(); + } + }).Add(this.ctx.Bot, this.ctx); + } + + this.ctx.Client.ComponentInteractionCreated += Interaction; + + while (!Finished && !Cancelled && timeOut.ElapsedMilliseconds < timeOutOverride.Value.TotalMilliseconds) + await Task.Delay(1000); + + this.ctx.Client.ComponentInteractionCreated -= Interaction; + + if (!Finished && !Cancelled && timeOut.ElapsedMilliseconds < timeOutOverride.Value.TotalMilliseconds) + return new InteractionResult(new TimedOutException()); + + if (Cancelled) + return new InteractionResult(new CancelException()); + + if (ResetToOriginalEmbed) + _ = await this.RespondOrEdit(new DiscordMessageBuilder().AddEmbeds(originalEmbed)); + + return new InteractionResult(currentSelectedTime); + } + #endregion + + public async Task<(Stream stream, int fileSize)> PromptForFileUpload(TimeSpan? timeOutOverride = null) + { + timeOutOverride ??= TimeSpan.FromMinutes(15); + + if (this.ctx.DbUser.PendingUserUpload is not null) + { + if (this.ctx.DbUser.PendingUserUpload.TimeOut.GetTotalSecondsUntil() > 0 && !this.ctx.DbUser.PendingUserUpload.InteractionHandled) + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Description = $"`An upload interaction is already taking place. Please finish it beforehand.`", + }.AsError(this.ctx))); + + throw new AlreadyAppliedException(""); + } + + this.ctx.DbUser.PendingUserUpload = null; + } + + this.ctx.DbUser.PendingUserUpload = new UserUpload + { + TimeOut = DateTime.UtcNow.Add(timeOutOverride.Value) + }; + + while (this.ctx.DbUser.PendingUserUpload is not null && !this.ctx.DbUser.PendingUserUpload.InteractionHandled && this.ctx.DbUser.PendingUserUpload.TimeOut.GetTotalSecondsUntil() > 0) + { + await Task.Delay(500); + } + + if (!this.ctx.DbUser.PendingUserUpload?.InteractionHandled ?? true) + throw new ArgumentException(""); + + var size = this.ctx.DbUser.PendingUserUpload.FileSize; + var stream = this.ctx.DbUser.PendingUserUpload.UploadedData; + + this.ctx.DbUser.PendingUserUpload = null; + return (stream, size); + } + + #region FinishInteraction + public void ModifyToTimedOut(bool Delete = false) + { + _ = this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder(this.ctx.ResponseMessage.Embeds[0]).WithFooter(this.ctx.ResponseMessage.Embeds[0]?.Footer?.Text + $" • {this.GetString(this.t.Commands.Common.InteractionTimeout)}").WithColor(DiscordColor.Gray))); + + if (Delete) + _ = Task.Delay(5000).ContinueWith(_ => + { + if (!this.ctx.ResponseMessage?.Flags.Value.HasMessageFlag(MessageFlags.Ephemeral) ?? false) + _ = this.ctx.ResponseMessage.DeleteAsync(); + }); + } + + public void DeleteOrInvalidate() + { + switch (this.ctx.CommandType) + { + case Enums.CommandType.ContextMenu: + { + _ = this.ctx.OriginalContextMenuContext.DeleteResponseAsync(); + break; + } + case Enums.CommandType.Event: + { + _ = this.ctx.OriginalComponentInteractionCreateEventArgs.Interaction.DeleteOriginalResponseAsync(); + break; + } + case Enums.CommandType.ApplicationCommand: + { + _ = this.ctx.OriginalInteractionContext.DeleteResponseAsync(); + break; + } + default: + { + _ = this.ctx.ResponseMessage?.DeleteAsync(); + break; + } + } + } + #endregion + + #region Checks + public async Task CheckVoiceState() + { + if (this.ctx.Member.VoiceState is null) + { + this.SendVoiceStateError(); + return false; + } + + return true; + } + + public async Task CheckMaintenance() + { + if (!this.ctx.User.IsMaintenance(this.ctx.Bot.status)) + { + this.SendMaintenanceError(); + return false; + } + + return true; + } + + public async Task CheckBotOwner() + { + if (!this.ctx.User.IsMaintenance(this.ctx.Bot.status)) + { + this.SendBotOwnerError(); + return false; + } + + return true; + } + + public async Task CheckAdmin() + { + if (!this.ctx.Member.IsAdmin(this.ctx.Bot.status)) + { + this.SendAdminError(); + return false; + } + + return true; + } + + public async Task CheckPermissions(Permissions perms) + { + if (!this.ctx.Member.Permissions.HasPermission(perms)) + { + this.SendPermissionError(perms); + return false; + } + + return true; + } + + public async Task CheckOwnPermissions(Permissions perms) + { + if (!this.ctx.CurrentMember.Permissions.HasPermission(perms)) + { + this.SendOwnPermissionError(perms); + return false; + } + + return true; + } + + public async Task CheckSource(Enums.CommandType commandType) + { + if (this.ctx.CommandType != commandType) + { + this.SendSourceError(commandType); + return false; + } + + return true; + } + #endregion + + #region ErrorTemplates + + public void SendDisabledCommandError(string disabledCommand) + => _ = this.RespondOrEdit(new DiscordEmbedBuilder() + { + Description = this.GetString(this.t.Commands.Common.Errors.CommandDisabled, true, new TVar("Command", disabledCommand)) + }.AsError(this.ctx)); + + public void SendNoMemberError() + => _ = this.RespondOrEdit(new DiscordEmbedBuilder() + { + Description = this.GetString(this.t.Commands.Common.Errors.NoMember) + }.AsError(this.ctx)); + + public void SendMaintenanceError() + => _ = this.RespondOrEdit(new DiscordEmbedBuilder() + { + Description = this.GetString(this.t.Commands.Common.Errors.Generic).Build(true, new TVar("Required", $"{this.ctx.CurrentUser.GetUsername()} Staff")) + }.AsError(this.ctx)); + + public void SendBotOwnerError() + => _ = this.RespondOrEdit(new DiscordEmbedBuilder() + { + Description = this.GetString(this.t.Commands.Common.Errors.Generic).Build(true, new TVar("Required", $"<@{this.ctx.Bot.status.TeamOwner}>", false)), + }.AsError(this.ctx)); + + public void SendAdminError() + => _ = this.RespondOrEdit(new DiscordEmbedBuilder() + { + Description = this.GetString(this.t.Commands.Common.Errors.Generic).Build(true, new TVar("Required", "Administrator")), + }.AsError(this.ctx)); + + public void SendPermissionError(Permissions perms) + => _ = this.RespondOrEdit(new DiscordEmbedBuilder() + { + Description = this.GetString(this.t.Commands.Common.Errors.Generic).Build(true, new TVar("Required", perms.ToTranslatedPermissionString(this.ctx.DbUser, this.ctx.Bot))), + }.AsError(this.ctx)); + + public void SendVoiceStateError() + => _ = this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Common.Errors.VoiceChannel).Build(true), + }.AsError(this.ctx))); + + public void SendUserBanError(BanDetails entry) + => _ = this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Description = this.t.Commands.Common.Errors.UserBan.t["en"].Build(true, new TVar("Reason", entry.Reason)), + }.AsError(this.ctx))); + + public void SendGuildBanError(BanDetails entry) + => _ = this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Common.Errors.GuildBan, true, new TVar("Reason", entry.Reason)), + }.AsError(this.ctx))); + + public void SendSourceError(Enums.CommandType commandType) + => _ = commandType switch + { + Enums.CommandType.ApplicationCommand => this.RespondOrEdit(new DiscordEmbedBuilder() + { + Description = this.GetString(this.t.Commands.Common.Errors.ExclusiveApp).Build(true), + }.AsError(this.ctx)), + Enums.CommandType.PrefixCommand => this.RespondOrEdit(new DiscordEmbedBuilder() + { + Description = this.GetString(this.t.Commands.Common.Errors.ExclusivePrefix).Build(true) + }.AsError(this.ctx)), + _ => throw new ArgumentException("Invalid Source defined."), + }; + + public void SendDataError() + => _ = this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Common.Errors.Data, true, new TVar("Command", $"{this.ctx.Prefix}data delete")), + }.AsError(this.ctx))); + + public void SendDmError() + => _ = this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Description = $"📩 {this.GetString(this.t.Commands.Common.Errors.DirectMessage, true)}", + ImageUrl = (this.ctx.User.Presence.ClientStatus.Mobile.HasValue ? "https://cdn.discordapp.com/attachments/1005430437952356423/1144961395515998238/34rhz83ghtzu3ght.gif" : "https://cdn.discordapp.com/attachments/1005430437952356423/1144964670197862400/et2grtzu2ghrzi52.gif") + }.AsError(this.ctx))); + + public void SendDmRedirect() + => _ = this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Description = $"📩 {this.GetString(this.t.Commands.Common.DirectMessageRedirect, true)}", + }.AsSuccess(this.ctx))); + + public void SendOwnPermissionError(Permissions perms) + { + if (perms is Permissions.AccessChannels or Permissions.SendMessages or Permissions.EmbedLinks) + return; + + _ = this.RespondOrEdit(new DiscordEmbedBuilder() + { + Description = this.GetString(this.t.Commands.Common.Errors.BotPermissions, true, new TVar("Required", perms.ToTranslatedPermissionString(this.ctx.DbUser, this.ctx.Bot))) + }.AsError(this.ctx)); + } + + public void SendSyntaxError() + { + if (this.ctx.CommandType != Enums.CommandType.PrefixCommand) + throw new ArgumentException("Syntax Error can only be generated for Prefix Commands."); + + var ctx = this.ctx.OriginalCommandContext; + + var embed = new DiscordEmbedBuilder + { + Description = $"**`{ctx.Prefix}{ctx.Command.Name}{(ctx.RawArgumentString != "" ? $" {ctx.RawArgumentString.SanitizeForCode().Replace("\\", "")}" : "")}` is not a valid way of using this command.**\nUse it like this instead: `{ctx.Prefix}{ctx.Command.GenerateUsage()}`\n\nArguments wrapped in `[]` are optional while arguments wrapped in `<>` are required.\n**Do not include the brackets when using commands, they're merely an indicator for requirement.**", + }.AsError(this.ctx); + + _ = this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed).WithContent(this.ctx.User.Mention)); + } + #endregion +} diff --git a/ProjectMakoto/Commands/Commands.cs b/ProjectMakoto/Commands/Commands.cs new file mode 100644 index 00000000..71c299aa --- /dev/null +++ b/ProjectMakoto/Commands/Commands.cs @@ -0,0 +1,199 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; +internal static class Commands +{ + public static List GetList() => [ + new MakotoModule("Utility", [ + new MakotoCommand("help", "Sends you a list of all available commands, their usage and their description.", typeof(HelpCommand), + new MakotoCommandOverload(typeof(string), "command", "The command to show help for", false) + .WithAutoComplete(typeof(AutocompleteProviders.HelpAutoComplete))), + + new MakotoCommand("user-info", "Displays information the bot knows about you or the mentioned user.", typeof(UserInfoCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "The User", false)) + .WithAliases("userinfo"), + + new MakotoCommand("guild-info", "Displays information this or the mentioned guild.", typeof(GuildInfoCommand), + new MakotoCommandOverload(typeof(string), "guild", "The Guild", false)) + .WithAliases("guildinfo"), + + new MakotoCommand("reminders", "Allows you to manage your reminders.", typeof(RemindersCommand)), + + new MakotoCommand("avatar", "Displays your or the mentioned user's avatar as an embedded image.", typeof(AvatarCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "The User", false)) + .WithAliases("pfp"), + + new MakotoCommand("banner", "Displays your or the mentioned user's banner as an embedded image.", typeof(BannerCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "The User", false)), + + new MakotoCommand("rank", "Shows your or the mentioned user's rank and rank progress.", typeof(RankCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "The User", false)) + .WithAliases("level", "lvl"), + + new MakotoCommand("leaderboard", "Displays the current experience rankings on this server.", typeof(LeaderboardCommand), + new MakotoCommandOverload(typeof(int), "amount", "The amount of rankings to show", false) + .WithMinimumValue(3) + .WithMaximumValue(50)), + + new MakotoCommand("report-host", "Allows you to contribute a new malicious host to our database.", typeof(ReportHostCommand), + new MakotoCommandOverload(typeof(string), "url", "The host", UseRemainingString: true)), + + new MakotoCommand("report-translation", "Allows you to report missing, invalid or incorrect translations in Makoto.", typeof(ReportTranslationCommand), + new MakotoCommandOverload(typeof(ReportTranslationType), "affected_type", "The type of module that is affected"), + new MakotoCommandOverload(typeof(string), "component", "The affected component") + .WithAutoComplete(typeof(AutocompleteProviders.ReportTranslationAutoComplete)), + new MakotoCommandOverload(typeof(ReportTranslationReason), "report_type", "What type of issue you're reporting"), + new MakotoCommandOverload(typeof(string), "additional_information", "Any additional information you can give us", false, true)), + + new MakotoCommand("upload", "Upload a file to the bot. Only use when instructed to.", typeof(UploadCommand), + new MakotoCommandOverload(typeof(DiscordAttachment), "file", "The file you want to upload.")), + + new MakotoCommand("urban-dictionary", "Look up a term on Urban Dictionary.", typeof(UrbanDictionaryCommand), + new MakotoCommandOverload(typeof(string), "term", "The term you want to look up.", UseRemainingString: true)), + + new MakotoCommand("data", "Allows you to request or manage your user data.", + new MakotoCommand("request", "Allows you to request your user data.", typeof(Data.RequestCommand)), + new MakotoCommand("delete", "Allows you to delete your user data and stop Makoto from further processing of your user data.", typeof(Data.DeleteCommand)), + new MakotoCommand("policy", "Allows you to view how Makoto processes your data.", typeof(Data.InfoCommand))), + + new MakotoCommand("language", "Change the language Makoto uses.", typeof(LanguageCommand)), + + new MakotoCommand("credits", "Allows you to view who contributed the bot.", typeof(CreditsCommand)), + + new MakotoCommand("vcc", "Allows you to modify your own voice channel.", + new MakotoCommand("open", "Opens your channel so new users can freely join.", typeof(VcCreator.OpenCommand)), + new MakotoCommand("close", "Closes your channel. You have to invite people for them to join.", typeof(VcCreator.CloseCommand)), + new MakotoCommand("name", "Changes the name of your channel.", typeof(VcCreator.NameCommand), + new MakotoCommandOverload(typeof(string), "name", "The name", false, UseRemainingString: true)), + new MakotoCommand("limit", "Changes the user limit of your channel.", typeof(VcCreator.LimitCommand), + new MakotoCommandOverload(typeof(int), "limit", "The limit", false) + .WithMaximumValue(99) + .WithMinimumValue(0)), + new MakotoCommand("invite", "Invites a new person to your channel.", typeof(VcCreator.InviteCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "User")), + new MakotoCommand("kick", "Kicks person from your channel.", typeof(VcCreator.KickCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "User")), + new MakotoCommand("ban", "Bans person from your channel.", typeof(VcCreator.BanCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "User")), + new MakotoCommand("unban", "Unbans person from your channel.", typeof(VcCreator.UnbanCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "User")), + new MakotoCommand("change-owner", "Sets a new person to be the owner of your channel.", typeof(VcCreator.ChangeOwnerCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "User"))), + + new MakotoCommand(ApplicationCommandType.Message, "Steal Emojis", "Steals all emojis and stickers of a message. Reply to a message to select it.", typeof(EmojiStealerCommand), "emoji") + .WithAliases("emojis", "emote", "steal", "grab", "sticker", "stickers"), + ]).WithPriority(999), + + new MakotoModule("Moderation", [ + new MakotoCommand("purge", "Deletes the specified amount of messages.", typeof(PurgeCommand), + new MakotoCommandOverload(typeof(int), "number", "1-2000") + .WithMinimumValue(1) + .WithMaximumValue(2000), + new MakotoCommandOverload(typeof(DiscordUser), "user", "Only delete messages by this user", false)) + .WithRequiredPermissions(Permissions.ManageMessages), + + new MakotoCommand("guild-purge", "Scans all channels and deletes the specified user's messages.", typeof(GuildPurgeCommand), + new MakotoCommandOverload(typeof(int), "number", "1-2000") + .WithMinimumValue(1) + .WithMaximumValue(2000), + new MakotoCommandOverload(typeof(DiscordUser), "user", "Only delete messages by this user")) + .WithRequiredPermissions(Permissions.ManageMessages | Permissions.ManageChannels), + + new MakotoCommand("clearbackup", "Clears the stored roles and nickname of a user.", typeof(ClearBackupCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "Only delete messages by this user")) + .WithRequiredPermissions(Permissions.ManageRoles), + + new MakotoCommand("timeout", "Sets the specified user into a timeout.", typeof(TimeoutCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "The user"), + new MakotoCommandOverload(typeof(string), "duration", "The duration", false), + new MakotoCommandOverload(typeof(string), "reason", "The reason", false, true)) + .WithRequiredPermissions(Permissions.ModerateMembers), + + new MakotoCommand("remove-timeout", "Removes a timeout from the specified user.", typeof(RemoveTimeoutCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "The user")) + .WithRequiredPermissions(Permissions.ModerateMembers), + + new MakotoCommand("kick", "Kicks the specified user.", typeof(KickCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "The user"), + new MakotoCommandOverload(typeof(string), "reason", "The reason", false, true)) + .WithRequiredPermissions(Permissions.KickMembers), + + new MakotoCommand("ban", "Bans the specified user.", typeof(BanCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "The user"), + new MakotoCommandOverload(typeof(int), "days", "Days of messages to delete") + .WithMinimumValue(0) + .WithMaximumValue(7), + new MakotoCommandOverload(typeof(string), "reason", "The reason", false, true)) + .WithRequiredPermissions(Permissions.BanMembers), + + new MakotoCommand("softban", "Soft bans the specified user.", typeof(SoftBanCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "The user"), + new MakotoCommandOverload(typeof(int), "days", "Days of messages to delete") + .WithMinimumValue(0) + .WithMaximumValue(7), + new MakotoCommandOverload(typeof(string), "reason", "The reason", false, true)) + .WithRequiredPermissions(Permissions.BanMembers), + + new MakotoCommand("unban", "Unbans the specified user.", typeof(UnbanCommand), + new MakotoCommandOverload(typeof(DiscordUser), "user", "The user")) + .WithRequiredPermissions(Permissions.BanMembers), + + new MakotoCommand("follow", "Allows you to follow an announcement channel from our support server.", typeof(FollowUpdatesCommand), + new MakotoCommandOverload(typeof(FollowChannel), "channel", "The channel")) + .WithRequiredPermissions(Permissions.ManageWebhooks), + + new MakotoCommand("moveall", "Move all users in your Voice Channel to another Voice Channel.", typeof(MoveAllCommand), + new MakotoCommandOverload(typeof(DiscordChannel), "channel", "The channel to move to.") + .WithChannelType(ChannelType.Voice)) + .WithRequiredPermissions(Permissions.MoveMembers), + + new MakotoCommand("movehere", "Move all users from another Voice Channel to your Voice Channel.", typeof(MoveHereCommand), + new MakotoCommandOverload(typeof(DiscordChannel), "channel", "The channel to move from.") + .WithChannelType(ChannelType.Voice)) + .WithRequiredPermissions(Permissions.MoveMembers), + + new MakotoCommand("customembed", "Create an embedded message", typeof(CustomEmbedCommand)) + .WithRequiredPermissions(Permissions.EmbedLinks | Permissions.ManageChannels), + + new MakotoCommand("override-bump-time", "Allows fixing of the last bump in case Disboard did not properly post a message.", typeof(ManualBumpCommand)) + .WithRequiredPermissions(Permissions.ManageChannels), + ]).WithPriority(995), + + new MakotoModule("Configuration", [ + new MakotoCommand("config", "Allows you to configure Makoto.", + new MakotoCommand("join", "Allows you to review and change settings in the event somebody joins the server.", typeof(Configuration.JoinCommand)), + new MakotoCommand("experience", "Allows you to review and change settings related to experience.", typeof(Configuration.ExperienceCommand)), + new MakotoCommand("levelrewards", "Allows you to review, add and change Level Rewards.", typeof(Configuration.LevelRewardsCommand)), + new MakotoCommand("phishing", "Allows you to review and change settings related to phishing link protection.", typeof(Configuration.PhishingCommand)), + new MakotoCommand("bumpreminder", "Allows you to review, set up and change settings related to the Bump Reminder.", typeof(Configuration.BumpReminderCommand)), + new MakotoCommand("actionlog", "Allows you to review and change settings related to the actionlog.", typeof(Configuration.ActionLogCommand)), + new MakotoCommand("autocrosspost", "Allows you to review and change settings related to automatic crossposting.", typeof(Configuration.AutoCrosspostCommand)), + new MakotoCommand("reactionroles", "Allows you to review and change settings related to Reaction Roles.", typeof(ReactionRolesCommand.ConfigCommand)), + new MakotoCommand("invoiceprivacy", "Allows you to review and change settings related to In-Voice Text Channel Privacy.", typeof(Configuration.InVoicePrivacyCommand)), + new MakotoCommand("invitetracker", "Allows you to review and change settings related to Invite Tracking.", typeof(Configuration.InviteTrackerCommand)), + new MakotoCommand("namenormalizer", "Allows you to review and change settings related to automatic name normalization.", typeof(Configuration.NameNormalizerCommand)), + new MakotoCommand("autounarchive", "Allows you to review and change settings related to automatic thread unarchiving.", typeof(Configuration.AutoUnarchiveCommand)), + new MakotoCommand("embedmessages", "Allows you to review and change settings related to automatic message embedding.", typeof(Configuration.EmbedMessageCommand)), + new MakotoCommand("tokendetection", "Allows you to review and change settings related to automatic token invalidation.", typeof(Configuration.TokenDetectionCommand)), + new MakotoCommand("invitenotes", "Allows you to add notes to invite codes.", typeof(Configuration.InviteNotesCommand)), + new MakotoCommand("vccreator", "Allows you to review and change settings related to the Voice Channel Creator.", typeof(Configuration.VcCreatorCommand)), + new MakotoCommand("guild-language", "Allows you to review and change settings related to the guild's selected language.", typeof(Configuration.GuildLanguageCommand)), + new MakotoCommand("guild-prefix", "Allows you to review and change settings related to the guild's prefix.", typeof(Configuration.PrefixCommand))) + .WithRequiredPermissions(Permissions.Administrator), + + new MakotoCommand(ApplicationCommandType.Message, "Add a Reaction Role", "Allows you to add a reaction role to a message directly.", typeof(ReactionRolesCommand.AddCommand)) + .WithRequiredPermissions(Permissions.Administrator), + new MakotoCommand(ApplicationCommandType.Message, "Remove a Reaction Role", "Allows you to remove a specific reaction role from a message directly.", typeof(ReactionRolesCommand.RemoveCommand)) + .WithRequiredPermissions(Permissions.Administrator), + new MakotoCommand(ApplicationCommandType.Message, "Remove all Reaction Roles", "Allows you to remove all reaction roles from a message directly.", typeof(ReactionRolesCommand.RemoveAllCommand)) + .WithRequiredPermissions(Permissions.Administrator), + ]).WithPriority(994), + ]; +} diff --git a/ProjectMakoto/Commands/Configuration/ActionLogCommand.cs b/ProjectMakoto/Commands/Configuration/ActionLogCommand.cs new file mode 100644 index 00000000..addcd05d --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/ActionLogCommand.cs @@ -0,0 +1,200 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class ActionLogCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + string GetCurrentConfiguration(SharedCommandContext ctx) + { + var CommandKey = ctx.Bot.LoadedTranslations.Commands.Config.ActionLog; + + if (!ctx.Guild.Channels.ContainsKey(ctx.DbGuild.ActionLog.Channel)) + ctx.DbGuild.ActionLog.Channel = 0; + + if (ctx.DbGuild.ActionLog.Channel == 0) + return $"❌ {CommandKey.ActionlogDisabled.Get(ctx.DbUser).Build(true)}"; + + var pad = TranslationUtil.CalculatePadding(ctx.DbUser, CommandKey.InviteModifications, CommandKey.VoiceChannelUpdates, CommandKey.ChannelModifications, CommandKey.ServerModifications, CommandKey.BanUpdates, + CommandKey.RoleUpdates, CommandKey.MessageModifications, CommandKey.MessageModifications, CommandKey.UserProfileUpdates, CommandKey.UserRoleUpdates, CommandKey.UserStateUpdates, + CommandKey.AttemptGatheringMoreDetails, CommandKey.ActionLogChannel); + + return $"{EmojiTemplates.GetChannel(ctx.Bot)} `{CommandKey.ActionLogChannel.Get(ctx.DbUser).PadRight(pad)}` : <#{ctx.DbGuild.ActionLog.Channel}>\n\n" + + $"⚠ `{CommandKey.AttemptGatheringMoreDetails.Get(ctx.DbUser).PadRight(pad)}` : {ctx.DbGuild.ActionLog.AttemptGettingMoreDetails.ToEmote(ctx.Bot)}\n" + + $"{EmojiTemplates.GetUser(ctx.Bot)} `{CommandKey.UserStateUpdates.Get(ctx.DbUser).PadRight(pad)}` : {ctx.DbGuild.ActionLog.MembersModified.ToEmote(ctx.Bot)}\n" + + $"{EmojiTemplates.GetUser(ctx.Bot)} `{CommandKey.UserRoleUpdates.Get(ctx.DbUser).PadRight(pad)}` : {ctx.DbGuild.ActionLog.MemberModified.ToEmote(ctx.Bot)}\n" + + $"{EmojiTemplates.GetUser(ctx.Bot)} `{CommandKey.UserProfileUpdates.Get(ctx.DbUser).PadRight(pad)}` : {ctx.DbGuild.ActionLog.MemberProfileModified.ToEmote(ctx.Bot)}\n" + + $"{EmojiTemplates.GetMessage(ctx.Bot)} `{CommandKey.MessageDeletions.Get(ctx.DbUser).PadRight(pad)}` : {ctx.DbGuild.ActionLog.MessageDeleted.ToEmote(ctx.Bot)}\n" + + $"{EmojiTemplates.GetMessage(ctx.Bot)} `{CommandKey.MessageModifications.Get(ctx.DbUser).PadRight(pad)}` : {ctx.DbGuild.ActionLog.MessageModified.ToEmote(ctx.Bot)}\n" + + $"{EmojiTemplates.GetUser(ctx.Bot)} `{CommandKey.RoleUpdates.Get(ctx.DbUser).PadRight(pad)}` : {ctx.DbGuild.ActionLog.RolesModified.ToEmote(ctx.Bot)}\n" + + $"{EmojiTemplates.GetUser(ctx.Bot)} `{CommandKey.BanUpdates.Get(ctx.DbUser).PadRight(pad)}` : {ctx.DbGuild.ActionLog.BanlistModified.ToEmote(ctx.Bot)}\n" + + $"{EmojiTemplates.GetGuild(ctx.Bot)} `{CommandKey.ServerModifications.Get(ctx.DbUser).PadRight(pad)}` : {ctx.DbGuild.ActionLog.GuildModified.ToEmote(ctx.Bot)}\n" + + $"{EmojiTemplates.GetChannel(ctx.Bot)} `{CommandKey.ChannelModifications.Get(ctx.DbUser).PadRight(pad)}` : {ctx.DbGuild.ActionLog.ChannelsModified.ToEmote(ctx.Bot)}\n" + + $"{EmojiTemplates.GetVoiceState(ctx.Bot)} `{CommandKey.VoiceChannelUpdates.Get(ctx.DbUser).PadRight(pad)}` : {ctx.DbGuild.ActionLog.VoiceStateUpdated.ToEmote(ctx.Bot)}\n" + + $"{EmojiTemplates.GetInvite(ctx.Bot)} `{CommandKey.InviteModifications.Get(ctx.DbUser).PadRight(pad)}` : {ctx.DbGuild.ActionLog.InvitesModified.ToEmote(ctx.Bot)}"; + } + + var CommandKey = this.t.Commands.Config.ActionLog; + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + var embed = new DiscordEmbedBuilder + { + Description = GetCurrentConfiguration(ctx) + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + var Disable = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), this.GetString(CommandKey.DisableActionLogButton), (ctx.DbGuild.ActionLog.Channel == 0), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✖"))); + var ChangeChannel = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), $"{(ctx.DbGuild.ActionLog.Channel == 0 ? this.GetString(CommandKey.SetChannelButton) : this.GetString(CommandKey.ChangeChannelButton))}", false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("💬"))); + var ChangeFilter = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.ChangeFilterButton), (ctx.DbGuild.ActionLog.Channel == 0), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("📣"))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed) + .AddComponents(new List + { + { Disable } + }) + .AddComponents(new List + { + { ChangeChannel }, + { ChangeFilter } + }).AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var Button = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (Button.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = Button.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (Button.GetCustomId() == Disable.CustomId) + { + ctx.DbGuild.ActionLog.Channel = 0; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == ChangeChannel.CustomId) + { + var ChannelResult = await this.PromptChannelSelection(ChannelType.Text, new ChannelPromptConfiguration + { + CreateChannelOption = new() + { + Name = "actionlog", + ChannelType = ChannelType.Text + } + }); + + if (ChannelResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ChannelResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ChannelResult.Failed) + { + if (ChannelResult.Exception.GetType() == typeof(NullReferenceException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(this.t.Commands.Common.Errors.NoChannels, true))); + await Task.Delay(3000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + throw ChannelResult.Exception; + } + + await ChannelResult.Result.ModifyAsync(x => x.PermissionOverwrites = new List + { + new(ctx.Guild.EveryoneRole) { Denied = Permissions.All }, + new(ctx.Member) { Allowed = Permissions.All }, + }); + + ctx.DbGuild.ActionLog.Channel = ChannelResult.Result.Id; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == ChangeFilter.CustomId) + { + try + { + var Selections = new List + { + new(this.GetString(CommandKey.AttemptGatheringMoreDetails), "attempt_further_detail", this.GetString(CommandKey.OptionInaccurate), ctx.DbGuild.ActionLog.AttemptGettingMoreDetails, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("⚠"))), + new(this.GetString(CommandKey.UserStateUpdates), "log_members_modified", null, ctx.DbGuild.ActionLog.MembersModified, new DiscordComponentEmoji(EmojiTemplates.GetUser(ctx.Bot))), + new(this.GetString(CommandKey.UserRoleUpdates), "log_member_modified", null, ctx.DbGuild.ActionLog.MemberModified, new DiscordComponentEmoji(EmojiTemplates.GetUser(ctx.Bot))), + new(this.GetString(CommandKey.UserProfileUpdates), "log_memberprofile_modified", null, ctx.DbGuild.ActionLog.MemberProfileModified, new DiscordComponentEmoji(EmojiTemplates.GetUser(ctx.Bot))), + new(this.GetString(CommandKey.MessageDeletions), "log_message_deleted", null, ctx.DbGuild.ActionLog.MessageDeleted, new DiscordComponentEmoji(EmojiTemplates.GetMessage(ctx.Bot))), + new(this.GetString(CommandKey.MessageModifications), "log_message_updated", null, ctx.DbGuild.ActionLog.MessageModified, new DiscordComponentEmoji(EmojiTemplates.GetMessage(ctx.Bot))), + new(this.GetString(CommandKey.RoleUpdates), "log_roles_modified", null, ctx.DbGuild.ActionLog.RolesModified, new DiscordComponentEmoji(EmojiTemplates.GetUser(ctx.Bot))), + new(this.GetString(CommandKey.BanUpdates), "log_banlist_modified", null, ctx.DbGuild.ActionLog.BanlistModified, new DiscordComponentEmoji(EmojiTemplates.GetUser(ctx.Bot))), + new(this.GetString(CommandKey.ServerModifications), "log_guild_modified", null, ctx.DbGuild.ActionLog.GuildModified, new DiscordComponentEmoji(EmojiTemplates.GetGuild(ctx.Bot))), + new(this.GetString(CommandKey.ChannelModifications), "log_channels_modified", null, ctx.DbGuild.ActionLog.ChannelsModified, new DiscordComponentEmoji(EmojiTemplates.GetChannel(ctx.Bot))), + new(this.GetString(CommandKey.VoiceChannelUpdates), "log_voice_state", null, ctx.DbGuild.ActionLog.VoiceStateUpdated, new DiscordComponentEmoji(EmojiTemplates.GetVoiceState(ctx.Bot))), + new(this.GetString(CommandKey.InviteModifications), "log_invites_modified", null, ctx.DbGuild.ActionLog.InvitesModified, new DiscordComponentEmoji(EmojiTemplates.GetInvite(ctx.Bot))), + }; + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed).AddComponents(new DiscordStringSelectComponent(this.GetString(CommandKey.NoOptions), Selections, Guid.NewGuid().ToString(), 0, Selections.Count, false))); + + var e = await ctx.Client.GetInteractivity().WaitForSelectAsync(ctx.ResponseMessage, x => x.User.Id == ctx.User.Id, ComponentType.StringSelect, TimeSpan.FromMinutes(2)); + + if (e.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = e.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + var selected = e.Result.Values.ToList(); + + ctx.DbGuild.ActionLog.AttemptGettingMoreDetails = selected.Contains("attempt_further_detail"); + + ctx.DbGuild.ActionLog.MembersModified = selected.Contains("log_members_modified"); + ctx.DbGuild.ActionLog.MemberModified = selected.Contains("log_member_modified"); + ctx.DbGuild.ActionLog.MemberProfileModified = selected.Contains("log_memberprofile_modified"); + ctx.DbGuild.ActionLog.MessageDeleted = selected.Contains("log_message_deleted"); + ctx.DbGuild.ActionLog.MessageModified = selected.Contains("log_message_updated"); + ctx.DbGuild.ActionLog.RolesModified = selected.Contains("log_roles_modified"); + ctx.DbGuild.ActionLog.BanlistModified = selected.Contains("log_banlist_modified"); + ctx.DbGuild.ActionLog.GuildModified = selected.Contains("log_guild_modified"); + ctx.DbGuild.ActionLog.ChannelsModified = selected.Contains("log_channels_modified"); + ctx.DbGuild.ActionLog.VoiceStateUpdated = selected.Contains("log_voice_state"); + ctx.DbGuild.ActionLog.InvitesModified = selected.Contains("log_invites_modified"); + + await this.ExecuteCommand(ctx, arguments); + return; + } + catch (ArgumentException) + { + this.ModifyToTimedOut(true); + return; + } + } + else if (Button.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + return; + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/AutoCrosspostCommand.cs b/ProjectMakoto/Commands/Configuration/AutoCrosspostCommand.cs new file mode 100644 index 00000000..0bd21aa6 --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/AutoCrosspostCommand.cs @@ -0,0 +1,226 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class AutoCrosspostCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.AutoCrosspost; + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + foreach (var b in ctx.DbGuild.Crosspost.CrosspostChannels.ToList()) + if (!ctx.Guild.Channels.ContainsKey(b)) + ctx.DbGuild.Crosspost.CrosspostChannels = ctx.DbGuild.Crosspost.CrosspostChannels.Remove(x => x.ToString(), b); + + string GetCurrentConfiguration(SharedCommandContext ctx) + { + var CommandKey = ctx.Bot.LoadedTranslations.Commands.Config.AutoCrosspost; + + var pad = TranslationUtil.CalculatePadding(ctx.DbUser, CommandKey.ExcludeBots, CommandKey.DelayBeforePosting); + + return $"🤖 `{CommandKey.ExcludeBots.Get(ctx.DbUser).PadRight(pad)}`: {ctx.DbGuild.Crosspost.ExcludeBots.ToEmote(ctx.Bot)}\n" + + $"🕒 `{CommandKey.DelayBeforePosting.Get(ctx.DbUser).PadRight(pad)}`: `{TimeSpan.FromSeconds(ctx.DbGuild.Crosspost.DelayBeforePosting).GetHumanReadable()}`\n\n" + + $"{(ctx.DbGuild.Crosspost.CrosspostChannels.Length != 0 ? string.Join("\n\n", ctx.DbGuild.Crosspost.CrosspostChannels.Where(x => ctx.Guild.Channels.ContainsKey(x)).Select(x => $"<#{x}> `[#{ctx.Guild.GetChannel(x).Name}]`")) : CommandKey.NoCrosspostChannels.Get(ctx.DbUser).Build(true))}"; + } + + var embed = new DiscordEmbedBuilder() + { + Description = GetCurrentConfiguration(ctx) + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + var SetDelayButton = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetDelayButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🕒"))); + var ExcludeBots = new DiscordButtonComponent((ctx.DbGuild.Crosspost.ExcludeBots ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.ToggleExcludeBotsButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🤖"))); + var AddButton = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.AddChannelButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("➕"))); + var RemoveButton = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), this.GetString(CommandKey.RemoveChannelButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✖"))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed) + .AddComponents(new List + { + ExcludeBots, + SetDelayButton + }) + .AddComponents(new List + { + AddButton, + RemoveButton + }).AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var Button = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (Button.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + if (Button.GetCustomId() == ExcludeBots.CustomId) + { + _ = Button.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + ctx.DbGuild.Crosspost.ExcludeBots = !ctx.DbGuild.Crosspost.ExcludeBots; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == SetDelayButton.CustomId) + { + _ = Button.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + var ModalResult = await this.PromptForTimeSpan(TimeSpan.FromMinutes(5), TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(ctx.DbGuild.Crosspost.DelayBeforePosting), false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ModalResult.Errored) + { + if (ModalResult.Exception.GetType() == typeof(InvalidOperationException)) + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.WithDescription(this.GetString(CommandKey.DurationLimit, true)).AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ModalResult.Exception.GetType() == typeof(ArgumentException)) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + + throw ModalResult.Exception; + } + + ctx.DbGuild.Crosspost.DelayBeforePosting = Convert.ToInt32(ModalResult.Result.TotalSeconds); + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == AddButton.CustomId) + { + _ = Button.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (ctx.DbGuild.Crosspost.CrosspostChannels.Length >= 20) + { + embed.Description = this.GetString(CommandKey.ChannelLimit, true, new TVar("Invite", ctx.Bot.status.DevelopmentServerInvite)); + embed = embed.AsError(ctx, this.GetString(CommandKey.Title)); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed)); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + var ChannelResult = await this.PromptChannelSelection(ChannelType.News); + + if (ChannelResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ChannelResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ChannelResult.Failed) + { + if (ChannelResult.Exception.GetType() == typeof(NullReferenceException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(this.t.Commands.Common.Errors.NoChannels, true))); + await Task.Delay(3000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + throw ChannelResult.Exception; + } + + if (ChannelResult.Result.Type != ChannelType.News) + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.WithDescription(this.GetString(this.t.Commands.Common.Errors.NoChannels, true)).AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + if (ctx.DbGuild.Crosspost.CrosspostChannels.Length >= 50) + { + _ = await this.RespondOrEdit(embed.WithDescription(this.GetString(CommandKey.ChannelLimit, true, new TVar("Invite", ctx.Bot.status.DevelopmentServerInvite))).AsError(ctx, this.GetString(CommandKey.Title))); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + if (!ctx.DbGuild.Crosspost.CrosspostChannels.Contains(ChannelResult.Result.Id)) + ctx.DbGuild.Crosspost.CrosspostChannels = ctx.DbGuild.Crosspost.CrosspostChannels.Add(ChannelResult.Result.Id); + + await this.ExecuteCommand(ctx, arguments); + return; + + } + else if (Button.GetCustomId() == RemoveButton.CustomId) + { + _ = Button.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (ctx.DbGuild.Crosspost.CrosspostChannels.Length == 0) + { + _ = await this.RespondOrEdit(embed.WithDescription(this.GetString(CommandKey.NoCrosspostChannels, true)).AsError(ctx, this.GetString(CommandKey.Title))); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + var ChannelResult = await this.PromptCustomSelection(ctx.DbGuild.Crosspost.CrosspostChannels + .Where(x => ctx.Guild.Channels.ContainsKey(x)) + .Select(x => new DiscordStringSelectComponentOption($"#{ctx.Guild.GetChannel(x).Name} ({x})", x.ToString(), $"{(ctx.Guild.GetChannel(x).Parent is not null ? $"{ctx.Guild.GetChannel(x).Parent.Name}" : "")}")).ToList()); + + if (ChannelResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ChannelResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ChannelResult.Errored) + { + throw ChannelResult.Exception; + } + + var ChannelToRemove = Convert.ToUInt64(ChannelResult.Result); + + if (ctx.DbGuild.Crosspost.CrosspostChannels.Contains(ChannelToRemove)) + ctx.DbGuild.Crosspost.CrosspostChannels = ctx.DbGuild.Crosspost.CrosspostChannels.Remove(x => x.ToString(), ChannelToRemove); + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + return; + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/AutoUnarchiveCommand.cs b/ProjectMakoto/Commands/Configuration/AutoUnarchiveCommand.cs new file mode 100644 index 00000000..31e8b076 --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/AutoUnarchiveCommand.cs @@ -0,0 +1,130 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class AutoUnarchiveCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.AutoUnarchive; + + string GetCurrentConfiguration(SharedCommandContext ctx) + { + foreach (var b in ctx.DbGuild.AutoUnarchiveThreads.ToList()) + { + if (!ctx.Guild.Channels.ContainsKey(b)) + ctx.DbGuild.AutoUnarchiveThreads = ctx.DbGuild.AutoUnarchiveThreads.Remove(x => x.ToString(), b); + } + + return $"{(ctx.DbGuild.AutoUnarchiveThreads.Length != 0 ? string.Join("\n", ctx.DbGuild.AutoUnarchiveThreads.Select(x => $"{ctx.Guild.GetChannel(x).Mention} [`#{ctx.Guild.GetChannel(x).Name}`] (`{x}`)")) : ctx.Bot.LoadedTranslations.Commands.Config.AutoUnarchive.NoChannels.Get(ctx.DbUser).Build(true))}"; + } + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + var embed = new DiscordEmbedBuilder + { + Description = $"{GetCurrentConfiguration(ctx)}\n\n{this.GetString(CommandKey.Explanation)}" + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + var Add = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(CommandKey.AddChannelButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("➕"))); + var Remove = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), this.GetString(CommandKey.RemoveChannelButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✖"))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed) + .AddComponents(new List + { + Add, + Remove + }) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var e = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (e.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = e.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == Add.CustomId) + { + var ChannelResult = await this.PromptChannelSelection(new ChannelType[] { ChannelType.Text, ChannelType.Forum }); + + if (ChannelResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ChannelResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ChannelResult.Failed) + { + if (ChannelResult.Exception.GetType() == typeof(NullReferenceException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(this.t.Commands.Common.Errors.NoChannels))); + await Task.Delay(3000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + throw ChannelResult.Exception; + } + + if (!ctx.DbGuild.AutoUnarchiveThreads.Contains(ChannelResult.Result.Id)) + ctx.DbGuild.AutoUnarchiveThreads = ctx.DbGuild.AutoUnarchiveThreads.Add(ChannelResult.Result.Id); + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == Remove.CustomId) + { + var ChannelResult = await this.PromptCustomSelection(ctx.DbGuild.AutoUnarchiveThreads + .Select(x => new DiscordStringSelectComponentOption($"#{ctx.Guild.GetChannel(x).Name} ({x})", x.ToString(), $"{(ctx.Guild.GetChannel(x).Parent is not null ? $"{ctx.Guild.GetChannel(x).Parent.Name}" : "")}")).ToList()); + + if (ChannelResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ChannelResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ChannelResult.Errored) + { + throw ChannelResult.Exception; + } + + var ChannelToRemove = Convert.ToUInt64(ChannelResult.Result); + + if (ctx.DbGuild.AutoUnarchiveThreads.Contains(ChannelToRemove)) + ctx.DbGuild.AutoUnarchiveThreads = ctx.DbGuild.AutoUnarchiveThreads.Remove(x => x.ToString(), ChannelToRemove); + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + return; + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/BumpReminderCommand.cs b/ProjectMakoto/Commands/Configuration/BumpReminderCommand.cs new file mode 100644 index 00000000..7fcc6b21 --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/BumpReminderCommand.cs @@ -0,0 +1,230 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class BumpReminderCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) + => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.BumpReminder; + + string GetCurrentConfiguration(SharedCommandContext ctx) + { + var CommandKey = ctx.Bot.LoadedTranslations.Commands.Config.BumpReminder; + + if (ctx.DbGuild.BumpReminder.ChannelId == 0) + return $"{EmojiTemplates.GetQuestionMark(ctx.Bot)} `{CommandKey.BumpReminderEnabled.Get(ctx.DbUser)}` : {false.ToEmote(ctx.Bot)}"; + + var pad = TranslationUtil.CalculatePadding(ctx.DbUser, CommandKey.BumpReminderEnabled, CommandKey.BumpReminderChannel, CommandKey.BumpReminderRole); + + return $"{EmojiTemplates.GetQuestionMark(ctx.Bot)} `{CommandKey.BumpReminderEnabled.Get(ctx.DbUser).PadRight(pad)}` : {true.ToEmote(ctx.Bot)}\n" + + $"{EmojiTemplates.GetChannel(ctx.Bot)} `{CommandKey.BumpReminderChannel.Get(ctx.DbUser).PadRight(pad)}` : <#{ctx.DbGuild.BumpReminder.ChannelId}> `({ctx.DbGuild.BumpReminder.ChannelId})`\n" + + $"{EmojiTemplates.GetUser(ctx.Bot)} `{CommandKey.BumpReminderRole.Get(ctx.DbUser).PadRight(pad)}` : <@&{ctx.DbGuild.BumpReminder.RoleId}> `({ctx.DbGuild.BumpReminder.RoleId})`"; + } + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + var Setup = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetupBumpReminderButton), ctx.DbGuild.BumpReminder.ChannelId != 0, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("➕"))); + var Disable = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), this.GetString(CommandKey.DisableBumpReminderButton), ctx.DbGuild.BumpReminder.ChannelId == 0, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✖"))); + var ChangeChannel = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.ChangeChannelButton), ctx.DbGuild.BumpReminder.ChannelId == 0, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("💬"))); + var ChangeRole = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.ChangeRoleButton), ctx.DbGuild.BumpReminder.ChannelId == 0, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👤"))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder() + .WithDescription(GetCurrentConfiguration(ctx)).AsAwaitingInput(ctx, this.GetString(CommandKey.Title))) + .AddComponents(new List + { + { Setup }, + { Disable } + }) + .AddComponents(new List + { + { ChangeChannel }, + { ChangeRole } + }).AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var e = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (e.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = e.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == Setup.CustomId) + { + if (!(await ctx.Guild.GetAllMembersAsync()).Any(x => x.Id == ctx.Bot.status.LoadedConfig.Accounts.Disboard)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.DisboardMissing, true)) + .AsError(ctx, this.GetString(CommandKey.Title))); + return; + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.SettingUp, true)) + .AsLoading(ctx, this.GetString(CommandKey.Title))); + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.SelectRole, true)) + .AsAwaitingInput(ctx, this.GetString(CommandKey.Title))); + + + var RoleResult = await this.PromptRoleSelection(new() { CreateRoleOption = "BumpReminder" }); + + if (RoleResult.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + else if (RoleResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (RoleResult.Failed) + { + if (RoleResult.Exception.GetType() == typeof(NullReferenceException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(this.t.Commands.Common.Errors.NoChannels, true))); + await Task.Delay(3000); + return; + } + + throw RoleResult.Exception; + } + + if (RoleResult.Result.Id == ctx.DbGuild.Join.AutoAssignRoleId || ctx.DbGuild.LevelRewards.Any(x => x.RoleId == RoleResult.Result.Id)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(CommandKey.CantUseRole, true))); + await Task.Delay(3000); + return; + } + + var bump_reaction_msg = await ctx.Channel.SendMessageAsync(this.GetGuildString(CommandKey.ReactionRoleMessage, new TVar("Emoji", "✅".UnicodeToEmoji()))); + _ = bump_reaction_msg.CreateReactionAsync(DiscordEmoji.FromUnicode("✅")); + _ = bump_reaction_msg.PinAsync(); + + _ = ctx.Channel.DeleteMessagesAsync((await ctx.Channel.GetMessagesAsync(2)).Where(x => x.Author.Id == ctx.Client.CurrentUser.Id && x.MessageType == MessageType.ChannelPinnedMessage)); + + ctx.DbGuild.BumpReminder.RoleId = RoleResult.Result.Id; + ctx.DbGuild.BumpReminder.ChannelId = ctx.Channel.Id; + ctx.DbGuild.BumpReminder.MessageId = bump_reaction_msg.Id; + ctx.DbGuild.BumpReminder.LastBump = DateTime.UtcNow.AddHours(-2); + ctx.DbGuild.BumpReminder.LastReminder = DateTime.UtcNow.AddHours(-2); + ctx.DbGuild.BumpReminder.LastUserId = 0; + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.SetupComplete, true)) + .AsSuccess(ctx, this.GetString(CommandKey.Title))); + + await Task.Delay(5000); + ctx.Bot.BumpReminder.SendPersistentMessage(ctx.Client, ctx.Channel, null); + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == Disable.CustomId) + { + ctx.DbGuild.BumpReminder.Reset(); + + foreach (var b in ScheduledTaskExtensions.GetScheduledTasks()) + { + if (b.CustomData is not ScheduledTaskIdentifier scheduledTaskIdentifier) + continue; + + if (scheduledTaskIdentifier.Snowflake == ctx.Guild.Id && scheduledTaskIdentifier.Type == "bumpmsg") + b.Delete(); + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.DisableBumpReminderButton, true)) + .AsSuccess(ctx, this.GetString(CommandKey.Title))); + + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == ChangeChannel.CustomId) + { + var ChannelResult = await this.PromptChannelSelection(ChannelType.Text); + + if (ChannelResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ChannelResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ChannelResult.Failed) + { + if (ChannelResult.Exception.GetType() == typeof(NullReferenceException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(this.t.Commands.Common.Errors.NoChannels))); + await Task.Delay(3000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + throw ChannelResult.Exception; + } + + ctx.DbGuild.BumpReminder.ChannelId = ChannelResult.Result.Id; + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == ChangeRole.CustomId) + { + + var RoleResult = await this.PromptRoleSelection(); + + if (RoleResult.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + else if (RoleResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (RoleResult.Failed) + { + if (RoleResult.Exception.GetType() == typeof(NullReferenceException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(this.t.Commands.Common.Errors.NoRoles, true))); + await Task.Delay(3000); + return; + } + + throw RoleResult.Exception; + } + + ctx.DbGuild.BumpReminder.RoleId = RoleResult.Result.Id; + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + return; + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/EmbedMessageCommand.cs b/ProjectMakoto/Commands/Configuration/EmbedMessageCommand.cs new file mode 100644 index 00000000..9db9f3d5 --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/EmbedMessageCommand.cs @@ -0,0 +1,82 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class EmbedMessageCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.EmbedMessages; + + string GetCurrentConfiguration(SharedCommandContext ctx) + { + var CommandKey = ctx.Bot.LoadedTranslations.Commands.Config.EmbedMessages; + + var pad = TranslationUtil.CalculatePadding(ctx.DbUser, CommandKey.EmbedGithubCode, CommandKey.EmbedMessageLinks); + + return $"{"💬".UnicodeToEmoji()} `{CommandKey.EmbedMessageLinks.Get(ctx.DbUser).PadRight(pad)}`: {ctx.DbGuild.EmbedMessage.UseEmbedding.ToEmote(ctx.Bot)}\n" + + $"{"🤖".UnicodeToEmoji()} `{CommandKey.EmbedGithubCode.Get(ctx.DbUser).PadRight(pad)}`: {ctx.DbGuild.EmbedMessage.UseGithubEmbedding.ToEmote(ctx.Bot)}"; + } + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + var embed = new DiscordEmbedBuilder + { + Description = GetCurrentConfiguration(ctx) + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + var ToggleMsg = new DiscordButtonComponent((ctx.DbGuild.EmbedMessage.UseEmbedding ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.ToggleMessageLinkButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("💬"))); + var ToggleGithub = new DiscordButtonComponent((ctx.DbGuild.EmbedMessage.UseGithubEmbedding ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.ToggleGithubCodeButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🤖"))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed) + .AddComponents(new List + { + ToggleMsg, + ToggleGithub + }) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var e = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (e.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = e.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == ToggleMsg.CustomId) + { + ctx.DbGuild.EmbedMessage.UseEmbedding = !ctx.DbGuild.EmbedMessage.UseEmbedding; + + await this.ExecuteCommand(ctx, arguments); + return; + } + if (e.GetCustomId() == ToggleGithub.CustomId) + { + ctx.DbGuild.EmbedMessage.UseGithubEmbedding = !ctx.DbGuild.EmbedMessage.UseGithubEmbedding; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + return; + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/ExperienceCommand.cs b/ProjectMakoto/Commands/Configuration/ExperienceCommand.cs new file mode 100644 index 00000000..733ff290 --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/ExperienceCommand.cs @@ -0,0 +1,83 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class ExperienceCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.Experience; + + string GetCurrentConfiguration(SharedCommandContext ctx) + { + var CommandKey = ctx.Bot.LoadedTranslations.Commands.Config.Experience; + + var pad = TranslationUtil.CalculatePadding(ctx.DbUser, CommandKey.ExperienceEnabled, CommandKey.ExperienceBoostForBumpers); + + return $"{"✨".UnicodeToEmoji()} `{CommandKey.ExperienceEnabled.Get(ctx.DbUser).PadRight(pad)}`: {ctx.DbGuild.Experience.UseExperience.ToEmote(ctx.Bot)}\n" + + $"{"⏫".UnicodeToEmoji()} `{CommandKey.ExperienceBoostForBumpers.Get(ctx.DbUser).PadRight(pad)}`: {ctx.DbGuild.Experience.BoostXpForBumpReminder.ToEmote(ctx.Bot)}"; + } + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + var embed = new DiscordEmbedBuilder() + { + Description = GetCurrentConfiguration(ctx) + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + var builder = new DiscordMessageBuilder().WithEmbed(embed); + + var ToggleExperienceSystem = new DiscordButtonComponent((ctx.DbGuild.Experience.UseExperience ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.ToggleExperienceButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✨"))); + var ToggleBumperBoost = new DiscordButtonComponent((ctx.DbGuild.Experience.BoostXpForBumpReminder ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.ToggleExperienceBoostButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("⏫"))); + + _ = await this.RespondOrEdit(builder + .AddComponents(new List + { + ToggleExperienceSystem, + ToggleBumperBoost, + }) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var e = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (e.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = e.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == ToggleExperienceSystem.CustomId) + { + ctx.DbGuild.Experience.UseExperience = !ctx.DbGuild.Experience.UseExperience; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == ToggleBumperBoost.CustomId) + { + ctx.DbGuild.Experience.BoostXpForBumpReminder = !ctx.DbGuild.Experience.BoostXpForBumpReminder; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/GuildLanguageCommand.cs b/ProjectMakoto/Commands/Configuration/GuildLanguageCommand.cs new file mode 100644 index 00000000..ded75eb8 --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/GuildLanguageCommand.cs @@ -0,0 +1,114 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class GuildLanguageCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + string GetCurrentConfiguration(SharedCommandContext ctx) + { + return $"🗨 `{ctx.BaseCommand.GetString(this.t.Commands.Config.GuildLanguage.Disclaimer)}`\n`{ctx.BaseCommand.GetString(ctx.t.Commands.Config.GuildLanguage.Response)}`: `{(ctx.DbGuild.OverrideLocale.IsNullOrWhiteSpace() ? (ctx.DbGuild.CurrentLocale.IsNullOrWhiteSpace() ? "en (Default)" : $"{ctx.DbGuild.CurrentLocale} (Discord)") : $"{ctx.DbGuild.OverrideLocale} (Override)")}`"; + } + + _ = await this.RespondOrEdit((new DiscordEmbedBuilder() + { + Description = GetCurrentConfiguration(ctx) + }.AsAwaitingInput(ctx, this.GetString(this.t.Commands.Config.GuildLanguage.Title)))); + + List options = new(); + List newOptions = new(); + + newOptions.Add(new DiscordStringSelectComponentOption("Disable Override", "_", this.GetString(this.t.Commands.Config.GuildLanguage.DisableOverride), false, DiscordEmoji.FromUnicode("❌").ToComponent())); + + options.Add(new DiscordStringSelectComponentOption("English", "en", "English")); + options.Add(new DiscordStringSelectComponentOption("German", "de", "Deutsch")); + options.Add(new DiscordStringSelectComponentOption("Indonesian", "id", "Bahasa Indonesia")); + options.Add(new DiscordStringSelectComponentOption("Danish", "da", "Dansk")); + options.Add(new DiscordStringSelectComponentOption("Spanish", "es-ES", "Español")); + options.Add(new DiscordStringSelectComponentOption("French", "fr", "Français")); + options.Add(new DiscordStringSelectComponentOption("Croatian", "hr", "Hrvatski")); + options.Add(new DiscordStringSelectComponentOption("Italian", "it", "Italiano")); + options.Add(new DiscordStringSelectComponentOption("Lithuanian", "lt", "Lietuviškai")); + options.Add(new DiscordStringSelectComponentOption("Hungarian", "hu", "Magyar")); + options.Add(new DiscordStringSelectComponentOption("Dutch", "nl", "Nederlands")); + options.Add(new DiscordStringSelectComponentOption("Norwegian", "no", "Norsk")); + options.Add(new DiscordStringSelectComponentOption("Polish", "pl", "Polski")); + options.Add(new DiscordStringSelectComponentOption("Portuguese, Brazilian", "pt-BR", "Português do Brasil")); + options.Add(new DiscordStringSelectComponentOption("Romanian, Romania", "ro", "Română")); + options.Add(new DiscordStringSelectComponentOption("Finnish", "fi", "Suomi")); + options.Add(new DiscordStringSelectComponentOption("Swedish", "sv-SE", "Svenska")); + options.Add(new DiscordStringSelectComponentOption("Vietnamese", "vi", "Tiếng Việt")); + options.Add(new DiscordStringSelectComponentOption("Turkish", "tr", "Türkçe")); + options.Add(new DiscordStringSelectComponentOption("Czech", "cs", "Čeština")); + options.Add(new DiscordStringSelectComponentOption("Greek", "el", "Ελληνικά")); + options.Add(new DiscordStringSelectComponentOption("Bulgarian", "bg", "български")); + options.Add(new DiscordStringSelectComponentOption("Russian", "ru", "Pусский")); + options.Add(new DiscordStringSelectComponentOption("Ukrainian", "uk", "Українська")); + options.Add(new DiscordStringSelectComponentOption("Hindi", "hi", "हिन्दी")); + options.Add(new DiscordStringSelectComponentOption("Thai", "th", "ไทย")); + options.Add(new DiscordStringSelectComponentOption("Chinese, China", "zh-CN", "中文")); + options.Add(new DiscordStringSelectComponentOption("Japanese", "ja", "日本語")); + options.Add(new DiscordStringSelectComponentOption("Chinese, Taiwan", "zh-TW", "繁體中文")); + options.Add(new DiscordStringSelectComponentOption("Korean", "ko", "한국어")); + + foreach (var b in options) + if (this.t.Progress.TryGetValue(b.Value, out var value)) + { + var perc = (value / (decimal)this.t.Progress["en"] * 100); + DiscordComponentEmoji emoji = null; + + if (perc >= 100) + emoji = DiscordEmoji.FromUnicode("🟢").ToComponent(); + else emoji = perc >= 85 ? DiscordEmoji.FromUnicode("🟡").ToComponent() : DiscordEmoji.FromUnicode("🔴").ToComponent(); + + newOptions.Add(new DiscordStringSelectComponentOption(b.Label, b.Value, b.Description.Insert(0, $"{perc.ToString("N1", CultureInfo.CreateSpecificCulture("en-US"))}% | "), false, emoji)); + } + + var SelectionResult = await this.PromptCustomSelection(newOptions, this.GetString(this.t.Commands.Config.GuildLanguage.Selector)); + + if (SelectionResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (SelectionResult.Cancelled) + { + this.DeleteOrInvalidate(); + return; + } + else if (SelectionResult.Errored) + { + throw SelectionResult.Exception; + } + + switch (SelectionResult.Result) + { + case "_": + { + ctx.DbGuild.OverrideLocale = null; + break; + } + default: + { + ctx.DbGuild.OverrideLocale = SelectionResult.Result; + break; + } + } + + await this.ExecuteCommand(ctx, arguments); + return; + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/InVoicePrivacyCommand.cs b/ProjectMakoto/Commands/Configuration/InVoicePrivacyCommand.cs new file mode 100644 index 00000000..e872534c --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/InVoicePrivacyCommand.cs @@ -0,0 +1,106 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class InVoicePrivacyCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.InVoicePrivacy; + + string GetCurrentConfiguration(SharedCommandContext ctx) + { + var CommandKey = ctx.Bot.LoadedTranslations.Commands.Config.InVoicePrivacy; + + var pad = TranslationUtil.CalculatePadding(ctx.DbUser, CommandKey.ClearMessagesOnLeave, CommandKey.SetPermissions); + + return $"{"🗑".UnicodeToEmoji()} `{CommandKey.ClearMessagesOnLeave.Get(ctx.DbUser).PadRight(pad)}`: {ctx.DbGuild.InVoiceTextPrivacy.ClearTextEnabled.ToEmote(ctx.Bot)}\n" + + $"{"📋".UnicodeToEmoji()} `{CommandKey.SetPermissions.Get(ctx.DbUser).PadRight(pad)}`: {ctx.DbGuild.InVoiceTextPrivacy.SetPermissionsEnabled.ToEmote(ctx.Bot)}"; + } + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + var embed = new DiscordEmbedBuilder + { + Description = GetCurrentConfiguration(ctx) + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + var ToggleDeletion = new DiscordButtonComponent((ctx.DbGuild.InVoiceTextPrivacy.ClearTextEnabled ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.ToggleMessageDeletionButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🗑"))); + var TogglePermission = new DiscordButtonComponent((ctx.DbGuild.InVoiceTextPrivacy.SetPermissionsEnabled ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.TogglePermissionProtectionButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("📋"))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed) + .AddComponents(new List + { + ToggleDeletion, + TogglePermission + }) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var e = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (e.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = e.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == ToggleDeletion.CustomId) + { + ctx.DbGuild.InVoiceTextPrivacy.ClearTextEnabled = !ctx.DbGuild.InVoiceTextPrivacy.ClearTextEnabled; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == TogglePermission.CustomId) + { + ctx.DbGuild.InVoiceTextPrivacy.SetPermissionsEnabled = !ctx.DbGuild.InVoiceTextPrivacy.SetPermissionsEnabled; + + _ = Task.Run(async () => + { + if (ctx.DbGuild.InVoiceTextPrivacy.SetPermissionsEnabled) + { + if (!ctx.Guild.Channels.Any(x => x.Value.Type == ChannelType.Voice)) + return; + + foreach (var b in ctx.Guild.Channels.Where(x => x.Value.Type == ChannelType.Voice)) + { + _ = b.Value.AddOverwriteAsync(ctx.Guild.EveryoneRole, Permissions.None, Permissions.ReadMessageHistory | Permissions.SendMessages, this.GetGuildString(CommandKey.EnabledInVoicePrivacy)); + } + } + else + { + if (!ctx.Guild.Channels.Any(x => x.Value.Type == ChannelType.Voice)) + return; + + foreach (var b in ctx.Guild.Channels.Where(x => x.Value.Type == ChannelType.Voice)) + { + _ = b.Value.DeleteOverwriteAsync(ctx.Guild.EveryoneRole, this.GetGuildString(CommandKey.DisabledInVoicePrivacy)); + } + } + }); + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + return; + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/InviteNotesCommand.cs b/ProjectMakoto/Commands/Configuration/InviteNotesCommand.cs new file mode 100644 index 00000000..6806e2d2 --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/InviteNotesCommand.cs @@ -0,0 +1,192 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class InviteNotesCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.InviteNotes; + + string GetCurrentConfiguration(SharedCommandContext ctx) + { + return ctx.DbGuild.InviteNotes.Notes.Length == 0 + ? ctx.BaseCommand.GetString(this.t.Commands.Config.InviteNotes.NoNotesDefined, true) + : $"{string.Join('\n', ctx.DbGuild.InviteNotes.Notes.Select(x => $"> `{x.Invite}`\n{x.Note.FullSanitize()}"))}"; + } + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + var AddButton = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.AddNoteButton), false, DiscordEmoji.FromUnicode("➕").ToComponent()); + var RemoveButton = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.RemoveNoteButton), false, DiscordEmoji.FromUnicode("➖").ToComponent()); + + var embed = new DiscordEmbedBuilder + { + Description = GetCurrentConfiguration(ctx) + }.AsInfo(ctx, this.GetString(CommandKey.Title)); + + if (!(ctx.DbGuild.InviteNotes.Notes.Length > 19)) + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed) + .AddComponents(new List + { + AddButton, + RemoveButton, + }).AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + } + else + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed) + .AddComponents(new List { RemoveButton }).AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + } + + var e = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (e.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = e.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == AddButton.CustomId) + { + string? SelectedText = null; + DiscordInvite SelectedInvite = null; + + while (true) + { + var SelectTextButton = new DiscordButtonComponent((SelectedText.IsNullOrWhiteSpace() ? ButtonStyle.Primary : ButtonStyle.Secondary), Guid.NewGuid().ToString(), this.GetString(CommandKey.SetNoteButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🗯"))); + var SelectInviteButton = new DiscordButtonComponent((SelectedText.IsNullOrWhiteSpace() ? ButtonStyle.Primary : ButtonStyle.Secondary), Guid.NewGuid().ToString(), this.GetString(CommandKey.SelectInviteButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👤"))); + var Finish = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(CommandKey.CreateButton), (SelectedText.IsNullOrWhiteSpace() || SelectedInvite is null), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✅"))); + + var pad = TranslationUtil.CalculatePadding(ctx.DbUser, CommandKey.Note, CommandKey.Invite); + + embed = new DiscordEmbedBuilder + { + Description = $"`{this.GetString(CommandKey.Note).PadRight(pad)}`: `{(SelectedText.IsNullOrWhiteSpace() ? this.GetString(this.t.Common.NotSelected) : SelectedText).SanitizeForCode()}`\n" + + $"`{this.GetString(CommandKey.Invite).PadRight(pad)}`: `{(SelectedInvite is null ? this.GetString(this.t.Common.NotSelected) : $"{SelectedInvite.Code}")}`" + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed) + .AddComponents(new List { SelectTextButton, SelectInviteButton, Finish }) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var Menu = await ctx.WaitForButtonAsync(); + + if (Menu.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + if (Menu.GetCustomId() == SelectTextButton.CustomId) + { + var ModalResult = await this.PromptModalWithRetry(Menu.Result.Interaction, new DiscordInteractionModalBuilder() + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Paragraph, "Note", this.GetString(CommandKey.Note), "", 1, 128, true)), false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + SelectedText = ModalResult.Result.Interaction.GetModalValueByCustomId("Note").Truncate(128); + } + else if (Menu.GetCustomId() == SelectInviteButton.CustomId) + { + _ = Menu.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + var invites = await ctx.Guild.GetInvitesAsync(); + + var SelectionResult = await this.PromptCustomSelection(invites.Where(x => !ctx.DbGuild.InviteNotes.Notes.Any(a => a.Invite == x.Code)) + .Select(x => new DiscordStringSelectComponentOption(x.Code, x.Code, this.GetString(CommandKey.InviteDescription, new TVar("Uses", x.Uses), new TVar("Creator", x.Inviter.GetUsernameWithIdentifier())))).ToList()); + + if (SelectionResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (SelectionResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (SelectionResult.Errored) + { + throw SelectionResult.Exception; + } + + SelectedInvite = invites.First(x => x.Code == SelectionResult.Result); + } + else if (Menu.GetCustomId() == Finish.CustomId) + { + _ = Menu.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + ctx.DbGuild.InviteNotes.Notes = ctx.DbGuild.InviteNotes.Notes.Add(new() + { + Invite = SelectedInvite.Code, + Moderator = ctx.User.Id, + Note = SelectedText + }); + + await this.ExecuteCommand(ctx, arguments); + return; + } + } + } + else if (e.GetCustomId() == RemoveButton.CustomId) + { + var SelectionResult = await this.PromptCustomSelection(ctx.DbGuild.InviteNotes.Notes.Select(x => new DiscordStringSelectComponentOption(x.Invite, x.Invite, $"{x.Note.TruncateWithIndication(50)}")).ToList()); + + if (SelectionResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (SelectionResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (SelectionResult.Errored) + { + throw SelectionResult.Exception; + } + + ctx.DbGuild.InviteNotes.Notes = ctx.DbGuild.InviteNotes.Notes + .Remove(x => x.Invite, ctx.DbGuild.InviteNotes.Notes.First(x => x.Invite == SelectionResult.Result)); + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + return; + } + }); + } +} diff --git a/ProjectMakoto/Commands/Configuration/InviteTrackerCommand.cs b/ProjectMakoto/Commands/Configuration/InviteTrackerCommand.cs new file mode 100644 index 00000000..acd4b117 --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/InviteTrackerCommand.cs @@ -0,0 +1,71 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class InviteTrackerCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.InviteTracker; + + string GetCurrentConfiguration(SharedCommandContext ctx) + { + return $"{"📲".UnicodeToEmoji()} `{this.GetString(this.t.Commands.Config.InviteTracker.InviteTrackerEnabled)}`: {ctx.DbGuild.InviteTracker.Enabled.ToEmote(ctx.Bot)}"; + } + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + var embed = new DiscordEmbedBuilder + { + Description = GetCurrentConfiguration(ctx) + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + var Toggle = new DiscordButtonComponent((ctx.DbGuild.InviteTracker.Enabled ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.ToggleInviteTrackerButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("📲"))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed) + .AddComponents(new List + { + Toggle + }) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var e = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (e.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = e.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == Toggle.CustomId) + { + ctx.DbGuild.InviteTracker.Enabled = !ctx.DbGuild.InviteTracker.Enabled; + + if (ctx.DbGuild.InviteTracker.Enabled) + _ = InviteTrackerEvents.UpdateCachedInvites(ctx.Bot, ctx.Guild); + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + return; + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/JoinCommand.cs b/ProjectMakoto/Commands/Configuration/JoinCommand.cs new file mode 100644 index 00000000..dfebaa34 --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/JoinCommand.cs @@ -0,0 +1,280 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class JoinCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.Join; + + string GetCurrentConfiguration(SharedCommandContext ctx) + { + var CommandKey = ctx.Bot.LoadedTranslations.Commands.Config.Join; + + var pad = TranslationUtil.CalculatePadding(ctx.DbUser, + CommandKey.Autoban, + CommandKey.JoinLogChannel, + CommandKey.Role, + CommandKey.ReApplyRoles, + CommandKey.ReApplyNickname, + CommandKey.AutoKickSpammer, + CommandKey.AutoKickNewAccounts, + CommandKey.AutoKickNoRoles); + + return $"{"🌐".UnicodeToEmoji()} `{CommandKey.Autoban.Get(ctx.DbUser).PadRight(pad)}`: {ctx.DbGuild.Join.AutoBanGlobalBans.ToEmote(ctx.Bot)}\n" + + $"{"👋".UnicodeToEmoji()} `{CommandKey.JoinLogChannel.Get(ctx.DbUser).PadRight(pad)}`: {(ctx.DbGuild.Join.JoinlogChannelId != 0 ? $"<#{ctx.DbGuild.Join.JoinlogChannelId}>" : false.ToEmote(ctx.Bot))}\n" + + $"{"👤".UnicodeToEmoji()} `{CommandKey.Role.Get(ctx.DbUser).PadRight(pad)}`: {(ctx.DbGuild.Join.AutoAssignRoleId != 0 ? $"<@&{ctx.DbGuild.Join.AutoAssignRoleId}>" : false.ToEmote(ctx.Bot))}\n" + + $"{"👥".UnicodeToEmoji()} `{CommandKey.ReApplyRoles.Get(ctx.DbUser).PadRight(pad)}`: {ctx.DbGuild.Join.ReApplyRoles.ToEmote(ctx.Bot)}\n" + + $"{"💬".UnicodeToEmoji()} `{CommandKey.ReApplyNickname.Get(ctx.DbUser).PadRight(pad)}`: {ctx.DbGuild.Join.ReApplyNickname.ToEmote(ctx.Bot)}\n\n" + + $"{"⚠️".UnicodeToEmoji()} `{CommandKey.AutoKickSpammer.Get(ctx.DbUser).PadRight(pad)}`: {ctx.DbGuild.Join.AutoKickSpammer.ToEmote(ctx.Bot)}\n" + + $"{"🕒".UnicodeToEmoji()} `{CommandKey.AutoKickNewAccounts.Get(ctx.DbUser).PadRight(pad)}`: {(ctx.DbGuild.Join.AutoKickAccountAge == TimeSpan.Zero ? false.ToEmote(ctx.Bot) : $"`{ctx.DbGuild.Join.AutoKickAccountAge.GetHumanReadable(TimeFormat.Days, TranslationUtil.GetTranslatedHumanReadableConfig(ctx.DbUser, ctx.Bot))}`")}\n" + + $"{"📝".UnicodeToEmoji()} `{CommandKey.AutoKickNoRoles.Get(ctx.DbUser).PadRight(pad)}`: {(ctx.DbGuild.Join.AutoKickNoRoleTime == TimeSpan.Zero ? false.ToEmote(ctx.Bot) : $"`{ctx.DbGuild.Join.AutoKickNoRoleTime.GetHumanReadable(TimeFormat.Minutes, TranslationUtil.GetTranslatedHumanReadableConfig(ctx.DbUser, ctx.Bot))}`")}\n\n" + + $"{CommandKey.SecurityNotice.Get(ctx.DbUser).Build(true, new TVar("Permissions", string.Join(", ", Resources.ProtectedPermissions.Select(x => $"{x.ToTranslatedPermissionString(ctx.DbUser, ctx.Bot)}"))))}\n\n" + + $"{CommandKey.TimeNotice.Get(ctx.DbUser).Build()}"; + } + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + var embed = new DiscordEmbedBuilder() + { + Description = GetCurrentConfiguration(ctx) + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + var builder = new DiscordMessageBuilder().WithEmbed(embed); + + var ToggleGlobalban = new DiscordButtonComponent((ctx.DbGuild.Join.AutoBanGlobalBans ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.ToggleGlobalBansButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🌐"))); + var ChangeJoinlogChannel = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.ChangeJoinlogChannelButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👋"))); + var ChangeRoleOnJoin = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.ChangeRoleButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👤"))); + var ToggleReApplyRoles = new DiscordButtonComponent((ctx.DbGuild.Join.ReApplyRoles ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.ToggleReApplyRole), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👥"))); + var ToggleReApplyName = new DiscordButtonComponent((ctx.DbGuild.Join.ReApplyNickname ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.ToggleReApplyNickname), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("💬"))); + + var ToggleAutoKickSpammer = new DiscordButtonComponent((ctx.DbGuild.Join.AutoKickSpammer ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.ToggleAutoKickSpammer), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("⚠️"))); + var ChangeAutoKickNewAccounts = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.ChangeAutoKickNewAccounts), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🕒"))); + var ChangeAutoKickNoRoles = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.ChangeAutoKickNoRoles), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("📝"))); + + _ = await this.RespondOrEdit(builder + .AddComponents(new List + { + ToggleGlobalban, + ToggleReApplyRoles, + ToggleReApplyName, + }) + .AddComponents(new List + { + ChangeJoinlogChannel, + ChangeRoleOnJoin, + }) + .AddComponents(new List + { + ToggleAutoKickSpammer, + ChangeAutoKickNewAccounts, + ChangeAutoKickNoRoles, + }) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var e = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (e.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = e.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == ToggleGlobalban.CustomId) + { + ctx.DbGuild.Join.AutoBanGlobalBans = !ctx.DbGuild.Join.AutoBanGlobalBans; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == ToggleReApplyRoles.CustomId) + { + ctx.DbGuild.Join.ReApplyRoles = !ctx.DbGuild.Join.ReApplyRoles; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == ToggleReApplyName.CustomId) + { + ctx.DbGuild.Join.ReApplyNickname = !ctx.DbGuild.Join.ReApplyNickname; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == ChangeJoinlogChannel.CustomId) + { + var ChannelResult = await this.PromptChannelSelection(ChannelType.Text, new ChannelPromptConfiguration + { + CreateChannelOption = new() + { + Name = this.GetString(CommandKey.JoinLogChannelName), + ChannelType = ChannelType.Text + }, + DisableOption = this.GetString(CommandKey.DisableJoinlog) + }); + + if (ChannelResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ChannelResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ChannelResult.Failed) + { + if (ChannelResult.Exception.GetType() == typeof(NullReferenceException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(this.t.Commands.Common.Errors.NoChannels))); + await Task.Delay(3000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + throw ChannelResult.Exception; + } + + ctx.DbGuild.Join.JoinlogChannelId = ChannelResult.Result is null ? 0 : ChannelResult.Result.Id; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == ChangeRoleOnJoin.CustomId) + { + var RoleResult = await this.PromptRoleSelection(new RolePromptConfiguration { CreateRoleOption = this.GetString(CommandKey.AutoAssignRoleName), DisableOption = this.GetString(CommandKey.DisableRoleOnJoin) }); + + if (RoleResult.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + else if (RoleResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (RoleResult.Failed) + { + if (RoleResult.Exception.GetType() == typeof(NullReferenceException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(this.t.Commands.Common.Errors.NoRoles))); + await Task.Delay(3000); + return; + } + + throw RoleResult.Exception; + } + + if (RoleResult.Result?.Id == ctx.DbGuild.BumpReminder.RoleId) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(CommandKey.CantUseRole, true))); + await Task.Delay(3000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + ctx.DbGuild.Join.AutoAssignRoleId = RoleResult.Result is null ? 0 : RoleResult.Result.Id; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == ToggleAutoKickSpammer.CustomId) + { + ctx.DbGuild.Join.AutoKickSpammer = !ctx.DbGuild.Join.AutoKickSpammer; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == ChangeAutoKickNewAccounts.CustomId) + { + var TimeResult = await this.PromptForTimeSpan(TimeSpan.FromDays(30), TimeSpan.Zero, ctx.DbGuild.Join.AutoKickAccountAge); + + if (TimeResult.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + else if (TimeResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (TimeResult.Failed) + { + if (TimeResult.Exception.GetType() == typeof(InvalidOperationException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(CommandKey.AutoKickNewAccountsDurationLimit))); + await Task.Delay(3000); + return; + } + + throw TimeResult.Exception; + } + + ctx.DbGuild.Join.AutoKickAccountAge = TimeResult.Result; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == ChangeAutoKickNoRoles.CustomId) + { + var TimeResult = await this.PromptForTimeSpan(TimeSpan.FromMinutes(30), TimeSpan.Zero, ctx.DbGuild.Join.AutoKickNoRoleTime); + + if (TimeResult.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + else if (TimeResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (TimeResult.Failed) + { + if (TimeResult.Exception.GetType() == typeof(NullReferenceException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(CommandKey.AutoKickNoRolesDurationLimit))); + await Task.Delay(3000); + return; + } + + throw TimeResult.Exception; + } + + if (TimeResult.Result < TimeSpan.FromMinutes(1) && TimeResult.Result != TimeSpan.Zero) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsWarning(ctx).WithDescription($"{this.GetString(CommandKey.LowTimeWarning, true, new TVar("Time", TimeResult.Result.GetHumanReadable(TimeFormat.Minutes, TranslationUtil.GetTranslatedHumanReadableConfig(ctx.DbUser, ctx.Bot))))}\n\n" + + $"{this.GetString(this.t.Commands.Moderation.CustomEmbed.ContinueTimer, true, new TVar("Timestamp", DateTime.UtcNow.AddSeconds(6).ToTimestamp()))}")); + await Task.Delay(5000); + } + + ctx.DbGuild.Join.AutoKickNoRoleTime = TimeResult.Result; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/LevelRewardsCommand.cs b/ProjectMakoto/Commands/Configuration/LevelRewardsCommand.cs new file mode 100644 index 00000000..c7604f2c --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/LevelRewardsCommand.cs @@ -0,0 +1,460 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class LevelRewardsCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.LevelRewards; + +#pragma warning disable CS8321 // Local function is declared but never used + string GetCurrentConfiguration(SharedCommandContext ctx) + { + var CommandKey = ctx.Bot.LoadedTranslations.Commands.Config.LevelRewards; + + var str = ""; + if (ctx.DbGuild.LevelRewards.Length != 0) + { + foreach (var b in ctx.DbGuild.LevelRewards.OrderBy(x => x.Level)) + { + if (!ctx.Guild.Roles.ContainsKey(b.RoleId)) + { + ctx.DbGuild.LevelRewards = ctx.DbGuild.LevelRewards.Remove(x => x.RoleId.ToString(), b); + continue; + } + + str += $"**{ctx.BaseCommand.GetString(CommandKey.Level)}**: `{b.Level}`\n" + + $"**{ctx.BaseCommand.GetString(CommandKey.Role)}**: <@&{b.RoleId}> (`{b.RoleId}`)\n" + + $"**{ctx.BaseCommand.GetString(CommandKey.Message)}**: `{b.Message}`\n"; + + str += "\n\n"; + } + } + else + { + str = ctx.BaseCommand.GetString(CommandKey.NoRewardsSetup, true); + } + + return str; + } +#pragma warning restore CS8321 // Local function is declared but never used + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + var CurrentPage = 0; + + var embed = new DiscordEmbedBuilder() + { + Description = this.GetString(CommandKey.Loading, true) + }.AsLoading(ctx, this.GetString(CommandKey.Title)); + + _ = await this.RespondOrEdit(embed); + + embed = embed.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + var selected = ""; + + async Task RefreshMessage() + { + List DefinedRewards = new(); + + embed.Description = ""; + + foreach (var reward in ctx.DbGuild.LevelRewards.ToList().OrderBy(x => x.Level)) + { + if (!ctx.Guild.Roles.ContainsKey(reward.RoleId)) + { + ctx.DbGuild.LevelRewards = ctx.DbGuild.LevelRewards.Remove(x => x.RoleId.ToString(), reward); + continue; + } + + var role = ctx.Guild.GetRole(reward.RoleId); + + DefinedRewards.Add(new DiscordStringSelectComponentOption($"{this.GetString(CommandKey.Level)} {reward.Level}: @{role.Name}", role.Id.ToString(), $"{reward.Message.TruncateWithIndication(100)}", (selected == role.Id.ToString()), new DiscordComponentEmoji(role.Color.GetClosestColorEmoji(ctx.Client)))); + + if (selected == role.Id.ToString()) + { + embed.Description = $"**{this.GetString(CommandKey.Level)}**: `{reward.Level}`\n" + + $"**{this.GetString(CommandKey.Role)}**: <@&{reward.RoleId}> (`{reward.RoleId}`)\n" + + $"**{this.GetString(CommandKey.Message)}**: `{reward.Message}`\n"; + } + } + + if (DefinedRewards.Count > 0) + { + if (embed.Description == "") + embed.Description = this.GetString(CommandKey.SelectPrompt, true); + } + else + { + embed.Description = this.GetString(CommandKey.NoRewardsSetup, true); + } + + var PreviousPage = new DiscordButtonComponent(ButtonStyle.Primary, "PreviousPage", this.GetString(this.t.Common.PreviousPage), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("◀"))); + var NextPage = new DiscordButtonComponent(ButtonStyle.Primary, "NextPage", this.GetString(this.t.Common.NextPage), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("▶"))); + + var Add = new DiscordButtonComponent(ButtonStyle.Success, "Add", this.GetString(CommandKey.AddNewButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("➕"))); + var Modify = new DiscordButtonComponent(ButtonStyle.Primary, "Modify", this.GetString(CommandKey.ModifyButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🔄"))); + var Delete = new DiscordButtonComponent(ButtonStyle.Danger, "Delete", this.GetString(CommandKey.RemoveButton), false, new DiscordComponentEmoji(DiscordEmoji.FromGuildEmote(ctx.Client, 1005430134070841395))); + + var Dropdown = new DiscordStringSelectComponent(this.GetString(CommandKey.SelectDropdown), DefinedRewards.Skip(CurrentPage * 20).Take(20).ToList(), "RewardSelection"); + embed = embed.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + var builder = new DiscordMessageBuilder().WithEmbed(embed); + + if (DefinedRewards.Count > 0) + _ = builder.AddComponents(Dropdown); + + List Row1 = new(); + List Row2 = new(); + + if (DefinedRewards.Skip(CurrentPage * 20).Count() > 20) + Row1.Add(NextPage); + + if (CurrentPage != 0) + Row1.Add(PreviousPage); + + Row2.Add(Add); + + if (selected != "") + { + Row2.Add(Modify); + Row2.Add(Delete); + } + + if (Row1.Count > 0) + _ = builder.AddComponents(Row1); + + _ = builder.AddComponents(Row2); + + _ = builder.AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot)); + + _ = await this.RespondOrEdit(builder); + } + + CancellationTokenSource cancellationTokenSource = new(); + + async Task SelectInteraction(DiscordClient s, ComponentInteractionCreateEventArgs e) + { + _ = Task.Run(async () => + { + if (e.Message?.Id == ctx.ResponseMessage.Id && e.User.Id == ctx.User.Id) + { + cancellationTokenSource.Cancel(); + cancellationTokenSource = new(); + + _ = Task.Delay(120000, cancellationTokenSource.Token).ContinueWith(x => + { + if (x.IsCompletedSuccessfully) + { + ctx.Client.ComponentInteractionCreated -= SelectInteraction; + this.ModifyToTimedOut(true); + } + }); + + if (e.GetCustomId() == "RewardSelection") + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + selected = e.Values.First(); + await RefreshMessage(); + } + else if (e.GetCustomId() == "Add") + { + ctx.Client.ComponentInteractionCreated -= SelectInteraction; + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + DiscordRole selectedRole = null; + var selectedLevel = -1; + var selectedCustomText = this.GetGuildString(CommandKey.DefaultCustomText); + + while (true) + { + var SelectRole = new DiscordButtonComponent((selectedRole is null ? ButtonStyle.Primary : ButtonStyle.Secondary), Guid.NewGuid().ToString(), this.GetString(CommandKey.SelectRoleButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👤"))); + var SelectLevel = new DiscordButtonComponent((selectedLevel is -1 ? ButtonStyle.Primary : ButtonStyle.Secondary), Guid.NewGuid().ToString(), this.GetString(CommandKey.SelectLevelButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✨"))); + var SelectCustomText = new DiscordButtonComponent((selectedCustomText.IsNullOrWhiteSpace() ? ButtonStyle.Primary : ButtonStyle.Secondary), Guid.NewGuid().ToString(), this.GetString(CommandKey.ChangeMessageButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🗯"))); + var Finish = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(this.t.Common.Submit), (selectedRole is null || selectedLevel is -1 || selectedCustomText.IsNullOrWhiteSpace()), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✅"))); + + var pad = TranslationUtil.CalculatePadding(ctx.DbUser, CommandKey.Role, CommandKey.Level, CommandKey.Message); + + var action_embed = new DiscordEmbedBuilder + { + Description = $"`{this.GetString(CommandKey.Role).PadRight(pad)}`: {(selectedRole is null ? this.GetString(this.t.Common.NotSelected, true) : selectedRole.Mention)}\n" + + $"`{this.GetString(CommandKey.Level).PadRight(pad)}`: {(selectedLevel is -1 ? this.GetString(this.t.Common.NotSelected, true) : selectedLevel.ToEmotes())}\n" + + $"`{this.GetString(CommandKey.Message).PadRight(pad)}`: `{selectedCustomText}`" + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed) + .AddComponents(new List { SelectRole, SelectLevel, SelectCustomText, Finish }) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var Menu = await ctx.WaitForButtonAsync(); + + if (Menu.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + if (Menu.GetCustomId() == SelectRole.CustomId) + { + _ = Menu.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + var RoleResult = await this.PromptRoleSelection(); + + if (RoleResult.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + else if (RoleResult.Cancelled) + { + continue; + } + else if (RoleResult.Failed) + { + if (RoleResult.Exception.GetType() == typeof(NullReferenceException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(this.t.Commands.Common.Errors.NoRoles, true))); + await Task.Delay(3000); + return; + } + + throw RoleResult.Exception; + } + + if (RoleResult.Result.Id == ctx.DbGuild.BumpReminder.RoleId) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(CommandKey.CantUseRole, true))); + await Task.Delay(3000); + continue; + } + + selectedRole = RoleResult.Result; + continue; + } + else if (Menu.GetCustomId() == SelectLevel.CustomId) + { + var modal = new DiscordInteractionModalBuilder(this.GetString(CommandKey.Title), Guid.NewGuid().ToString()) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "level", this.GetString(CommandKey.Level), "2", 1, 3, true, (selectedLevel is -1 ? 2 : selectedLevel).ToString())); + + + var ModalResult = await this.PromptModalWithRetry(Menu.Result.Interaction, modal, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + continue; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + InteractionCreateEventArgs Response = ModalResult.Result; + var rawInt = Response.Interaction.GetModalValueByCustomId("level"); + + uint level; + + try + { + level = Convert.ToUInt32(rawInt); + + if (level < 2) + throw new Exception(""); + } + catch (Exception) + { + continue; + } + + selectedLevel = (int)level; + continue; + } + else if (Menu.GetCustomId() == SelectCustomText.CustomId) + { + var modal = new DiscordInteractionModalBuilder(this.GetString(CommandKey.Title), Guid.NewGuid().ToString()) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "message", this.GetString(CommandKey.Message), this.GetGuildString(CommandKey.DefaultCustomText), 1, 256, true, selectedCustomText)); + + + var ModalResult = await this.PromptModalWithRetry(Menu.Result.Interaction, modal, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + continue; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + InteractionCreateEventArgs Response = ModalResult.Result; + + var newMessage = Response.Interaction.GetModalValueByCustomId("message"); + + if (newMessage.Length > 256) + { + action_embed.Description = this.GetString(CommandKey.MessageTooLong, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(3000); + continue; + } + + selectedCustomText = newMessage; + continue; + } + else if (Menu.GetCustomId() == Finish.CustomId) + { + if (selectedRole.Id == ctx.DbGuild.BumpReminder.RoleId) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(CommandKey.CantUseRole, true))); + await Task.Delay(3000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + ctx.DbGuild.LevelRewards = ctx.DbGuild.LevelRewards.Add(new() + { + Level = selectedLevel, + RoleId = selectedRole.Id, + Message = selectedCustomText + }); + + action_embed.Description = this.GetString(CommandKey.AddedNewReward, true, new TVar("Role", $"<@&{selectedRole.Id}>"), new TVar("Level", selectedLevel)); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsSuccess(ctx, this.GetString(CommandKey.Title)))); + + await Task.Delay(5000); + await RefreshMessage(); + ctx.Client.ComponentInteractionCreated += SelectInteraction; + return; + } + else if (Menu.GetCustomId() == MessageComponents.CancelButtonId) + { + _ = Menu.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + await RefreshMessage(); + ctx.Client.ComponentInteractionCreated += SelectInteraction; + return; + } + + return; + } + } + else if (e.GetCustomId() == "Modify") + { + var modal = new DiscordInteractionModalBuilder() + .WithTitle(this.GetString(CommandKey.Title)) + .WithCustomId(Guid.NewGuid().ToString()) + .AddTextComponents(new DiscordTextComponent(TextComponentStyle.Small, "new_text", this.GetString(CommandKey.Message), null, 0, 256, false, ctx.DbGuild.LevelRewards.First(x => x.RoleId == Convert.ToUInt64(selected)).Message)); + ; + + var ModalResult = await this.PromptModalWithRetry(e.Interaction, modal, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + await RefreshMessage(); + return; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + InteractionCreateEventArgs Response = ModalResult.Result; + var result = Response.Interaction.GetModalValueByCustomId("new_text"); + + if (result.Length > 256) + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.WithDescription(this.GetString(CommandKey.MessageTooLong, true)).AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + ctx.DbGuild.LevelRewards.First(x => x.RoleId == Convert.ToUInt64(selected)).Message = result; + + await RefreshMessage(); + } + else if (e.GetCustomId() == "Delete") + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + ctx.DbGuild.LevelRewards = ctx.DbGuild.LevelRewards.Remove(x => x.RoleId.ToString(), ctx.DbGuild.LevelRewards.First(x => x.RoleId == Convert.ToUInt64(selected))); + + if (ctx.DbGuild.LevelRewards.Length == 0) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + + embed.Description = this.GetString(CommandKey.SelectPrompt, true); + selected = ""; + + await RefreshMessage(); + } + else if (e.GetCustomId() == "PreviousPage") + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + CurrentPage--; + await RefreshMessage(); + } + else if (e.GetCustomId() == "NextPage") + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + CurrentPage++; + await RefreshMessage(); + } + else if (e.GetCustomId() == MessageComponents.CancelButtonId) + { + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + this.DeleteOrInvalidate(); + return; + } + } + }).Add(ctx.Bot, ctx); + } + + await RefreshMessage(); + + _ = Task.Delay(120000, cancellationTokenSource.Token).ContinueWith(x => + { + if (x.IsCompletedSuccessfully) + { + ctx.Client.ComponentInteractionCreated -= SelectInteraction; + this.ModifyToTimedOut(true); + } + }); + + ctx.Client.ComponentInteractionCreated += SelectInteraction; + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/NameNormalizerCommand.cs b/ProjectMakoto/Commands/Configuration/NameNormalizerCommand.cs new file mode 100644 index 00000000..65c20670 --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/NameNormalizerCommand.cs @@ -0,0 +1,125 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class NameNormalizerCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.NameNormalizer; + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + string GetCurrentConfiguration(SharedCommandContext ctx) + { + return $"💬 `{this.GetString(CommandKey.NameNormalizerEnabled)}`: {ctx.DbGuild.NameNormalizer.NameNormalizerEnabled.ToEmote(ctx.Bot)}"; + } + + var embed = new DiscordEmbedBuilder + { + Description = GetCurrentConfiguration(ctx) + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + var Toggle = new DiscordButtonComponent((ctx.DbGuild.NameNormalizer.NameNormalizerEnabled ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.ToggleNameNormalizer), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("💬"))); + var SearchAllNames = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), this.GetString(CommandKey.NormalizeNow), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🔨"))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed) + .AddComponents(new List + { + Toggle, + SearchAllNames + }) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var e = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (e.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = e.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == Toggle.CustomId) + { + ctx.DbGuild.NameNormalizer.NameNormalizerEnabled = !ctx.DbGuild.NameNormalizer.NameNormalizerEnabled; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == SearchAllNames.CustomId) + { + if (ctx.DbGuild.NameNormalizer.NameNormalizerRunning) + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsError(ctx, this.GetString(CommandKey.Title)) + .WithDescription(this.GetString(CommandKey.NormalizerRunning, true)))); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx)) + return; + + ctx.DbGuild.NameNormalizer.NameNormalizerRunning = true; + + try + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsLoading(ctx, this.GetString(CommandKey.Title)) + .WithDescription(this.GetString(CommandKey.RenamingAllMembers, true)))); + + var members = await ctx.Guild.GetAllMembersAsync(); + var Renamed = 0; + + for (var i = 0; i < members.Count; i++) + { + var b = members.ElementAt(i); + + var PingableName = RegexTemplates.AllowedNickname.Replace(b.DisplayName.Normalize(NormalizationForm.FormKC), ""); + + if (PingableName.IsNullOrWhiteSpace()) + PingableName = this.GetGuildString(CommandKey.DefaultName); + + if (PingableName != b.DisplayName) + { + _ = b.ModifyAsync(x => x.Nickname = PingableName); + Renamed++; + await Task.Delay(2000); + } + } + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsSuccess(ctx, this.GetString(CommandKey.Title)) + .WithDescription(this.GetString(CommandKey.RenamedMembers, true, new TVar("Count", Renamed))))); + await Task.Delay(5000); + ctx.DbGuild.NameNormalizer.NameNormalizerRunning = false; + } + catch (Exception) + { + ctx.DbGuild.NameNormalizer.NameNormalizerRunning = false; + throw; + } + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + return; + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/PhishingCommand.cs b/ProjectMakoto/Commands/Configuration/PhishingCommand.cs new file mode 100644 index 00000000..a18ef2de --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/PhishingCommand.cs @@ -0,0 +1,234 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class PhishingCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.Phishing; + + string GetCurrentConfiguration(SharedCommandContext ctx) + { + var pad = TranslationUtil.CalculatePadding(ctx.DbUser, CommandKey.DetectPhishingLinks, CommandKey.RedirectWarning, CommandKey.AbuseIpDbReports, CommandKey.PunishmentType, + CommandKey.CustomPunishmentReason, CommandKey.CustomTimeoutLength); + + return $"💀 `{this.GetString(CommandKey.DetectPhishingLinks).PadRight(pad)}` : {ctx.DbGuild.PhishingDetection.DetectPhishing.ToEmote(ctx.Bot)}\n" + + $"⚠ `{this.GetString(CommandKey.RedirectWarning).PadRight(pad)}` : {ctx.DbGuild.PhishingDetection.WarnOnRedirect.ToEmote(ctx.Bot)}\n" + + $"{EmojiTemplates.GetAbuseIpDb(ctx.Bot)} `{this.GetString(CommandKey.AbuseIpDbReports).PadRight(pad)}` : {ctx.DbGuild.PhishingDetection.AbuseIpDbReports.ToEmote(ctx.Bot)}\n" + + $"🔨 `{this.GetString(CommandKey.PunishmentType).PadRight(pad)}` : `{GetTypeString(ctx.DbGuild.PhishingDetection.PunishmentType)}`\n" + + $"💬 `{this.GetString(CommandKey.CustomPunishmentReason).PadRight(pad)}` : `{ctx.DbGuild.PhishingDetection.CustomPunishmentReason}`\n" + + $"🕒 `{this.GetString(CommandKey.CustomTimeoutLength).PadRight(pad)}` : `{ctx.DbGuild.PhishingDetection.CustomPunishmentLength.GetHumanReadable(TimeFormat.Days, TranslationUtil.GetTranslatedHumanReadableConfig(ctx.DbUser, ctx.Bot))}`"; + } + + string GetTypeString(PhishingPunishmentType type) + { + return type switch + { + PhishingPunishmentType.Delete => this.GetString(CommandKey.PunishmentTypeDelete), + PhishingPunishmentType.Timeout => this.GetString(CommandKey.PunishmentTypeTimeout), + PhishingPunishmentType.Kick => this.GetString(CommandKey.PunishmentTypeKick), + PhishingPunishmentType.Ban => this.GetString(CommandKey.PunishmentTypeBan), + PhishingPunishmentType.SoftBan => this.GetString(CommandKey.PunishmentTypeSoftban), + _ => throw new NotImplementedException(), + }; + } + + string GetTypeDescriptionString(PhishingPunishmentType type) + { + return type switch + { + PhishingPunishmentType.Delete => this.GetString(CommandKey.PunishmentTypeDeleteDescription), + PhishingPunishmentType.Timeout => this.GetString(CommandKey.PunishmentTypeTimeoutDescription), + PhishingPunishmentType.Kick => this.GetString(CommandKey.PunishmentTypeKickDescription), + PhishingPunishmentType.Ban => this.GetString(CommandKey.PunishmentTypeBanDescription), + PhishingPunishmentType.SoftBan => this.GetString(CommandKey.PunishmentTypeSoftbanDescription), + _ => throw new NotImplementedException(), + }; + } + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + var embed = new DiscordEmbedBuilder() + { + Description = GetCurrentConfiguration(ctx) + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + var ToggleDetectionButton = new DiscordButtonComponent((ctx.DbGuild.PhishingDetection.DetectPhishing ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.ToggleDetection), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("💀"))); + var ToggleWarningButton = new DiscordButtonComponent((ctx.DbGuild.PhishingDetection.WarnOnRedirect ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.ToggleWarning), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("⚠"))); + var ToggleAbuseIpDbButton = new DiscordButtonComponent((ctx.DbGuild.PhishingDetection.AbuseIpDbReports ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.AbuseIpDbReports), false, new DiscordComponentEmoji(EmojiTemplates.GetAbuseIpDb(ctx.Bot))); + var ChangePunishmentButton = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.ChangePunishmentType), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🔨"))); + var ChangeReasonButton = new DiscordButtonComponent(ButtonStyle.Secondary, Guid.NewGuid().ToString(), this.GetString(CommandKey.ChangePunishmentReason), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("💬"))); + var ChangeTimeoutLengthButton = new DiscordButtonComponent(ButtonStyle.Secondary, Guid.NewGuid().ToString(), this.GetString(CommandKey.ChangeTimeoutLength), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🕒"))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed) + .AddComponents(new List + { + { ToggleDetectionButton }, + { ToggleWarningButton }, + { ToggleAbuseIpDbButton }, + }) + .AddComponents(new List + { + { ChangePunishmentButton }, + { ChangeReasonButton }, + { ChangeTimeoutLengthButton } + }).AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var Button = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (Button.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + if (Button.GetCustomId() == ToggleDetectionButton.CustomId) + { + _ = Button.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + ctx.DbGuild.PhishingDetection.DetectPhishing = !ctx.DbGuild.PhishingDetection.DetectPhishing; + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == ToggleWarningButton.CustomId) + { + _ = Button.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + ctx.DbGuild.PhishingDetection.WarnOnRedirect = !ctx.DbGuild.PhishingDetection.WarnOnRedirect; + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == ToggleAbuseIpDbButton.CustomId) + { + _ = Button.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + ctx.DbGuild.PhishingDetection.AbuseIpDbReports = !ctx.DbGuild.PhishingDetection.AbuseIpDbReports; + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == ChangePunishmentButton.CustomId) + { + _ = Button.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + var e = await this.PromptCustomSelection(Enum.GetNames(typeof(PhishingPunishmentType)).Select(x => + { + var type = Enum.Parse(x); + return new DiscordStringSelectComponentOption(GetTypeString(type), x, GetTypeDescriptionString(type)); + })); + + if (e.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + switch (e.Result) + { + case "Ban": + ctx.DbGuild.PhishingDetection.PunishmentType = PhishingPunishmentType.Ban; + break; + case "SoftBan": + ctx.DbGuild.PhishingDetection.PunishmentType = PhishingPunishmentType.SoftBan; + break; + case "Kick": + ctx.DbGuild.PhishingDetection.PunishmentType = PhishingPunishmentType.Kick; + break; + case "Timeout": + ctx.DbGuild.PhishingDetection.PunishmentType = PhishingPunishmentType.Timeout; + break; + case "Delete": + ctx.DbGuild.PhishingDetection.PunishmentType = PhishingPunishmentType.Delete; + break; + } + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == ChangeReasonButton.CustomId) + { + var modal = new DiscordInteractionModalBuilder(this.GetString(CommandKey.Title), Guid.NewGuid().ToString()) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "new_reason", this.GetString(CommandKey.DefineNewReason), "", null, null, true, ctx.DbGuild.PhishingDetection.CustomPunishmentReason)); + + var ModalResult = await this.PromptModalWithRetry(Button.Result.Interaction, modal, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + ctx.DbGuild.PhishingDetection.CustomPunishmentReason = ModalResult.Result.Interaction.GetModalValueByCustomId("new_reason"); + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == ChangeTimeoutLengthButton.CustomId) + { + if (ctx.DbGuild.PhishingDetection.PunishmentType != PhishingPunishmentType.Timeout) + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.WithDescription(this.GetString(CommandKey.NotUsingType, true, new TVar("Type", this.GetString(CommandKey.PunishmentTypeTimeout)))))); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + _ = Button.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + var ModalResult = await this.PromptForTimeSpan(TimeSpan.FromDays(28), TimeSpan.FromSeconds(10), ctx.DbGuild.PhishingDetection.CustomPunishmentLength, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ModalResult.Errored) + { + if (ModalResult.Exception.GetType() == typeof(InvalidOperationException)) + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.WithDescription(this.GetString(CommandKey.InvalidDuration, true)).AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + throw ModalResult.Exception; + } + + ctx.DbGuild.PhishingDetection.CustomPunishmentLength = ModalResult.Result; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/PrefixCommand.cs b/ProjectMakoto/Commands/Configuration/PrefixCommand.cs new file mode 100644 index 00000000..d41c17f8 --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/PrefixCommand.cs @@ -0,0 +1,103 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class PrefixCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + string GetCurrentConfiguration(SharedCommandContext ctx) + { + var pad = TranslationUtil.CalculatePadding(ctx.DbUser, this.t.Commands.Config.PrefixConfigCommand.CurrentPrefix, this.t.Commands.Config.PrefixConfigCommand.PrefixDisabled); + + return $"⌨ `{this.GetString(this.t.Commands.Config.PrefixConfigCommand.PrefixDisabled).PadRight(pad)}` : {ctx.DbGuild.PrefixSettings.PrefixDisabled.ToEmote(ctx.Bot)}\n" + + $"🗝 `{this.GetString(this.t.Commands.Config.PrefixConfigCommand.CurrentPrefix).PadRight(pad)}` : `{ctx.DbGuild.PrefixSettings.Prefix}`"; + } + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + var embed = new DiscordEmbedBuilder() + { + Description = GetCurrentConfiguration(ctx) + }.AsAwaitingInput(ctx, this.GetString(this.t.Commands.Config.PrefixConfigCommand.Title)); + + var TogglePrefixCommands = new DiscordButtonComponent((ctx.DbGuild.PrefixSettings.PrefixDisabled ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Config.PrefixConfigCommand.TogglePrefixCommands), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("⌨"))); + var ChangePrefix = new DiscordButtonComponent(ButtonStyle.Secondary, Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Config.PrefixConfigCommand.ChangePrefix), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🗝"))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed) + .AddComponents(new List + { + { TogglePrefixCommands }, + { ChangePrefix }, + }).AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var Button = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (Button.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + if (Button.GetCustomId() == TogglePrefixCommands.CustomId) + { + _ = Button.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + ctx.DbGuild.PrefixSettings.PrefixDisabled = !ctx.DbGuild.PrefixSettings.PrefixDisabled; + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == ChangePrefix.CustomId) + { + var modal = new DiscordInteractionModalBuilder(this.GetString(this.t.Commands.Config.PrefixConfigCommand.NewPrefixModalTitle), Guid.NewGuid().ToString()); + + _ = modal.AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "newPrefix", this.GetString(this.t.Commands.Config.PrefixConfigCommand.NewPrefix), ctx.Bot.Prefix, 1, 4, true, ctx.DbGuild.PrefixSettings.Prefix)); + + var ModalResult = await this.PromptModalWithRetry(Button.Result.Interaction, modal, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + var newPrefix = ModalResult.Result.Interaction.GetModalValueByCustomId("newPrefix"); + + if (newPrefix.Length is > 4 or < 1) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + + ctx.DbGuild.PrefixSettings.Prefix = newPrefix; + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/ReactionRolesCommand/AddCommand.cs b/ProjectMakoto/Commands/Configuration/ReactionRolesCommand/AddCommand.cs new file mode 100644 index 00000000..6703cebe --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/ReactionRolesCommand/AddCommand.cs @@ -0,0 +1,204 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.ReactionRolesCommand; + +internal sealed class AddCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.ReactionRoles; + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + DiscordMessage message; + + var embed = new DiscordEmbedBuilder + { + Description = this.GetString(CommandKey.AddingReactionRole, true) + }.AsLoading(ctx, this.GetString(CommandKey.Title)); + + _ = await this.RespondOrEdit(embed); + + DiscordRole role_parameter; + DiscordEmoji emoji_parameter; + + if (arguments?.ContainsKey("message") ?? false) + { + message = (DiscordMessage)arguments["message"]; + } + else + { + switch (ctx.CommandType) + { + case Enums.CommandType.PrefixCommand: + { + if (ctx.OriginalCommandContext.Message.ReferencedMessage is not null) + { + message = ctx.OriginalCommandContext.Message.ReferencedMessage; + } + else + { + this.SendSyntaxError(); + return; + } + + break; + } + default: + throw new ArgumentException("Message expected"); + } + } + + if (message is null) + { + this.SendSyntaxError(); + return; + } + + if (arguments?.ContainsKey("role_parameter") ?? false) + { + role_parameter = (DiscordRole)arguments["role_parameter"]; + } + else + { + switch (ctx.CommandType) + { + case Enums.CommandType.ContextMenu: + { + embed.Description = this.GetString(CommandKey.SelectRolePrompt, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)))); + var RoleResult = await this.PromptRoleSelection(); + + if (RoleResult.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + else if (RoleResult.Cancelled) + { + this.DeleteOrInvalidate(); + return; + } + else if (RoleResult.Failed) + { + if (RoleResult.Exception.GetType() == typeof(NullReferenceException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(CommandKey.NoRoles, true))); + await Task.Delay(3000); + return; + } + + throw RoleResult.Exception; + } + + role_parameter = RoleResult.Result; + break; + } + default: + throw new ArgumentException("Interaction expected"); + } + } + + if (arguments?.ContainsKey("emoji_parameter") ?? false) + { + emoji_parameter = (DiscordEmoji)arguments["emoji_parameter"]; + } + else + { + switch (ctx.CommandType) + { + case Enums.CommandType.ContextMenu: + { + embed.Description = this.GetString(CommandKey.ReactWithEmoji, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)))); + + var emoji_wait = await ctx.Client.GetInteractivity().WaitForReactionAsync(x => x.Channel.Id == ctx.Channel.Id && x.User.Id == ctx.User.Id && x.Message.Id == message.Id, TimeSpan.FromMinutes(2)); + + if (emoji_wait.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + try + { _ = emoji_wait.Result.Message.DeleteReactionAsync(emoji_wait.Result.Emoji, ctx.User); } + catch { } + + emoji_parameter = emoji_wait.Result.Emoji; + + if (emoji_parameter.Id != 0 && !ctx.Guild.Emojis.ContainsKey(emoji_parameter.Id)) + { + embed.Description = this.GetString(CommandKey.NoAccessToEmoji); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsError(ctx, this.GetString(CommandKey.Title)))); + return; + } + + break; + } + default: + throw new ArgumentException("Interaction expected"); + } + } + + embed.Author.IconUrl = ctx.Guild.IconUrl; + + if (ctx.DbGuild.ReactionRoles.Length > 100) + { + embed.Description = this.GetString(CommandKey.ReactionRoleLimitReached, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsError(ctx, this.GetString(CommandKey.Title)))); + return; + } + + if (emoji_parameter.Id != 0 && !ctx.Guild.Emojis.ContainsKey(emoji_parameter.Id)) + { + embed.Description = this.GetString(CommandKey.NoAccessToEmoji, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsError(ctx, this.GetString(CommandKey.Title)))); + return; + } + + if (ctx.DbGuild.ReactionRoles.Any(x => (x.MessageId == message.Id && x.EmojiName == emoji_parameter.GetUniqueDiscordName()))) + { + embed.Description = this.GetString(CommandKey.EmojiAlreadyUsed, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsError(ctx, this.GetString(CommandKey.Title)))); + return; + } + + if (ctx.DbGuild.ReactionRoles.Any(x => x.RoleId == role_parameter.Id)) + { + embed.Description = this.GetString(CommandKey.RoleAlreadyUsed, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsError(ctx, this.GetString(CommandKey.Title)))); + return; + } + + await message.CreateReactionAsync(emoji_parameter); + + ctx.DbGuild.ReactionRoles = ctx.DbGuild.ReactionRoles.Add(new() + { + ChannelId = message.Channel.Id, + RoleId = role_parameter.Id, + EmojiId = emoji_parameter.Id, + EmojiName = emoji_parameter.GetUniqueDiscordName(), + MessageId = message.Id + }); + + embed.Description = this.GetString(CommandKey.AddedReactionRole, true, + new TVar("Role", role_parameter.Mention), + new TVar("User", message.Author.Mention), + new TVar("Channel", message.Channel.Mention), + new TVar("Emoji", emoji_parameter)); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsSuccess(ctx, this.GetString(CommandKey.Title)))); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/ReactionRolesCommand/ConfigCommand.cs b/ProjectMakoto/Commands/Configuration/ReactionRolesCommand/ConfigCommand.cs new file mode 100644 index 00000000..96feb20f --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/ReactionRolesCommand/ConfigCommand.cs @@ -0,0 +1,372 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.ReactionRolesCommand; + +internal sealed class ConfigCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.ReactionRoles; + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(CommandKey.LoadingReactionRoles, true) + }.AsLoading(ctx, this.GetString(CommandKey.Title))); + + _ = await ReactionRolesCommandAbstractions.CheckForInvalid(ctx); + + var AddButton = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.AddNewReactionRole), (ctx.DbGuild.ReactionRoles.Length > 100), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("➕"))); + var RemoveButton = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), this.GetString(CommandKey.RemoveReactionRole), (ctx.DbGuild.ReactionRoles.Length == 0), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✖"))); + + var embed = new DiscordEmbedBuilder + { + Description = this.GetString(CommandKey.ReactionRoleCount, true, new TVar("Count", ctx.DbGuild.ReactionRoles.Length)) + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed) + .AddComponents(new List + { + AddButton, RemoveButton + }) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var e = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (e.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = e.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == AddButton.CustomId) + { + DiscordMessage selectedMessage = null; + DiscordEmoji selectedEmoji = null; + DiscordRole selectedRole = null; + + while (true) + { + var SelectMessage = new DiscordButtonComponent((selectedMessage is null ? ButtonStyle.Primary : ButtonStyle.Secondary), Guid.NewGuid().ToString(), this.GetString(CommandKey.SelectMessage), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("💬"))); + var SelectEmoji = new DiscordButtonComponent((selectedEmoji is null ? ButtonStyle.Primary : ButtonStyle.Secondary), Guid.NewGuid().ToString(), this.GetString(CommandKey.SelectEmoji), (selectedMessage is null), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("😀"))); + var SelectRole = new DiscordButtonComponent((selectedRole is null ? ButtonStyle.Primary : ButtonStyle.Secondary), Guid.NewGuid().ToString(), this.GetString(CommandKey.SelectRole), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👤"))); + var Finish = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(this.t.Common.Submit), (selectedMessage is null || selectedRole is null || selectedEmoji is null), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✅"))); + + var pad = TranslationUtil.CalculatePadding(ctx.DbUser, CommandKey.Message, CommandKey.Emoji, CommandKey.Role); + + var action_embed = new DiscordEmbedBuilder + { + Description = $"`{this.GetString(CommandKey.Message).PadRight(pad)}`: {(selectedMessage is null ? this.GetString(this.t.Common.NotSelected, true) : $"[`{this.GetString(this.t.Common.JumpToMessage)}`]({selectedMessage.JumpLink})")}\n" + + $"`{this.GetString(CommandKey.Emoji).PadRight(pad)}`: {(selectedEmoji is null ? this.GetString(this.t.Common.NotSelected, true) : selectedEmoji.ToString())}\n" + + $"`{this.GetString(CommandKey.Role).PadRight(pad)}`: {(selectedRole is null ? this.GetString(this.t.Common.NotSelected, true) : selectedRole.Mention)}" + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + + if (ctx.DbGuild.ReactionRoles.Length > 100) + { + action_embed.Description = this.GetString(CommandKey.ReactionRoleLimitReached, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed) + .AddComponents(new List { SelectMessage, SelectEmoji, SelectRole, Finish }) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var Menu = await ctx.WaitForButtonAsync(); + + if (Menu.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + if (Menu.GetCustomId() == SelectMessage.CustomId) + { + var modal = new DiscordInteractionModalBuilder(this.GetString(CommandKey.Title), Guid.NewGuid().ToString()) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "url", this.GetString(CommandKey.MessageUrl), "https://discord.com/channels/012345678901234567/012345678901234567/012345678912345678", null, null, true)); + + var ModalResult = await this.PromptModalWithRetry(Menu.Result.Interaction, modal, new DiscordEmbedBuilder + { + Description = this.GetString(CommandKey.MessageUrlInstructions, true), + ImageUrl = "https://cdn.discordapp.com/attachments/906976602557145110/967753175241203712/unknown.png" + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)), false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + continue; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + var url = ModalResult.Result.Interaction.GetModalValueByCustomId("url"); + + if (!RegexTemplates.DiscordChannelUrl.IsMatch(url) || !url.TryParseMessageLink(out var GuildId, out var ChannelId, out var MessageId)) + { + action_embed.Description = $"{this.GetString(CommandKey.InvalidMessageUrl, true)}\n" + + $"`https://discord.com/channels/012345678901234567/012345678901234567/012345678912345678`\n" + + $"`https://ptb.discord.com/channels/012345678901234567/012345678901234567/012345678912345678`\n" + + $"`https://canary.discord.com/channels/012345678901234567/012345678901234567/012345678912345678`"; + action_embed.ImageUrl = ""; + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(3000); + continue; + } + + if (GuildId != ctx.Guild.Id) + { + action_embed.Description = this.GetString(CommandKey.MessageUrlWrongGuild, true); + action_embed.ImageUrl = ""; + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(3000); + continue; + } + + if (!ctx.Guild.Channels.ContainsKey(ChannelId)) + { + action_embed.Description = this.GetString(CommandKey.MessageUrlNoChannel, true); + action_embed.ImageUrl = ""; + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(3000); + continue; + } + + var channel = ctx.Guild.GetChannel(ChannelId); + + if (!channel.TryGetMessage(MessageId, out var reactionMessage)) + { + action_embed.Description = this.GetString(CommandKey.MessageUrlNoMessage, true); + action_embed.ImageUrl = ""; + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(3000); + continue; + } + + selectedMessage = reactionMessage; + continue; + } + else if (Menu.GetCustomId() == SelectEmoji.CustomId) + { + _ = Menu.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + action_embed.Description = this.GetString(CommandKey.ReactWithEmoji, true); + action_embed.ImageUrl = ""; + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)))); + + var emoji_wait = await ctx.Client.GetInteractivity().WaitForReactionAsync(x => x.Channel.Id == ctx.Channel.Id && x.User.Id == ctx.User.Id && x.Message.Id == selectedMessage.Id, TimeSpan.FromMinutes(2)); + + if (emoji_wait.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + try + { _ = emoji_wait.Result.Message.DeleteReactionAsync(emoji_wait.Result.Emoji, ctx.User); } + catch { } + + var emoji = emoji_wait.Result.Emoji; + + if (emoji.Id != 0 && !ctx.Guild.Emojis.ContainsKey(emoji.Id)) + { + action_embed.Description = this.GetString(CommandKey.NoAccessToEmoji, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(3000); + continue; + } + + if (ctx.DbGuild.ReactionRoles.Any(x => (x.MessageId == selectedMessage.Id && x.EmojiName == emoji.GetUniqueDiscordName()))) + { + action_embed.Description = this.GetString(CommandKey.EmojiAlreadyUsed, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(3000); + continue; + } + + selectedEmoji = emoji; + continue; + } + else if (Menu.GetCustomId() == SelectRole.CustomId) + { + _ = Menu.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + _ = await this.RespondOrEdit(action_embed.WithDescription(this.GetString(CommandKey.SelectRolePrompt, true)).AsAwaitingInput(ctx, this.GetString(CommandKey.Title))); + + var RoleResult = await this.PromptRoleSelection(); + + if (RoleResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (RoleResult.Cancelled) + { + continue; + } + else if (RoleResult.Failed) + { + if (RoleResult.Exception.GetType() == typeof(NullReferenceException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(CommandKey.NoRoles, true))); + await Task.Delay(3000); + continue; + } + + throw RoleResult.Exception; + } + + if (ctx.DbGuild.ReactionRoles.Any(x => x.RoleId == RoleResult.Result.Id)) + { + action_embed.Description = this.GetString(CommandKey.RoleAlreadyUsed, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(3000); + continue; + } + + selectedRole = RoleResult.Result; + continue; + } + else if (Menu.GetCustomId() == Finish.CustomId) + { + _ = Menu.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (ctx.DbGuild.ReactionRoles.Length > 100) + { + action_embed.Description = this.GetString(CommandKey.ReactionRoleLimitReached, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + if (ctx.DbGuild.ReactionRoles.Any(x => x.RoleId == selectedRole.Id)) + { + action_embed.Description = this.GetString(CommandKey.RoleAlreadyUsed, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + if (selectedEmoji.Id != 0 && !ctx.Guild.Emojis.ContainsKey(selectedEmoji.Id)) + { + action_embed.Description = this.GetString(CommandKey.NoAccessToEmoji, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + if (ctx.DbGuild.ReactionRoles.Any(x => (x.MessageId == selectedMessage.Id && x.EmojiName == selectedEmoji.GetUniqueDiscordName()))) + { + action_embed.Description = this.GetString(CommandKey.EmojiAlreadyUsed, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsError(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + ctx.DbGuild.ReactionRoles = ctx.DbGuild.ReactionRoles.Add(new() + { + ChannelId = selectedMessage.Channel.Id, + RoleId = selectedRole.Id, + EmojiId = selectedEmoji.Id, + EmojiName = selectedEmoji.GetUniqueDiscordName(), + MessageId = selectedMessage.Id + }); + + await selectedMessage.CreateReactionAsync(selectedEmoji); + + embed.Description = this.GetString(CommandKey.AddedReactionRole, true, + new TVar("Role", selectedRole.Mention), + new TVar("User", selectedMessage.Author.Mention), + new TVar("Channel", selectedMessage.Channel.Mention), + new TVar("Emoji", selectedEmoji)); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed.AsSuccess(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Menu.GetCustomId() == MessageComponents.CancelButtonId) + { + _ = Menu.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + await this.ExecuteCommand(ctx, arguments); + return; + } + + return; + } + } + else if (e.GetCustomId() == RemoveButton.CustomId) + { + var RoleResult = await this.PromptCustomSelection(ctx.DbGuild.ReactionRoles + .Select(x => new DiscordStringSelectComponentOption($"@{ctx.Guild.GetRole(x.RoleId).Name}", x.UUID, $"in Channel #{ctx.Guild.GetChannel(x.ChannelId).Name}", emoji: new DiscordComponentEmoji(x.GetEmoji(ctx.Client)))).ToList()); + + if (RoleResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (RoleResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (RoleResult.Errored) + { + throw RoleResult.Exception; + } + + var obj = ctx.DbGuild.ReactionRoles.First(x => x.UUID == RoleResult.Result); + + if (ctx.Guild.GetChannel(obj.ChannelId).TryGetMessage(obj.MessageId, out var reactionMessage)) + _ = reactionMessage.DeleteReactionsEmojiAsync(obj.GetEmoji(ctx.Client)); + + var role = ctx.Guild.GetRole(obj.RoleId); + + ctx.DbGuild.ReactionRoles = ctx.DbGuild.ReactionRoles.Remove(x => x.MessageId.ToString(), obj); + + + embed.Description = this.GetString(CommandKey.RemovedReactionRole, true, + new TVar("Role", role.Mention), + new TVar("User", reactionMessage?.Author.Mention ?? "`/`"), + new TVar("Channel", reactionMessage?.Channel.Mention ?? "`/`"), + new TVar("Emoji", obj.GetEmoji(ctx.Client))); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsSuccess(ctx, this.GetString(CommandKey.Title)))); + await Task.Delay(5000); + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + return; + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/ReactionRolesCommand/ReactionRolesCommandAbstractions.cs b/ProjectMakoto/Commands/Configuration/ReactionRolesCommand/ReactionRolesCommandAbstractions.cs new file mode 100644 index 00000000..45bf4b49 --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/ReactionRolesCommand/ReactionRolesCommandAbstractions.cs @@ -0,0 +1,83 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.ReactionRolesCommand; + +internal static class ReactionRolesCommandAbstractions +{ + internal static async Task> CheckForInvalid(SharedCommandContext ctx) + { + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx)) + return new(); + + Dictionary messageCache = new(); + + foreach (var b in ctx.DbGuild.ReactionRoles.ToList()) + { + if (!ctx.Guild.Channels.ContainsKey(b.ChannelId)) + { + ctx.DbGuild.ReactionRoles = ctx.DbGuild.ReactionRoles.Remove(x => x.MessageId.ToString(), b); + continue; + } + + if (!ctx.Guild.Roles.ContainsKey(b.RoleId)) + { + ctx.DbGuild.ReactionRoles = ctx.DbGuild.ReactionRoles.Remove(x => x.MessageId.ToString(), b); + continue; + } + + var channel = ctx.Guild.GetChannel(b.ChannelId); + + if (!messageCache.ContainsKey(b.MessageId)) + { + try + { + var requested_msg = await channel.GetMessageAsync(b.MessageId); + messageCache.Add(b.MessageId, requested_msg); + } + catch (DisCatSharp.Exceptions.NotFoundException) + { + messageCache.Add(b.MessageId, null); + + ctx.DbGuild.ReactionRoles = ctx.DbGuild.ReactionRoles.Remove(x => x.MessageId.ToString(), b); + continue; + } + catch (DisCatSharp.Exceptions.UnauthorizedException) + { + messageCache.Add(b.MessageId, null); + + ctx.DbGuild.ReactionRoles = ctx.DbGuild.ReactionRoles.Remove(x => x.MessageId.ToString(), b); + continue; + } + } + + if (messageCache[b.MessageId] == null) + { + ctx.DbGuild.ReactionRoles = ctx.DbGuild.ReactionRoles.Remove(x => x.MessageId.ToString(), b); + continue; + } + + var msg = messageCache[b.MessageId ]; + + if (!msg.Reactions.Any(x => x.Emoji.Id == b.EmojiId && x.Emoji.GetUniqueDiscordName() == b.EmojiName && x.IsMe)) + { + _ = msg.CreateReactionAsync(b.GetEmoji(ctx.Client)).ContinueWith(x => + { + if (x.IsFaulted) + { + ctx.DbGuild.ReactionRoles = ctx.DbGuild.ReactionRoles.Remove(x => x.MessageId.ToString(), b); + } + }); + continue; + } + } + + return messageCache; + } +} diff --git a/ProjectMakoto/Commands/Configuration/ReactionRolesCommand/RemoveAllCommand.cs b/ProjectMakoto/Commands/Configuration/ReactionRolesCommand/RemoveAllCommand.cs new file mode 100644 index 00000000..ef09781a --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/ReactionRolesCommand/RemoveAllCommand.cs @@ -0,0 +1,86 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.ReactionRolesCommand; + +internal sealed class RemoveAllCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.ReactionRoles; + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + DiscordMessage message; + + if (arguments?.ContainsKey("message") ?? false) + { + message = (DiscordMessage)arguments["message"]; + } + else + { + switch (ctx.CommandType) + { + case Enums.CommandType.PrefixCommand: + { + if (ctx.OriginalCommandContext.Message.ReferencedMessage is not null) + { + message = ctx.OriginalCommandContext.Message.ReferencedMessage; + } + else + { + this.SendSyntaxError(); + return; + } + + break; + } + default: + throw new ArgumentException("Message expected"); + } + } + + if (message is null) + { + this.SendSyntaxError(); + return; + } + + var embed = new DiscordEmbedBuilder + { + Description = this.GetString(CommandKey.RemovingAllReactionRoles) + }.AsLoading(ctx, this.GetString(CommandKey.Title)); + + _ = await this.RespondOrEdit(embed); + embed.Author.IconUrl = ctx.Guild.IconUrl; + + if (!ctx.DbGuild.ReactionRoles.Any(x => x.MessageId == message.Id)) + { + embed.Description = this.GetString(CommandKey.NoReactionRoles, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsError(ctx, this.GetString(CommandKey.Title)))); + return; + } + + foreach (var b in ctx.DbGuild.ReactionRoles.Where(x => x.MessageId == message.Id).ToList()) + ctx.DbGuild.ReactionRoles = ctx.DbGuild.ReactionRoles.Remove(x => x.MessageId.ToString(), b); + + _ = message.DeleteAllReactionsAsync(); + + embed.Description = this.GetString(CommandKey.RemovedAllReactionRoles, true, + new TVar("User", message?.Author.Mention ?? "`/`"), + new TVar("Channel", message?.Channel.Mention ?? "`/`")); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsSuccess(ctx, this.GetString(CommandKey.Title)))); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/ReactionRolesCommand/RemoveCommand.cs b/ProjectMakoto/Commands/Configuration/ReactionRolesCommand/RemoveCommand.cs new file mode 100644 index 00000000..5a2fa9a6 --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/ReactionRolesCommand/RemoveCommand.cs @@ -0,0 +1,122 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.ReactionRolesCommand; + +internal sealed class RemoveCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.ReactionRoles; + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + DiscordMessage message; + + var embed = new DiscordEmbedBuilder + { + Description = this.GetString(CommandKey.RemovingReactionRole, true) + }.AsLoading(ctx, this.GetString(CommandKey.Title)); + + _ = await this.RespondOrEdit(embed); + + DiscordEmoji emoji_parameter; + + if (arguments?.ContainsKey("message") ?? false) + { + message = (DiscordMessage)arguments["message"]; + } + else + { + switch (ctx.CommandType) + { + case Enums.CommandType.PrefixCommand: + { + if (ctx.OriginalCommandContext.Message.ReferencedMessage is not null) + { + message = ctx.OriginalCommandContext.Message.ReferencedMessage; + } + else + { + this.SendSyntaxError(); + return; + } + + break; + } + default: + throw new ArgumentException("Message expected"); + } + } + + if (message is null) + { + this.SendSyntaxError(); + return; + } + + if (arguments?.ContainsKey("emoji_parameter") ?? false) + { + emoji_parameter = (DiscordEmoji)arguments["emoji_parameter"]; + } + else + { + switch (ctx.CommandType) + { + case Enums.CommandType.ContextMenu: + { + embed.Description = this.GetString(CommandKey.ReactWithEmojiToRemove, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)))); + + var emoji_wait = await ctx.Client.GetInteractivity().WaitForReactionAsync(x => x.Channel.Id == ctx.Channel.Id && x.User.Id == ctx.User.Id && x.Message.Id == message.Id, TimeSpan.FromMinutes(2)); + + if (emoji_wait.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + emoji_parameter = emoji_wait.Result.Emoji; + break; + } + default: + throw new ArgumentException("Interaction expected"); + } + } + + if (!ctx.DbGuild.ReactionRoles.Any(x => x.MessageId == message.Id && x.EmojiName == emoji_parameter.GetUniqueDiscordName())) + { + embed.Description = this.GetString(CommandKey.NoReactionRoleFound); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsError(ctx, this.GetString(CommandKey.Title)))); + return; + } + + var obj = ctx.DbGuild.ReactionRoles.First(x => x.MessageId == message.Id && x.EmojiName == emoji_parameter.GetUniqueDiscordName()); + + var role = ctx.Guild.GetRole(obj.RoleId); + var channel = ctx.Guild.GetChannel(obj.ChannelId); + var reactionMessage = await channel.GetMessageAsync(obj.MessageId); + _ = reactionMessage.DeleteReactionsEmojiAsync(obj.GetEmoji(ctx.Client)); + + ctx.DbGuild.ReactionRoles = ctx.DbGuild.ReactionRoles.Remove(x => x.MessageId.ToString(), obj); + + embed.Description = this.GetString(CommandKey.RemovedReactionRole, true, + new TVar("Role", role.Mention), + new TVar("User", reactionMessage?.Author.Mention ?? "`/`"), + new TVar("Channel", reactionMessage?.Channel.Mention ?? "`/`"), + new TVar("Emoji", obj.GetEmoji(ctx.Client))); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsSuccess(ctx, this.GetString(CommandKey.Title)))); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/TokenDetectionCommand.cs b/ProjectMakoto/Commands/Configuration/TokenDetectionCommand.cs new file mode 100644 index 00000000..5ac6f0af --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/TokenDetectionCommand.cs @@ -0,0 +1,68 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class TokenDetectionCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckAdmin(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.TokenDetection; + + string GetCurrentConfiguration(SharedCommandContext ctx) + { + return $"⚠ `{this.GetString(CommandKey.DetectTokens)}`: {ctx.DbGuild.TokenLeakDetection.DetectTokens.ToEmote(ctx.Bot)}"; + } + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + var embed = new DiscordEmbedBuilder + { + Description = GetCurrentConfiguration(ctx) + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + var Toggle = new DiscordButtonComponent((ctx.DbGuild.TokenLeakDetection.DetectTokens ? ButtonStyle.Danger : ButtonStyle.Success), Guid.NewGuid().ToString(), this.GetString(CommandKey.ToggleTokenDetection), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("⚠"))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed) + .AddComponents(new List + { + Toggle + }) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var e = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (e.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = e.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == Toggle.CustomId) + { + ctx.DbGuild.TokenLeakDetection.DetectTokens = !ctx.DbGuild.TokenLeakDetection.DetectTokens; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + return; + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Configuration/VcCreatorCommand.cs b/ProjectMakoto/Commands/Configuration/VcCreatorCommand.cs new file mode 100644 index 00000000..638e3790 --- /dev/null +++ b/ProjectMakoto/Commands/Configuration/VcCreatorCommand.cs @@ -0,0 +1,98 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Configuration; + +internal sealed class VcCreatorCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckAdmin() && await this.CheckOwnPermissions(Permissions.ManageChannels)); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Config.VcCreator; + + string GetCurrentConfiguration(SharedCommandContext ctx) + { + return $"{EmojiTemplates.GetChannel(ctx.Bot)} `{this.GetString(CommandKey.Title)}`: {(ctx.DbGuild.VcCreator.Channel == 0 ? false.ToEmote(ctx.Bot) : $"<#{ctx.DbGuild.VcCreator.Channel}>")}"; + } + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + var embed = new DiscordEmbedBuilder + { + Description = GetCurrentConfiguration(ctx) + }.AsInfo(ctx, this.GetString(CommandKey.Title)); + + var SetChannel = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetVcCreator), false, EmojiTemplates.GetChannel(ctx.Bot).ToComponent()); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed) + .AddComponents(new List + { + SetChannel + }) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var e = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (e.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = e.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == SetChannel.CustomId) + { + var ChannelResult = await this.PromptChannelSelection(ChannelType.Voice, new ChannelPromptConfiguration { DisableOption = this.GetString(CommandKey.DisableVcCreator) }); + + if (ChannelResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ChannelResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ChannelResult.Failed) + { + if (ChannelResult.Exception.GetType() == typeof(NullReferenceException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(CommandKey.NoChannels, true))); + await Task.Delay(3000); + await this.ExecuteCommand(ctx, arguments); + return; + } + + throw ChannelResult.Exception; + } + + var present = ChannelResult.Result.Parent.PermissionOverwrites; + + var Category = ChannelResult.Result?.Parent ?? await ctx.Guild.CreateChannelAsync(this.GetString(CommandKey.Title), ChannelType.Category); + await ChannelResult.Result?.ModifyAsync(x => { x.Name = $"➕ {this.GetGuildString(CommandKey.CreateNewChannel)}"; x.Parent = Category; x.PermissionOverwrites = ChannelResult.Result.Parent.PermissionOverwrites.Merge(ctx.Guild.EveryoneRole, Permissions.None, Permissions.ReadMessageHistory | Permissions.UseVoiceDetection | Permissions.Speak); }); + + ctx.DbGuild.VcCreator.Channel = ChannelResult.Result?.Id ?? 0; + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (e.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + return; + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/DebugCommands.cs b/ProjectMakoto/Commands/DebugCommands.cs new file mode 100644 index 00000000..8cfe7b2b --- /dev/null +++ b/ProjectMakoto/Commands/DebugCommands.cs @@ -0,0 +1,480 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.ApplicationCommands; + +[ModulePriority(-999)] + +public sealed partial class DebugCommands : ApplicationCommandsModule +{ + public Bot _bot { private get; set; } + + public sealed class MaintainerAutoComplete : IAutocompleteProvider + { + public async Task> Provider(AutocompleteContext ctx) + { + try + { + var bot = ((Bot)ctx.Services.GetService(typeof(Bot))); + + if (!ctx.User.IsMaintenance(bot.status)) + return new List().AsEnumerable(); + + var filteredCommands = Enum.GetNames(typeof(DevCommands)) + .Where(x => x.Contains(ctx.FocusedOption.Value.ToString(), StringComparison.InvariantCultureIgnoreCase)).Take(25); + + var options = filteredCommands + .Select(x => new DiscordApplicationCommandAutocompleteChoice(x, x)).ToList(); + return options.AsEnumerable(); + } + catch (Exception) + { + return new List().AsEnumerable(); + } + } + } + + public sealed class ArgumentAutoComplete : IAutocompleteProvider + { + public async Task> Provider(AutocompleteContext ctx) + { + try + { + var bot = ((Bot)ctx.Services.GetService(typeof(Bot))); + + if (!ctx.User.IsMaintenance(bot.status)) + return new List().AsEnumerable(); + + if (ctx.Options.Any(x => x.Name == "command")) + { + var currentArgument = ctx.FocusedOption.Name switch + { + "argument1" => 1, + "argument2" => 2, + "argument3" => 3, + "argument4" => 4, + _ => -1, + }; + + var Command = (DevCommands)Enum.Parse(typeof(DevCommands), ctx.Options.First(x => x.Name == "command").Value.ToString()); + + return Command switch + { + DevCommands.RawGuild => currentArgument switch + { + 1 => [ new("GuildId", "") ], + _ => [], + }, + DevCommands.BotNick => currentArgument switch + { + 1 => new List() { new("NewNickName", "") }, + _ => [], + }, + DevCommands.BanUser => currentArgument switch + { + 1 => [ new("UserId", "") ], + 2 => [ new("Reason", "") ], + _ => [], + }, + DevCommands.UnbanUser => currentArgument switch + { + 1 => new List() { new("UserId", "") }, + _ => [], + }, + DevCommands.BanGuild => currentArgument switch + { + 1 => [ new("GuildId", "") ], + 2 => [ new("Reason", "") ], + _ => [], + }, + DevCommands.UnbanGuild => currentArgument switch + { + 1 => [ new("GuildId", "") ], + _ => [], + }, + DevCommands.GlobalBan => currentArgument switch + { + 1 => [ new("UserIds", "") ], + 2 => [ new("Reason", "") ], + _ => [], + }, + DevCommands.GlobalUnban => currentArgument switch + { + 1 => [ ..bot.globalBans.Keys + .Where(bannedId => + { + var currentInputRaw = ctx.Options.First(x => x.Name == "argument1").Value.ToString().Trim(); + var currentInput = DiscordExtensions.ParseStringAsIdArray(currentInputRaw).ToList(); + + foreach (var input in currentInput) + if (bannedId == input) + return false; + + return true; + }) + .Select(bannedId => + { + var val = bot.globalBans[bannedId]; + var currentInputRaw = ctx.Options.First(x => x.Name == "argument1").Value.ToString().Trim(); + var currentInput = DiscordExtensions.ParseStringAsIdArray(currentInputRaw) + .Select(id => id.ToString()) + .ToList(); + + return new DiscordApplicationCommandAutocompleteChoice($"{(currentInput.Count > 0 ? $"{string.Join(", ", currentInput)}, " : string.Empty)}{bannedId}", $"{string.Join(" ", currentInput)} {bannedId}"); + }).ToList() + ], + 2 => [ new("Unban from all servers that global banned them", "true"), new("Do not unban from all servers that global banned them", "false") ], + _ => [], + }, + DevCommands.GlobalNotes => currentArgument switch + { + 1 => [ new("UserId", "") ], + _ => [], + }, + DevCommands.Log => currentArgument switch + { + 1 => [ + ..Enum.GetValues(typeof(LogEventLevel)).Cast().Select(x => { + var val = (LogEventLevel)x; + return new DiscordApplicationCommandAutocompleteChoice($"{Enum.GetName(val)} and above", ((int)val).ToString()); + }).ToList() + ], + _ => [], + }, + DevCommands.BatchLookup => currentArgument switch + { + 1 => [ new("UserId, UserId, UserId, ...", "") ], + _ => [], + }, + DevCommands.CreateIssue => currentArgument switch + { + 1 => [ new("Use Textbox Label Selector", "true"), new("Use Dropdown Label Selector (broken)", "false")], + _ => [], + }, + DevCommands.Evaluate => currentArgument switch + { + 1 => [ new("MessageId", "") ], + _ => [], + }, + DevCommands.Disenroll2FAUser => currentArgument switch + { + 1 => [ new("UserId", "") ], + _ => [], + }, + _ => [], + }; + } + + return []; + } + catch (Exception) + { + return new List().AsEnumerable(); + } + } + } + + [SlashCommand("developertools", "Developer Tools used to manage Makoto.", dmPermission: false, defaultMemberPermissions: (long)Permissions.None)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0075:Simplify conditional expression", Justification = "")] + public async Task DevTools(InteractionContext ctx, + [Autocomplete(typeof(MaintainerAutoComplete))][Option("command", "The command to run.", true)] string command, + [Autocomplete(typeof(ArgumentAutoComplete))][Option("argument1", "Argument 1, if required", true)] string argument1 = "", + [Autocomplete(typeof(ArgumentAutoComplete))][Option("argument2", "Argument 2, if required", true)] string argument2 = "", + [Autocomplete(typeof(ArgumentAutoComplete))][Option("argument3", "Argument 3, if required", true)] string argument3 = "") + { + bool Require1() + { + if (argument1.IsNullOrWhiteSpace()) + { + _ = ctx.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder().WithContent("Argument 1 required").AsEphemeral()); + return false; + } + else + return true; + } + + bool Require2() + { + if (argument2.IsNullOrWhiteSpace()) + { + _ = ctx.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder().WithContent("Argument 2 required").AsEphemeral()); + return false; + } + else + return true; + } + +#pragma warning disable CS8321 // Local function is declared but never used + bool Require3() + { + if (argument3.IsNullOrWhiteSpace()) + { + _ = ctx.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder().WithContent("Argument 3 required").AsEphemeral()); + return false; + } + else + return true; + } + + + + if (!ctx.User.IsMaintenance(this._bot.status)) + { + DummyCommand dummyCommand = new(); + await dummyCommand.ExecuteCommand(ctx, this._bot); + + dummyCommand.SendMaintenanceError(); + return; + } + + DevCommands Command; + + try + { + Command = (DevCommands)Enum.Parse(typeof(DevCommands), command); + } + catch (Exception) + { + await ctx.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder().WithContent("Specified Command does not exist.").AsEphemeral()); + return; + } + + try + { + switch (Command) + { + case DevCommands.Info: + _ = Task.Run(async () => + { + await new Commands.DevTools.InfoCommand().ExecuteCommand(ctx, this._bot); + }); + break; + case DevCommands.RawGuild: + _ = Task.Run(async () => + { + if (!Require1()) + return; + + await new Commands.DevTools.RawGuildCommand().ExecuteCommand(ctx, this._bot, new Dictionary + { + { "guild", argument1 is not null ? Convert.ToUInt64(argument1) : null } + }); + }); + break; + case DevCommands.BotNick: + _ = Task.Run(async () => + { + await new Commands.DevTools.BotnickCommand().ExecuteCommand(ctx, this._bot, new Dictionary + { + { "newNickname", argument1 } + }); + }); + break; + case DevCommands.BanUser: + _ = Task.Run(async () => + { + if (!Require1() || !Require2()) + return; + + await new Commands.DevTools.BanUserCommand().ExecuteCommandWith2FA(ctx, this._bot, new Dictionary + { + { "victim", await DiscordExtensions.ParseStringAsUser(argument1, ctx.Client) }, + { "reason", argument2 }, + }); + }); + break; + case DevCommands.UnbanUser: + _ = Task.Run(async () => + { + if (!Require1()) + return; + + await new Commands.DevTools.UnbanUserCommand().ExecuteCommandWith2FA(ctx, this._bot, new Dictionary + { + { "victim", await DiscordExtensions.ParseStringAsUser(argument1, ctx.Client) }, + }); + }); + break; + case DevCommands.BanGuild: + _ = Task.Run(async () => + { + if (!Require1() || !Require2()) + return; + + await new Commands.DevTools.BanGuildCommand().ExecuteCommandWith2FA(ctx, this._bot, new Dictionary + { + { "guild", Convert.ToUInt64(argument1) }, + { "reason", argument2 } + }); + }); + break; + case DevCommands.UnbanGuild: + _ = Task.Run(async () => + { + if (!Require1()) + return; + + await new Commands.DevTools.UnbanGuildCommand().ExecuteCommandWith2FA(ctx, this._bot, new Dictionary + { + { "guild", Convert.ToUInt64(argument1) }, + }); + }); + break; + case DevCommands.GlobalBan: + _ = Task.Run(async () => + { + if (!Require1() || !Require2()) + return; + + await new Commands.DevTools.GlobalBanCommand().ExecuteCommandWith2FA(ctx, this._bot, new Dictionary + { + { "victims", argument1 }, + { "reason", argument2 }, + }); + }); + break; + case DevCommands.GlobalUnban: + _ = Task.Run(async () => + { + if (!Require1()) + return; + + await new Commands.DevTools.GlobalUnbanCommand().ExecuteCommandWith2FA(ctx, this._bot, new Dictionary + { + { "victims", argument1 }, + { "UnbanFromGuilds", bool.TryParse(argument2, out var r) ? r : true }, + }); + }); + break; + case DevCommands.GlobalNotes: + _ = Task.Run(async () => + { + if (!Require1()) + return; + + await new Commands.DevTools.GlobalNotesCommand().ExecuteCommandWith2FA(ctx, this._bot, new Dictionary + { + { "victim", await DiscordExtensions.ParseStringAsUser(argument1, ctx.Client) }, + }); + }); + break; + case DevCommands.Log: + _ = Task.Run(async () => + { + if (!Require1()) + return; + + await new Commands.DevTools.LogCommand().ExecuteCommand(ctx, this._bot, new Dictionary + { + { "Level", (LogEventLevel)Enum.Parse(typeof(LogEventLevel), argument1) }, + }); + }); + break; + case DevCommands.Stop: + _ = Task.Run(async () => + { + await new Commands.DevTools.StopCommand().ExecuteCommandWith2FA(ctx, this._bot, null); + }); + break; + case DevCommands.BatchLookup: + _ = Task.Run(async () => + { + if (!Require1()) + return; + + await new Commands.DevTools.BatchLookupCommand().ExecuteCommand(ctx, this._bot, new Dictionary + { + { "IDs", argument1 }, + }); + }); + break; + case DevCommands.CreateIssue: + _ = Task.Run(async () => + { + await new Commands.DevTools.CreateIssueCommand().ExecuteCommand(ctx, this._bot, new Dictionary + { + { "UseOldTagsSelector", (bool.TryParse(argument1, out var result) ? result : true) }, + }, InitiateInteraction: false); + }); + break; + case DevCommands.Evaluate: + _ = Task.Run(async () => + { + if (!Require1()) + return; + + var id = Convert.ToUInt64(argument1); + var message = await ctx.Channel.GetMessageAsync(id); + + await new Commands.DevTools.EvaluationCommand().ExecuteCommandWith2FA(ctx, this._bot, new Dictionary + { + { "code", message.Content }, + }); + }); + break; + case DevCommands.Enroll2FA: + _ = Task.Run(async () => + { + await new Commands.DevTools.EnrollTwoFactorCommand().ExecuteCommand(ctx, this._bot); + }); + break; + case DevCommands.Quit2FASession: + _ = Task.Run(async () => + { + await new Commands.DevTools.Quit2FASessionCommand().ExecuteCommand(ctx, this._bot); + }); + break; + + case DevCommands.Disenroll2FAUser: + _ = Task.Run(async () => + { + if (!Require1()) + return; + + await new Commands.DevTools.Disenroll2FAUserCommand().ExecuteCommandWith2FA(ctx, this._bot, new Dictionary + { + { "victim", await DiscordExtensions.ParseStringAsUser(argument1, ctx.Client) }, + }); + }); + break; + case DevCommands.ManageCommands: + _ = Task.Run(async () => + { + await new Commands.DevTools.CommandManageCommand().ExecuteCommand(ctx, this._bot); + }); + break; + } + } + catch (Exception) + { + await ctx.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder().WithContent("Exception occurred, check console.").AsEphemeral()); + } + } + +#if DEBUG + [SlashCommandGroup("debug", "Debug commands, only registered in this server.")] + public sealed class Debug : ApplicationCommandsModule + { + public Bot _bot { private get; set; } + + [SlashCommand("throw", "Throw.")] + public async Task Throw(InteractionContext ctx) + => _ = new Commands.Debug.ThrowCommand().ExecuteCommand(ctx, this._bot); + + [SlashCommand("test", "Test.")] + public async Task Test(InteractionContext ctx) + { + _ = Task.Run(async () => + { + + }); + } + } +#endif +} diff --git a/ProjectMakoto/Commands/DummyCommand.cs b/ProjectMakoto/Commands/DummyCommand.cs new file mode 100644 index 00000000..b8dc322e --- /dev/null +++ b/ProjectMakoto/Commands/DummyCommand.cs @@ -0,0 +1,18 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class DummyCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.CompletedTask; + } +} diff --git a/ProjectMakoto/Commands/Maintainers/DevDebug/ThrowCommand.cs b/ProjectMakoto/Commands/Maintainers/DevDebug/ThrowCommand.cs new file mode 100644 index 00000000..048a8875 --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevDebug/ThrowCommand.cs @@ -0,0 +1,21 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Debug; + +internal sealed class ThrowCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + throw new InvalidCastException(); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/BanGuildCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/BanGuildCommand.cs new file mode 100644 index 00000000..3d88008c --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/BanGuildCommand.cs @@ -0,0 +1,43 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class BanGuildCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var guild = (ulong)arguments["guild"]; + var reason = (string)arguments["reason"]; + + if (reason.IsNullOrWhiteSpace()) + reason = "No reason provided."; + + if (ctx.Bot.bannedGuilds.ContainsKey(guild)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"`Guild '{guild}' is already banned from using the bot.`").AsError(ctx)); + return; + } + + ctx.Bot.bannedGuilds.Add(guild, new(ctx.Bot, "banned_guilds", guild) { Reason = reason, Moderator = ctx.User.Id }); + + foreach (var b in ctx.Client.Guilds.Where(x => x.Key == guild)) + { + Log.Information("Leaving guild '{guild}'..", b.Key); + await b.Value.LeaveAsync(); + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"`Guild '{guild}' was banned from using the bot.`").AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/BanUserCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/BanUserCommand.cs new file mode 100644 index 00000000..3b323973 --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/BanUserCommand.cs @@ -0,0 +1,49 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class BanUserCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var victim = (DiscordUser)arguments["victim"]; + var reason = (string)arguments["reason"]; + + if (reason.IsNullOrWhiteSpace()) + reason = "No reason provided."; + + if (ctx.Bot.status.TeamMembers.Contains(victim.Id)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"`'{victim.GetUsernameWithIdentifier()}' is registered in the staff team.`").AsError(ctx)); + return; + } + + if (ctx.Bot.bannedUsers.ContainsKey(victim.Id)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"`'{victim.GetUsernameWithIdentifier()}' is already banned from using the bot.`").AsError(ctx)); + return; + } + + ctx.Bot.bannedUsers.Add(victim.Id, new(ctx.Bot, "banned_users", victim.Id) { Reason = reason, Moderator = ctx.User.Id }); + + foreach (var b in ctx.Client.Guilds.Where(x => x.Value.OwnerId == victim.Id)) + { + Log.Information("Leaving guild '{guild}'..", b.Key); + await b.Value.LeaveAsync(); + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"`'{victim.GetUsernameWithIdentifier()}' was banned from using the bot.`").AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/BatchLookupCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/BatchLookupCommand.cs new file mode 100644 index 00000000..f301495b --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/BatchLookupCommand.cs @@ -0,0 +1,43 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class BatchLookupCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var IDs = ((string)arguments["IDs"]).Split(" ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(x => x.ToUInt64()).ToList(); + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"`Looking up {IDs.Count} users..`\n`{StringTools.GenerateASCIIProgressbar(0d, IDs.Count)}`").AsLoading(ctx)); + + Dictionary fetched = new(); + + for (var i = 0; i < IDs.Count; i++) + { + try + { + fetched.Add(IDs[i], await ctx.Client.GetUserAsync(IDs[i])); + } + catch (Exception) + { + fetched.Add(IDs[i], null); + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"`Looking up {IDs.Count} users..`\n`{StringTools.GenerateASCIIProgressbar(i, IDs.Count)}`").AsLoading(ctx)); + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(string.Join("\n", fetched.Select(x => $"{(x.Value is null ? $"❌ `Failed to fetch '{x.Key}'`" : $"✅ {x.Value.Mention} `{x.Value.GetUsernameWithIdentifier()}` (`{x.Value.Id}`)")}"))).AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/BotnickCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/BotnickCommand.cs new file mode 100644 index 00000000..e0fa7dbb --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/BotnickCommand.cs @@ -0,0 +1,37 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class BotnickCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var newNickname = (string)arguments["newNickname"]; + + try + { + await ctx.Guild.CurrentMember.ModifyAsync(x => x.Nickname = newNickname); + + if (newNickname.IsNullOrWhiteSpace()) + _ = await this.RespondOrEdit($"My nickname on this server has been reset."); + else + _ = await this.RespondOrEdit($"My nickname on this server has been changed to **{newNickname}**."); + } + catch (Exception) + { + _ = await this.RespondOrEdit($"My nickname could not be changed."); + } + }); + } +} diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/CommandManageCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/CommandManageCommand.cs new file mode 100644 index 00000000..79eb72b6 --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/CommandManageCommand.cs @@ -0,0 +1,142 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class CommandManageCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var EnableCommandButton = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), "Enable Command", ctx.Bot.status.LoadedConfig.Discord.DisabledCommands.Count == 0, "➕".UnicodeToEmoji().ToComponent()); + var DisableCommandButton = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), "Disable Command", false, "➖".UnicodeToEmoji().ToComponent()); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder() + .AddEmbed(new DiscordEmbedBuilder() + .WithTitle("Disabled Commands") + .WithDescription($"{(ctx.Bot.status.LoadedConfig.Discord.DisabledCommands.Count != 0 ? string.Join(", ", ctx.Bot.status.LoadedConfig.Discord.DisabledCommands.Select(x => $"`{x}`")) : "`No commands disabled.`")}") + .AsAwaitingInput(ctx)) + .AddComponents(EnableCommandButton, DisableCommandButton) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var Button = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (Button.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = Button.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (Button.GetCustomId() == EnableCommandButton.CustomId) + { + var SelectionResult = await this.PromptCustomSelection(ctx.Bot.status.LoadedConfig.Discord.DisabledCommands.Select(x => new DiscordStringSelectComponentOption(x, x)).ToList()); + + if (SelectionResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (SelectionResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (SelectionResult.Errored) + { + throw SelectionResult.Exception; + } + + if (!ctx.Bot.status.LoadedConfig.Discord.DisabledCommands.Contains(SelectionResult.Result)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription("`That command is already enabled.`") + .AsError(ctx)); + await this.ExecuteCommand(ctx, arguments); + return; + } + + _ = ctx.Bot.status.LoadedConfig.Discord.DisabledCommands.Remove(SelectionResult.Result); + ctx.Bot.status.LoadedConfig.Save(); + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == DisableCommandButton.CustomId) + { + List CommandList = new(); + + foreach (var cmd in ctx.Client.GetCommandList(ctx.Bot)) + { + if (ctx.Bot.status.LoadedConfig.Discord.DisabledCommands.Contains(cmd.Name.ToLower())) + continue; + + CommandList.Add(cmd.Name.ToLower()); + + foreach (var sub in cmd.Options?.Where(x => x.Type == ApplicationCommandOptionType.SubCommand) ?? new List()) + { + if (ctx.Bot.status.LoadedConfig.Discord.DisabledCommands.Contains($"{cmd.Name} {sub.Name}".ToLower())) + continue; + + CommandList.Add($"{cmd.Name} {sub.Name}".ToLower()); + } + } + + if (CommandList.Count == 0) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + + var SelectionResult = await this.PromptCustomSelection(CommandList.Select(x => + new DiscordStringSelectComponentOption(x.FirstLetterToUpper(), x, + (x.Contains(' ') ? "Sub Command" : (CommandList.Where(y => y.StartsWith(x)).Count() >= 2 ? "Command Group" : "Single Command")))).ToList(), "Select a command to disable.."); + + if (SelectionResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (SelectionResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (SelectionResult.Errored) + { + throw SelectionResult.Exception; + } + + if (ctx.Bot.status.LoadedConfig.Discord.DisabledCommands.Contains(SelectionResult.Result)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription("`That command is already disabled.`") + .AsError(ctx)); + await this.ExecuteCommand(ctx, arguments); + return; + } + + ctx.Bot.status.LoadedConfig.Discord.DisabledCommands.Add(SelectionResult.Result); + ctx.Bot.status.LoadedConfig.Save(); + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + return; + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/CreateIssueCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/CreateIssueCommand.cs new file mode 100644 index 00000000..1702070d --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/CreateIssueCommand.cs @@ -0,0 +1,92 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using Octokit; + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class CreateIssueCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckMaintenance() && await this.CheckSource(Enums.CommandType.ApplicationCommand)); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var UseOldTagsSelector = (bool)arguments["UseOldTagsSelector"]; + + if (ctx.Bot.status.LoadedConfig.Secrets.Github.TokenExperiation.GetTotalSecondsUntil() <= 0) + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithContent($"❌ `The GitHub Token expired, please update.`")); + return; + } + + var labels = await ctx.Bot.GithubClient.Issue.Labels.GetAllForRepository(ctx.Bot.status.LoadedConfig.Secrets.Github.Username, ctx.Bot.status.LoadedConfig.Secrets.Github.Repository); + + var modal = new DiscordInteractionModalBuilder().WithCustomId(Guid.NewGuid().ToString()).WithTitle("Create new Issue on Github") + .AddModalComponents(new DiscordTextComponent(TextComponentStyle.Small, "title", "Title", "New issue", 4, 250, true)) + .AddModalComponents(new DiscordTextComponent(TextComponentStyle.Paragraph, "description", "Description", required: false)); + + if (!UseOldTagsSelector) + _ = modal.AddModalComponents(new DiscordStringSelectComponent("Select tags", labels.Select(x => new DiscordStringSelectComponentOption(x.Name, x.Name.ToLower().MakeValidFileName(), "", false, new DiscordComponentEmoji(new DiscordColor(x.Color).GetClosestColorEmoji(ctx.Client)))), "labels", 1, labels.Count)); + else + _ = modal.AddModalComponents(new DiscordTextComponent(TextComponentStyle.Paragraph, "labels", "Labels", "", null, null, false, $"Put a # in front of every label you want to add.\n\n{string.Join("\n", labels.Select(x => x.Name))}")); + + await ctx.OriginalInteractionContext.CreateModalResponseAsync(modal); + + CancellationTokenSource cancellationTokenSource = new(); + + ctx.Client.ComponentInteractionCreated += RunInteraction; + + async Task RunInteraction(DiscordClient s, ComponentInteractionCreateEventArgs e) + { + _ = Task.Run(async () => + { + if (e.GetCustomId() == modal.CustomId) + { + cancellationTokenSource.Cancel(); + ctx.Client.ComponentInteractionCreated -= RunInteraction; + + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + var followup = await e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder { IsEphemeral = true }.WithContent(":arrows_counterclockwise: `Submitting your issue..`")); + + var labelComp = e.Interaction.Data.Components.Where(x => x.CustomId == "labels").First(); + + var title = e.Interaction.Data.Components.Where(x => x.CustomId == "title").First().Value; + var description = e.Interaction.Data.Components.Where(x => x.CustomId == "description").First().Value; + var labels = labelComp.Type == ComponentType.StringSelect + ? labelComp.Values.ToList() + : labelComp.Value.Split("\n", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Where(x => x.StartsWith('#')).Select(x => x.Replace("#", "")).ToList(); + + if (ctx.Bot.status.LoadedConfig.Secrets.Github.TokenExperiation.GetTotalSecondsUntil() <= 0) + { + _ = e.Interaction.EditFollowupMessageAsync(followup.Id, new DiscordWebhookBuilder().WithContent($"❌ `The GitHub Token expired, please update.`")); + return; + } + + var issue = await ctx.Bot.GithubClient.Issue.Create(ctx.Bot.status.LoadedConfig.Secrets.Github.Username, ctx.Bot.status.LoadedConfig.Secrets.Github.Repository, new NewIssue(title) { Body = $"{(description.IsNullOrWhiteSpace() ? "_No description provided_" : description)}\n\n\n\n##### _Submitted by [`{ctx.User.GetUsernameWithIdentifier()}`]({ctx.User.ProfileUrl}) (`{ctx.User.Id}`) via Discord._" }); + + if (labels.Count > 0) + _ = await ctx.Bot.GithubClient.Issue.Labels.ReplaceAllForIssue(ctx.Bot.status.LoadedConfig.Secrets.Github.Username, ctx.Bot.status.LoadedConfig.Secrets.Github.Repository, issue.Number, labels.ToArray()); + + _ = e.Interaction.EditFollowupMessageAsync(followup.Id, new DiscordWebhookBuilder().WithContent($"✅ `Issue submitted:` {issue.HtmlUrl}")); + } + }).Add(ctx.Bot, ctx); + } + + try + { + await Task.Delay(TimeSpan.FromMinutes(15), cancellationTokenSource.Token); + + ctx.Client.ComponentInteractionCreated -= RunInteraction; + } + catch { } + }); + } +} diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/Disenroll2FAUserCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/Disenroll2FAUserCommand.cs new file mode 100644 index 00000000..9420d8f8 --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/Disenroll2FAUserCommand.cs @@ -0,0 +1,33 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class Disenroll2FAUserCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) + => await this.CheckMaintenance() && await this.CheckBotOwner(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var victim = (DiscordUser)arguments["victim"]; + + if (!ctx.Client.CheckTwoFactorEnrollmentFor(victim.Id)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"`{victim.GetUsernameWithIdentifier()} is not enrolled in Two Factor Authentication.`").AsError(ctx)); + return; + } + + ctx.Client.DisenrollTwoFactor(victim.Id); + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"`Two Factor Authentication removed for {victim.GetUsername()}.`").AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/EnrollTwoFactorCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/EnrollTwoFactorCommand.cs new file mode 100644 index 00000000..a01407bf --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/EnrollTwoFactorCommand.cs @@ -0,0 +1,93 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using DisCatSharp.Extensions.TwoFactorCommands.Enums; + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class EnrollTwoFactorCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) + => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (ctx.Client.CheckTwoFactorEnrollmentFor(ctx.User.Id)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription("`You're already enrolled in Two Factor Authentication.`").AsError(ctx)); + return; + } + + var Confirmed = false; + + var ConfirmButton = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), "Confirm Two Factor Authentication", false, DiscordEmoji.FromUnicode("✅").ToComponent()); + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription("`Enrolling you into Two Factor Authentication..`").AsLoading(ctx)); + var (Secret, QrCode) = ctx.Client.EnrollTwoFactor(ctx.User); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithContent($"Please scan this QR Code or use the Secret below to register the Two Factor in an App of your choosing." + + $"\n\n`{Secret}`\n\n" + + $"When you're done, please press the button below to confirm the success of the registration.") + .WithFile("2fa.png", QrCode, false, "This is a QR Code for an Authenticator App.") + .AddComponents(ConfirmButton, MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + _ = Task.Delay(120000).ContinueWith((_) => + { + ctx.Client.ComponentInteractionCreated -= RunInteraction; + + if (!Confirmed) + { + ctx.Client.DisenrollTwoFactor(ctx.User.Id); + _ = this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription("`Failed to authenticate. Enrollment reverted.`").AsError(ctx)); + } + }); + + async Task RunInteraction(DiscordClient s, ComponentInteractionCreateEventArgs e) + { + _ = Task.Run(async () => + { + if (e.Message?.Id == ctx.ResponseMessage.Id && e.User.Id == ctx.User.Id) + { + try + { + if (e.GetCustomId() == ConfirmButton.CustomId) + { + var tfa_result = await e.RequestTwoFactorAsync(s); + + if (tfa_result.Result is TwoFactorResult.ValidCode or TwoFactorResult.InvalidCode) + _ = tfa_result.ComponentInteraction.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (tfa_result.Result == TwoFactorResult.ValidCode) + { + Confirmed = true; + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription("`Enrolled successfully.`").AsSuccess(ctx)); + return; + } + + throw new Exception("Invalid Code"); + } + else if (e.GetCustomId() == MessageComponents.CancelButtonId) + { + throw new Exception("Cancelled"); + } + } + catch (Exception) + { + ctx.Client.DisenrollTwoFactor(ctx.User.Id); + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription("`Failed to authenticate. Enrollment reverted.`").AsError(ctx)); + } + } + }); + } + + ctx.Client.ComponentInteractionCreated += RunInteraction; + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/EvaluationCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/EvaluationCommand.cs new file mode 100644 index 00000000..c35d0d27 --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/EvaluationCommand.cs @@ -0,0 +1,83 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class EvaluationCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckBotOwner(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (ctx.CommandType is not Enums.CommandType.ApplicationCommand and not Enums.CommandType.ContextMenu) + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder().WithDescription("Evaluating CScript has the potential of leaking confidential information. Are you sure you want to run this command as Prefix Command?").AsWarning(ctx)) + .AddComponents(new List { new DiscordButtonComponent(ButtonStyle.Success, "yes", "Yes"), + new DiscordButtonComponent(ButtonStyle.Danger, "no", "No")})); + + var result = await ctx.ResponseMessage.WaitForButtonAsync(ctx.User); + + if (result.TimedOut || result.GetCustomId() != "yes") + { + this.DeleteOrInvalidate(); + return; + } + } + + var rawCode = (string)arguments["code"]; + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription("`Evaluating..`").AsLoading(ctx)); + + var code = RegexTemplates.Code.Match(rawCode).Groups[1]?.Value?.Trim() ?? ""; + + if (code.IsNullOrWhiteSpace()) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription("`No code block was found.`").AsError(ctx)); + return; + } + + try + { + var options = ScriptOptions.Default; + options = options.WithImports( + "System", + "System.Collections.Generic", + "System.Linq", + "System.Text", + "System.Threading.Tasks", + "DisCatSharp", + "DisCatSharp.Entities", + "DisCatSharp.Interactivity", + "DisCatSharp.Interactivity.Extensions", + "DisCatSharp.Interactivity.Enums", + "DisCatSharp.Enums", + "Newtonsoft.Json" + ); + options = options.WithReferences(AppDomain.CurrentDomain.GetAssemblies().Where(x => !x.IsDynamic && !x.Location.IsNullOrWhiteSpace())); + + var script = CSharpScript.Create(code, options, typeof(SharedCommandContext)); + _ = script.Compile(); + var result = await script.RunAsync(ctx).ConfigureAwait(false); + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithTitle("Successful Evaluation") + .WithDescription($"{(result.ReturnValue?.ToString().IsNullOrWhiteSpace() ?? true ? "`The evaluation did not return any result.`" : $"{result.ReturnValue}")}").AsSuccess(ctx)); + } + catch (Exception ex) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithTitle("Failed Evaluation").WithDescription($"```{ex.Message.SanitizeForCode()}```").AsError(ctx)); + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/GlobalBanCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/GlobalBanCommand.cs new file mode 100644 index 00000000..acad0963 --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/GlobalBanCommand.cs @@ -0,0 +1,183 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; +internal sealed class GlobalBanCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var victims = await DiscordExtensions.ParseStringAsUserArray((string)arguments["victims"], ctx.Client); + var reason = (string)arguments["reason"]; + + if (victims?.Length <= 0) + { + _ = this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription("`Please provide user(s).`").AsError(ctx, "Global Ban")); + return; + } + + if (reason.IsNullOrWhiteSpace()) + { + _ = this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription("`Please provide a reason for the global ban.`").AsError(ctx, "Global Ban")); + return; + } + + var currentStatus = new Dictionary([ + ..victims.Select(x => new KeyValuePair(x, CurrentStatus.InQueue)).ToList() + ]); + + _ = Task.Run(async () => + { + while (true) + { + var desc = string.Empty; + + lock (currentStatus) + { + desc = $"{string.Join("\n\n", currentStatus + .Select(x => + { + var emoji = x.Value switch + { + CurrentStatus.Invalid => DiscordEmoji.FromUnicode("❌"), + CurrentStatus.Added => DiscordEmoji.FromUnicode("✅"), + CurrentStatus.Changed => DiscordEmoji.FromUnicode("🔄"), + CurrentStatus.InQueue => DiscordEmoji.FromUnicode("🕒"), + CurrentStatus.InProgress => EmojiTemplates.GetLoading(ctx.Bot), + _ => throw new NotImplementedException(), + }; + + var text = x.Value switch + { + CurrentStatus.Invalid => $"**`{x.Key.GetUsernameWithIdentifier()} ({x.Key.Id})`**\n{EmojiTemplates.GetInVisible(ctx.Bot)} `This user cannot be global banned.`", + CurrentStatus.Added => $"**`{x.Key.GetUsernameWithIdentifier()} ({x.Key.Id})`**\n{EmojiTemplates.GetInVisible(ctx.Bot)} `User was added to global ban list.`", + CurrentStatus.Changed => $"**`{x.Key.GetUsernameWithIdentifier()} ({x.Key.Id})`**\n{EmojiTemplates.GetInVisible(ctx.Bot)} `User was already global banned, updated entry.`", + CurrentStatus.InQueue => $"**`{x.Key.GetUsernameWithIdentifier()} ({x.Key.Id})`**\n{EmojiTemplates.GetInVisible(ctx.Bot)} `In queue..`", + CurrentStatus.InProgress => $"**`{x.Key.GetUsernameWithIdentifier()} ({x.Key.Id})`**\n{EmojiTemplates.GetInVisible(ctx.Bot)} `Processing..`", + _ => throw new NotImplementedException(), + }; + + return $"{emoji} {text}"; + }))}"; + } + + var embed = new DiscordEmbedBuilder(); + + var done = true; + + if (currentStatus.All(x => x.Value is CurrentStatus.Changed or CurrentStatus.Added)) + _ = embed.AsSuccess(ctx, "Global Ban").WithDescription(desc.TruncateWithIndication(2000)); + else if (currentStatus.All(x => x.Value is CurrentStatus.Changed or CurrentStatus.Added or CurrentStatus.Invalid)) + _ = embed.AsWarning(ctx, "Global Ban").WithDescription(desc.TruncateWithIndication(2000)); + else + { + _ = embed.AsLoading(ctx, "Global Ban").WithDescription($"`Global banning {currentStatus.Count} users..`\n\n{desc}".TruncateWithIndication(2000)); + done = false; + } + + _ = await this.RespondOrEdit(embed); + + if (done) + return; + + await Task.Delay(1000); + } + }).Add(ctx.Bot, ctx); + + foreach (var victim in currentStatus) + { + currentStatus[victim.Key] = CurrentStatus.InProgress; + await Task.Delay(2000); + + if (ctx.Bot.globalBans.ContainsKey(victim.Key.Id)) + { + ctx.Bot.globalBans[victim.Key.Id].Reason = reason; + ctx.Bot.globalBans[victim.Key.Id].Moderator = ctx.User.Id; + currentStatus[victim.Key] = CurrentStatus.Changed; + + var announceChannel1 = await ctx.Client.GetChannelAsync(ctx.Bot.status.LoadedConfig.Channels.GlobalBanAnnouncements); + _ = await announceChannel1.SendMessageAsync(new DiscordEmbedBuilder + { + Author = new DiscordEmbedBuilder.EmbedAuthor + { + Name = ctx.CurrentUser.GetUsername(), + IconUrl = AuditLogIcons.UserUpdated + }, + Description = $"The global ban entry of {victim.Key.Mention} `{victim.Key.GetUsernameWithIdentifier()}` (`{victim.Key.Id}`) was updated.\n\n" + + $"Reason: `{reason.SanitizeForCode()}`\n" + + $"Moderator: {ctx.User.Mention} `{ctx.User.GetUsernameWithIdentifier()}` (`{ctx.User.Id}`)", + Color = EmbedColors.Warning, + Timestamp = DateTime.UtcNow + }); + continue; + } + + if (ctx.Bot.status.TeamMembers.Contains(victim.Key.Id)) + { + currentStatus[victim.Key] = CurrentStatus.Invalid; + continue; + } + + ctx.Bot.globalBans.Add(victim.Key.Id, new(ctx.Bot, "globalbans", victim.Key.Id) { Reason = reason, Moderator = ctx.User.Id }); + + var Success = 0; + var Failed = 0; + + foreach (var b in ctx.Client.Guilds.OrderByDescending(x => x.Key == ctx.Guild.Id)) + { + if (!ctx.Bot.Guilds.ContainsKey(b.Key)) + ctx.Bot.Guilds.Add(b.Key, new Guild(ctx.Bot, b.Key)); + + if (ctx.Bot.Guilds[b.Key].Join.AutoBanGlobalBans) + { + try + { + await b.Value.BanMemberAsync(victim.Key.Id, 7, $"Globalban: {reason}"); + Success++; + } + catch (Exception ex) + { + Log.Error(ex, "Exception occurred while trying to ban user from {guild}", b.Key); + Failed++; + } + } + } + + currentStatus[victim.Key] = CurrentStatus.Added; + + var announceChannel = await ctx.Client.GetChannelAsync(ctx.Bot.status.LoadedConfig.Channels.GlobalBanAnnouncements); + _ = await announceChannel.SendMessageAsync(new DiscordEmbedBuilder + { + Author = new DiscordEmbedBuilder.EmbedAuthor + { + Name = ctx.CurrentUser.GetUsername(), + IconUrl = AuditLogIcons.UserBanned + }, + Description = $"{victim.Key.Mention} `{victim.Key.GetUsernameWithIdentifier()}` (`{victim.Key.Id}`) was added to the global ban list.\n\n" + + $"Reason: `{reason.SanitizeForCode()}`\n" + + $"Moderator: {ctx.User.Mention} `{ctx.User.GetUsernameWithIdentifier()}` (`{ctx.User.Id}`)", + Color = EmbedColors.Error, + Timestamp = DateTime.UtcNow + }); + } + }); + } + + private enum CurrentStatus + { + InQueue, + InProgress, + Changed, + Added, + Invalid + } +} diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/GlobalNotesCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/GlobalNotesCommand.cs new file mode 100644 index 00000000..912b94cb --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/GlobalNotesCommand.cs @@ -0,0 +1,121 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class GlobalNotesCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForModerate(ctx, true)) + return; + + var victim = (DiscordUser)arguments["victim"]; + + var ModeratorCache = new Dictionary(); + + if (ctx.Bot.globalNotes.TryGetValue(victim.Id, out var globalNotes)) + foreach (var b in globalNotes.Notes) + { + if (ModeratorCache.ContainsKey(b.Moderator)) + continue; + + try + { + ModeratorCache.Add(b.Moderator, await ctx.Client.GetUserAsync(b.Moderator)); + } + catch (Exception) + { + ModeratorCache.Add(b.Moderator, null); + } + } + + var AddButton = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), "Add Notes", false, DiscordEmoji.FromUnicode("➕").ToComponent()); + var RemoveButton = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), "Remove Notes", (!ctx.Bot.globalNotes.ContainsKey(victim.Id)), DiscordEmoji.FromUnicode("➖").ToComponent()); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder() + .WithEmbed(new DiscordEmbedBuilder() + .WithDescription($"{victim.Mention} `has {(ctx.Bot.globalNotes.TryGetValue(victim.Id, out var noteObj) ? noteObj.Notes.Length : 0)} global notes.`") + .AddFields((noteObj is not null ? noteObj.Notes.Take(20).Select(x => new DiscordEmbedField("󠂪 󠂪", $"{x.Reason.FullSanitize()} - `{(ModeratorCache[x.Moderator] is null ? "Unknown#0000" : ModeratorCache[x.Moderator].GetUsernameWithIdentifier())}` {x.Timestamp.ToTimestamp()}")) : new List()))) + .AddComponents(new List { AddButton, RemoveButton }) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var Button = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (Button.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + if (Button.GetCustomId() == AddButton.CustomId) + { + var ModalResult = await this.PromptModalWithRetry(Button.Result.Interaction, + new DiscordInteractionModalBuilder().AddTextComponent(new DiscordTextComponent(TextComponentStyle.Paragraph, "Note", "New Note", "", 1, 256, true)), false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + var note = ModalResult.Result.Interaction.GetModalValueByCustomId("Note"); + + ctx.Bot.globalNotes[victim.Id].Notes = ctx.Bot.globalNotes[victim.Id].Notes.Add(new GlobalNote.Note() { Moderator = ctx.User.Id, Reason = note }); + + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == RemoveButton.CustomId) + { + _ = Button.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + var SelectionResult = await this.PromptCustomSelection(ctx.Bot.globalNotes[victim.Id].Notes + .Select(x => new DiscordStringSelectComponentOption(x.Reason.TruncateWithIndication(100), x.Timestamp.Ticks.ToString(), $"Added by {(ModeratorCache[x.Moderator] is null ? "Unknown#0000" : ModeratorCache[x.Moderator].GetUsernameWithIdentifier())} {x.Timestamp.GetTimespanSince().GetHumanReadable()} ago")).ToList()); + + if (SelectionResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (SelectionResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (SelectionResult.Errored) + { + throw SelectionResult.Exception; + } + + ctx.Bot.globalNotes[victim.Id].Notes = ctx.Bot.globalNotes[victim.Id].Notes.Remove(x => x.UUID, ctx.Bot.globalNotes[victim.Id].Notes.First(x => x.Timestamp.Ticks.ToString() == SelectionResult.Result)); + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (Button.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + return; + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/GlobalUnbanCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/GlobalUnbanCommand.cs new file mode 100644 index 00000000..4e721ac0 --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/GlobalUnbanCommand.cs @@ -0,0 +1,155 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class GlobalUnbanCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var victims = await DiscordExtensions.ParseStringAsUserArray((string)arguments["victims"], ctx.Client); + var UnbanFromGuilds = (bool)arguments["UnbanFromGuilds"]; + + if (victims?.Length <= 0) + { + _ = this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription("`Please provide user(s).`").AsError(ctx, "Global Ban")); + return; + } + + var currentStatus = new Dictionary([ + ..victims.Select(x => new KeyValuePair(x, CurrentStatus.InQueue)).ToList() + ]); + + _ = Task.Run(async () => + { + while (true) + { + var desc = string.Empty; + + lock (currentStatus) + { + desc = $"{string.Join("\n\n", currentStatus + .Select(x => + { + var emoji = x.Value switch + { + CurrentStatus.Invalid => DiscordEmoji.FromUnicode("❌"), + CurrentStatus.Removed => DiscordEmoji.FromUnicode("✅"), + CurrentStatus.InQueue => DiscordEmoji.FromUnicode("🕒"), + CurrentStatus.InProgress => EmojiTemplates.GetLoading(ctx.Bot), + _ => throw new NotImplementedException(), + }; + + var text = x.Value switch + { + CurrentStatus.Invalid => $"**`{x.Key.GetUsernameWithIdentifier()} ({x.Key.Id})`**\n{EmojiTemplates.GetInVisible(ctx.Bot)} `This user is not on the global ban list.`", + CurrentStatus.Removed => $"**`{x.Key.GetUsernameWithIdentifier()} ({x.Key.Id})`**\n{EmojiTemplates.GetInVisible(ctx.Bot)} `User was removed from the global ban list.`", + CurrentStatus.InQueue => $"**`{x.Key.GetUsernameWithIdentifier()} ({x.Key.Id})`**\n{EmojiTemplates.GetInVisible(ctx.Bot)} `In queue..`", + CurrentStatus.InProgress => $"**`{x.Key.GetUsernameWithIdentifier()} ({x.Key.Id})`**\n{EmojiTemplates.GetInVisible(ctx.Bot)} `Processing..`", + _ => throw new NotImplementedException(), + }; + + return $"{emoji} {text}"; + }))}"; + } + + var embed = new DiscordEmbedBuilder(); + + var done = true; + + if (currentStatus.All(x => x.Value is CurrentStatus.Removed)) + _ = embed.AsSuccess(ctx, "Global Ban").WithDescription(desc.TruncateWithIndication(2000)); + else if (currentStatus.All(x => x.Value is CurrentStatus.Removed or CurrentStatus.Invalid)) + _ = embed.AsWarning(ctx, "Global Ban").WithDescription(desc.TruncateWithIndication(2000)); + else + { + _ = embed.AsLoading(ctx, "Global Ban").WithDescription($"`Removing Global ban for {currentStatus.Count} users..`\n\n{desc}".TruncateWithIndication(2000)); + done = false; + } + + _ = await this.RespondOrEdit(embed); + + if (done) + return; + + await Task.Delay(1000); + } + }).Add(ctx.Bot, ctx); + + foreach (var victim in currentStatus) + { + currentStatus[victim.Key] = CurrentStatus.InProgress; + await Task.Delay(2000); + + if (!ctx.Bot.globalBans.ContainsKey(victim.Key.Id)) + { + currentStatus[victim.Key] = CurrentStatus.Invalid; + continue; + } + + _ = ctx.Bot.globalBans.Remove(victim.Key.Id); + currentStatus[victim.Key] = CurrentStatus.Removed; + + var Success = 0; + var Failed = 0; + + if (UnbanFromGuilds) + foreach (var b in ctx.Client.Guilds.OrderByDescending(x => x.Key == ctx.Guild.Id)) + { + if (!ctx.Bot.Guilds.ContainsKey(b.Key)) + ctx.Bot.Guilds.Add(b.Key, new Guild(ctx.Bot, b.Key)); + + if (ctx.Bot.Guilds[b.Key].Join.AutoBanGlobalBans) + { + try + { + var Ban = await b.Value.GetBanAsync(victim.Key); + + if (Ban.Reason.StartsWith("Globalban: ")) + await b.Value.UnbanMemberAsync(victim.Key, $"Globalban removed."); + + Success++; + } + catch (Exception ex) + { + Log.Error(ex, "Exception occurred while trying to unban user from {guild}", b.Key); + Failed++; + } + } + } + + var announceChannel = await ctx.Client.GetChannelAsync(ctx.Bot.status.LoadedConfig.Channels.GlobalBanAnnouncements); + _ = await announceChannel.SendMessageAsync(new DiscordEmbedBuilder + { + Author = new DiscordEmbedBuilder.EmbedAuthor + { + Name = ctx.CurrentUser.GetUsername(), + IconUrl = AuditLogIcons.UserBanRemoved + }, + Description = $"{victim.Key.Mention} `{victim.Key.GetUsernameWithIdentifier()}` (`{victim.Key.Id}`) was removed from the global ban list.\n\n" + + $"Moderator: {ctx.User.Mention} `{ctx.User.GetUsernameWithIdentifier()}` (`{ctx.User.Id}`)", + Color = EmbedColors.Success, + Timestamp = DateTime.UtcNow + }); + } + }); + } + + private enum CurrentStatus + { + InQueue, + InProgress, + Removed, + Invalid + } +} diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/InfoCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/InfoCommand.cs new file mode 100644 index 00000000..909c60fe --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/InfoCommand.cs @@ -0,0 +1,211 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class InfoCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForModerate(ctx)) + return; + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription("`Fetching system details..`").AsLoading(ctx)); + + Dictionary history = new(); + + try + { + var rawHistory = ctx.Bot.MonitorClient.GetHistory().GroupBy(x => $"{x.Key.Hour}-{(int)Math.Floor(x.Key.Minute / 6d)}"); + foreach (var entry in rawHistory) + { + history.Add(entry.Last().Key, new() + { + Cpu = new() + { + Load = entry.Average(x => x.Value.Cpu.Load), + Temperature = entry.Average(x => x.Value.Cpu.Temperature) + }, + Memory = new() + { + Available = entry.Average(x => x.Value.Memory.Available), + Used = entry.Average(x => x.Value.Memory.Used), + } + }); + } + } + catch {} + + history.Add(DateTime.UtcNow, await ctx.Bot.MonitorClient.GetCurrent()); + history = history.OrderBy(x => x.Key.Ticks).ToDictionary(x => x.Key, x => x.Value); + + var ServerUptime = ""; + if (Environment.OSVersion.Platform == PlatformID.Unix) + { + ProcessStartInfo info = new() + { + FileName = "bash", + Arguments = $"-c uptime", + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false + }; + + var b = Process.Start(info); + + b.WaitForExit(); + + var Output = b.StandardOutput.ReadToEnd(); + ServerUptime = Output.Remove(Output.IndexOf(','), Output.Length - Output.IndexOf(',')).TrimStart(); + } + + IEnumerable bFile; + + try + { + bFile = File.ReadLines("LatestGitPush.cfg"); + } + catch (Exception) + { + bFile = new List + { + "Developer Version", + "dev", + $"{DateTime.UtcNow:dd.MM.yy}", + $"{DateTime.UtcNow:HH:mm:ss},00" + }; + } + + var Version = bFile.First().Trim(); + var Branch = bFile.Skip(1).First().Trim(); + var Date = bFile.Skip(2).First().Trim().Replace("/", "."); + + var Time = bFile.Skip(3).First().Trim(); + Time = Time[..Time.IndexOf(',')]; + + var miscEmbed = new DiscordEmbedBuilder().WithTitle($"{ctx.CurrentUser.GetUsername()} Details") + .AddField(new DiscordEmbedField("Currently running as", $"`{ctx.CurrentUser.GetUsernameWithIdentifier()}`", true)) + .AddField(new DiscordEmbedField("Process PID", $"`{Environment.ProcessId}`", true)) + .AddField(new DiscordEmbedField("󠂪 󠂪", $"󠂪 󠂪", true)) + .AddField(new DiscordEmbedField("Bot uptime", $"`{Math.Round((DateTime.UtcNow - ctx.Bot.status.startupTime).TotalHours, 2)} hours`", true)) + .AddField(new DiscordEmbedField("Discord API Latency", $"`{ctx.Client.Ping}ms`", true)) + .AddField(new DiscordEmbedField("Server uptime", $"`{(ServerUptime.IsNullOrWhiteSpace() ? "Currently unavailable" : ServerUptime)}`", true)) + .AddField(new DiscordEmbedField("Currently running software", $"`Project Makoto by {(await ctx.Client.GetUserAsync(411950662662881290)).GetUsernameWithIdentifier()} ({Version} ({Branch}) built on the {Date} at {Time})`")) + .AddField(new DiscordEmbedField("Current bot library and version", $"[`{ctx.Client.BotLibrary} {ctx.Client.VersionString}`](https://github.com/Aiko-IT-Systems/DisCatSharp)")) + .AddField(new DiscordEmbedField("Plugin Status", + $"{(ctx.Bot.status.LoadedConfig.EnablePlugins && ctx.Bot.Plugins.Count > 0 ? + $"`{ctx.Bot.Plugins.Count}/{new DirectoryInfo("Plugins")?.GetFiles()?.Where(x => !x.Name.StartsWith('.') && x.Extension == ".pmpl")?.Count() ?? 0} loaded`\n\n" + + $"{string.Join("\n", ctx.Bot.Plugins.Select(x => $"- {x.Value.OfficialPlugin.ToEmote(ctx.Bot)} `{x.Value.Name}` `v{x.Value.Version}` by {x.Value.AuthorUser?.Mention ?? "`N/A`"} (`{x.Value.Author}`)"))}" : + "`Disabled`")}")) + .AsInfo(ctx).WithFooter().WithTimestamp(null); + + var cpuEmbed1 = new DiscordEmbedBuilder() + .WithTitle("CPU") + .AddField(new DiscordEmbedField("Load", $"`{history.MaxBy(x => x.Key).Value.Cpu.Load.ToString("N0", CultureInfo.CreateSpecificCulture("en-US")),3}%`", true)) + .AddField(new DiscordEmbedField("Temperature", $"`{history.MaxBy(x => x.Key).Value.Cpu.Temperature.ToString("N0", CultureInfo.CreateSpecificCulture("en-US")),2}°C`", true)) + .AsLoading(ctx).WithFooter().WithTimestamp(null).WithAuthor(); + + var memoryEmbed = new DiscordEmbedBuilder() + .WithTitle("Memory") + .AddField(new DiscordEmbedField("Usage", $"`{history.MaxBy(x => x.Key).Value.Memory.Used.ToString("N0", CultureInfo.CreateSpecificCulture("en-US"))}/{history.MaxBy(x => x.Key).Value.Memory.Total.ToString("N0", CultureInfo.CreateSpecificCulture("en-US"))} MB`", true)) + .AsLoading(ctx); + + var embeds = new List() { miscEmbed }; + + if (ctx.Bot.status.LoadedConfig.MonitorSystem.Enabled) + embeds.AddRange(cpuEmbed1, memoryEmbed); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().AddEmbeds(embeds)); + + if (!ctx.Bot.status.LoadedConfig.MonitorSystem.Enabled) + return; + + Dictionary charts = new(); + + try + { + var prev = ""; + var qc = ctx.Bot.ChartsClient.GetChart(800, 600, history.Select(x => + { + var value = x.Key.ToString("HH:mm"); + if (prev == value) + return " "; + prev = value; + return $"{value}"; + }), new ChartGeneration.Dataset[] + { + new("Usage (%)", history.Select(x => $"{(int)x.Value.Cpu.Load}"), "getGradientFillHelper('vertical', ['#ff0000', '#00ff00'])"), + new("Temperature (°C)", history.Select(x => $"{(int)x.Value.Cpu.Temperature}"), "getGradientFillHelper('vertical', ['#4287f5', '#ff0000'])"), + }, 0, 100); + + charts.Add("cpu.png", qc.ToByteArray()); + cpuEmbed1.ImageUrl = "attachment://cpu.png"; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to generate cpu usage graph"); + cpuEmbed1.ImageUrl = "attachment://1.png"; + } + finally + { + _ = cpuEmbed1.AsInfo(ctx).WithFooter().WithTimestamp(null).WithAuthor(); + } + + try + { + var prev = ""; + var qc = ctx.Bot.ChartsClient.GetChart(800, 600, history.Select(x => + { + var value = x.Key.ToString("HH:mm"); + if (prev == value) + return " "; + prev = value; + return $"{value}"; + }), + new ChartGeneration.Dataset[] + { + new("Usage (MB)", history.Select(x => $"{(int)x.Value.Memory.Used}")) + }, 0, (int)history.First().Value.Memory.Total); + + charts.Add("mem.png", qc.ToByteArray()); + memoryEmbed.ImageUrl = "attachment://mem.png"; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to generate memory graph"); + memoryEmbed.ImageUrl = "attachment://1.png"; + } + finally + { + _ = memoryEmbed.AsInfo(ctx).WithAuthor(); + } + + var list = new List(); + list.Add(miscEmbed.WithImageUrl("attachment://1.png")); + list.Add(cpuEmbed1); + list.Add(memoryEmbed); + + var files = charts.ToDictionary(x => x.Key, y => (Stream)new MemoryStream(y.Value)); + try + { + files.Add("1.png", new FileStream("Assets/1.png", FileMode.Open)); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().AddEmbeds(new DiscordEmbed[] { miscEmbed.Build(), cpuEmbed1.Build(), memoryEmbed.Build() }).WithFiles(files)); + } + finally + { + foreach (var file in files) + file.Value.Dispose(); + } + }); + } +} diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/LogCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/LogCommand.cs new file mode 100644 index 00000000..25d82f63 --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/LogCommand.cs @@ -0,0 +1,29 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class LogCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var Level = (int)arguments["Level"]; + + if (Level is > ((int)LogEventLevel.Fatal) or < ((int)LogEventLevel.Verbose)) + throw new Exception("Invalid Log Level"); + + ctx.Bot.loggingLevel.MinimumLevel = (LogEventLevel)Level; + _ = await this.RespondOrEdit($"`Changed LogLevel to '{Enum.GetName((LogEventLevel)Level)}'`"); + }); + } +} diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/Quit2FASessionCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/Quit2FASessionCommand.cs new file mode 100644 index 00000000..45215090 --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/Quit2FASessionCommand.cs @@ -0,0 +1,25 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class Quit2FASessionCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) + => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + ctx.DbUser.LastSuccessful2FA = DateTime.MinValue; + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription("`Your active 2FA Session, if present, has been quit.`").AsSuccess(ctx)); + }); + } +} diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/RawGuildCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/RawGuildCommand.cs new file mode 100644 index 00000000..4ecf0205 --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/RawGuildCommand.cs @@ -0,0 +1,29 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class RawGuildCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var guild = (ulong?)arguments["guild"]; + guild ??= ctx.Guild.Id; + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithFile("guild.json", JsonConvert.SerializeObject(ctx.Bot.Guilds[guild.Value], Formatting.Indented, new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + }).ToStream())); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/StopCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/StopCommand.cs new file mode 100644 index 00000000..c8b032f5 --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/StopCommand.cs @@ -0,0 +1,35 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class StopCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var msg = await this.RespondOrEdit(new DiscordMessageBuilder().WithContent("Confirm?").AddComponents(new DiscordButtonComponent(ButtonStyle.Danger, "Shutdown", "Confirm shutdown", false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("⛔"))))); + + var x = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(1)); + + if (x.TimedOut) + { + _ = await this.RespondOrEdit("_Interaction timed out._"); + return; + } + + _ = await this.RespondOrEdit("Shutting down!"); + + await ctx.Bot.ExitApplication(true); + }); + } +} diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/UnbanGuildCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/UnbanGuildCommand.cs new file mode 100644 index 00000000..f7c46976 --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/UnbanGuildCommand.cs @@ -0,0 +1,32 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class UnbanGuildCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var guild = (ulong)arguments["guild"]; + + if (!ctx.Bot.bannedGuilds.ContainsKey(guild)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"`Guild '{guild}' is not banned from using the bot.`").AsError(ctx)); + return; + } + + _ = ctx.Bot.bannedGuilds.Remove(guild); + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"`Guild '{guild}' was unbanned from using the bot.`").AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Maintainers/DevTools/UnbanUserCommand.cs b/ProjectMakoto/Commands/Maintainers/DevTools/UnbanUserCommand.cs new file mode 100644 index 00000000..a4f0e211 --- /dev/null +++ b/ProjectMakoto/Commands/Maintainers/DevTools/UnbanUserCommand.cs @@ -0,0 +1,32 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.DevTools; + +internal sealed class UnbanUserCommand : BaseCommand +{ + public override Task BeforeExecution(SharedCommandContext ctx) => this.CheckMaintenance(); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var victim = (DiscordUser)arguments["victim"]; + + if (!ctx.Bot.bannedUsers.ContainsKey(victim.Id)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"`'{victim.GetUsernameWithIdentifier()}' is not banned from using the bot.`").AsError(ctx)); + return; + } + + _ = ctx.Bot.bannedUsers.Remove(victim.Id); + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"`'{victim.GetUsernameWithIdentifier()}' was unbanned from using the bot.`").AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Moderation/BanCommand.cs b/ProjectMakoto/Commands/Moderation/BanCommand.cs new file mode 100644 index 00000000..68371301 --- /dev/null +++ b/ProjectMakoto/Commands/Moderation/BanCommand.cs @@ -0,0 +1,58 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class BanCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckPermissions(Permissions.BanMembers) && await this.CheckOwnPermissions(Permissions.BanMembers)); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var victim = (DiscordUser)arguments["user"]; + var deleteMessageDays = (int)arguments["days"] > 7 ? 7 : ((int)arguments["days"] < 0 ? 0 : (int)arguments["days"]); + var reason = (string)arguments["reason"]; + + var CommandKey = this.t.Commands.Moderation.Ban; + + DiscordMember bMember = null; + + try + { + bMember = await victim.ConvertToMember(ctx.Guild); + } + catch { } + + var embed = new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Banning, true, new TVar("Victim", victim.Mention))) + .WithThumbnail(victim.AvatarUrl) + .AsLoading(ctx); + _ = await this.RespondOrEdit(embed); + + try + { + if (ctx.Member.GetRoleHighestPosition() <= bMember.GetRoleHighestPosition()) + throw new Exception(); + + var newReason = (reason.IsNullOrWhiteSpace() ? this.GetGuildString(this.t.Commands.Moderation.NoReason) : reason); + await ctx.Guild.BanMemberAsync(victim.Id, deleteMessageDays, this.GetGuildString(CommandKey.AuditLog, new TVar("Reason", newReason))); + + embed = embed.WithDescription(this.GetString(CommandKey.Banned, true, new TVar("Victim", victim.Mention), new TVar("Reason", newReason))).AsSuccess(ctx); + } + catch (Exception) + { + embed = embed.WithDescription(this.GetString(CommandKey.Errored, true, new TVar("Victim", victim.Mention))).AsError(ctx); + } + + _ = await this.RespondOrEdit(embed); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Moderation/ClearBackupCommand.cs b/ProjectMakoto/Commands/Moderation/ClearBackupCommand.cs new file mode 100644 index 00000000..8ce26033 --- /dev/null +++ b/ProjectMakoto/Commands/Moderation/ClearBackupCommand.cs @@ -0,0 +1,48 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using ProjectMakoto.Entities.Members; + +namespace ProjectMakoto.Commands; + +internal sealed class ClearBackupCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckPermissions(Permissions.ManageRoles)); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var victim = (DiscordUser)arguments["user"]; + + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx)) + return; + + var CommandKey = this.t.Commands.Moderation.ClearBackup; + + if ((await ctx.Guild.GetAllMembersAsync()).Any(x => x.Id == victim.Id)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.IsOnServer, true, new TVar("Victim", victim.Mention))) + .WithThumbnail(victim.AvatarUrl) + .AsError(ctx)); + + return; + } + + ctx.DbGuild.Members[victim.Id].MemberRoles = Array.Empty(); + ctx.DbGuild.Members[victim.Id].SavedNickname = ""; + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Deleted, true, new TVar("Victim", victim.Mention))) + .WithThumbnail(victim.AvatarUrl) + .AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Moderation/CustomEmbedCommand.cs b/ProjectMakoto/Commands/Moderation/CustomEmbedCommand.cs new file mode 100644 index 00000000..8f9eb92f --- /dev/null +++ b/ProjectMakoto/Commands/Moderation/CustomEmbedCommand.cs @@ -0,0 +1,750 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class CustomEmbedCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckPermissions(Permissions.EmbedLinks)); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Moderation.CustomEmbed; + + var GeneratedEmbed = new DiscordEmbedBuilder().WithDescription(this.GetString(CommandKey.New)); + + while (true) + { + try + { + var SetTitle = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetTitleButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🖋"))); + var SetAuthor = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetAuthorButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👤"))); + var SetThumbnail = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetThumbnailButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🖼"))); + + var SetDescription = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetDescriptionButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("📝"))); + var SetImage = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetImageButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🖼"))); + var SetColor = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetColorButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🎨"))); + + var SetTimestamp = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetTimestampButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🕒"))); + var SetFooter = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetFooterButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✒"))); + + var AddField = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(CommandKey.AddFieldButton), (GeneratedEmbed.Fields.Count >= 25), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("➕"))); + var ModifyField = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.ModifyFieldButton), (GeneratedEmbed.Fields.Count <= 0), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🔁"))); + var RemoveField = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), this.GetString(CommandKey.RemoveFieldButton), (GeneratedEmbed.Fields.Count <= 0), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("➖"))); + + var FinishAndSend = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(CommandKey.SendEmbedButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✅"))); + + try + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(GeneratedEmbed) + .AddComponents(new List { SetTitle, SetAuthor, SetThumbnail }) + .AddComponents(new List { SetDescription, SetImage, SetColor }) + .AddComponents(new List { SetFooter, SetTimestamp }) + .AddComponents(new List { AddField, ModifyField, RemoveField }) + .AddComponents(new List { FinishAndSend, MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot) })); + } + catch (Exception) + { + GeneratedEmbed = new DiscordEmbedBuilder().WithDescription(this.GetString(CommandKey.New)); + continue; + } + + var Menu1 = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(15)); + + if (Menu1.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + if (Menu1.GetCustomId() == SetTitle.CustomId) + { + var modal = new DiscordInteractionModalBuilder(this.GetString(CommandKey.ModifyingTitle), Guid.NewGuid().ToString()) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "title", this.GetString(CommandKey.TitleField), "", 0, 256, false, GeneratedEmbed.Title)) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "url", this.GetString(CommandKey.UrlField), "", 0, 256, false, GeneratedEmbed.Url)); + + var ModalResult = await this.PromptModalWithRetry(Menu1.Result.Interaction, modal, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + continue; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + InteractionCreateEventArgs Response = ModalResult.Result; + + var url = Response.Interaction.GetModalValueByCustomId("url"); + + try + { + url = new UriBuilder(url).Uri.ToString(); + } + catch (Exception) + { continue; } + + if (!url.IsNullOrWhiteSpace()) + if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && !url.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + url = url.Insert(0, "https://"); + + GeneratedEmbed.Title = Response.Interaction.GetModalValueByCustomId("title"); + GeneratedEmbed.Url = url; + continue; + } + else if (Menu1.GetCustomId() == SetAuthor.CustomId) + { + GeneratedEmbed.Author ??= new(); + + _ = Menu1.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + var SetName = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetNameButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👤"))); + var SetUrl = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetUrlButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("↘"))); + var SetIcon = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetIconButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🖼"))); + + var SetByUser = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetAsUserButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👤"))); + var SetByGuild = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetAsServer), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🖥"))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(GeneratedEmbed) + .AddComponents(new List { SetName, SetUrl, SetIcon }) + .AddComponents(new List { SetByUser, SetByGuild }) + .AddComponents(new List { MessageComponents.GetBackButton(ctx.DbUser, ctx.Bot) })); + + var Menu2 = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(15)); + + if (Menu2.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + if (Menu2.GetCustomId() == SetName.CustomId) + { + var modal = new DiscordInteractionModalBuilder(this.GetString(CommandKey.ModifyingAuthorName), Guid.NewGuid().ToString()) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "name", this.GetString(CommandKey.NameField), "", 0, 256, false, GeneratedEmbed.Author.Name)); + + var ModalResult = await this.PromptModalWithRetry(Menu2.Result.Interaction, modal, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + continue; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + InteractionCreateEventArgs Response = ModalResult.Result; + + GeneratedEmbed.Author.Name = Response.Interaction.GetModalValueByCustomId("name"); + continue; + } + else if (Menu2.GetCustomId() == SetUrl.CustomId) + { + var modal = new DiscordInteractionModalBuilder(this.GetString(CommandKey.ModifyingAuthorUrl), Guid.NewGuid().ToString()) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "url", this.GetString(CommandKey.UrlField), "", 0, 256, false, GeneratedEmbed.Author.Url)); + + var ModalResult = await this.PromptModalWithRetry(Menu2.Result.Interaction, modal, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + continue; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + InteractionCreateEventArgs Response = ModalResult.Result; + + var url = Response.Interaction.GetModalValueByCustomId("url"); + + if (!url.IsNullOrWhiteSpace()) + if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && !url.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + url = url.Insert(0, "https://"); + + GeneratedEmbed.Author.Url = url; + continue; + } + else if (Menu2.GetCustomId() == SetIcon.CustomId) + { + _ = Menu2.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"{this.GetString(CommandKey.UploadImage, true, new TVar("Command", $"{ctx.Prefix}upload"))}\n\n" + + $"⚠ {this.GetString(CommandKey.UploadNotice, true)}").AsAwaitingInput(ctx)); + + (Stream stream, int fileSize) stream; + + try + { + stream = await this.PromptForFileUpload(TimeSpan.FromMinutes(1)); + } + catch (AlreadyAppliedException) + { + continue; + } + catch (ArgumentException) + { + this.ModifyToTimedOut(); + continue; + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(CommandKey.ImportingUpload, true)).AsAwaitingInput(ctx)); + + if (stream.fileSize > ctx.Bot.status.LoadedConfig.Discord.MaxUploadSize) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"{this.GetString(CommandKey.ImportSizeError, true, new TVar("Size", ctx.Bot.status.LoadedConfig.Discord.MaxUploadSize.FileSizeToHumanReadable()))}\n\n" + + $"{this.GetString(CommandKey.ContinueTimer, true, new TVar("Timestamp", DateTime.UtcNow.AddSeconds(6).ToTimestamp()))}").AsError(ctx)); + await Task.Delay(5000); + continue; + } + + var asset = await (await ctx.Client.GetChannelAsync(ctx.Bot.status.LoadedConfig.Channels.OtherAssets)).SendMessageAsync(new DiscordMessageBuilder().WithContent($"{ctx.User.Mention} `{ctx.User.GetUsernameWithIdentifier()} ({ctx.User.Id})`").WithFile($"{Guid.NewGuid()}.png", stream.stream)); + + GeneratedEmbed.Author.IconUrl = asset.Attachments[0].Url; + continue; + } + else if (Menu2.GetCustomId() == SetByUser.CustomId) + { + var modal = new DiscordInteractionModalBuilder(this.GetString(CommandKey.ModifyingAuthorbyUserId), Guid.NewGuid().ToString()) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "userid", this.GetString(CommandKey.UserIdField), "", 0, 20, false)); + + var ModalResult = await this.PromptModalWithRetry(Menu2.Result.Interaction, modal, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + continue; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + InteractionCreateEventArgs Response = ModalResult.Result; + + try + { + var user = await ctx.Client.GetUserAsync(Convert.ToUInt64(Response.Interaction.GetModalValueByCustomId("userid"))); + + GeneratedEmbed.Author = new DiscordEmbedBuilder.EmbedAuthor + { + Name = user.GetUsernameWithIdentifier(), + IconUrl = user.AvatarUrl, + Url = user.ProfileUrl + }; + } + catch { } + continue; + } + else if (Menu2.GetCustomId() == SetByGuild.CustomId) + { + _ = Menu2.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + GeneratedEmbed.Author = new DiscordEmbedBuilder.EmbedAuthor + { + Name = ctx.Guild.Name, + IconUrl = ctx.Guild.IconUrl + }; + continue; + } + else if (Menu2.GetCustomId() == MessageComponents.BackButtonId) + { + _ = Menu2.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + continue; + } + + continue; + } + else if (Menu1.GetCustomId() == SetThumbnail.CustomId) + { + GeneratedEmbed.Thumbnail ??= new(); + + _ = Menu1.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"{this.GetString(CommandKey.UploadImage, true, new TVar("Command", $"{ctx.Prefix}upload"))}\n\n" + + $"⚠ {this.GetString(CommandKey.UploadNotice, true)}").AsAwaitingInput(ctx)); + + (Stream stream, int fileSize) stream; + + try + { + stream = await this.PromptForFileUpload(TimeSpan.FromMinutes(1)); + } + catch (AlreadyAppliedException) + { + continue; + } + catch (ArgumentException) + { + this.ModifyToTimedOut(); + continue; + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(CommandKey.ImportingUpload, true)).AsAwaitingInput(ctx)); + + if (stream.fileSize > ctx.Bot.status.LoadedConfig.Discord.MaxUploadSize) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"{this.GetString(CommandKey.ImportSizeError, true, new TVar("Size", ctx.Bot.status.LoadedConfig.Discord.MaxUploadSize.FileSizeToHumanReadable()))}\n\n" + + $"{this.GetString(CommandKey.ContinueTimer, true, new TVar("Timestamp", DateTime.UtcNow.AddSeconds(6).ToTimestamp()))}").AsError(ctx)); + await Task.Delay(5000); + continue; + } + + var asset = await (await ctx.Client.GetChannelAsync(ctx.Bot.status.LoadedConfig.Channels.OtherAssets)).SendMessageAsync(new DiscordMessageBuilder().WithContent($"{ctx.User.Mention} `{ctx.User.GetUsernameWithIdentifier()} ({ctx.User.Id})`").WithFile($"{Guid.NewGuid()}.png", stream.stream)); + + GeneratedEmbed.Thumbnail.Url = asset.Attachments[0].Url; + continue; + } + else if (Menu1.GetCustomId() == SetDescription.CustomId) + { + var modal = new DiscordInteractionModalBuilder(this.GetString(CommandKey.ModifyingDescription), Guid.NewGuid().ToString()) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Paragraph, "description", this.GetString(CommandKey.DescriptionField), "", 0, 4000, false, GeneratedEmbed.Description)); + + var ModalResult = await this.PromptModalWithRetry(Menu1.Result.Interaction, modal, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + continue; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + InteractionCreateEventArgs Response = ModalResult.Result; + + GeneratedEmbed.Description = Response.Interaction.GetModalValueByCustomId("description"); + continue; + } + else if (Menu1.GetCustomId() == SetImage.CustomId) + { + _ = Menu1.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"{this.GetString(CommandKey.UploadImage, true, new TVar("Command", $"{ctx.Prefix}upload"))}\n\n" + + $"⚠ {this.GetString(CommandKey.UploadNotice, true)}").AsAwaitingInput(ctx)); + + (Stream stream, int fileSize) stream; + + try + { + stream = await this.PromptForFileUpload(TimeSpan.FromMinutes(1)); + } + catch (AlreadyAppliedException) + { + continue; + } + catch (ArgumentException) + { + this.ModifyToTimedOut(); + continue; + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(CommandKey.ImportingUpload, true)).AsAwaitingInput(ctx)); + + if (stream.fileSize > ctx.Bot.status.LoadedConfig.Discord.MaxUploadSize) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"{this.GetString(CommandKey.ImportSizeError, true, new TVar("Size", ctx.Bot.status.LoadedConfig.Discord.MaxUploadSize.FileSizeToHumanReadable()))}\n\n" + + $"{this.GetString(CommandKey.ContinueTimer, true, new TVar("Timestamp", DateTime.UtcNow.AddSeconds(6).ToTimestamp()))}").AsError(ctx)); + await Task.Delay(5000); + continue; + } + + var asset = await (await ctx.Client.GetChannelAsync(ctx.Bot.status.LoadedConfig.Channels.OtherAssets)).SendMessageAsync(new DiscordMessageBuilder().WithContent($"{ctx.User.Mention} `{ctx.User.GetUsernameWithIdentifier()} ({ctx.User.Id})`").WithFile($"{Guid.NewGuid()}.png", stream.stream)); + + GeneratedEmbed.ImageUrl = asset.Attachments[0].Url; + continue; + } + else if (Menu1.GetCustomId() == SetColor.CustomId) + { + var modal = new DiscordInteractionModalBuilder(this.GetString(CommandKey.ModifyingColor), Guid.NewGuid().ToString()) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "color", this.GetString(CommandKey.ColorField), "#FF0000", 1, 100, false)); + + var ModalResult = await this.PromptModalWithRetry(Menu1.Result.Interaction, modal, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + continue; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + InteractionCreateEventArgs Response = ModalResult.Result; + + GeneratedEmbed.Color = new DiscordColor(Response.Interaction.GetModalValueByCustomId("color").Truncate(7).IsValidHexColor()); + continue; + } + else if (Menu1.GetCustomId() == SetFooter.CustomId) + { + GeneratedEmbed.Footer ??= new(); + + _ = Menu1.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + var SetText = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetTextButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🖊"))); + var SetIcon = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetIconButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🖼"))); + + var SetByUser = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetAsUserButton), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👤"))); + var SetByGuild = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(CommandKey.SetAsServer), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🖥"))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(GeneratedEmbed) + .AddComponents(new List { SetText, SetIcon }) + .AddComponents(new List { SetByUser, SetByGuild }) + .AddComponents(new List { MessageComponents.GetBackButton(ctx.DbUser, ctx.Bot) })); + + var Menu2 = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(15)); + + if (Menu2.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + if (Menu2.GetCustomId() == SetText.CustomId) + { + var modal = new DiscordInteractionModalBuilder(this.GetString(CommandKey.ModifyingFooterText), Guid.NewGuid().ToString()) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Paragraph, "text", this.GetString(CommandKey.TextField), "", 0, 2048, false, GeneratedEmbed.Footer.Text)); + + var ModalResult = await this.PromptModalWithRetry(Menu2.Result.Interaction, modal, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + continue; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + InteractionCreateEventArgs Response = ModalResult.Result; + + GeneratedEmbed.Footer.Text = Response.Interaction.GetModalValueByCustomId("text"); + continue; + } + else if (Menu2.GetCustomId() == SetIcon.CustomId) + { + _ = Menu2.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"{this.GetString(CommandKey.UploadImage, true, new TVar("Command", $"{ctx.Prefix}upload"))}\n\n" + + $"⚠ {this.GetString(CommandKey.UploadNotice, true)}").AsAwaitingInput(ctx)); + + (Stream stream, int fileSize) stream; + + try + { + stream = await this.PromptForFileUpload(TimeSpan.FromMinutes(1)); + } + catch (AlreadyAppliedException) + { + continue; + } + catch (ArgumentException) + { + this.ModifyToTimedOut(); + continue; + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(CommandKey.ImportingUpload, true)).AsAwaitingInput(ctx)); + + if (stream.fileSize > ctx.Bot.status.LoadedConfig.Discord.MaxUploadSize) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription($"{this.GetString(CommandKey.ImportSizeError, true, new TVar("Size", ctx.Bot.status.LoadedConfig.Discord.MaxUploadSize.FileSizeToHumanReadable()))}\n\n" + + $"{this.GetString(CommandKey.ContinueTimer, true, new TVar("Timestamp", DateTime.UtcNow.AddSeconds(6).ToTimestamp()))}").AsError(ctx)); + await Task.Delay(5000); + continue; + } + + var asset = await (await ctx.Client.GetChannelAsync(ctx.Bot.status.LoadedConfig.Channels.OtherAssets)).SendMessageAsync(new DiscordMessageBuilder().WithContent($"{ctx.User.Mention} `{ctx.User.GetUsernameWithIdentifier()} ({ctx.User.Id})`").WithFile($"{Guid.NewGuid()}.png", stream.stream)); + + GeneratedEmbed.Footer.IconUrl = asset.Attachments[0].Url; + continue; + } + else if (Menu2.GetCustomId() == SetByUser.CustomId) + { + var modal = new DiscordInteractionModalBuilder(this.GetString(CommandKey.ModifyingAuthorbyUserId), Guid.NewGuid().ToString()) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "userid", this.GetString(CommandKey.UserIdField), "", 0, 20, false)); + + var ModalResult = await this.PromptModalWithRetry(Menu2.Result.Interaction, modal, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + continue; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + InteractionCreateEventArgs Response = ModalResult.Result; + + try + { + var user = await ctx.Client.GetUserAsync(Convert.ToUInt64(Response.Interaction.GetModalValueByCustomId("userid"))); + + GeneratedEmbed.Footer = new DiscordEmbedBuilder.EmbedFooter + { + Text = user.GetUsernameWithIdentifier(), + IconUrl = user.AvatarUrl + }; + } + catch { } + continue; + } + else if (Menu2.GetCustomId() == SetByGuild.CustomId) + { + _ = Menu2.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + GeneratedEmbed.Footer = new DiscordEmbedBuilder.EmbedFooter + { + Text = ctx.Guild.Name, + IconUrl = ctx.Guild.IconUrl + }; + continue; + } + else if (Menu2.GetCustomId() == MessageComponents.BackButtonId) + { + _ = Menu2.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + continue; + } + + continue; + } + else if (Menu1.GetCustomId() == SetTimestamp.CustomId) + { + _ = Menu1.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + var ModalResult = await this.PromptModalForDateTime(null, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ModalResult.Errored) + { + continue; + } + + GeneratedEmbed.Timestamp = ModalResult.Result; + continue; + } + else if (Menu1.GetCustomId() == AddField.CustomId) + { + var modal = new DiscordInteractionModalBuilder(this.GetString(CommandKey.ModifyingField), Guid.NewGuid().ToString()) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "title", this.GetString(CommandKey.TitleField), "", 0, 256, true)) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Paragraph, "description", this.GetString(CommandKey.DescriptionField), "", 0, 1024, true)) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "inline", this.GetString(CommandKey.InlineField), "", 4, 5, true, false.ToString())); + + var ModalResult = await this.PromptModalWithRetry(Menu1.Result.Interaction, modal, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + continue; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + InteractionCreateEventArgs Response = ModalResult.Result; + + try + { + _ = GeneratedEmbed.AddField(new DiscordEmbedField(Response.Interaction.GetModalValueByCustomId("title"), Response.Interaction.GetModalValueByCustomId("description"), Convert.ToBoolean(Response.Interaction.GetModalValueByCustomId("inline")))); + } + catch { } + continue; + } + else if (Menu1.GetCustomId() == ModifyField.CustomId) + { + var Count = -1; + + int GetInt() + { + Count++; + return Count; + } + + var FieldResult = await this.PromptCustomSelection(GeneratedEmbed.Fields + .Select(x => new DiscordStringSelectComponentOption($"{x.Name}", GetInt().ToString(), x.Value.TruncateWithIndication(10))).ToList()); + + if (FieldResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (FieldResult.Cancelled) + { + continue; + } + else if (FieldResult.Errored) + { + throw FieldResult.Exception; + } + + var FieldToEdit = GeneratedEmbed.Fields[Convert.ToInt32(FieldResult.Result)]; + + var modal = new DiscordInteractionModalBuilder(this.GetString(CommandKey.ModifyingField), Guid.NewGuid().ToString()) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Paragraph, "title", this.GetString(CommandKey.TitleField), "", 1, 256, true, FieldToEdit.Name)) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Paragraph, "description", this.GetString(CommandKey.DescriptionField), "", 1, 1024, true, FieldToEdit.Value)) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "inline", this.GetString(CommandKey.InlineField), "", 4, 5, true, FieldToEdit.Inline.ToString())); + + var ModalResult = await this.PromptModalWithRetry(Menu1.Result.Interaction, modal, null, false, null, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + continue; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + InteractionCreateEventArgs Response = ModalResult.Result; + + try + { + FieldToEdit.Name = Response.Interaction.GetModalValueByCustomId("title"); + FieldToEdit.Value = Response.Interaction.GetModalValueByCustomId("description"); + FieldToEdit.Inline = Convert.ToBoolean(Response.Interaction.GetModalValueByCustomId("inline")); + } + catch { } + continue; + } + else if (Menu1.GetCustomId() == RemoveField.CustomId) + { + _ = Menu1.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + var Count = -1; + + int GetInt() + { + Count++; + return Count; + } + + var FieldResult = await this.PromptCustomSelection(GeneratedEmbed.Fields + .Select(x => new DiscordStringSelectComponentOption($"{x.Name}", GetInt().ToString(), x.Value.TruncateWithIndication(10))).ToList()); + + if (FieldResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (FieldResult.Cancelled) + { + continue; + } + else if (FieldResult.Errored) + { + throw FieldResult.Exception; + } + + _ = GeneratedEmbed.RemoveField(GeneratedEmbed.Fields[Convert.ToInt32(FieldResult.Result)]); + continue; + } + else if (Menu1.GetCustomId() == FinishAndSend.CustomId) + { + _ = Menu1.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + var ChannelResult = await this.PromptChannelSelection(new ChannelType[] { ChannelType.Text, ChannelType.News }); + + if (ChannelResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ChannelResult.Cancelled) + { + await this.ExecuteCommand(ctx, arguments); + return; + } + else if (ChannelResult.Failed) + { + if (ChannelResult.Exception.GetType() == typeof(NullReferenceException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().AsError(ctx).WithDescription(this.GetString(CommandKey.NoValidChannels, true))); + await Task.Delay(3000); + continue; + } + + throw ChannelResult.Exception; + } + + _ = await ChannelResult.Result.SendMessageAsync(GeneratedEmbed); + this.DeleteOrInvalidate(); + return; + } + else if (Menu1.GetCustomId() == MessageComponents.CancelButtonId) + { + _ = Menu1.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + this.DeleteOrInvalidate(); + return; + } + } + catch (Exception ex) { Log.Error(ex, "Failed to change an embed"); } + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Moderation/FollowUpdatesCommand.cs b/ProjectMakoto/Commands/Moderation/FollowUpdatesCommand.cs new file mode 100644 index 00000000..9084359f --- /dev/null +++ b/ProjectMakoto/Commands/Moderation/FollowUpdatesCommand.cs @@ -0,0 +1,62 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class FollowUpdatesCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckPermissions(Permissions.ManageWebhooks) && await this.CheckOwnPermissions(Permissions.ManageWebhooks)); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var channel = (FollowChannel)arguments["channel"]; + + var CommandKey = this.t.Commands.Moderation.FollowUpdates; + + try + { + switch (channel) + { + case FollowChannel.GithubUpdates: + { + var b = await ctx.Client.GetChannelAsync(ctx.Bot.status.LoadedConfig.Channels.GithubLog); + _ = await b.FollowAsync(ctx.Channel); + break; + } + case FollowChannel.GlobalBans: + { + var b = await ctx.Client.GetChannelAsync(ctx.Bot.status.LoadedConfig.Channels.GlobalBanAnnouncements); + _ = await b.FollowAsync(ctx.Channel); + break; + } + case FollowChannel.News: + { + var b = await ctx.Client.GetChannelAsync(ctx.Bot.status.LoadedConfig.Channels.News); + _ = await b.FollowAsync(ctx.Channel); + break; + } + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(CommandKey.Followed, true, new TVar("Channel", channel)), + }.AsSuccess(ctx)); + } + catch (Exception) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(CommandKey.Failed, true, new TVar("Channel", channel)), + }.AsError(ctx)); + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Moderation/GuildPurgeCommand.cs b/ProjectMakoto/Commands/Moderation/GuildPurgeCommand.cs new file mode 100644 index 00000000..4492c934 --- /dev/null +++ b/ProjectMakoto/Commands/Moderation/GuildPurgeCommand.cs @@ -0,0 +1,157 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class GuildPurgeCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckPermissions(Permissions.ManageMessages) && await this.CheckPermissions(Permissions.ManageChannels) && await this.CheckOwnPermissions(Permissions.ManageMessages)); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Moderation.GuildPurge; + + var number = (int)arguments["number"]; + var victim = (DiscordUser)arguments["user"]; + + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx)) + return; + + if (number is > 2000 or < 1) + { + this.SendSyntaxError(); + return; + } + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder(). + WithDescription(this.GetString(CommandKey.Scanning, true, new TVar("Victim", victim.Mention))) + .AsLoading(ctx))); + + var currentProg = 0; + var maxProg = ctx.Guild.Channels.Count; + + var allMsg = 0; + Dictionary> channelList = new(); + + foreach (var channel in ctx.Guild.Channels.Where(x => x.Value.Type is ChannelType.Text or ChannelType.PublicThread or ChannelType.PrivateThread or ChannelType.News or ChannelType.Voice)) + { + allMsg = 0; + foreach (var b in channelList) + allMsg += b.Value.Count; + + currentProg++; + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder(). + WithDescription($"{this.GetString(CommandKey.Scanning, true, new TVar("Victim", victim.Mention))}\n" + + $"`{StringTools.GenerateASCIIProgressbar(currentProg, maxProg)} {MathTools.CalculatePercentage(currentProg, maxProg),3}%`") + .AsLoading(ctx))); + + var MessageInt = number; + + List requested_messages = new(); + + var pre_request = await channel.Value.GetMessagesAsync(1); + + if (pre_request.Count > 0) + { + requested_messages.Add(pre_request[0]); + MessageInt -= 1; + } + + while (true) + { + if (pre_request.Count == 0) + break; + + if (MessageInt <= 0) + break; + + if (MessageInt > 100) + { + var current_request = await channel.Value.GetMessagesBeforeAsync(requested_messages.Last().Id, 100); + + if (current_request.Count == 0) + break; + + foreach (var b in current_request) + requested_messages.Add(b); + + MessageInt -= 100; + } + else + { + var current_request = await channel.Value.GetMessagesBeforeAsync(requested_messages.Last().Id, MessageInt); + + if (current_request.Count == 0) + break; + + foreach (var b in current_request) + requested_messages.Add(b); + + MessageInt -= MessageInt; + } + } + + if (requested_messages.Count > 0) + foreach (var b in requested_messages.ToList()) + { + if (b.Author.Id == victim.Id && b.CreationTimestamp.AddDays(14) > DateTime.UtcNow) + { + if (!channelList.ContainsKey(channel.Key)) + channelList.Add(channel.Key, new List()); + + channelList[channel.Key].Add(b); + } + } + } + + foreach (var channel in channelList) + foreach (var message in channel.Value.ToList()) + if (message.CreationTimestamp.GetTimespanSince() > TimeSpan.FromDays(14)) + _ = channel.Value.Remove(message); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder() + .WithDescription($"{this.GetString(CommandKey.Deleting, true, new TVar("Victim", victim.Mention), new TVar("Count", allMsg))}\n" + + $"`{StringTools.GenerateASCIIProgressbar(currentProg, maxProg)} {MathTools.CalculatePercentage(currentProg, maxProg)}%`") + .AsLoading(ctx))); + + currentProg = 0; + maxProg = 0; + + foreach (var channel in channelList) + maxProg += channel.Value.Count; + + foreach (var channel in channelList) + { + try + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder() + .WithDescription($"{this.GetString(CommandKey.Deleting, true, new TVar("Victim", victim.Mention), new TVar("Count", allMsg))}\n" + + $"`{StringTools.GenerateASCIIProgressbar(currentProg, maxProg)} {MathTools.CalculatePercentage(currentProg, maxProg)}%`") + .AsLoading(ctx))); + + while (channel.Value.Count > 0) + { + var msgs = channel.Value.Take(100).ToList(); + await ctx.Guild.GetChannel(channel.Key).DeleteMessagesAsync(msgs); + channel.Value.RemoveRange(0, msgs.Count); + currentProg += msgs.Count; + } + } + catch { } + } + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Ended, true, new TVar("Victim", victim.Mention), new TVar("Min", currentProg), new TVar("Max", maxProg), new TVar("ChannelCount", channelList.Count))) + .AsSuccess(ctx))); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Moderation/KickCommand.cs b/ProjectMakoto/Commands/Moderation/KickCommand.cs new file mode 100644 index 00000000..79e552d5 --- /dev/null +++ b/ProjectMakoto/Commands/Moderation/KickCommand.cs @@ -0,0 +1,65 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class KickCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckPermissions(Permissions.KickMembers) && await this.CheckOwnPermissions(Permissions.KickMembers)); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var victim = (DiscordUser)arguments["user"]; + var reason = (string)arguments["reason"]; + + var CommandKey = this.t.Commands.Moderation.Kick; + + DiscordMember bMember = null; + + try + { + bMember = await victim.ConvertToMember(ctx.Guild); + } + catch (DisCatSharp.Exceptions.NotFoundException) + { + this.SendNoMemberError(); + return; + } + catch (Exception) + { + throw; + } + + var embed = new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Kicking, true, new TVar("Victim", victim.Mention))) + .WithThumbnail(victim.AvatarUrl) + .AsLoading(ctx); + _ = await this.RespondOrEdit(embed); + + try + { + if (ctx.Member.GetRoleHighestPosition() <= bMember.GetRoleHighestPosition()) + throw new Exception(); + + var newReason = (reason.IsNullOrWhiteSpace() ? this.GetGuildString(this.t.Commands.Moderation.NoReason) : reason); + await bMember.RemoveAsync(this.GetGuildString(CommandKey.AuditLog, new TVar("Reason", newReason))); + + embed = embed.WithDescription(this.GetString(CommandKey.Kicked, true, new TVar("Victim", victim.Mention), new TVar("Reason", newReason))).AsSuccess(ctx); + } + catch (Exception) + { + embed = embed.WithDescription(this.GetString(CommandKey.Errored, true, new TVar("Victim", victim.Mention))).AsError(ctx); + } + + _ = await this.RespondOrEdit(embed); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Moderation/ManualBumpCommand.cs b/ProjectMakoto/Commands/Moderation/ManualBumpCommand.cs new file mode 100644 index 00000000..afbf2c52 --- /dev/null +++ b/ProjectMakoto/Commands/Moderation/ManualBumpCommand.cs @@ -0,0 +1,54 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class ManualBumpCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckPermissions(Permissions.ManageMessages)); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Moderation.ManualBump; + + if (ctx.DbGuild.BumpReminder.ChannelId == 0) + { + _ = this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(CommandKey.NotSetUp, true)).AsError(ctx)); + return; + } + + DiscordButtonComponent YesButton = new(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(this.t.Common.Yes), false, DiscordEmoji.FromUnicode("✅").ToComponent()); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder() + .WithEmbed(new DiscordEmbedBuilder().WithDescription(this.GetString(CommandKey.Warning, true)).AsWarning(ctx)) + .AddComponents(new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), this.GetString(this.t.Common.No), false, DiscordEmoji.FromUnicode("❌").ToComponent()), YesButton)); + + var e = await ctx.ResponseMessage.WaitForButtonAsync(ctx.User); + + if (e.TimedOut || e.GetCustomId() != YesButton.CustomId) + { + this.DeleteOrInvalidate(); + return; + } + + var channel = ctx.Guild.GetChannel(ctx.DbGuild.BumpReminder.ChannelId); + + ctx.DbGuild.BumpReminder.LastBump = DateTime.UtcNow; + ctx.DbGuild.BumpReminder.LastReminder = DateTime.UtcNow; + ctx.DbGuild.BumpReminder.BumpsMissed = 0; + ctx.DbGuild.BumpReminder.LastUserId = 0; + ctx.Bot.BumpReminder.ScheduleBump(ctx.Client, ctx.Guild.Id); + + _ = channel.DeleteMessageAsync(await channel.GetMessageAsync(ctx.DbGuild.BumpReminder.PersistentMessageId)); + this.DeleteOrInvalidate(); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Moderation/MoveAllCommand.cs b/ProjectMakoto/Commands/Moderation/MoveAllCommand.cs new file mode 100644 index 00000000..b4175846 --- /dev/null +++ b/ProjectMakoto/Commands/Moderation/MoveAllCommand.cs @@ -0,0 +1,52 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class MoveAllCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckPermissions(Permissions.MoveMembers) && await this.CheckOwnPermissions(Permissions.MoveMembers) && await this.CheckVoiceState()); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var newChannel = (DiscordChannel)arguments["channel"]; + + var CommandKey = this.t.Commands.Moderation.Move; + + if (newChannel.Type != ChannelType.Voice) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.NotAVc, true)) + .AsError(ctx)); + return; + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Moving, true, + new TVar("Count", ctx.Member.VoiceState.Channel.Users.Count), + new TVar("Origin", ctx.Member.VoiceState.Channel.Mention), + new TVar("Destination", newChannel.Mention))) + .AsLoading(ctx)); + + foreach (var b in ctx.Member.VoiceState.Channel.Users) + { + _ = b.ModifyAsync(x => x.VoiceChannel = newChannel); + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Moved, true, + new TVar("Count", ctx.Member.VoiceState.Channel.Users.Count), + new TVar("Origin", ctx.Member.VoiceState.Channel.Mention), + new TVar("Destination", newChannel.Mention))) + .AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Moderation/MoveHereCommand.cs b/ProjectMakoto/Commands/Moderation/MoveHereCommand.cs new file mode 100644 index 00000000..f7d231cb --- /dev/null +++ b/ProjectMakoto/Commands/Moderation/MoveHereCommand.cs @@ -0,0 +1,60 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class MoveHereCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckPermissions(Permissions.MoveMembers) && await this.CheckOwnPermissions(Permissions.MoveMembers) && await this.CheckVoiceState()); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var oldChannel = (DiscordChannel)arguments["channel"]; + + var CommandKey = this.t.Commands.Moderation.Move; + + if (oldChannel.Type != ChannelType.Voice) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.NotAVc, true)) + .AsError(ctx)); + return; + } + + if (!oldChannel.Users.IsNotNullAndNotEmpty()) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.VcEmpty, true)) + .AsError(ctx)); + return; + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Moving, true, + new TVar("Count", ctx.Member.VoiceState.Channel.Users.Count), + new TVar("Destination", ctx.Member.VoiceState.Channel.Mention), + new TVar("Origin", oldChannel.Mention))) + .AsLoading(ctx)); + + foreach (var b in oldChannel.Users) + { + _ = b.ModifyAsync(x => x.VoiceChannel = ctx.Member.VoiceState.Channel); + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Moved, true, + new TVar("Count", ctx.Member.VoiceState.Channel.Users.Count), + new TVar("Destination", ctx.Member.VoiceState.Channel.Mention), + new TVar("Origin", oldChannel.Mention))) + .AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Moderation/PurgeCommand.cs b/ProjectMakoto/Commands/Moderation/PurgeCommand.cs new file mode 100644 index 00000000..4a2a377d --- /dev/null +++ b/ProjectMakoto/Commands/Moderation/PurgeCommand.cs @@ -0,0 +1,156 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class PurgeCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckPermissions(Permissions.ManageMessages) && await this.CheckOwnPermissions(Permissions.ManageMessages)); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var number = (int)arguments["number"]; + var victim = (DiscordUser)arguments["user"]; + + var CommandKey = this.t.Commands.Moderation.Purge; + + try + { + if (ctx.CommandType == Enums.CommandType.PrefixCommand) + await ctx.OriginalCommandContext.Message.DeleteAsync(); + } + catch { } + + if (number is > 2000 or < 1) + { + this.SendSyntaxError(); + return; + } + + var FailedToDeleteAmount = 0; + + if (number > 100) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Fetching, true, new TVar("Count", number))) + .AsLoading(ctx)); + + var fetchedMessages = (await ctx.Channel.GetMessagesAsync(100)).ToList(); + + if (fetchedMessages.Any(x => x.Id == ctx.ResponseMessage.Id)) + _ = fetchedMessages.Remove(fetchedMessages.First(x => x.Id == ctx.ResponseMessage.Id)); + + while (fetchedMessages.Count <= number) + { + var fetch = fetchedMessages.Count + 100 <= number + ? await ctx.Channel.GetMessagesBeforeAsync(fetchedMessages.Last().Id, 100) + : await ctx.Channel.GetMessagesBeforeAsync(fetchedMessages.Last().Id, number - fetchedMessages.Count); + + if (fetch.Any()) + fetchedMessages.AddRange(fetch); + else + break; + } + + if (victim is not null) + foreach (var b in fetchedMessages.Where(x => x.Author.Id != victim.Id).ToList()) + _ = fetchedMessages.Remove(b); + + var failedDeletion = 0; + + foreach (var b in fetchedMessages.Where(x => x.CreationTimestamp < DateTime.UtcNow.AddDays(-14)).ToList()) + { + _ = fetchedMessages.Remove(b); + FailedToDeleteAmount++; + failedDeletion++; + } + + if (fetchedMessages.Count > 0) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Fetched, true, new TVar("Count", fetchedMessages.Count))) + .AsError(ctx)); + } + else + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.NoMessages, true)) + .AsError(ctx)); + return; + } + + var total = fetchedMessages.Count; + var deleted = 0; + + List deletionOperations = new(); + + try + { + while (fetchedMessages.Count != 0) + { + var currentDeletion = fetchedMessages.Take(100); + + deletionOperations.Add(ctx.Channel.DeleteMessagesAsync(currentDeletion).ContinueWith(task => + { + if (task.IsCompletedSuccessfully) + deleted += currentDeletion.Count(); + else + failedDeletion += currentDeletion.Count(); + })); + + foreach (var b in currentDeletion.ToList()) + _ = fetchedMessages.Remove(b); + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to delete messages"); + throw; + } + + while (!deletionOperations.All(x => x.IsCompleted)) + { + _ = await this.RespondOrEdit($"`{StringTools.GenerateASCIIProgressbar(deleted, total)} {MathTools.CalculatePercentage(deleted, total),3}%`"); + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder(). + WithDescription($"{this.GetString(CommandKey.Deleted, true, new TVar("Count", deleted))}\n{this.GetString(CommandKey.Failed, true, new TVar("Count", FailedToDeleteAmount))}") + .AsSuccess(ctx)); + return; + } + else + { + var bMessages = (await ctx.Channel.GetMessagesAsync(number)).ToList(); + + if (victim is not null) + { + foreach (var b in bMessages.Where(x => x.Author.Id != victim.Id).ToList()) + { + _ = bMessages.Remove(b); + } + } + + foreach (var b in bMessages.Where(x => x.CreationTimestamp < DateTime.UtcNow.AddDays(-14)).ToList()) + { + _ = bMessages.Remove(b); + FailedToDeleteAmount++; + } + + if (bMessages.Count > 0) + await ctx.Channel.DeleteMessagesAsync(bMessages); + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder(). + WithDescription($"{this.GetString(CommandKey.Deleted, true, new TVar("Count", bMessages.Count))}\n{this.GetString(CommandKey.Failed, true, new TVar("Count", FailedToDeleteAmount))}") + .AsSuccess(ctx)); + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Moderation/RemoveTimeoutCommand.cs b/ProjectMakoto/Commands/Moderation/RemoveTimeoutCommand.cs new file mode 100644 index 00000000..0670b6d0 --- /dev/null +++ b/ProjectMakoto/Commands/Moderation/RemoveTimeoutCommand.cs @@ -0,0 +1,57 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class RemoveTimeoutCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckPermissions(Permissions.ModerateMembers) && await this.CheckOwnPermissions(Permissions.ModerateMembers)); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + DiscordMember victim; + + try + { + victim = await ((DiscordUser)arguments["user"]).ConvertToMember(ctx.Guild); + } + catch (DisCatSharp.Exceptions.NotFoundException) + { + this.SendNoMemberError(); + throw; + } + catch (Exception) + { + throw; + } + + var CommandKey = this.t.Commands.Moderation.RemoveTimeout; + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Removing, true, new TVar("Victim", victim.Mention))) + .AsLoading(ctx)); + + try + { + await victim.RemoveTimeoutAsync(); + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Removed, true, new TVar("Victim", victim.Mention))) + .AsSuccess(ctx)); + } + catch (Exception) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Failed, true, new TVar("Victim", victim.Mention))) + .AsError(ctx)); + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Moderation/SoftBanCommand.cs b/ProjectMakoto/Commands/Moderation/SoftBanCommand.cs new file mode 100644 index 00000000..fed732ae --- /dev/null +++ b/ProjectMakoto/Commands/Moderation/SoftBanCommand.cs @@ -0,0 +1,59 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class SoftBanCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckPermissions(Permissions.BanMembers) && await this.CheckOwnPermissions(Permissions.BanMembers)); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var victim = (DiscordUser)arguments["user"]; + var deleteMessageDays = (int)arguments["days"] > 7 ? 7 : ((int)arguments["days"] < 0 ? 0 : (int)arguments["days"]); + var reason = (string)arguments["reason"]; + + var CommandKey = this.t.Commands.Moderation.Softban; + + DiscordMember bMember = null; + + try + { + bMember = await victim.ConvertToMember(ctx.Guild); + } + catch { } + + var embed = new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Banning, true, new TVar("Victim", victim.Mention))) + .WithThumbnail(victim.AvatarUrl) + .AsLoading(ctx); + _ = await this.RespondOrEdit(embed); + + try + { + if (ctx.Member.GetRoleHighestPosition() <= bMember.GetRoleHighestPosition()) + throw new Exception(); + + var newReason = (reason.IsNullOrWhiteSpace() ? this.GetGuildString(this.t.Commands.Moderation.NoReason) : reason); + await ctx.Guild.BanMemberAsync(victim.Id, deleteMessageDays, this.GetGuildString(CommandKey.AuditLog, new TVar("Reason", newReason))); + await ctx.Guild.UnbanMemberAsync(victim, this.GetGuildString(CommandKey.AuditLog, new TVar("Reason", newReason))); + + embed = embed.WithDescription(this.GetString(CommandKey.Banned, true, new TVar("Victim", victim.Mention), new TVar("Reason", newReason))).AsSuccess(ctx); + } + catch (Exception) + { + embed = embed.WithDescription(this.GetString(CommandKey.Errored, true, new TVar("Victim", victim.Mention))).AsError(ctx); + } + + _ = await this.RespondOrEdit(embed); + }); + } +} diff --git a/ProjectMakoto/Commands/Moderation/TimeoutCommand.cs b/ProjectMakoto/Commands/Moderation/TimeoutCommand.cs new file mode 100644 index 00000000..19ced611 --- /dev/null +++ b/ProjectMakoto/Commands/Moderation/TimeoutCommand.cs @@ -0,0 +1,98 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class TimeoutCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckPermissions(Permissions.ModerateMembers) && await this.CheckOwnPermissions(Permissions.ModerateMembers)); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + DiscordMember victim; + var duration = (string)arguments["duration"]; + var reason = (string)arguments["reason"]; + + try + { + victim = await ((DiscordUser)arguments["user"]).ConvertToMember(ctx.Guild); + } + catch (DisCatSharp.Exceptions.NotFoundException) + { + this.SendNoMemberError(); + throw; + } + catch (Exception) + { + throw; + } + + var CommandKey = this.t.Commands.Moderation.Timeout; + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.TimingOut, true, new TVar("Victim", victim.Mention))) + .AsLoading(ctx)); + + if (string.IsNullOrWhiteSpace(duration)) + duration = "30m"; + + if (!DateTime.TryParse(duration, out var until)) + { + try + { + until = duration[^1..] switch + { + "Y" => DateTime.UtcNow.AddYears(Convert.ToInt32(duration.Replace("Y", ""))), + "M" => DateTime.UtcNow.AddMonths(Convert.ToInt32(duration.Replace("M", ""))), + "d" => DateTime.UtcNow.AddDays(Convert.ToInt32(duration.Replace("d", ""))), + "h" => DateTime.UtcNow.AddHours(Convert.ToInt32(duration.Replace("h", ""))), + "m" => DateTime.UtcNow.AddMinutes(Convert.ToInt32(duration.Replace("m", ""))), + "s" => DateTime.UtcNow.AddSeconds(Convert.ToInt32(duration.Replace("s", ""))), + _ => DateTime.UtcNow.AddMinutes(Convert.ToInt32(duration)), + }; + } + catch (Exception) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Invalid, true)) + .AsError(ctx)); + return; + } + } + + if (DateTime.UtcNow > until || DateTime.UtcNow.AddDays(28) < until) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Invalid, true)) + .AsError(ctx)); + return; + } + + try + { + if (ctx.Member.GetRoleHighestPosition() <= victim.GetRoleHighestPosition()) + throw new Exception(); + + await victim.TimeoutAsync(until, this.GetGuildString(CommandKey.AuditLog, new TVar("Reason", (reason.IsNullOrWhiteSpace() ? "No reason provided." : reason)))); + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.TimedOut, true, new TVar("Victim", victim.Mention), new TVar("Timestamp", until.ToTimestamp()), new TVar("Reason", reason.IsNullOrWhiteSpace() ? "No reason provided" : reason))) + .AsSuccess(ctx)); + } + catch (Exception) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Failed, true, new TVar("Victim", victim.Mention))) + .AsError(ctx)); + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Moderation/UnbanCommand.cs b/ProjectMakoto/Commands/Moderation/UnbanCommand.cs new file mode 100644 index 00000000..d0e6565f --- /dev/null +++ b/ProjectMakoto/Commands/Moderation/UnbanCommand.cs @@ -0,0 +1,44 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class UnbanCommand : BaseCommand +{ + public override async Task BeforeExecution(SharedCommandContext ctx) => (await this.CheckPermissions(Permissions.BanMembers) && await this.CheckOwnPermissions(Permissions.BanMembers)); + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var victim = (DiscordUser)arguments["user"]; + + var CommandKey = this.t.Commands.Moderation.Unban; + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Removing, true, new TVar("Victim", victim.Mention))) + .AsLoading(ctx)); + + try + { + await ctx.Guild.UnbanMemberAsync(victim); + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Removed, true, new TVar("Victim", victim.Mention))) + .AsSuccess(ctx)); + } + catch (Exception) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.Failed, true, new TVar("Victim", victim.Mention))) + .AsError(ctx)); + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/AvatarCommand.cs b/ProjectMakoto/Commands/Utility/AvatarCommand.cs new file mode 100644 index 00000000..84d78a28 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/AvatarCommand.cs @@ -0,0 +1,92 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; +internal sealed class AvatarCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var victim = (DiscordUser)arguments["user"]; + + if (await ctx.DbUser.Cooldown.WaitForModerate(ctx)) + return; + + victim ??= ctx.User; + + victim = await victim.GetFromApiAsync(); + + var embed = new DiscordEmbedBuilder + { + ImageUrl = victim.AvatarUrl, + }.AsInfo(ctx, this.GetString(this.t.Commands.Utility.Avatar.Avatar, false, new TVar("User", victim.GetUsernameWithIdentifier()))); + + DiscordMember member = null; + + try + { member = await victim.ConvertToMember(ctx.Guild); } + catch { } + + var ServerProfilePictureButton = new DiscordButtonComponent(ButtonStyle.Secondary, "ShowServer", this.GetString(this.t.Commands.Utility.Avatar.ShowServerProfile), (string.IsNullOrWhiteSpace(member?.GuildAvatarHash)), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🖥"))); + var ProfilePictureButton = new DiscordButtonComponent(ButtonStyle.Secondary, "ShowProfile", this.GetString(this.t.Commands.Utility.Avatar.ShowUserProfile), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👤"))); + + var builder = new DiscordMessageBuilder().WithEmbed(embed).AddComponents(ServerProfilePictureButton); + + var msg = await this.RespondOrEdit(builder); + + CancellationTokenSource cancellationTokenSource = new(); + + ctx.Client.ComponentInteractionCreated += RunInteraction; + + _ = Task.Delay(60000, cancellationTokenSource.Token).ContinueWith(x => + { + if (x.IsCompletedSuccessfully) + { + ctx.Client.ComponentInteractionCreated -= RunInteraction; + this.ModifyToTimedOut(true); + } + }); + + async Task RunInteraction(DiscordClient s, ComponentInteractionCreateEventArgs e) + { + _ = Task.Run(async () => + { + if (e.Message?.Id == msg.Id && e.User.Id == ctx.User.Id) + { + cancellationTokenSource.Cancel(); + cancellationTokenSource = new(); + + _ = Task.Delay(60000, cancellationTokenSource.Token).ContinueWith(x => + { + if (x.IsCompletedSuccessfully) + { + ctx.Client.ComponentInteractionCreated -= RunInteraction; + this.ModifyToTimedOut(true); + } + }); + + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == ServerProfilePictureButton.CustomId) + { + embed.ImageUrl = member.GuildAvatarUrl; + _ = this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed).AddComponents(ProfilePictureButton)); + } + else if (e.GetCustomId() == ProfilePictureButton.CustomId) + { + embed.ImageUrl = member.AvatarUrl; + _ = this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed).AddComponents(ServerProfilePictureButton)); + } + } + }).Add(ctx.Bot, ctx); + } + }); + } +} diff --git a/ProjectMakoto/Commands/Utility/BannerCommand.cs b/ProjectMakoto/Commands/Utility/BannerCommand.cs new file mode 100644 index 00000000..3d0609a0 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/BannerCommand.cs @@ -0,0 +1,37 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; +internal sealed class BannerCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var victim = (DiscordUser)arguments["user"]; + + if (await ctx.DbUser.Cooldown.WaitForModerate(ctx)) + return; + + victim ??= ctx.User; + + victim = await victim.GetFromApiAsync(); + + var embed = new DiscordEmbedBuilder + { + ImageUrl = victim.BannerUrl, + Description = victim.BannerUrl.IsNullOrWhiteSpace() ? this.GetString(this.t.Commands.Utility.Banner.NoBanner, true) : "" + }.AsInfo(ctx, this.GetString(this.t.Commands.Utility.Banner.Banner, false, new TVar("User", victim.GetUsernameWithIdentifier()))); + + var builder = new DiscordMessageBuilder().WithEmbed(embed); + + _ = await this.RespondOrEdit(builder); + }); + } +} diff --git a/ProjectMakoto/Commands/Utility/CreditsCommand.cs b/ProjectMakoto/Commands/Utility/CreditsCommand.cs new file mode 100644 index 00000000..d43d504d --- /dev/null +++ b/ProjectMakoto/Commands/Utility/CreditsCommand.cs @@ -0,0 +1,48 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class CreditsCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx, true)) + return; + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Credits.Fetching, true) + }.AsLoading(ctx)); + + var contributors = await ctx.Bot.GithubClient.Repository.GetAllContributors(ctx.Bot.status.LoadedConfig.Secrets.Github.Username, ctx.Bot.status.LoadedConfig.Secrets.Github.Repository); + var contributorsdcs = await ctx.Bot.GithubClient.Repository.GetAllContributors("Aiko-IT-Systems", "DisCatSharp"); + + List userlist = new(); + + foreach (var b in ctx.Bot.status.TeamMembers.Reverse()) + userlist.Add(await ctx.Client.GetUserAsync(b)); + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Credits.Credits, false, false, + new TVar("BotName", ctx.CurrentUser.GetUsername(), false), + new TVar("Developer", "<@411950662662881290> ([`TheXorog`](https://github.com/TheXorog))", false), + new TVar("DiscordStaffList", string.Join(", ", userlist.Select(x => $"{x.Mention} [`{x.GetUsernameWithIdentifier()}`]({x.ProfileUrl})")), false), + new TVar("GitHubContList", string.Join("\n", contributors.Where(x => !x.Login.Contains("[bot]") && x.Login != "TheXorog").OrderByDescending(x => x.Contributions).Select(x => $"• [`{x.Login}`]({x.HtmlUrl})")), false), + new TVar("Library", "[`DisCatSharp`](https://github.com/Aiko-IT-Systems/DisCatSharp)", false), + new TVar("LibraryContList", string.Join(", ", contributorsdcs.Take(10).Where(x => !x.Login.Contains("[bot]")).OrderByDescending(x => x.Contributions).Select(x => $"[`{x.Login}`]({x.HtmlUrl})")), false), + new TVar("LibraryContCount", $"[{contributorsdcs.Count - 10}](https://github.com/Aiko-IT-Systems/DisCatSharp/graphs/contributors)", false), + new TVar("PhishingListRepos", $"[`nikolaischunk`](https://github.com/nikolaischunk), [`DevSpen`](https://github.com/DevSpen), [`PoorPocketsMcNewHold`](https://github.com/PoorPocketsMcNewHold), [`sk-cat`](https://github.com/sk-cat) & [`Junortiz`](https://github.com/Junortiz)", false)) + }.AsInfo(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/Data/DeleteCommand.cs b/ProjectMakoto/Commands/Utility/Data/DeleteCommand.cs new file mode 100644 index 00000000..b06b9b94 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/Data/DeleteCommand.cs @@ -0,0 +1,166 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Data; + +internal sealed class DeleteCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx, true)) + return; + + var Yes = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(this.t.Common.Yes), false, new DiscordComponentEmoji(true.ToEmote(ctx.Bot))); + var No = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), this.GetString(this.t.Common.No), false, new DiscordComponentEmoji(false.ToEmote(ctx.Bot))); + + if (ctx.Bot.objectedUsers.Contains(ctx.User.Id)) + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Data.Object.ProfileAlreadyDeleted, true) + }.AsAwaitingInput(ctx)).AddComponents(new List { Yes, No })); + + var Menu1 = await ctx.WaitForButtonAsync(); + + if (Menu1.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + _ = Menu1.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (Menu1.GetCustomId() == Yes.CustomId) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Data.Object.EnablingDataProcessing, true) + }.AsLoading(ctx)); + + try + { + _ = ctx.Bot.objectedUsers.Remove(ctx.User.Id); + } + catch (Exception ex) + { + Log.Error(ex, "An exception occurred while trying to remove a user from the objection list"); + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Data.Object.EnablingDataProcessingError, true) + }.AsError(ctx)); + return; + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Data.Object.EnablingDataProcessingSuccess, true) + }.AsSuccess(ctx)); + } + else + { + this.DeleteOrInvalidate(); + } + + return; + } + + if (ctx.DbUser.Data.DeletionRequested) + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Data.Object.DeletionAlreadyScheduled, true, + new TVar("RequestTimestamp", ctx.DbUser.Data.DeletionRequestDate.AddDays(-14).ToTimestamp()), + new TVar("ScheduleTimestamp", ctx.DbUser.Data.DeletionRequestDate.ToTimestamp())) + }.AsAwaitingInput(ctx)).AddComponents(new List { Yes, No })); + + var Menu1 = await ctx.WaitForButtonAsync(); + + if (Menu1.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + _ = Menu1.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (Menu1.GetCustomId() == Yes.CustomId) + { + ctx.DbUser.Data.DeletionRequested = false; + ctx.DbUser.Data.DeletionRequestDate = DateTime.MinValue; + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Data.Object.DeletionScheduleReversed, true) + }.AsSuccess(ctx)); + } + else + { + this.DeleteOrInvalidate(); + } + + return; + } + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Data.Object.ObjectionDisclaimer, true, true) + }.AsAwaitingInput(ctx)).AddComponents(new List { Yes, No })); + + var Menu = await ctx.WaitForButtonAsync(); + + if (Menu.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + _ = Menu.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (Menu.GetCustomId() == Yes.CustomId) + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Description = $"**{this.GetString(this.t.Commands.Utility.Data.Object.SecondaryConfirm, true)}**" + }.AsAwaitingInput(ctx)).AddComponents(new List { No, Yes })); + + Menu = await ctx.WaitForButtonAsync(); + + if (Menu.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + _ = Menu.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (Menu.GetCustomId() == Yes.CustomId) + { + ctx.DbUser.Data.DeletionRequestDate = DateTime.UtcNow.AddDays(14); + ctx.DbUser.Data.DeletionRequested = true; + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Data.Object.ProfileDeletionScheduled, true) + }.AsSuccess(ctx)); + } + else + { + this.DeleteOrInvalidate(); + } + } + else + { + this.DeleteOrInvalidate(); + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/Data/InfoCommand.cs b/ProjectMakoto/Commands/Utility/Data/InfoCommand.cs new file mode 100644 index 00000000..868c0899 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/Data/InfoCommand.cs @@ -0,0 +1,65 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Data; + +internal sealed class InfoCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx, true)) + return; + + if (ctx.Bot.RawFetchedPrivacyPolicy.IsNullOrWhiteSpace()) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Data.Policy.NoPolicy, true, new TVar("Bot", ctx.CurrentUser.GetUsername())), + }.AsError(ctx)); + return; + } + + var RawPolicy = ctx.Bot.RawFetchedPrivacyPolicy.Replace("#", ""); + + var PolicyStrings = RawPolicy.ReplaceLineEndings("\n").Split("\n\n").ToList(); + + var Title = ""; + List embeds = new(); + + for (var i = 0; i < PolicyStrings.Count; i++) + { + if (i == 0) + { + Title = PolicyStrings[i]; + continue; + } + + embeds.Add(new DiscordEmbedBuilder + { + Title = (i == 1 ? Title : ""), + Description = PolicyStrings[i] + }); + } + + try + { + foreach (var b in embeds) + _ = await ctx.User.SendMessageAsync(b); + + this.SendDmRedirect(); + } + catch (DisCatSharp.Exceptions.UnauthorizedException) + { + this.SendDmError(); + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/Data/RequestCommand.cs b/ProjectMakoto/Commands/Utility/Data/RequestCommand.cs new file mode 100644 index 00000000..ffe38945 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/Data/RequestCommand.cs @@ -0,0 +1,86 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.Data; + +internal sealed class RequestCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx, true)) + return; + + if (ctx.DbUser.Data.LastDataRequest.GetTimespanSince() < TimeSpan.FromDays(14)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Data.Request.TimeError, true, + new TVar("RequestTimestamp", ctx.DbUser.Data.LastDataRequest.ToTimestamp(TimestampFormat.ShortDateTime)), + new TVar("WaitTimestamp", ctx.DbUser.Data.LastDataRequest.AddDays(14).ToTimestamp(TimestampFormat.ShortDateTime))) + }.AsError(ctx)); + return; + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Data.Request.Fetching, true) + }.AsLoading(ctx)); + + RequestData requestData = new(); + + if (ctx.Bot.Users.ContainsKey(ctx.User.Id)) + { + requestData.User = ctx.DbUser; + } + + foreach (var guild in ctx.Bot.Guilds) + { + if (guild.Value.Members.TryGetValue(ctx.User.Id, out var member)) + { + requestData.GuildData.Add(guild.Key, member); + } + } + + Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(requestData, Formatting.Indented))); + + switch (ctx.CommandType) + { + case Enums.CommandType.ApplicationCommand: + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Data.Request.Confirm, true) + }.AsSuccess(ctx)).WithFile("userdata.json", stream)); + ctx.DbUser.Data.LastDataRequest = DateTime.UtcNow; + break; + } + default: + { + try + { + _ = await ctx.User.SendMessageAsync(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Data.Request.Confirm, true) + }.AsSuccess(ctx)).WithFile("userdata.json", stream)); + ctx.DbUser.Data.LastDataRequest = DateTime.UtcNow; + + this.SendDmRedirect(); + } + catch (DisCatSharp.Exceptions.UnauthorizedException) + { + this.SendDmError(); + } + break; + } + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/EmojiStealerCommand.cs b/ProjectMakoto/Commands/Utility/EmojiStealerCommand.cs new file mode 100644 index 00000000..f349f12e --- /dev/null +++ b/ProjectMakoto/Commands/Utility/EmojiStealerCommand.cs @@ -0,0 +1,530 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class EmojiStealerCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + DiscordMessage bMessage; + + if (arguments?.ContainsKey("message") ?? false) + { + bMessage = (DiscordMessage)arguments["message"]; + } + else + { + switch (ctx.CommandType) + { + case Enums.CommandType.PrefixCommand: + { + if (ctx.OriginalCommandContext.Message.ReferencedMessage is not null) + { + bMessage = ctx.OriginalCommandContext.Message.ReferencedMessage; + } + else + { + this.SendSyntaxError(); + return; + } + + break; + } + default: + throw new ArgumentException("Message expected"); + } + } + + if (await ctx.DbUser.Cooldown.WaitForModerate(ctx)) + return; + + HttpClient client = new(); + + var embed = new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.EmojiStealer.DownloadingPre, true), + }.AsLoading(ctx); + _ = await this.RespondOrEdit(embed); + + Dictionary SanitizedEmoteList = new(); + MemoryStream zipFileStream = new(); + var FinishedInteraction = false; + + var Emotes = bMessage.Content.GetEmotes(); + + foreach (var b in Emotes) + SanitizedEmoteList.Add(b.Item1, new EmojiEntry + { + Name = b.Item2, + Animated = b.Item3, + EntryType = EmojiType.EMOJI + }); + + if (Emotes.Count == 0 && (bMessage.Stickers is null || bMessage.Stickers.Count == 0)) + { + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.NoEmojis, true); + _ = await this.RespondOrEdit(embed.AsError(ctx)); + return; + } + + var guid = Guid.NewGuid().ToString().MakeValidFileName(); + + try + { + if (SanitizedEmoteList.Count > 0) + { + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.DownloadingEmojis, true, new TVar("Count", SanitizedEmoteList.Count)); + _ = await this.RespondOrEdit(embed); + + foreach (var b in SanitizedEmoteList.ToList()) + { + try + { + var EmoteStream = await client.GetStreamAsync($"https://cdn.discordapp.com/emojis/{b.Key}.{(b.Value.Animated ? "gif" : "png")}"); + + var NameExists = ""; + var NameExistsInt = 1; + + var Name = $"{b.Value.Name}{NameExists}.{(b.Value.Animated ? "gif" : "png")}".MakeValidFileName('_'); + + while (SanitizedEmoteList.Any(x => x.Value.Data.Name == Name)) + { + NameExistsInt++; + NameExists = $" ({NameExistsInt})"; + + Name = $"{b.Value.Name}{NameExists}.{(b.Value.Animated ? "gif" : "png")}".MakeValidFileName('_'); + } + + b.Value.Data.Name = Name; + EmoteStream.CopyTo(b.Value.Data.Stream); + b.Value.Data.Stream.Position = 0; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to download an emote"); + + _ = SanitizedEmoteList.Remove(b.Key); + } + } + } + + if (bMessage.Stickers.Count > 0) + { + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.DownloadingStickers, true, new TVar("Count", bMessage.Stickers.GroupBy(x => x.Url).Select(x => x.First()).Count())); + _ = await this.RespondOrEdit(embed); + + foreach (var b in bMessage.Stickers.GroupBy(x => x.Url).Select(x => x.First())) + { + var newEntry = new EmojiEntry + { + Animated = false, + Name = b.Name, + Description = b.Description, + Emoji = "🤖".UnicodeToEmoji(), + StickerFormat = b.FormatType, + EntryType = EmojiType.STICKER + }; + + try + { + var StickerStream = await client.GetStreamAsync(b.Url); + + var NameExists = ""; + var NameExistsInt = 1; + + var Name = $"{b.Name}{NameExists}.png".MakeValidFileName('_'); + + while (SanitizedEmoteList.Any(x => x.Value.Data.Name == Name)) + { + NameExistsInt++; + NameExists = $" ({NameExistsInt})"; + + Name = $"{newEntry.Name}{NameExists}.png".MakeValidFileName('_'); + } + + newEntry.Data.Name = Name; + StickerStream.CopyTo(newEntry.Data.Stream); + newEntry.Data.Stream.Position = 0; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to download an emote"); + + _ = SanitizedEmoteList.Remove(b.Id); + } + + SanitizedEmoteList.Add(b.Id, newEntry); + } + } + + if (SanitizedEmoteList.Count == 0) + { + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.NoSuccessfulDownload, true); + _ = await this.RespondOrEdit(embed.AsError(ctx)); + + return; + } + + var emojiText = ""; + + if (SanitizedEmoteList.Any(x => x.Value.EntryType == EmojiType.EMOJI)) + emojiText += this.GetString(this.t.Commands.Utility.EmojiStealer.Emoji); + + if (SanitizedEmoteList.Any(x => x.Value.EntryType == EmojiType.STICKER)) + emojiText += $"{(emojiText.Length > 0 ? $" & {this.GetString(this.t.Commands.Utility.EmojiStealer.Sticker)}" : this.GetString(this.t.Commands.Utility.EmojiStealer.Sticker))}"; + + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.ReceivePrompt, true, new TVar("Type", emojiText)); + _ = embed.AsAwaitingInput(ctx); + + var IncludeStickers = false; + + if (!SanitizedEmoteList.Any(x => x.Value.EntryType == EmojiType.EMOJI)) + IncludeStickers = true; + + var IncludeStickersButton = new DiscordButtonComponent((IncludeStickers ? ButtonStyle.Success : ButtonStyle.Danger), "ToggleStickers", this.GetString(this.t.Commands.Utility.EmojiStealer.ToggleStickers), !SanitizedEmoteList.Any(x => x.Value.EntryType == EmojiType.EMOJI), new DiscordComponentEmoji(DiscordEmoji.FromGuildEmote(ctx.Client, (ulong)(IncludeStickers ? 970278964755038248 : 970278964079767574)))); + + var AddToServerButton = new DiscordButtonComponent(ButtonStyle.Success, "AddToServer", this.GetString(this.t.Commands.Utility.EmojiStealer.AddEmojisToServer), !ctx.Member.Permissions.HasPermission(Permissions.ManageGuildExpressions), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("➕"))); + var ZipPrivateMessageButton = new DiscordButtonComponent(ButtonStyle.Primary, "ZipPrivateMessage", this.GetString(this.t.Commands.Utility.EmojiStealer.DirectMessageZip), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🖥"))); + var SinglePrivateMessageButton = new DiscordButtonComponent(ButtonStyle.Primary, "SinglePrivateMessage", this.GetString(this.t.Commands.Utility.EmojiStealer.DirectMessageSingle), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("📱"))); + + var SendHereButton = new DiscordButtonComponent(ButtonStyle.Secondary, "SendHere", this.GetString(this.t.Commands.Utility.EmojiStealer.CurrentChatZip), !(ctx.Member.Permissions.HasPermission(Permissions.AttachFiles)), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("💬"))); + + var builder = new DiscordMessageBuilder().WithEmbed(embed); + + if (SanitizedEmoteList.Any(x => x.Value.EntryType == EmojiType.STICKER)) + _ = builder.AddComponents(IncludeStickersButton); + + _ = builder.AddComponents(new List { AddToServerButton, ZipPrivateMessageButton, SinglePrivateMessageButton, SendHereButton }); + + _ = await this.RespondOrEdit(builder); + + CancellationTokenSource cancellationTokenSource = new(); + + ctx.Client.ComponentInteractionCreated += RunInteraction; + + _ = Task.Delay(60000, cancellationTokenSource.Token).ContinueWith(x => + { + if (x.IsCompletedSuccessfully) + { + ctx.Client.ComponentInteractionCreated -= RunInteraction; + FinishedInteraction = true; + + this.ModifyToTimedOut(); + } + }); + + async Task RunInteraction(DiscordClient s, ComponentInteractionCreateEventArgs e) + { + _ = Task.Run(async () => + { + try + { + if (e.Message?.Id == ctx.ResponseMessage.Id && e.User.Id == ctx.User.Id) + { + cancellationTokenSource.Cancel(); + cancellationTokenSource = new(); + + _ = Task.Delay(60000, cancellationTokenSource.Token).ContinueWith(x => + { + if (x.IsCompletedSuccessfully) + { + ctx.Client.ComponentInteractionCreated -= RunInteraction; + + this.ModifyToTimedOut(); + } + }); + + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == AddToServerButton.CustomId) + { + ctx.Client.ComponentInteractionCreated -= RunInteraction; + cancellationTokenSource.Cancel(); + + if (!ctx.Member.Permissions.HasPermission(Permissions.ManageGuildExpressions)) + { + this.SendPermissionError(Permissions.ManageGuildExpressions); + return; + } + + if (!ctx.CurrentMember.Permissions.HasPermission(Permissions.ManageGuildExpressions)) + { + this.SendOwnPermissionError(Permissions.ManageGuildExpressions); + return; + } + + var DiscordWarning = false; + + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.AddEmojisToServerLoading, true, + new TVar("Min", 0), + new TVar("Max", (IncludeStickers ? SanitizedEmoteList.Count : SanitizedEmoteList.Where(x => x.Value.EntryType == EmojiType.EMOJI).Count()))); + _ = embed.AsLoading(ctx); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed)); + + for (var i = 0; i < SanitizedEmoteList.Count; i++) + { + try + { + Task task; + + switch (SanitizedEmoteList.ElementAt(i).Value.EntryType) + { + case EmojiType.STICKER: + { + var sticker = SanitizedEmoteList.ElementAt(i).Value; + + task = ctx.Guild.CreateStickerAsync(sticker.Name, sticker.Description ?? sticker.Name, sticker.Emoji, sticker.Data.Stream, sticker.StickerFormat); + + var WaitSeconds = 0; + + while (task.Status == TaskStatus.WaitingForActivation) + { + WaitSeconds++; + + if (WaitSeconds > 10 && !DiscordWarning) + { + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.AddStickersToServerLoading, true, + new TVar("Min", 0), + new TVar("Max", (IncludeStickers ? SanitizedEmoteList.Count : SanitizedEmoteList.Where(x => x.Value.EntryType == EmojiType.EMOJI).Count()))) + + $"\n{this.GetString(this.t.Commands.Utility.EmojiStealer.AddToServerLoadingNotice)}"; + _ = await this.RespondOrEdit(embed); + + DiscordWarning = true; + } + await Task.Delay(1000); + } + + if (task.IsFaulted) + throw task.Exception.InnerException; + break; + } + case EmojiType.EMOJI: + { + var emoji = SanitizedEmoteList.ElementAt(i); + + task = ctx.Guild.CreateEmojiAsync(SanitizedEmoteList.ElementAt(i).Value.Name, emoji.Value.Data.Stream); + + var WaitSeconds = 0; + + while (task.Status == TaskStatus.WaitingForActivation) + { + WaitSeconds++; + + if (WaitSeconds > 10 && !DiscordWarning) + { + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.AddEmojisToServerLoading, true, + new TVar("Min", 0), + new TVar("Max", (IncludeStickers ? SanitizedEmoteList.Count : SanitizedEmoteList.Where(x => x.Value.EntryType == EmojiType.EMOJI).Count()))) + + $"\n{this.GetString(this.t.Commands.Utility.EmojiStealer.AddToServerLoadingNotice)}"; + _ = await this.RespondOrEdit(embed); + + DiscordWarning = true; + } + await Task.Delay(1000); + } + + if (task.IsFaulted) + throw task.Exception.InnerException; + break; + } + default: + throw new NotImplementedException(); + } + } + catch (DisCatSharp.Exceptions.BadRequestException ex) + { + var regex = Regex.Match(ex.WebResponse.Response.Replace("\\", ""), "((\"code\": )(\\d*))"); + + if (regex.Groups[3].Value == "30008") + { + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.NoMoreRoom, true, new TVar("Count", i)); + _ = embed.AsError(ctx); + _ = await this.RespondOrEdit(embed); + return; + } + else + throw; + } + } + + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.SuccessAdded, true, + new TVar("Count", (IncludeStickers ? SanitizedEmoteList.Count : SanitizedEmoteList.Where(x => x.Value.EntryType == EmojiType.EMOJI).Count()))); + _ = embed.AsSuccess(ctx); + _ = await this.RespondOrEdit(embed); + return; + } + else if (e.GetCustomId() == SinglePrivateMessageButton.CustomId) + { + ctx.Client.ComponentInteractionCreated -= RunInteraction; + cancellationTokenSource.Cancel(); + + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.SendingDm, true, new TVar("Type", emojiText)); + _ = embed.AsLoading(ctx); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed)); + + try + { + var totalCount = IncludeStickers ? SanitizedEmoteList.Count : SanitizedEmoteList.Where(x => x.Value.EntryType == EmojiType.EMOJI).Count(); + + for (var i = 0; i < SanitizedEmoteList.Count; i++) + { + if (!IncludeStickers) + if (SanitizedEmoteList.ElementAt(i).Value.EntryType != EmojiType.EMOJI) + continue; + + var current = SanitizedEmoteList.ElementAt(i); + _ = current.Value.Data.Stream.Seek(0, SeekOrigin.Begin); + + var currentFilename = $"{current.Value.Name}.{(current.Value.Animated == true ? "gif" : "png")}"; + + _ = await ctx.User.SendMessageAsync(new DiscordMessageBuilder() + .WithContent($"`{i + 1}/{totalCount}` `{currentFilename}`") + .WithFile($"{currentFilename}", current.Value.Data.Stream)); + await Task.Delay(1000); + } + + _ = await ctx.User.SendMessageAsync(new DiscordMessageBuilder().WithContent(this.GetString(this.t.Commands.Utility.EmojiStealer.SuccessDm, new TVar("Type", emojiText)))); + } + catch (DisCatSharp.Exceptions.UnauthorizedException) + { + this.SendDmError(); + return; + } + catch (Exception) + { + throw; + } + + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.SuccessDmMain, true, + new TVar("Count", (IncludeStickers ? SanitizedEmoteList.Count : SanitizedEmoteList.Where(x => x.Value.EntryType == EmojiType.EMOJI).Count())), + new TVar("Type", emojiText)); + _ = await this.RespondOrEdit(embed.AsSuccess(ctx)); + return; + } + else if (e.GetCustomId() == ZipPrivateMessageButton.CustomId || e.GetCustomId() == SendHereButton.CustomId) + { + ctx.Client.ComponentInteractionCreated -= RunInteraction; + cancellationTokenSource.Cancel(); + + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.PreparingZip, true); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed.AsLoading(ctx))); + + using (var archive = new ZipArchive(zipFileStream, ZipArchiveMode.Create, true)) + { + for (var i = 0; i < SanitizedEmoteList.Count; i++) + { + if (!IncludeStickers) + if (SanitizedEmoteList.ElementAt(i).Value.EntryType != EmojiType.EMOJI) + continue; + + var current = SanitizedEmoteList.ElementAt(i); + var newEntry = archive.CreateEntry(current.Value.Data.Name); + using (var entryStream = newEntry.Open()) + await current.Value.Data.Stream.CopyToAsync(entryStream); + } + } + + _ = zipFileStream.Seek(0, SeekOrigin.Begin); + + if (e.GetCustomId() == ZipPrivateMessageButton.CustomId) + { + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.SendingZipDm, true); + _ = await this.RespondOrEdit(embed); + + try + { + _ = zipFileStream.Seek(0, SeekOrigin.Begin); + _ = await ctx.User.SendMessageAsync(new DiscordMessageBuilder().WithFile($"Emojis.zip", zipFileStream).WithContent(this.GetString(this.t.Commands.Utility.EmojiStealer.SuccessDm, new TVar("Type", emojiText)))); + } + catch (DisCatSharp.Exceptions.UnauthorizedException) + { + this.SendDmError(); + return; + } + catch (Exception) + { + throw; + } + + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.SuccessDmMain, true, + new TVar("Count", (IncludeStickers ? SanitizedEmoteList.Count : SanitizedEmoteList.Where(x => x.Value.EntryType == EmojiType.EMOJI).Count())), + new TVar("Type", emojiText)); + _ = await this.RespondOrEdit(embed.AsSuccess(ctx)); + } + else if (e.GetCustomId() == SendHereButton.CustomId) + { + if (!ctx.Member.Permissions.HasPermission(Permissions.AttachFiles)) + { + this.SendPermissionError(Permissions.AttachFiles); + return; + } + + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.SendingZipChat, true); + _ = await this.RespondOrEdit(embed); + + embed.Description = this.GetString(this.t.Commands.Utility.EmojiStealer.SuccessChat, true, + new TVar("Count", (IncludeStickers ? SanitizedEmoteList.Count : SanitizedEmoteList.Where(x => x.Value.EntryType == EmojiType.EMOJI).Count())), + new TVar("Type", emojiText)); + + _ = zipFileStream.Seek(0, SeekOrigin.Begin); + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithFile($"Emotes.zip", zipFileStream).WithEmbed(embed.AsSuccess(ctx))); + } + return; + } + else if (e.GetCustomId() == IncludeStickersButton.CustomId) + { + IncludeStickers = !IncludeStickers; + + if (!IncludeStickers) + { + if (!SanitizedEmoteList.Any(x => x.Value.EntryType == EmojiType.EMOJI)) + IncludeStickers = true; + } + + IncludeStickersButton = new DiscordButtonComponent((IncludeStickers ? ButtonStyle.Success : ButtonStyle.Danger), "ToggleStickers", this.GetString(this.t.Commands.Utility.EmojiStealer.ToggleStickers), !SanitizedEmoteList.Any(x => x.Value.EntryType == EmojiType.EMOJI), new DiscordComponentEmoji(DiscordEmoji.FromGuildEmote(ctx.Client, (ulong)(IncludeStickers ? 970278964755038248 : 970278964079767574)))); + AddToServerButton = new DiscordButtonComponent(ButtonStyle.Success, "AddToServer", (IncludeStickers ? this.GetString(this.t.Commands.Utility.EmojiStealer.AddEmojisAndStickerToServer) : this.GetString(this.t.Commands.Utility.EmojiStealer.AddEmojisToServer)), !ctx.Member.Permissions.HasPermission(Permissions.ManageGuildExpressions), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("➕"))); + + var builder = new DiscordMessageBuilder().WithEmbed(embed); + + if (SanitizedEmoteList.Any(x => x.Value.EntryType == EmojiType.STICKER)) + _ = builder.AddComponents(IncludeStickersButton); + + _ = builder.AddComponents(new List { AddToServerButton, ZipPrivateMessageButton, SinglePrivateMessageButton, SendHereButton }); + + _ = await this.RespondOrEdit(builder); + } + } + } + finally + { + if (e.GetCustomId() != IncludeStickersButton.CustomId) + FinishedInteraction = true; + } + }).Add(ctx.Bot, ctx); + } + } + finally + { + while (!FinishedInteraction) + await Task.Delay(1000); + + try + { await zipFileStream.DisposeAsync(); } + catch { } + foreach (var b in SanitizedEmoteList) + try + { await b.Value.Data.Stream.DisposeAsync(); } + catch { } + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/GuildInfoCommand.cs b/ProjectMakoto/Commands/Utility/GuildInfoCommand.cs new file mode 100644 index 00000000..3cf655e4 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/GuildInfoCommand.cs @@ -0,0 +1,231 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class GuildInfoCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Utility.GuildInfo; + + var rawGuildId = (string?)arguments["guild"]; + + if (await ctx.DbUser.Cooldown.WaitForModerate(ctx)) + return; + + var guildId = rawGuildId?.ToUInt64() ?? ctx.Guild.Id; + + if (guildId == 0) + guildId = ctx.Guild.Id; + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(CommandKey.Fetching, true)).AsLoading(ctx)); + + _ = Directory.CreateDirectory("cache"); + + try + { + var guild = await ctx.Client.GetGuildAsync(guildId); + + //var imageHash = guild.DiscoverySplashHash ?? guild.SplashHash ?? ""; + //var imageUrl = guild.DiscoverySplashUrl ?? guild.SplashUrl ?? ""; + //if (!File.Exists($"cache/{imageHash}") && !imageHash.IsNullOrWhiteSpace()) + //{ + // var fileExtension = imageUrl[..(imageUrl.LastIndexOf('?'))]; + // fileExtension = fileExtension[(fileExtension.LastIndexOf(".") + 1)..]; + + // using (var outputStream = new MemoryStream()) + // { + // var arguments = FFMpegArguments + // .FromPipeInput(new StreamPipeSource(await new HttpClient().GetStreamAsync(imageUrl))) + // .OutputToPipe(new StreamPipeSink(outputStream), x => x + // .ForceFormat("image2") + // .WithVideoCodec(fileExtension) + // .WithArgument(new CustomArgument("-vf scale=2048:256:force_original_aspect_ratio=decrease,pad=2048:256:-1:-1"))); + + // _ = await arguments.ProcessAsynchronously(); + + // using (var file = new FileStream($"cache/{imageHash}", FileMode.Create, FileAccess.Write)) + // { + // outputStream.Position = 0; + // await outputStream.CopyToAsync(file); + // } + // } + //} + + var embed = new DiscordEmbedBuilder + { + Title = guild.Name, + Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail + { + Url = guild.IconUrl ?? AuditLogIcons.QuestionMark, + }, + //ImageUrl = $"attachment://banner.png", + Description = $"{(guild.Description.IsNullOrWhiteSpace() ? "" : $"{guild.Description}\n\n")}", + }.AsInfo(ctx); + + _ = embed.AddField(new DiscordEmbedField(this.GetString(CommandKey.MemberTitle), $"👥 `{guild.Members.Count}` **{this.GetString(CommandKey.MemberTitle)}**\n" + + $"🟢 `{guild.Members.Where(x => (x.Value?.Presence?.Status ?? UserStatus.Offline) != UserStatus.Offline).Count()}` **{this.GetString(CommandKey.OnlineMembers)}**\n" + + $"🛑 `{guild.MaxMembers}` **{this.GetString(CommandKey.MaxMembers)}**\n")); + + _ = embed.AddField(new DiscordEmbedField(this.GetString(CommandKey.GuildTitle), $"👤 **{this.GetString(CommandKey.Owner)}**: {guild.Owner.Mention} (`{guild.Owner.GetUsernameWithIdentifier()}`)\n" + + $"🕒 **{this.GetString(CommandKey.Creation)}**: {guild.CreationTimestamp.ToTimestamp(TimestampFormat.LongDateTime)} ({guild.CreationTimestamp.ToTimestamp()})\n" + + $"🗺 **{this.GetString(CommandKey.Locale)}**: `{guild.PreferredLocale}`\n" + + $"🔮 `{guild.PremiumSubscriptionCount}` **{this.GetString(CommandKey.Boosts)} (`{guild.PremiumTier switch { PremiumTier.None => this.GetString(CommandKey.BoostsNone), PremiumTier.TierOne => this.GetString(CommandKey.BoostsTierOne), PremiumTier.TierTwo => this.GetString(CommandKey.BoostsTierTwo), PremiumTier.TierThree => this.GetString(CommandKey.BoostsTierThree), PremiumTier.Unknown => "?", _ => "?", }}`)**\n\n" + + $"😀 `{guild.Emojis.Count}` **{this.GetString(this.t.Commands.Utility.EmojiStealer.Emoji)}**\n" + + $"🖼 `{guild.Stickers.Count}` **{this.GetString(this.t.Commands.Utility.EmojiStealer.Sticker)}**\n\n" + + $"{(guild.WidgetEnabled ?? false).ToPillEmote(ctx.Bot)} **{this.GetString(CommandKey.Widget)}**\n" + + $"{(guild.IsCommunity).ToPillEmote(ctx.Bot)} **{this.GetString(CommandKey.Community)}**", true)); + + _ = embed.AddField(new DiscordEmbedField(this.GetString(CommandKey.Security), $"{(guild.MfaLevel == MfaLevel.Enabled).ToPillEmote(ctx.Bot)} **{this.GetString(CommandKey.MultiFactor)}**\n" + + $"{(guild.Features.Features.Any(x => x == GuildFeaturesEnum.HasMembershipScreeningEnabled)).ToPillEmote(ctx.Bot)} **{this.GetString(CommandKey.Screening)}**\n" + + $"{(guild.Features.Features.Any(x => x == GuildFeaturesEnum.HasWelcomeScreenEnabled)).ToPillEmote(ctx.Bot)} **{this.GetString(CommandKey.WelcomeScreen)}**\n" + + $"🚪 **{this.GetString(CommandKey.Verification)}**: `{guild.VerificationLevel switch { VerificationLevel.None => this.GetString(CommandKey.VerificationNone), VerificationLevel.Low => this.GetString(CommandKey.VerificationLow), VerificationLevel.Medium => this.GetString(CommandKey.VerificationMedium), VerificationLevel.High => this.GetString(CommandKey.VerificationHigh), VerificationLevel.Highest => this.GetString(CommandKey.VerificationHighest), _ => "?", }}`\n" + + $"🔍 **{this.GetString(CommandKey.ExplicitContent)}**: `{guild.ExplicitContentFilter switch { ExplicitContentFilter.Disabled => this.GetString(CommandKey.ExplicitContentNone), ExplicitContentFilter.MembersWithoutRoles => this.GetString(CommandKey.ExplicitContentNoRoles), ExplicitContentFilter.AllMembers => this.GetString(CommandKey.ExplicitContentEveryone), _ => "?", }}`\n" + + $"⚠ **{this.GetString(CommandKey.Nsfw)}**: `{guild.NsfwLevel switch { NsfwLevel.Default => this.GetString(CommandKey.NsfwNoRating), NsfwLevel.Explicit => this.GetString(CommandKey.NsfwExplicit), NsfwLevel.Safe => this.GetString(CommandKey.NsfwSafe), NsfwLevel.Age_Restricted => this.GetString(CommandKey.NsfwQuestionable), _ => "?", }}`\n" + + $"💬 **{this.GetString(CommandKey.DefaultNotifications)}**: `{guild.DefaultMessageNotifications switch { DefaultMessageNotifications.AllMessages => this.GetString(CommandKey.DefaultNotificationsAll), DefaultMessageNotifications.MentionsOnly => this.GetString(CommandKey.DefaultNotificationsMentions), _ => "?", }}`\n", true)); + + _ = embed.AddField(new DiscordEmbedField(this.GetString(CommandKey.SpecialChannels), $"📑 **{this.GetString(CommandKey.Rules)}**: {guild.RulesChannel?.Mention ?? this.GetString(this.t.Common.Off, true)}\n" + + $"📰 **{this.GetString(CommandKey.CommunityUpdates)}**: {guild.PublicUpdatesChannel?.Mention ?? this.GetString(this.t.Common.Off, true)}\n\n" + + $"⌨ **{this.GetString(CommandKey.InactiveChannel)}**: {guild.AfkChannel?.Mention ?? this.GetString(this.t.Common.Off, true)}\n" + + $"> **{this.GetString(CommandKey.InactiveTimeout)}**: `{((long)guild.AfkTimeout).GetHumanReadable()}`\n\n" + + $"🤖 **{this.GetString(CommandKey.SystemMessages)}**: {guild.SystemChannel?.Mention ?? this.GetString(this.t.Common.Off, true)}\n" + + $"> {(!guild.SystemChannelFlags.HasSystemChannelFlag(SystemChannelFlags.SuppressJoinNotifications)).ToPillEmote(ctx.Bot)} **{this.GetString(CommandKey.SystemMessagesWelcome)}**\n" + + $"> {(!guild.SystemChannelFlags.HasSystemChannelFlag(SystemChannelFlags.SuppressJoinNotificationReplies)).ToPillEmote(ctx.Bot)} **{this.GetString(CommandKey.SystemMessagesWelcomeStickers)}**\n" + + $"> {(!guild.SystemChannelFlags.HasSystemChannelFlag(SystemChannelFlags.SuppressPremiumSubscriptions)).ToPillEmote(ctx.Bot)} **{this.GetString(CommandKey.SystemMessagesBoost)}**\n" + + $"> {(!guild.SystemChannelFlags.HasSystemChannelFlag(SystemChannelFlags.SuppressRoleSubbscriptionPurchaseNotification)).ToPillEmote(ctx.Bot)} **{this.GetString(CommandKey.SystemMessagesRole)}**\n" + + $"> {(!guild.SystemChannelFlags.HasSystemChannelFlag(SystemChannelFlags.SuppressRoleSubbscriptionPurchaseNotificationReplies)).ToPillEmote(ctx.Bot)} **{this.GetString(CommandKey.SystemMessagesRoleSticker)}**\n" + + $"> {(!guild.SystemChannelFlags.HasSystemChannelFlag(SystemChannelFlags.SuppressGuildReminderNotifications)).ToPillEmote(ctx.Bot)} **{this.GetString(CommandKey.SystemMessagesSetupTips)}**\n")); + + if (guild.RawFeatures.Count > 0) + _ = embed.AddField(new DiscordEmbedField(this.GetString(CommandKey.GuildFeatures), $"{string.Join(", ", guild.RawFeatures.Select(x => $"`{string.Join(" ", x.Replace("_", " ").ToLower().Split(" ").Select(x => x.FirstLetterToUpper()))}`"))}")); + + var builder = new DiscordMessageBuilder().WithEmbed(embed); + + if (!guild.VanityUrlCode.IsNullOrWhiteSpace()) + _ = builder.AddComponents(new DiscordLinkButtonComponent($"https://discord.gg/{guild.VanityUrlCode}", this.GetString(CommandKey.JoinServer), false, DiscordEmoji.FromUnicode("🔗").ToComponent())); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder() + .WithEmbed(embed) + .AddComponents(new DiscordLinkButtonComponent(guild.BannerUrl ?? "https://discord.gg", this.GetString(CommandKey.Banner), guild.BannerUrl is null), + new DiscordLinkButtonComponent(guild.SplashUrl ?? "https://discord.gg", this.GetString(CommandKey.Splash), guild.BannerUrl is null), + new DiscordLinkButtonComponent(guild.DiscoverySplashUrl ?? "https://discord.gg", this.GetString(CommandKey.DiscoverySplash), guild.BannerUrl is null), + new DiscordLinkButtonComponent(guild.HomeHeaderUrl ?? "https://discord.gg", this.GetString(CommandKey.HomeHeader), guild.HomeHeaderUrl is null))); + + //if (imageHash.IsNullOrWhiteSpace()) + // _ = await this.RespondOrEdit(embed); + //else + //{ + // using (var file = new FileStream($"cache/{imageHash}", FileMode.Open, FileAccess.Read)) + // { + // _ = await this.RespondOrEdit(new DiscordMessageBuilder() + // .WithEmbed(embed) + // .WithFile("banner.png", file)); + // } + //} + } + catch (Exception ex1) when (ex1 is DisCatSharp.Exceptions.UnauthorizedException or + DisCatSharp.Exceptions.NotFoundException) + { + HttpClient client = new(); + + try + { + var preview = await ctx.Client.GetGuildPreviewAsync(guildId); + + var embed = new DiscordEmbedBuilder + { + Title = preview.Name, + Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail + { + Url = preview.IconUrl ?? AuditLogIcons.QuestionMark, + }, + //ImageUrl = preview.SplashUrl ?? preview.DiscoverySplashUrl ?? "", + Description = preview.Description ?? "", + }.AsInfo(ctx, "", this.GetString(CommandKey.GuildPreviewNotice)); + + _ = embed.AddField(new DiscordEmbedField(this.GetString(CommandKey.MemberTitle), $"👥 `{preview.ApproximateMemberCount}` **{this.GetString(CommandKey.MemberTitle)}**\n" + + $"🟢 `{preview.ApproximatePresenceCount}` **{this.GetString(CommandKey.OnlineMembers)}**\n")); + + _ = embed.AddField(new DiscordEmbedField(this.GetString(CommandKey.GuildTitle), $"🕒 **{this.GetString(CommandKey.Creation)}**: {preview.CreationTimestamp.ToTimestamp(TimestampFormat.LongDateTime)} ({preview.CreationTimestamp.ToTimestamp()})\n" + + $"😀 `{preview.Emojis.Count}` **{this.GetString(this.t.Commands.Utility.EmojiStealer.Emoji)}**\n" + + $"🖼 `{preview.Stickers.Count}` **{this.GetString(this.t.Commands.Utility.EmojiStealer.Sticker)}**\n", true)); + + _ = embed.AddField(new DiscordEmbedField(this.GetString(CommandKey.GuildFeatures), $"{string.Join(", ", preview.Features.Select(x => $"`{string.Join(" ", x.Replace("_", " ").ToLower().Split(" ").Select(x => x.FirstLetterToUpper()))}`"))}")); + + + var builder = new DiscordMessageBuilder().WithEmbed(embed); + + var invite = ""; + + try { invite = (await ctx.Client.GetGuildWidgetAsync(guildId)).InstantInviteUrl; } catch { } + + if (!invite.IsNullOrWhiteSpace()) + _ = builder.AddComponents(new DiscordLinkButtonComponent(invite, this.GetString(CommandKey.JoinServer), false, DiscordEmoji.FromUnicode("🔗").ToComponent())); + + _ = await this.RespondOrEdit(builder); + } + catch (Exception ex2) when (ex2 is DisCatSharp.Exceptions.UnauthorizedException or + DisCatSharp.Exceptions.NotFoundException) + { + try + { + var widget = await ctx.Client.GetGuildWidgetAsync(guildId); + + var embed = new DiscordEmbedBuilder + { + Title = widget.Name, + }.AsInfo(ctx, "", this.GetString(CommandKey.GuildWidgetNotice)); + + _ = embed.AddField(new DiscordEmbedField(this.GetString(CommandKey.MemberTitle), $"🟢 `{widget.PresenceCount}` **{this.GetString(CommandKey.OnlineMembers)}**\n")); + + var builder = new DiscordMessageBuilder().WithEmbed(embed); + + if (!widget.InstantInviteUrl.IsNullOrWhiteSpace()) + _ = builder.AddComponents(new DiscordLinkButtonComponent(widget.InstantInviteUrl, this.GetString(CommandKey.JoinServer), false, DiscordEmoji.FromUnicode("🔗").ToComponent())); + + _ = await this.RespondOrEdit(builder); + } + catch (Exception) + { + try + { + var mee6 = JsonConvert.DeserializeObject(await client.GetStringAsync($"https://mee6.xyz/api/plugins/levels/leaderboard/{guildId}")); + + var embed = new DiscordEmbedBuilder + { + Title = mee6.guild.name, + Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail + { + Url = $"https://cdn.discordapp.com/icons/{guildId}/{mee6.guild.icon}.webp?size=96", + }, + //ImageUrl = mee6.banner_url ?? "", + }.AsInfo(ctx, "", this.GetString(CommandKey.Mee6Notice)); + + _ = embed.AddField(new DiscordEmbedField(this.GetString(CommandKey.MemberTitle), $"👥 `{mee6.players.Length}` **{this.GetString(CommandKey.MemberTitle)}**\n")); + + _ = await this.RespondOrEdit(embed); + } + catch (Exception) + { + var embed = new DiscordEmbedBuilder + { + Description = this.GetString(CommandKey.NoGuildFound, true), + }.AsError(ctx); + + _ = await this.RespondOrEdit(embed); + } + } + } + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/HelpCommand.cs b/ProjectMakoto/Commands/Utility/HelpCommand.cs new file mode 100644 index 00000000..1bc1745d --- /dev/null +++ b/ProjectMakoto/Commands/Utility/HelpCommand.cs @@ -0,0 +1,220 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class HelpCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var command_filter = (string)arguments["command"]; + + if (await ctx.DbUser.Cooldown.WaitForModerate(ctx)) + return; + + List> Commands = new(); + var PrefixCommandsList = ctx.Client.GetCommandsNext().RegisteredCommands.GroupBy(x => x.Value.Name).Select(x => x.First()).ToList(); + + var ApplicationCommandsList = ctx.Client.GetApplicationCommands().RegisteredCommands.First(x => x.Value?.Count > 0).Value.Where(x => x.Version != 0); + + foreach (var appCommand in ApplicationCommandsList + .OrderByDescending(x => x.ContainingType?.GetCustomAttribute()?.Priority ?? 0)) + { + var nspace = appCommand?.ContainingType?.Namespace ?? ""; + var module = appCommand?.ContainingType?.Name?.Replace("Commands", "")?.ToLower() ?? ""; + + if (!nspace.Equals("ProjectMakoto.ApplicationCommands", StringComparison.InvariantCultureIgnoreCase)) + module = ctx.Bot.CommandModules.FirstOrDefault(m => m.Commands.Any(cmd => cmd.Name == appCommand.Name))?.Name ?? ctx.Bot.PluginCommandModules + .FirstOrDefault(pl => pl.Value.Any(m => m.Commands.Any(cmd => cmd.Name == appCommand.Name)), default).Value? + .FirstOrDefault(m => m.Commands.Any(cmd => cmd.Name == appCommand.Name))?.Name; + + if (module.IsNullOrEmpty()) + continue; + + switch (module) + { + case "configuration": + if (!ctx.Member.IsAdmin(ctx.Bot.status)) + continue; + break; + case "debug": + if (!ctx.User.IsMaintenance(ctx.Bot.status)) + continue; + break; + case "hidden": + continue; + default: + break; + } + + var cmdPerm = appCommand.DefaultMemberPermissions ?? null; + + if (cmdPerm is not null && ctx.Member.Permissions.HasPermission(cmdPerm.Value)) + continue; + + try + { + var commandKey = this.t.CommandList.FirstOrDefault(localized => localized.Names.Any(x => x.Value == appCommand.Name), null); + + string commandName; + string commandDescription; + string commandUsage; + + if (commandKey is not null) + { + commandName = this.GetString(commandKey.Names); + commandDescription = this.GetString(commandKey.Descriptions); + commandUsage = string.Join(" ", commandKey.Options?.Select(x => $"<{this.GetString(x.Names).FirstLetterToUpper()}>") ?? new List()); + } + else + { + commandName = appCommand.Name; + commandDescription = appCommand.Description; + commandUsage = string.Join(" ", appCommand.Options?.Select(x => $"<{x.Name.FirstLetterToUpper()}>") ?? new List()); + } + + if (command_filter is not null) + if (!(commandKey?.Names.Any(x => x.Value.Contains(command_filter, StringComparison.InvariantCultureIgnoreCase)) ?? false) && !commandName.Contains(command_filter, StringComparison.InvariantCultureIgnoreCase)) + continue; + + string commandMention; + + if (appCommand.Options?.Any(x => x.Type == ApplicationCommandOptionType.SubCommand) ?? false) + commandMention = $"`/{commandName}`"; + else commandMention = appCommand.Type != ApplicationCommandType.ChatInput ? $"`{commandName}`" : appCommand.Mention; + + Command? prefixCommand; + + if (PrefixCommandsList.Any(x => x.Value.Name.Equals(appCommand.Name, StringComparison.CurrentCultureIgnoreCase))) + prefixCommand = PrefixCommandsList.First(x => x.Value.Name.Equals(appCommand.Name, StringComparison.CurrentCultureIgnoreCase)).Value; + else prefixCommand = appCommand.CustomAttributes.Any(x => x is PrefixCommandAlternativeAttribute) + ? PrefixCommandsList + .First(x => x.Value.Name.ToLower() == ((PrefixCommandAlternativeAttribute)appCommand.CustomAttributes + .First(x => x is PrefixCommandAlternativeAttribute)).PrefixCommand.ToLower().TruncateAt(' ')).Value + : null; + + var commandModuleName = module.ToLower() switch + { + "utility" => this.GetString(this.t.Commands.ModuleNames.Utility), + "social" => this.GetString(this.t.Commands.ModuleNames.Social), + "music" => this.GetString(this.t.Commands.ModuleNames.Music), + "moderation" => this.GetString(this.t.Commands.ModuleNames.Moderation), + "configuration" => this.GetString(this.t.Commands.ModuleNames.Configuration), + _ => module.FirstLetterToUpper(), + }; + + var TypeEmoji = appCommand.Type switch + { + ApplicationCommandType.ChatInput => EmojiTemplates.GetSlashCommand(ctx.Bot), + ApplicationCommandType.Message => EmojiTemplates.GetMessageCommand(ctx.Bot), + ApplicationCommandType.User => EmojiTemplates.GetUserCommand(ctx.Bot), + _ => throw new NotImplementedException(), + }; + + Commands.Add(new KeyValuePair($"{commandModuleName}", + $"{TypeEmoji}{((prefixCommand is null) ? EmojiTemplates.GetPrefixCommandDisabled(ctx.Bot) : EmojiTemplates.GetPrefixCommandEnabled(ctx.Bot))} {commandMention}{(commandUsage.IsNullOrWhiteSpace() ? "" : $"`{commandUsage}`")}{(commandDescription.IsNullOrWhiteSpace() ? "" : $" - _{commandDescription}_")}")); + + foreach (var subCmd in appCommand.Options?.Where(x => x.Type == ApplicationCommandOptionType.SubCommand) ?? new List()) + { + var subKey = commandKey?.Commands.FirstOrDefault(localized => localized.Names.Any(x => x.Value == subCmd.Name), null); + + string subName; + string subDescription; + string subUsage; + + if (subKey is not null) + { + subName = $"{commandName} {this.GetString(subKey.Names)}"; + subDescription = this.GetString(subKey.Descriptions); + subUsage = string.Join(" ", subKey.Options?.Select(x => $"<{this.GetString(x.Names).FirstLetterToUpper()}>") ?? new List()); + } + else + { + subName = $"{commandName} {subCmd.Name}"; + subDescription = subCmd.Description; + subUsage = string.Join(" ", subCmd.Options?.Select(x => $"<{x.Name.FirstLetterToUpper()}>") ?? new List()); + } + + Command? subPrefixCommand = null; + + if (prefixCommand is CommandGroup group) + subPrefixCommand = group.Children.FirstOrDefault(x => x.Name == subCmd.Name); + + Commands.Add(new KeyValuePair($"{commandModuleName}", + $"{EmojiTemplates.GetInVisible(ctx.Bot)}{TypeEmoji}{(subPrefixCommand is null ? EmojiTemplates.GetPrefixCommandDisabled(ctx.Bot) : EmojiTemplates.GetPrefixCommandEnabled(ctx.Bot))} `/{subName}`‍{(subUsage.IsNullOrWhiteSpace() ? "" : $"`{subUsage}`")}{(subDescription.IsNullOrWhiteSpace() ? "" : $" - _{subDescription}_")}")); + } + } + catch (Exception ex) + { + Log.Error(ex.AddData("Command", appCommand), "Failed to generate help"); + } + } + + if (Commands.Count == 0) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(this.t.Commands.Utility.Help.MissingCommand, true)) + .AsError(ctx)); + return; + } + + var Fields = Commands.PrepareEmbedFields(); + + var discordEmbeds = Fields.PrepareEmbeds(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.Help.Disclaimer)).AsInfo(ctx), true); + + var Page = 0; + + while (true) + { + var PreviousButton = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(this.t.Common.PreviousPage), (Page <= 0), DiscordEmoji.FromUnicode("◀").ToComponent()); + var NextButton = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(this.t.Common.NextPage), (Page >= discordEmbeds.Count - 1), DiscordEmoji.FromUnicode("▶").ToComponent()); + + var builder = new DiscordMessageBuilder().WithEmbed(discordEmbeds.ElementAt(Page)); + + if (!PreviousButton.Disabled || !NextButton.Disabled) + _ = builder.AddComponents(PreviousButton, NextButton); + + _ = builder.AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot)); + + _ = await this.RespondOrEdit(builder); + + if (PreviousButton.Disabled && NextButton.Disabled) + return; + + var Menu = await ctx.WaitForButtonAsync(); + + if (Menu.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + _ = Menu.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (Menu.GetCustomId() == PreviousButton.CustomId) + { + Page--; + continue; + } + else if (Menu.GetCustomId() == NextButton.CustomId) + { + Page++; + continue; + } + else + { + this.DeleteOrInvalidate(); + return; + } + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/LanguageCommand.cs b/ProjectMakoto/Commands/Utility/LanguageCommand.cs new file mode 100644 index 00000000..f79de737 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/LanguageCommand.cs @@ -0,0 +1,108 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class LanguageCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + { + Description = $"{this.GetString(this.t.Commands.Utility.Language.Disclaimer, true)}\n" + + $"{this.GetString(this.t.Commands.Utility.Language.Response, true)}: `{(ctx.DbUser.OverrideLocale.IsNullOrWhiteSpace() ? (ctx.DbUser.CurrentLocale.IsNullOrWhiteSpace() ? "en (Default)" : $"{ctx.DbUser.CurrentLocale} (Discord)") : $"{ctx.DbUser.OverrideLocale} (Override)")}`" + }); + + List options = new(); + List newOptions = new(); + + newOptions.Add(new DiscordStringSelectComponentOption("Disable Override", "_", this.GetString(this.t.Commands.Utility.Language.DisableOverride), false, DiscordEmoji.FromUnicode("❌").ToComponent())); + + options.Add(new DiscordStringSelectComponentOption("English", "en", "English")); + options.Add(new DiscordStringSelectComponentOption("German", "de", "Deutsch")); + options.Add(new DiscordStringSelectComponentOption("Indonesian", "id", "Bahasa Indonesia")); + options.Add(new DiscordStringSelectComponentOption("Danish", "da", "Dansk")); + options.Add(new DiscordStringSelectComponentOption("Spanish", "es-ES", "Español")); + options.Add(new DiscordStringSelectComponentOption("French", "fr", "Français")); + options.Add(new DiscordStringSelectComponentOption("Croatian", "hr", "Hrvatski")); + options.Add(new DiscordStringSelectComponentOption("Italian", "it", "Italiano")); + options.Add(new DiscordStringSelectComponentOption("Lithuanian", "lt", "Lietuviškai")); + options.Add(new DiscordStringSelectComponentOption("Hungarian", "hu", "Magyar")); + options.Add(new DiscordStringSelectComponentOption("Dutch", "nl", "Nederlands")); + options.Add(new DiscordStringSelectComponentOption("Norwegian", "no", "Norsk")); + options.Add(new DiscordStringSelectComponentOption("Polish", "pl", "Polski")); + options.Add(new DiscordStringSelectComponentOption("Portuguese, Brazilian", "pt-BR", "Português do Brasil")); + options.Add(new DiscordStringSelectComponentOption("Romanian, Romania", "ro", "Română")); + options.Add(new DiscordStringSelectComponentOption("Finnish", "fi", "Suomi")); + options.Add(new DiscordStringSelectComponentOption("Swedish", "sv-SE", "Svenska")); + options.Add(new DiscordStringSelectComponentOption("Vietnamese", "vi", "Tiếng Việt")); + options.Add(new DiscordStringSelectComponentOption("Turkish", "tr", "Türkçe")); + options.Add(new DiscordStringSelectComponentOption("Czech", "cs", "Čeština")); + options.Add(new DiscordStringSelectComponentOption("Greek", "el", "Ελληνικά")); + options.Add(new DiscordStringSelectComponentOption("Bulgarian", "bg", "български")); + options.Add(new DiscordStringSelectComponentOption("Russian", "ru", "Pусский")); + options.Add(new DiscordStringSelectComponentOption("Ukrainian", "uk", "Українська")); + options.Add(new DiscordStringSelectComponentOption("Hindi", "hi", "हिन्दी")); + options.Add(new DiscordStringSelectComponentOption("Thai", "th", "ไทย")); + options.Add(new DiscordStringSelectComponentOption("Chinese, China", "zh-CN", "中文")); + options.Add(new DiscordStringSelectComponentOption("Japanese", "ja", "日本語")); + options.Add(new DiscordStringSelectComponentOption("Chinese, Taiwan", "zh-TW", "繁體中文")); + options.Add(new DiscordStringSelectComponentOption("Korean", "ko", "한국어")); + + foreach (var b in options) + if (this.t.Progress.TryGetValue(b.Value, out var value)) + { + var perc = (value / (decimal)this.t.Progress["en"] * 100); + DiscordComponentEmoji emoji = null; + + if (perc >= 100) + emoji = DiscordEmoji.FromUnicode("🟢").ToComponent(); + else emoji = perc >= 85 ? DiscordEmoji.FromUnicode("🟡").ToComponent() : DiscordEmoji.FromUnicode("🔴").ToComponent(); + + newOptions.Add(new DiscordStringSelectComponentOption(b.Label, b.Value, b.Description.Insert(0, $"{perc.ToString("N1", CultureInfo.CreateSpecificCulture("en-US"))}% | "), false, emoji)); + } + + var SelectionResult = await this.PromptCustomSelection(newOptions, this.GetString(this.t.Commands.Utility.Language.Selector)); + + if (SelectionResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (SelectionResult.Cancelled) + { + this.DeleteOrInvalidate(); + return; + } + else if (SelectionResult.Errored) + { + throw SelectionResult.Exception; + } + + switch (SelectionResult.Result) + { + case "_": + { + ctx.DbUser.OverrideLocale = null; + break; + } + default: + { + ctx.DbUser.OverrideLocale = SelectionResult.Result; + break; + } + } + + await this.ExecuteCommand(ctx, arguments); + return; + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/LeaderboardCommand.cs b/ProjectMakoto/Commands/Utility/LeaderboardCommand.cs new file mode 100644 index 00000000..471af074 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/LeaderboardCommand.cs @@ -0,0 +1,106 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; +internal sealed class LeaderboardCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var ShowAmount = (int)arguments["amount"]; + + if (await ctx.DbUser.Cooldown.WaitForModerate(ctx)) + return; + + if (!ctx.DbGuild.Experience.UseExperience) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Leaderboard.Disabled, true, + new TVar("Command", $"{ctx.Prefix}experiencesettings config")) + }.AsError(ctx, this.GetString(this.t.Commands.Utility.Leaderboard.Title))); + return; + } + + if (ShowAmount is > 50 or < 3) + { + this.SendSyntaxError(); + return; + } + + var embed = new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Leaderboard.Fetching, true), + }.AsLoading(ctx, this.GetString(this.t.Commands.Utility.Leaderboard.Title)); + + _ = await this.RespondOrEdit(embed: embed); + + var count = 0; + + var currentuserplacement = 0; + + foreach (var b in ctx.DbGuild.Members.Fetch().OrderByDescending(x => x.Value.Experience.Points)) + { + currentuserplacement++; + if (b.Key == ctx.User.Id) + break; + } + + var members = await ctx.Guild.GetAllMembersAsync(); + + List> Board = new(); + + foreach (var b in ctx.DbGuild.Members.Fetch().OrderByDescending(x => x.Value.Experience.Points)) + { + try + { + if (!members.Any(x => x.Id == b.Key)) + continue; + + var bMember = members.First(x => x.Id == b.Key); + + if (bMember is null) + continue; + + if (bMember.IsBot) + continue; + + if (b.Value.Experience.Points <= 1) + break; + + count++; + + Board.Add(new KeyValuePair("󠂪 󠂪 ", $"**{count.ToEmotes()}**. <@{b.Key}> `{bMember.GetUsernameWithIdentifier()}` ({this.GetString(this.t.Commands.Utility.Leaderboard.Level, true, new TVar("Level", b.Value.Experience.Level), new TVar("Points", b.Value.Experience.Points))}")); + + if (count >= ShowAmount) + break; + } + catch { } + } + + var fields = Board.PrepareEmbedFields(); + + foreach (var field in fields) + _ = embed.AddField(new DiscordEmbedField(field.Key, field.Value)); + + if (count != 0) + { + embed.Author.IconUrl = ctx.Guild.IconUrl; + embed.Description = this.GetString(this.t.Commands.Utility.Leaderboard.Placement, new TVar("Placement", currentuserplacement)); + _ = await this.RespondOrEdit(embed.AsInfo(ctx, this.GetString(this.t.Commands.Utility.Leaderboard.Title))); + } + else + { + embed.Description = $":no_entry_sign: {this.GetString(this.t.Commands.Utility.Leaderboard.NoPoints, true)}"; + _ = await this.RespondOrEdit(embed.AsInfo(ctx, this.GetString(this.t.Commands.Utility.Leaderboard.Title))); + } + }); + } +} diff --git a/ProjectMakoto/Commands/Utility/RankCommand.cs b/ProjectMakoto/Commands/Utility/RankCommand.cs new file mode 100644 index 00000000..74d2d1a8 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/RankCommand.cs @@ -0,0 +1,49 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; +internal sealed class RankCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var victim = (DiscordUser)arguments["user"]; + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + if (!ctx.DbGuild.Experience.UseExperience) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Leaderboard.Disabled, true, + new TVar("Command", $"{ctx.Prefix}experiencesettings config")) + }.AsError(ctx, this.GetString(this.t.Commands.Utility.Rank.Title))); + return; + } + + victim ??= ctx.User; + + victim = await victim.GetFromApiAsync(); + + var current = (long)Math.Floor((decimal)(ctx.DbGuild.Members[victim.Id].Experience.Points - ctx.Bot.ExperienceHandler.CalculateLevelRequirement(ctx.DbGuild.Members[victim.Id].Experience.Level - 1))); + var max = (long)Math.Floor((decimal)(ctx.Bot.ExperienceHandler.CalculateLevelRequirement(ctx.DbGuild.Members[victim.Id].Experience.Level) - ctx.Bot.ExperienceHandler.CalculateLevelRequirement(ctx.DbGuild.Members[victim.Id].Experience.Level - 1))); + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = $"{(victim.Id == ctx.User.Id ? this.GetString(this.t.Commands.Utility.Rank.Self, new TVar("Level", ctx.DbGuild.Members[victim.Id].Experience.Level.ToEmotes()), new TVar("Points", ctx.DbGuild.Members[victim.Id].Experience.Points.ToString("N0", CultureInfo.GetCultureInfo("en-US")))) : this.GetString(this.t.Commands.Utility.Rank.Other, new TVar("User", victim.Mention), new TVar("Level", ctx.DbGuild.Members[victim.Id].Experience.Level.ToEmotes()), new TVar("Points", ctx.DbGuild.Members[victim.Id].Experience.Points.ToString("N0", CultureInfo.GetCultureInfo("en-US")))))}\n\n" + + $"**{this.GetString(this.t.Commands.Utility.Rank.Progress, new TVar("Level", (ctx.DbGuild.Members[victim.Id].Experience.Level + 1).ToEmotes()))}**\n" + + $"`{Math.Floor((decimal)((decimal)((decimal)current / (decimal)max) * 100)).ToString().Replace(",", ".")}%` " + + $"`{StringTools.GenerateASCIIProgressbar(current, max, 44)}` " + + $"`{current}/{max} XP`", + }.AsInfo(ctx, this.GetString(this.t.Commands.Utility.Rank.Title))); + }); + } +} diff --git a/ProjectMakoto/Commands/Utility/RemindersCommand.cs b/ProjectMakoto/Commands/Utility/RemindersCommand.cs new file mode 100644 index 00000000..02274ba9 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/RemindersCommand.cs @@ -0,0 +1,217 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using ProjectMakoto.Entities.Users; + +namespace ProjectMakoto.Commands; + +internal sealed class RemindersCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + string? snoozeDescription = null; + + if ((arguments?.Count ?? 0) > 0) + snoozeDescription = arguments["description"]?.ToString(); + + if (await ctx.DbUser.Cooldown.WaitForModerate(ctx)) + return; + + var rem = ctx.DbUser.Reminders; + + var AddButton = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Utility.Reminders.NewReminder), (rem.ScheduledReminders.Length >= 10), DiscordEmoji.FromUnicode("➕").ToComponent()); + var RemoveButton = new DiscordButtonComponent(ButtonStyle.Primary, Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Utility.Reminders.DeleteReminder), (rem.ScheduledReminders.Length <= 0), DiscordEmoji.FromUnicode("➖").ToComponent()); + var SelectedCustomId = (snoozeDescription is null ? "" : AddButton.CustomId); + + if (snoozeDescription is null) + { + _ = await this.RespondOrEdit(new DiscordMessageBuilder() + .WithEmbed(new DiscordEmbedBuilder() + .WithDescription($"{this.GetString(this.t.Commands.Utility.Reminders.Count, true, new TVar("Count", rem.ScheduledReminders.Length))}\n\n" + + $"{string.Join("\n\n", rem.ScheduledReminders.Select(x => $"> {x.Description.FullSanitize()}\n{this.GetString(this.t.Commands.Utility.Reminders.CreatedOn, new TVar("Guild", $"**{x.CreationPlace}**"))}\n{this.GetString(this.t.Commands.Utility.Reminders.DueTime, new TVar("Relative", x.DueTime.ToTimestamp()), new TVar("DateTime", x.DueTime.ToTimestamp(TimestampFormat.LongDateTime)))}").ToList())}\n\n" + + $"**⚠ {this.GetString(this.t.Commands.Utility.Reminders.Notice)}**") + .AsInfo(ctx, this.GetString(this.t.Commands.Utility.Reminders.Title))) + .AddComponents(new List { AddButton, RemoveButton }) + .AddComponents(MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot))); + + var Button = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (Button.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + _ = Button.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + SelectedCustomId = Button.GetCustomId(); + } + + if (SelectedCustomId == AddButton.CustomId) + { + var selectedDescription = snoozeDescription.IsNullOrWhiteSpace() ? "" : snoozeDescription; + DateTime? selectedDueDate = null; + + while (true) + { + if (selectedDueDate.HasValue && (selectedDueDate.Value.Ticks < DateTime.UtcNow.Ticks || selectedDueDate.Value.GetTimespanUntil() > TimeSpan.FromDays(30 * 6))) + { + selectedDueDate = null; + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.Reminders.InvalidDateTime, true)).AsError(ctx)); + await Task.Delay(5000); + } + + var SelectDescriptionButton = new DiscordButtonComponent((selectedDescription.IsNullOrWhiteSpace() ? ButtonStyle.Primary : ButtonStyle.Secondary), Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Utility.Reminders.SetDescription), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✏"))); + var SelectDueDateButton = new DiscordButtonComponent((selectedDueDate is null ? ButtonStyle.Primary : ButtonStyle.Secondary), Guid.NewGuid().ToString(), this.GetString(this.t.Commands.Utility.Reminders.SetDateTime), (selectedDescription is null), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🕒"))); + var Finish = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(this.t.Common.Submit), (selectedDescription.IsNullOrWhiteSpace() || selectedDueDate is null), new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✅"))); + + var padding = TranslationUtil.CalculatePadding(ctx.DbUser, this.t.Commands.Utility.Reminders.Description, this.t.Commands.Utility.Reminders.DateTime); + + var action_embed = new DiscordEmbedBuilder + { + Description = $"`{this.GetString(this.t.Commands.Utility.Reminders.Description).PadRight(padding)}`: {(selectedDescription.IsNullOrWhiteSpace() ? $"`{this.GetString(this.t.Common.NotSelected)}`" : $"`{selectedDescription.FullSanitize()}`")}\n" + + $"`{this.GetString(this.t.Commands.Utility.Reminders.DateTime).PadRight(padding)}`: {(selectedDueDate is null ? $"`{this.GetString(this.t.Common.NotSelected)}`" : $"{selectedDueDate.Value.ToTimestamp(TimestampFormat.LongDateTime)} ({selectedDueDate.Value.ToTimestamp()})")}" + }.AsAwaitingInput(ctx, this.GetString(this.t.Commands.Utility.Reminders.Title)); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(action_embed) + .AddComponents(new List { SelectDescriptionButton, SelectDueDateButton, Finish }) + .AddComponents(MessageComponents.GetBackButton(ctx.DbUser, ctx.Bot))); + + var Menu = await ctx.WaitForButtonAsync(); + + if (Menu.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + if (Menu.GetCustomId() == SelectDescriptionButton.CustomId) + { + var maxLength = 100 - JsonConvert.SerializeObject(new ReminderSnoozeButton(), new JsonSerializerSettings() { NullValueHandling = NullValueHandling.Include }).Length; + + var modal = new DiscordInteractionModalBuilder(this.GetString(this.t.Commands.Utility.Reminders.NewReminder), Guid.NewGuid().ToString()) + .AddTextComponent(new DiscordTextComponent(TextComponentStyle.Small, "desc", this.GetString(this.t.Commands.Utility.Reminders.Description), this.GetString(this.t.Commands.Utility.Reminders.SetDescription), 1, maxLength, true)); + + + var ModalResult = await this.PromptModalWithRetry(Menu.Result.Interaction, modal, false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + continue; + } + else if (ModalResult.Errored) + { + throw ModalResult.Exception; + } + + selectedDescription = ModalResult.Result.Interaction.GetModalValueByCustomId("desc").TruncateWithIndication(maxLength); + } + else if (Menu.GetCustomId() == SelectDueDateButton.CustomId) + { + _ = Menu.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + var ModalResult = await this.PromptModalForDateTime(selectedDueDate ?? DateTime.UtcNow.AddMinutes(5), false); + + if (ModalResult.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + else if (ModalResult.Cancelled) + { + continue; + } + else if (ModalResult.Errored) + { + if (ModalResult.Exception.GetType() == typeof(ArgumentException) || ModalResult.Exception.GetType() == typeof(ArgumentOutOfRangeException)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.Reminders.InvalidDateTime, true)).AsError(ctx)); + await Task.Delay(5000); + continue; + } + + throw ModalResult.Exception; + } + + selectedDueDate = ModalResult.Result; + } + else if (Menu.GetCustomId() == Finish.CustomId) + { + _ = Menu.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (selectedDueDate < DateTime.UtcNow) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.Reminders.InvalidDateTime, true)).AsError(ctx, this.GetString(this.t.Commands.Utility.Reminders.Title))); + await Task.Delay(2000); + continue; + } + + rem.ScheduledReminders = rem.ScheduledReminders.Add(new() + { + Description = selectedDescription, + DueTime = selectedDueDate.Value.ToUniversalTime(), + CreationPlace = ctx.Channel.IsPrivate ? $"[`@{ctx.CurrentUser.GetUsername()}`](https://discord.com/channels/@me/{ctx.Channel.Id})" : $"[`{ctx.Guild.Name}`](https://discord.com/channels/{ctx.Guild.Id}/{ctx.Channel.Id})" + }); + + await this.ExecuteCommand(ctx, null); + return; + } + else if (Menu.GetCustomId() == MessageComponents.BackButtonId) + { + _ = Menu.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + await this.ExecuteCommand(ctx, null); + return; + } + } + } + else if (SelectedCustomId == RemoveButton.CustomId) + { + if (rem.ScheduledReminders.Length == 0) + { + await this.ExecuteCommand(ctx, null); + return; + } + + var UuidResult = await this.PromptCustomSelection(rem.ScheduledReminders + .Select(x => new DiscordStringSelectComponentOption($"{x.Description}".TruncateWithIndication(100), x.UUID, $"in {x.DueTime.GetTotalSecondsUntil().GetHumanReadable()}")).ToList()); + + if (UuidResult.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + else if (UuidResult.Cancelled) + { + await this.ExecuteCommand(ctx, null); + return; + } + else if (UuidResult.Errored) + { + throw UuidResult.Exception; + } + + rem.ScheduledReminders = rem.ScheduledReminders.Remove(x => x.UUID, rem.ScheduledReminders.First(x => x.UUID == UuidResult.Result)); + await this.ExecuteCommand(ctx, null); + return; + } + else if (SelectedCustomId == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + return; + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/ReportHostCommand.cs b/ProjectMakoto/Commands/Utility/ReportHostCommand.cs new file mode 100644 index 00000000..b1be2d76 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/ReportHostCommand.cs @@ -0,0 +1,197 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class ReportHostCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var url = (string)arguments["url"]; + + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx)) + return; + + var tos_version = 3; + + if (ctx.DbUser.UrlSubmissions.AcceptedTOS != tos_version) + { + var button = new DiscordButtonComponent(ButtonStyle.Primary, "accepted-tos", this.GetString(this.t.Commands.Utility.ReportHost.AcceptTos), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👍"))); + + var tos_embed = new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.ReportHost.Tos, + new TVar("1", 1.ToEmotes()), + new TVar("2", 2.ToEmotes()), + new TVar("3", 3.ToEmotes()), + new TVar("4", 4.ToEmotes())) + }.AsAwaitingInput(ctx, this.GetString(this.t.Commands.Utility.ReportHost.Title)); + + if (ctx.DbUser.UrlSubmissions.AcceptedTOS != 0 && ctx.DbUser.UrlSubmissions.AcceptedTOS < tos_version) + { + tos_embed.Description = tos_embed.Description.Insert(0, $"**{this.GetString(this.t.Commands.Utility.ReportHost.TosChangedNotice)}**\n\n"); + } + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(tos_embed).AddComponents(button)); + + var TosAccept = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (TosAccept.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + await TosAccept.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + ctx.DbUser.UrlSubmissions.AcceptedTOS = tos_version; + } + + var embed = new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.ReportHost.Processing, true) + }.AsLoading(ctx, this.GetString(this.t.Commands.Utility.ReportHost.Title)); + + _ = await this.RespondOrEdit(embed); + + if (ctx.DbUser.UrlSubmissions.LastTime.AddMinutes(45) > DateTime.UtcNow && !ctx.User.IsMaintenance(ctx.Bot.status)) + { + embed.Description = this.GetString(this.t.Commands.Utility.ReportHost.CooldownError, true, + new TVar("Timestamp", ctx.DbUser.UrlSubmissions.LastTime.AddMinutes(45).ToTimestamp())); + _ = this.RespondOrEdit(embed.AsError(ctx, this.GetString(this.t.Commands.Utility.ReportHost.Title))); + return; + } + + if (ctx.Bot.SubmittedHosts.Fetch().Any(x => x.Value.Submitter == ctx.User.Id) && !ctx.User.IsMaintenance(ctx.Bot.status)) + { + if (ctx.Bot.SubmittedHosts.Fetch().Where(x => x.Value.Submitter == ctx.User.Id).Count() >= 5) + { + embed.Description = this.GetString(this.t.Commands.Utility.ReportHost.LimitError, true); + _ = this.RespondOrEdit(embed.AsError(ctx, this.GetString(this.t.Commands.Utility.ReportHost.Title))); + return; + } + } + + string host; + + try + { + host = new UriBuilder(url).Host; + } + catch (Exception) + { + embed.Description = this.GetString(this.t.Commands.Utility.ReportHost.InvalidHost, true, + new TVar("Host", url, true)); + _ = this.RespondOrEdit(embed.AsError(ctx, this.GetString(this.t.Commands.Utility.ReportHost.Title))); + return; + } + + embed.Description = this.GetString(this.t.Commands.Utility.ReportHost.ConfirmHost, true, + new TVar("Host", host, true)); + _ = embed.AsAwaitingInput(ctx, this.GetString(this.t.Commands.Utility.ReportHost.Title)); + + var ContinueButton = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(this.t.Common.Confirm), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✅"))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(embed).AddComponents(new List + { + { ContinueButton }, + { MessageComponents.GetCancelButton(ctx.DbUser, ctx.Bot) } + })); + + var e = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (e.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + await e.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == ContinueButton.CustomId) + { + _ = embed.AsLoading(ctx, this.GetString(this.t.Commands.Utility.ReportHost.Title)); + + embed.Description = this.GetString(this.t.Commands.Utility.ReportHost.DatabaseCheck, true); + _ = await this.RespondOrEdit(embed); + + foreach (var b in ctx.Bot.PhishingHosts) + { + if (host.Contains(b.Key)) + { + embed.Description = this.GetString(this.t.Commands.Utility.ReportHost.DatabaseError, true, new TVar("Host", host, true)); + _ = embed.AsError(ctx, this.GetString(this.t.Commands.Utility.ReportHost.Title)); + _ = this.RespondOrEdit(embed.Build()); + return; + } + } + + embed.Description = this.GetString(this.t.Commands.Utility.ReportHost.SubmissionCheck, true); + _ = await this.RespondOrEdit(embed); + + foreach (var b in ctx.Bot.SubmittedHosts) + { + if (b.Value.Url == host) + { + embed.Description = this.GetString(this.t.Commands.Utility.ReportHost.SubmissionError, true, new TVar("Host", host, true)); + _ = embed.AsError(ctx, this.GetString(this.t.Commands.Utility.ReportHost.Title)); + _ = this.RespondOrEdit(embed.Build()); + return; + } + } + + embed.Description = this.GetString(this.t.Commands.Utility.ReportHost.CreatingSubmission, true); + _ = await this.RespondOrEdit(embed); + + var channel = await ctx.Client.GetChannelAsync(ctx.Bot.status.LoadedConfig.Channels.UrlSubmissions); + + var AcceptSubmission = new DiscordButtonComponent(ButtonStyle.Success, "accept_submission", "Accept submission", false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("✅"))); + var DenySubmission = new DiscordButtonComponent(ButtonStyle.Danger, "deny_submission", "Deny submission", false, new DiscordComponentEmoji(DiscordEmoji.FromGuildEmote(ctx.Client, 1005430134070841395))); + var BanUserButton = new DiscordButtonComponent(ButtonStyle.Danger, "ban_user", "Deny submission & ban submitter", false, new DiscordComponentEmoji(DiscordEmoji.FromGuildEmote(ctx.Client, 1005430134070841395))); + var BanGuildButton = new DiscordButtonComponent(ButtonStyle.Danger, "ban_guild", "Deny submission & ban guild", false, new DiscordComponentEmoji(DiscordEmoji.FromGuildEmote(ctx.Client, 1005430134070841395))); + + var submittedMsg = await channel.SendMessageAsync(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Author = new DiscordEmbedBuilder.EmbedAuthor { IconUrl = StatusIndicatorIcons.Success, Name = this.GetString(this.t.Commands.Utility.ReportHost.Title) }, + Color = EmbedColors.Success, + Timestamp = DateTime.UtcNow, + Description = $"`Submitted host`: `{host.SanitizeForCode()}`\n" + + $"`Submission by `: `{ctx.User.GetUsernameWithIdentifier()} ({ctx.User.Id})`\n" + + $"`Submitted on `: `{ctx.Guild.Name} ({ctx.Guild.Id})`" + }) + .AddComponents(new List + { + { AcceptSubmission }, + { DenySubmission }, + { BanUserButton }, + { BanGuildButton }, + })); + + ctx.Bot.SubmittedHosts.Add(submittedMsg.Id, new SubmittedUrlEntry(ctx.Bot, submittedMsg.Id) + { + Url = host, + Submitter = ctx.User.Id, + GuildOrigin = ctx.Guild.Id + }); + + ctx.DbUser.UrlSubmissions.LastTime = DateTime.UtcNow; + + embed.Description = this.GetString(this.t.Commands.Utility.ReportHost.SubmissionCreated, true); + _ = embed.AsSuccess(ctx, this.GetString(this.t.Commands.Utility.ReportHost.Title)); + _ = await this.RespondOrEdit(embed); + } + else if (e.GetCustomId() == MessageComponents.CancelButtonId) + { + this.DeleteOrInvalidate(); + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/ReportTranslationCommand.cs b/ProjectMakoto/Commands/Utility/ReportTranslationCommand.cs new file mode 100644 index 00000000..eb169b65 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/ReportTranslationCommand.cs @@ -0,0 +1,155 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using Octokit; + +namespace ProjectMakoto.Commands; + +internal sealed class ReportTranslationCommand : BaseCommand +{ + internal static readonly string[] labels = new string[] { "Translations", "Low Priority" }; + + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var CommandKey = this.t.Commands.Utility.ReportTranslation; + + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx)) + return; + + var affectedType = (ReportTranslationType)arguments["affected_type"]; + var reasonType = (ReportTranslationReason)arguments["report_type"]; + var component = (string)arguments["component"]; + var additionalInformation = (string?)arguments["additional_information"]; + + var tos_version = 1; + + if (ctx.DbUser.TranslationReports.AcceptedTOS != tos_version) + { + var button = new DiscordButtonComponent(ButtonStyle.Primary, "accepted-tos", this.GetString(CommandKey.AcceptTos), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👍"))); + + var tos_embed = new DiscordEmbedBuilder + { + Description = this.GetString(CommandKey.Tos, + new TVar("1", 1.ToEmotes()), + new TVar("2", 2.ToEmotes()), + new TVar("3", 3.ToEmotes()), + new TVar("4", 4.ToEmotes())) + }.AsAwaitingInput(ctx, this.GetString(CommandKey.Title)); + + if (ctx.DbUser.TranslationReports.AcceptedTOS != 0 && ctx.DbUser.TranslationReports.AcceptedTOS < tos_version) + { + tos_embed.Description = tos_embed.Description.Insert(0, $"**{this.GetString(CommandKey.TosChangedNotice)}**\n\n"); + } + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(tos_embed).AddComponents(button)); + + var TosAccept = await ctx.WaitForButtonAsync(TimeSpan.FromMinutes(2)); + + if (TosAccept.TimedOut) + { + this.ModifyToTimedOut(true); + return; + } + + await TosAccept.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + ctx.DbUser.TranslationReports.AcceptedTOS = tos_version; + } + + if (ctx.Bot.status.LoadedConfig.Secrets.Github.TokenExperiation.GetTotalSecondsUntil() <= 0) + throw new Exception("Required login data for report outdated."); + + if (ctx.DbUser.TranslationReports.FirstRequestTime.GetTimespanSince() > TimeSpan.FromHours(24)) + { + ctx.DbUser.TranslationReports.RequestCount = 0; + ctx.DbUser.TranslationReports.FirstRequestTime = DateTime.UtcNow; + } + + if (ctx.DbUser.TranslationReports.RequestCount >= 3) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.RatelimitReached, true, new TVar("Timestamp", ctx.DbUser.TranslationReports.FirstRequestTime.AddHours(24).ToTimestamp()))) + .AsError(ctx, this.GetString(CommandKey.Title))); + return; + } + + var YesButton = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(this.t.Common.Yes), false, "✅".UnicodeToEmoji().ToComponent()); + var NoButton = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), this.GetString(this.t.Common.No), false, "❌".UnicodeToEmoji().ToComponent()); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder() + .AddEmbed(new DiscordEmbedBuilder() + .WithDescription($"{this.GetString(CommandKey.ConfirmationPrompt, true)}") + .AsAwaitingInput(ctx, this.GetString(CommandKey.Title))) + .AddComponents(YesButton, NoButton)); + + var result = await ctx.ResponseMessage.WaitForButtonAsync(ctx.User); + + if (result.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + if (result.Result.GetCustomId() != YesButton.CustomId) + { + this.DeleteOrInvalidate(); + return; + } + + string GetReason(ReportTranslationReason reason) + { + return reason switch + { + ReportTranslationReason.MissingTranslation => "Missing Translation", + ReportTranslationReason.IncorrectTranslation => "Incorrect Translation", + ReportTranslationReason.ValuesNotFilledIntoString => "Values Missing in Strings", + ReportTranslationReason.Other => "Other", + _ => throw new NotImplementedException(), + }; + } + + string GetType(ReportTranslationType type) + { + return Enum.GetName(typeof(ReportTranslationType), type); + } + + var issue = await ctx.Bot.GithubClient.Issue.Create(ctx.Bot.status.LoadedConfig.Secrets.Github.Username, + ctx.Bot.status.LoadedConfig.Secrets.Github.Repository, + new NewIssue($"{GetReason(reasonType)}: {component.FullSanitize()}") + { + Body = + $"### Component Type: `{GetType(affectedType)}`\n" + + $"### Affected Component: `{component.SanitizeForCode().Replace("@", "")}`\n" + + $"```\n" + + $"{additionalInformation?.Replace("@", "") ?? "No additional information supplied."}\n" + + $"```\n" + + $"


\n" + + $"**Submission Details**\n" + + $"
\n" + + $" [`{ctx.User.GetUsernameWithIdentifier().SanitizeForCode()}`]({ctx.User.AvatarUrl}) (`{ctx.User.Id}`)\n\n" + + $" [`{ctx.Guild.Name.SanitizeForCode()}`]({ctx.Guild.IconUrl}) (`{ctx.Guild.Id}`)\n" + }); + + try + { + _ = await ctx.Bot.GithubClient.Issue.Labels.ReplaceAllForIssue(ctx.Bot.status.LoadedConfig.Secrets.Github.Username, ctx.Bot.status.LoadedConfig.Secrets.Github.Repository, issue.Number, labels); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to update labels on reported issue"); + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder() + .WithDescription(this.GetString(CommandKey.ReportSubmitted, true)) + .AsSuccess(ctx, this.GetString(CommandKey.Title))); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/UploadCommand.cs b/ProjectMakoto/Commands/Utility/UploadCommand.cs new file mode 100644 index 00000000..33a8af5c --- /dev/null +++ b/ProjectMakoto/Commands/Utility/UploadCommand.cs @@ -0,0 +1,64 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class UploadCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var attachment = (DiscordAttachment)arguments["file"]; + var stream = await new HttpClient().GetStreamAsync(attachment.Url); + var filesize = attachment.FileSize ?? 0; + + if (ctx.DbUser.PendingUserUpload is null) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Upload.NoInteraction, true) + }.AsError(ctx)); + return; + } + + if (ctx.DbUser.PendingUserUpload.InteractionHandled) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Upload.AlreadyUploaded, true) + }.AsError(ctx)); + return; + } + + if (ctx.DbUser.PendingUserUpload.TimeOut.GetTotalSecondsUntil() < 0) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Upload.TimedOut, true, + new TVar("Timestamp", ctx.DbUser.PendingUserUpload.TimeOut.ToTimestamp())) + }.AsError(ctx)); + ctx.DbUser.PendingUserUpload = null; + return; + } + + ctx.DbUser.PendingUserUpload.UploadedData = stream; + ctx.DbUser.PendingUserUpload.FileSize = filesize; + ctx.DbUser.PendingUserUpload.InteractionHandled = true; + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.Upload.Uploaded, true) + }.AsSuccess(ctx)); + + await Task.Delay(500); + this.DeleteOrInvalidate(); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/UrbanDictionaryCommand.cs b/ProjectMakoto/Commands/Utility/UrbanDictionaryCommand.cs new file mode 100644 index 00000000..2d81ab58 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/UrbanDictionaryCommand.cs @@ -0,0 +1,133 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class UrbanDictionaryCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForModerate(ctx, true)) + return; + + var term = (string)arguments["term"]; + + if (!ctx.Channel.IsNsfw && ctx.CommandType != Enums.CommandType.ApplicationCommand) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.UrbanDictionary.AdultContentError, true) + }.AsError(ctx)); + return; + } + + var Yes = new DiscordButtonComponent(ButtonStyle.Success, Guid.NewGuid().ToString(), this.GetString(this.t.Common.Yes), false, new DiscordComponentEmoji(true.ToEmote(ctx.Bot))); + var No = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), this.GetString(this.t.Common.No), false, new DiscordComponentEmoji(false.ToEmote(ctx.Bot))); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.UrbanDictionary.AdultContentWarning, true) + }.AsAwaitingInput(ctx)).AddComponents(new List { Yes, No })); + + var Menu = await ctx.WaitForButtonAsync(); + + if (Menu.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + _ = Menu.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (Menu.GetCustomId() == Yes.CustomId) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.UrbanDictionary.LookingUp, true, + new TVar("Term", term)) + }.AsLoading(ctx)); + + if (term.IsNullOrWhiteSpace()) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.UrbanDictionary.LookupFail, true, + new TVar("Term", term)) + }.AsError(ctx)); + return; + } + + HttpClient client = new(); + + string query; + + using (var content = new FormUrlEncodedContent(new Dictionary + { + { "term", term }, + })) + { + query = await content.ReadAsStringAsync(); + } + + var Response = await client.GetAsync($"https://api.urbandictionary.com/v0/define?{query}"); + + if (!Response.IsSuccessStatusCode) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.UrbanDictionary.LookupFail, true, + new TVar("Term", term)) + }.AsError(ctx)); + return; + } + + List Definitions = null; + + try + { + var rawDefinitions = JsonConvert.DeserializeObject(await Response.Content.ReadAsStringAsync()); + Definitions = rawDefinitions.list.ToList(); + Definitions.Sort((a, b) => b.RatingRatio.CompareTo(a.RatingRatio)); + } + catch (Exception ex) + { + Log.Error(ex, string.Empty); + } + + if (!Definitions.IsNotNullAndNotEmpty()) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder + { + Description = this.GetString(this.t.Commands.Utility.UrbanDictionary.NotExist, true, new TVar("Term", term)) + }.AsError(ctx)); + return; + } + + var embeds = Definitions.Take(3).Select(x => new DiscordEmbedBuilder + { + Title = $"**{x.word.Replace("**", "")}** - {this.GetString(this.t.Commands.Utility.UrbanDictionary.WrittenBy, new TVar("Author", x.author))}", + Description = $"**{this.GetString(this.t.Commands.Utility.UrbanDictionary.Definition)}**\n\n" + + $"{x.definition.Replace("[", "").Replace("]", "")}\n\n" + + $"**{this.GetString(this.t.Commands.Utility.UrbanDictionary.Example)}**\n\n" + + $"{x.example.Replace("[", "").Replace("]", "")}\n\n" + + $"👍 `{x.thumbs_up}` | 👎 `{x.thumbs_down}` | 🕒 {Formatter.Timestamp(x.written_on, TimestampFormat.LongDateTime)}", + Url = x.permalink + }.AsInfo(ctx).Build()).ToList(); + + _ = await this.RespondOrEdit(new DiscordMessageBuilder().AddEmbeds(embeds)); + } + else + { + this.DeleteOrInvalidate(); + } + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/UserInfoCommand.cs b/ProjectMakoto/Commands/Utility/UserInfoCommand.cs new file mode 100644 index 00000000..487a574d --- /dev/null +++ b/ProjectMakoto/Commands/Utility/UserInfoCommand.cs @@ -0,0 +1,234 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands; + +internal sealed class UserInfoCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + var victim = (DiscordUser)arguments["user"]; + + if (await ctx.DbUser.Cooldown.WaitForLight(ctx)) + return; + + victim ??= ctx.User; + + victim = await victim.GetFromApiAsync(); + + DiscordMember? bMember = null; + + try + { + bMember = await ctx.Guild.GetMemberAsync(victim.Id); + } + catch { } + + static string GetStatusIcon(UserStatus? status) + { + return status switch + { + UserStatus.Online => "🟢", + UserStatus.DoNotDisturb => "🔴", + UserStatus.Idle => "🟡", + UserStatus.Streaming => "🟣", + _ => "⚪", + }; + } + + var GenerateRoles = ""; + + if (bMember is not null) + { + GenerateRoles = bMember.Roles.Any() ? string.Join(", ", bMember.Roles.Select(x => x.Mention)) : this.GetString(this.t.Commands.Utility.UserInfo.NoRoles, true); + } + else + { + GenerateRoles = ctx.DbGuild.Members[victim.Id].MemberRoles.Length > 0 + ? string.Join(", ", ctx.DbGuild.Members[victim.Id].MemberRoles.Where(x => ctx.Guild.Roles.ContainsKey(x.Id)).Select(x => $"{ctx.Guild.GetRole(x.Id).Mention}")) + : this.GetString(this.t.Commands.Utility.UserInfo.NoStoredRoles, true); + } + + var banList = await ctx.Guild.GetBansAsync(); + var isBanned = banList.Any(x => x.User.Id == victim.Id); + var banDetails = (isBanned ? banList.First(x => x.User.Id == victim.Id) : null); + + var builder = new DiscordMessageBuilder(); + + var embed = new DiscordEmbedBuilder() + { + Author = new DiscordEmbedBuilder.EmbedAuthor + { + Name = $"{(victim.IsBot ? $"[{(victim.IsSystem ?? false ? this.GetString(this.t.Commands.Utility.UserInfo.System) : $"{this.GetString(this.t.Commands.Utility.UserInfo.Bot)}{(victim.IsVerifiedBot ? "✅" : "❎")}")}] " : "")}{victim.GetUsernameWithIdentifier()}", + Url = victim.ProfileUrl + }, + Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail + { + Url = (string.IsNullOrWhiteSpace(victim.AvatarUrl) ? "https://cdn.discordapp.com/attachments/712761268393738301/899051918037504040/QuestionMark.png" : victim.AvatarUrl) + }, + Color = victim.BannerColor ?? new("2f3136"), + ImageUrl = victim.BannerUrl, + Footer = new DiscordEmbedBuilder.EmbedFooter + { + Text = $"User-Id: {victim.Id}" + }, + Description = $"{(bMember is null ? $"{(ctx.DbGuild.Members[victim.Id].FirstJoinDate == DateTime.MinValue ? this.GetString(this.t.Commands.Utility.UserInfo.NeverJoined, true) : $"{(isBanned ? this.GetString(this.t.Commands.Utility.UserInfo.IsBanned, true) : this.GetString(this.t.Commands.Utility.UserInfo.JoinedBefore, true))}")}\n\n" : "")}" + + $"{(ctx.Bot.globalBans.ContainsKey(victim.Id) ? $"💀 **{this.GetString(this.t.Commands.Utility.UserInfo.GlobalBanned, true)}**\n" : "")}" + + $"{(ctx.Bot.status.TeamOwner == victim.Id ? $"👑 **{this.GetString(this.t.Commands.Utility.UserInfo.BotOwner, true)}**\n" : "")}" + + $"{(ctx.Bot.status.TeamMembers.Contains(victim.Id) ? $"🔏 **{this.GetString(this.t.Commands.Utility.UserInfo.BotStaff, true)}**\n\n" : "")}" + + $"{(bMember is not null && bMember.IsOwner ? $"✨ {this.GetString(this.t.Commands.Utility.UserInfo.Owner, true)}\n" : "")}" + + $"{(victim.IsStaff ? $"📘 **{this.GetString(this.t.Commands.Utility.UserInfo.DiscordStaff, true)}**\n" : "")}" + + $"{(victim.IsMod ? $"⚒ {this.GetString(this.t.Commands.Utility.UserInfo.CertifiedMod, true)}\n" : "")}" + + $"{(victim.IsBotDev ? $"⌨ {this.GetString(this.t.Commands.Utility.UserInfo.VerifiedBotDeveloper, true)}\n" : "")}" + + $"{(victim.IsPartner ? $"👥 {this.GetString(this.t.Commands.Utility.UserInfo.DiscordPartner, true)}\n" : "")}" + + $"{(bMember is not null && bMember.IsPending.HasValue && bMember.IsPending.Value ? $"❗ {this.GetString(this.t.Commands.Utility.UserInfo.PendingMembership, true)}\n" : "")}" + + $"\n**{(bMember is null ? $"{this.GetString(this.t.Commands.Utility.UserInfo.Roles)} ({this.GetString(this.t.Commands.Utility.UserInfo.Backup)})" : this.GetString(this.t.Commands.Utility.UserInfo.Roles))}**\n{GenerateRoles}" + }; + + if (ctx.Bot.globalNotes.TryGetValue(victim.Id, out var globalNotes) && globalNotes.Notes.Length != 0) + { + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.BotNotes), $"{string.Join("\n\n", ctx.Bot.globalNotes[victim.Id].Notes.Select(x => $"{x.Reason.FullSanitize()} - <@{x.Moderator}> {x.Timestamp.ToTimestamp()}"))}".TruncateWithIndication(512))); + } + + if (ctx.Bot.globalBans.TryGetValue(victim.Id, out var globalBanDetails)) + { + var gBanMod = await ctx.Client.GetUserAsync(ctx.Bot.globalBans[victim.Id].Moderator); + + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.GlobalBanReason), $"`{((string.IsNullOrWhiteSpace(globalBanDetails.Reason) || globalBanDetails.Reason == "-") ? this.GetString(this.t.Commands.Utility.UserInfo.NoReason) : globalBanDetails.Reason).SanitizeForCode()}`", true)); + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.GlobalBanMod), $"`{gBanMod.GetUsernameWithIdentifier()}`", true)); + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.GlobalBanDate), $"{Formatter.Timestamp(globalBanDetails.Timestamp)} ({Formatter.Timestamp(globalBanDetails.Timestamp, TimestampFormat.LongDateTime)})", true)); + } + + if (isBanned) + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.BanDetails), $"`{(string.IsNullOrWhiteSpace(banDetails?.Reason) ? this.GetString(this.t.Commands.Utility.UserInfo.NoReason) : $"{banDetails.Reason}")}`", false)); + + var InviterButtonAdded = false; + + if (ctx.DbGuild.InviteTracker.Enabled) + { + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.InvitedBy), $"{(ctx.DbGuild.Members[victim.Id].InviteTracker.Code.IsNullOrWhiteSpace() ? this.GetString(this.t.Commands.Utility.UserInfo.NoInviter, true) : $"<@{ctx.DbGuild.Members[victim.Id].InviteTracker.UserId}> (`{ctx.DbGuild.Members[victim.Id].InviteTracker.UserId}`)")}", true)); + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.UsersInvited), $"`{(ctx.DbGuild.Members.Fetch().Where(b => b.Value.InviteTracker.UserId == victim.Id)).Count()}`", true)); + + if (!ctx.DbGuild.Members[victim.Id].InviteTracker.Code.IsNullOrWhiteSpace()) + { + InviterButtonAdded = true; + _ = builder.AddComponents(new DiscordButtonComponent(ButtonStyle.Secondary, $"userinfo-inviter", this.GetString(this.t.Commands.Utility.UserInfo.ShowProfileInviter), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("👤")))); + } + } + + if (bMember is not null) + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.ServerJoinDate), $"{Formatter.Timestamp(bMember.JoinedAt, TimestampFormat.LongDateTime)}", true)); + else + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.ServerLeaveDate), (ctx.DbGuild.Members[victim.Id].LastLeaveDate != DateTime.MinValue ? $"{Formatter.Timestamp(ctx.DbGuild.Members[victim.Id].LastLeaveDate, TimestampFormat.LongDateTime)} ({Formatter.Timestamp(ctx.DbGuild.Members[victim.Id].LastLeaveDate)})" : this.GetString(this.t.Commands.Utility.UserInfo.NeverJoined, true)), true)); + + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.FirstJoinDate), (ctx.DbGuild.Members[victim.Id].FirstJoinDate != DateTime.MinValue ? $"{Formatter.Timestamp(ctx.DbGuild.Members[victim.Id].FirstJoinDate, TimestampFormat.LongDateTime)} ({Formatter.Timestamp(ctx.DbGuild.Members[victim.Id].FirstJoinDate)})" : this.GetString(this.t.Commands.Utility.UserInfo.NeverJoined, true)), true)); + + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.AccountCreationDate), $"{Formatter.Timestamp(victim.CreationTimestamp, TimestampFormat.LongDateTime)}", true)); + + if (bMember is not null && bMember.PremiumSince.HasValue) + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.ServerBoosterSince), $"{Formatter.Timestamp(bMember.PremiumSince.Value, TimestampFormat.LongDateTime)}", true)); + + if (!string.IsNullOrWhiteSpace(victim.Pronouns)) + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.Pronouns), $"`{victim.Pronouns}`", true)); + + if (victim.BannerColor is not null) + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.BannerColor), $"`{victim.BannerColor.Value}`", true)); + + string TranslatePresence(UserStatus status) + { + return status switch + { + UserStatus.Online => this.GetString(this.t.Commands.Utility.UserInfo.Online), + UserStatus.Idle => this.GetString(this.t.Commands.Utility.UserInfo.Idle), + UserStatus.DoNotDisturb => this.GetString(this.t.Commands.Utility.UserInfo.DoNotDisturb), + UserStatus.Streaming => this.GetString(this.t.Commands.Utility.UserInfo.Streaming), + UserStatus.Offline => this.GetString(this.t.Commands.Utility.UserInfo.Offline), + UserStatus.Invisible => this.GetString(this.t.Commands.Utility.UserInfo.Offline), + _ => status.ToString(), + }; + } + + try + { + if (victim.Presence is not null) + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.Presence), $"{GetStatusIcon(victim.Presence.Status)} `{TranslatePresence(victim.Presence.Status)}`\n" + + $"󠂪 󠂪 󠂪 󠂪{GetStatusIcon(victim.Presence.ClientStatus.Desktop.HasValue ? victim.Presence.ClientStatus.Desktop.Value : UserStatus.Offline)} {this.GetString(this.t.Commands.Utility.UserInfo.Desktop, true)}\n" + + $"󠂪 󠂪 󠂪 󠂪{GetStatusIcon(victim.Presence.ClientStatus.Mobile.HasValue ? victim.Presence.ClientStatus.Mobile.Value : UserStatus.Offline)} {this.GetString(this.t.Commands.Utility.UserInfo.Mobile, true)}\n" + + $"󠂪 󠂪 󠂪 󠂪{GetStatusIcon(victim.Presence.ClientStatus.Web.HasValue ? victim.Presence.ClientStatus.Web.Value : UserStatus.Offline)} {this.GetString(this.t.Commands.Utility.UserInfo.Web, true)}\n\n", true)); + } + catch { } + + string TranslateActivity(ActivityType type) + { + return type switch + { + ActivityType.Playing => this.GetString(this.t.Commands.Utility.UserInfo.Playing), + ActivityType.Streaming => this.GetString(this.t.Commands.Utility.UserInfo.Streaming), + ActivityType.ListeningTo => this.GetString(this.t.Commands.Utility.UserInfo.ListeningTo), + ActivityType.Watching => this.GetString(this.t.Commands.Utility.UserInfo.Watching), + ActivityType.Competing => this.GetString(this.t.Commands.Utility.UserInfo.Competing), + _ => type.ToString(), + }; + } + + try + { + if (victim.Presence is not null && victim.Presence.Activities is not null && victim.Presence.Activities?.Count > 0) + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.Activities), string.Join("\n", victim.Presence.Activities.Select(x => $"{(x.ActivityType == ActivityType.Custom ? $"• {this.GetString(this.t.Commands.Utility.UserInfo.Status)}: `{x.CustomStatus.Emoji?.Name ?? "None"}`{(string.IsNullOrWhiteSpace(x.CustomStatus.Name) ? "" : $" {x.CustomStatus.Name}")}\n" : $"• {TranslateActivity(x.ActivityType)} {x.Name}")}")), true)); + } + catch { } + + if (bMember is not null && bMember.CommunicationDisabledUntil.HasValue && bMember.CommunicationDisabledUntil.Value.GetTotalSecondsUntil() > 0) + _ = embed.AddField(new DiscordEmbedField(this.GetString(this.t.Commands.Utility.UserInfo.TimedOutUntil), $"{Formatter.Timestamp(bMember.CommunicationDisabledUntil.Value, TimestampFormat.LongDateTime)}", true)); + + _ = await this.RespondOrEdit(builder.WithEmbed(embed)); + + if (InviterButtonAdded) + { + _ = ctx.ResponseMessage.WaitForButtonAsync(ctx.User, TimeSpan.FromMinutes(15)).ContinueWith(async x => + { + if (x.IsFaulted) + return; + + var e = x.Result; + + if (e.TimedOut) + { + this.ModifyToTimedOut(); + return; + } + + _ = e.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + DiscordUser newVictim; + + try + { + newVictim = await ctx.Client.GetUserAsync(ctx.DbGuild.Members[victim.Id].InviteTracker.UserId); + } + catch (Exception) + { + _ = e.Result.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder() + .AddEmbed(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.UserInfo.FetchUserError, true, new TVar("User", ctx.DbGuild.Members[victim.Id].InviteTracker.UserId))).AsError(ctx))); + return; + } + + await this.ExecuteCommand(ctx, new Dictionary + { + { "victim", newVictim } + }); + + return; + }).Add(ctx.Bot, ctx); + } + }); + } +} diff --git a/ProjectMakoto/Commands/Utility/VcCreator/BanCommand.cs b/ProjectMakoto/Commands/Utility/VcCreator/BanCommand.cs new file mode 100644 index 00000000..ed9a82a4 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/VcCreator/BanCommand.cs @@ -0,0 +1,62 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.VcCreator; + +internal sealed class BanCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx)) + return; + + var victim = (DiscordMember)arguments["user"]; + var channel = ctx.Member.VoiceState?.Channel; + + if (!ctx.DbGuild.VcCreator.CreatedChannels.Any(x => x.ChannelId == (channel?.Id ?? 0))) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannel, true)).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId != ctx.User.Id) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannelOwner, true)).AsError(ctx)); + return; + } + + if (!channel.Users.Any(x => x.Id == victim.Id)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.VictimNotPresent, true, + new TVar("User", victim.Mention))).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId == victim.Id) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Ban.CannotBanSelf, true)).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].BannedUsers.Contains(victim.Id)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Ban.VictimAlreadyBanned, true, + new TVar("User", victim.Mention))).AsError(ctx)); + return; + } + + ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].BannedUsers = ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].BannedUsers.Add(victim.Id); + await channel.AddOverwriteAsync(victim, deny: Permissions.UseVoice); + await victim.DisconnectFromVoiceAsync(); + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Ban.VictimBanned, true, new TVar("User", victim.Mention))).AsError(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/VcCreator/ChangeOwnerCommand.cs b/ProjectMakoto/Commands/Utility/VcCreator/ChangeOwnerCommand.cs new file mode 100644 index 00000000..468541cc --- /dev/null +++ b/ProjectMakoto/Commands/Utility/VcCreator/ChangeOwnerCommand.cs @@ -0,0 +1,65 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.VcCreator; + +internal sealed class ChangeOwnerCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx)) + return; + + var victim = (DiscordMember)arguments["user"]; + var channel = ctx.Member.VoiceState?.Channel; + + if (!ctx.DbGuild.VcCreator.CreatedChannels.Any(x => x.ChannelId == (channel?.Id ?? 0))) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannel, true)).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId != ctx.User.Id) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannelOwner, true)).AsError(ctx)); + return; + } + + if (victim.IsBot) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.VictimIsBot, true, new TVar("User", victim.Mention))).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId == victim.Id) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.ChangeOwner.AlreadyOwner, true, new TVar("User", victim.Mention))).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId != ctx.User.Id) + { + if (ctx.Member.Permissions.HasPermission(Permissions.ManageChannels)) + { + ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId = victim.Id; + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.ChangeOwner.ForceAssign, true, new TVar("User", victim.Mention))).AsSuccess(ctx)); + return; + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannelOwner, true)).AsError(ctx)); + return; + } + + ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId = victim.Id; + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.ChangeOwner.Success, true, new TVar("User", victim.Mention))).AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/VcCreator/CloseCommand.cs b/ProjectMakoto/Commands/Utility/VcCreator/CloseCommand.cs new file mode 100644 index 00000000..adad6c72 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/VcCreator/CloseCommand.cs @@ -0,0 +1,39 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.VcCreator; + +internal sealed class CloseCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx)) + return; + + var channel = ctx.Member.VoiceState?.Channel; + + if (!ctx.DbGuild.VcCreator.CreatedChannels.Any(x => x.ChannelId == (channel?.Id ?? 0))) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannel, true)).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId != ctx.User.Id) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannelOwner, true)).AsError(ctx)); + return; + } + + await channel.ModifyAsync(x => x.PermissionOverwrites = channel.PermissionOverwrites.Merge(ctx.Guild.EveryoneRole, Permissions.None, Permissions.UseVoice)); + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Close.Success, true)).AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/VcCreator/InviteCommand.cs b/ProjectMakoto/Commands/Utility/VcCreator/InviteCommand.cs new file mode 100644 index 00000000..25d22235 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/VcCreator/InviteCommand.cs @@ -0,0 +1,69 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.VcCreator; + +internal sealed class InviteCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx)) + return; + + var victim = (DiscordMember)arguments["user"]; + var channel = ctx.Member.VoiceState?.Channel; + + if (!ctx.DbGuild.VcCreator.CreatedChannels.Any(x => x.ChannelId == (channel?.Id ?? 0))) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannel, true)).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId != ctx.User.Id) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannelOwner, true)).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId == victim.Id) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Invite.CannotInviteSelf, true)).AsError(ctx)); + return; + } + + if (channel.Users.Any(x => x.Id == victim.Id)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Invite.AlreadyPresent, true, new TVar("User", victim.Mention))).AsError(ctx)); + return; + } + + if (victim.IsBot) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.VictimIsBot, true, new TVar("User", victim.Mention))).AsError(ctx)); + return; + } + + await channel.AddOverwriteAsync(victim, Permissions.UseVoice); + + try + { + _ = await victim.SendMessageAsync(this.t.Commands.Utility.VoiceChannelCreator.Invite.VictimMessage.Get(ctx.Bot.Users[victim.Id]).Build(new TVar("Channel", channel.Mention))); + } + catch (DisCatSharp.Exceptions.UnauthorizedException) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Invite.PartialSuccess, true, new TVar("User", victim.Mention))).AsError(ctx)); + return; + } + + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Invite.Success, true, new TVar("User", victim.Mention))).AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/VcCreator/KickCommand.cs b/ProjectMakoto/Commands/Utility/VcCreator/KickCommand.cs new file mode 100644 index 00000000..a08884ed --- /dev/null +++ b/ProjectMakoto/Commands/Utility/VcCreator/KickCommand.cs @@ -0,0 +1,52 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.VcCreator; + +internal sealed class KickCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx)) + return; + + var victim = (DiscordMember)arguments["user"]; + var channel = ctx.Member.VoiceState?.Channel; + + if (!ctx.DbGuild.VcCreator.CreatedChannels.Any(x => x.ChannelId == (channel?.Id ?? 0))) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannel, true)).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId != ctx.User.Id) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannelOwner, true)).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId == victim.Id) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Kick.CannotKickSelf, true)).AsError(ctx)); + return; + } + + if (!channel.Users.Any(x => x.Id == victim.Id)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.VictimNotPresent, true, new TVar("User", victim.Mention))).AsError(ctx)); + return; + } + + await victim.DisconnectFromVoiceAsync(); + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Kick.Success, true, new TVar("User", victim.Mention))).AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/VcCreator/LimitCommand.cs b/ProjectMakoto/Commands/Utility/VcCreator/LimitCommand.cs new file mode 100644 index 00000000..341506f7 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/VcCreator/LimitCommand.cs @@ -0,0 +1,46 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.VcCreator; + +internal sealed class LimitCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx)) + return; + + var newLimit = (uint)arguments["limit"]; + var channel = ctx.Member.VoiceState?.Channel; + + if (!ctx.DbGuild.VcCreator.CreatedChannels.Any(x => x.ChannelId == (channel?.Id ?? 0))) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannel, true)).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId != ctx.User.Id) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannelOwner, true)).AsError(ctx)); + return; + } + + if (newLimit > 99) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Limit.OutsideRange, true)).AsError(ctx)); + return; + } + + await channel.ModifyAsync(x => x.UserLimit = newLimit.ToInt32()); + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Limit.Success, true, new TVar("Count", newLimit))).AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/VcCreator/NameCommand.cs b/ProjectMakoto/Commands/Utility/VcCreator/NameCommand.cs new file mode 100644 index 00000000..28b32966 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/VcCreator/NameCommand.cs @@ -0,0 +1,54 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.VcCreator; + +internal sealed class NameCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx)) + return; + + var newName = (string)arguments["name"]; + var channel = ctx.Member.VoiceState?.Channel; + + newName = (newName.IsNullOrWhiteSpace() ? this.GetGuildString(this.t.Commands.Utility.VoiceChannelCreator.Events.DefaultChannelName, new TVar("User", ctx.Member.DisplayName)) : newName); + + if (!ctx.DbGuild.VcCreator.CreatedChannels.Any(x => x.ChannelId == (channel?.Id ?? 0))) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannel, true)).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId != ctx.User.Id) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannelOwner, true)).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].LastRename.GetTimespanSince() < TimeSpan.FromMinutes(5)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Name.Cooldown, true, + new TVar("Timestamp", ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].LastRename.AddMinutes(5).ToTimestamp()))).AsError(ctx)); + return; + } + + foreach (var b in ctx.Bot.ProfanityList) + newName = newName.Replace(b, new String('*', b.Length)); + + ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].LastRename = DateTime.UtcNow; + await channel.ModifyAsync(x => x.Name = newName.TruncateWithIndication(25)); + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Name.Success, true, + new TVar("Name", newName, true))).AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/VcCreator/OpenCommand.cs b/ProjectMakoto/Commands/Utility/VcCreator/OpenCommand.cs new file mode 100644 index 00000000..9c0e5ee2 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/VcCreator/OpenCommand.cs @@ -0,0 +1,39 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.VcCreator; + +internal sealed class OpenCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx)) + return; + + var channel = ctx.Member.VoiceState?.Channel; + + if (!ctx.DbGuild.VcCreator.CreatedChannels.Any(x => x.ChannelId == (channel?.Id ?? 0))) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannel, true)).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId != ctx.User.Id) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannelOwner, true)).AsError(ctx)); + return; + } + + await channel.ModifyAsync(x => x.PermissionOverwrites = channel.PermissionOverwrites.Merge(ctx.Guild.EveryoneRole, Permissions.None, Permissions.None, Permissions.UseVoice)); + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Open.Success, true)).AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Commands/Utility/VcCreator/UnbanCommand.cs b/ProjectMakoto/Commands/Utility/VcCreator/UnbanCommand.cs new file mode 100644 index 00000000..18083996 --- /dev/null +++ b/ProjectMakoto/Commands/Utility/VcCreator/UnbanCommand.cs @@ -0,0 +1,47 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Commands.VcCreator; + +internal sealed class UnbanCommand : BaseCommand +{ + public override Task ExecuteCommand(SharedCommandContext ctx, Dictionary arguments) + { + return Task.Run(async () => + { + if (await ctx.DbUser.Cooldown.WaitForHeavy(ctx)) + return; + + var victim = (DiscordMember)arguments["user"]; + var channel = ctx.Member.VoiceState?.Channel; + + if (!ctx.DbGuild.VcCreator.CreatedChannels.Any(x => x.ChannelId == (channel?.Id ?? 0))) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannel, true)).AsError(ctx)); + return; + } + + if (ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].OwnerId != ctx.User.Id) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.NotAVccChannelOwner, true)).AsError(ctx)); + return; + } + + if (!ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].BannedUsers.Contains(victim.Id)) + { + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Unban.VictimNotBanned, true, new TVar("User", victim.Mention))).AsError(ctx)); + return; + } + + ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].BannedUsers = ctx.DbGuild.VcCreator.CreatedChannels[channel.Id].BannedUsers.Remove(x => x.ToString(), victim.Id); + await channel.AddOverwriteAsync(victim, deny: Permissions.None); + _ = await this.RespondOrEdit(new DiscordEmbedBuilder().WithDescription(this.GetString(this.t.Commands.Utility.VoiceChannelCreator.Unban.VictimUnbanned, true, new TVar("User", victim.Mention))).AsSuccess(ctx)); + }); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Database/DatabaseClient.TypeInfo.cs b/ProjectMakoto/Database/DatabaseClient.TypeInfo.cs new file mode 100644 index 00000000..fc902deb --- /dev/null +++ b/ProjectMakoto/Database/DatabaseClient.TypeInfo.cs @@ -0,0 +1,98 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Database; +partial class DatabaseClient +{ + /// + /// Gets the name of this table. + /// + /// The type to get the table name for. + /// The name of the table. + /// + internal string GetTableName(Type type) + => type.GetCustomAttribute()?.Name ?? throw new InvalidOperationException("Type is not a table").AddData("info", type); + + /// + /// Gets all valid properties of this table, recursively. + /// + /// The type to get the valid properties from. + /// An array of valid s. + internal PropertyInfo[] GetValidProperties(Type type) + { + var propertyList = new List(); + + void AddProperties(Type type) + { + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (property.GetCustomAttribute() is null && + property.GetCustomAttribute() is null) + continue; + + if (propertyList.Contains(property)) + continue; + + propertyList.Add(property); + + if (property.GetCustomAttribute() is not null) + { + AddProperties(property.PropertyType); + } + } + } + + AddProperties(type); + + return propertyList.ToArray(); + } + + /// + /// Get's all column information backed by the specified property. + /// + /// The property to get the information for. + /// All values that are backed by this property. + /// + internal (string ColumnName, ColumnTypes ColumnType, bool Primary, long? MaxValue, string? Collation, bool Nullable, string Default) GetPropertyInfo(PropertyInfo info) + => (info.GetCustomAttribute()?.Name ?? throw new InvalidOperationException("Not a valid column.").AddData("info", info), + info.GetCustomAttribute()?.Type ?? throw new InvalidOperationException("Not a valid column.").AddData("info", info), + info.GetCustomAttribute()?.Primary ?? false, + info.GetCustomAttribute()?.MaxValue ?? (this.GetDefaultMaxValue(info.GetCustomAttribute().Type, out var max) ? max : null), + this.UsesCollation(info.GetCustomAttribute().Type) ? this.Bot.status.LoadedConfig.Secrets.Database.Collation : null, + info.GetCustomAttribute()?.Nullable ?? false, + info.GetCustomAttribute()?.Default); + + private bool UsesCollation(ColumnTypes columnType) + => columnType switch + { + ColumnTypes.BigInt or ColumnTypes.Int or ColumnTypes.TinyInt => false, + ColumnTypes.LongText or ColumnTypes.Text or ColumnTypes.VarChar => true, + _ => throw new InvalidOperationException(), + }; + + internal bool GetDefaultMaxValue(ColumnTypes type, out long maxValue) + { + maxValue = type switch + { + ColumnTypes.BigInt => 20, + ColumnTypes.Int => 11, + ColumnTypes.TinyInt => 4, + _ => -1, + }; + + return type switch + { + ColumnTypes.BigInt or ColumnTypes.Int or ColumnTypes.TinyInt => true, + _ => false, + }; + } + + internal (string ColumnName, ColumnTypes ColumnType, bool Primary, long? MaxValue, string? Collation, bool Nullable, string Default) GetPrimaryKey(Type type) + => this.GetPropertyInfo(this.GetValidProperties(type).First(x => this.GetPropertyInfo(x).Primary)); +} diff --git a/ProjectMakoto/Database/DatabaseClient.cs b/ProjectMakoto/Database/DatabaseClient.cs new file mode 100644 index 00000000..481f3852 --- /dev/null +++ b/ProjectMakoto/Database/DatabaseClient.cs @@ -0,0 +1,876 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using System.Collections.Concurrent; +using ProjectMakoto.Entities.Guilds; + +namespace ProjectMakoto.Database; + +public sealed partial class DatabaseClient : RequiresBotReference +{ + private DatabaseClient(Bot bot) : base(bot) + { + } + + public class MySqlConnectionInformation + { + public Func CreateConnection { get; set; } + } + + internal MySqlConnectionInformation mainDatabaseConnection; + internal MySqlConnectionInformation guildDatabaseConnection; + internal MySqlConnectionInformation pluginDatabaseConnection; + + public bool Disposed { get; private set; } = false; + + internal static async Task InitializeDatabase(Bot bot) + { + Log.Information("Connecting to database.."); + + var databaseClient = new DatabaseClient(bot) + { + mainDatabaseConnection = new() + { + CreateConnection = () => + { + var conn = new MySqlConnection($"Server={bot.status.LoadedConfig.Secrets.Database.Host};" + + $"Port={bot.status.LoadedConfig.Secrets.Database.Port};" + + $"User Id={bot.status.LoadedConfig.Secrets.Database.Username};" + + $"Password={bot.status.LoadedConfig.Secrets.Database.Password};" + + $"Connection Timeout=60;" + + $"Connection Lifetime=30;" + + $"Database={bot.status.LoadedConfig.Secrets.Database.MainDatabaseName};"); + return conn; + } + }, + + guildDatabaseConnection = new() + { + CreateConnection = () => + { + var conn = new MySqlConnection($"Server={bot.status.LoadedConfig.Secrets.Database.Host};" + + $"Port={bot.status.LoadedConfig.Secrets.Database.Port};" + + $"User Id={bot.status.LoadedConfig.Secrets.Database.Username};" + + $"Password={bot.status.LoadedConfig.Secrets.Database.Password};" + + $"Connection Timeout=60;" + + $"Connection Lifetime=30;" + + $"Database={bot.status.LoadedConfig.Secrets.Database.GuildDatabaseName};"); + return conn; + } + } + }; + + if (bot.status.LoadedConfig.EnablePlugins) + databaseClient.pluginDatabaseConnection = new() + { + CreateConnection = () => + { + var conn = new MySqlConnection($"Server={bot.status.LoadedConfig.Secrets.Database.Host};" + + $"Port={bot.status.LoadedConfig.Secrets.Database.Port};" + + $"User Id={bot.status.LoadedConfig.Secrets.Database.Username};" + + $"Password={bot.status.LoadedConfig.Secrets.Database.Password};" + + $"Connection Timeout=60;" + + $"Connection Lifetime=30;" + + $"Database={bot.status.LoadedConfig.Secrets.Database.PluginDatabaseName};"); + return conn; + } + }; + + await databaseClient.SyncStandardTable(new KeyValuePair[] + { + new(typeof(User), ""), + new(typeof(Guild), ""), + new(typeof(PhishingUrlEntry), ""), + new(typeof(SubmittedUrlEntry), ""), + new(typeof(GlobalNote), ""), + new(typeof(BanDetails), "banned_guilds"), + new(typeof(BanDetails), "banned_users"), + new(typeof(BanDetails), "globalbans"), + new(typeof(DatabaseULongList), "objected_users") + }, databaseClient.mainDatabaseConnection); + + var remoteTables = databaseClient.ListTables(databaseClient.guildDatabaseConnection); + foreach (var tableName in databaseClient.ListTables(databaseClient.guildDatabaseConnection).Where(x => x.IsDigitsOnly())) + { + var internalTable = typeof(Member); + var propertyList = databaseClient.GetValidProperties(internalTable); + + if (!remoteTables.Contains(tableName)) + { + Log.Warning("Missing table '{Name}'. Creating..", tableName); + + _ = databaseClient.CreateTable(tableName, internalTable, databaseClient.guildDatabaseConnection); + } + + var remoteColumns = databaseClient.ListColumns(tableName, databaseClient.guildDatabaseConnection); + + foreach (var internalColumn in propertyList) + { + (string ColumnName, ColumnTypes ColumnType, bool Primary, long? MaxValue, string? Collation, bool Nullable, string Default) columnInfo; + + try + { + columnInfo = databaseClient.GetPropertyInfo(internalColumn); + } + catch (Exception) + { + continue; + } + + if (columnInfo.ColumnName is null || columnInfo.ColumnType == ColumnTypes.Unknown) + continue; + + if (!remoteColumns.Any(x => x.Name.ToLower() == columnInfo.ColumnName.ToLower())) + { + Log.Warning("Missing column '{Column}' in '{Table}'. Creating..", columnInfo.ColumnName, tableName); + + databaseClient.AddColumn(databaseClient.guildDatabaseConnection, tableName, internalColumn, columnInfo); + remoteColumns = databaseClient.ListColumns(tableName, databaseClient.guildDatabaseConnection); + } + + var remoteColumn = remoteColumns.First(x => x.Name == columnInfo.ColumnName); + + var typeMatches = remoteColumn.Type.ToLower() == columnInfo.ColumnType.GetName().ToLower() + (columnInfo.MaxValue is not null ? $"({columnInfo.MaxValue})" : ""); + var nullabilityMatches = remoteColumn.Nullable == columnInfo.Nullable; + var defaultMatches = remoteColumn.Default == columnInfo.Default; + + if (!typeMatches || !nullabilityMatches || !defaultMatches) + { + Log.Warning("Wrong data type for column '{Column}' in '{Table}'\nType: {TypeMatch} ({Type1}:{Type2})\nNullable: {NullableMatch}\nDefault: {Default} ({Default1}:{Default2})", + columnInfo.ColumnName, + tableName, + typeMatches, remoteColumn.Type.ToLower(), columnInfo.ColumnType.GetName().ToLower() + (columnInfo.MaxValue is not null ? $"({columnInfo.MaxValue})" : ""), + nullabilityMatches, + defaultMatches, remoteColumn.Default, columnInfo.Default); + + databaseClient.ModifyColumn(databaseClient.guildDatabaseConnection, tableName, internalColumn, columnInfo); + remoteColumns = databaseClient.ListColumns(tableName, databaseClient.guildDatabaseConnection); + } + } + + foreach (var remoteColumn in remoteColumns) + { + if (!propertyList.Any(x => x.GetCustomAttribute()?.Name == remoteColumn.Name)) + { + Log.Warning("Invalid column '{Column}' in '{Table}'", remoteColumn.Name, tableName); + + databaseClient.DropColumn(databaseClient.guildDatabaseConnection, tableName, remoteColumn.Name); + remoteColumns = databaseClient.ListColumns(tableName, databaseClient.guildDatabaseConnection); + } + } + } + + var pluginTables = new List>(); + + if (bot.status.LoadedConfig.EnablePlugins) + foreach (var plugin in bot.Plugins) + { + var prefix = databaseClient.MakePluginTablePrefix(plugin.Value); + var currentPluginTables = await plugin.Value.RegisterTables(); + + if (currentPluginTables.GroupBy(x => x.FullName).Any(x => x.Count() >= 2)) + throw new Exception("You cannot use the same type twice."); + + if (currentPluginTables.GroupBy(x => databaseClient.GetTableName(x)).Any(x => x.Count() >= 2)) + throw new Exception("You cannot use the same tablename twice."); + + if (currentPluginTables.Any(x => x.BaseType != typeof(Plugins.PluginDatabaseTable))) + throw new Exception("One or more types do not inherit PluginDatabaseTable"); + + if (currentPluginTables.Any(x => x.GetCustomAttribute() == null)) + throw new ArgumentException("One or more types is missing the TableNameAttribute"); + + if (currentPluginTables.Any(x => databaseClient.GetValidProperties(x).Length == 0)) + throw new ArgumentException("One or more types is missing a property with ColumnNameAttribute"); + + try + { + var primaryKeys = currentPluginTables.Select(x => databaseClient.GetPrimaryKey(x)).ToList(); + } + catch (Exception) + { + throw new ArgumentException("One or more types is missing a property with PrimaryAttribute"); + } + + foreach (var table in currentPluginTables) + { + var requestedTableName = databaseClient.GetTableName(table); + var newTableName = $"{prefix}{requestedTableName}".ToLower(); + + if (!Regex.IsMatch(newTableName, @"^[a-zA-Z_][a-zA-Z0-9_$]*$", RegexOptions.Compiled)) + throw new ArgumentException($"Created invalid table name: {newTableName} ({requestedTableName}). Please make sure that the name starts with a letter or an underscore and only contains letters, underscores, digits and dollar signs."); + + if (pluginTables.Any(x => x.Value == newTableName)) + throw new Exception($"The specified plugin tablename '{newTableName}' ({requestedTableName}) already exists."); + + pluginTables.Add(new KeyValuePair(table, newTableName)); + plugin.Value.AllowedTables.Add(newTableName); + } + } + + if (bot.status.LoadedConfig.EnablePlugins) + await databaseClient.SyncStandardTable(pluginTables, databaseClient.pluginDatabaseConnection); + + bot.DatabaseClient = databaseClient; + Log.Information("Connected to database."); + return databaseClient; + } + + internal string MakePluginTablePrefix(BasePlugin plugin) + { + return $"{Regex.Replace(plugin.Name.ToLower().Replace(" ", ""), @"[^\w]", "-", RegexOptions.Singleline | RegexOptions.Compiled)}_"; + } + + private void AddColumn(MySqlConnectionInformation connectionInfo, string tableName, PropertyInfo internalColumn, (string ColumnName, ColumnTypes ColumnType, bool Primary, long? MaxValue, string? Collation, bool Nullable, string Default) columnInfo) + { + using (var connection = connectionInfo.CreateConnection()) + { + var sql = $"ALTER TABLE `{tableName}` ADD {this.Build(internalColumn)}"; + + var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + + _ = this.RunCommand(cmd); + + Log.Information("Created column '{Column}' in '{Table}'.", columnInfo.ColumnName, tableName); + } + } + + private void ModifyColumn(MySqlConnectionInformation connectionInfo, string tableName, PropertyInfo internalColumn, (string ColumnName, ColumnTypes ColumnType, bool Primary, long? MaxValue, string? Collation, bool Nullable, string Default) columnInfo) + { + using (var connection = connectionInfo.CreateConnection()) + { + var sql = $"ALTER TABLE `{tableName}` CHANGE `{columnInfo.ColumnName}` {this.Build(internalColumn)}"; + + var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + + _ = this.RunCommand(cmd); + + Log.Information("Changed column '{Column}' in '{Table}' to datatype '{NewDataType}'.", + columnInfo.ColumnName, + tableName, + Enum.GetName(internalColumn.GetCustomAttribute().Type).ToUpper()); + } + } + + private void DropColumn(MySqlConnectionInformation connectionInfo, string tableName, string remoteColumn) + { + using (var connection = connectionInfo.CreateConnection()) + { + var cmd = connection.CreateCommand(); + cmd.CommandText = $"ALTER TABLE `{tableName}` DROP COLUMN `{remoteColumn}`"; + + _ = this.RunCommand(cmd); + } + } + + internal async Task SyncStandardTable(IEnumerable> localTables, MySqlConnectionInformation connection) + { + var remoteTables = this.ListTables(connection); + + foreach (var keyValue in localTables) + { + var internalTable = keyValue.Key; + var tableName = keyValue.Value.IsNullOrWhiteSpace() ? internalTable.GetCustomAttribute().Name : keyValue.Value; + var propertyList = this.GetValidProperties(internalTable); + + if (!remoteTables.Contains(tableName)) + { + Log.Warning("Missing table '{Name}'. Creating..", tableName); + + _ = this.CreateTable(tableName, internalTable, connection); + } + + var remoteColumns = this.ListColumns(tableName, connection); + + foreach (var internalColumn in propertyList) + { + (string ColumnName, ColumnTypes ColumnType, bool Primary, long? MaxValue, string? Collation, bool Nullable, string Default) columnInfo; + + try + { + columnInfo = this.GetPropertyInfo(internalColumn); + } + catch (Exception) + { + continue; + } + + if (columnInfo.ColumnName is null || columnInfo.ColumnType == ColumnTypes.Unknown) + continue; + + if (!remoteColumns.Any(x => x.Name.ToLower() == columnInfo.ColumnName.ToLower())) + { + Log.Warning("Missing column '{Column}' in '{Table}'. Creating..", columnInfo.ColumnName, tableName); + + this.AddColumn(connection, tableName, internalColumn, columnInfo); + remoteColumns = this.ListColumns(tableName, connection); + } + + var remoteColumn = remoteColumns.First(x => x.Name == columnInfo.ColumnName); + + var typeMatches = remoteColumn.Type.ToLower() == columnInfo.ColumnType.GetName().ToLower() + (columnInfo.MaxValue is not null ? $"({columnInfo.MaxValue})" : ""); + var nullabilityMatches = remoteColumn.Nullable == columnInfo.Nullable; + var defaultMatches = remoteColumn.Default == columnInfo.Default; + + if (!typeMatches || !nullabilityMatches || !defaultMatches) + { + Log.Warning("Wrong data type for column '{Column}' in '{Table}'\nType: {TypeMatch} ({Type1}:{Type2})\nNullable: {NullableMatch}\nDefault: {Default} ({Default1}:{Default2})", + columnInfo.ColumnName, + tableName, + typeMatches, remoteColumn.Type.ToLower(), columnInfo.ColumnType.GetName().ToLower() + (columnInfo.MaxValue is not null ? $"({columnInfo.MaxValue})" : ""), + nullabilityMatches, + defaultMatches, remoteColumn.Default, columnInfo.Default); + + this.ModifyColumn(connection, tableName, internalColumn, columnInfo); + remoteColumns = this.ListColumns(tableName, connection); + } + } + + foreach (var remoteColumn in remoteColumns) + { + if (!propertyList.Any(x => x.GetCustomAttribute()?.Name == remoteColumn.Name)) + { + Log.Warning("Invalid column '{Column}' in '{Table}'", remoteColumn.Name, tableName); + + this.DropColumn(connection, tableName, remoteColumn.Name); + remoteColumns = this.ListColumns(tableName, connection); + } + } + } + } + + /// + /// Runs a command. + /// + /// The command to run. + /// + /// + internal Task RunCommand(MySqlCommand cmd) + { + if (this.Disposed) + throw new Exception("DatabaseClient is disposed"); + + using (cmd.Connection) + { + cmd.Connection.Open(); + + Log.Verbose("Executing command on database '{database}': {command}", cmd.Connection.Database, cmd.CommandText.Truncate(300)); + + _ = cmd.ExecuteNonQuery(); + + return Task.CompletedTask; + } + } + + /// + /// Sets a value in the database. + /// + /// The table to set the value in. + /// The unique column to look for. + /// The unique value to look for. + /// The column to edit. + /// The new value. + /// The connection to use. + /// + /// + /// + internal Task SetValue(string table, string columnKey, object columnValue, string columnToEdit, object newValue, MySqlConnectionInformation connectionInfo) + { + if (this.Disposed) + throw new Exception("DatabaseClient is disposed"); + + try + { + using (var connection = connectionInfo.CreateConnection()) + { + connection.Open(); + + if (newValue?.GetType() == typeof(bool)) + newValue = (bool)newValue ? "1" : "0"; + + if (newValue?.GetType() == typeof(DateTime)) + newValue = ((DateTime)newValue).ToUniversalTime().Ticks; + + if (newValue?.GetType() == typeof(TimeSpan)) + newValue = ((TimeSpan)newValue).TotalSeconds; + + string? v; + + if (newValue is null) + v = null; + else + v = MySqlHelper.EscapeString(newValue?.ToString()); + + var v1 = new MySqlCommand(Regex.IsMatch(v ?? " ", @"^(?=.*SELECT.*FROM)(?!.*(?:CREATE|DROP|UPDATE|INSERT|ALTER|DELETE|ATTACH|DETACH)).*$", RegexOptions.IgnoreCase) + ? throw new InvalidOperationException("Sql detected.") + : $"UPDATE `{table}` SET `{columnToEdit}`={(v is not null ? $"'{v}'" : "NULL")} WHERE `{columnKey}`='{columnValue}'", connection).ExecuteNonQuery(); + + if (v1 != 1) + throw new InvalidOperationException($"Affected more or less than 1 row: {v1} rows affected") + .AddData("table", table) + .AddData("columnKey", columnKey) + .AddData("columnValue", columnValue) + .AddData("columnToEdit", columnToEdit) + .AddData("newValue", newValue) + .AddData("newValueEscaped", v) + .AddData("connection", connection); + + this.GetCache[$"{table}-{columnKey}-{columnValue}-{columnToEdit}"] = new CacheItem(null, DateTime.MinValue); + + return Task.CompletedTask; + } + } + catch (Exception ex) + { + var newEx = new AggregateException("Failed to update value in database", ex); + throw newEx; + } + } + + internal ConcurrentDictionary GetCache = new(); + internal record CacheItem(object item, DateTime CacheTime); + + internal T GetValue(string tableName, string columnKey, object columnValue, string columnToGet, MySqlConnectionInformation connectionInfo) + { + if (this.Disposed) + throw new Exception("DatabaseClient is disposed"); + + T BuildReturnItem(object input) + { + if (typeof(T) == typeof(bool)) + return (Convert.ToInt16(input) == 1) is T t ? t : throw new Exception("Impossible Exception."); + + if (typeof(T) == typeof(DateTime)) + return new DateTime(Convert.ToInt64(input), DateTimeKind.Utc) is T t ? t : throw new Exception("Impossible Exception."); + + if (typeof(T) == typeof(TimeSpan)) + return TimeSpan.FromSeconds(Convert.ToInt64(input)) is T t ? t : throw new Exception("Impossible Exception."); + + var t1 = (T)Convert.ChangeType(input, typeof(T)); + return t1; + } + + try + { + if (this.GetCache.TryGetValue($"{tableName}-{columnKey}-{columnValue}-{columnToGet}", out var cacheItem) && cacheItem.CacheTime.GetTimespanSince() < TimeSpan.FromMilliseconds(2000)) + { + return BuildReturnItem(cacheItem.item); + } + + using (var connection = connectionInfo.CreateConnection()) + { + connection.Open(); + + var command = new MySqlCommand($"SELECT `{columnToGet}` FROM `{tableName}` WHERE `{columnKey}` = '{columnValue}'", connection); + + var reader = command.ExecuteReader(); + + string? value = null; + if (!reader.HasRows) + return default; + + while (reader.Read()) + { + if (reader.IsDBNull(0)) + break; + + value = reader.GetValue(0).ToString(); + break; + } + + reader.Close(); + + this.GetCache[$"{tableName}-{columnKey}-keys"] = new CacheItem(null, DateTime.MinValue); + this.GetCache[$"{tableName}-{columnKey}-{columnValue}-{columnToGet}"] = new CacheItem(value, DateTime.UtcNow); + + return BuildReturnItem(value); + } + } + catch (Exception ex) + { + var newEx = new AggregateException("Failed to get value from database", ex); + throw newEx; + } + } + + internal bool CreateTable(string tableName, Type internalTable, MySqlConnectionInformation connectionInfo) + { + if (this.Disposed) + throw new Exception("DatabaseClient is disposed"); + + if (this.ListTables(connectionInfo).Contains(tableName)) + return false; + + using (var connection = connectionInfo.CreateConnection()) + { + var propertyList = this.GetValidProperties(internalTable); + + var sql = $"CREATE TABLE `{connection.Database}`.`{tableName}` " + + $"( {string.Join(", ", propertyList.Select(x => + { + if (x.GetCustomAttribute() is not null) + return this.Build(x); + + return string.Empty; + }).Where(x => !x.IsNullOrWhiteSpace()))}" + + $"{(propertyList.Any(x => x.GetCustomAttribute() is not null) ? + $", PRIMARY KEY (`{this.GetPrimaryKey(internalTable).ColumnName}`)" : "")} )"; + + var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + + _ = this.RunCommand(cmd); + Log.Information("Created table '{Name}'.", tableName); + } + + return true; + } + + internal Task CreateRow(string tableName, Type type, object uniqueValue, MySqlConnectionInformation connectionInfo) + { + if (this.Disposed) + throw new Exception("DatabaseClient is disposed"); + + using (var connection = connectionInfo.CreateConnection()) + { + var primaryColumn = this.GetPrimaryKey(type); + + if (this.RowExists(tableName, primaryColumn.ColumnName, uniqueValue, connectionInfo)) + return Task.CompletedTask; + + var cmd = connection.CreateCommand(); + cmd.CommandText = $"INSERT INTO `{tableName}` ( {string.Join(", ", this.GetValidProperties(type) + .Where(x => + { + try + { + var propInfo = this.GetPropertyInfo(x); + return (!propInfo.Nullable) || propInfo.Primary; + } + catch (Exception) + { + return false; + } + }) + .Select(x => + { + return $"`{this.GetPropertyInfo(x).ColumnName}`"; + }))} ) VALUES ( {string.Join(", ", this.GetValidProperties(type) + .Where(x => + { + try + { + var propInfo = this.GetPropertyInfo(x); + + return (!propInfo.Nullable) || propInfo.Primary; + } + catch (Exception) + { + return false; + } + }) + .Select(x => + { + return $"@{this.GetPropertyInfo(x).ColumnName}"; + }))} )"; + + foreach (var property in this.GetValidProperties(type).Where(x => + { + try + { + var propInfo = this.GetPropertyInfo(x); + + return (!propInfo.Nullable) || propInfo.Primary; + } + catch (Exception) + { + return false; + } + })) + { + var value = this.GetPropertyInfo(property); + + if (value.Primary) + _ = cmd.Parameters.AddWithValue($"@{value.ColumnName}", uniqueValue); + else + _ = cmd.Parameters.AddWithValue($"@{value.ColumnName}", value.Default ?? (property.PropertyType.IsValueType ? Activator.CreateInstance(property.PropertyType) : null)); + } + + _ = this.RunCommand(cmd); + this.GetCache[$"{tableName}-{primaryColumn.ColumnName}-keys"] = new CacheItem(null, DateTime.MinValue); + this.GetCache[$"{tableName}-{primaryColumn.ColumnName}-{uniqueValue}-exists"] = new CacheItem(null, DateTime.MinValue); + return Task.CompletedTask; + } + } + + internal bool CreateRow(string tableName, string key, object value, MySqlConnectionInformation connectionInfo) + { + if (this.Disposed) + throw new Exception("DatabaseClient is disposed"); + + using (var connection = connectionInfo.CreateConnection()) + { + if (this.RowExists(tableName, key, value, connectionInfo)) + return false; + + var cmd = connection.CreateCommand(); + cmd.CommandText = $"INSERT INTO `{tableName}` ( `{key}` ) VALUES ( @value )"; + _ = cmd.Parameters.AddWithValue($"@value", value); + + _ = this.RunCommand(cmd); + + this.GetCache[$"{tableName}-{key}-keys"] = new CacheItem(null, DateTime.MinValue); + this.GetCache[$"{tableName}-{key}-{value}-exists"] = new CacheItem(null, DateTime.MinValue); + + return true; + } + } + + internal long GetRowCount(string tableName, MySqlConnectionInformation connectionInfo) + { + if (this.Disposed) + throw new Exception("DatabaseClient is disposed"); + + using (var connection = connectionInfo.CreateConnection()) + { + connection.Open(); + + var Count = 0L; + using (var reader = new MySqlCommand($"SELECT COUNT(*) FROM `{tableName}`", connection).ExecuteReader()) + { + while (reader.Read()) + { + Count = reader.GetInt64(0); + } + } + + return Count; + } + } + + internal T[] GetRowKeys(string tableName, string columnKey, MySqlConnectionInformation connectionInfo) + { + if (this.Disposed) + throw new Exception("DatabaseClient is disposed"); + + if (this.GetCache.TryGetValue($"{tableName}-{columnKey}-keys", out var cacheItem) && cacheItem.CacheTime.GetTimespanSince() < TimeSpan.FromMilliseconds(60000)) + { + return (T[])cacheItem.item; + } + + using (var connection = connectionInfo.CreateConnection()) + { + connection.Open(); + + var rows = new List(); + using (var reader = new MySqlCommand($"SELECT `{columnKey}` FROM `{tableName}`", connection).ExecuteReader()) + { + while (reader.Read()) + { + rows.Add((T)Convert.ChangeType(reader.GetValue(0), typeof(T))); + } + } + + var ts = rows.ToArray(); + this.GetCache[$"{tableName}-{columnKey}-keys"] = new CacheItem(ts, DateTime.UtcNow); + return ts; + } + } + + internal bool RowExists(string tableName, string columnKey, object columnValue, MySqlConnectionInformation connectionInfo) + { + if (this.Disposed) + throw new Exception("DatabaseClient is disposed"); + + if (this.GetCache.TryGetValue($"{tableName}-{columnKey}-{columnValue}-exists", out var cacheItem) && cacheItem.CacheTime.GetTimespanSince() < TimeSpan.FromMilliseconds(1000) && (bool)cacheItem.item) + { + return true; + } + + using (var connection = connectionInfo.CreateConnection()) + { + connection.Open(); + + var Exists = false; + using (var reader = new MySqlCommand($"SELECT EXISTS(SELECT 1 FROM `{tableName}` WHERE `{columnKey}` = '{columnValue}')", connection).ExecuteReader()) + { + while (reader.Read()) + { + Exists = reader.GetInt16(0) >= 1; + } + } + + //Log.Debug("Column exists: {tableName}:{columnKey} = {columnValue}:{Exists}", tableName, columnKey, columnValue, Exists); + this.GetCache[$"{tableName}-{columnKey}-{columnValue}-exists"] = new CacheItem(Exists, DateTime.UtcNow); + return Exists; + } + } + + internal IEnumerable ListTables(MySqlConnectionInformation connectionInfo) + { + if (this.Disposed) + throw new Exception("DatabaseClient is disposed"); + + try + { + using (var connection = connectionInfo.CreateConnection()) + { + connection.Open(); + + List SavedTables = new(); + + using (var reader = new MySqlCommand($"SHOW TABLES", connection).ExecuteReader()) + { + while (reader.Read()) + { + SavedTables.Add(reader.GetString(0)); + } + } + + return SavedTables; + } + } + catch (Exception) + { + Thread.Sleep(1000); + return this.ListTables(connectionInfo); + } + } + + internal (string Name, string Type, bool Nullable, string Key, string? Default, string Extra)[] ListColumns(string tableName, MySqlConnectionInformation connectionInfo) + { + if (this.Disposed) + throw new Exception("DatabaseClient is disposed"); + + try + { + using (var connection = connectionInfo.CreateConnection()) + { + connection.Open(); + + List<(string Name, string Type, bool Nullable, string Key, string Default, string Extra)> Columns = new(); + + using (var reader = new MySqlCommand($"SHOW FIELDS FROM `{tableName}`", connection).ExecuteReader()) + { + while (reader.Read()) + { + Columns.Add((reader.GetString(0), + reader.GetString(1), + (!reader.IsDBNull(2) ? reader.GetString(2) : "").Equals("yes", StringComparison.CurrentCultureIgnoreCase), + (!reader.IsDBNull(3) ? reader.GetString(3) : ""), + (!reader.IsDBNull(4) ? (reader.GetString(4).Contains('\'') ? Regex.Match(reader.GetString(4), @"\\?'(.*)\\?'").Groups[1].Value : reader.GetString(4)) : null), + (!reader.IsDBNull(5) ? reader.GetString(5) : ""))); + } + } + + return Columns.ToArray(); + } + } + catch (Exception) + { + Thread.Sleep(1000); + return this.ListColumns(tableName, connectionInfo); + } + } + + internal Task DeleteRow(string tableName, string columnKey, string columnValue, MySqlConnectionInformation connectionInfo) + { + if (this.Disposed) + throw new Exception("DatabaseClient is disposed"); + + using (var connection = connectionInfo.CreateConnection()) + { + var cmd = connection.CreateCommand(); + cmd.CommandText = $"DELETE FROM `{tableName}` WHERE {columnKey}='{columnValue}'"; + cmd.Connection = connection; + + this.GetCache[$"{tableName}-{columnKey}-keys"] = new CacheItem(null, DateTime.MinValue); + return this.RunCommand(cmd); + } + } + + internal Task ClearRows(string tableName, MySqlConnectionInformation connectionInfo) + { + if (this.Disposed) + throw new Exception("DatabaseClient is disposed"); + + using (var connection = connectionInfo.CreateConnection()) + { + var cmd = connection.CreateCommand(); + cmd.CommandText = $"TRUNCATE `{tableName}`"; + cmd.Connection = connection; + + foreach (var b in this.GetCache.Where(x => x.Key.StartsWith(tableName))) + this.GetCache[b.Key] = new CacheItem(null, DateTime.MinValue); + + return this.RunCommand(cmd); + } + } + + internal Task DropTable(string tableName, MySqlConnectionInformation connectionInfo) + { + if (this.Disposed) + throw new Exception("DatabaseClient is disposed"); + + using (var connection = connectionInfo.CreateConnection()) + { + var cmd = connection.CreateCommand(); + cmd.CommandText = $"DROP TABLE IF EXISTS `{tableName}`"; + cmd.Connection = connection; + return this.RunCommand(cmd); + } + } + + internal Task Dispose() + { + if (this.Disposed) + throw new Exception("DatabaseClient is disposed"); + + this.Disposed = true; + return Task.CompletedTask; + } + + private string Build(PropertyInfo info) + { + var columnInfo = this.GetPropertyInfo(info); + + var defaultText = string.Empty; + + if (columnInfo.Default is not null) + switch (columnInfo.ColumnType) + { + case ColumnTypes.BigInt: + case ColumnTypes.Int: + case ColumnTypes.TinyInt: + defaultText = $"{columnInfo.Default}"; + break; + case ColumnTypes.LongText: + case ColumnTypes.Text: + defaultText = $"('{columnInfo.Default}')"; + break; + case ColumnTypes.VarChar: + defaultText = $"'{columnInfo.Default}'"; + break; + default: + break; + } + + if (!columnInfo.Nullable && columnInfo.Default is null && !columnInfo.Primary) + throw new InvalidOperationException("A column should be either nullable, have a default value or be the primary key.") + .AddData("columnInfo", columnInfo); + + return $"`{columnInfo.ColumnName}` {Enum.GetName(columnInfo.ColumnType).ToUpper()}" + + $"{(columnInfo.MaxValue is not null ? $"({columnInfo.MaxValue})" : "")}" + + $"{(columnInfo.Collation is not null ? $" CHARACTER SET {columnInfo.Collation[..columnInfo.Collation.IndexOf('_')]} COLLATE {columnInfo.Collation}" : "")}" + + $"{(columnInfo.Nullable ? " NULL" : " NOT NULL")}" + + $"{(columnInfo.Default is not null ? $" DEFAULT {defaultText}" : "")}"; + } +} diff --git a/ProjectMakoto/Entities/AbuseIpDbQuery.cs b/ProjectMakoto/Entities/AbuseIpDbQuery.cs new file mode 100644 index 00000000..ef15f08f --- /dev/null +++ b/ProjectMakoto/Entities/AbuseIpDbQuery.cs @@ -0,0 +1,44 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public sealed class AbuseIpDbQuery +{ + public Data data { get; set; } + + public sealed class Data + { + public string? ipAddress { get; set; } + public bool? isPublic { get; set; } + public int? ipVersion { get; set; } + public bool? isWhitelisted { get; set; } + public int? abuseConfidenceScore { get; set; } + public string? countryCode { get; set; } + public string? countryName { get; set; } + public string? usageType { get; set; } + public string? isp { get; set; } + public string? domain { get; set; } + public object[]? hostnames { get; set; } + public int? totalReports { get; set; } + public int? numDistinctUsers { get; set; } + public DateTime? lastReportedAt { get; set; } + public Report[]? reports { get; set; } + } + + public sealed class Report + { + public DateTime? reportedAt { get; set; } + public string? comment { get; set; } + public int[]? categories { get; set; } + public int? reporterId { get; set; } + public string? reporterCountryCode { get; set; } + public string? reporterCountryName { get; set; } + } +} diff --git a/ProjectMakoto/Entities/Attributes/ModulePriorityAttribute.cs b/ProjectMakoto/Entities/Attributes/ModulePriorityAttribute.cs new file mode 100644 index 00000000..48ff09b4 --- /dev/null +++ b/ProjectMakoto/Entities/Attributes/ModulePriorityAttribute.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +[AttributeUsage(AttributeTargets.Class)] +public class ModulePriorityAttribute(int Priority) : Attribute +{ + public int Priority { get; set; } = Priority; +} diff --git a/ProjectMakoto/Entities/Attributes/PrefixCommandAlternativeAttribute.cs b/ProjectMakoto/Entities/Attributes/PrefixCommandAlternativeAttribute.cs new file mode 100644 index 00000000..d004ec90 --- /dev/null +++ b/ProjectMakoto/Entities/Attributes/PrefixCommandAlternativeAttribute.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +[AttributeUsage(AttributeTargets.Method)] +public class PrefixCommandAlternativeAttribute(string prefixCommand) : Attribute +{ + public string PrefixCommand { get; init; } = prefixCommand; +} diff --git a/ProjectMakoto/Entities/Attributes/PreventCommandDeletionAttribute.cs b/ProjectMakoto/Entities/Attributes/PreventCommandDeletionAttribute.cs new file mode 100644 index 00000000..71e14070 --- /dev/null +++ b/ProjectMakoto/Entities/Attributes/PreventCommandDeletionAttribute.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +[AttributeUsage(AttributeTargets.All, AllowMultiple = false)] +public sealed class PreventCommandDeletionAttribute(bool PreventDeleteMessage = true) : Attribute +{ + public readonly bool PreventDeleteCommandMessage = PreventDeleteMessage; +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/BanDetails.cs b/ProjectMakoto/Entities/BanDetails.cs new file mode 100644 index 00000000..2b48c926 --- /dev/null +++ b/ProjectMakoto/Entities/BanDetails.cs @@ -0,0 +1,49 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +[TableName("-")] +public sealed class BanDetails : RequiresBotReference +{ + private string _tableName; + + public BanDetails(Bot bot, string tableName, ulong Id) : base(bot) + { + this.Id = Id; + + this._tableName = tableName; + + _ = this.Bot.DatabaseClient.CreateRow(this._tableName, typeof(BanDetails), Id, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("id"), ColumnType(ColumnTypes.BigInt), Primary] + internal ulong Id { get; init; } + + [ColumnName("reason"), ColumnType(ColumnTypes.LongText), Default("-")] + public string Reason + { + get => this.Bot.DatabaseClient.GetValue(this._tableName, "id", this.Id, "reason", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue(this._tableName, "id", this.Id, "reason", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("moderator"), ColumnType(ColumnTypes.BigInt), Default("0")] + public ulong Moderator + { + get => this.Bot.DatabaseClient.GetValue(this._tableName, "id", this.Id, "moderator", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue(this._tableName, "id", this.Id, "moderator", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("timestamp"), ColumnType(ColumnTypes.BigInt), Default("0")] + public DateTime Timestamp + { + get => this.Bot.DatabaseClient.GetValue(this._tableName, "id", this.Id, "timestamp", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue(this._tableName, "id", this.Id, "timestamp", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } +} diff --git a/ProjectMakoto/Entities/BaseEntities/RequiresBotReference.cs b/ProjectMakoto/Entities/BaseEntities/RequiresBotReference.cs new file mode 100644 index 00000000..67978f08 --- /dev/null +++ b/ProjectMakoto/Entities/BaseEntities/RequiresBotReference.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public abstract class RequiresBotReference(Bot bot) +{ + [JsonIgnore] + public Bot Bot { get; set; } = bot; +} diff --git a/ProjectMakoto/Entities/BaseEntities/RequiresParent.cs b/ProjectMakoto/Entities/BaseEntities/RequiresParent.cs new file mode 100644 index 00000000..842c2694 --- /dev/null +++ b/ProjectMakoto/Entities/BaseEntities/RequiresParent.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public class RequiresParent(Bot bot, T parent) : RequiresBotReference(bot) +{ + [JsonIgnore] + public T Parent { get; set; } = parent; +} diff --git a/ProjectMakoto/Entities/BaseEntities/RequiresTranslation.cs b/ProjectMakoto/Entities/BaseEntities/RequiresTranslation.cs new file mode 100644 index 00000000..fac54eca --- /dev/null +++ b/ProjectMakoto/Entities/BaseEntities/RequiresTranslation.cs @@ -0,0 +1,15 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public abstract class RequiresTranslation(Bot bot) : RequiresBotReference(bot) +{ + protected Translations t { get; private set; } = bot.LoadedTranslations; +} diff --git a/ProjectMakoto/Entities/Commands/GuildInfo/Mee6Leaderboard.cs b/ProjectMakoto/Entities/Commands/GuildInfo/Mee6Leaderboard.cs new file mode 100644 index 00000000..fdbc5717 --- /dev/null +++ b/ProjectMakoto/Entities/Commands/GuildInfo/Mee6Leaderboard.cs @@ -0,0 +1,50 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +internal sealed class Mee6Leaderboard +{ + public bool admin { get; set; } + public string banner_url { get; set; } + public string country { get; set; } + public Guild guild { get; set; } + public bool is_member { get; set; } + public int page { get; set; } + public object player { get; set; } + public Player[] players { get; set; } + public object[] role_rewards { get; set; } + public object user_guild_settings { get; set; } + public int[] xp_per_message { get; set; } + public float xp_rate { get; set; } + + public sealed class Guild + { + public bool allow_join { get; set; } + public string icon { get; set; } + public string id { get; set; } + public bool invite_leaderboard { get; set; } + public string leaderboard_url { get; set; } + public string name { get; set; } + public bool premium { get; set; } + } + + public sealed class Player + { + public string avatar { get; set; } + public int[] detailed_xp { get; set; } + public string discriminator { get; set; } + public string guild_id { get; set; } + public string id { get; set; } + public int level { get; set; } + public int message_count { get; set; } + public string username { get; set; } + public int xp { get; set; } + } +} diff --git a/ProjectMakoto/Entities/Commands/RequestData.cs b/ProjectMakoto/Entities/Commands/RequestData.cs new file mode 100644 index 00000000..42a87080 --- /dev/null +++ b/ProjectMakoto/Entities/Commands/RequestData.cs @@ -0,0 +1,18 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using ProjectMakoto.Entities.Guilds; + +namespace ProjectMakoto.Entities; + +internal sealed class RequestData +{ + public User User { get; set; } + public Dictionary GuildData { get; set; } = new(); +} diff --git a/ProjectMakoto/Entities/Commands/Social/KawaiiRequest.cs b/ProjectMakoto/Entities/Commands/Social/KawaiiRequest.cs new file mode 100644 index 00000000..f451c6bb --- /dev/null +++ b/ProjectMakoto/Entities/Commands/Social/KawaiiRequest.cs @@ -0,0 +1,15 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public sealed class KawaiiResponse +{ + public string response { get; set; } +} diff --git a/ProjectMakoto/Entities/Commands/Social/NekosLifeRequest.cs b/ProjectMakoto/Entities/Commands/Social/NekosLifeRequest.cs new file mode 100644 index 00000000..327a0107 --- /dev/null +++ b/ProjectMakoto/Entities/Commands/Social/NekosLifeRequest.cs @@ -0,0 +1,15 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public sealed class NekosLifeRequest +{ + public string url { get; set; } +} diff --git a/ProjectMakoto/Entities/Commands/UrbanDictionary.cs b/ProjectMakoto/Entities/Commands/UrbanDictionary.cs new file mode 100644 index 00000000..57fe2bf9 --- /dev/null +++ b/ProjectMakoto/Entities/Commands/UrbanDictionary.cs @@ -0,0 +1,42 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +internal sealed class UrbanDictionary +{ + public List[] list { get; set; } + + public sealed class List + { + public string definition { get; set; } + public string permalink { get; set; } + public int thumbs_up { get; set; } + public object[] sound_urls { get; set; } + public string author { get; set; } + public string word { get; set; } + public int defid { get; set; } + public string current_vote { get; set; } + public DateTime written_on { get; set; } + public string example { get; set; } + public int thumbs_down { get; set; } + + /// + /// Return Thumbs Up/Thumbs Down Ratio. + /// + [JsonIgnore] + public int RatingRatio + { + get + { + return this.thumbs_up - this.thumbs_down; + } + } + } +} diff --git a/ProjectMakoto/Entities/Commands/UserUpload.cs b/ProjectMakoto/Entities/Commands/UserUpload.cs new file mode 100644 index 00000000..8d2bc8b8 --- /dev/null +++ b/ProjectMakoto/Entities/Commands/UserUpload.cs @@ -0,0 +1,18 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public sealed class UserUpload +{ + public bool InteractionHandled { get; set; } = false; + public DateTime TimeOut { get; set; } = DateTime.Now; + public Stream UploadedData { get; set; } + public int FileSize { get; set; } = 0; +} diff --git a/ProjectMakoto/Entities/Config.cs b/ProjectMakoto/Entities/Config.cs new file mode 100644 index 00000000..23199dff --- /dev/null +++ b/ProjectMakoto/Entities/Config.cs @@ -0,0 +1,207 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using ProjectMakoto.Util.Initializers; + +namespace ProjectMakoto.Entities; + +public sealed class Config +{ + public void Save(int retry = 0) + { + try + { + File.WriteAllText("config.json", JsonConvert.SerializeObject(this, Formatting.Indented, new JsonSerializerSettings() { DefaultValueHandling = DefaultValueHandling.Include })); + } + catch (Exception) + { + if (retry > 10) + return; + + Thread.Sleep(500); + this.Save(retry + 1); + } + } + + public bool IsDev = false; + public bool AllowMoreThan100Guilds = false; + + public bool EnablePlugins = false; + public bool OnlyLoadOfficialPlugins = true; + + public string SupportServerInvite = ""; + + public MonitorConfig MonitorSystem = new(); + public WebServerConfig WebServer = new(); + public DiscordConfig Discord = new(); + public ChannelsConfig Channels = new(); + public EmojiConfig Emojis = new(); + public AccountIdsConfig Accounts = new(); + public SecretsConfig Secrets = new(); + public DontModifyConfig DontModify = new(); + + public Dictionary CommandCache = new(); + public Dictionary PluginData = new(); + + public sealed class MonitorConfig + { + public bool Enabled = true; + public string? SensorName = "k10temp-pci-00c3"; + public string? SensorKey = "Tctl"; + } + + public sealed class WebServerConfig + { + public string UrlPrefix = string.Empty; + public ushort Port = 7878; + } + + public sealed class DiscordConfig + { + public ulong AssetsGuild = 0; + public ulong DevelopmentGuild = 0; + + public uint MaxUploadSize = 8388608; + public List DisabledCommands = new(); + } + + public sealed class ChannelsConfig + { + public ulong GlobalBanAnnouncements = 0; + public ulong GithubLog = 0; + public ulong News = 0; + + public ulong GraphAssets = 0; + public ulong PlaylistAssets = 0; + public ulong UrlSubmissions = 0; + public ulong OtherAssets = 0; + + public ulong ExceptionLog = 0; + } + + public sealed class EmojiConfig + { + public string[] JoinEvent = ["🙋‍", "🙋‍"]; + + public ulong DisabledRepeat = 0; + public ulong DisabledShuffle = 0; + public ulong Paused = 0; + public ulong DisabledPlay = 0; + + public ulong Error = 0; + + public ulong CheckboxTicked = 0; + public ulong CheckboxUnticked = 0; + + public ulong PillOn = 0; + public ulong PillOff = 0; + + public ulong QuestionMark = 0; + + public ulong PrefixCommandDisabled = 0; + public ulong PrefixCommandEnabled = 0; + + public ulong SlashCommand = 0; + public ulong MessageCommand = 0; + public ulong UserCommand = 0; + + public ulong Channel = 0; + public ulong User = 0; + public ulong VoiceState = 0; + public ulong Message = 0; + public ulong Guild = 0; + public ulong Invite = 0; + public ulong In = 0; + + public ulong YouTube = 0; + public ulong SoundCloud = 0; + public ulong AbuseIPDB = 0; + public ulong Spotify = 0; + public ulong Loading = 0; + } + + public sealed class AccountIdsConfig + { + public ulong Disboard = 302050872383242240; + } + + public sealed class SecretsConfig + { + public string AbuseIpDbToken = ""; + + public QuickChartSecrets QuickChart = new(); + public DiscordSecrets Discord = new(); + public TelegramSecrets Telegram = new(); + public GithubSecrets Github = new(); + public DatabaseSecrets Database = new(); + public LavalinkSecrets Lavalink = new(); + + public sealed class QuickChartSecrets + { + public string? Scheme = null; + public string? Host = null; + public int? Port = null; + } + + public sealed class DiscordSecrets + { + public string Token = ""; + } + + public sealed class TelegramSecrets + { + public string Token = ""; + } + + public sealed class GithubSecrets + { + public string Token = ""; + + public DateTimeOffset TokenExperiation = new(0001, 01, 01, 15, 00, 00, TimeSpan.Zero); + public string Username = ""; + public string Repository = ""; + public string? Branch = null; + public string TokenLeakRepoOwner = ""; + public string TokenLeakRepo = ""; + } + + public sealed class DatabaseSecrets + { + public string Host = "127.0.0.1"; + public int Port = 3306; + public string Username = ""; + public string Password = ""; + + public string MainDatabaseName = ""; + public string GuildDatabaseName = ""; + public string PluginDatabaseName = ""; + + public string Collation = "utf8mb4_general_ci"; + } + + public sealed class LavalinkSecrets + { + public string Host = "127.0.0.1"; + public int Port = 2333; + public string Password = ""; + } + } + + public sealed class DontModifyConfig + { + public string LastStartedVersion = "UNIDENTIFIED"; + public string LastKnownHash = ""; + } + + public sealed class CommandSupplierInfo + { + public string? LastKnownHash = null; + public Dictionary CompiledCommands = new(); + } +} diff --git a/ProjectMakoto/Entities/CountryCodes.cs b/ProjectMakoto/Entities/CountryCodes.cs new file mode 100644 index 00000000..683dc997 --- /dev/null +++ b/ProjectMakoto/Entities/CountryCodes.cs @@ -0,0 +1,29 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public sealed class CountryCodes +{ + internal CountryCodes() { } + + public IReadOnlyDictionary List + => this._List.AsReadOnly(); + + internal Dictionary _List { get; set; } = new(); + + public sealed class CountryInfo + { + internal CountryInfo() { } + + public string Name { get; internal set; } + public string ContinentCode { get; internal set; } + public string ContinentName { get; internal set; } + } +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/Database/Attributes/ColumnNameAttribute.cs b/ProjectMakoto/Entities/Database/Attributes/ColumnNameAttribute.cs new file mode 100644 index 00000000..4edd9b51 --- /dev/null +++ b/ProjectMakoto/Entities/Database/Attributes/ColumnNameAttribute.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Database; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] +public sealed class TableNameAttribute(string Name) : Attribute +{ + public readonly string Name = Name; +} diff --git a/ProjectMakoto/Entities/Database/Attributes/ColumnTypeAttribute.cs b/ProjectMakoto/Entities/Database/Attributes/ColumnTypeAttribute.cs new file mode 100644 index 00000000..c3bca5d6 --- /dev/null +++ b/ProjectMakoto/Entities/Database/Attributes/ColumnTypeAttribute.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Database; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public sealed class ColumnTypeAttribute(ColumnTypes type) : Attribute +{ + internal ColumnTypes Type { get; set; } = type; +} diff --git a/ProjectMakoto/Entities/Database/Attributes/ContainsValuesAttribute.cs b/ProjectMakoto/Entities/Database/Attributes/ContainsValuesAttribute.cs new file mode 100644 index 00000000..d155b860 --- /dev/null +++ b/ProjectMakoto/Entities/Database/Attributes/ContainsValuesAttribute.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Database; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public sealed class ContainsValuesAttribute(bool containsValues = true) : Attribute +{ + public bool ContainsValues { get; set; } = containsValues; +} diff --git a/ProjectMakoto/Entities/Database/Attributes/DefaultAttribute.cs b/ProjectMakoto/Entities/Database/Attributes/DefaultAttribute.cs new file mode 100644 index 00000000..213bf3c9 --- /dev/null +++ b/ProjectMakoto/Entities/Database/Attributes/DefaultAttribute.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Database; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public sealed class DefaultAttribute(string Default) : Attribute +{ + public readonly string Default = Default; +} diff --git a/ProjectMakoto/Entities/Database/Attributes/MaxValueAttribute.cs b/ProjectMakoto/Entities/Database/Attributes/MaxValueAttribute.cs new file mode 100644 index 00000000..d814da41 --- /dev/null +++ b/ProjectMakoto/Entities/Database/Attributes/MaxValueAttribute.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Database; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public sealed class MaxValueAttribute(long MaxValue) : Attribute +{ + public readonly long MaxValue = MaxValue; +} diff --git a/ProjectMakoto/Entities/Database/Attributes/NullableAttribute.cs b/ProjectMakoto/Entities/Database/Attributes/NullableAttribute.cs new file mode 100644 index 00000000..68aac7b6 --- /dev/null +++ b/ProjectMakoto/Entities/Database/Attributes/NullableAttribute.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Database; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public sealed class NullableAttribute(bool Nullable = true) : Attribute +{ + public readonly bool Nullable = Nullable; +} diff --git a/ProjectMakoto/Entities/Database/Attributes/PrimaryAttribute.cs b/ProjectMakoto/Entities/Database/Attributes/PrimaryAttribute.cs new file mode 100644 index 00000000..06e74301 --- /dev/null +++ b/ProjectMakoto/Entities/Database/Attributes/PrimaryAttribute.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Database; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public sealed class PrimaryAttribute(bool Primary = true) : Attribute +{ + public readonly bool Primary = Primary; +} diff --git a/ProjectMakoto/Entities/Database/Attributes/TableNameAttribute.cs b/ProjectMakoto/Entities/Database/Attributes/TableNameAttribute.cs new file mode 100644 index 00000000..b7ba498e --- /dev/null +++ b/ProjectMakoto/Entities/Database/Attributes/TableNameAttribute.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Database; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public sealed class ColumnNameAttribute(string Name) : Attribute +{ + public readonly string Name = Name; +} diff --git a/ProjectMakoto/Entities/Database/DummyTables/DatabaseULongList.cs b/ProjectMakoto/Entities/Database/DummyTables/DatabaseULongList.cs new file mode 100644 index 00000000..fc76a866 --- /dev/null +++ b/ProjectMakoto/Entities/Database/DummyTables/DatabaseULongList.cs @@ -0,0 +1,17 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Database; + +[TableName("/")] +public class DatabaseULongList +{ + [ColumnName("id"), ColumnType(ColumnTypes.BigInt), Primary] + public ulong Id { get; init; } +} diff --git a/ProjectMakoto/Entities/Database/Lists/DatabaseDictionary.cs b/ProjectMakoto/Entities/Database/Lists/DatabaseDictionary.cs new file mode 100644 index 00000000..b9982f10 --- /dev/null +++ b/ProjectMakoto/Entities/Database/Lists/DatabaseDictionary.cs @@ -0,0 +1,209 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +namespace ProjectMakoto.Entities; + +public class DatabaseDictionary +{ + /// + /// Create a new dictionary mapped to a database table. The key corresponds to the table's primary key. + /// + /// The database client to use. + /// The table this dictionary is mapped to. + /// The name of the table's primary key. + /// Which connection to use. + /// A predicate returning the value object. + public DatabaseDictionary(DatabaseClient client, string tableName, string primaryKey, DatabaseClient.MySqlConnectionInformation connection, Func? newValuePredicate) + { + if (typeof(T2).GetCustomAttribute() is null || !this.Try(() => { _ = client.GetPrimaryKey(typeof(T2)); })) + throw new ArgumentException("The given type is not a valid database type. A valid database type needs to have the 'TableName' attribute."); + + this._client = client; + this._tableName = tableName; + this._primaryKey = primaryKey; + this._connection = connection; + this._newValuePredicate = newValuePredicate; + } + + public DatabaseDictionary(BasePlugin plugin, Type type, Func? newValuePredicate = null) + { + if (typeof(T2).GetCustomAttribute() is null || !this.Try(() => { _ = plugin.Bot.DatabaseClient.GetPrimaryKey(typeof(T2)); })) + throw new ArgumentException("The given type is not a valid database type. A valid database type needs to have the 'TableName' attribute."); + + this._client = plugin.Bot.DatabaseClient; + this._tableName = (plugin.Bot.DatabaseClient.MakePluginTablePrefix(plugin) + plugin.Bot.DatabaseClient.GetTableName(type)).ToLower(); + this._primaryKey = plugin.Bot.DatabaseClient.GetPrimaryKey(type).ColumnName; + this._connection = plugin.Bot.DatabaseClient.pluginDatabaseConnection; + this._newValuePredicate = newValuePredicate; + } + + protected Func? _newValuePredicate; + + private DatabaseClient _client; + private string _tableName; + private string _primaryKey; + private DatabaseClient.MySqlConnectionInformation _connection; + + private ConcurrentDictionary _items = new(); + + /// + /// Gets a value from this dictionary, filling in values if not already present. + /// + /// The key to get. Will not automatically fill if key is 0. + /// + public virtual T2 this[T1 key] + { + get + { + if (!this.ContainsKey(key)) + throw new NullReferenceException($"Could not find {this._primaryKey}:{key} in {this._tableName}"); + + if (!this._items.ContainsKey(key)) + { + if (_newValuePredicate is not null) + this._items[key] = _newValuePredicate.Invoke(key); + else + this._items[key] = default; + } + + return this._items[key]; + } + } + + /// + /// Gets a list of all keys. + /// + public T1[] Keys + => this._client.GetRowKeys(this._tableName, this._primaryKey, this._connection); + + /// + /// Gets a count of all rows. + /// + public int Count + => (int)this._client.GetRowCount(this._tableName, this._connection); + + /// + /// Adds a new key to the database. + /// + /// The new unique key to add. + /// The new value to add. + /// Thrown if the key failed to create or already exists. + public void Add(T1 key, T2 value) + { + if (!this.Keys.Contains(key)) + _ = this._client.CreateRow(this._tableName, typeof(T2), key, this._connection); + + this._items[key] = value; + } + + /// + /// Clears the table. + /// + public void Clear() + { + _ = this._client.ClearRows(this._tableName, this._connection); + this._items.Clear(); + } + + /// + /// Removes a given key from the database, including it's value. + /// + /// The key to remove. + /// Whether the key was removed. + public bool Remove(T1 key) + { + if (this._items.ContainsKey(key)) + if (!this._items.Remove(key, out _)) + return false; + + return this.Try(() => { _ = this._client.DeleteRow(this._tableName, this._primaryKey, key.ToString(), this._connection); }); + } + + /// + /// Checks if the given key exists in the database. + /// + /// The key to check for. + /// Whether the key exists in the database. + public bool ContainsKey(T1 key) + { + var set = new HashSet(this.Keys); + return set.Contains(key); + } + + /// + /// Gets the enumerator. + /// + /// + public IEnumerator> GetEnumerator() + => this.Fetch().GetEnumerator(); + + /// + /// Tries retrieving a value from the database. + /// + /// The key to retrieve. + /// The retrieved value. Null if false. + /// Whether the value was retrieved. + public bool TryGetValue(T1 key, [MaybeNullWhen(false)] out T2 value) + { + try + { + value = this[key]; + return true; + } + catch (Exception) + { + value = default; + return false; + } + } + + /// + /// Retrieves a linq compatible read-only list. + /// + /// + public IReadOnlyDictionary Fetch() + { + var set = new HashSet(this.Keys); + foreach (var b in this._items) + { + if (!set.Contains(b.Key)) + while (!this._items.Remove(b.Key, out _)) + Thread.Sleep(1); + } + + foreach (var b in this.Keys) + { + if (!this._items.ContainsKey(b)) + { + if (_newValuePredicate is not null) + this._items[b] = _newValuePredicate.Invoke(b); + else + this._items[b] = default; + } + } + + return _items.AsReadOnly(); + } + + private bool Try(Action action) + { + try + { + action.Invoke(); + return true; + } + catch (Exception) + { + return false; + } + } +} diff --git a/ProjectMakoto/Entities/Database/Lists/DatabaseList.cs b/ProjectMakoto/Entities/Database/Lists/DatabaseList.cs new file mode 100644 index 00000000..8b063942 --- /dev/null +++ b/ProjectMakoto/Entities/Database/Lists/DatabaseList.cs @@ -0,0 +1,113 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +/// +/// Create a new dictionary mapped to a database table. The key corresponds to the table's primary key. +/// +/// The database client to use. +/// The table this dictionary is mapped to. +/// The name of the table's primary key. +/// Whether this table is in the guild database. +public class DatabaseList(DatabaseClient client, string tableName, string primaryKey, bool useGuildConnection) +{ + private DatabaseClient _client = client; + private string _tableName = tableName; + private string _primaryKey = primaryKey; + private bool _useGuildConnection = useGuildConnection; + + private DatabaseClient.MySqlConnectionInformation _connection + => this._useGuildConnection ? this._client.guildDatabaseConnection : this._client.mainDatabaseConnection; + + private List _items = new(); + + /// + /// Gets a count of all rows. + /// + public int Count + => (int)this._client.GetRowCount(this._tableName, this._connection); + + /// + /// Adds a new item to the database. + /// + /// The new item to add. + /// Thrown if the key failed to create or already exists. + public void Add(T1 item) + { + if (!this._client.CreateRow(this._tableName, this._primaryKey, item, this._connection)) + throw new ArgumentException("Failed to create key or key already exists."); + + lock (this._items) + { + this._items.Add(item); + } + } + + /// + /// Clears the table. + /// + public void Clear() + { + lock (this._items) + this._items.Clear(); + + _ = this._client.ClearRows(this._tableName, this._connection); + } + + /// + /// Removes a given key from the database, including it's value. + /// + /// The key to remove. + /// Whether the key was removed. + public bool Remove(T1 key) + { + lock (this._items) + if (!this._items.Remove(key)) + return false; + + return this.Try(() => { _ = this._client.DeleteRow(this._tableName, this._primaryKey, key.ToString(), this._connection); }); + } + + /// + /// Checks if the given key exists in the database. + /// + /// The key to check for. + /// Whether the key exists in the database. + public bool Contains(T1 key) + => this._client.RowExists(this._tableName, this._primaryKey, key, this._connection); + + /// + /// Gets the enumerator. + /// + /// + public IEnumerator GetEnumerator() + => this._client.GetRowKeys(this._tableName, this._primaryKey, this._connection).ToList().GetEnumerator(); + + + /// + /// Retrieves a linq compatible read-only list. + /// + /// + public IReadOnlyList Fetch() + => this._client.GetRowKeys(this._tableName, this._primaryKey, this._connection); + + private bool Try(Action action) + { + try + { + action.Invoke(); + return true; + } + catch (Exception) + { + return false; + } + } +} diff --git a/ProjectMakoto/Entities/Database/Lists/SelfFillingDatabaseDictionary.cs b/ProjectMakoto/Entities/Database/Lists/SelfFillingDatabaseDictionary.cs new file mode 100644 index 00000000..4e8e93d8 --- /dev/null +++ b/ProjectMakoto/Entities/Database/Lists/SelfFillingDatabaseDictionary.cs @@ -0,0 +1,64 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +/// +/// Creates a new self filling dictionary. +/// +/// A predicate to create the intended value. If null, will default to the default value of the value. +public sealed class SelfFillingDatabaseDictionary : DatabaseDictionary +{ + public SelfFillingDatabaseDictionary(DatabaseClient client, string tableName, string primaryKey, DatabaseClient.MySqlConnectionInformation connection, Func? newValuePredicate = null) : + base(client, tableName, primaryKey, connection, newValuePredicate) + { + } + + /// + /// Creates a new Dictionary with ulong as key. Constructor for plugins. + /// + /// + /// + /// + public SelfFillingDatabaseDictionary(BasePlugin plugin, Type type, Func? newValuePredicate = null) : + base(plugin.Bot.DatabaseClient, + (plugin.Bot.DatabaseClient.MakePluginTablePrefix(plugin) + plugin.Bot.DatabaseClient.GetTableName(type)).ToLower(), + plugin.Bot.DatabaseClient.GetPrimaryKey(type).ColumnName, + plugin.Bot.DatabaseClient.pluginDatabaseConnection, + newValuePredicate) + { + } + + /// + /// Gets a value from this dictionary, filling in values if not already present. + /// + /// The key to get. Will not automatically fill if key is 0. + /// + public override T this[ulong key] + { + get + { + if (!this.ContainsKey(key) && key != 0) + { + if (_newValuePredicate is not null) + { + Log.Verbose("Creating '{id}' of type '{type}'", key, typeof(T).Name); + this.Add(key, _newValuePredicate.Invoke(key)); + } + else + { + Log.Warning("Creating '{id}' of type '{type}' with default value", key, typeof(T).Name); + this.Add(key, default); + } + } + + return base[key]; + } + } +} diff --git a/ProjectMakoto/Entities/DatabaseMigration.cs b/ProjectMakoto/Entities/DatabaseMigration.cs new file mode 100644 index 00000000..b58cbcff --- /dev/null +++ b/ProjectMakoto/Entities/DatabaseMigration.cs @@ -0,0 +1,21 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; +internal class DatabaseMigration +{ + internal class ReactionRoles + { + public string UUID { get; set; } + public ulong EmojiId { get; set; } + public string EmojiName { get; set; } + public ulong RoleId { get; set; } + public ulong ChannelId { get; set; } + } +} diff --git a/ProjectMakoto/Entities/EmbedColors.cs b/ProjectMakoto/Entities/EmbedColors.cs new file mode 100644 index 00000000..94a7e2fb --- /dev/null +++ b/ProjectMakoto/Entities/EmbedColors.cs @@ -0,0 +1,25 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public static class EmbedColors +{ + public static DiscordColor Error => new("dd2e44"); + public static DiscordColor StrongPunishment => DiscordColor.DarkRed; + public static DiscordColor LightPunishment => DiscordColor.Red; + public static DiscordColor Loading => DiscordColor.Orange; + public static DiscordColor Info => DiscordColor.Aquamarine; + public static DiscordColor Warning => DiscordColor.Orange; + public static DiscordColor Important => DiscordColor.Orange; + public static DiscordColor Processing => new("3d437e"); + public static DiscordColor AwaitingInput => DiscordColor.Orange; + public static DiscordColor Success => new("77b255"); + public static DiscordColor HiddenSidebar => new("2f3136"); +} diff --git a/ProjectMakoto/Entities/EmojiEntry.cs b/ProjectMakoto/Entities/EmojiEntry.cs new file mode 100644 index 00000000..8f4b8666 --- /dev/null +++ b/ProjectMakoto/Entities/EmojiEntry.cs @@ -0,0 +1,27 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +internal sealed class EmojiEntry +{ + public string Name { get; set; } + public string Description { get; set; } + public DiscordEmoji Emoji { get; set; } + public StickerFormat StickerFormat { get; set; } + public EmojiType EntryType { get; set; } + public bool Animated { get; set; } + + public data Data { get; set; } = new(); + public sealed class data + { + public string Name { get; set; } + public Stream Stream { get; set; } = new MemoryStream(); + } +} diff --git a/ProjectMakoto/Entities/GlobalNote.cs b/ProjectMakoto/Entities/GlobalNote.cs new file mode 100644 index 00000000..2e935bbb --- /dev/null +++ b/ProjectMakoto/Entities/GlobalNote.cs @@ -0,0 +1,40 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +[TableName("globalnotes")] +internal class GlobalNote : RequiresBotReference +{ + public GlobalNote(Bot bot, ulong Id) : base(bot) + { + this.Id = Id; + + _ = this.Bot.DatabaseClient.CreateRow("globalnotes", typeof(GlobalNote), Id, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("id"), ColumnType(ColumnTypes.BigInt), Primary] + internal ulong Id { get; init; } + + + [ColumnName("notes"), ColumnType(ColumnTypes.LongText), Default("[]")] + internal Note[] Notes + { + get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("globalnotes", "id", this.Id, "notes", this.Bot.DatabaseClient.mainDatabaseConnection)); + set => _ = this.Bot.DatabaseClient.SetValue("globalnotes", "id", this.Id, "notes", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); + } + + internal class Note + { + public string UUID { get; set; } = Guid.NewGuid().ToString(); + public string Reason { get; set; } + public ulong Moderator { get; set; } + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + } +} diff --git a/ProjectMakoto/Entities/Guild.cs b/ProjectMakoto/Entities/Guild.cs new file mode 100644 index 00000000..182f2b2f --- /dev/null +++ b/ProjectMakoto/Entities/Guild.cs @@ -0,0 +1,128 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using ProjectMakoto.Entities.Guilds; + +namespace ProjectMakoto.Entities; + +[TableName("guilds")] +public sealed class Guild : RequiresBotReference +{ + public Guild(Bot bot, ulong serverId) : base(bot) + { + this.Id = serverId; + + _ = this.Bot.DatabaseClient.CreateRow("guilds", typeof(Guild), serverId, this.Bot.DatabaseClient.mainDatabaseConnection); + _ = this.Bot.DatabaseClient.CreateTable(serverId.ToString(), typeof(Member), this.Bot.DatabaseClient.guildDatabaseConnection); + + this.TokenLeakDetection = new(bot, this); + this.PhishingDetection = new(bot, this); + this.BumpReminder = new(bot, this); + this.Join = new(bot, this); + this.Experience = new(bot, this); + this.Crosspost = new(bot, this); + this.ActionLog = new(bot, this); + this.InVoiceTextPrivacy = new(bot, this); + this.InviteTracker = new(bot, this); + this.InviteNotes = new(bot, this); + this.NameNormalizer = new(bot, this); + this.EmbedMessage = new(bot, this); + this.VcCreator = new(bot, this); + this.PrefixSettings = new(bot, this); + + this.Members = new(this.Bot.DatabaseClient, serverId.ToString(), "userid", this.Bot.DatabaseClient.guildDatabaseConnection, (id) => + { + return new Member(bot, this, id); + }); + } + + [ColumnName("serverid"), ColumnType(ColumnTypes.BigInt), Primary] + internal ulong Id { get; init; } + + [ContainsValues] + public TokenLeakDetectionSettings TokenLeakDetection { get; init; } + + [ContainsValues] + public PhishingDetectionSettings PhishingDetection { get; init; } + + [ContainsValues] + public BumpReminderSettings BumpReminder { get; init; } + + [ContainsValues] + public JoinSettings Join { get; init; } + + [ContainsValues] + public ExperienceSettings Experience { get; init; } + + [ContainsValues] + public CrosspostSettings Crosspost { get; init; } + + [ContainsValues] + public ActionLogSettings ActionLog { get; init; } + + [ContainsValues] + public InVoiceTextPrivacySettings InVoiceTextPrivacy { get; init; } + + [ContainsValues] + public InviteTrackerSettings InviteTracker { get; init; } + + [ContainsValues] + public InviteNotesSettings InviteNotes { get; init; } + + [ContainsValues] + public NameNormalizerSettings NameNormalizer { get; init; } + + [ContainsValues] + public EmbedMessageSettings EmbedMessage { get; init; } + + [ContainsValues] + public VcCreatorSettings VcCreator { get; init; } + + [ContainsValues] + public PrefixSettings PrefixSettings { get; init; } + + [ColumnName("autounarchivelist"), ColumnType(ColumnTypes.LongText), Default("[]")] + public ulong[] AutoUnarchiveThreads + { + get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Id, "autounarchivelist", this.Bot.DatabaseClient.mainDatabaseConnection)); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Id, "autounarchivelist", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("levelrewards"), ColumnType(ColumnTypes.LongText), Default("[]")] + public LevelRewardEntry[] LevelRewards + { + get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Id, "levelrewards", this.Bot.DatabaseClient.mainDatabaseConnection)); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Id, "levelrewards", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("reactionroles"), ColumnType(ColumnTypes.LongText), Default("[]")] + public ReactionRoleEntry[] ReactionRoles + { + get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Id, "reactionroles", this.Bot.DatabaseClient.mainDatabaseConnection)); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Id, "reactionroles", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("current_locale"), ColumnType(ColumnTypes.LongText), Nullable] + public string? CurrentLocale + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Id, "current_locale", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Id, "current_locale", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("override_locale"), ColumnType(ColumnTypes.LongText), Nullable] + public string? OverrideLocale + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Id, "override_locale", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Id, "override_locale", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + + public SelfFillingDatabaseDictionary Members { get; init; } + +} diff --git a/ProjectMakoto/Entities/Guilds/ActionLogSettings.cs b/ProjectMakoto/Entities/Guilds/ActionLogSettings.cs new file mode 100644 index 00000000..5035778e --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/ActionLogSettings.cs @@ -0,0 +1,129 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class ActionLogSettings : RequiresParent +{ + public ActionLogSettings(Bot bot, Guild parent) : base(bot, parent) + { + this.AuditLogCollectionUpdated(); + } + + [ColumnName("auditlogcache"), ColumnType(ColumnTypes.LongText), Default("[]")] + public ulong[] ProcessedAuditLogs + { + get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "auditlogcache", this.Bot.DatabaseClient.mainDatabaseConnection)); + set + { + _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "auditlogcache", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); + this.AuditLogCollectionUpdated(); + } + } + + [ColumnName("actionlog_channel"), ColumnType(ColumnTypes.BigInt), Default("0")] + public ulong Channel + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "actionlog_channel", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "actionlog_channel", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("actionlog_attempt_further_detail"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool AttemptGettingMoreDetails + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "actionlog_attempt_further_detail", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "actionlog_attempt_further_detail", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("actionlog_log_members_modified"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool MembersModified + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_members_modified", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_members_modified", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("actionlog_log_member_modified"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool MemberModified + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_member_modified", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_member_modified", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("actionlog_log_memberprofile_modified"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool MemberProfileModified + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_memberprofile_modified", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_memberprofile_modified", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("actionlog_log_message_deleted"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool MessageDeleted + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_message_deleted", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_message_deleted", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("actionlog_log_message_updated"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool MessageModified + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_message_updated", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_message_updated", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("actionlog_log_roles_modified"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool RolesModified + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_roles_modified", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_roles_modified", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("actionlog_log_banlist_modified"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool BanlistModified + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_banlist_modified", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_banlist_modified", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("actionlog_log_guild_modified"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool GuildModified + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_guild_modified", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_guild_modified", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("actionlog_log_channels_modified"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool ChannelsModified + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_channels_modified", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_channels_modified", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("actionlog_log_voice_state"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool VoiceStateUpdated + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_voice_state", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_voice_state", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("actionlog_log_invites_modified"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool InvitesModified + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_invites_modified", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "actionlog_log_invites_modified", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + + private void AuditLogCollectionUpdated() + { + while (this.ProcessedAuditLogs.Length > 50) + { + this.ProcessedAuditLogs = this.ProcessedAuditLogs.Skip(1).ToArray(); + } + } +} diff --git a/ProjectMakoto/Entities/Guilds/BumpReminderSettings.cs b/ProjectMakoto/Entities/Guilds/BumpReminderSettings.cs new file mode 100644 index 00000000..54a1cdbc --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/BumpReminderSettings.cs @@ -0,0 +1,82 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class BumpReminderSettings(Bot bot, Guild parent) : RequiresParent(bot, parent) +{ + public void Reset() + { + this.ChannelId = 0; + this.RoleId = 0; + this.MessageId = 0; + this.PersistentMessageId = 0; + this.LastUserId = 0; + this.LastBump = DateTime.MinValue; + this.LastReminder = DateTime.MinValue; + this.BumpsMissed = 0; + } + + [ColumnName("bump_channel"), ColumnType(ColumnTypes.BigInt), Default("0")] + public ulong ChannelId + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "bump_channel", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "bump_channel", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("bump_role"), ColumnType(ColumnTypes.BigInt), Default("0")] + public ulong RoleId + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "bump_role", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "bump_role", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + + [ColumnName("bump_message"), ColumnType(ColumnTypes.BigInt), Default("0")] + public ulong MessageId + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "bump_message", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "bump_message", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("bump_persistent_msg"), ColumnType(ColumnTypes.BigInt), Default("0")] + public ulong PersistentMessageId + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "bump_persistent_msg", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "bump_persistent_msg", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("bump_last_user"), ColumnType(ColumnTypes.BigInt), Default("0")] + public ulong LastUserId + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "bump_last_user", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "bump_last_user", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("bump_last_time"), ColumnType(ColumnTypes.BigInt), Default("0")] + public DateTime LastBump + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "bump_last_time", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "bump_last_time", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("bump_last_reminder"), ColumnType(ColumnTypes.BigInt), Default("0")] + public DateTime LastReminder + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "bump_last_reminder", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "bump_last_reminder", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("bump_missed"), ColumnType(ColumnTypes.Int), Default("0")] + public int BumpsMissed + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "bump_missed", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "bump_missed", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/Guilds/Crosspost/CrosspostMessage.cs b/ProjectMakoto/Entities/Guilds/Crosspost/CrosspostMessage.cs new file mode 100644 index 00000000..9cbd0b6c --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/Crosspost/CrosspostMessage.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class CrosspostMessage +{ + public ulong MessageId { get; set; } + public ulong ChannelId { get; set; } +} diff --git a/ProjectMakoto/Entities/Guilds/Crosspost/CrosspostRatelimit.cs b/ProjectMakoto/Entities/Guilds/Crosspost/CrosspostRatelimit.cs new file mode 100644 index 00000000..9e6fd047 --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/Crosspost/CrosspostRatelimit.cs @@ -0,0 +1,60 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class CrosspostRatelimit +{ + [JsonIgnore] + public Bot Bot { get; set; } + + [JsonIgnore] + public Guild Parent { get; set; } + + private ulong _Id { get; set; } + public ulong Id + { + get => this._Id; + set + { + this._Id = value; + this.Update(); + } + } + + private DateTime _FirstPost { get; set; } = DateTime.MinValue; + public DateTime FirstPost + { + get => this._FirstPost; + set + { + this._FirstPost = value; + this.Update(); + } + } + + private int _PostsRemaining { get; set; } = 0; + public int PostsRemaining + { + get => this._PostsRemaining; + set + { + this._PostsRemaining = value; + this.Update(); + } + } + + void Update() + { + if (this.Bot is null || this.Parent is null) + return; + + this.Parent.Crosspost.CrosspostRatelimits = this.Parent.Crosspost.CrosspostRatelimits.Update(x => x.Id.ToString(), this); + } +} diff --git a/ProjectMakoto/Entities/Guilds/CrosspostSettings.cs b/ProjectMakoto/Entities/Guilds/CrosspostSettings.cs new file mode 100644 index 00000000..4e274d1a --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/CrosspostSettings.cs @@ -0,0 +1,196 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class CrosspostSettings(Bot bot, Guild parent) : RequiresParent(bot, parent) +{ + [ColumnName("crosspostdelay"), ColumnType(ColumnTypes.Int), Default("5")] + public int DelayBeforePosting + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "crosspostdelay", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "crosspostdelay", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("crosspostexcludebots"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool ExcludeBots + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "crosspostexcludebots", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "crosspostexcludebots", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("crosspostchannels"), ColumnType(ColumnTypes.LongText), Default("[]")] + public ulong[] CrosspostChannels + { + get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "crosspostchannels", this.Bot.DatabaseClient.mainDatabaseConnection)); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "crosspostchannels", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("crosspost_ratelimits"), ColumnType(ColumnTypes.LongText), Default("[]")] + public CrosspostRatelimit[] CrosspostRatelimits + { + get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "crosspost_ratelimits", this.Bot.DatabaseClient.mainDatabaseConnection)) + .Select(x => + { + x.Bot = this.Bot; + x.Parent = this.Parent; + + return x; + }).ToArray(); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "crosspost_ratelimits", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); + } + + private bool QueueInitialized = false; + private Dictionary _queue = new(); + + public async Task CrosspostQueue() + { + this.QueueInitialized = true; + Log.Debug("Initializing crosspost queue for '{Guild}'", this.Parent.Id); + + while (true) + { + DiscordChannel channel; + DiscordMessage message; + + try + { + while (this._queue.Count == 0) + await Task.Delay(1000); + + var keyValuePair = this._queue.First(); + channel = keyValuePair.Value; + message = keyValuePair.Key; + } + catch (Exception) + { + this._queue ??= new(); + continue; + } + + try + { + if (!this.CrosspostRatelimits.Any(x => x.Id == channel.Id)) + { + Log.Debug("Initialized new crosspost ratelimit for '{Channel}'", channel.Id); + this.CrosspostRatelimits = this.CrosspostRatelimits.Add(new() + { + Id = channel.Id, + }); + } + + var r = this.CrosspostRatelimits.First(x => x.Id == channel.Id); + + Log.Debug("Crosspost Ratelimit '{Channel}': First: {First}; Remaining: {Remaining}", channel.Id, r.FirstPost, r.PostsRemaining); + + async Task Crosspost() + { + if (message.Flags?.HasMessageFlag(MessageFlags.Crossposted) ?? false) + return; + + r.PostsRemaining--; + var crossPostTask = channel.CrosspostMessageAsync(message); + + Stopwatch sw = new(); + sw.Start(); + while (!crossPostTask.IsCompleted && sw.ElapsedMilliseconds < 3000) + await Task.Delay(50); + sw.Stop(); + + Log.Debug("It took {Milliseconds}ms to process a crosspost", sw.ElapsedMilliseconds); + + if (!crossPostTask.IsCompleted) + { + Log.Warning("Crosspost Ratelimit tripped for '{Channel}': {Message}", channel.Id, message.Id); + + r.FirstPost = DateTime.UtcNow; + r.PostsRemaining = 0; + } + + _ = await crossPostTask; + + _ = this._queue.Remove(message); + Log.Debug("Crossposted message in '{Channel}': {Message}", channel.Id, message.Id); + } + + void ResetLimits() + { + r.PostsRemaining = 10; + r.FirstPost = DateTime.UtcNow; + } + + if (r.FirstPost.AddHours(1).GetTotalSecondsUntil() <= 0) + { + Log.Debug("First crosspost for '{Channel}' was at {FirstPost}, resetting crosspost availability", channel.Id, r.FirstPost.AddHours(1)); + ResetLimits(); + } + + if (r.PostsRemaining > 0) + { + Log.Debug("{Remaining} crossposts available for '{Channel}', allowing request", r.PostsRemaining, channel.Id); + await Crosspost(); + continue; + } + + if (r.FirstPost.AddHours(1).GetTotalSecondsUntil() > 0) + { + Log.Debug("No crossposts available for '{Channel}', waiting until {WaitUntil} ({WaitUntilSec} seconds)", channel.Id, r.FirstPost.AddHours(1), r.FirstPost.AddHours(1).GetTotalSecondsUntil()); + await Task.Delay(r.FirstPost.AddHours(1).GetTimespanUntil()); + } + + ResetLimits(); + + Log.Debug("Crossposts for '{Channel}' available again, allowing request. {Remaining} requests remaining, first post at {First}.", channel.Id, r.PostsRemaining, r.FirstPost); + await Crosspost(); + continue; + } + catch (Exception ex) + { + _ = this._queue.Remove(message); + Log.Error(ex, "Failed to process crosspost queue"); + } + } + } + + public async Task CrosspostWithRatelimit(DiscordClient client, DiscordMessage message) + { + if (message.Reference is not null || message.MessageType is not MessageType.Default) + return; + + if (this.Parent.Crosspost.ExcludeBots) + if (message.WebhookMessage || message.Author.IsBot) + return; + + var ReactionAdded = false; + + if (!this.QueueInitialized) + _ = this.CrosspostQueue(); + + this._queue.Add(message, message.Channel); + + await Task.Delay(5000); + + if (this._queue.ContainsKey(message)) + { + if (!ReactionAdded) + { + await message.CreateReactionAsync(DiscordEmoji.FromGuildEmote(client, 974029756355977216)); + ReactionAdded = true; + } + } + + while (this._queue.ContainsKey(message)) + { + await Task.Delay(1000); + } + + if (ReactionAdded) + _ = message.DeleteReactionsEmojiAsync(DiscordEmoji.FromGuildEmote(client, 974029756355977216)); + } +} diff --git a/ProjectMakoto/Entities/Guilds/EmbedMessageSettings.cs b/ProjectMakoto/Entities/Guilds/EmbedMessageSettings.cs new file mode 100644 index 00000000..e4eeb834 --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/EmbedMessageSettings.cs @@ -0,0 +1,27 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class EmbedMessageSettings(Bot bot, Guild parent) : RequiresParent(bot, parent) +{ + [ColumnName("embed_messages"), ColumnType(ColumnTypes.TinyInt), Default("1")] + public bool UseEmbedding + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "embed_messages", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "embed_messages", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("embed_github"), ColumnType(ColumnTypes.TinyInt), Default("1")] + public bool UseGithubEmbedding + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "embed_github", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "embed_github", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } +} diff --git a/ProjectMakoto/Entities/Guilds/ExperienceSettings.cs b/ProjectMakoto/Entities/Guilds/ExperienceSettings.cs new file mode 100644 index 00000000..44a30e6a --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/ExperienceSettings.cs @@ -0,0 +1,27 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class ExperienceSettings(Bot bot, Guild parent) : RequiresParent(bot, parent) +{ + [ColumnName("experience_use"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool UseExperience + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "experience_use", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "experience_use", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("experience_boost_bumpreminder"), ColumnType(ColumnTypes.TinyInt), Default("1")] + public bool BoostXpForBumpReminder + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "experience_boost_bumpreminder", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "experience_boost_bumpreminder", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } +} diff --git a/ProjectMakoto/Entities/Guilds/InVoiceTextPrivacySettings.cs b/ProjectMakoto/Entities/Guilds/InVoiceTextPrivacySettings.cs new file mode 100644 index 00000000..8a3fe6aa --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/InVoiceTextPrivacySettings.cs @@ -0,0 +1,27 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class InVoiceTextPrivacySettings(Bot bot, Guild parent) : RequiresParent(bot, parent) +{ + [ColumnName("vc_privacy_clear"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool ClearTextEnabled + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "vc_privacy_clear", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "vc_privacy_clear", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("vc_privacy_perms"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool SetPermissionsEnabled + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "vc_privacy_perms", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "vc_privacy_perms", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/Guilds/InviteNotesDetails.cs b/ProjectMakoto/Entities/Guilds/InviteNotesDetails.cs new file mode 100644 index 00000000..1b936e9b --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/InviteNotesDetails.cs @@ -0,0 +1,60 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class InviteNotesDetails +{ + [JsonIgnore] + public Bot Bot { get; set; } + + [JsonIgnore] + public Guild Parent { get; set; } + + private string _Invite { get; set; } + public string Invite + { + get => this._Invite; + set + { + this._Invite = value; + this.Update(); + } + } + + private string _Note { get; set; } + public string Note + { + get => this._Note; + set + { + this._Note = value; + this.Update(); + } + } + + private ulong _Moderator { get; set; } + public ulong Moderator + { + get => this._Moderator; + set + { + this._Moderator = value; + this.Update(); + } + } + + void Update() + { + if (this.Bot is null || this.Parent is null) + return; + + this.Parent.InviteNotes.Notes = this.Parent.InviteNotes.Notes.Update(x => x.Invite, this); + } +} diff --git a/ProjectMakoto/Entities/Guilds/InviteNotesSettings.cs b/ProjectMakoto/Entities/Guilds/InviteNotesSettings.cs new file mode 100644 index 00000000..2cc38589 --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/InviteNotesSettings.cs @@ -0,0 +1,27 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class InviteNotesSettings(Bot bot, Guild parent) : RequiresParent(bot, parent) +{ + [ColumnName("invitenotes"), ColumnType(ColumnTypes.LongText), Default("[]")] + public InviteNotesDetails[] Notes + { + get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "invitenotes", this.Bot.DatabaseClient.mainDatabaseConnection)) + .Select(x => + { + x.Bot = this.Bot; + x.Parent = this.Parent; + + return x; + }).ToArray(); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "invitenotes", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); + } +} diff --git a/ProjectMakoto/Entities/Guilds/InviteTracker/InviteTrackerCacheItem.cs b/ProjectMakoto/Entities/Guilds/InviteTracker/InviteTrackerCacheItem.cs new file mode 100644 index 00000000..11c25a8b --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/InviteTracker/InviteTrackerCacheItem.cs @@ -0,0 +1,61 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class InviteTrackerCacheItem +{ + [JsonIgnore] + public Bot Bot { get; set; } + + [JsonIgnore] + public Guild Parent { get; set; } + + private ulong _CreatorId { get; set; } + public ulong CreatorId + { + get => this._CreatorId; + set + { + this._CreatorId = value; + this.Update(); + } + } + + + private string _Code { get; set; } + public string Code + { + get => this._Code; + set + { + this._Code = value; + this.Update(); + } + } + + private long _Uses { get; set; } + public long Uses + { + get => this._Uses; + set + { + this._Uses = value; + this.Update(); + } + } + + void Update() + { + if (this.Bot is null || this.Parent is null) + return; + + this.Parent.InviteTracker.Cache = this.Parent.InviteTracker.Cache.Update(x => x.Code, this); + } +} diff --git a/ProjectMakoto/Entities/Guilds/InviteTrackerSettings.cs b/ProjectMakoto/Entities/Guilds/InviteTrackerSettings.cs new file mode 100644 index 00000000..84ff62c7 --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/InviteTrackerSettings.cs @@ -0,0 +1,34 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class InviteTrackerSettings(Bot bot, Guild parent) : RequiresParent(bot, parent) +{ + [ColumnName("invitetracker_enabled"), ColumnType(ColumnTypes.TinyInt), Default("1")] + public bool Enabled + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "invitetracker_enabled", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "invitetracker_enabled", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("invitetracker_cache"), ColumnType(ColumnTypes.LongText), Default("[]")] + public InviteTrackerCacheItem[] Cache + { + get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "invitetracker_cache", this.Bot.DatabaseClient.mainDatabaseConnection)) + .Select(x => + { + x.Bot = this.Bot; + x.Parent = this.Parent; + + return x; + }).ToArray(); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "invitetracker_cache", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); + } +} diff --git a/ProjectMakoto/Entities/Guilds/JoinSettings.cs b/ProjectMakoto/Entities/Guilds/JoinSettings.cs new file mode 100644 index 00000000..f58c9d9a --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/JoinSettings.cs @@ -0,0 +1,69 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class JoinSettings(Bot bot, Guild parent) : RequiresParent(bot, parent) +{ + [ColumnName("auto_assign_role_id"), ColumnType(ColumnTypes.BigInt), Default("0")] + public ulong AutoAssignRoleId + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "auto_assign_role_id", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "auto_assign_role_id", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("joinlog_channel_id"), ColumnType(ColumnTypes.BigInt), Default("0")] + public ulong JoinlogChannelId + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "joinlog_channel_id", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "joinlog_channel_id", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("autoban_global_ban"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool AutoBanGlobalBans + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "autoban_global_ban", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "autoban_global_ban", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("reapplyroles"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool ReApplyRoles + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "reapplyroles", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "reapplyroles", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("reapplynickname"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool ReApplyNickname + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "reapplynickname", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "reapplynickname", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("autokickspammer"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool AutoKickSpammer + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "autokickspammer", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "autokickspammer", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("autokickaccountage"), ColumnType(ColumnTypes.BigInt), Default("0")] + public TimeSpan AutoKickAccountAge + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "autokickaccountage", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "autokickaccountage", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("autokicknoroletime"), ColumnType(ColumnTypes.BigInt), Default("0")] + public TimeSpan AutoKickNoRoleTime + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "autokicknoroletime", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "autokicknoroletime", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/Guilds/LevelRewardEntry.cs b/ProjectMakoto/Entities/Guilds/LevelRewardEntry.cs new file mode 100644 index 00000000..20449e95 --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/LevelRewardEntry.cs @@ -0,0 +1,17 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class LevelRewardEntry +{ + public long Level { get; set; } + public ulong RoleId { get; set; } + public string Message { get; set; } +} diff --git a/ProjectMakoto/Entities/Guilds/Member.cs b/ProjectMakoto/Entities/Guilds/Member.cs new file mode 100644 index 00000000..7df4a383 --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/Member.cs @@ -0,0 +1,141 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using ProjectMakoto.Entities.Members; + +namespace ProjectMakoto.Entities.Guilds; + +[TableName("-")] +public sealed class Member : RequiresParent +{ + public Member(Bot bot, Guild guild, ulong key) : base(bot, guild) + { + this.InviteTracker = new(bot, this); + this.Experience = new(bot, this); + this.Id = key; + + _ = this.Bot.DatabaseClient.CreateRow(this.Parent.Id.ToString(), typeof(Member), key, this.Bot.DatabaseClient.guildDatabaseConnection); + } + + public async Task PerformAutoKickChecks(DiscordGuild guild = null, DiscordMember member = null, int retryCount = 0, List exceptions = null) + { + exceptions ??= new(); + + if (retryCount >= 3) + { + Log.Error("Failed to perform auto kick checks for {id}", this.Id); + + foreach (var b in exceptions) + Log.Error(b, "Auto Kick Error"); + + throw exceptions.Last(); + } + + try + { + guild ??= await this.Bot.DiscordClient.GetShard(this.Parent.Id).GetGuildAsync(this.Parent.Id); + member ??= await guild.GetMemberAsync(this.Id); + } + catch (Exception ex) + { + exceptions.Add(ex); + await this.PerformAutoKickChecks(null, null, retryCount + 1, exceptions); + return; + } + + if (member.IsBot) + return; + + if (this.Parent.Join.AutoKickSpammer && member.Flags.HasValue && + (member.Flags.Value.HasFlag(UserFlags.Spammer) || member.Flags.Value.HasFlag(UserFlags.DisabledSuspiciousActivity))) + { + await member.RemoveAsync(this.Bot.LoadedTranslations.Commands.Config.Join.AutoKickSpammerReason.Get(this.Parent)); + Log.Debug("Kicked {User} from {Guild}: Account is likely spammer", this.Id, this.Parent.Id); + return; + } + + if (this.Parent.Join.AutoKickAccountAge != TimeSpan.Zero && + member.CreationTimestamp.GetTimespanSince() < this.Parent.Join.AutoKickAccountAge) + { + await member.RemoveAsync(this.Bot.LoadedTranslations.Commands.Config.Join.AutoKickAccountAgeReason.Get(this.Parent)); + Log.Debug("Kicked {User} from {Guild}: Account is too young", this.Id, this.Parent.Id); + return; + } + + if (this.Parent.Join.AutoKickNoRoleTime != TimeSpan.Zero && member.JoinedAt.Add(this.Parent.Join.AutoKickNoRoleTime).GetTimespanSince() < this.Parent.Join.AutoKickNoRoleTime) + { + _ = new Func(async () => + { + + try + { + member = await guild.GetMemberAsync(this.Id, true); + + if (member.GetRoleHighestPosition() >= guild.CurrentMember.GetRoleHighestPosition()) + return; + + if (member.Roles.Count == 0) + { + await member.RemoveAsync(this.Bot.LoadedTranslations.Commands.Config.Join.AutoKickNoRolesReason.Get(this.Parent)); + Log.Debug("Kicked {User} from {Guild}: User did not pick roles after {Time}", this.Id, this.Parent.Id, this.Parent.Join.AutoKickNoRoleTime.GetHumanReadable()); + } + } + catch (DisCatSharp.Exceptions.NotFoundException) + { + return; + } + catch (Exception ex) + { + Log.Error(ex, "Auto Kick Error"); + return; + } + }).CreateScheduledTask(member.JoinedAt.Add(this.Parent.Join.AutoKickNoRoleTime).UtcDateTime, + new ScheduledTaskIdentifier(this.Id, Guid.NewGuid().ToString(), "norolesautokick")); + + return; + } + } + + [ColumnName("userid"), ColumnType(ColumnTypes.BigInt), Primary] + internal ulong Id { get; set; } + + [ColumnName("saved_nickname"), ColumnType(ColumnTypes.Text), Nullable] + public string? SavedNickname + { + get => this.Bot.DatabaseClient.GetValue(this.Parent.Id.ToString(), "userid", this.Id, "saved_nickname", this.Bot.DatabaseClient.guildDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue(this.Parent.Id.ToString(), "userid", this.Id, "saved_nickname", value, this.Bot.DatabaseClient.guildDatabaseConnection); + } + + [ColumnName("first_join"), ColumnType(ColumnTypes.BigInt), Default("0")] + public DateTime FirstJoinDate + { + get => this.Bot.DatabaseClient.GetValue(this.Parent.Id.ToString(), "userid", this.Id, "first_join", this.Bot.DatabaseClient.guildDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue(this.Parent.Id.ToString(), "userid", this.Id, "first_join", value, this.Bot.DatabaseClient.guildDatabaseConnection); + } + + [ColumnName("last_leave"), ColumnType(ColumnTypes.BigInt), Default("0")] + public DateTime LastLeaveDate + { + get => this.Bot.DatabaseClient.GetValue(this.Parent.Id.ToString(), "userid", this.Id, "last_leave", this.Bot.DatabaseClient.guildDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue(this.Parent.Id.ToString(), "userid", this.Id, "last_leave", value, this.Bot.DatabaseClient.guildDatabaseConnection); + } + + [ColumnName("roles"), ColumnType(ColumnTypes.LongText), Default("[]")] + public MemberRole[] MemberRoles + { + get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue(this.Parent.Id.ToString(), "userid", this.Id, "roles", this.Bot.DatabaseClient.guildDatabaseConnection)); + set => _ = this.Bot.DatabaseClient.SetValue(this.Parent.Id.ToString(), "userid", this.Id, "roles", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.guildDatabaseConnection); + } + + [ContainsValues] + public InviteTrackerMember InviteTracker { get; init; } + + [ContainsValues] + public ExperienceMember Experience { get; init; } +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/Guilds/Members/ExperienceMember.cs b/ProjectMakoto/Entities/Guilds/Members/ExperienceMember.cs new file mode 100644 index 00000000..4dcb0f8f --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/Members/ExperienceMember.cs @@ -0,0 +1,36 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using ProjectMakoto.Entities.Guilds; + +namespace ProjectMakoto.Entities.Members; + +public sealed class ExperienceMember(Bot bot, Member parent) : RequiresParent(bot, parent) +{ + [ColumnName("experience_last_message"), ColumnType(ColumnTypes.BigInt), Default("0")] + public DateTime Last_Message + { + get => this.Bot.DatabaseClient.GetValue(this.Parent.Parent.Id.ToString(), "userid", this.Parent.Id, "experience_last_message", this.Bot.DatabaseClient.guildDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue(this.Parent.Parent.Id.ToString(), "userid", this.Parent.Id, "experience_last_message", value, this.Bot.DatabaseClient.guildDatabaseConnection); + } + + [ColumnName("experience"), ColumnType(ColumnTypes.BigInt), Default("1")] + public long Points + { + get => this.Bot.DatabaseClient.GetValue(this.Parent.Parent.Id.ToString(), "userid", this.Parent.Id, "experience", this.Bot.DatabaseClient.guildDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue(this.Parent.Parent.Id.ToString(), "userid", this.Parent.Id, "experience", value, this.Bot.DatabaseClient.guildDatabaseConnection); + } + + [ColumnName("experience_level"), ColumnType(ColumnTypes.BigInt), Default("1")] + public long Level + { + get => this.Bot.DatabaseClient.GetValue(this.Parent.Parent.Id.ToString(), "userid", this.Parent.Id, "experience_level", this.Bot.DatabaseClient.guildDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue(this.Parent.Parent.Id.ToString(), "userid", this.Parent.Id, "experience_level", value, this.Bot.DatabaseClient.guildDatabaseConnection); + } +} diff --git a/ProjectMakoto/Entities/Guilds/Members/InviteTrackerMember.cs b/ProjectMakoto/Entities/Guilds/Members/InviteTrackerMember.cs new file mode 100644 index 00000000..9920980b --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/Members/InviteTrackerMember.cs @@ -0,0 +1,29 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using ProjectMakoto.Entities.Guilds; + +namespace ProjectMakoto.Entities.Members; + +public sealed class InviteTrackerMember(Bot bot, Member parent) : RequiresParent(bot, parent) +{ + [ColumnName("invite_user"), ColumnType(ColumnTypes.BigInt), Default("0")] + public ulong UserId + { + get => this.Bot.DatabaseClient.GetValue(this.Parent.Parent.Id.ToString(), "userid", this.Parent.Id, "invite_user", this.Bot.DatabaseClient.guildDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue(this.Parent.Parent.Id.ToString(), "userid", this.Parent.Id, "invite_user", value, this.Bot.DatabaseClient.guildDatabaseConnection); + } + + [ColumnName("invite_code"), ColumnType(ColumnTypes.Text), Default("")] + public string Code + { + get => this.Bot.DatabaseClient.GetValue(this.Parent.Parent.Id.ToString(), "userid", this.Parent.Id, "invite_code", this.Bot.DatabaseClient.guildDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue(this.Parent.Parent.Id.ToString(), "userid", this.Parent.Id, "invite_code", value, this.Bot.DatabaseClient.guildDatabaseConnection); + } +} diff --git a/ProjectMakoto/Entities/Guilds/Members/MemberRole.cs b/ProjectMakoto/Entities/Guilds/Members/MemberRole.cs new file mode 100644 index 00000000..097dd646 --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/Members/MemberRole.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Members; + +public sealed class MemberRole +{ + public string Name { get; set; } + public ulong Id { get; set; } +} diff --git a/ProjectMakoto/Entities/Guilds/NameNormalizerSettings.cs b/ProjectMakoto/Entities/Guilds/NameNormalizerSettings.cs new file mode 100644 index 00000000..dde59d2b --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/NameNormalizerSettings.cs @@ -0,0 +1,22 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class NameNormalizerSettings(Bot bot, Guild parent) : RequiresParent(bot, parent) +{ + [ColumnName("normalizenames"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool NameNormalizerEnabled + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "normalizenames", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "normalizenames", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + public bool NameNormalizerRunning = false; +} diff --git a/ProjectMakoto/Entities/Guilds/PhishingDetectionSettings.cs b/ProjectMakoto/Entities/Guilds/PhishingDetectionSettings.cs new file mode 100644 index 00000000..15a0ce80 --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/PhishingDetectionSettings.cs @@ -0,0 +1,57 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class PhishingDetectionSettings(Bot bot, Guild parent) : RequiresParent(bot, parent) +{ + [ColumnName("phishing_detect"), ColumnType(ColumnTypes.TinyInt), Default("1")] + public bool DetectPhishing + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "phishing_detect", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "phishing_detect", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("phishing_warnonredirect"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool WarnOnRedirect + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "phishing_warnonredirect", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "phishing_warnonredirect", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("phishing_abuseipdb"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool AbuseIpDbReports + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "phishing_abuseipdb", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "phishing_abuseipdb", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("phishing_type"), ColumnType(ColumnTypes.TinyInt), Default("2")] + public PhishingPunishmentType PunishmentType + { + get => (PhishingPunishmentType)this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "phishing_type", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "phishing_type", Convert.ToInt32(value), this.Bot.DatabaseClient.mainDatabaseConnection); + } + + + [ColumnName("phishing_reason"), ColumnType(ColumnTypes.Text), Default("%R")] + public string CustomPunishmentReason + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "phishing_reason", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "phishing_reason", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + + [ColumnName("phishing_time"), ColumnType(ColumnTypes.BigInt), Default("1209600")] + public TimeSpan CustomPunishmentLength + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "phishing_time", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "phishing_time", Convert.ToInt64(value.TotalSeconds), this.Bot.DatabaseClient.mainDatabaseConnection); + } +} diff --git a/ProjectMakoto/Entities/Guilds/PrefixSettings.cs b/ProjectMakoto/Entities/Guilds/PrefixSettings.cs new file mode 100644 index 00000000..32ab9328 --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/PrefixSettings.cs @@ -0,0 +1,26 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; +public sealed class PrefixSettings(Bot bot, Guild parent) : RequiresParent(bot, parent) +{ + [ColumnName("prefix"), ColumnType(ColumnTypes.Text), Default(";;")] + public string Prefix + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "prefix", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "prefix", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("prefix_disabled"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool PrefixDisabled + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "prefix_disabled", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "prefix_disabled", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } +} diff --git a/ProjectMakoto/Entities/Guilds/ReactionRoleEntry.cs b/ProjectMakoto/Entities/Guilds/ReactionRoleEntry.cs new file mode 100644 index 00000000..07bb3613 --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/ReactionRoleEntry.cs @@ -0,0 +1,28 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class ReactionRoleEntry +{ + public string UUID = Guid.NewGuid().ToString(); + public ulong EmojiId { get; set; } + public string EmojiName { get; set; } + + public DiscordEmoji GetEmoji(DiscordClient client) + { + return this.EmojiId == 0 + ? DiscordEmoji.FromName(client, $":{this.EmojiName.Remove(this.EmojiName.LastIndexOf(':'), this.EmojiName.Length - this.EmojiName.LastIndexOf(':'))}:") + : DiscordEmoji.FromGuildEmote(client, this.EmojiId); + } + + public ulong RoleId { get; set; } + public ulong ChannelId { get; set; } + public ulong MessageId { get; set; } +} diff --git a/ProjectMakoto/Entities/Guilds/TokenLeakDetectionSettings.cs b/ProjectMakoto/Entities/Guilds/TokenLeakDetectionSettings.cs new file mode 100644 index 00000000..455a09dd --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/TokenLeakDetectionSettings.cs @@ -0,0 +1,20 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class TokenLeakDetectionSettings(Bot bot, Guild parent) : RequiresParent(bot, parent) +{ + [ColumnName("tokens_detect"), ColumnType(ColumnTypes.TinyInt), Default("1")] + public bool DetectTokens + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "tokens_detect", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "tokens_detect", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } +} diff --git a/ProjectMakoto/Entities/Guilds/VcCreator/VcCreatorDetails.cs b/ProjectMakoto/Entities/Guilds/VcCreator/VcCreatorDetails.cs new file mode 100644 index 00000000..87b72895 --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/VcCreator/VcCreatorDetails.cs @@ -0,0 +1,74 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class VcCreatorDetails +{ + [JsonIgnore] + public Bot Bot { get; set; } + + [JsonIgnore] + public VcCreatorSettings Parent { get; set; } + + private ulong _ChannelId { get; set; } + public ulong ChannelId + { + get => this._ChannelId; + set + { + this._ChannelId = value; + this.Update(); + } + } + + private ulong _OwnerId { get; set; } + public ulong OwnerId + { + get => this._OwnerId; + set + { + this._OwnerId = value; + this.Update(); + } + } + + private ulong[] _BannedUsers { get; set; } = Array.Empty(); + public ulong[] BannedUsers + { + get => this._BannedUsers; + set + { + this._BannedUsers = value; + this.Update(); + } + } + + private DateTime _LastRename { get; set; } = DateTime.MinValue; + public DateTime LastRename + { + get => this._LastRename; + set + { + this._LastRename = value; + this.Update(); + } + } + + [JsonIgnore] + public bool EventsRegistered { get; set; } = false; + + void Update() + { + if (this.Bot is null || this.Parent is null) + return; + + this.Parent.CreatedChannels = this.Parent.CreatedChannels.Update(x => x.ChannelId.ToString(), this); + } +} diff --git a/ProjectMakoto/Entities/Guilds/VcCreator/VcCreatorSettings.cs b/ProjectMakoto/Entities/Guilds/VcCreator/VcCreatorSettings.cs new file mode 100644 index 00000000..35bba566 --- /dev/null +++ b/ProjectMakoto/Entities/Guilds/VcCreator/VcCreatorSettings.cs @@ -0,0 +1,164 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Guilds; + +public sealed class VcCreatorSettings : RequiresParent +{ + public VcCreatorSettings(Bot bot, Guild parent) : base(bot, parent) + { + this.CreatedChannelsUpdated(); + } + + Translations.events.vcCreator tKey + => this.Bot.LoadedTranslations.Events.VcCreator; + + private DiscordGuild cachedGuild { get; set; } + + [ColumnName("vccreator_channelid"), ColumnType(ColumnTypes.BigInt), Default("0")] + public ulong Channel + { + get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "vccreator_channelid", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "vccreator_channelid", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("vccreator_channellist"), ColumnType(ColumnTypes.LongText), Default("[]")] + public VcCreatorDetails[] CreatedChannels + { + get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "vccreator_channellist", this.Bot.DatabaseClient.mainDatabaseConnection)).Select(x => + { + x.Bot = this.Bot; + x.Parent = this; + + return x; + }).ToArray(); + set + { + _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "vccreator_channellist", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); + this.CreatedChannelsUpdated(); + } + } + + [JsonIgnore] + public Dictionary LastCreatedChannel = new(); + + private void CreatedChannelsUpdated() + { + _ = Task.Run(async () => + { + while (!this.Bot.status.DiscordGuildDownloadCompleted) + await Task.Delay(1000); + + this.cachedGuild ??= await this.Bot.DiscordClient.GetShard(this.Parent.Id).GetGuildAsync(this.Parent.Id); + + await Task.Delay(5000); + + for (var i = 0; i < this.CreatedChannels.Length; i++) + { + var b = this.CreatedChannels.ElementAt(i); + + if (!this.cachedGuild.Channels.ContainsKey(b.OwnerId)) + { + Log.Debug("Channel '{Channel}' was deleted, deleting Vc Creator Entry.", b.OwnerId); + this.CreatedChannels = this.CreatedChannels.Remove(x => x.ChannelId.ToString(), b); + i--; + } + } + + foreach (var b in this.CreatedChannels) + if (!b.EventsRegistered) + { + _ = Task.Run(async () => + { + b.EventsRegistered = true; + async Task VoiceStateUpdated(DiscordClient sender, VoiceStateUpdateEventArgs e) + { + _ = Task.Run(async () => + { + if (e.Before?.Channel?.Id == b.ChannelId || e.After?.Channel?.Id == b.ChannelId) + { + var channel = (e.After?.Channel?.Id != 0 ? e.After.Channel : null) ?? e.Before.Channel; + var users = channel.Users.Where(x => !x.IsBot).ToList(); + + if (users.Count <= 0) + { + Log.Debug("Channel '{Channel}' is now empty, deleting.", b.ChannelId); + + await channel.DeleteAsync(); + this.CreatedChannels = this.CreatedChannels.Remove(x => x.ChannelId.ToString(), b); + return; + } + + if (e.User.Id == b.OwnerId && e.After?.Channel?.Id != b.ChannelId) + { + Log.Debug("The owner of channel '{Channel}' left, assigning new owner.", b.ChannelId); + var newOwner = users.SelectRandom(); + + b.OwnerId = newOwner.Id; + + _ = await channel.SendMessageAsync(new DiscordEmbedBuilder().WithDescription(this.tKey.NewOwner.Get(this.Parent).Build(true, new TVar("User", newOwner.Mention))).WithColor(EmbedColors.Info)); + return; + } + + if (b.BannedUsers.Contains(e.After?.User?.Id ?? 0)) + { + var u = await e.User.ConvertToMember(this.cachedGuild); + + Log.Debug("Banned user in channel '{Channel}' joined, disconnecting.", b.ChannelId); + if (u.Permissions.HasPermission(Permissions.Administrator) || u.Permissions.HasPermission(Permissions.ManageChannels) || u.Permissions.HasPermission(Permissions.ModerateMembers) || u.Permissions.HasPermission(Permissions.KickMembers) || u.Permissions.HasPermission(Permissions.BanMembers) || u.Permissions.HasPermission(Permissions.MuteMembers) || u.Permissions.HasPermission(Permissions.DeafenMembers)) + return; + + await u.DisconnectFromVoiceAsync(); + return; + } + + if (e.Before?.Channel?.Id != e.After?.Channel?.Id) + { + if (e.After?.Channel?.Id == b.ChannelId) + { + _ = await channel.SendMessageAsync(new DiscordEmbedBuilder().WithDescription(this.tKey.UserJoined.Get(this.Parent).Build(true, new TVar("User", e.User.Mention))).WithColor(EmbedColors.Success).WithAuthor(this.Bot.LoadedTranslations.Events.Actionlog.UserJoined.Get(this.Parent), "", AuditLogIcons.UserAdded)); + } + else + { + _ = await channel.SendMessageAsync(new DiscordEmbedBuilder().WithDescription(this.tKey.UserLeft.Get(this.Parent).Build(true, new TVar("User", e.User.Mention))).WithColor(EmbedColors.Error).WithAuthor(this.Bot.LoadedTranslations.Events.Actionlog.UserLeft.Get(this.Parent), "", AuditLogIcons.UserLeft)); + } + } + } + }).Add(this.Bot); + } + + _ = Task.Run(async () => + { + await Task.Delay(5000); + + var channel = await this.Bot.DiscordClient.GetShard(this.Parent.Id).GetChannelAsync(b.ChannelId); + + if (channel.Users.Count <= 0) + { + Log.Debug("No one joined channel '{Channel}', deleting.", b.ChannelId); + + await channel.DeleteAsync(); + this.CreatedChannels = this.CreatedChannels.Remove(x => x.ChannelId.ToString(), b); + return; + } + }).Add(this.Bot); + + this.Bot.DiscordClient.VoiceStateUpdated += VoiceStateUpdated; + Log.Debug("Created VcCreator Event for '{Channel}'", b.ChannelId); + + while (this.CreatedChannels.Any(x => x.ChannelId == b.ChannelId)) + await Task.Delay(500); + + this.Bot.DiscordClient.VoiceStateUpdated -= VoiceStateUpdated; + Log.Debug("Deleted VcCreator Event for '{Channel}'", b.ChannelId); + }).Add(this.Bot); + } + }); + } +} diff --git a/ProjectMakoto/Entities/InteractionResult.cs b/ProjectMakoto/Entities/InteractionResult.cs new file mode 100644 index 00000000..ae14aff5 --- /dev/null +++ b/ProjectMakoto/Entities/InteractionResult.cs @@ -0,0 +1,35 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public sealed class InteractionResult +{ + public InteractionResult(T result) + { + this.Result = result; + } + + public InteractionResult(Exception exception) + { + this.Exception = exception; + } + + public T Result { get; set; } + + public bool Failed { get { return this.TimedOut || this.Cancelled || this.Errored; } } + + public bool TimedOut { get { return (this.Exception is not null && this.Exception.GetType() == typeof(TimedOutException)); } } + + public bool Cancelled { get { return (this.Exception is not null && this.Exception.GetType() == typeof(CancelException)); } } + + public bool Errored { get { return this.Exception is not null; } } + + public Exception Exception { get; set; } +} diff --git a/ProjectMakoto/Entities/LanguageCodes.cs b/ProjectMakoto/Entities/LanguageCodes.cs new file mode 100644 index 00000000..ba53015a --- /dev/null +++ b/ProjectMakoto/Entities/LanguageCodes.cs @@ -0,0 +1,28 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public sealed class LanguageCodes +{ + internal LanguageCodes() { } + + public IReadOnlyList List + => this._List.AsReadOnly(); + + internal List _List = new(); + + public sealed class LanguageInfo + { + internal LanguageInfo() { } + + public string Name { get; set; } + public string Code { get; set; } + } +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/LoggingEnrichers/BadRequestExceptionEnricher.cs b/ProjectMakoto/Entities/LoggingEnrichers/BadRequestExceptionEnricher.cs new file mode 100644 index 00000000..f54ed41d --- /dev/null +++ b/ProjectMakoto/Entities/LoggingEnrichers/BadRequestExceptionEnricher.cs @@ -0,0 +1,42 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using Serilog.Core; + +namespace ProjectMakoto.Entities.LoggingEnrichers; + +public class BadRequestExceptionEnricher : ILogEventEnricher +{ + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + if (logEvent.Exception == null) + return; + + if (logEvent.Exception is not DisCatSharp.Exceptions.BadRequestException badRequest) + { + if (logEvent.Exception is AggregateException aggregateException && aggregateException.InnerException is DisCatSharp.Exceptions.BadRequestException innerException) + badRequest = innerException; + else + return; + } + + List> badRequestData = + [ + new KeyValuePair("Code", badRequest.Code), + new KeyValuePair("WebRequest", badRequest.WebRequest), + new KeyValuePair("WebResponse", badRequest.WebResponse), + new KeyValuePair("JsonMessage", badRequest.JsonMessage), + new KeyValuePair("Errors", badRequest.Errors), + ]; + + var property = propertyFactory.CreateProperty("BadRequestException", badRequestData, destructureObjects: true); + + logEvent.AddPropertyIfAbsent(property); + } +} diff --git a/ProjectMakoto/Entities/LoggingEnrichers/ExceptionDataEnricher.cs b/ProjectMakoto/Entities/LoggingEnrichers/ExceptionDataEnricher.cs new file mode 100644 index 00000000..e64b734d --- /dev/null +++ b/ProjectMakoto/Entities/LoggingEnrichers/ExceptionDataEnricher.cs @@ -0,0 +1,31 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using Serilog.Core; + +namespace ProjectMakoto.Entities.LoggingEnrichers; +public class ExceptionDataEnricher : ILogEventEnricher +{ + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + if (logEvent.Exception == null || + logEvent.Exception.Data == null || + logEvent.Exception.Data.Count == 0) + return; + + var dataDictionary = logEvent.Exception.Data + .Cast() + .Where(e => e.Key is string) + .ToDictionary(e => (string)e.Key, e => e.Value); + + var property = propertyFactory.CreateProperty("ExceptionData", dataDictionary, destructureObjects: true); + + logEvent.AddPropertyIfAbsent(property); + } +} diff --git a/ProjectMakoto/Entities/LogsSink.cs b/ProjectMakoto/Entities/LogsSink.cs new file mode 100644 index 00000000..f94bf17a --- /dev/null +++ b/ProjectMakoto/Entities/LogsSink.cs @@ -0,0 +1,24 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using Serilog.Core; +using Serilog.Events; + +namespace ProjectMakoto.Entities; +public class LogsSink(Bot bot) : ILogEventSink +{ + /// + /// Emit the provided log event to the sink. + /// + /// The log event to write + public void Emit(LogEvent logEvent) + { + bot.Watcher.LogHandler(bot, null, logEvent); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/MakotoCommands/MakotoCommand.cs b/ProjectMakoto/Entities/MakotoCommands/MakotoCommand.cs new file mode 100644 index 00000000..670bc2e7 --- /dev/null +++ b/ProjectMakoto/Entities/MakotoCommands/MakotoCommand.cs @@ -0,0 +1,317 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto; + +public sealed class MakotoCommand +{ + private MakotoCommand() { } + + /// + /// Creates a new Context Menu Command. + /// + /// The name of the command to be registered. + /// The description of the command to be registered. + /// The command to be executed. + /// If not set, no prefix alternative for the command will be set. + /// Thrown if any required argument is or consists only of whitespaces. + public MakotoCommand(ApplicationCommandType type, string ContextName, string Description, Type Command, string? PrefixAlternativeName = null) + { + if (ContextName.IsNullOrWhiteSpace()) + throw new ArgumentNullException(nameof(ContextName)); + + if (Command is null) + throw new ArgumentNullException(nameof(Command)); + + if (type is not ApplicationCommandType.Message and not ApplicationCommandType.User) + throw new InvalidOperationException("The ApplicationCommandType has to be Message or User!"); + + this.ContextMenuType = type; + this.Name = ContextName.Trim(); + this.AlternativeName = PrefixAlternativeName; + this.Description = Description.Trim(); + this.Command = Command; + this.SupportedCommandTypes = !PrefixAlternativeName.IsNullOrWhiteSpace() ? new[] { MakotoCommandType.ContextMenu, MakotoCommandType.PrefixCommand } : new[] { MakotoCommandType.ContextMenu }; + } + + /// + /// Create a new Command. + /// + /// The name of the command to be registered. + /// The description of the command to be registered. + /// The command to be executed. + /// The required overloads of the command to be registered. + /// Thrown if any required argument is or consists only of whitespaces. + public MakotoCommand(string Name, string Description, Type Command, params MakotoCommandOverload[] Overloads) + { + if (Name.IsNullOrWhiteSpace()) + throw new ArgumentNullException(nameof(Name)); + + if (Description.IsNullOrWhiteSpace()) + throw new ArgumentNullException(nameof(Description)); + + if (Command is null) + throw new ArgumentNullException(nameof(Command)); + + if (!Command.IsAssignableTo(typeof(BaseCommand))) + throw new ArgumentException($"Command has to inherit {nameof(BaseCommand)}", nameof(Command)); + + this.Name = Name.Trim(); + this.Description = Description.Trim(); + this.Command = Command; + this.Overloads = Overloads?.ToArray() ?? Array.Empty(); + } + + /// + /// Creates a new Command Group. + /// To extent an existing command group, name the command group with the same name (case-insensitive) as the existing command group. + /// + /// The name of this plugin group. + /// The description of this plugin group. + /// The commands of this group. + public MakotoCommand(string Name, string Description, params MakotoCommand[] Commands) + { + if (Name.IsNullOrWhiteSpace()) + throw new ArgumentNullException(nameof(Name)); + + if (Description.IsNullOrWhiteSpace()) + throw new ArgumentNullException(nameof(Description)); + + if ((Commands?.Length ?? 0) == 0) + throw new ArgumentNullException(nameof(Commands)); + + if (this.UseDefaultHelp && Commands.Any(x => x.Name == "help")) + throw new ArgumentException("You cannot provide a help command if the default help is enabled."); + + this.Name = Name.Trim(); + this.Description = Description.Trim(); + this.SubCommands = Commands; + this.Overloads = this.Overloads?.ToArray() ?? Array.Empty(); + this.UseDefaultHelp = true; + } + + /// + /// Whether the command has been registered. + /// All modifications will fail if this values is true. + /// + public bool Registered { get; internal set; } = false; + + /// + /// The command's name. + /// + public string Name { get; internal set; } + + /// + /// If using Context Menu, this sets the alternative command name for prefix commands. + /// + public string? AlternativeName { get; internal set; } + + /// + /// The command's description. + /// + public string Description { get; internal set; } + + /// + /// This command's parent, if group. + /// + public MakotoCommand? Parent { get; internal set; } + + /// + /// Whether this command is a group. + /// + public bool IsGroup + => (this.Command is null && this.SubCommands is not null); + + /// + /// The command to execute. + /// + public Type? Command { get; internal set; } + + /// + /// The command's sub commands, if group. + /// + public MakotoCommand[]? SubCommands { get; internal set; } + + /// + /// The required overloads. + /// + public MakotoCommandOverload[] Overloads { get; internal set; } + + /// + /// The Context Menu Type, only usable if includes + /// + public ApplicationCommandType? ContextMenuType { get; internal set; } = null; + + /// + /// Whether to use the default help for command groups. + /// Defaults to . + /// + public bool UseDefaultHelp { get; internal set; } = true; + + /// + /// Updates the value. + /// + /// + /// The new value. + /// This with the updated value. + /// Thrown if the command is already registered. + public MakotoCommand WithUseDefaultHelp(bool UseDefaultHelp) + { + if (this.Registered) + throw new InvalidOperationException("The command is already registered. It can no longer be modified."); + + if (!this.IsGroup) + throw new InvalidOperationException("The command is not a group."); + + this.UseDefaultHelp = UseDefaultHelp; + return this; + } + + /// + /// The required permissions to view the command as application command. + /// This does not protect the command from users without this permission. It only hides the command in the application command list when the user does not fulfill the requirement. + /// Defaults to . + /// + public Permissions? RequiredPermissions { get; internal set; } = null; + + /// + /// Updates the value. + /// + /// + /// The new value. + /// This with the updated value. + /// Thrown if the command is already registered. + public MakotoCommand WithRequiredPermissions(Permissions RequiredPermissions) + { + if (this.Registered) + throw new InvalidOperationException("The command is already registered. It can no longer be modified."); + + this.RequiredPermissions = RequiredPermissions; + return this; + } + + /// + /// Whether to allow running this command in Direct Messages. + /// Make sure to adjust your command to accommodate for usage in direct messages. + /// Defaults to . + /// + public bool AllowPrivateUsage { get; internal set; } = false; + + /// + /// Updates the value. + /// + /// + /// The new value. + /// This with the updated value. + /// Thrown if the command is already registered. + public MakotoCommand WithAllowPrivateUsage(bool AllowPrivateUsage) + { + if (this.Registered) + throw new InvalidOperationException("The command is already registered. It can no longer be modified."); + + this.AllowPrivateUsage = AllowPrivateUsage; + return this; + } + + /// + /// Whether the command should be marked as NSFW. + /// This does not ensure that the command is only run by adult users. It only hides this command in the application command list when the user does not fulfill the requirement. + /// Defaults to . + /// + public bool IsNsfw { get; internal set; } = false; + + /// + /// Updates the value. + /// + /// + /// The new value. + /// This with the updated value. + /// Thrown if the command is already registered. + public MakotoCommand WithIsNsfw(bool IsNsfw) + { + if (this.Registered) + throw new InvalidOperationException("The command is already registered. It can no longer be modified."); + + this.IsNsfw = IsNsfw; + return this; + } + + /// + /// Which command types are supported. + /// Defaults to and . + /// + public IReadOnlyList SupportedCommandTypes { get; internal set; } = new List() { MakotoCommandType.PrefixCommand, MakotoCommandType.SlashCommand }.AsReadOnly(); + + /// + /// Updates the value. + /// + /// + /// The new value. + /// This with the updated value. + /// Thrown if the command is already registered or the type cannot be changed. + public MakotoCommand WithSupportedCommandTypes(params MakotoCommandType[] SupportedCommands) + { + if (this.Registered) + throw new InvalidOperationException("The command is already registered. It can no longer be modified."); + + if (SupportedCommands.Contains(MakotoCommandType.ContextMenu)) + throw new InvalidOperationException("You cannot use ContextMenu or ContextMenuWithoutPrefix as supported CommandType, please use the constructor instead."); + + if (this.SupportedCommandTypes.Any(x => x == MakotoCommandType.ContextMenu)) + throw new InvalidOperationException("You cannot modify the supported command types on context menus."); + + this.SupportedCommandTypes = SupportedCommands; + return this; + } + + /// + /// Whether to run this command with an ephemeral message when ran via slash command. + /// Defaults to . + /// + public bool IsEphemeral { get; internal set; } = true; + + /// + /// Updates the value. + /// + /// + /// The new value. + /// This with the updated value. + /// Thrown if the command is already registered. + public MakotoCommand WithIsEphemeral(bool useEphemeral) + { + if (this.Registered) + throw new InvalidOperationException("The command is already registered. It can no longer be modified."); + + this.IsEphemeral = useEphemeral; + return this; + } + + + /// + /// Alternative command names when running command as prefix command. + /// + public string[]? Aliases { get; internal set; } = null; + + + /// + /// Updates the value. + /// + /// + /// The new value. + /// This with the updated value. + /// Thrown if the command is already registered. + public MakotoCommand WithAliases(params string[] aliases) + { + if (this.Registered) + throw new InvalidOperationException("The command is already registered. It can no longer be modified."); + + this.Aliases = aliases; + return this; + } +} diff --git a/ProjectMakoto/Entities/MakotoCommands/MakotoCommandOverload.cs b/ProjectMakoto/Entities/MakotoCommands/MakotoCommandOverload.cs new file mode 100644 index 00000000..2c6b4c79 --- /dev/null +++ b/ProjectMakoto/Entities/MakotoCommands/MakotoCommandOverload.cs @@ -0,0 +1,126 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto; +/// +/// Creates a new required overload for a command. +/// +/// The type to use for the overload. +/// The name for the overload to use. +/// The description of the overload to use. +/// If the overload should be required. +/// If the remaining string of the triggering message should be used as the last argument. +public sealed class MakotoCommandOverload(Type Type, string Name, string Description, bool Required = true, bool UseRemainingString = false) +{ + + /// + /// The type of overload. + /// + public Type Type { get; init; } = Type; + + /// + /// The name of the overload. + /// + public string Name { get; init; } = Name; + + /// + /// The description of the overload. + /// + public string Description { get; init; } = Description; + + /// + /// If the overload is required. + /// + public bool Required { get; init; } = Required; + + /// + /// If the overload is required. + /// + public bool UseRemainingString { get; init; } = UseRemainingString; + + /// + /// The type used for auto complete, null if no auto complete defined. + /// + public Type? AutoCompleteType { get; internal set; } = null; + + /// + /// The minimum value of an int. + /// + public long? MinimumValue { get; internal set; } = null; + + /// + /// The maximum value of an int. + /// + public long? MaximumValue { get; internal set; } = null; + + /// + /// The type of channels to limit this overload to. + /// + public ChannelType? ChannelType { get; internal set; } = null; + + /// + /// Sets the auto complete provider for this overload. + /// + /// + /// + /// + public MakotoCommandOverload WithAutoComplete(Type autocompleteType) + { + if (autocompleteType.IsAssignableFrom(typeof(IAutocompleteProvider))) + throw new ArgumentException($"The provided type does not inherit {nameof(IAutocompleteProvider)}!", nameof(autocompleteType)); + + this.AutoCompleteType = autocompleteType; + return this; + } + + /// + /// Sets the channel type and returns this overload. + /// + /// + /// + /// + public MakotoCommandOverload WithChannelType(ChannelType channelType) + { + if (this.Type != typeof(DiscordChannel)) + throw new ArgumentException($"Type has to be a {nameof(DiscordChannel)}!", nameof(channelType)); + + this.ChannelType = channelType; + return this; + } + + /// + /// Sets the minimum value and returns this overload. + /// + /// + /// + /// + public MakotoCommandOverload WithMinimumValue(long newValue) + { + if (this.Type != typeof(int) && this.Type != typeof(long) && this.Type != typeof(double)) + throw new ArgumentException("Type has to be an int, long or double!", nameof(newValue)); + + this.MinimumValue = newValue; + return this; + } + + /// + /// Sets the minimum value and returns this overload. + /// + /// + /// + /// + public MakotoCommandOverload WithMaximumValue(long newValue) + { + if (this.Type != typeof(int) && this.Type != typeof(long) && this.Type != typeof(double)) + throw new ArgumentException("Type has to be an int, long or double!", nameof(newValue)); + + this.MaximumValue = newValue; + return this; + } +} diff --git a/ProjectMakoto/Entities/MakotoCommands/MakotoCommandType.cs b/ProjectMakoto/Entities/MakotoCommands/MakotoCommandType.cs new file mode 100644 index 00000000..f4f9a394 --- /dev/null +++ b/ProjectMakoto/Entities/MakotoCommands/MakotoCommandType.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto; +public enum MakotoCommandType +{ + SlashCommand = 0, + PrefixCommand = 1, + ContextMenu = 2, +} diff --git a/ProjectMakoto/Entities/MakotoCommands/MakotoModule.cs b/ProjectMakoto/Entities/MakotoCommands/MakotoModule.cs new file mode 100644 index 00000000..59b93a71 --- /dev/null +++ b/ProjectMakoto/Entities/MakotoCommands/MakotoModule.cs @@ -0,0 +1,76 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ProjectMakoto; +public class MakotoModule +{ + private MakotoModule() { } + + /// + /// Creates a new Makoto Command Module. + /// To extent an existing module, add a module with the same name (case-insensitive). + /// + /// The name of the module. + /// The commands contained within the module. + public MakotoModule(string ModuleName, IEnumerable Commands) + { + this.Name = ModuleName; + this.Commands = Commands; + } + + /// + /// The name of this module. + /// + public string Name { get; internal set; } + + /// + /// The priority in the help command. + /// + public int? Priority { get; internal set; } = null; + + /// + /// The commands contained within this module. + /// + public IEnumerable Commands { get; internal set; } + + /// + /// Whether the command has been registered. + /// All modifications will fail if this values is true. + /// + public bool Registered { get; internal set; } = false; + + /// + /// Sets the priority for this module. + /// Internally used priorities are: + /// 999 - Utility + /// 998 - Social + /// 997 - Music + /// 996 - ScoreSaber + /// 995 - Moderation + /// 994 - Configuration + /// -999 - Maintainer + /// + /// + /// + /// + public MakotoModule WithPriority(int priority) + { + if (this.Registered) + throw new InvalidOperationException("The module is already registered. It can no longer be modified."); + + this.Priority = priority; + return this; + } +} diff --git a/ProjectMakoto/Entities/MethodConfigs/ChannelPromptConfiguration.cs b/ProjectMakoto/Entities/MethodConfigs/ChannelPromptConfiguration.cs new file mode 100644 index 00000000..11e4155c --- /dev/null +++ b/ProjectMakoto/Entities/MethodConfigs/ChannelPromptConfiguration.cs @@ -0,0 +1,23 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public sealed class ChannelPromptConfiguration +{ + public ChannelConfig CreateChannelOption { get; set; } = null; + + public string? DisableOption { get; set; } = null; + + public sealed class ChannelConfig + { + public string Name { get; set; } + public ChannelType ChannelType { get; set; } + } +} diff --git a/ProjectMakoto/Entities/MethodConfigs/RolePromptConfiguration.cs b/ProjectMakoto/Entities/MethodConfigs/RolePromptConfiguration.cs new file mode 100644 index 00000000..ad1ab4c8 --- /dev/null +++ b/ProjectMakoto/Entities/MethodConfigs/RolePromptConfiguration.cs @@ -0,0 +1,19 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public sealed class RolePromptConfiguration +{ + public string? CreateRoleOption { get; set; } = null; + + public string? DisableOption { get; set; } = null; + + public bool IncludeEveryone { get; set; } = false; +} diff --git a/ProjectMakoto/Entities/PhishingProtection/PhishingUrlEntry.cs b/ProjectMakoto/Entities/PhishingProtection/PhishingUrlEntry.cs new file mode 100644 index 00000000..dc3a338e --- /dev/null +++ b/ProjectMakoto/Entities/PhishingProtection/PhishingUrlEntry.cs @@ -0,0 +1,42 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto; + +[TableName("scam_urls")] +public sealed class PhishingUrlEntry : RequiresBotReference +{ + public PhishingUrlEntry(Bot bot, string Url) : base(bot) + { + if (Url.IsNullOrWhiteSpace()) + throw new ArgumentNullException(nameof(Url)); + + if (!this.Bot.PhishingHosts.ContainsKey(Url)) + _ = this.Bot.DatabaseClient.CreateRow("scam_urls", typeof(PhishingUrlEntry), Url, this.Bot.DatabaseClient.mainDatabaseConnection); + + this.Url = Url; + } + + [ColumnName("url"), ColumnType(ColumnTypes.VarChar), MaxValue(500), Primary] + public string Url { get; init; } + + [ColumnName("origin"), ColumnType(ColumnTypes.LongText), Default("[]")] + public string[] Origin + { + get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("scam_urls", "url", this.Url, "origin", this.Bot.DatabaseClient.mainDatabaseConnection) ?? ""); + set => _ = this.Bot.DatabaseClient.SetValue("scam_urls", "url", this.Url, "origin", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("submitter"), ColumnType(ColumnTypes.BigInt), Default("0")] + public ulong Submitter + { + get => this.Bot.DatabaseClient.GetValue("scam_urls", "url", this.Url, "submitter", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("scam_urls", "url", this.Url, "submitter", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/PhishingProtection/SubmittedUrlEntry.cs b/ProjectMakoto/Entities/PhishingProtection/SubmittedUrlEntry.cs new file mode 100644 index 00000000..36a5d3ce --- /dev/null +++ b/ProjectMakoto/Entities/PhishingProtection/SubmittedUrlEntry.cs @@ -0,0 +1,45 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto; + +[TableName("active_url_submissions")] +internal sealed class SubmittedUrlEntry : RequiresBotReference +{ + public SubmittedUrlEntry(Bot bot, ulong Id) : base(bot) + { + this.Id = Id; + + _ = this.Bot.DatabaseClient.CreateRow("active_url_submissions", typeof(SubmittedUrlEntry), Id, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("messageid"), ColumnType(ColumnTypes.BigInt), Primary] + internal ulong Id { get; init; } + + [ColumnName("url"), ColumnType(ColumnTypes.LongText), Default("")] + public string Url + { + get => this.Bot.DatabaseClient.GetValue("active_url_submissions", "messageid", this.Id, "url", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("active_url_submissions", "messageid", this.Id, "url", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("submitter"), ColumnType(ColumnTypes.BigInt), Default("0")] + public ulong Submitter + { + get => this.Bot.DatabaseClient.GetValue("active_url_submissions", "messageid", this.Id, "submitter", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("active_url_submissions", "messageid", this.Id, "submitter", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("guild"), ColumnType(ColumnTypes.BigInt), Default("0")] + public ulong GuildOrigin + { + get => this.Bot.DatabaseClient.GetValue("active_url_submissions", "messageid", this.Id, "guild", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("active_url_submissions", "messageid", this.Id, "guild", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } +} diff --git a/ProjectMakoto/Entities/Plugins/BasePlugin.cs b/ProjectMakoto/Entities/Plugins/BasePlugin.cs new file mode 100644 index 00000000..c3b4b2a0 --- /dev/null +++ b/ProjectMakoto/Entities/Plugins/BasePlugin.cs @@ -0,0 +1,412 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using Octokit; +using ProjectMakoto.Plugins.EventArgs; +using ProjectMakoto.Util.Initializers; +using User = ProjectMakoto.Entities.User; + +namespace ProjectMakoto.Plugins; + +public abstract class BasePlugin +{ + public BasePlugin() + { + this._logger = new(Log.Logger, this); + } + + /// + /// 1 + /// + internal static int CurrentApiVersion = 1; + + /// + /// The file this plugin was loaded from. + /// + internal FileInfo LoadedFile { get; set; } + + /// + /// Whether this plugin has translations enabled. + /// + internal bool UsesTranslations { get; set; } = false; + + /// + /// A list of all the tables this plugin has access to. + /// + internal List AllowedTables { get; set; } = new(); + + /// + /// Whether this plugin is official or not. + /// + internal bool OfficialPlugin { get; set; } = new(); + + /// + /// Makoto Instance + /// + public Bot Bot { get; set; } + + /// + /// Allows you to log events. + /// + public PluginLoggerClient _logger { get; internal set; } + + /// + /// Your Plugin's translations, load via . + /// + public ITranslations Translations { get; internal set; } + + /// + /// Whether the client logged into discord. + /// + public bool DiscordInitialized + => this.Bot.status.DiscordInitialized; + + /// + /// Whether the guild download has been completed. + /// + public bool DiscordGuildDownloadCompleted + => this.Bot.status.DiscordGuildDownloadCompleted; + + /// + /// Whether the commands have been registered. + /// + public bool DiscordCommandsRegistered + => this.Bot.status.DiscordCommandsRegistered; + + #region Events + + /// + /// Raised before login to discord takes place. Useful for registering DisCatSharp extensions. + /// + public event EventHandler PreLogin; + internal static Task RaisePreLogin(Bot bot, DiscordShardedClient client) + => Task.Run(() => CallEvent(bot, bot.Plugins?.Select(x => x.Value.PreLogin ), new PreLoginEventArgs(client))); + + /// + /// Raised on first successful log in to discord. + /// + public event EventHandler Connected; + internal static Task RaiseConnected(Bot bot) + => Task.Run(() => CallEvent(bot, bot.Plugins?.Select(x => x.Value.Connected), System.EventArgs.Empty)); + + /// + /// Raised on when database is initialized. + /// + public event EventHandler DatabaseInitialized; + internal static Task RaiseDatabaseInitialized(Bot bot) + => Task.Run(() => CallEvent(bot, bot.Plugins?.Select(x => x.Value.DatabaseInitialized), System.EventArgs.Empty)); + + /// + /// Raised before sync tasks are ran. + /// + public event EventHandler PreSyncTasksExecution; + internal static Task RaisePreSyncTasksExecution(Bot bot, IEnumerable discordGuilds) + => Task.Run(() => CallEvent(bot, bot.Plugins?.Select(x => x.Value.PreSyncTasksExecution), new(discordGuilds))); + + /// + /// Raised after sync tasks are ran. + /// + public event EventHandler PostSyncTasksExecution; + internal static Task RaisePostSyncTasksExecution(Bot bot, IEnumerable discordGuilds) + => Task.Run(() => CallEvent(bot, bot.Plugins?.Select(x => x.Value.PostSyncTasksExecution), new(discordGuilds))); + + #endregion + + #region Plugin Identity + /// + /// The name of this plugin. + /// + public abstract string Name { get; } + + /// + /// The description of this plugin. + /// + public abstract string Description { get; } + + /// + /// The plugin author's name. + /// + public abstract string Author { get; } + + /// + /// The plugin author's discord id. + /// + public abstract ulong? AuthorId { get; } + + /// + /// Loads the author from discord upon launch, null if failed to fetch. + /// + public DiscordUser? AuthorUser { get; internal set; } + + /// + /// The current version of this plugin. + /// + public abstract SemVer Version { get; } + + /// + /// The currently supported PluginApis. Current Plugin Api is + /// Gets changed every breaking change. + /// = [ 1, 2 ]; // example + /// + public abstract int[] SupportedPluginApis { get; } + + /// + /// The url to the github repo containing this plugin. Used for automated update checking. + /// + public virtual string UpdateUrl { get; } + + /// + /// If the plugin is in a private repo, a login may be required. + /// + public virtual Credentials? UpdateUrlCredentials { get; } + #endregion + + #region Plugin Init Logic + /// + /// Called upon loading dll. + /// + /// The loading Makoto instance. + internal void Load(Bot bot) + { + this.Bot = bot; + _ = this.Initialize(); + } + + /// + /// Called when plugin was loaded into memory. + /// + /// The plugin + public abstract BasePlugin Initialize(); + + /// + /// Called after registering built-in commands. + /// + /// A list of all commands the plugin wants to register. (An empty list if none.) + public virtual async Task> RegisterCommands() + { + return new List(); + } + + /// + /// Called when initializing the database connection. Allows you to register your own database tables. + /// + /// A list of all tables the plugin wants to register (or ). + public virtual async Task?> RegisterTables() + { + return null; + } + + /// + /// Allows you to define a translation file. Return or empty string if none is present. + /// + /// A tuple of a string responsible for the path of the json and type to deserialize the json into. + public virtual (string? path, Type? type) LoadTranslations() + { + return (null, null); + } + + /// + /// Called when Makoto is shuttin down. + /// + /// + public virtual async Task Shutdown() + { + return; + } + #endregion + + #region Config Logic + /// + /// Gets your plugin's config object. + /// + /// The previously saved config or if none exists yet. + public object GetConfig() + => (this.Bot.status.LoadedConfig.PluginData.TryGetValue(this.Name, out var val) ? val : null); + + /// + /// Writes your plugin's config to makoto's config. + /// + /// + public void WriteConfig(object configObject) + { + if (!this.Bot.status.LoadedConfig.PluginData.ContainsKey(this.Name)) + this.Bot.status.LoadedConfig.PluginData.Add(this.Name, null); + + this.Bot.status.LoadedConfig.PluginData[this.Name] = configObject; + this.Bot.status.LoadedConfig.Save(); + } + + /// + /// Checks whether a config already exists. + /// + /// Whether or not the a config has already been created. + public bool CheckIfConfigExists() + => this.Bot.status.LoadedConfig.PluginData.ContainsKey(this.Name); + #endregion + + #region Helper Methods + + /// + /// Checks if a user has objected to having their data processed. + /// + /// The user's id + /// + public bool HasUserObjected(ulong id) + => this.Bot.objectedUsers?.Contains(id) ?? false; + + /// + /// The user. + public bool HasUserObjected(DiscordUser user) + => this.HasUserObjected(user?.Id ?? 0); + + /// + /// Checks if a user has been banned from using this bot. + /// + /// The user's id + /// + public bool IsUserBanned(ulong id) + => this.Bot.bannedUsers?.ContainsKey(id) ?? false; + + /// + /// The user. + public bool IsUserBanned(DiscordUser user) + => this.IsUserBanned(user?.Id ?? 0); + + /// + /// Checks if a user has objected to having their data processed. + /// + /// The user's id + /// + public bool IsGuildBanned(ulong id) + => this.Bot.bannedGuilds?.ContainsKey(id) ?? false; + + /// + /// The guild. + public bool IsGuildBanned(DiscordGuild guild) + => this.IsGuildBanned(guild?.Id ?? 0); + + #endregion + + #region Internal Logic + /// + /// Calls an event for all plugin instances. + /// + /// The type of event + /// The event sender + /// All event instances + /// The arguments + /// + private static Task CallEvent(Bot bot, IEnumerable?> eventInstances, T? args) + { + if (eventInstances is null) + return Task.CompletedTask; + + foreach (var e in eventInstances) + { + if (e is null) + continue; + + try + { + e.Invoke(bot, args); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to run event handler"); + } + } + + return Task.CompletedTask; + } + + /// + /// Enables AppCommand Translations if plugin provides any. + /// + /// + internal void EnableCommandTranslations(ApplicationCommandsTranslationContext ctx) + { + DisCatSharpExtensionsLoader.GetCommandTranslations(ctx); + + if (!this.UsesTranslations) + { + ctx.AddSingleTranslation(null); + ctx.AddGroupTranslation(null); + } + + return; + } + + internal async Task PostLoginInternalInit() + { + this._logger.LogDebug("Performing Post-Login tasks for {plugin}", this.Name); + + if (this.Bot.DiscordClient.GetFirstShard().TryGetUser(this.AuthorId ?? 0, out var fetchedAuthor, true)) + this.AuthorUser = fetchedAuthor; + } + + internal async Task CheckForUpdates() + { + if (this.UpdateUrl is null) + return; + + var regex = RegexTemplates.GitHubRepoUrl.Match(this.UpdateUrl); + + if (!regex.Success) + throw new InvalidDataException("The provided url does not match a github repo url."); + + var Owner = regex.Groups[1].Value; + var Repository = regex.Groups[2].Value; + + GitHubClient client = new(new ProductHeaderValue("ProjectMakoto", this.Bot.status.RunningVersion)); + + if (this.UpdateUrlCredentials is not null) + client.Credentials = this.UpdateUrlCredentials; + + try + { + var release = await client.Repository.Release.GetLatest(Owner, Repository); + var latestVersion = new SemVer(release.TagName); + var currentVersion = this.Version; + + if ((int)latestVersion > (int)currentVersion) + { + this._logger.LogWarn("Update found. The installed version is '{CurrentVersion}' and the latest version is '{LatestVersion}'.", currentVersion, latestVersion); + + if (this.UpdateUrlCredentials is not null) + { + this._logger.LogInfo("Private repository detected. Downloading latest version to 'UpdatedPlugins' Directory.."); + _ = Directory.CreateDirectory("UpdatedPlugins"); + HttpClient downloadClient = new(); + + var asset = release.Assets.First(x => x.Name.EndsWith(".dll")); + + using (var fileStream = new FileStream($"UpdatedPlugins/{asset.Name}", System.IO.FileMode.Create, FileAccess.ReadWrite)) + { + var downloadStream = await downloadClient.GetStreamAsync(asset.BrowserDownloadUrl); + await downloadStream.CopyToAsync(fileStream); + } + } + else + { + this._logger.LogWarn("You can download the update at '{LatestReleaseUrl}'", release.HtmlUrl); + } + } + } + catch (Octokit.ApiException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + this._logger.LogError("The repository could not be found at '{RepoUrl}', is the repo private, the credentials outdated or no release published?", this.UpdateUrl); + } + catch (Exception ex) + { + this._logger.LogError(ex, "Could not check for a new version"); + } + } + + #endregion +} diff --git a/ProjectMakoto/Entities/Plugins/Database/PluginDatabaseTable.cs b/ProjectMakoto/Entities/Plugins/Database/PluginDatabaseTable.cs new file mode 100644 index 00000000..6053a68b --- /dev/null +++ b/ProjectMakoto/Entities/Plugins/Database/PluginDatabaseTable.cs @@ -0,0 +1,113 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Plugins; + +[System.Reflection.Obfuscation] +public class PluginDatabaseTable : RequiresBotReference +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "Prevents creation without proper data")] + private PluginDatabaseTable(BasePlugin plugin) : base(plugin.Bot) { } + + public PluginDatabaseTable(BasePlugin plugin, object identifierValue) : base(plugin.Bot) + { + this.Plugin = plugin; + + this.Validate(); + + if (identifierValue.GetType() == typeof(ulong)) + if (this.Plugin.HasUserObjected((ulong)identifierValue)) + throw new InvalidOperationException($"User {identifierValue} has objected to having their data processed."); + + this.CreateRow(identifierValue).GetAwaiter().GetResult(); + } + + protected BasePlugin Plugin { get; private set; } + + /// + /// Creates a new row in the type's table. + /// + /// Your plugin instance. + /// The value that identifies this key, typically a user, channel or guild id. + /// + /// + public Task CreateRow(object identifierValue) + { + var type = this.GetType(); + var tableName = (this.Bot.DatabaseClient.MakePluginTablePrefix(this.Plugin) + type.GetCustomAttribute().Name).ToLower(); + return this.Plugin.Bot.DatabaseClient.CreateRow(tableName, type, identifierValue, this.Plugin.Bot.DatabaseClient.pluginDatabaseConnection); + } + + /// + /// Gets a value from this tables columns. + /// + /// What this value is supposed to be. + /// Your plugin instance. + /// The value that identifies this key, typically a user, channel or guild id. + /// The name of the column to retrieve. + /// + public T GetValue(object identifierValue, string columnName) + { + var type = this.GetType(); + var tableName = (this.Bot.DatabaseClient.MakePluginTablePrefix(this.Plugin) + type.GetCustomAttribute().Name).ToLower(); + var uniqueValueColumn = this.Plugin.Bot.DatabaseClient.GetPrimaryKey(type); + + return this.Plugin.Bot.DatabaseClient.GetValue(tableName, uniqueValueColumn.ColumnName, identifierValue, columnName, this.Plugin.Bot.DatabaseClient.pluginDatabaseConnection); + } + + /// + /// Sets a value in this table's columns. + /// + /// Your plugin instance. + /// The value that identifies this key, typically a user, channel or guild id. + /// The name of the column to modify. + /// The new column data. + /// + public Task SetValue(object identifierValue, string columnName, object newColumnData) + { + var type = this.GetType(); + var tableName = (this.Bot.DatabaseClient.MakePluginTablePrefix(this.Plugin) + type.GetCustomAttribute().Name).ToLower(); + var uniqueValueColumn = this.Plugin.Bot.DatabaseClient.GetPrimaryKey(type); + + return this.Plugin.Bot.DatabaseClient.SetValue(tableName, uniqueValueColumn.ColumnName, identifierValue, columnName, newColumnData, this.Bot.DatabaseClient.pluginDatabaseConnection); + } + + private void Validate() + { + var type = this.GetType(); + + if (type.BaseType != typeof(PluginDatabaseTable)) + throw new ArgumentException("Specified type has to inherit PluginDatabaseTable"); + + if (!this.TryGetCustomAttribute(type, out var nameAttr)) + throw new ArgumentException("Specified type has to have TableNameAttribute"); + + var validProperties = this.Bot.DatabaseClient.GetValidProperties(type); + + if (!validProperties.IsNotNullAndNotEmpty()) + throw new ArgumentException("Specified type has to have properties with ColumnNameAttribute"); + + if (!this.Plugin.AllowedTables.Contains(this.Bot.DatabaseClient.MakePluginTablePrefix(this.Plugin) + nameAttr.Name)) + throw new ArgumentException("Your plugin is not allowed to use this table name. This may be because the database name is already in use by Makoto or another plugin already registered this table."); + } + + private bool TryGetCustomAttribute(Type type, out T attribute) where T : Attribute + { + var attr = type.GetCustomAttribute(); + + if (attr != null) + { + attribute = attr; + return true; + } + + attribute = default; + return false; + } +} diff --git a/ProjectMakoto/Entities/Plugins/EventArgs/PreLoginEventArgs.cs b/ProjectMakoto/Entities/Plugins/EventArgs/PreLoginEventArgs.cs new file mode 100644 index 00000000..2dd0189c --- /dev/null +++ b/ProjectMakoto/Entities/Plugins/EventArgs/PreLoginEventArgs.cs @@ -0,0 +1,14 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Plugins.EventArgs; +public class PreLoginEventArgs(DiscordShardedClient clients) : System.EventArgs +{ + public DiscordShardedClient DiscordClient { get; set; } = clients; +} diff --git a/ProjectMakoto/Entities/Plugins/EventArgs/SyncTaskEventArgs.cs b/ProjectMakoto/Entities/Plugins/EventArgs/SyncTaskEventArgs.cs new file mode 100644 index 00000000..e7078739 --- /dev/null +++ b/ProjectMakoto/Entities/Plugins/EventArgs/SyncTaskEventArgs.cs @@ -0,0 +1,14 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Plugins.EventArgs; +public class SyncTaskEventArgs(IEnumerable discordGuilds) : System.EventArgs +{ + public IReadOnlyList Guilds { get; set; } = discordGuilds.ToList().AsReadOnly(); +} diff --git a/ProjectMakoto/Entities/Plugins/ManifestBuilder.cs b/ProjectMakoto/Entities/Plugins/ManifestBuilder.cs new file mode 100644 index 00000000..842c0e60 --- /dev/null +++ b/ProjectMakoto/Entities/Plugins/ManifestBuilder.cs @@ -0,0 +1,95 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Plugins; +internal static class ManifestBuilder +{ + public static async Task BuildPluginManifests(Bot bot, string[] args) + { + Log.Warning("Makoto has been started as a Manifest Builder.", args); + + var pluginDirectoryIndex = args.IndexOf("--build-manifests") + 1; + if (pluginDirectoryIndex > args.Length) + { + Log.Fatal("No plugin directory provided."); + bot.ExitApplication(true).Wait(); + return; + } + + var pluginDirectoryPath = args[pluginDirectoryIndex]; + if (!Directory.Exists(pluginDirectoryPath)) + { + Log.Fatal("Plugin directory was not found."); + bot.ExitApplication(true).Wait(); + return; + } + + string? manifestOutputDirectory = null; + + if (args.Contains("--output-manifests")) + { + var outputDirectoryIndex = args.IndexOf("--output-manifests") + 1; + if (outputDirectoryIndex > args.Length) + { + Log.Fatal("No output directory provided."); + bot.ExitApplication(true).Wait(); + return; + } + + manifestOutputDirectory = args[outputDirectoryIndex]; + } + + Log.Information("Building Makoto Plugin manifests in '{Directory}'..", pluginDirectoryPath); + + UniversalExtensions.LoadAllReferencedAssemblies(AppDomain.CurrentDomain); + await Util.Initializers.PluginLoader.LoadPlugins(bot, false, pluginDirectoryPath); + + foreach (var plugin in bot.Plugins) + { + Log.Information("Generating Plugin Manifest for '{assembly}'..", plugin.Key); + + var manifest = new PluginManifest() + { + Name = plugin.Value.Name, + Description = plugin.Value.Description, + Author = plugin.Value.Author, + AuthorId = plugin.Value.AuthorId, + Version = plugin.Value.Version, + }; + + if (manifestOutputDirectory is not null) + { + var pluginHash = HashingExtensions.ComputeSHA256Hash(plugin.Value.LoadedFile); + + File.WriteAllText($"{manifestOutputDirectory}/{pluginHash}.json", JsonConvert.SerializeObject(manifest, Formatting.Indented)); + continue; + } + + using var zipStream = plugin.Value.LoadedFile.Open(FileMode.Open, FileAccess.ReadWrite); + using var pluginZip = new ZipArchive(zipStream, ZipArchiveMode.Update, false); + + if (pluginZip.Entries.Any(x => x.Name == "manifest.json")) + { + Log.Warning("Plugin '{assembly}' already contains a manifest! Skipping..", plugin.Key); + continue; + } + + var manifestBytes = UTF8Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(manifest, Formatting.Indented)); + + var newEntry = pluginZip.CreateEntry("manifest.json"); + var newEntryStream = newEntry.Open(); + newEntryStream.Write(manifestBytes, 0, manifestBytes.Length); + newEntryStream.Flush(); + newEntry.LastWriteTime = DateTime.Now; + + pluginZip.Dispose(); + zipStream.Close(); + } + } +} diff --git a/ProjectMakoto/Entities/Plugins/PluginLoggerClient.cs b/ProjectMakoto/Entities/Plugins/PluginLoggerClient.cs new file mode 100644 index 00000000..99d3cd35 --- /dev/null +++ b/ProjectMakoto/Entities/Plugins/PluginLoggerClient.cs @@ -0,0 +1,99 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Plugins; +public sealed class PluginLoggerClient +{ + internal PluginLoggerClient(ILogger client, BasePlugin parent) + { + this._client = client; + this.Parent = parent; + } + + private ILogger _client; + + private BasePlugin Parent; + + /// + public void LogTrace(string message, Exception? exception = null, params object[] args) + => this._client.Verbose(exception, message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + /// + public void LogTrace(Exception? exception, string message, params object[] args) + => this._client.Verbose(exception, message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + /// + public void LogTrace(string message, params object[] args) + => this._client.Verbose(message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + + /// + public void LogDebug(string message, Exception? exception = null, params object[] args) + => this._client.Debug(exception, message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + /// + public void LogDebug(Exception? exception, string message, params object[] args) + => this._client.Debug(exception, message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + /// + public void LogDebug(string message, params object[] args) + => this._client.Debug(message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + + /// + public void LogInfo(string message, Exception? exception = null, params object[] args) + => this._client.Information(exception, message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + /// + public void LogInfo(Exception? exception, string message, params object[] args) + => this._client.Information(exception, message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + /// + public void LogInfo(string message, params object[] args) + => this._client.Information(message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + + /// + public void LogWarn(string message, Exception? exception = null, params object[] args) + => this._client.Warning(exception, message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + /// + public void LogWarn(Exception? exception, string message, params object[] args) + => this._client.Warning(exception, message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + /// + public void LogWarn(string message, params object[] args) + => this._client.Warning(message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + + /// + public void LogError(string message, Exception? exception = null, params object[] args) + => this._client.Error(exception, message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + /// + public void LogError(Exception? exception, string message, params object[] args) + => this._client.Error(exception, message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + /// + public void LogError(string message, params object[] args) + => this._client.Error(message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + + /// + public void LogFatal(string message, Exception? exception = null, params object[] args) + => this._client.Fatal(exception, message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + /// + public void LogFatal(Exception? exception, string message, params object[] args) + => this._client.Fatal(exception, message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); + + /// + public void LogFatal(string message, params object[] args) + => this._client.Fatal(message.Insert(0, "[{Plugin}] "), args.Prepend(this.Parent.Name).ToArray()); +} diff --git a/ProjectMakoto/Entities/Plugins/PluginManifest.cs b/ProjectMakoto/Entities/Plugins/PluginManifest.cs new file mode 100644 index 00000000..1862eba5 --- /dev/null +++ b/ProjectMakoto/Entities/Plugins/PluginManifest.cs @@ -0,0 +1,18 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Plugins; +internal class PluginManifest +{ + public string Name { get; set; } + public string Description { get; set; } + public SemVer Version { get; set; } + public string Author { get; set; } + public ulong? AuthorId { get; set; } +} diff --git a/ProjectMakoto/Entities/Plugins/SemVer.cs b/ProjectMakoto/Entities/Plugins/SemVer.cs new file mode 100644 index 00000000..66573038 --- /dev/null +++ b/ProjectMakoto/Entities/Plugins/SemVer.cs @@ -0,0 +1,85 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Plugins; + +public sealed class SemVer : IComparable +{ + [JsonConstructor] + internal SemVer() { } + + public SemVer(int major, int minor, int patch) + { + this.Major = major; + this.Minor = minor; + this.Patch = patch; + } + + public SemVer(string v) + { + var regex = RegexTemplates.SemVer.Match(v); + + if (!regex.Success) + throw new ArgumentException("Input string in incorrect format."); + + this.Major = regex.Groups[1].Value.ToInt32(); + this.Minor = regex.Groups[2].Value.ToInt32(); + this.Patch = regex.Groups[3].Value.ToInt32(); + } + + public int Major { get; init; } + public int Minor { get; init; } + public int Patch { get; init; } + + public override string ToString() + => $"{this.Major}.{this.Minor}.{this.Patch}"; + + public int CompareTo(SemVer? other) + { + if (other is null) + return 1; + + return ((int)this).CompareTo((int)other); + } + + public static implicit operator string(SemVer v) + => $"{v.Major}.{v.Minor}.{v.Patch}"; + + public static implicit operator int(SemVer v) + => (v.Major * 1000) + (v.Minor * 100) + v.Patch; + + public static implicit operator SemVer(string v) + => new(v); + + public override bool Equals(object obj) + { + if (ReferenceEquals(this, obj)) + return true; + + if (obj is SemVer other) + return this == other; + + return false; + } + + public override int GetHashCode() + { + return (int)this; + } + + public static bool operator ==(SemVer v1, SemVer v2) + { + return v1.CompareTo(v2) == 0; + } + + public static bool operator !=(SemVer v1, SemVer v2) + { + return !(v1 == v2); + } +} diff --git a/ProjectMakoto/Entities/Resources/AuditLogIcons.cs b/ProjectMakoto/Entities/Resources/AuditLogIcons.cs new file mode 100644 index 00000000..f39b3f3c --- /dev/null +++ b/ProjectMakoto/Entities/Resources/AuditLogIcons.cs @@ -0,0 +1,39 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public static class AuditLogIcons +{ + public static readonly string QuestionMark = "https://media.discordapp.net/attachments/1005430437952356423/1006675199577567262/QuestionMark.png"; + + public static readonly string GuildUpdated = "https://media.discordapp.net/attachments/1005430437952356423/1006675562384850954/GuildUpdated.png"; + + public static readonly string MessageDeleted = "https://media.discordapp.net/attachments/1005430437952356423/1006675627799220244/MessageRemoved.png"; + public static readonly string MessageEdited = "https://media.discordapp.net/attachments/1005430437952356423/1006675628122198016/MessageUpdated.png"; + + public static readonly string InviteAdded = "https://media.discordapp.net/attachments/1005430437952356423/1006675471569801287/InviteAdded.png"; + public static readonly string InviteRemoved = "https://media.discordapp.net/attachments/1005430437952356423/1006675471859196054/InviteRemoved.png"; + + public static readonly string ChannelAdded = "https://media.discordapp.net/attachments/1005430437952356423/1006675281718804521/ChannelAdded.png"; + public static readonly string ChannelRemoved = "https://media.discordapp.net/attachments/1005430437952356423/1006675282905813092/ChannelRemoved.png"; + public static readonly string ChannelModified = "https://media.discordapp.net/attachments/1005430437952356423/1006675283266514974/ChannelUpdated.png"; + + public static readonly string VoiceStateUserJoined = "https://media.discordapp.net/attachments/1005430437952356423/1006676083871076512/VoiceStateUserJoined.png"; + public static readonly string VoiceStateUserLeft = "https://media.discordapp.net/attachments/1005430437952356423/1006676084235968724/VoiceStateUserLeft.png"; + public static readonly string VoiceStateUserUpdated = "https://media.discordapp.net/attachments/1005430437952356423/1006676084659601508/VoiceStateUserUpdated.png"; + + public static readonly string UserAdded = "https://media.discordapp.net/attachments/1005430437952356423/1006675756056850492/UserAdded.png"; + public static readonly string UserBanned = "https://media.discordapp.net/attachments/1005430437952356423/1006675756534997072/UserBanned.png"; + public static readonly string UserBanRemoved = "https://media.discordapp.net/attachments/1005430437952356423/1006675754588839936/BanRemoved.png"; + public static readonly string UserKicked = "https://media.discordapp.net/attachments/1005430437952356423/1006675757222858873/UserKicked.png"; + public static readonly string UserLeft = "https://media.discordapp.net/attachments/1005430437952356423/1006675757726171268/UserRemoved.png"; + public static readonly string UserUpdated = "https://media.discordapp.net/attachments/1005430437952356423/1006675758053331044/UserUpdated.png"; + public static readonly string UserWarned = "https://media.discordapp.net/attachments/1005430437952356423/1006675758405664798/UserWarned.png"; +} diff --git a/ProjectMakoto/Entities/Resources/EmojiTemplates.cs b/ProjectMakoto/Entities/Resources/EmojiTemplates.cs new file mode 100644 index 00000000..d52131f3 --- /dev/null +++ b/ProjectMakoto/Entities/Resources/EmojiTemplates.cs @@ -0,0 +1,49 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public static class EmojiTemplates +{ + public static DiscordEmoji GetCheckboxTickedBlue(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.CheckboxTicked); + public static DiscordEmoji GetCheckboxUntickedBlue(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.CheckboxUnticked); + + public static DiscordEmoji GetPillOff(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.PillOff); + public static DiscordEmoji GetPillOn(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.PillOn); + + public static DiscordEmoji GetError(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.Error); + + public static DiscordEmoji GetSlashCommand(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.SlashCommand); + public static DiscordEmoji GetMessageCommand(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.MessageCommand); + public static DiscordEmoji GetUserCommand(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.UserCommand); + + public static DiscordEmoji GetPrefixCommandDisabled(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.PrefixCommandDisabled); + public static DiscordEmoji GetPrefixCommandEnabled(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.PrefixCommandEnabled); + + public static DiscordEmoji GetQuestionMark(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.QuestionMark); + + public static DiscordEmoji GetGuild(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.Guild); + public static DiscordEmoji GetChannel(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.Channel); + public static DiscordEmoji GetUser(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.User); + public static DiscordEmoji GetVoiceState(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.VoiceState); + public static DiscordEmoji GetMessage(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.Message); + public static DiscordEmoji GetInvite(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.Invite); + public static DiscordEmoji GetInVisible(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.In); + + public static DiscordEmoji GetYouTube(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.YouTube); + public static DiscordEmoji GetSoundcloud(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.SoundCloud); + public static DiscordEmoji GetSpotify(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.Spotify); + public static DiscordEmoji GetAbuseIpDb(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.AbuseIPDB); + public static DiscordEmoji GetLoading(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.Loading); + + public static DiscordEmoji GetPaused(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.Paused); + public static DiscordEmoji GetDisabledPlay(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.DisabledPlay); + public static DiscordEmoji GetDisabledRepeat(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.DisabledRepeat); + public static DiscordEmoji GetDisabledShuffle(Bot bot) => DiscordEmoji.FromGuildEmote(bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild), bot.status.LoadedConfig.Emojis.DisabledShuffle); +} diff --git a/ProjectMakoto/Entities/Resources/MessageComponents.cs b/ProjectMakoto/Entities/Resources/MessageComponents.cs new file mode 100644 index 00000000..bf206a70 --- /dev/null +++ b/ProjectMakoto/Entities/Resources/MessageComponents.cs @@ -0,0 +1,24 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; +public static class MessageComponents +{ + public static DiscordButtonComponent GetCancelButton(User user, Bot _bot) + => new(ButtonStyle.Secondary, CancelButtonId, _bot.LoadedTranslations.Common.Cancel.Get(user), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("❌"))); + + public static DiscordButtonComponent GetBackButton(User user, Bot _bot) + => new(ButtonStyle.Secondary, BackButtonId, _bot.LoadedTranslations.Common.Back.Get(user), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("◀"))); + + public static string BackButtonId + => "back"; + + public static string CancelButtonId + => "cancel"; +} diff --git a/ProjectMakoto/Entities/Resources/RegexTemplates.cs b/ProjectMakoto/Entities/Resources/RegexTemplates.cs new file mode 100644 index 00000000..e6ff14f0 --- /dev/null +++ b/ProjectMakoto/Entities/Resources/RegexTemplates.cs @@ -0,0 +1,35 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public static class RegexTemplates +{ + public static readonly Regex UserMention = new(@"((<@(\d+)>)|(<@!(\d+)>))", RegexOptions.Compiled); + public static readonly Regex ChannelMention = new(@"(<#\d+>)", RegexOptions.Compiled); + + public static readonly Regex BandcampUrl = new(@"(https?:\/\/)?([\d|\w]+)\.bandcamp\.com\/?.*", RegexOptions.IgnoreCase | RegexOptions.Compiled); + public static readonly Regex SoundcloudUrl = new(@"^https?:\/\/(soundcloud\.com|snd\.sc)\/(.*)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + public static readonly Regex SpotifyUrl = new(@"https?://(open\.spotify\.com|spotify\.link)(/track)?/([^ ?&])+(\?si\=[^ ?&]*)?", RegexOptions.IgnoreCase | RegexOptions.Compiled); + public static readonly Regex YouTubeUrl = new(@"^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + public static readonly Regex DiscordChannelUrl = new(@"((https|http):\/\/(ptb\.|canary\.)?discord.com\/channels\/(\d+)\/(\d+)\/(\d+))", RegexOptions.IgnoreCase | RegexOptions.Compiled); + public static readonly Regex Url = new(@"https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public static readonly Regex Token = new(@"(mfa\.[a-z0-9_-]{20,})|((?[a-z0-9_-]{23,28})\.(?[a-z0-9_-]{6,7})\.(?[a-z0-9_-]{27,}))", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public static readonly Regex GitHubUrl = new(@"https:\/\/github\.com\/([^ \/]*)\/([^ \/]*)(\/blob\/([^ \/]*))?\/([^ #]*)#(L\d*)(-(L\d*))?", RegexOptions.IgnoreCase); + public static readonly Regex Ip = new(@"^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$"); + + public static readonly Regex Code = new(@"(?:```)(?:cs)?((.|\n)*)(?:```)", RegexOptions.Multiline | RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public static readonly Regex AllowedNickname = new(@"[^a-zA-Z0-9 _\-!.,:;#+*~´`?^°<>|""§$%&\/\\()={\[\]}²³€@_]", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public static readonly Regex GitHubRepoUrl = new(@"https:\/\/github\.com\/([^\/]*)\/([^\/]*)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + public static readonly Regex SemVer = new(@"^(\d*)\.(\d*)\.(\d*)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); +} diff --git a/ProjectMakoto/Entities/Resources/Resources.cs b/ProjectMakoto/Entities/Resources/Resources.cs new file mode 100644 index 00000000..c6b41641 --- /dev/null +++ b/ProjectMakoto/Entities/Resources/Resources.cs @@ -0,0 +1,36 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; +public static class Resources +{ + public static readonly IReadOnlyList ProtectedPermissions = new List() + { + Permissions.Administrator, + + Permissions.MuteMembers, + Permissions.DeafenMembers, + Permissions.ModerateMembers, + Permissions.KickMembers, + Permissions.BanMembers, + + Permissions.ManageGuild, + Permissions.ManageChannels, + Permissions.ManageRoles, + Permissions.ManageMessages, + Permissions.ManageEvents, + Permissions.ManageThreads, + Permissions.ManageWebhooks, + Permissions.ManageNicknames, + + Permissions.ViewAuditLog, + }; + + public static readonly string AbuseIpDbIcon = "https://cdn.discordapp.com/attachments/1005430437952356423/1021782030511517757/ezgif.com-gif-maker.png"; +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/Resources/StatusIndicatorIcons.cs b/ProjectMakoto/Entities/Resources/StatusIndicatorIcons.cs new file mode 100644 index 00000000..b0cb742a --- /dev/null +++ b/ProjectMakoto/Entities/Resources/StatusIndicatorIcons.cs @@ -0,0 +1,18 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public static class StatusIndicatorIcons +{ + public static readonly string Loading = "https://media.discordapp.net/attachments/1005430437952356423/1006676441343201370/Loading.gif"; + public static readonly string Success = "https://media.discordapp.net/attachments/1005430437952356423/1006676420770136114/CheckMark_Icon.png"; + public static readonly string Error = "https://media.discordapp.net/attachments/1005430437952356423/1006676421546098698/Error_Icon.png"; + public static readonly string Warning = "https://media.discordapp.net/attachments/1005430437952356423/1006676420388470911/Warning.png"; +} diff --git a/ProjectMakoto/Entities/ScheduledTaskIdentifier.cs b/ProjectMakoto/Entities/ScheduledTaskIdentifier.cs new file mode 100644 index 00000000..61377f65 --- /dev/null +++ b/ProjectMakoto/Entities/ScheduledTaskIdentifier.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; +public sealed class ScheduledTaskIdentifier(ulong Snowflake, string Id, string Type) +{ + public ulong Snowflake { get; set; } = Snowflake; + public string Id { get; set; } = Id; + public string Type { get; set; } = Type; +} diff --git a/ProjectMakoto/Entities/ScoreSaber/Leaderboard.cs b/ProjectMakoto/Entities/ScoreSaber/Leaderboard.cs new file mode 100644 index 00000000..06e7ddf3 --- /dev/null +++ b/ProjectMakoto/Entities/ScoreSaber/Leaderboard.cs @@ -0,0 +1,127 @@ +namespace ProjectMakoto.Entities.ScoreSaber; + +public class Leaderboard +{ + public int requestId { get; set; } + public string requestDescription { get; set; } + public Leaderboardinfo leaderboardInfo { get; set; } + public string created_at { get; set; } + public Rankvotes rankVotes { get; set; } + public Qatvotes qatVotes { get; set; } + public Rankcomment[] rankComments { get; set; } + public Qatcomment[] qatComments { get; set; } + public int requestType { get; set; } + public int approved { get; set; } + public Difficulty2[] difficulties { get; set; } + + public class Leaderboardinfo + { + public int id { get; set; } + public string songHash { get; set; } + public string songName { get; set; } + public string songSubName { get; set; } + public string songAuthorName { get; set; } + public string levelAuthorName { get; set; } + public Difficulty difficulty { get; set; } + public int maxScore { get; set; } + public DateTime createdDate { get; set; } + public string rankedDate { get; set; } + public string qualifiedDate { get; set; } + public string lovedDate { get; set; } + public bool ranked { get; set; } + public bool qualified { get; set; } + public bool loved { get; set; } + public float maxPP { get; set; } + public float stars { get; set; } + public bool positiveModifiers { get; set; } + public int plays { get; set; } + public int dailyPlays { get; set; } + public string coverImage { get; set; } + public Playerscore playerScore { get; set; } + public Difficulty1[] difficulties { get; set; } + } + + public class Difficulty + { + public int leaderboardId { get; set; } + public int difficulty { get; set; } + public string gameMode { get; set; } + public string difficultyRaw { get; set; } + } + + public class Playerscore + { + public int id { get; set; } + public Leaderboardplayerinfo leaderboardPlayerInfo { get; set; } + public int rank { get; set; } + public int baseScore { get; set; } + public int modifiedScore { get; set; } + public float pp { get; set; } + public int weight { get; set; } + public string modifiers { get; set; } + public int multiplier { get; set; } + public int badCuts { get; set; } + public int missedNotes { get; set; } + public int maxCombo { get; set; } + public bool fullCombo { get; set; } + public int hmd { get; set; } + public bool hasReplay { get; set; } + public DateTime timeSet { get; set; } + } + + public class Leaderboardplayerinfo + { + public string id { get; set; } + public string name { get; set; } + public string profilePicture { get; set; } + public string country { get; set; } + public int permissions { get; set; } + public string role { get; set; } + } + + public class Difficulty1 + { + public int leaderboardId { get; set; } + public int difficulty { get; set; } + public string gameMode { get; set; } + public string difficultyRaw { get; set; } + } + + public class Rankvotes + { + public int upvotes { get; set; } + public int downvotes { get; set; } + public bool myVote { get; set; } + public int neutral { get; set; } + } + + public class Qatvotes + { + public int upvotes { get; set; } + public int downvotes { get; set; } + public bool myVote { get; set; } + public int neutral { get; set; } + } + + public class Rankcomment + { + public string username { get; set; } + public string userId { get; set; } + public string comment { get; set; } + public string timeStamp { get; set; } + } + + public class Qatcomment + { + public string username { get; set; } + public string userId { get; set; } + public string comment { get; set; } + public string timeStamp { get; set; } + } + + public class Difficulty2 + { + public int requestId { get; set; } + public int difficulty { get; set; } + } +} diff --git a/ProjectMakoto/Entities/ScoreSaber/LeaderboardScores.cs b/ProjectMakoto/Entities/ScoreSaber/LeaderboardScores.cs new file mode 100644 index 00000000..336fa2d2 --- /dev/null +++ b/ProjectMakoto/Entities/ScoreSaber/LeaderboardScores.cs @@ -0,0 +1,176 @@ +namespace ProjectMakoto.Entities.ScoreSaber; + +public class LeaderboardScores +{ + /// + /// The scores. + /// + [JsonProperty("scores")] + public ScoreInfo[] Scores { get; internal set; } + + /// + /// The metadata this request contains. + /// + [JsonProperty("metadata")] + public MetadataInfo Metadata { get; internal set; } + + public class ScoreInfo + { + /// + /// The id of the score set. + /// + [JsonProperty("id")] + public int Id { get; internal set; } + + /// + /// The player that set the score. + /// + [JsonProperty("leaderboardPlayerInfo")] + public PlayerInfo Player { get; set; } + + /// + /// The rank at which this score resides. + /// + [JsonProperty("rank")] + public int Rank { get; internal set; } + + /// + /// The score without any modifications. + /// + [JsonProperty("baseScore")] + public int Score { get; internal set; } + + /// + /// The score after modifications have been applied. + /// + [JsonProperty("modifiedScore")] + public int ModifiedScore { get; internal set; } + + /// + /// The pp achieved with this score. + /// + [JsonProperty("pp")] + public float PP { get; internal set; } + + /// + /// How much weight this score has in player's total pp. + /// + [JsonProperty("weight")] + public float Weight { get; internal set; } + + /// + /// Which modifiers were used. + /// + [JsonProperty("modifiers")] + public string Modifiers { get; internal set; } + + /// + /// The multiplier used to calculate the + /// + [JsonProperty("multiplier")] + public float Multiplier { get; internal set; } + + /// + /// The amount of bad cuts. + /// + [JsonProperty("badCuts")] + public int BadCuts { get; internal set; } + + /// + /// The amount of missed notes. + /// + [JsonProperty("missedNotes")] + public int MissedNotes { get; internal set; } + + /// + /// The biggest combo achieved in this score. + /// + [JsonProperty("maxCombo")] + public int MaxCombo { get; internal set; } + + /// + /// Whether this score has no mistakes. + /// + [JsonProperty("fullCombo")] + public bool FullCombo { get; internal set; } + + /// + /// The index id of the head mounted display used. + /// + [JsonProperty("hmd")] + public int HMD { get; internal set; } + + /// + /// The time this score was set. + /// + [JsonProperty("timeSet")] + public DateTime Timestamp { get; internal set; } + + + /// + /// Whether this score has a replay. + /// + [JsonProperty("hasReplay")] + public bool HasReplay { get; internal set; } + + public class PlayerInfo + { + /// + /// The name of this player. + /// + [JsonProperty("id")] + public int Id { get; internal set; } + + /// + /// The name of this player. + /// + [JsonProperty("name")] + public string Name { get; internal set; } + + /// + /// The avatar this player uses. + /// + [JsonProperty("profilePicture")] + public string AvatarUrl { get; internal set; } + + /// + /// The country this player is from. + /// + [JsonProperty("country")] + public string Country { get; internal set; } + + /// + /// The permissions this player has. + /// + [JsonProperty("permissions")] + public int Permissions { get; internal set; } + + /// + /// The role this player has. + /// + [JsonProperty("role")] + public string Role { get; internal set; } + } + } + + public class MetadataInfo + { + /// + /// The total amount of pages. + /// + [JsonProperty("total")] + public int TotalPages { get; internal set; } + + /// + /// The page that's been returned. + /// + [JsonProperty("page")] + public int Page { get; internal set; } + + /// + /// How many items this page contains. + /// + [JsonProperty("itemsPerPage")] + public int ItemCount { get; internal set; } + } +} diff --git a/ProjectMakoto/Entities/ScoreSaber/PlayerInfo.cs b/ProjectMakoto/Entities/ScoreSaber/PlayerInfo.cs new file mode 100644 index 00000000..3748e3f1 --- /dev/null +++ b/ProjectMakoto/Entities/ScoreSaber/PlayerInfo.cs @@ -0,0 +1,35 @@ +namespace ProjectMakoto.Entities.ScoreSaber; + +public class PlayerInfo +{ + public string id { get; set; } + public string name { get; set; } + public string profilePicture { get; set; } + public string country { get; set; } + public decimal pp { get; set; } + public int rank { get; set; } + public int countryRank { get; set; } + public string role { get; set; } + public Badge[] badges { get; set; } + public string histories { get; set; } + public Scorestats scoreStats { get; set; } + public int permissions { get; set; } + public bool banned { get; set; } + public bool inactive { get; set; } + + public class Scorestats + { + public long totalScore { get; set; } + public long totalRankedScore { get; set; } + public float averageRankedAccuracy { get; set; } + public int totalPlayCount { get; set; } + public int rankedPlayCount { get; set; } + public int replaysWatched { get; set; } + } + + public class Badge + { + public string description { get; set; } + public string image { get; set; } + } +} diff --git a/ProjectMakoto/Entities/ScoreSaber/PlayerScores.cs b/ProjectMakoto/Entities/ScoreSaber/PlayerScores.cs new file mode 100644 index 00000000..a90218ed --- /dev/null +++ b/ProjectMakoto/Entities/ScoreSaber/PlayerScores.cs @@ -0,0 +1,74 @@ +namespace ProjectMakoto.Entities.ScoreSaber; + +public class PlayerScores +{ + public Playerscore[] playerScores { get; set; } + public Metadata metadata { get; set; } + + public class Metadata + { + public int total { get; set; } + public int page { get; set; } + public int itemsPerPage { get; set; } + } + + public class Playerscore + { + public Score score { get; set; } + public Leaderboard leaderboard { get; set; } + } + + public class Score + { + public int id { get; set; } + public int rank { get; set; } + public int baseScore { get; set; } + public int modifiedScore { get; set; } + public float pp { get; set; } + public float weight { get; set; } + public string modifiers { get; set; } + public float multiplier { get; set; } + public int badCuts { get; set; } + public int missedNotes { get; set; } + public int maxCombo { get; set; } + public bool fullCombo { get; set; } + public int hmd { get; set; } + public DateTime timeSet { get; set; } + public bool hasReplay { get; set; } + } + + public class Leaderboard + { + public int id { get; set; } + public string songHash { get; set; } + public string songName { get; set; } + public string songSubName { get; set; } + public string songAuthorName { get; set; } + public string levelAuthorName { get; set; } + public Difficulty difficulty { get; set; } + public int maxScore { get; set; } + public DateTime? createdDate { get; set; } + public DateTime? rankedDate { get; set; } + public DateTime? qualifiedDate { get; set; } + public DateTime? lovedDate { get; set; } + public bool ranked { get; set; } + public bool qualified { get; set; } + public bool loved { get; set; } + public int maxPP { get; set; } + public float stars { get; set; } + public int plays { get; set; } + public int dailyPlays { get; set; } + public bool positiveModifiers { get; set; } + public object playerScore { get; set; } + public string coverImage { get; set; } + public object difficulties { get; set; } + } + + public class Difficulty + { + public int leaderboardId { get; set; } + public int difficulty { get; set; } + public string gameMode { get; set; } + public string difficultyRaw { get; set; } + } +} diff --git a/ProjectMakoto/Entities/ScoreSaber/PlayerSearch.cs b/ProjectMakoto/Entities/ScoreSaber/PlayerSearch.cs new file mode 100644 index 00000000..3886de5b --- /dev/null +++ b/ProjectMakoto/Entities/ScoreSaber/PlayerSearch.cs @@ -0,0 +1,48 @@ +namespace ProjectMakoto.Entities.ScoreSaber; + +public class PlayerSearch +{ + public Player[] players { get; set; } + public Metadata metadata { get; set; } + + public class Metadata + { + public int total { get; set; } + public int page { get; set; } + public int itemsPerPage { get; set; } + } + + public class Player + { + public string id { get; set; } + public string name { get; set; } + public string profilePicture { get; set; } + public string country { get; set; } + public decimal pp { get; set; } + public int rank { get; set; } + public int countryRank { get; set; } + public string role { get; set; } + public Badge[] badges { get; set; } + public string histories { get; set; } + public Scorestats scoreStats { get; set; } + public int permissions { get; set; } + public bool banned { get; set; } + public bool inactive { get; set; } + } + + public class Scorestats + { + public long totalScore { get; set; } + public long totalRankedScore { get; set; } + public float averageRankedAccuracy { get; set; } + public int totalPlayCount { get; set; } + public int rankedPlayCount { get; set; } + public int replaysWatched { get; set; } + } + + public class Badge + { + public string description { get; set; } + public string image { get; set; } + } +} diff --git a/ProjectMakoto/Entities/SharedCommandContext.cs b/ProjectMakoto/Entities/SharedCommandContext.cs new file mode 100644 index 00000000..d3392176 --- /dev/null +++ b/ProjectMakoto/Entities/SharedCommandContext.cs @@ -0,0 +1,398 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using CommandType = ProjectMakoto.Enums.CommandType; + +namespace ProjectMakoto.Entities; + +public sealed class SharedCommandContext +{ + public SharedCommandContext() { } + + public SharedCommandContext(BaseCommand cmd, CommandContext ctx, Bot _bot) + { + this.CommandType = CommandType.PrefixCommand; + + this.Member = ctx.Member; + this.User = ctx.User; + this.Guild = ctx.Guild; + this.Channel = ctx.Channel; + this.Client = ctx.Client; + + this.CurrentMember = ctx.Guild?.CurrentMember; + this.CurrentUser = ctx.Client.CurrentUser; + + this.OriginalCommandContext = ctx; + + this.Bot = _bot; + this.t = _bot.LoadedTranslations; + + this.Prefix = ctx.Prefix; + this.CommandName = ctx.Command.Name; + + if (ctx.Command.Parent != null) + this.CommandName = this.CommandName.Insert(0, $"{ctx.Command.Parent.Name} "); + + this._baseCommand = cmd; + + try + { + this.DbUser = _bot.Users[ctx.User.Id]; + } + catch (Exception ex) + { + Log.Warning(ex, "Unable to fetch database user entry for '{User}'", ctx.User.Id); + } + + try + { + this.DbGuild = _bot.Guilds[ctx.Guild.Id]; + } + catch (Exception ex) + { + Log.Warning(ex, "Unable to fetch database guild entry for '{User}'", ctx.User.Id); + } + } + + public SharedCommandContext(DiscordMessage message, Bot _bot, string CommandIdentifier) + { + this.CommandType = CommandType.Custom; + + this.Client = _bot.DiscordClient.GetShard(message.Guild); + this.User = message.Author; + this.Guild = message.Channel.Guild; + this.Channel = message.Channel; + + this.CurrentMember = message.Channel?.Guild?.CurrentMember; + this.CurrentUser = _bot.DiscordClient.CurrentUser; + + this.Bot = _bot; + this.t = _bot.LoadedTranslations; + + this.CommandName = CommandIdentifier; + + this._baseCommand = new DummyCommand() + { + ctx = this, + t = this.t + }; + + try + { + this.DbUser = _bot.Users[message.Author.Id]; + } + catch (Exception ex) + { + Log.Warning(ex, "Unable to fetch database user entry for '{User}'", message.Author.Id); + } + + try + { + this.DbGuild = _bot.Guilds[message.Channel.Guild.Id]; + } + catch (Exception ex) + { + Log.Warning(ex, "Unable to fetch database guild entry for '{User}'", message.Channel.Guild.Id); + } + } + + public SharedCommandContext(BaseCommand cmd, InteractionContext ctx, Bot _bot) + { + this.CommandType = CommandType.ApplicationCommand; + + this.Member = ctx.Member; + this.User = ctx.User; + this.Guild = ctx.Guild; + this.Channel = ctx.Channel; + this.Client = ctx.Client; + + this.CurrentMember = ctx.Guild?.CurrentMember; + this.CurrentUser = ctx.Client.CurrentUser; + + this.OriginalInteractionContext = ctx; + + this.Prefix = "/"; + this.CommandName = ctx.FullCommandName; + this.ParentCommandName = ctx.CommandName; + + this.Bot = _bot; + this.t = _bot.LoadedTranslations; + + this._baseCommand = cmd; + + try + { + this.DbUser = _bot.Users[ctx.User.Id]; + } + catch (Exception ex) + { + Log.Warning(ex, "Unable to fetch database user entry for '{User}'", ctx.User.Id); + } + + try + { + this.DbGuild = _bot.Guilds[ctx.Guild.Id]; + } + catch (Exception ex) + { + Log.Warning(ex, "Unable to fetch database guild entry for '{User}'", ctx.User.Id); + } + } + + public SharedCommandContext(BaseCommand cmd, ComponentInteractionCreateEventArgs ctx, DiscordClient client, string commandName, Bot _bot) + { + this.CommandType = CommandType.Event; + + this.User = ctx.User; + this.Guild = ctx.Guild; + this.Channel = ctx.Channel; + this.Client = client; + + try + { if (ctx.Guild is not null) this.Member = ctx.User.ConvertToMember(ctx.Guild).GetAwaiter().GetResult(); } + catch { } + + this.CurrentMember = ctx.Guild?.CurrentMember; + this.CurrentUser = client.CurrentUser; + + this.OriginalComponentInteractionCreateEventArgs = ctx; + + this.Prefix = "/"; + this.CommandName = commandName; + + this.Bot = _bot; + this.t = _bot.LoadedTranslations; + + this._baseCommand = cmd; + + try + { + this.DbUser = _bot.Users[ctx.User.Id]; + } + catch (Exception ex) + { + Log.Warning(ex, "Unable to fetch database user entry for '{User}'", ctx.User?.Id ?? 0); + } + + try + { + this.DbGuild = _bot.Guilds[ctx.Guild.Id]; + } + catch (Exception ex) + { + Log.Warning(ex, "Unable to fetch database guild entry for '{Guild}'", ctx.Guild?.Id ?? 0); + } + } + + public SharedCommandContext(BaseCommand cmd, ContextMenuContext ctx, Bot _bot) + { + this.CommandType = CommandType.ContextMenu; + + this.Member = ctx.Member; + this.User = ctx.User; + this.Guild = ctx.Guild; + this.Channel = ctx.Channel; + this.Client = ctx.Client; + + this.CurrentMember = ctx.Guild?.CurrentMember; + this.CurrentUser = ctx.Client.CurrentUser; + + this.OriginalContextMenuContext = ctx; + + this.Prefix = ""; + this.CommandName = ctx.FullCommandName; + this.ParentCommandName = ctx.CommandName; + + this.Bot = _bot; + this.t = _bot.LoadedTranslations; + + this._baseCommand = cmd; + + try + { + this.DbUser = _bot.Users[ctx.User.Id]; + } + catch (Exception ex) + { + Log.Warning(ex, "Unable to fetch database user entry for '{User}'", ctx.User.Id); + } + + try + { + this.DbGuild = _bot.Guilds[ctx.Guild.Id]; + } + catch (Exception ex) + { + Log.Warning(ex, "Unable to fetch database guild entry for '{User}'", ctx.User.Id); + } + } + + /// + /// Get's translations. + /// + public Translations t { get; set; } + + /// + /// From what kind of source this command originated from. + /// + public CommandType CommandType { get; set; } + + /// + /// The Command's Environment. + /// + public BaseCommand BaseCommand + => this._baseCommand ?? new DummyCommand() + { + ctx = this, + t = this.t, + }; + + private BaseCommand? _baseCommand { get; set; } + + /// + /// What prefix was used to execute this command. + /// + public string Prefix { get; set; } + + /// + /// The name of the command used. + /// + public string CommandName { get; set; } + + /// + /// The name of the command used. + /// + public string ParentCommandName { get; set; } + + /// + /// What Bot Instance was used to execute this command. + /// + public Bot Bot { get; set; } + + /// + /// What DiscordClient was used to execute this command. + /// + public DiscordClient Client { get; set; } + + /// + public DiscordClient Discord + => this.Client; + + + /// + /// The member that executed this command. + /// + public DiscordMember Member + { + get + { + if (this._member is null && this.Guild is not null) + _member = this.Guild.GetMemberAsync(this.CurrentUser.Id).Result; + + return _member; + } + set => this._member = value; + } + + private DiscordMember? _member; + + /// + /// This user that executed this command. + /// + public DiscordUser User { get; set; } + + /// + /// The user's database entry that executed this command. + /// + public User DbUser { get; set; } + + /// + /// The current member the bot uses. + /// + public DiscordMember CurrentMember + { + get + { + if (this._currentMember is null && this.Guild is not null) + _currentMember = this.Guild.GetMemberAsync(this.CurrentUser.Id).Result; + + return _currentMember; + } + set => this._currentMember = value; + } + + private DiscordMember? _currentMember; + + /// + /// The current user the bot uses. + /// + public DiscordUser CurrentUser { get; set; } + + /// + /// The guild this command was executed on. + /// + public DiscordGuild Guild { get; set; } + + /// + /// The guild's database entry the command was executed on. + /// + public Guild DbGuild { get; set; } + + /// + /// The channel this command was executed in. + /// + public DiscordChannel Channel { get; set; } + + /// + /// Whether the bot already responded once. Only set if Type is ApplicationCommand or ContextMenu. + /// + public bool RespondedToInitial { get; set; } + + /// + /// If the command was executed through another command. + /// + public bool Transferred { get; set; } = false; + + /// + /// The message that's being used to interact with the user. + /// + public DiscordMessage ResponseMessage { get; set; } + + /// + /// The original context. + /// + public ContextMenuContext OriginalContextMenuContext { get; set; } + + /// + /// The original context. + /// + public CommandContext OriginalCommandContext { get; set; } + + /// + /// The original context. + /// + public InteractionContext OriginalInteractionContext { get; set; } + + /// + /// The original event args. + /// + public ComponentInteractionCreateEventArgs OriginalComponentInteractionCreateEventArgs { get; set; } + + /// + /// The original interaction that started this command. + /// + public DiscordInteraction Interaction + => this.CommandType switch + { + CommandType.ApplicationCommand => this.OriginalInteractionContext.Interaction, + CommandType.Event => this.OriginalComponentInteractionCreateEventArgs.Interaction, + CommandType.ContextMenu => this.OriginalContextMenuContext.Interaction, + _ => null + }; +} diff --git a/ProjectMakoto/Entities/Status.cs b/ProjectMakoto/Entities/Status.cs new file mode 100644 index 00000000..54331ae0 --- /dev/null +++ b/ProjectMakoto/Entities/Status.cs @@ -0,0 +1,129 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public sealed class Status +{ + internal Status() { } + + public DateTime startupTime { get; internal set; } = DateTime.UtcNow; + + public string RunningVersion { get; internal set; } + + public bool DiscordInitialized { get; internal set; } = false; + public bool DiscordGuildDownloadCompleted { get; internal set; } = false; + public bool DiscordCommandsRegistered { get; internal set; } = false; + + public ulong TeamOwner { get; internal set; } = new(); + public IReadOnlyList TeamMembers + => this._TeamMembers.AsReadOnly(); + internal List _TeamMembers { get; set; } = new(); + + internal long DiscordDisconnections = 0; + + internal Config LoadedConfig { get; set; } + + private string? _CurrentAppHash { get; set; } = null; + internal string CurrentAppHash + { + get + { + this._CurrentAppHash ??= HashingExtensions.ComputeSHA256Hash(new FileInfo(Assembly.GetExecutingAssembly().Location)); + return this._CurrentAppHash; + } + } + + public ExposedConfig SafeReadOnlyConfig + => new(this.LoadedConfig); + + public class ExposedConfig(Config config) + { + public bool IsDev => config.IsDev; + public bool AllowMoreThan100Guilds => config.AllowMoreThan100Guilds; + + public string SupportServerInvite = config.SupportServerInvite; + public EmojiConfig Emojis = new(config); + public DiscordConfig Discord = new(config); + public ChannelsConfig Channels = new(config); + + public sealed class DiscordConfig(Config config) + { + public ulong AssetsGuild => config.Discord.AssetsGuild; + public ulong DevelopmentGuild => config.Discord.DevelopmentGuild; + + public uint MaxUploadSize => config.Discord.MaxUploadSize; + public IReadOnlyList DisabledCommands => config.Discord.DisabledCommands.ToList().AsReadOnly(); + } + + public sealed class ChannelsConfig(Config config) + { + public ulong GlobalBanAnnouncements => config.Channels.GlobalBanAnnouncements; + public ulong GithubLog => config.Channels.GithubLog; + public ulong News => config.Channels.News; + + public ulong GraphAssets => config.Channels.GraphAssets; + public ulong PlaylistAssets => config.Channels.PlaylistAssets; + public ulong UrlSubmissions => config.Channels.UrlSubmissions; + public ulong OtherAssets => config.Channels.OtherAssets; + + public ulong ExceptionLog => config.Channels.ExceptionLog; + } + + public sealed class EmojiConfig(Config config) + { + public ulong DisabledRepeat => config.Emojis.DisabledRepeat; + public ulong DisabledShuffle => config.Emojis.DisabledShuffle; + public ulong Paused => config.Emojis.Paused; + public ulong DisabledPlay => config.Emojis.DisabledPlay; + + public ulong Error => config.Emojis.Error; + + public ulong CheckboxTicked => config.Emojis.CheckboxTicked; + public ulong CheckboxUnticked => config.Emojis.CheckboxUnticked; + + public ulong PillOn => config.Emojis.PillOn; + public ulong PillOff => config.Emojis.PillOff; + + public ulong QuestionMark => config.Emojis.QuestionMark; + + public ulong PrefixCommandDisabled => config.Emojis.PrefixCommandDisabled; + public ulong PrefixCommandEnabled => config.Emojis.PrefixCommandEnabled; + + public ulong SlashCommand => config.Emojis.SlashCommand; + public ulong MessageCommand => config.Emojis.MessageCommand; + public ulong UserCommand => config.Emojis.UserCommand; + + public ulong Channel => config.Emojis.Channel; + public ulong User => config.Emojis.User; + public ulong VoiceState => config.Emojis.VoiceState; + public ulong Message => config.Emojis.Message; + public ulong Guild => config.Emojis.Guild; + public ulong Invite => config.Emojis.Invite; + public ulong In => config.Emojis.In; + + public ulong YouTube => config.Emojis.YouTube; + public ulong SoundCloud => config.Emojis.SoundCloud; + public ulong AbuseIPDB => config.Emojis.AbuseIPDB; + public ulong Spotify => config.Emojis.Spotify; + } + } + + #region Legacy + + internal string DevelopmentServerInvite + { + get + { + return this.LoadedConfig.SupportServerInvite.IsNullOrWhiteSpace() ? "Invite not set." : this.LoadedConfig.SupportServerInvite; + } + } + + #endregion +} diff --git a/ProjectMakoto/Entities/SystemMonitor/SystemInfo.cs b/ProjectMakoto/Entities/SystemMonitor/SystemInfo.cs new file mode 100644 index 00000000..2ca97e90 --- /dev/null +++ b/ProjectMakoto/Entities/SystemMonitor/SystemInfo.cs @@ -0,0 +1,46 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.SystemMonitor; + +public sealed class SystemInfo +{ + public CpuInfo Cpu { get; set; } = new(); + public MemoryInfo Memory { get; set; } = new(); + public NetworkInfo Network { get; set; } = new(); + + public sealed class CpuInfo + { + public float Load { get; set; } = 0; + public decimal Temperature { get; set; } = 0; + } + + public sealed class MemoryInfo + { + public float Available { get; set; } = 0; + public float Used { get; set; } = 0; + + public float Total + { + get { return this.Available + this.Used; } + set { this.Available = value - this.Used; } + } + } + + public sealed class NetworkInfo + { + public float TotalDownloaded { get; set; } = 0; + public float TotalUploaded { get; set; } = 0; + + public float CurrentDownloadSpeed { get; set; } = 0; + public float CurrentUploadSpeed { get; set; } = 0; + + public float TotalUtilization { get; set; } = 0; + } +} diff --git a/ProjectMakoto/Entities/TaskInfo.cs b/ProjectMakoto/Entities/TaskInfo.cs new file mode 100644 index 00000000..34cc2ca8 --- /dev/null +++ b/ProjectMakoto/Entities/TaskInfo.cs @@ -0,0 +1,37 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public sealed class TaskInfo +{ + internal TaskInfo(Task task) + { + this.Task = task; + } + + internal TaskInfo(Task task, object? customData = null) + { + this.CustomData = customData; + this.Task = task; + } + public string Uuid { get; private set; } = Guid.NewGuid().ToString(); + public Task Task { get; private set; } + public DateTime CreationTime { get; private set; } = DateTime.UtcNow; + public bool IsVital { get; internal set; } = false; + + public string CallingMethod { get; init; } = string.Empty; + public string CallingFile { get; init; } = string.Empty; + public int CallingLine { get; init; } = -1; + + public object? CustomData { get; internal set;} = null; + + public string GetName() + => $"{this.Uuid}; F:{this.CallingFile ?? "-"}:{this.CallingLine} (M:{this.CallingMethod ?? "-"})"; +} diff --git a/ProjectMakoto/Entities/Translation/CommandTranslation.cs b/ProjectMakoto/Entities/Translation/CommandTranslation.cs new file mode 100644 index 00000000..a581f40f --- /dev/null +++ b/ProjectMakoto/Entities/Translation/CommandTranslation.cs @@ -0,0 +1,31 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; +public sealed class CommandTranslation +{ + [JsonIgnore] + public CommandTranslationType TranslatorType { get; set; } + + public int? Type; + public SingleTranslationKey Names; + public SingleTranslationKey? Descriptions; + + public CommandTranslation[]? Options; + public CommandTranslation[]? Choices; + public CommandTranslation[]? Groups; + public CommandTranslation[]? Commands; +} + +public enum CommandTranslationType +{ + Command = 0, + Option = 1, + Group = 2, +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/Translation/EmbeddedLink.cs b/ProjectMakoto/Entities/Translation/EmbeddedLink.cs new file mode 100644 index 00000000..d9469175 --- /dev/null +++ b/ProjectMakoto/Entities/Translation/EmbeddedLink.cs @@ -0,0 +1,17 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Translation; +public sealed record EmbeddedLink(string Url, string Text) +{ + public override string ToString() + { + return $"[{this.Text}]({this.Url})"; + } +} diff --git a/ProjectMakoto/Entities/Translation/ITranslations.cs b/ProjectMakoto/Entities/Translation/ITranslations.cs new file mode 100644 index 00000000..2a7f8051 --- /dev/null +++ b/ProjectMakoto/Entities/Translation/ITranslations.cs @@ -0,0 +1,15 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public interface ITranslations +{ + public CommandTranslation[] CommandList { get; set; } +} diff --git a/ProjectMakoto/Entities/Translation/MultiTranslationKey.cs b/ProjectMakoto/Entities/Translation/MultiTranslationKey.cs new file mode 100644 index 00000000..0c236505 --- /dev/null +++ b/ProjectMakoto/Entities/Translation/MultiTranslationKey.cs @@ -0,0 +1,120 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using System.Diagnostics.CodeAnalysis; + +namespace ProjectMakoto.Entities; + +public sealed class MultiTranslationKey : IDictionary +{ + public Dictionary t { get; set; } = new(); + + public string[] Get(User user) + { + var Locale = !user.OverrideLocale.IsNullOrWhiteSpace() ? user.OverrideLocale : user.CurrentLocale; + + if (Locale is null || !this.t.ContainsKey(Locale)) + Locale = "en"; + + return !this.t.TryGetValue(Locale, out var value) ? (new string[] { "Missing Translation. Please report to the developer." }) : value; + } + + public string[] Get(Guild user) + { + var Locale = !user.OverrideLocale.IsNullOrWhiteSpace() ? user.OverrideLocale : user.CurrentLocale; + + if (Locale is null || !this.t.ContainsKey(Locale)) + Locale = "en"; + + return !this.t.TryGetValue(Locale, out var value) ? (new string[] { "Missing Translation. Please report to the developer." }) : value; + } + + public string[] Get(DiscordGuild guild) + { + string? Locale = null; + + if (!guild.PreferredLocale.IsNullOrWhiteSpace()) + Locale = guild.PreferredLocale; + + if (Locale is null || !this.t.ContainsKey(Locale)) + Locale = "en"; + + return !this.t.TryGetValue(Locale, out var value) ? (new string[] { "Missing Translation. Please report to the developer." }) : value; + } + + public string[] Get(DiscordUser user) + { + var Locale = user.Locale; + + if (Locale is null && !this.t.ContainsKey("en")) + return new string[] { "Missing Translation. Please report to the developer." }; + + if (Locale is null || !this.t.ContainsKey(Locale)) + Locale = "en"; + + return !this.t.TryGetValue(Locale, out var value) ? (new string[] { "Missing Translation. Please report to the developer." }) : value; + } + + public ICollection Keys => this.t.Keys; + + public ICollection Values => this.t.Values; + + public int Count => this.t.Count; + + public bool IsReadOnly => false; + + public string[] this[string key] { get => this.t[key]; set => this.t[key] = value; } + + public void Add(string key, string[] value) + => this.t.Add(key, value); + + public bool ContainsKey(string key) + => this.t.ContainsKey(key); + + public bool Remove(string key) + => this.t.Remove(key); + + public bool TryGetValue(string key, [MaybeNullWhen(false)] out string[] value) + => this.t.TryGetValue(key, out value); + + public void Add(KeyValuePair item) + => this.t.Add(item.Key, item.Value); + + public void Clear() + => this.t.Clear(); + + public bool Contains(KeyValuePair item) + => this.t.Contains(item); + + public void CopyTo(KeyValuePair[] array, int arrayIndex) { } + + public bool Remove(KeyValuePair item) + => this.t.Remove(item.Key); + + public IEnumerator> GetEnumerator() + => this.t.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => this.t.GetEnumerator(); + +#pragma warning disable CS0809 // Obsolete member overrides non-obsolete member + [Obsolete("Do not call .ToString(). Use the .Get() Method instead.", true)] + public override string ToString() + { + StackTrace stackTrace = new(); + var stackFrames = stackTrace.GetFrames(); + + var callingFrame = stackFrames[1]; + var method = callingFrame.GetMethod(); + + Log.Error(new InvalidCallException().AddData("StackTrace", stackTrace).AddData("DeclaryingType", method.DeclaringType).AddData("Method", method), + "Key with english text '{text}' was incorrectly accessed. Defaulting to english translation.", this.t["en"].Build()); + return this.t["en"].Build(); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/Translation/SingleTranslationKey.cs b/ProjectMakoto/Entities/Translation/SingleTranslationKey.cs new file mode 100644 index 00000000..ec896949 --- /dev/null +++ b/ProjectMakoto/Entities/Translation/SingleTranslationKey.cs @@ -0,0 +1,120 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using System.Diagnostics.CodeAnalysis; + +namespace ProjectMakoto.Entities; + +public sealed class SingleTranslationKey : IDictionary +{ + public Dictionary t { get; set; } = new(); + + public string Get(User user) + { + var Locale = !user.OverrideLocale.IsNullOrWhiteSpace() ? user.OverrideLocale : user.CurrentLocale; + + if (Locale is null || !this.t.ContainsKey(Locale)) + Locale = "en"; + + return !this.t.TryGetValue(Locale, out var value) ? "Missing Translation. Please report to the developer." : value; + } + + public string Get(Guild user) + { + var Locale = !user.OverrideLocale.IsNullOrWhiteSpace() ? user.OverrideLocale : user.CurrentLocale; + + if (Locale is null || !this.t.ContainsKey(Locale)) + Locale = "en"; + + return !this.t.TryGetValue(Locale, out var value) ? "Missing Translation. Please report to the developer." : value; + } + + public string Get(DiscordGuild guild) + { + string? Locale = null; + + if (!guild.PreferredLocale.IsNullOrWhiteSpace()) + Locale = guild.PreferredLocale; + + if (Locale is null || !this.t.ContainsKey(Locale)) + Locale = "en"; + + return !this.t.TryGetValue(Locale, out var value) ? "Missing Translation. Please report to the developer." : value; + } + + public string Get(DiscordUser user) + { + var Locale = user.Locale; + + if (Locale is null && !this.t.ContainsKey("en")) + return "Missing Translation. Please report to the developer."; + + if (Locale is null || !this.t.ContainsKey(Locale)) + Locale = "en"; + + return !this.t.TryGetValue(Locale, out var value) ? "Missing Translation. Please report to the developer." : value; + } + + public ICollection Keys => this.t.Keys; + + public ICollection Values => this.t.Values; + + public int Count => this.t.Count; + + public bool IsReadOnly => false; + + public string this[string key] { get => this.t[key]; set => this.t[key] = value; } + + public void Add(string key, string value) + => this.t.Add(key, value); + + public bool ContainsKey(string key) + => this.t.ContainsKey(key); + + public bool Remove(string key) + => this.t.Remove(key); + + public bool TryGetValue(string key, [MaybeNullWhen(false)] out string value) + => this.t.TryGetValue(key, out value); + + public void Add(KeyValuePair item) + => this.t.Add(item.Key, item.Value); + + public void Clear() + => this.t.Clear(); + + public bool Contains(KeyValuePair item) + => this.t.Contains(item); + + public void CopyTo(KeyValuePair[] array, int arrayIndex) { } + + public bool Remove(KeyValuePair item) + => this.t.Remove(item.Key); + + public IEnumerator> GetEnumerator() + => this.t.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => this.t.GetEnumerator(); + +#pragma warning disable CS0809 // Obsolete member overrides non-obsolete member + [Obsolete("Do not call .ToString(). Use the .Get() Method instead.", true)] + public override string ToString() + { + StackTrace stackTrace = new(); + var stackFrames = stackTrace.GetFrames(); + + var callingFrame = stackFrames[1]; + var method = callingFrame.GetMethod(); + + Log.Error(new InvalidCallException(stackTrace.ToString()).AddData("DeclaryingType", method.DeclaringType).AddData("Method", method), + "Key with english text '{text}' was incorrectly accessed. Defaulting to english translation.", this.t["en"]); + return this.t["en"]; + } +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/Translation/TVar.cs b/ProjectMakoto/Entities/Translation/TVar.cs new file mode 100644 index 00000000..7f44106b --- /dev/null +++ b/ProjectMakoto/Entities/Translation/TVar.cs @@ -0,0 +1,12 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Translation; + +public sealed record TVar(string ValName, object Replacement, bool Sanitize = false); diff --git a/ProjectMakoto/Entities/Translation/Translations.cs b/ProjectMakoto/Entities/Translation/Translations.cs new file mode 100644 index 00000000..0bfc5e2a --- /dev/null +++ b/ProjectMakoto/Entities/Translation/Translations.cs @@ -0,0 +1,1200 @@ +// Project Makoto +// Copyright (C) 2023 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY +namespace ProjectMakoto.Entities; +#pragma warning disable CS8981 +#pragma warning disable CS8618 +#pragma warning disable IDE1006 +public class Translations : ITranslations +{ + public Dictionary Progress = new(); + public CommandTranslation[] CommandList { get; set; } + #region AutoGenerated + public events Events; + public sealed class events + { + public vcCreator VcCreator; + public sealed class vcCreator + { + public SingleTranslationKey UserLeft; + public SingleTranslationKey UserJoined; + public SingleTranslationKey NewOwner; + public MultiTranslationKey NewChannelNotice; + public SingleTranslationKey DefaultChannelName; + } + public tokenDetection TokenDetection; + public sealed class tokenDetection + { + public MultiTranslationKey TokenInvalidated; + } + public phishing Phishing; + public sealed class phishing + { + public SingleTranslationKey DetectedMaliciousHost; + public SingleTranslationKey FoundRedirects; + public SingleTranslationKey RedirectCheckTimeoutUnknownError; + public SingleTranslationKey RedirectDepthLimitError; + public SingleTranslationKey RedirectCheckTimeoutError; + public SingleTranslationKey OpenInBrowser; + public SingleTranslationKey DomainName; + public SingleTranslationKey ISP; + public SingleTranslationKey Country; + public SingleTranslationKey ConfidenceOfAbuse; + public SingleTranslationKey HostWasFoundInAbuseIpDb; + public SingleTranslationKey AbuseIpDbReport; + } + public join Join; + public sealed class join + { + public MultiTranslationKey UserLeft; + public SingleTranslationKey UserJoined; + public SingleTranslationKey Globalban; + } + public inVoicePrivacy InVoicePrivacy; + public sealed class inVoicePrivacy + { + public SingleTranslationKey LeftWithDeleteMessages; + public SingleTranslationKey JoinedWithSetPermissions; + public SingleTranslationKey LeftWithSetPermissions; + public SingleTranslationKey CreatedWithSetPermissions; + } + public experience Experience; + public sealed class experience + { + public SingleTranslationKey DirectMessagesDisabled; + public SingleTranslationKey DisableDirectMessages; + public SingleTranslationKey AutomaticDeletion; + public SingleTranslationKey NewLevel; + public SingleTranslationKey GainedLevels; + public SingleTranslationKey GainedLevel; + } + public genericEvent GenericEvent; + public sealed class genericEvent + { + public MultiTranslationKey SuccessfulJoin; + public MultiTranslationKey LimitedReached; + public MultiTranslationKey PingMessage; + } + public embedMessages EmbedMessages; + public sealed class embedMessages + { + public SingleTranslationKey NotAuthor; + public SingleTranslationKey FailedToDelete; + public SingleTranslationKey Line; + public SingleTranslationKey Lines; + public SingleTranslationKey Delete; + } + public bumpReminder BumpReminder; + public sealed class bumpReminder + { + public SingleTranslationKey BumpNotification; + public SingleTranslationKey LastBumpMissed; + public SingleTranslationKey BumpReminderDisabledMessageDeleted; + public SingleTranslationKey BumpReminderDisabledNotPinned; + public SingleTranslationKey BumpReminderDisabledReactionRemoved; + public SingleTranslationKey BumpReminderDisabled; + public SingleTranslationKey ServerCanBeBump; + public SingleTranslationKey LastBumpBy; + public SingleTranslationKey NextBumpTime; + public SingleTranslationKey SubscribeRoleNotice; + public SingleTranslationKey ServerBumped; + } + public actionlog Actionlog; + public sealed class actionlog + { + public SingleTranslationKey NoInviter; + public SingleTranslationKey InviteDeleted; + public SingleTranslationKey InviteCreated; + public SingleTranslationKey Invite; + public SingleTranslationKey DefaultAutoArchiveDuration; + public SingleTranslationKey Bitrate; + public SingleTranslationKey NsfwChannel; + public SingleTranslationKey ChannelId; + public SingleTranslationKey ChannelModified; + public SingleTranslationKey ChannelDeleted; + public SingleTranslationKey ChannelCreated; + public SingleTranslationKey GuildUpdated; + public SingleTranslationKey MaximumMembers; + public SingleTranslationKey SafetyAlertsChannel; + public SingleTranslationKey DiscordUpdateChannel; + public SingleTranslationKey SystemChannel; + public SingleTranslationKey AfkChannel; + public SingleTranslationKey AfkTimeout; + public SingleTranslationKey RuleChannel; + public SingleTranslationKey BoostProgressBar; + public SingleTranslationKey WelcomeScreen; + public SingleTranslationKey MembershipScreening; + public SingleTranslationKey CommunityGuild; + public SingleTranslationKey NsfwGuild; + public SingleTranslationKey LargeGuild; + public SingleTranslationKey GuildWidgetChannel; + public SingleTranslationKey GuildWidgetEnabled; + public SingleTranslationKey ExplicitContentFilter; + public SingleTranslationKey RequiredMfaLevel; + public SingleTranslationKey DiscoverySplashUpdated; + public SingleTranslationKey HomeHeaderUpdated; + public SingleTranslationKey SplashUpdated; + public SingleTranslationKey BannerUpdated; + public SingleTranslationKey VerificationLevel; + public SingleTranslationKey DefaultNotificationSettings; + public SingleTranslationKey IconUpdated; + public SingleTranslationKey VanityUrl; + public SingleTranslationKey PreferredLocale; + public SingleTranslationKey Description; + public SingleTranslationKey Name; + public SingleTranslationKey Owner; + public SingleTranslationKey UnbannedBy; + public SingleTranslationKey UserUnbanned; + public SingleTranslationKey UserBanned; + public SingleTranslationKey BannedBy; + public SingleTranslationKey ModifiedBy; + public SingleTranslationKey RoleUpdated; + public SingleTranslationKey PermissionsUpdated; + public SingleTranslationKey PermissionsAdded; + public SingleTranslationKey PermissionsRemoved; + public SingleTranslationKey DeletedBy; + public SingleTranslationKey RoleWasIntegration; + public SingleTranslationKey RoleDeleted; + public SingleTranslationKey CreatedBy; + public SingleTranslationKey Permissions; + public SingleTranslationKey DisplayedRoleMembers; + public SingleTranslationKey RoleMentionable; + public SingleTranslationKey RoleIsIntegration; + public SingleTranslationKey RoleId; + public SingleTranslationKey Color; + public SingleTranslationKey Role; + public SingleTranslationKey RoleCreated; + public SingleTranslationKey ServerBooster; + public SingleTranslationKey Integration; + public SingleTranslationKey TimeoutRemoved; + public SingleTranslationKey TimedOutUntil; + public SingleTranslationKey TimedOut; + public SingleTranslationKey GuildProfilePictureUpdated; + public SingleTranslationKey MembershipApproved; + public SingleTranslationKey RolesRemoved; + public SingleTranslationKey RolesAdded; + public SingleTranslationKey RolesUpdated; + public SingleTranslationKey NewNickname; + public SingleTranslationKey PreviousNickname; + public SingleTranslationKey NicknameRemoved; + public SingleTranslationKey NicknameAdded; + public SingleTranslationKey NicknameUpdated; + public SingleTranslationKey NewContent; + public SingleTranslationKey PreviousContent; + public SingleTranslationKey Message; + public SingleTranslationKey MessageUpdated; + public SingleTranslationKey AffectedUsers; + public SingleTranslationKey CheckAttachedFileForDeletedMessages; + public SingleTranslationKey MultipleMessagesDeleted; + public SingleTranslationKey UserSwitchedVoiceChannel; + public SingleTranslationKey UserLeftVoiceChannel; + public SingleTranslationKey UserJoinedVoiceChannel; + public SingleTranslationKey ReplyTo; + public SingleTranslationKey Stickers; + public SingleTranslationKey Attachments; + public SingleTranslationKey Content; + public SingleTranslationKey Channel; + public SingleTranslationKey MessageDeleted; + public SingleTranslationKey FooterAuditLogDisclaimer; + public SingleTranslationKey Reason; + public SingleTranslationKey KickedBy; + public SingleTranslationKey UserKicked; + public SingleTranslationKey Roles; + public SingleTranslationKey JoinedAt; + public SingleTranslationKey UserLeft; + public SingleTranslationKey InviteNote; + public SingleTranslationKey InviteCode; + public SingleTranslationKey InvitedBy; + public SingleTranslationKey InviteNotes; + public SingleTranslationKey StaffNotes; + public SingleTranslationKey AccountAge; + public SingleTranslationKey UserRejoined; + public SingleTranslationKey UserJoined; + public SingleTranslationKey UserId; + public SingleTranslationKey User; + } + } + public commands Commands; + public sealed class commands + { + public config Config; + public sealed class config + { + public vcCreator VcCreator; + public sealed class vcCreator + { + public SingleTranslationKey CreateNewChannel; + public SingleTranslationKey NoChannels; + public SingleTranslationKey DisableVcCreator; + public SingleTranslationKey SetVcCreator; + public SingleTranslationKey Title; + } + public tokenDetection TokenDetection; + public sealed class tokenDetection + { + public SingleTranslationKey ToggleTokenDetection; + public SingleTranslationKey DetectTokens; + public SingleTranslationKey Title; + } + public reactionRoles ReactionRoles; + public sealed class reactionRoles + { + public SingleTranslationKey RemovedAllReactionRoles; + public SingleTranslationKey NoReactionRoles; + public SingleTranslationKey RemovingAllReactionRoles; + public SingleTranslationKey RemovedReactionRole; + public SingleTranslationKey NoReactionRoleFound; + public SingleTranslationKey ReactWithEmojiToRemove; + public SingleTranslationKey RemovingReactionRole; + public SingleTranslationKey AddedReactionRole; + public SingleTranslationKey RoleAlreadyUsed; + public SingleTranslationKey EmojiAlreadyUsed; + public SingleTranslationKey ReactionRoleLimitReached; + public SingleTranslationKey NoAccessToEmoji; + public SingleTranslationKey ReactWithEmoji; + public SingleTranslationKey NoRoles; + public SingleTranslationKey SelectRolePrompt; + public SingleTranslationKey AddingReactionRole; + public SingleTranslationKey MessageUrlNoMessage; + public SingleTranslationKey MessageUrlNoChannel; + public SingleTranslationKey MessageUrlWrongGuild; + public SingleTranslationKey InvalidMessageUrl; + public SingleTranslationKey MessageUrlInstructions; + public SingleTranslationKey MessageUrl; + public SingleTranslationKey Role; + public SingleTranslationKey Emoji; + public SingleTranslationKey Message; + public SingleTranslationKey SelectRole; + public SingleTranslationKey SelectEmoji; + public SingleTranslationKey SelectMessage; + public SingleTranslationKey ReactionRoleCount; + public SingleTranslationKey RemoveReactionRole; + public SingleTranslationKey AddNewReactionRole; + public SingleTranslationKey LoadingReactionRoles; + public SingleTranslationKey Title; + } + public phishing Phishing; + public sealed class phishing + { + public SingleTranslationKey InvalidDuration; + public SingleTranslationKey NotUsingType; + public SingleTranslationKey DefineNewReason; + public SingleTranslationKey ChangeTimeoutLength; + public SingleTranslationKey ChangePunishmentReason; + public SingleTranslationKey ChangePunishmentType; + public SingleTranslationKey ToggleAbuseIpDb; + public SingleTranslationKey ToggleWarning; + public SingleTranslationKey ToggleDetection; + public SingleTranslationKey CustomTimeoutLength; + public SingleTranslationKey CustomPunishmentReason; + public SingleTranslationKey PunishmentTypeSoftbanDescription; + public SingleTranslationKey PunishmentTypeBanDescription; + public SingleTranslationKey PunishmentTypeKickDescription; + public SingleTranslationKey PunishmentTypeTimeoutDescription; + public SingleTranslationKey PunishmentTypeDeleteDescription; + public SingleTranslationKey PunishmentTypeSoftban; + public SingleTranslationKey PunishmentTypeBan; + public SingleTranslationKey PunishmentTypeKick; + public SingleTranslationKey PunishmentTypeTimeout; + public SingleTranslationKey PunishmentTypeDelete; + public SingleTranslationKey PunishmentType; + public SingleTranslationKey AbuseIpDbReports; + public SingleTranslationKey RedirectWarning; + public SingleTranslationKey DetectPhishingLinks; + public SingleTranslationKey Title; + } + public nameNormalizer NameNormalizer; + public sealed class nameNormalizer + { + public SingleTranslationKey RenamedMembers; + public SingleTranslationKey RenamingAllMembers; + public SingleTranslationKey NormalizerRunning; + public SingleTranslationKey NormalizeNow; + public SingleTranslationKey ToggleNameNormalizer; + public SingleTranslationKey NameNormalizerEnabled; + public SingleTranslationKey DefaultName; + public SingleTranslationKey Title; + } + public guildLanguage GuildLanguage; + public sealed class guildLanguage + { + public SingleTranslationKey Selector; + public SingleTranslationKey DisableOverride; + public SingleTranslationKey Response; + public SingleTranslationKey Disclaimer; + public SingleTranslationKey Title; + } + public prefixConfigCommand PrefixConfigCommand; + public sealed class prefixConfigCommand + { + public SingleTranslationKey NewPrefix; + public SingleTranslationKey NewPrefixModalTitle; + public SingleTranslationKey ChangePrefix; + public SingleTranslationKey TogglePrefixCommands; + public SingleTranslationKey CurrentPrefix; + public SingleTranslationKey PrefixDisabled; + public SingleTranslationKey Title; + } + public levelRewards LevelRewards; + public sealed class levelRewards + { + public SingleTranslationKey AddedNewReward; + public SingleTranslationKey MessageTooLong; + public SingleTranslationKey CantUseRole; + public SingleTranslationKey ChangeMessageButton; + public SingleTranslationKey SelectLevelButton; + public SingleTranslationKey SelectRoleButton; + public SingleTranslationKey DefaultCustomText; + public SingleTranslationKey SelectDropdown; + public SingleTranslationKey RemoveButton; + public SingleTranslationKey ModifyButton; + public SingleTranslationKey AddNewButton; + public SingleTranslationKey SelectPrompt; + public SingleTranslationKey Loading; + public SingleTranslationKey NoRewardsSetup; + public SingleTranslationKey Message; + public SingleTranslationKey Role; + public SingleTranslationKey Level; + public SingleTranslationKey Title; + } + public join Join; + public sealed class join + { + public SingleTranslationKey LowTimeWarning; + public SingleTranslationKey AutoKickNoRolesReason; + public SingleTranslationKey AutoKickAccountAgeReason; + public SingleTranslationKey AutoKickSpammerReason; + public SingleTranslationKey AutoKickNewAccountsDurationLimit; + public SingleTranslationKey AutoKickNoRolesDurationLimit; + public SingleTranslationKey ChangeAutoKickNoRoles; + public SingleTranslationKey ChangeAutoKickNewAccounts; + public SingleTranslationKey ToggleAutoKickSpammer; + public SingleTranslationKey AutoKickNoRoles; + public SingleTranslationKey AutoKickNewAccounts; + public SingleTranslationKey AutoKickSpammer; + public SingleTranslationKey CantUseRole; + public SingleTranslationKey DisableRoleOnJoin; + public SingleTranslationKey AutoAssignRoleName; + public SingleTranslationKey DisableJoinlog; + public SingleTranslationKey JoinLogChannelName; + public SingleTranslationKey ToggleReApplyNickname; + public SingleTranslationKey ToggleReApplyRole; + public SingleTranslationKey ChangeRoleButton; + public SingleTranslationKey ChangeJoinlogChannelButton; + public SingleTranslationKey ToggleGlobalBansButton; + public SingleTranslationKey TimeNotice; + public SingleTranslationKey SecurityNotice; + public SingleTranslationKey ReApplyNickname; + public SingleTranslationKey ReApplyRoles; + public SingleTranslationKey Role; + public SingleTranslationKey JoinLogChannel; + public SingleTranslationKey Autoban; + public SingleTranslationKey Title; + } + public inVoicePrivacy InVoicePrivacy; + public sealed class inVoicePrivacy + { + public SingleTranslationKey DisabledInVoicePrivacy; + public SingleTranslationKey EnabledInVoicePrivacy; + public SingleTranslationKey TogglePermissionProtectionButton; + public SingleTranslationKey ToggleMessageDeletionButton; + public SingleTranslationKey SetPermissions; + public SingleTranslationKey ClearMessagesOnLeave; + public SingleTranslationKey Title; + } + public inviteTracker InviteTracker; + public sealed class inviteTracker + { + public SingleTranslationKey ToggleInviteTrackerButton; + public SingleTranslationKey InviteTrackerEnabled; + public SingleTranslationKey Title; + } + public inviteNotes InviteNotes; + public sealed class inviteNotes + { + public SingleTranslationKey InviteDescription; + public SingleTranslationKey Invite; + public SingleTranslationKey Note; + public SingleTranslationKey CreateButton; + public SingleTranslationKey SelectInviteButton; + public SingleTranslationKey SetNoteButton; + public SingleTranslationKey RemoveNoteButton; + public SingleTranslationKey AddNoteButton; + public SingleTranslationKey NoNotesDefined; + public SingleTranslationKey Title; + } + public experience Experience; + public sealed class experience + { + public SingleTranslationKey ToggleExperienceBoostButton; + public SingleTranslationKey ToggleExperienceButton; + public SingleTranslationKey ExperienceBoostForBumpers; + public SingleTranslationKey ExperienceEnabled; + public SingleTranslationKey Title; + } + public embedMessages EmbedMessages; + public sealed class embedMessages + { + public SingleTranslationKey ToggleGithubCodeButton; + public SingleTranslationKey ToggleMessageLinkButton; + public SingleTranslationKey EmbedGithubCode; + public SingleTranslationKey EmbedMessageLinks; + public SingleTranslationKey Title; + } + public bumpReminder BumpReminder; + public sealed class bumpReminder + { + public SingleTranslationKey Disabled; + public SingleTranslationKey SetupComplete; + public SingleTranslationKey ReactionRoleMessage; + public SingleTranslationKey CantUseRole; + public SingleTranslationKey SelectRole; + public SingleTranslationKey SettingUp; + public SingleTranslationKey DisboardMissing; + public SingleTranslationKey ChangeRoleButton; + public SingleTranslationKey ChangeChannelButton; + public SingleTranslationKey DisableBumpReminderButton; + public SingleTranslationKey SetupBumpReminderButton; + public SingleTranslationKey BumpReminderRole; + public SingleTranslationKey BumpReminderChannel; + public SingleTranslationKey BumpReminderEnabled; + public SingleTranslationKey Title; + } + public autoUnarchive AutoUnarchive; + public sealed class autoUnarchive + { + public SingleTranslationKey RemoveChannelButton; + public SingleTranslationKey AddChannelButton; + public SingleTranslationKey Explanation; + public SingleTranslationKey NoChannels; + public SingleTranslationKey Title; + } + public autoCrosspost AutoCrosspost; + public sealed class autoCrosspost + { + public SingleTranslationKey NoCrosspostChannels; + public SingleTranslationKey ChannelLimit; + public SingleTranslationKey DurationLimit; + public SingleTranslationKey RemoveChannelButton; + public SingleTranslationKey AddChannelButton; + public SingleTranslationKey ToggleExcludeBotsButton; + public SingleTranslationKey SetDelayButton; + public SingleTranslationKey DelayBeforePosting; + public SingleTranslationKey ExcludeBots; + public SingleTranslationKey Title; + } + public actionLog ActionLog; + public sealed class actionLog + { + public SingleTranslationKey NoOptions; + public SingleTranslationKey OptionInaccurate; + public SingleTranslationKey ChangeFilterButton; + public SingleTranslationKey ChangeChannelButton; + public SingleTranslationKey SetChannelButton; + public SingleTranslationKey DisableActionLogButton; + public SingleTranslationKey InviteModifications; + public SingleTranslationKey VoiceChannelUpdates; + public SingleTranslationKey ChannelModifications; + public SingleTranslationKey ServerModifications; + public SingleTranslationKey BanUpdates; + public SingleTranslationKey RoleUpdates; + public SingleTranslationKey MessageModifications; + public SingleTranslationKey MessageDeletions; + public SingleTranslationKey UserProfileUpdates; + public SingleTranslationKey UserRoleUpdates; + public SingleTranslationKey UserStateUpdates; + public SingleTranslationKey AttemptGatheringMoreDetails; + public SingleTranslationKey ActionLogChannel; + public SingleTranslationKey ActionlogDisabled; + public SingleTranslationKey Title; + } + } + public moderation Moderation; + public sealed class moderation + { + public unban Unban; + public sealed class unban + { + public SingleTranslationKey Failed; + public SingleTranslationKey Removed; + public SingleTranslationKey Removing; + } + public timeout Timeout; + public sealed class timeout + { + public SingleTranslationKey Invalid; + public SingleTranslationKey Failed; + public SingleTranslationKey TimedOut; + public SingleTranslationKey TimingOut; + public SingleTranslationKey AuditLog; + } + public softban Softban; + public sealed class softban + { + public SingleTranslationKey Errored; + public SingleTranslationKey Banned; + public SingleTranslationKey Banning; + public SingleTranslationKey AuditLog; + } + public removeTimeout RemoveTimeout; + public sealed class removeTimeout + { + public SingleTranslationKey Failed; + public SingleTranslationKey Removed; + public SingleTranslationKey Removing; + } + public purge Purge; + public sealed class purge + { + public SingleTranslationKey Failed; + public SingleTranslationKey Deleted; + public SingleTranslationKey NoMessages; + public SingleTranslationKey Fetched; + public SingleTranslationKey Fetching; + } + public move Move; + public sealed class move + { + public SingleTranslationKey Moved; + public SingleTranslationKey Moving; + public SingleTranslationKey VcEmpty; + public SingleTranslationKey NotAVc; + } + public manualBump ManualBump; + public sealed class manualBump + { + public SingleTranslationKey Warning; + public SingleTranslationKey NotSetUp; + } + public kick Kick; + public sealed class kick + { + public SingleTranslationKey Errored; + public SingleTranslationKey Kicked; + public SingleTranslationKey Kicking; + public SingleTranslationKey AuditLog; + } + public guildPurge GuildPurge; + public sealed class guildPurge + { + public SingleTranslationKey Ended; + public SingleTranslationKey Deleting; + public SingleTranslationKey Scanning; + } + public followUpdates FollowUpdates; + public sealed class followUpdates + { + public SingleTranslationKey Failed; + public SingleTranslationKey Followed; + } + public customEmbed CustomEmbed; + public sealed class customEmbed + { + public SingleTranslationKey NoValidChannels; + public SingleTranslationKey InlineField; + public SingleTranslationKey ModifyingField; + public SingleTranslationKey TextField; + public SingleTranslationKey ModifyingFooterText; + public SingleTranslationKey SetTextButton; + public SingleTranslationKey ColorField; + public SingleTranslationKey ModifyingColor; + public SingleTranslationKey DescriptionField; + public SingleTranslationKey ModifyingDescription; + public SingleTranslationKey UserIdField; + public SingleTranslationKey ModifyingAuthorbyUserId; + public SingleTranslationKey ModifyingAuthorUrl; + public SingleTranslationKey NameField; + public SingleTranslationKey ModifyingAuthorName; + public SingleTranslationKey SetAsServer; + public SingleTranslationKey SetAsUserButton; + public SingleTranslationKey SetIconButton; + public SingleTranslationKey SetUrlButton; + public SingleTranslationKey SetNameButton; + public SingleTranslationKey UrlField; + public SingleTranslationKey TitleField; + public SingleTranslationKey ModifyingTitle; + public SingleTranslationKey ImportSizeError; + public SingleTranslationKey ImportingUpload; + public SingleTranslationKey UploadImage; + public SingleTranslationKey ContinueTimer; + public SingleTranslationKey SendEmbedButton; + public SingleTranslationKey RemoveFieldButton; + public SingleTranslationKey ModifyFieldButton; + public SingleTranslationKey AddFieldButton; + public SingleTranslationKey SetFooterButton; + public SingleTranslationKey SetTimestampButton; + public SingleTranslationKey SetColorButton; + public SingleTranslationKey SetImageButton; + public SingleTranslationKey SetDescriptionButton; + public SingleTranslationKey SetThumbnailButton; + public SingleTranslationKey SetAuthorButton; + public SingleTranslationKey SetTitleButton; + public SingleTranslationKey New; + public SingleTranslationKey UploadNotice; + } + public clearBackup ClearBackup; + public sealed class clearBackup + { + public SingleTranslationKey Deleted; + public SingleTranslationKey IsOnServer; + } + public ban Ban; + public sealed class ban + { + public SingleTranslationKey Errored; + public SingleTranslationKey Banned; + public SingleTranslationKey Banning; + public SingleTranslationKey AuditLog; + } + public SingleTranslationKey NoReason; + } + public utility Utility; + public sealed class utility + { + public voiceChannelCreator VoiceChannelCreator; + public sealed class voiceChannelCreator + { + public unban Unban; + public sealed class unban + { + public SingleTranslationKey VictimUnbanned; + public SingleTranslationKey VictimNotBanned; + } + public open Open; + public sealed class open + { + public SingleTranslationKey Success; + } + public name Name; + public sealed class name + { + public SingleTranslationKey Success; + public SingleTranslationKey Cooldown; + } + public limit Limit; + public sealed class limit + { + public SingleTranslationKey Success; + public SingleTranslationKey OutsideRange; + } + public kick Kick; + public sealed class kick + { + public SingleTranslationKey Success; + public SingleTranslationKey CannotKickSelf; + } + public invite Invite; + public sealed class invite + { + public SingleTranslationKey VictimMessage; + public SingleTranslationKey Success; + public SingleTranslationKey PartialSuccess; + public SingleTranslationKey AlreadyPresent; + public SingleTranslationKey CannotInviteSelf; + } + public close Close; + public sealed class close + { + public SingleTranslationKey Success; + } + public changeOwner ChangeOwner; + public sealed class changeOwner + { + public SingleTranslationKey Success; + public SingleTranslationKey ForceAssign; + public SingleTranslationKey AlreadyOwner; + } + public ban Ban; + public sealed class ban + { + public SingleTranslationKey VictimBanned; + public SingleTranslationKey VictimAlreadyBanned; + public SingleTranslationKey CannotBanSelf; + } + public events Events; + public sealed class events + { + public SingleTranslationKey DefaultChannelName; + } + public SingleTranslationKey VictimIsBot; + public SingleTranslationKey VictimNotPresent; + public SingleTranslationKey NotAVccChannelOwner; + public SingleTranslationKey NotAVccChannel; + } + public data Data; + public sealed class data + { + public @object Object; + public sealed class @object + { + public SingleTranslationKey ProfileDeletionScheduled; + public SingleTranslationKey SecondaryConfirm; + public MultiTranslationKey ObjectionDisclaimer; + public SingleTranslationKey DeletionScheduleReversed; + public SingleTranslationKey DeletionAlreadyScheduled; + public SingleTranslationKey EnablingDataProcessingSuccess; + public SingleTranslationKey EnablingDataProcessingError; + public SingleTranslationKey EnablingDataProcessing; + public SingleTranslationKey ProfileAlreadyDeleted; + } + public policy Policy; + public sealed class policy + { + public SingleTranslationKey NoPolicy; + } + public request Request; + public sealed class request + { + public SingleTranslationKey DmNotice; + public SingleTranslationKey Confirm; + public SingleTranslationKey Fetching; + public SingleTranslationKey TimeError; + } + } + public userInfo UserInfo; + public sealed class userInfo + { + public SingleTranslationKey FetchUserError; + public SingleTranslationKey TimedOutUntil; + public SingleTranslationKey Status; + public SingleTranslationKey Competing; + public SingleTranslationKey Watching; + public SingleTranslationKey ListeningTo; + public SingleTranslationKey Streaming; + public SingleTranslationKey Playing; + public SingleTranslationKey Activities; + public SingleTranslationKey DoNotDisturb; + public SingleTranslationKey Idle; + public SingleTranslationKey Offline; + public SingleTranslationKey Online; + public SingleTranslationKey Web; + public SingleTranslationKey Mobile; + public SingleTranslationKey Desktop; + public SingleTranslationKey Presence; + public SingleTranslationKey BannerColor; + public SingleTranslationKey Pronouns; + public SingleTranslationKey ServerBoosterSince; + public SingleTranslationKey AccountCreationDate; + public SingleTranslationKey FirstJoinDate; + public SingleTranslationKey ServerLeaveDate; + public SingleTranslationKey ServerJoinDate; + public SingleTranslationKey ShowProfileInviter; + public SingleTranslationKey UsersInvited; + public SingleTranslationKey NoInviter; + public SingleTranslationKey InvitedBy; + public SingleTranslationKey BanDetails; + public SingleTranslationKey GlobalBanDate; + public SingleTranslationKey GlobalBanMod; + public SingleTranslationKey GlobalBanReason; + public SingleTranslationKey NoReason; + public SingleTranslationKey BotNotes; + public SingleTranslationKey Backup; + public SingleTranslationKey Roles; + public SingleTranslationKey PendingMembership; + public SingleTranslationKey DiscordPartner; + public SingleTranslationKey VerifiedBotDeveloper; + public SingleTranslationKey CertifiedMod; + public SingleTranslationKey DiscordStaff; + public SingleTranslationKey Owner; + public SingleTranslationKey BotStaff; + public SingleTranslationKey BotOwner; + public SingleTranslationKey GlobalBanned; + public SingleTranslationKey JoinedBefore; + public SingleTranslationKey IsBanned; + public SingleTranslationKey NoStoredRoles; + public SingleTranslationKey NoRoles; + public SingleTranslationKey NeverJoined; + public SingleTranslationKey Bot; + public SingleTranslationKey System; + } + public urbanDictionary UrbanDictionary; + public sealed class urbanDictionary + { + public SingleTranslationKey Example; + public SingleTranslationKey Definition; + public SingleTranslationKey WrittenBy; + public SingleTranslationKey NotExist; + public SingleTranslationKey LookupFail; + public SingleTranslationKey LookingUp; + public SingleTranslationKey AdultContentWarning; + public SingleTranslationKey AdultContentError; + } + public upload Upload; + public sealed class upload + { + public SingleTranslationKey Uploaded; + public SingleTranslationKey TimedOut; + public SingleTranslationKey AlreadyUploaded; + public SingleTranslationKey NoInteraction; + } + public reportTranslation ReportTranslation; + public sealed class reportTranslation + { + public SingleTranslationKey ReportSubmitted; + public SingleTranslationKey RatelimitReached; + public SingleTranslationKey ConfirmationPrompt; + public SingleTranslationKey TosChangedNotice; + public MultiTranslationKey Tos; + public SingleTranslationKey AcceptTos; + public SingleTranslationKey Title; + } + public reportHost ReportHost; + public sealed class reportHost + { + public SingleTranslationKey SubmissionCreated; + public SingleTranslationKey CreatingSubmission; + public SingleTranslationKey SubmissionError; + public SingleTranslationKey SubmissionCheck; + public SingleTranslationKey DatabaseError; + public SingleTranslationKey DatabaseCheck; + public SingleTranslationKey ConfirmHost; + public SingleTranslationKey InvalidHost; + public SingleTranslationKey LimitError; + public SingleTranslationKey CooldownError; + public SingleTranslationKey Processing; + public SingleTranslationKey TosChangedNotice; + public MultiTranslationKey Tos; + public SingleTranslationKey AcceptTos; + public SingleTranslationKey Title; + } + public reminders Reminders; + public sealed class reminders + { + public SingleTranslationKey ReminderNotification; + public SingleTranslationKey SentLate; + public SingleTranslationKey DateTime; + public SingleTranslationKey Description; + public SingleTranslationKey SetDateTime; + public SingleTranslationKey SetDescription; + public SingleTranslationKey InvalidDateTime; + public SingleTranslationKey Notice; + public SingleTranslationKey DueTime; + public SingleTranslationKey CreatedAt; + public SingleTranslationKey CreatedOn; + public SingleTranslationKey Count; + public SingleTranslationKey DeleteReminder; + public SingleTranslationKey Snooze; + public SingleTranslationKey NewReminder; + public SingleTranslationKey Title; + } + public rank Rank; + public sealed class rank + { + public SingleTranslationKey Progress; + public SingleTranslationKey Other; + public SingleTranslationKey Self; + public SingleTranslationKey Title; + } + public leaderboard Leaderboard; + public sealed class leaderboard + { + public SingleTranslationKey NoPoints; + public SingleTranslationKey Placement; + public SingleTranslationKey Level; + public SingleTranslationKey Fetching; + public SingleTranslationKey Disabled; + public SingleTranslationKey Title; + } + public guildInfo GuildInfo; + public sealed class guildInfo + { + public SingleTranslationKey HomeHeader; + public SingleTranslationKey DiscoverySplash; + public SingleTranslationKey Splash; + public SingleTranslationKey Banner; + public SingleTranslationKey NoGuildFound; + public SingleTranslationKey Mee6Notice; + public SingleTranslationKey GuildWidgetNotice; + public SingleTranslationKey GuildPreviewNotice; + public SingleTranslationKey JoinServer; + public SingleTranslationKey GuildFeatures; + public SingleTranslationKey SystemMessagesSetupTips; + public SingleTranslationKey SystemMessagesRoleSticker; + public SingleTranslationKey SystemMessagesRole; + public SingleTranslationKey SystemMessagesBoost; + public SingleTranslationKey SystemMessagesWelcomeStickers; + public SingleTranslationKey SystemMessagesWelcome; + public SingleTranslationKey SystemMessages; + public SingleTranslationKey InactiveTimeout; + public SingleTranslationKey InactiveChannel; + public SingleTranslationKey CommunityUpdates; + public SingleTranslationKey Rules; + public SingleTranslationKey SpecialChannels; + public SingleTranslationKey DefaultNotificationsMentions; + public SingleTranslationKey DefaultNotificationsAll; + public SingleTranslationKey DefaultNotifications; + public SingleTranslationKey NsfwQuestionable; + public SingleTranslationKey NsfwSafe; + public SingleTranslationKey NsfwExplicit; + public SingleTranslationKey NsfwNoRating; + public SingleTranslationKey Nsfw; + public SingleTranslationKey ExplicitContentEveryone; + public SingleTranslationKey ExplicitContentNoRoles; + public SingleTranslationKey ExplicitContentNone; + public SingleTranslationKey ExplicitContent; + public SingleTranslationKey VerificationHighest; + public SingleTranslationKey VerificationHigh; + public SingleTranslationKey VerificationMedium; + public SingleTranslationKey VerificationLow; + public SingleTranslationKey VerificationNone; + public SingleTranslationKey Verification; + public SingleTranslationKey WelcomeScreen; + public SingleTranslationKey Screening; + public SingleTranslationKey MultiFactor; + public SingleTranslationKey Security; + public SingleTranslationKey Community; + public SingleTranslationKey Widget; + public SingleTranslationKey BoostsTierThree; + public SingleTranslationKey BoostsTierTwo; + public SingleTranslationKey BoostsTierOne; + public SingleTranslationKey BoostsNone; + public SingleTranslationKey Boosts; + public SingleTranslationKey Locale; + public SingleTranslationKey Creation; + public SingleTranslationKey Owner; + public SingleTranslationKey GuildTitle; + public SingleTranslationKey MaxMembers; + public SingleTranslationKey OnlineMembers; + public SingleTranslationKey MemberTitle; + public SingleTranslationKey Fetching; + } + public emojiStealer EmojiStealer; + public sealed class emojiStealer + { + public SingleTranslationKey SuccessChat; + public SingleTranslationKey SendingZipChat; + public SingleTranslationKey SendingZipDm; + public SingleTranslationKey PreparingZip; + public SingleTranslationKey SuccessDmMain; + public SingleTranslationKey SuccessDm; + public SingleTranslationKey SendingDm; + public SingleTranslationKey SuccessAdded; + public SingleTranslationKey NoMoreRoom; + public SingleTranslationKey AddToServerLoadingNotice; + public SingleTranslationKey AddStickersToServerLoading; + public SingleTranslationKey AddEmojisToServerLoading; + public SingleTranslationKey CurrentChatZip; + public SingleTranslationKey DirectMessageSingle; + public SingleTranslationKey DirectMessageZip; + public SingleTranslationKey AddEmojisAndStickerToServer; + public SingleTranslationKey AddEmojisToServer; + public SingleTranslationKey ToggleStickers; + public SingleTranslationKey ReceivePrompt; + public SingleTranslationKey NoSuccessfulDownload; + public SingleTranslationKey DownloadingStickers; + public SingleTranslationKey DownloadingEmojis; + public SingleTranslationKey NoEmojis; + public SingleTranslationKey DownloadingPre; + public SingleTranslationKey Sticker; + public SingleTranslationKey Emoji; + } + public credits Credits; + public sealed class credits + { + public MultiTranslationKey Credits; + public SingleTranslationKey Fetching; + } + public banner Banner; + public sealed class banner + { + public SingleTranslationKey NoBanner; + public SingleTranslationKey Banner; + } + public avatar Avatar; + public sealed class avatar + { + public SingleTranslationKey ShowUserProfile; + public SingleTranslationKey ShowServerProfile; + public SingleTranslationKey Avatar; + } + public language Language; + public sealed class language + { + public SingleTranslationKey Selector; + public SingleTranslationKey DisableOverride; + public SingleTranslationKey Response; + public SingleTranslationKey Disclaimer; + } + public help Help; + public sealed class help + { + public SingleTranslationKey MissingCommand; + public SingleTranslationKey Disclaimer; + public SingleTranslationKey Module; + } + } + public moduleNames ModuleNames; + public sealed class moduleNames + { + public SingleTranslationKey Unknown; + public SingleTranslationKey Configuration; + public SingleTranslationKey Moderation; + public SingleTranslationKey Music; + public SingleTranslationKey Social; + public SingleTranslationKey Utility; + } + public common Common; + public sealed class common + { + public SingleTranslationKey DirectMessageRedirect; + public SingleTranslationKey InteractionFinished; + public SingleTranslationKey InteractionTimeout; + public SingleTranslationKey UsedByFooter; + public cooldown Cooldown; + public sealed class cooldown + { + public SingleTranslationKey WaitingForCooldown; + public SingleTranslationKey CancelCommand; + public SingleTranslationKey SlowDown; + } + public prompts Prompts; + public sealed class prompts + { + public SingleTranslationKey DateTimeYear; + public SingleTranslationKey DateTimeMonth; + public SingleTranslationKey DateTimeDay; + public SingleTranslationKey DateTimeHour; + public SingleTranslationKey DateTimeMinute; + public SingleTranslationKey SelectTimezone; + public SingleTranslationKey SelectTimezonePrompt; + public SingleTranslationKey ManuallyDefineDateTime; + public SingleTranslationKey SelectADateTime; + public SingleTranslationKey CurrentDateTime; + public SingleTranslationKey ManuallyDefineTimespan; + public SingleTranslationKey CurrentTimespan; + public SingleTranslationKey TimespanDays; + public SingleTranslationKey TimespanHours; + public SingleTranslationKey TimespanMinutes; + public SingleTranslationKey TimespanSeconds; + public SingleTranslationKey SelectATimeSpan; + public SingleTranslationKey WaitingForModalResponse; + public SingleTranslationKey ReOpenModal; + public SingleTranslationKey SelectAnOption; + public SingleTranslationKey SelectAChannel; + public SingleTranslationKey CreateChannelForMe; + public SingleTranslationKey SelectedRoleUnavailable; + public SingleTranslationKey SelectEveryone; + public SingleTranslationKey SelectARole; + public SingleTranslationKey CreateRoleForMe; + public SingleTranslationKey Disable; + public SingleTranslationKey ConfirmSelection; + } + public errors Errors; + public sealed class errors + { + public MultiTranslationKey UnhandledException; + public SingleTranslationKey CommandDisabled; + public SingleTranslationKey NoChannels; + public SingleTranslationKey NoRoles; + public SingleTranslationKey UploadInProgress; + public SingleTranslationKey DirectMessage; + public SingleTranslationKey BotPermissions; + public SingleTranslationKey Data; + public SingleTranslationKey ExclusiveApp; + public SingleTranslationKey ExclusivePrefix; + public SingleTranslationKey GuildBan; + public SingleTranslationKey UserBan; + public SingleTranslationKey VoiceChannel; + public SingleTranslationKey BotOwner; + public SingleTranslationKey NoMember; + public SingleTranslationKey Generic; + } + } + } + public common Common; + public sealed class common + { + public SingleTranslationKey JumpToMessage; + public SingleTranslationKey Reason; + public SingleTranslationKey NotSelected; + public SingleTranslationKey Refresh; + public SingleTranslationKey NextPage; + public SingleTranslationKey PreviousPage; + public SingleTranslationKey Page; + public SingleTranslationKey Back; + public SingleTranslationKey Cancel; + public SingleTranslationKey Submit; + public SingleTranslationKey Deny; + public SingleTranslationKey Confirm; + public SingleTranslationKey Off; + public SingleTranslationKey On; + public SingleTranslationKey No; + public SingleTranslationKey Yes; + public SingleTranslationKey MissingTranslation; + public time Time; + public sealed class time + { + public SingleTranslationKey Second; + public SingleTranslationKey Seconds; + public SingleTranslationKey Minute; + public SingleTranslationKey Minutes; + public SingleTranslationKey Hour; + public SingleTranslationKey Hours; + public SingleTranslationKey Day; + public SingleTranslationKey Days; + public SingleTranslationKey Month; + public SingleTranslationKey Months; + public SingleTranslationKey Year; + public SingleTranslationKey Years; + } + public permissions Permissions; + public sealed class permissions + { + public SingleTranslationKey SendVoiceMessages; + public SingleTranslationKey UseExternalSounds; + public SingleTranslationKey CreateEvents; + public SingleTranslationKey CreateGuildExpressions; + public SingleTranslationKey UseSoundboard; + public SingleTranslationKey ViewCreatorMonetizationInsights; + public SingleTranslationKey ModerateMembers; + public SingleTranslationKey StartEmbeddedActivities; + public SingleTranslationKey SendMessagesInThreads; + public SingleTranslationKey UseExternalStickers; + public SingleTranslationKey CreatePrivateThreads; + public SingleTranslationKey CreatePublicThreads; + public SingleTranslationKey ManageThreads; + public SingleTranslationKey ManageEvents; + public SingleTranslationKey RequestToSpeak; + public SingleTranslationKey UseApplicationCommands; + public SingleTranslationKey ManageGuildExpressions; + public SingleTranslationKey ManageWebhooks; + public SingleTranslationKey ManageRoles; + public SingleTranslationKey ManageNicknames; + public SingleTranslationKey ChangeNickname; + public SingleTranslationKey UseVoiceDetection; + public SingleTranslationKey MoveMembers; + public SingleTranslationKey DeafenMembers; + public SingleTranslationKey MuteMembers; + public SingleTranslationKey Speak; + public SingleTranslationKey UseVoice; + public SingleTranslationKey ViewGuildInsights; + public SingleTranslationKey UseExternalEmojis; + public SingleTranslationKey MentionEveryone; + public SingleTranslationKey ReadMessageHistory; + public SingleTranslationKey AttachFiles; + public SingleTranslationKey EmbedLinks; + public SingleTranslationKey ManageMessages; + public SingleTranslationKey SendTtsMessages; + public SingleTranslationKey SendMessages; + public SingleTranslationKey AccessChannels; + public SingleTranslationKey Stream; + public SingleTranslationKey PrioritySpeaker; + public SingleTranslationKey ViewAuditLog; + public SingleTranslationKey AddReactions; + public SingleTranslationKey ManageGuild; + public SingleTranslationKey ManageChannels; + public SingleTranslationKey Administrator; + public SingleTranslationKey BanMembers; + public SingleTranslationKey KickMembers; + public SingleTranslationKey CreateInstantInvite; + public SingleTranslationKey All; + public SingleTranslationKey None; + } + } + #endregion AutoGenerated +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/User.cs b/ProjectMakoto/Entities/User.cs new file mode 100644 index 00000000..e17bc1da --- /dev/null +++ b/ProjectMakoto/Entities/User.cs @@ -0,0 +1,103 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using ProjectMakoto.Entities.Users; +using ProjectMakoto.Entities.Users.Legacy; + +namespace ProjectMakoto.Entities; + +[TableName("users")] +public sealed class User : RequiresBotReference +{ + public User(Bot bot, ulong userId) : base(bot) + { + if (bot.objectedUsers.Contains(userId)) + throw new InvalidOperationException($"User {userId} has objected to having their data processed."); + + this.Id = userId; + + _ = this.Bot.DatabaseClient.CreateRow("users", typeof(User), userId, this.Bot.DatabaseClient.mainDatabaseConnection); + + this.Cooldown = new(bot, this); + + this.UrlSubmissions = new(bot, this); + this.ExperienceUser = new(bot, this); + this.Reminders = new(bot, this); + this.TranslationReports = new(bot, this); + this.Data = new(bot, this); + } + + [ColumnName("userid"), ColumnType(ColumnTypes.BigInt), Primary] + internal ulong Id { get; init; } + + [ContainsValues] + public UrlSubmissionSettings UrlSubmissions { get; init; } + + [ContainsValues] + public ExperienceUserSettings ExperienceUser { get; init; } + + [ContainsValues] + public ReminderSettings Reminders { get; init; } + + [ContainsValues] + public TranslationReportSettings TranslationReports { get; init; } + + [ContainsValues] + public DataSettings Data { get; init; } + + [ColumnName("current_locale"), ColumnType(ColumnTypes.Text), Nullable] + public string? CurrentLocale + { + get => this.Bot.DatabaseClient.GetValue("users", "userid", this.Id, "current_locale", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("users", "userid", this.Id, "current_locale", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("override_locale"), ColumnType(ColumnTypes.Text), Nullable] + public string? OverrideLocale + { + get => this.Bot.DatabaseClient.GetValue("users", "userid", this.Id, "override_locale", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("users", "userid", this.Id, "override_locale", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("timezone"), ColumnType(ColumnTypes.Text), Nullable] + public string? Timezone + { + get => this.Bot.DatabaseClient.GetValue("users", "userid", this.Id, "timezone", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("users", "userid", this.Id, "timezone", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [JsonIgnore] + public string? Locale + => this.OverrideLocale ?? this.CurrentLocale ?? "en"; + + [JsonIgnore] + public Cooldown Cooldown { get; init; } + + [JsonIgnore] + public DateTime LastSuccessful2FA { get; set; } = DateTime.MinValue; + + [JsonIgnore] + public UserUpload? PendingUserUpload { get; set; } = null; + + #region Legacy + [ColumnName("playlists"), ColumnType(ColumnTypes.LongText), Default("[]")] + public UserPlaylist[] LegacyUserPlaylists + { + get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("users", "userid", this.Id, "playlists", this.Bot.DatabaseClient.mainDatabaseConnection)); + set => this.Bot.DatabaseClient.SetValue("users", "userid", this.Id, "playlists", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("blocked_users"), ColumnType(ColumnTypes.LongText), Default("[]")] + public ulong[] LegacyBlockedUsers + { + get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("users", "userid", this.Id, "blocked_users", this.Bot.DatabaseClient.mainDatabaseConnection)); + set => this.Bot.DatabaseClient.SetValue("users", "userid", this.Id, "blocked_users", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); + } + #endregion +} diff --git a/ProjectMakoto/Entities/Users/Cooldown.cs b/ProjectMakoto/Entities/Users/Cooldown.cs new file mode 100644 index 00000000..8fdd8cb3 --- /dev/null +++ b/ProjectMakoto/Entities/Users/Cooldown.cs @@ -0,0 +1,121 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Users; + +public sealed class Cooldown(Bot bot, User parent) : RequiresParent(bot, parent) +{ + private Dictionary LastUseByCommand = new(); + private List WaitingList = new(); + + internal async Task Wait(SharedCommandContext ctx, int CooldownTime, bool IgnoreStaff) + { + if (this.Bot.status.TeamMembers.Contains(ctx.User.Id) && !IgnoreStaff) + return false; + + bool alreadyWaiting; + lock (this.WaitingList) + { + alreadyWaiting = this.WaitingList.Contains(ctx.CommandName); + } + if (alreadyWaiting) + { + var stop_warn = await ctx.BaseCommand.RespondOrEdit(new DiscordMessageBuilder().WithContent($"{ctx.User.Mention} 🛑 `{ctx.BaseCommand.GetString(ctx.t.Commands.Common.Cooldown.SlowDown)}`")); + await Task.Delay(3000); + ctx.BaseCommand.DeleteOrInvalidate(); + return true; + } + + lock (this.LastUseByCommand) + { + _ = this.LastUseByCommand.TryAdd(ctx.CommandName, DateTime.MinValue); + if (this.LastUseByCommand[ctx.CommandName].ToUniversalTime().AddSeconds(CooldownTime).GetTotalSecondsUntil() <= 0) + { + this.LastUseByCommand[ctx.CommandName] = DateTime.UtcNow.ToUniversalTime(); + return false; + } + } + + var cancelButton = new DiscordButtonComponent(ButtonStyle.Danger, Guid.NewGuid().ToString(), ctx.BaseCommand.GetString(ctx.t.Commands.Common.Cooldown.CancelCommand), false, EmojiTemplates.GetError(ctx.Bot).ToComponent()); + var cancellationTokenSource = new CancellationTokenSource(); + var Cancelled = false; + + var msg = await ctx.BaseCommand.RespondOrEdit(new DiscordMessageBuilder() + .WithContent($"{ctx.User.Mention} ⏳ {ctx.BaseCommand.GetString(ctx.t.Commands.Common.Cooldown.WaitingForCooldown, true, new TVar("Timestamp", this.LastUseByCommand[ctx.CommandName].ToUniversalTime().AddSeconds(CooldownTime).ToTimestamp()))}") + .AddComponents(cancelButton)); + + _ = Task.Run(async () => + { + var result = await msg.WaitForButtonAsync(ctx.User); + + if (result.TimedOut || result.Result.GetCustomId() != cancelButton.CustomId) + return; + + Cancelled = true; + + await result.Result.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + ctx.BaseCommand.DeleteOrInvalidate(); + cancellationTokenSource.Cancel(); + }).Add(ctx.Bot); + + double milliseconds; + lock (this.LastUseByCommand) + { + milliseconds = this.LastUseByCommand[ctx.CommandName].ToUniversalTime().AddSeconds(CooldownTime).GetTimespanUntil().TotalMilliseconds; + } + if (milliseconds <= 0) + milliseconds = 500; + + lock (this.WaitingList) + { + this.WaitingList.Add(ctx.CommandName); + } + try + { + await Task.Delay(Convert.ToInt32(Math.Round(milliseconds, 0)), cancellationTokenSource.Token); + } + catch { } + finally + { + lock (this.WaitingList) + { + _ = this.WaitingList.Remove(ctx.CommandName); + } + } + + try + { + _ = await ctx.BaseCommand.RespondOrEdit(new DiscordMessageBuilder() + .WithContent($"{ctx.User.Mention} ⏳ {ctx.BaseCommand.GetString(ctx.t.Commands.Common.Cooldown.WaitingForCooldown, true, new TVar("Timestamp", DateTime.UtcNow.ToTimestamp()))}") + .AddComponents(cancelButton.Disable())); + } + catch { } + + if (Cancelled) + return true; + + if (ctx.CommandType == Enums.CommandType.Custom) + ctx.BaseCommand.DeleteOrInvalidate(); + + lock (this.LastUseByCommand) + { + this.LastUseByCommand[ctx.CommandName] = DateTime.UtcNow.ToUniversalTime(); + } + return false; + } + + public Task WaitForLight(SharedCommandContext ctx, bool IgnoreStaff = false) + => this.Wait(ctx, 1, IgnoreStaff); + + public Task WaitForModerate(SharedCommandContext ctx, bool IgnoreStaff = false) + => this.Wait(ctx, 6, IgnoreStaff); + + public Task WaitForHeavy(SharedCommandContext ctx, bool IgnoreStaff = false) + => this.Wait(ctx, 20, IgnoreStaff); +} diff --git a/ProjectMakoto/Entities/Users/DataSettings.cs b/ProjectMakoto/Entities/Users/DataSettings.cs new file mode 100644 index 00000000..71163172 --- /dev/null +++ b/ProjectMakoto/Entities/Users/DataSettings.cs @@ -0,0 +1,33 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Users; +public sealed class DataSettings(Bot bot, User parent) : RequiresParent(bot, parent) +{ + [ColumnName("last_data_request"), ColumnType(ColumnTypes.BigInt), Default("0")] + public DateTime LastDataRequest + { + get => this.Bot.DatabaseClient.GetValue("users", "userid", this.Parent.Id, "last_data_request", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("users", "userid", this.Parent.Id, "last_data_request", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("deletion_requested"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool DeletionRequested + { + get => this.Bot.DatabaseClient.GetValue("users", "userid", this.Parent.Id, "deletion_requested", this.Bot.DatabaseClient.mainDatabaseConnection); + set => this.Bot.DatabaseClient.SetValue("users", "userid", this.Parent.Id, "deletion_requested", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("data_deletion_date"), ColumnType(ColumnTypes.BigInt), Default("0")] + public DateTime DeletionRequestDate + { + get => this.Bot.DatabaseClient.GetValue("users", "userid", this.Parent.Id, "data_deletion_date", this.Bot.DatabaseClient.mainDatabaseConnection); + set => this.Bot.DatabaseClient.SetValue("users", "userid", this.Parent.Id, "data_deletion_date", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } +} diff --git a/ProjectMakoto/Entities/Users/ExperienceUserSettings.cs b/ProjectMakoto/Entities/Users/ExperienceUserSettings.cs new file mode 100644 index 00000000..3497fdac --- /dev/null +++ b/ProjectMakoto/Entities/Users/ExperienceUserSettings.cs @@ -0,0 +1,20 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Users; + +public sealed class ExperienceUserSettings(Bot bot, User parent) : RequiresParent(bot, parent) +{ + [ColumnName("experience_directmessageoptout"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public bool DirectMessageOptOut + { + get => this.Bot.DatabaseClient.GetValue("users", "userid", this.Parent.Id, "experience_directmessageoptout", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("users", "userid", this.Parent.Id, "experience_directmessageoptout", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } +} diff --git a/ProjectMakoto/Entities/Users/Reminders/ReminderItem.cs b/ProjectMakoto/Entities/Users/Reminders/ReminderItem.cs new file mode 100644 index 00000000..aed1c316 --- /dev/null +++ b/ProjectMakoto/Entities/Users/Reminders/ReminderItem.cs @@ -0,0 +1,34 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Users; + +public sealed class ReminderItem +{ + public string UUID { get; set; } = Guid.NewGuid().ToString(); + + public string CreationPlace { get; set; } + + private string _Description { get; set; } + public string Description + { + get => this._Description; + set + { + if (value.Length > 512) + throw new ArgumentException("The description cannot be longer than 512 characters."); + + this._Description = value; + } + } + + public DateTime DueTime { get; set; } + + public DateTime CreationTime { get; set; } = DateTime.UtcNow; +} diff --git a/ProjectMakoto/Entities/Users/Reminders/ReminderSettings.cs b/ProjectMakoto/Entities/Users/Reminders/ReminderSettings.cs new file mode 100644 index 00000000..54a1f8a3 --- /dev/null +++ b/ProjectMakoto/Entities/Users/Reminders/ReminderSettings.cs @@ -0,0 +1,87 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Users; + +public sealed class ReminderSettings : RequiresParent +{ + public ReminderSettings(Bot bot, User parent) : base(bot, parent) + { + this.RemindersUpdated(); + } + + [ColumnName("reminders"), ColumnType(ColumnTypes.LongText), Default("[]")] + public ReminderItem[] ScheduledReminders + { + get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("users", "userid", this.Parent.Id, "reminders", this.Bot.DatabaseClient.mainDatabaseConnection)); + set + { + _ = this.Bot.DatabaseClient.SetValue("users", "userid", this.Parent.Id, "reminders", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); + this.RemindersUpdated(); + } + } + + private void RemindersUpdated() + { + _ = Task.Run(async () => + { + while (!this.Bot.status.DiscordGuildDownloadCompleted) + await Task.Delay(1000); + + if (this.ScheduledReminders.Length > 10) + this.ScheduledReminders = this.ScheduledReminders.Take(10).ToArray(); + + foreach (var b in this.ScheduledReminders.ToList()) + if (!ScheduledTaskExtensions.GetScheduledTasks().ContainsTask("reminder", this.Parent.Id, b.UUID)) + { + Func task = new(async () => + { + var CommandKey = this.Bot.LoadedTranslations.Commands.Utility.Reminders; + + this.ScheduledReminders = this.ScheduledReminders.Remove(x => x.ToString(), b); + + var user = await this.Bot.DiscordClient.GetFirstShard().GetUserAsync(this.Parent.Id); + + var builder = new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder() + .WithDescription($"> {b.Description.FullSanitize()}\n" + + $"{CommandKey.CreatedOn.Get(this.Bot.Users[user.Id]).Build(new TVar("Guild", b.CreationPlace))}\n" + + $"{CommandKey.CreatedAt.Get(this.Bot.Users[user.Id]).Build(new TVar("Timestamp", $"{b.CreationTime.ToTimestamp()} ({b.CreationTime.ToTimestamp(TimestampFormat.LongDateTime)})"))}\n" + + $"{CommandKey.DueTime.Get(this.Bot.Users[user.Id]).Build(new TVar("Relative", b.DueTime.ToTimestamp()), new TVar("DateTime", b.DueTime.ToTimestamp(TimestampFormat.LongDateTime)))}\n" + + $"{(b.DueTime.GetTimespanSince() > TimeSpan.FromMinutes(2) ? $"\n\n**{CommandKey.SentLate.Get(this.Bot.Users[user.Id])}**" : "")}") + .WithTitle(CommandKey.ReminderNotification.Get(this.Bot.Users[user.Id])) + .WithColor(EmbedColors.Info)); + + var maxLength = 100 - JsonConvert.SerializeObject(new ReminderSnoozeButton(), new JsonSerializerSettings() { NullValueHandling = NullValueHandling.Include }).Length; + DiscordButtonComponent snoozeButton = new(ButtonStyle.Secondary, JsonConvert.SerializeObject(new ReminderSnoozeButton + { + Description = b.Description.TruncateWithIndication(maxLength) + }), CommandKey.Snooze.Get(this.Bot.Users[user.Id]), false, DiscordEmoji.FromUnicode("💤").ToComponent()); + var msg = await user.SendMessageAsync(builder.AddComponents(snoozeButton)); + }); + + _ = task.CreateScheduledTask(b.DueTime, new ScheduledTaskIdentifier(this.Parent.Id, b.UUID, "reminder")); + + Log.Debug("Created scheduled task for reminder by '{User}'", this.Parent.Id); + } + + foreach (var b in ScheduledTaskExtensions.GetScheduledTasks()) + { + if (b.CustomData is not ScheduledTaskIdentifier scheduledTaskIdentifier) + continue; + + if (scheduledTaskIdentifier.Snowflake == this.Parent.Id && scheduledTaskIdentifier.Type == "reminder" && !this.ScheduledReminders.Any(x => x.UUID == ((ScheduledTaskIdentifier)b.CustomData).Id)) + { + b.Delete(); + + Log.Debug("Deleted scheduled task for reminder by '{User}'", this.Parent.Id); + } + } + }); + } +} diff --git a/ProjectMakoto/Entities/Users/Reminders/ReminderSnoozeButton.cs b/ProjectMakoto/Entities/Users/Reminders/ReminderSnoozeButton.cs new file mode 100644 index 00000000..612e9c1d --- /dev/null +++ b/ProjectMakoto/Entities/Users/Reminders/ReminderSnoozeButton.cs @@ -0,0 +1,19 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Users; + +[JsonConverter(typeof(ReminderSnoozeMinifiedSerializer))] +public sealed class ReminderSnoozeButton +{ + public PrivateButtonType Type + => PrivateButtonType.ReminderSnooze; + + public string Description { get; set; } +} diff --git a/ProjectMakoto/Entities/Users/TranslationReportSettings.cs b/ProjectMakoto/Entities/Users/TranslationReportSettings.cs new file mode 100644 index 00000000..518bde5d --- /dev/null +++ b/ProjectMakoto/Entities/Users/TranslationReportSettings.cs @@ -0,0 +1,33 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Users; +public class TranslationReportSettings(Bot bot, User parent) : RequiresParent(bot, parent) +{ + [ColumnName("translationreport_accepted_tos"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public int AcceptedTOS + { + get => this.Bot.DatabaseClient.GetValue("users", "userid", this.Parent.Id, "translationreport_accepted_tos", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("users", "userid", this.Parent.Id, "translationreport_accepted_tos", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("translationreport_ratelimit_first"), ColumnType(ColumnTypes.BigInt), Default("0")] + public DateTime FirstRequestTime + { + get => this.Bot.DatabaseClient.GetValue("users", "userid", this.Parent.Id, "translationreport_ratelimit_first", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("users", "userid", this.Parent.Id, "translationreport_ratelimit_first", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("translationreport_ratelimit_count"), ColumnType(ColumnTypes.BigInt), Default("0")] + public int RequestCount + { + get => this.Bot.DatabaseClient.GetValue("users", "userid", this.Parent.Id, "translationreport_ratelimit_count", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("users", "userid", this.Parent.Id, "translationreport_ratelimit_count", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } +} diff --git a/ProjectMakoto/Entities/Users/UrlSubmissionSettings.cs b/ProjectMakoto/Entities/Users/UrlSubmissionSettings.cs new file mode 100644 index 00000000..1b2e1f66 --- /dev/null +++ b/ProjectMakoto/Entities/Users/UrlSubmissionSettings.cs @@ -0,0 +1,27 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Users; + +public sealed class UrlSubmissionSettings(Bot bot, User parent) : RequiresParent(bot, parent) +{ + [ColumnName("submission_accepted_tos"), ColumnType(ColumnTypes.TinyInt), Default("0")] + public int AcceptedTOS + { + get => this.Bot.DatabaseClient.GetValue("users", "userid", this.Parent.Id, "submission_accepted_tos", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("users", "userid", this.Parent.Id, "submission_accepted_tos", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } + + [ColumnName("submission_last_datetime"), ColumnType(ColumnTypes.BigInt), Default("0")] + public DateTime LastTime + { + get => this.Bot.DatabaseClient.GetValue("users", "userid", this.Parent.Id, "submission_last_datetime", this.Bot.DatabaseClient.mainDatabaseConnection); + set => _ = this.Bot.DatabaseClient.SetValue("users", "userid", this.Parent.Id, "submission_last_datetime", value, this.Bot.DatabaseClient.mainDatabaseConnection); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/Users/UserPlaylist/PlaylistEntry.cs b/ProjectMakoto/Entities/Users/UserPlaylist/PlaylistEntry.cs new file mode 100644 index 00000000..3ae7bac3 --- /dev/null +++ b/ProjectMakoto/Entities/Users/UserPlaylist/PlaylistEntry.cs @@ -0,0 +1,26 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Users.Legacy; + +public sealed class PlaylistEntry +{ + private string _Title { get; set; } + [JsonProperty(Required = Required.Always)] + public string Title { get => this._Title; set => this._Title = value.TruncateWithIndication(100); } + + private TimeSpan? _Length { get; set; } + public TimeSpan? Length { get => this._Length; set => this._Length = value; } + + private string _Url { get; set; } + [JsonProperty(Required = Required.Always)] + public string Url { get => this._Url; set => this._Url = value.TruncateWithIndication(2048); } + + public DateTime AddedTime { get; set; } = DateTime.UtcNow; +} diff --git a/ProjectMakoto/Entities/Users/UserPlaylist/UserPlaylist.cs b/ProjectMakoto/Entities/Users/UserPlaylist/UserPlaylist.cs new file mode 100644 index 00000000..64315cb6 --- /dev/null +++ b/ProjectMakoto/Entities/Users/UserPlaylist/UserPlaylist.cs @@ -0,0 +1,59 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities.Users.Legacy; + +public sealed class UserPlaylist +{ + public string PlaylistId { get; set; } = Guid.NewGuid().ToString(); + + private string _PlaylistName { get; set; } = ""; + + [JsonProperty(Required = Required.Always)] + public string PlaylistName + { + get => this._PlaylistName; + set + { + this._PlaylistName = value.TruncateWithIndication(256); + } + } + + private string _PlaylistColor { get; set; } = "#FFFFFF"; + public string PlaylistColor + { + get => this._PlaylistColor; + set + { + this._PlaylistColor = value.Truncate(7).IsValidHexColor(); + } + } + + private string _PlaylistThumbnail { get; set; } = ""; + public string PlaylistThumbnail + { + get => this._PlaylistThumbnail; + set + { + this._PlaylistThumbnail = value.Truncate(2048); + } + } + + private PlaylistEntry[] _List = Array.Empty(); + + [JsonProperty(Required = Required.Always)] + public PlaylistEntry[] List + { + get => this._List; + set + { + this._List = value.Take(250).ToArray(); + } + } +} \ No newline at end of file diff --git a/ProjectMakoto/Entities/WebRequestItem.cs b/ProjectMakoto/Entities/WebRequestItem.cs new file mode 100644 index 00000000..97a65030 --- /dev/null +++ b/ProjectMakoto/Entities/WebRequestItem.cs @@ -0,0 +1,22 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Entities; + +public sealed class WebRequestItem +{ + public string Url { get; set; } + + public string Response { get; set; } + + public bool Resolved { get; set; } + public bool Failed { get; set; } + public HttpStatusCode StatusCode { get; set; } + public Exception Exception { get; set; } +} \ No newline at end of file diff --git a/ProjectMakoto/Enums/ColumnTypes.cs b/ProjectMakoto/Enums/ColumnTypes.cs new file mode 100644 index 00000000..510247f4 --- /dev/null +++ b/ProjectMakoto/Enums/ColumnTypes.cs @@ -0,0 +1,21 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Enums; + +public enum ColumnTypes +{ + Unknown, + BigInt, + Int, + TinyInt, + LongText, + Text, + VarChar +} diff --git a/ProjectMakoto/Enums/CommandType.cs b/ProjectMakoto/Enums/CommandType.cs new file mode 100644 index 00000000..3e6820a7 --- /dev/null +++ b/ProjectMakoto/Enums/CommandType.cs @@ -0,0 +1,19 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Enums; + +public enum CommandType +{ + ApplicationCommand, + ContextMenu, + PrefixCommand, + Event, + Custom +} diff --git a/ProjectMakoto/Enums/DatabaseRequestType.cs b/ProjectMakoto/Enums/DatabaseRequestType.cs new file mode 100644 index 00000000..e15f7449 --- /dev/null +++ b/ProjectMakoto/Enums/DatabaseRequestType.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Enums; + +internal enum DatabaseRequestType +{ + Command, + Ping +} diff --git a/ProjectMakoto/Enums/DevCommands.cs b/ProjectMakoto/Enums/DevCommands.cs new file mode 100644 index 00000000..c3912dd4 --- /dev/null +++ b/ProjectMakoto/Enums/DevCommands.cs @@ -0,0 +1,33 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Enums; + +internal enum DevCommands +{ + Info, + Log, + Stop, + BotNick, + Evaluate, + CreateIssue, + Enroll2FA, + Quit2FASession, + Disenroll2FAUser, + ManageCommands, + GlobalBan, + GlobalUnban, + GlobalNotes, + BanUser, + UnbanUser, + BanGuild, + UnbanGuild, + BatchLookup, + RawGuild, +} diff --git a/ProjectMakoto/Enums/EmojiType.cs b/ProjectMakoto/Enums/EmojiType.cs new file mode 100644 index 00000000..97f01888 --- /dev/null +++ b/ProjectMakoto/Enums/EmojiType.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Enums; + +internal enum EmojiType +{ + STICKER, + EMOJI +} \ No newline at end of file diff --git a/ProjectMakoto/Enums/ExitCodes.cs b/ProjectMakoto/Enums/ExitCodes.cs new file mode 100644 index 00000000..24b67fdf --- /dev/null +++ b/ProjectMakoto/Enums/ExitCodes.cs @@ -0,0 +1,22 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Enums; + +internal enum ExitCodes +{ + VitalTaskFailed = 1, + ExitTasksTimeout = 21, + + NoToken = 8, + FailedDiscordLogin = 9, + + FailedDatabaseLoad = 18, + FailedDatabaseLogin = 19, +} \ No newline at end of file diff --git a/ProjectMakoto/Enums/FollowChannel.cs b/ProjectMakoto/Enums/FollowChannel.cs new file mode 100644 index 00000000..e9283208 --- /dev/null +++ b/ProjectMakoto/Enums/FollowChannel.cs @@ -0,0 +1,17 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Enums; + +public enum FollowChannel +{ + GithubUpdates, + GlobalBans, + News +} diff --git a/ProjectMakoto/Enums/PhishingPunishmentType.cs b/ProjectMakoto/Enums/PhishingPunishmentType.cs new file mode 100644 index 00000000..a9365acc --- /dev/null +++ b/ProjectMakoto/Enums/PhishingPunishmentType.cs @@ -0,0 +1,19 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Enums; + +public enum PhishingPunishmentType +{ + Delete, + Timeout, + Kick, + Ban, + SoftBan +} \ No newline at end of file diff --git a/ProjectMakoto/Enums/PrivateButtonType.cs b/ProjectMakoto/Enums/PrivateButtonType.cs new file mode 100644 index 00000000..962c1f4c --- /dev/null +++ b/ProjectMakoto/Enums/PrivateButtonType.cs @@ -0,0 +1,15 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Enums; +public enum PrivateButtonType +{ + None = 0, + ReminderSnooze = 1, +} diff --git a/ProjectMakoto/Enums/PunishmentActions.cs b/ProjectMakoto/Enums/PunishmentActions.cs new file mode 100644 index 00000000..e1d32fad --- /dev/null +++ b/ProjectMakoto/Enums/PunishmentActions.cs @@ -0,0 +1,18 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Enums; + +internal enum PunishmentActions +{ + BAN, + KICK, + MUTE, + WARN +} \ No newline at end of file diff --git a/ProjectMakoto/Enums/QueuePriority.cs b/ProjectMakoto/Enums/QueuePriority.cs new file mode 100644 index 00000000..c7f8b765 --- /dev/null +++ b/ProjectMakoto/Enums/QueuePriority.cs @@ -0,0 +1,18 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Enums; + +internal enum QueuePriority +{ + Critical, + High, + Normal, + Low, +} diff --git a/ProjectMakoto/Enums/ReportTranslationReason.cs b/ProjectMakoto/Enums/ReportTranslationReason.cs new file mode 100644 index 00000000..0fbcc6c0 --- /dev/null +++ b/ProjectMakoto/Enums/ReportTranslationReason.cs @@ -0,0 +1,17 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Enums; +public enum ReportTranslationReason +{ + MissingTranslation = 0, + IncorrectTranslation = 1, + ValuesNotFilledIntoString = 2, + Other = 3, +} diff --git a/ProjectMakoto/Enums/ReportTranslationType.cs b/ProjectMakoto/Enums/ReportTranslationType.cs new file mode 100644 index 00000000..b59076fa --- /dev/null +++ b/ProjectMakoto/Enums/ReportTranslationType.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Enums; +public enum ReportTranslationType +{ + Miscellaneous = 0, + Command = 1, + Event = 2, +} diff --git a/ProjectMakoto/Enums/ScoreSaber/Difficulty.cs b/ProjectMakoto/Enums/ScoreSaber/Difficulty.cs new file mode 100644 index 00000000..f85c284c --- /dev/null +++ b/ProjectMakoto/Enums/ScoreSaber/Difficulty.cs @@ -0,0 +1,19 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Enums.ScoreSaber; + +public enum Difficulty +{ + Easy = 1, + Normal = 3, + Hard = 5, + Expert = 7, + ExpertPlus = 9, +} diff --git a/ProjectMakoto/Enums/ScoreSaber/ScoreType.cs b/ProjectMakoto/Enums/ScoreSaber/ScoreType.cs new file mode 100644 index 00000000..97f629ca --- /dev/null +++ b/ProjectMakoto/Enums/ScoreSaber/ScoreType.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Enums.ScoreSaber; + +public enum ScoreType +{ + Top = 0, + Recent = 1, +} diff --git a/ProjectMakoto/Events/.DiscordEventHandler.cs b/ProjectMakoto/Events/.DiscordEventHandler.cs new file mode 100644 index 00000000..ce27a9c8 --- /dev/null +++ b/ProjectMakoto/Events/.DiscordEventHandler.cs @@ -0,0 +1,409 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Events; + +internal sealed class DiscordEventHandler : RequiresBotReference +{ + private DiscordEventHandler(Bot bot) : base(bot) { } + + Translations.events.genericEvent tKey + => this.Bot.LoadedTranslations.Events.GenericEvent; + + public static void SetupEvents(Bot _bot) + { + DiscordEventHandler handler = new(_bot); + + Log.Debug("Registering DisCatSharp EventHandler.."); + handler.genericGuildEvents = new(_bot); + handler.commandEvents = new(_bot); + handler.crosspostEvents = new(_bot); + handler.phishingProtectionEvents = new(_bot); + handler.submissionEvents = new(_bot); + handler.discordEvents = new(_bot); + handler.actionlogEvents = new(_bot); + handler.joinEvents = new(_bot); + handler.bumpReminderEvents = new(_bot); + handler.experienceEvents = new(_bot); + handler.reactionRoleEvents = new(_bot); + handler.voicePrivacyEvents = new(_bot); + handler.inviteTrackerEvents = new(_bot); + handler.inviteNoteEvents = new(_bot); + handler.autoUnarchiveEvents = new(_bot); + handler.nameNormalizerEvents = new(_bot); + handler.embedMessagesEvents = new(_bot); + handler.tokenLeakEvents = new(_bot); + handler.vcCreatorEvents = new(_bot); + handler.reminderEvents = new(_bot); + + _bot.DiscordClient.GuildCreated += handler.GuildCreated; + _bot.DiscordClient.GuildUpdated += handler.GuildUpdated; + + _bot.DiscordClient.ChannelCreated += handler.ChannelCreated; + _bot.DiscordClient.ChannelDeleted += handler.ChannelDeleted; + _bot.DiscordClient.ChannelUpdated += handler.ChannelUpdated; + + _bot.DiscordClient.GuildMemberAdded += handler.GuildMemberAdded; + _bot.DiscordClient.GuildMemberRemoved += handler.GuildMemberRemoved; + _bot.DiscordClient.GuildMemberUpdated += handler.GuildMemberUpdated; + _bot.DiscordClient.GuildBanAdded += handler.GuildBanAdded; + _bot.DiscordClient.GuildBanRemoved += handler.GuildBanRemoved; + + _bot.DiscordClient.InviteCreated += handler.InviteCreated; + _bot.DiscordClient.InviteDeleted += handler.InviteDeleted; + + _bot.DiscordClient.MessageCreated += handler.MessageCreated; + _bot.DiscordClient.MessageDeleted += handler.MessageDeleted; + _bot.DiscordClient.MessagesBulkDeleted += handler.MessagesBulkDeleted; + _bot.DiscordClient.MessageUpdated += handler.MessageUpdated; + + _bot.DiscordClient.MessageReactionAdded += handler.MessageReactionAdded; + _bot.DiscordClient.MessageReactionRemoved += handler.MessageReactionRemoved; + + _bot.DiscordClient.ComponentInteractionCreated += handler.ComponentInteractionCreated; + + _bot.DiscordClient.GuildRoleCreated += handler.GuildRoleCreated; + _bot.DiscordClient.GuildRoleDeleted += handler.GuildRoleDeleted; + _bot.DiscordClient.GuildRoleUpdated += handler.GuildRoleUpdated; + + _bot.DiscordClient.VoiceStateUpdated += handler.VoiceStateUpdated; + + _bot.DiscordClient.ThreadCreated += handler.ThreadCreated; + _bot.DiscordClient.ThreadDeleted += handler.ThreadDeleted; + _bot.DiscordClient.ThreadMemberUpdated += handler.ThreadMemberUpdated; + _bot.DiscordClient.ThreadMembersUpdated += handler.ThreadMembersUpdated; + _bot.DiscordClient.ThreadUpdated += handler.ThreadUpdated; + _bot.DiscordClient.ThreadListSynced += handler.ThreadListSynced; + _bot.DiscordClient.UserUpdated += handler.UserUpdated; + + _bot.DiscordClient.GetFirstShard().GetCommandsNext().CommandExecuted += handler.CommandExecuted; + _bot.DiscordClient.GetFirstShard().GetCommandsNext().CommandErrored += handler.CommandError; + } + + GenericGuildEvents genericGuildEvents { get; set; } + CommandEvents commandEvents { get; set; } + CrosspostEvents crosspostEvents { get; set; } + PhishingProtectionEvents phishingProtectionEvents { get; set; } + PhishingSubmissionEvents submissionEvents { get; set; } + DiscordEvents discordEvents { get; set; } + ActionlogEvents actionlogEvents { get; set; } + JoinEvents joinEvents { get; set; } + BumpReminderEvents bumpReminderEvents { get; set; } + ExperienceEvents experienceEvents { get; set; } + ReactionRoleEvents reactionRoleEvents { get; set; } + VoicePrivacyEvents voicePrivacyEvents { get; set; } + InviteTrackerEvents inviteTrackerEvents { get; set; } + InviteNoteEvents inviteNoteEvents { get; set; } + VcCreatorEvents vcCreatorEvents { get; set; } + AutoUnarchiveEvents autoUnarchiveEvents { get; set; } + NameNormalizerEvents nameNormalizerEvents { get; set; } + EmbedMessagesEvents embedMessagesEvents { get; set; } + TokenLeakEvents tokenLeakEvents { get; set; } + ReminderEvents reminderEvents { get; set; } + + internal async Task GuildMemberAdded(DiscordClient sender, GuildMemberAddEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.genericGuildEvents.GuildMemberAdded(sender, e).Add(this.Bot); + _ = this.actionlogEvents.UserJoined(sender, e).Add(this.Bot); + _ = this.joinEvents.GuildMemberAdded(sender, e).Add(this.Bot); + _ = this.inviteTrackerEvents.GuildMemberAdded(sender, e).Add(this.Bot); + _ = this.nameNormalizerEvents.GuildMemberAdded(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task GuildMemberRemoved(DiscordClient sender, GuildMemberRemoveEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.genericGuildEvents.GuildMemberRemoved(sender, e).Add(this.Bot); + _ = this.actionlogEvents.UserLeft(sender, e).Add(this.Bot); + _ = this.joinEvents.GuildMemberRemoved(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task GuildMemberUpdated(DiscordClient sender, GuildMemberUpdateEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.genericGuildEvents.GuildMemberUpdated(sender, e).Add(this.Bot); + _ = this.actionlogEvents.MemberUpdated(sender, e).Add(this.Bot); + _ = this.nameNormalizerEvents.GuildMemberUpdated(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task GuildBanAdded(DiscordClient sender, GuildBanAddEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.genericGuildEvents.GuildMemberBanned(sender, e).Add(this.Bot); + _ = this.actionlogEvents.BanAdded(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task CommandExecuted(CommandsNextExtension sender, CommandExecutionEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.commandEvents.CommandExecuted(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task CommandError(CommandsNextExtension sender, CommandErrorEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.commandEvents.CommandError(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task MessageCreated(DiscordClient sender, MessageCreateEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.crosspostEvents.MessageCreated(sender, e).Add(this.Bot); + _ = this.phishingProtectionEvents.MessageCreated(sender, e).Add(this.Bot); + _ = this.bumpReminderEvents.MessageCreated(sender, e).Add(this.Bot); + _ = this.experienceEvents.MessageCreated(sender, e).Add(this.Bot); + _ = this.embedMessagesEvents.MessageCreated(sender, e).Add(this.Bot); + _ = this.tokenLeakEvents.MessageCreated(sender, e).Add(this.Bot); + + if (!e.Message.Content.IsNullOrWhiteSpace() && (e.Message.Content == $"<@{sender.CurrentUser.Id}>" || e.Message.Content == $"<@!{sender.CurrentUser.Id}>")) + { + var prefix = e.Guild.GetGuildPrefix(this.Bot); + + _ = e.Message.RespondAsync(this.tKey.PingMessage.Get(this.Bot.Guilds[e.Guild.Id]).Build(false, true, + new TVar("User", e.Author.Mention), + new TVar("Bot", sender.CurrentUser.GetUsername()), + new TVar("BotMention", sender.CurrentUser.Mention), + new TVar("Help", sender.GetCommandMention(this.Bot, "help")), + new TVar("Invite", $"<{this.Bot.status.DevelopmentServerInvite}>"), + new TVar("GithubRepo", ""))); + } + }).Add(this.Bot); + } + + internal async Task MessageUpdated(DiscordClient sender, MessageUpdateEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.phishingProtectionEvents.MessageUpdated(sender, e).Add(this.Bot); + _ = this.actionlogEvents.MessageUpdated(sender, e).Add(this.Bot); + _ = this.tokenLeakEvents.MessageUpdated(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task ComponentInteractionCreated(DiscordClient sender, ComponentInteractionCreateEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.submissionEvents.ComponentInteractionCreated(sender, e).Add(this.Bot); + _ = this.embedMessagesEvents.ComponentInteractionCreated(sender, e).Add(this.Bot); + _ = this.reminderEvents.ComponentInteractionCreated(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task GuildCreated(DiscordClient sender, GuildCreateEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.discordEvents.GuildCreated(sender, e).Add(this.Bot); + _ = this.inviteTrackerEvents.GuildCreated(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task MessageDeleted(DiscordClient sender, MessageDeleteEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.actionlogEvents.MessageDeleted(sender, e).Add(this.Bot); + _ = this.bumpReminderEvents.MessageDeleted(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task MessagesBulkDeleted(DiscordClient sender, MessageBulkDeleteEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.actionlogEvents.MessageBulkDeleted(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task GuildRoleCreated(DiscordClient sender, GuildRoleCreateEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.actionlogEvents.RoleCreated(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task GuildRoleUpdated(DiscordClient sender, GuildRoleUpdateEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.actionlogEvents.RoleModified(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task GuildRoleDeleted(DiscordClient sender, GuildRoleDeleteEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.actionlogEvents.RoleDeleted(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task GuildBanRemoved(DiscordClient sender, GuildBanRemoveEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.actionlogEvents.BanRemoved(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task GuildUpdated(DiscordClient sender, GuildUpdateEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.actionlogEvents.GuildUpdated(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task ChannelCreated(DiscordClient sender, ChannelCreateEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.actionlogEvents.ChannelCreated(sender, e).Add(this.Bot); + _ = this.voicePrivacyEvents.ChannelCreated(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task ChannelDeleted(DiscordClient sender, ChannelDeleteEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.actionlogEvents.ChannelDeleted(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task ChannelUpdated(DiscordClient sender, ChannelUpdateEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.actionlogEvents.ChannelUpdated(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task InviteCreated(DiscordClient sender, InviteCreateEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.actionlogEvents.InviteCreated(sender, e).Add(this.Bot); + _ = this.inviteTrackerEvents.InviteCreated(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task InviteDeleted(DiscordClient sender, InviteDeleteEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.actionlogEvents.InviteDeleted(sender, e).Add(this.Bot); + _ = this.inviteTrackerEvents.InviteDeleted(sender, e).Add(this.Bot); + _ = this.inviteNoteEvents.InviteDeleted(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task MessageReactionAdded(DiscordClient sender, MessageReactionAddEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.bumpReminderEvents.ReactionAdded(sender, e).Add(this.Bot); + _ = this.reactionRoleEvents.MessageReactionAdded(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task MessageReactionRemoved(DiscordClient sender, MessageReactionRemoveEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.bumpReminderEvents.ReactionRemoved(sender, e).Add(this.Bot); + _ = this.reactionRoleEvents.MessageReactionRemoved(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task VoiceStateUpdated(DiscordClient sender, VoiceStateUpdateEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.actionlogEvents.VoiceStateUpdated(sender, e).Add(this.Bot); + _ = this.voicePrivacyEvents.VoiceStateUpdated(sender, e).Add(this.Bot); + _ = this.vcCreatorEvents.VoiceStateUpdated(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task ThreadCreated(DiscordClient sender, ThreadCreateEventArgs e) + { + _ = Task.Run(async () => + { + e.Thread.JoinWithQueue(this.Bot.ThreadJoinClient); + }).Add(this.Bot); + } + + internal Task ThreadDeleted(DiscordClient sender, ThreadDeleteEventArgs e) + { + return Task.CompletedTask; + //_ = Task.Run(async () => + //{ + + //}).Add(this.Bot); + } + + internal async Task ThreadMemberUpdated(DiscordClient sender, ThreadMemberUpdateEventArgs e) + { + _ = Task.Run(async () => + { + e.Thread.JoinWithQueue(this.Bot.ThreadJoinClient); + }).Add(this.Bot); + } + + internal async Task ThreadMembersUpdated(DiscordClient sender, ThreadMembersUpdateEventArgs e) + { + _ = Task.Run(async () => + { + e.Thread.JoinWithQueue(this.Bot.ThreadJoinClient); + }).Add(this.Bot); + } + + internal async Task ThreadListSynced(DiscordClient sender, ThreadListSyncEventArgs e) + { + _ = Task.Run(async () => + { + if (this.Bot.status.DiscordGuildDownloadCompleted) + foreach (var b in e.Threads) + b.JoinWithQueue(this.Bot.ThreadJoinClient); + }).Add(this.Bot); + } + + internal async Task ThreadUpdated(DiscordClient sender, ThreadUpdateEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.autoUnarchiveEvents.ThreadUpdated(sender, e).Add(this.Bot); + }).Add(this.Bot); + } + + internal async Task UserUpdated(DiscordClient sender, UserUpdateEventArgs e) + { + _ = Task.Run(async () => + { + _ = this.nameNormalizerEvents.UserUpdated(sender, e).Add(this.Bot); + }).Add(this.Bot); + } +} diff --git a/ProjectMakoto/Events/ActionlogEvents.cs b/ProjectMakoto/Events/ActionlogEvents.cs new file mode 100644 index 00000000..62f7c087 --- /dev/null +++ b/ProjectMakoto/Events/ActionlogEvents.cs @@ -0,0 +1,1140 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Events; + +internal sealed class ActionlogEvents(Bot bot) : RequiresTranslation(bot) +{ + internal async Task ValidateServer(DiscordGuild guild) + => guild is not null + && this.Bot.Guilds[guild.Id].ActionLog.Channel != 0 + && guild.Channels.ContainsKey(this.Bot.Guilds[guild.Id].ActionLog.Channel); + + Translations.events.actionlog tKey => this.t.Events.Actionlog; + + private Task SendActionlog(DiscordGuild guild, DiscordMessageBuilder builder) + => guild.GetChannel(this.Bot.Guilds[guild.Id].ActionLog.Channel).SendMessageAsync(builder); + + internal async Task UserJoined(DiscordClient sender, GuildMemberAddEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.MembersModified) + return; + + var embed = new DiscordEmbedBuilder() + .WithAuthor(e.Member.MemberFlags.HasFlag(MemberFlags.DidRejoin) ? this.tKey.UserRejoined.Get(this.Bot.Guilds[e.Guild.Id]).Build() : this.tKey.UserJoined.Get(this.Bot.Guilds[e.Guild.Id]).Build(), + null, AuditLogIcons.UserAdded) + .WithColor(EmbedColors.Success) + .WithFooter($"{this.tKey.UserId.Get(this.Bot.Guilds[e.Guild.Id]).Build()}: {e.Member.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithThumbnail(e.Member.AvatarUrl) + .WithDescription($"**{this.tKey.User.Get(this.Bot.Guilds[e.Guild.Id]).Build()}**: {e.Member.Mention} `{e.Member.GetUsernameWithIdentifier()}`\n" + + $"**{this.tKey.AccountAge.Get(this.Bot.Guilds[e.Guild.Id]).Build()}**: {e.Member.CreationTimestamp.ToTimestamp()} ({e.Member.CreationTimestamp.ToTimestamp(TimestampFormat.LongDateTime)})"); + + if (this.Bot.globalNotes.TryGetValue(e.Member.Id, out var globalNote) && globalNote.Notes.Length != 0) + { + _ = embed.AddField(new DiscordEmbedField(this.tKey.StaffNotes.Get(this.Bot.Guilds[e.Guild.Id]).Build(), + $"{string.Join("\n\n", globalNote.Notes.Select(x => $"{x.Reason.FullSanitize()} - <@{x.Moderator}> {x.Timestamp.ToTimestamp()}"))}".TruncateWithIndication(512))); + } + + var message = await this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed)); + + await Task.Delay(5000); + + var Wait = 0; + + while (Wait < 10 && this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].InviteTracker.Code == "") + { + Wait++; + await Task.Delay(1000); + } + + if (this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].InviteTracker.Code == "") + return; + + embed.Description += $"\n\n**{this.tKey.InvitedBy.Get(this.Bot.Guilds[e.Guild.Id]).Build()}**: <@{this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].InviteTracker.UserId}>\n"; + embed.Description += $"**{this.tKey.InviteCode.Get(this.Bot.Guilds[e.Guild.Id]).Build()}**: `{this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].InviteTracker.Code}`"; + + if (this.Bot.Guilds[e.Guild.Id].InviteNotes.Notes.Any(x => x.Invite == this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].InviteTracker.Code)) + embed.Description += $"**{this.tKey.InviteNote.Get(this.Bot.Guilds[e.Guild.Id])}**: `{this.Bot.Guilds[e.Guild.Id].InviteNotes.Notes.First(x => x.Invite == this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].InviteTracker.Code).Note.SanitizeForCode()}`"; + + _ = message.ModifyAsync(new DiscordMessageBuilder().WithEmbed(embed)); + } + + internal async Task UserLeft(DiscordClient sender, GuildMemberRemoveEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.MembersModified) + return; + + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.UserLeft.Get(this.Bot.Guilds[e.Guild.Id]).Build(), null, AuditLogIcons.UserLeft) + .WithColor(EmbedColors.Error) + .WithFooter($"{this.tKey.UserId.Get(this.Bot.Guilds[e.Guild.Id]).Build()}: {e.Member.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithThumbnail(e.Member.AvatarUrl) + .WithDescription($"**{this.tKey.User.Get(this.Bot.Guilds[e.Guild.Id]).Build()}**: {e.Member.Mention} `{e.Member.GetUsernameWithIdentifier()}`\n" + + $"**{this.tKey.JoinedAt.Get(this.Bot.Guilds[e.Guild.Id]).Build()}**: {e.Member.JoinedAt.ToTimestamp()} ({e.Member.JoinedAt.ToTimestamp(TimestampFormat.LongDateTime)})"); + + if (e.Member.Roles.Any()) + _ = embed.AddField(new DiscordEmbedField(this.tKey.Roles.Get(this.Bot.Guilds[e.Guild.Id]).Build(), $"{string.Join(", ", e.Member.Roles.Select(x => x.Mention))}".TruncateWithIndication(1000))); + + var msg = await this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed)); + + for (var i = 0; i < 3; i++) + { + var AuditKickLogEntries = await e.Guild.GetAuditLogsAsync(actionType: AuditLogActionType.Kick); + var AuditBanLogEntries = await e.Guild.GetAuditLogsAsync(actionType: AuditLogActionType.Ban); + + if (AuditKickLogEntries.Count > 0 && AuditKickLogEntries.Any(x => ((DiscordAuditLogKickEntry)x).Target.Id == e.Member.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id))) + { + var Entry = (DiscordAuditLogKickEntry)AuditKickLogEntries.First(x => ((DiscordAuditLogKickEntry)x).Target.Id == e.Member.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id)); + + this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs = this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Add(Entry.Id); + + embed.Author.Name = this.tKey.UserKicked.Get(this.Bot.Guilds[e.Guild.Id]).Build(); + embed.Author.IconUrl = AuditLogIcons.UserKicked; + embed.Description += $"\n\n**{this.tKey.KickedBy.Get(this.Bot.Guilds[e.Guild.Id]).Build()}**: {Entry.UserResponsible.Mention} `{Entry.UserResponsible.GetUsernameWithIdentifier()}`"; + + if (!string.IsNullOrWhiteSpace(Entry.Reason)) + embed.Description += $"\n**{this.tKey.Reason.Get(this.Bot.Guilds[e.Guild.Id]).Build()}**: {Entry.Reason.SanitizeForCode()}"; + + embed.Footer = new(); + embed.Footer.Text += $"\n({this.tKey.FooterAuditLogDisclaimer.Get(this.Bot.Guilds[e.Guild.Id]).Build(new TVar("Fields", $"'{this.tKey.KickedBy.Get(this.Bot.Guilds[e.Guild.Id])}' & '{this.tKey.Reason.Get(this.Bot.Guilds[e.Guild.Id])}'"))})"; + + _ = msg.ModifyAsync(new DiscordMessageBuilder().WithEmbed(embed)); + break; + } + + if (this.Bot.Guilds[e.Guild.Id].ActionLog.BanlistModified && AuditBanLogEntries.Count > 0 && AuditBanLogEntries.Any(x => ((DiscordAuditLogBanEntry)x).Target.Id == e.Member.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id))) + { + var Entry = (DiscordAuditLogBanEntry)AuditBanLogEntries.First(x => ((DiscordAuditLogBanEntry)x).Target.Id == e.Member.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id)); + + this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs = this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Add(Entry.Id); + + _ = msg.DeleteAsync(); + break; + } + + await Task.Delay(5000); + } + } + + internal async Task MessageDeleted(DiscordClient sender, MessageDeleteEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.MessageDeleted || e.Message.WebhookMessage || e.Message is null || e.Message.Author is null || e.Message.Author.IsBot) + return; + + var prefix = e.Guild.GetGuildPrefix(this.Bot); + + if (e?.Message?.Content?.StartsWith(prefix) ?? false) + foreach (var command in sender.GetCommandsNext().RegisteredCommands) + if (e.Message.Content.StartsWith($"{prefix}{command.Key}")) + return; + + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.MessageDeleted.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.MessageDeleted) + .WithColor(EmbedColors.Error) + .WithFooter($"{this.tKey.UserId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.Message.Author.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithThumbnail(e.Message.Author.AvatarUrl) + .WithDescription($"**{this.tKey.User.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Message.Author.Mention} `{e.Message.Author.GetUsernameWithIdentifier()}`\n" + + $"**{this.tKey.Channel.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Channel.Mention} `[{e.Channel.GetIcon()}{e.Channel.Name}]`"); + + if (!string.IsNullOrWhiteSpace(e.Message.Content)) + _ = embed.AddField(new DiscordEmbedField(this.tKey.Content.Get(this.Bot.Guilds[e.Guild.Id]), $"`{e.Message.Content.SanitizeForCode().TruncateWithIndication(1022)}`")); + + if (e.Message.Attachments.Count != 0) + _ = embed.AddField(new DiscordEmbedField(this.tKey.Attachments.Get(this.Bot.Guilds[e.Guild.Id]), $"{string.Join("\n", e.Message.Attachments.Select(x => $"`[{x.FileSize.Value.FileSizeToHumanReadable()}]` `{x.Url}`"))}")); + + if (e.Message.Stickers.Count != 0) + _ = embed.AddField(new DiscordEmbedField(this.tKey.Stickers.Get(this.Bot.Guilds[e.Guild.Id]), $"{string.Join("\n", e.Message.Stickers.Select(x => $"`{x.Name}`"))}")); + + if (e.Message.ReferencedMessage is not null) + _ = embed.AddField(new DiscordEmbedField(this.tKey.ReplyTo.Get(this.Bot.Guilds[e.Guild.Id]), $"{(e.Message.ReferencedMessage.Author is not null ? $"{e.Message.ReferencedMessage.Author.Mention}: " : "")}[`{this.t.Common.JumpToMessage.Get(this.Bot.Guilds[e.Guild.Id])}`]({e.Message.ReferencedMessage.JumpLink})")); + + if (embed.Fields.Count == 0) + return; + + _ = this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed)); + } + + internal async Task VoiceStateUpdated(DiscordClient sender, VoiceStateUpdateEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.VoiceStateUpdated) + return; + + var PreviousChannel = e.Before?.Channel; + var NewChannel = e.After?.Channel; + + if (PreviousChannel != NewChannel) + if (PreviousChannel is null && NewChannel is not null) + { + _ = await this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder() + .WithAuthor(this.tKey.UserJoinedVoiceChannel.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.VoiceStateUserJoined) + .WithThumbnail(e.User.AvatarUrl) + .WithColor(EmbedColors.Success) + .WithFooter($"{this.tKey.UserId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.User.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithDescription($"**{this.tKey.User.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.User.Mention} `{e.User.GetUsernameWithIdentifier()}`\n" + + $"**{this.tKey.Channel.Get(this.Bot.Guilds[e.Guild.Id])}**: {NewChannel.Mention} `[{NewChannel.GetIcon()}{NewChannel.Name}]`"))); + } + else if (PreviousChannel is not null && NewChannel is null) + { + _ = await this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder() + .WithAuthor(this.tKey.UserLeftVoiceChannel.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.VoiceStateUserLeft) + .WithThumbnail(e.User.AvatarUrl) + .WithColor(EmbedColors.Error) + .WithFooter($"{this.tKey.UserId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.User.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithDescription($"**{this.tKey.User.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.User.Mention} `{e.User.GetUsernameWithIdentifier()}`\n" + + $"**{this.tKey.Channel.Get(this.Bot.Guilds[e.Guild.Id])}**: {PreviousChannel.Mention} `[{PreviousChannel.GetIcon()}{PreviousChannel.Name}]`"))); + } + else if (PreviousChannel is not null && NewChannel is not null) + { + _ = await this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder() + .WithAuthor(this.tKey.UserSwitchedVoiceChannel.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.VoiceStateUserUpdated) + .WithThumbnail(e.User.AvatarUrl) + .WithColor(EmbedColors.Warning) + .WithFooter($"{this.tKey.UserId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.User.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithDescription($"**{this.tKey.User.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.User.Mention} `{e.User.GetUsernameWithIdentifier()}`\n" + + $"**{this.tKey.Channel.Get(this.Bot.Guilds[e.Guild.Id])}**: {PreviousChannel.Mention} `[{PreviousChannel.GetIcon()}{PreviousChannel.Name}]` ➡ {NewChannel.Mention} `[{NewChannel.GetIcon()}{NewChannel.Name}]`"))); + } + } + + internal async Task MessageBulkDeleted(DiscordClient sender, MessageBulkDeleteEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.MessageDeleted) + return; + + IEnumerable affectedUsers = Array.Empty(); + + try + { + affectedUsers = e.Messages.Where(x => x.Author is not null) + .Select(x => x.Author) + .GroupBy(x => x.Id).Select(x => x.First()) + .Where(x => x is not null) + .Select(x => x?.Mention); + } catch { } + + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.MultipleMessagesDeleted.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.MessageDeleted) + .WithColor(EmbedColors.Error) + .WithTimestamp(DateTime.UtcNow) + .WithDescription($"**{this.tKey.Channel.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Channel.Mention} `[{e.Channel.GetIcon()}{e.Channel.Name}]`\n" + + $"{this.tKey.CheckAttachedFileForDeletedMessages.Get(this.Bot.Guilds[e.Guild.Id]).Build(true)}\n\n" + + $"**{this.tKey.AffectedUsers.Get(this.Bot.Guilds[e.Guild.Id])}**: {(affectedUsers.Any() ? string.Join(", ", affectedUsers) : "`-`")}"); + + string FileName; + string Messages; + + try + { + FileName = $"{Guid.NewGuid()}.html"; + Messages = e.Messages.GenerateHtmlFromMessages(this.Bot); + + _ = Directory.CreateDirectory($"WebServer/{e.Guild.Id}/DeletedMessages"); + File.WriteAllText($"WebServer/{e.Guild.Id}/DeletedMessages/{FileName}", Messages); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to generate html from messages"); + + FileName = $"{Guid.NewGuid()}.json"; + Messages = JsonConvert.SerializeObject(e.Messages.OrderBy(x => x.Id.GetSnowflakeTime().Ticks), new JsonSerializerSettings + { + Formatting = Formatting.Indented, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + Error = (serializer, err) => + { + Log.Error(err.ErrorContext.Error, "Failed to serialize member '{member}' at '{path}'", err.ErrorContext.Member, err.ErrorContext.Path); + err.ErrorContext.Handled = true; + }, + }); + } + + if (Messages.Length == 0) + return; + + using (var fileStream = new MemoryStream(Encoding.UTF8.GetBytes(Messages))) + { + _ = await this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed).WithFile(FileName, fileStream) + .AddComponents(new DiscordLinkButtonComponent($"{this.Bot.status.LoadedConfig.WebServer.UrlPrefix}/{e.Guild.Id}/DeletedMessages/{FileName}", "Open in Browser", + this.Bot.status.LoadedConfig.WebServer.UrlPrefix.IsNullOrWhiteSpace()))); + } + } + + internal async Task MessageUpdated(DiscordClient sender, MessageUpdateEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || + !this.Bot.Guilds[e.Guild.Id].ActionLog.MessageDeleted || + e.Message is null || + e.MessageBefore is null || + e.Message.WebhookMessage || + e.Message.Author is null || + e.Message.Author.IsBot) + return; + + var prefix = e.Guild.GetGuildPrefix(this.Bot); + + if (e?.Message?.Content?.StartsWith(prefix) ?? false) + foreach (var command in sender.GetCommandsNext().RegisteredCommands) + if (e.Message.Content.StartsWith($"{prefix}{command.Key}")) + return; + + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.MessageUpdated.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.MessageEdited) + .WithColor(EmbedColors.Warning) + .WithFooter($"{this.tKey.UserId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.Message.Author?.Id ?? 0}") + .WithTimestamp(DateTime.UtcNow) + .WithThumbnail(e.Message.Author?.AvatarUrl) + .WithDescription($"**{this.tKey.User.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Message.Author?.Mention ?? "/"} `{e.Message.Author?.GetUsernameWithIdentifier() ?? "/"}`\n" + + $"**{this.tKey.Channel.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Channel.Mention} `[{e.Channel.GetIcon()}{e.Channel.Name}]`\n" + + $"**{this.tKey.Message.Get(this.Bot.Guilds[e.Guild.Id])}**: [`{this.t.Common.JumpToMessage.Get(this.Bot.Guilds[e.Guild.Id])}`]({e.Message.JumpLink})"); + + if (e.MessageBefore.Content != e.Message.Content) + { + if (!string.IsNullOrWhiteSpace(e.MessageBefore.Content)) + _ = embed.AddField(new DiscordEmbedField(this.tKey.PreviousContent.Get(this.Bot.Guilds[e.Guild.Id]), $"`{e.MessageBefore.Content.SanitizeForCode().TruncateWithIndication(1022)}`")); + + if (!string.IsNullOrWhiteSpace(e.Message.Content)) + _ = embed.AddField(new DiscordEmbedField(this.tKey.NewContent.Get(this.Bot.Guilds[e.Guild.Id]), $"`{e.Message.Content.SanitizeForCode().TruncateWithIndication(1022)}`")); + } + else + { + return; + } + + _ = this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed)); + } + + internal async Task MemberUpdated(DiscordClient sender, GuildMemberUpdateEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.MemberModified) + return; + + if (e.NicknameBefore != e.NicknameAfter) + { + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.MessageUpdated.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.UserUpdated) + .WithColor(EmbedColors.Warning) + .WithFooter($"{this.tKey.UserId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.Member.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithThumbnail(e.Member.AvatarUrl) + .WithDescription($"**{this.tKey.User.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Member.Mention} `{e.Member.GetUsernameWithIdentifier()}`"); + + if (string.IsNullOrWhiteSpace(e.NicknameBefore)) + embed.Author.Name = this.tKey.NicknameAdded.Get(this.Bot.Guilds[e.Guild.Id]); + else + _ = embed.AddField(new DiscordEmbedField(this.tKey.PreviousNickname.Get(this.Bot.Guilds[e.Guild.Id]), $"`{e.NicknameBefore}`")); + + if (string.IsNullOrWhiteSpace(e.NicknameAfter)) + embed.Author.Name = this.tKey.NicknameRemoved.Get(this.Bot.Guilds[e.Guild.Id]); + else + _ = embed.AddField(new DiscordEmbedField(this.tKey.NewNickname.Get(this.Bot.Guilds[e.Guild.Id]), $"`{e.NicknameAfter}`")); + + _ = this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed)); + } + + var RolesUpdated = false; + + foreach (var role in e.RolesBefore) + { + if (!e.RolesAfter.Any(x => x.Id == role.Id)) + { + await Task.Delay(3000); + RolesUpdated = true; + + if (!e.Guild.Roles.ContainsKey(role.Id)) + { + RolesUpdated = false; + continue; + } + + break; + } + } + + if (!RolesUpdated) + foreach (var role in e.RolesAfter) + { + if (!e.RolesBefore.Any(x => x.Id == role.Id)) + { + await Task.Delay(3000); + RolesUpdated = true; + + if (!e.Guild.Roles.ContainsKey(role.Id)) + { + RolesUpdated = false; + continue; + } + + break; + } + } + + if (RolesUpdated) + { + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.RolesUpdated.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.UserUpdated) + .WithColor(EmbedColors.Warning) + .WithFooter($"{this.tKey.UserId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.Member.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithThumbnail(e.Member.AvatarUrl) + .WithDescription($"**{this.tKey.User.Get(this.Bot.Guilds[e.Guild.Id])}**: `{e.Member.GetUsernameWithIdentifier()}`"); + + var Roles = ""; + + var RolesAdded = false; + var RolesRemoved = false; + + foreach (var role in e.RolesAfter) + { + if (!e.RolesBefore.Any(x => x.Id == role.Id)) + { + Roles += $"`+` {role.Mention} `{role.Name}` `({role.Id})`\n"; + RolesAdded = true; + } + } + + foreach (var role in e.RolesBefore) + { + if (!e.RolesAfter.Any(x => x.Id == role.Id)) + { + Roles += $"`-` {role.Mention} `{role.Name}` `({role.Id})`\n"; + RolesRemoved = true; + } + } + + if (RolesAdded && !RolesRemoved) + { + embed.Author.Name = this.tKey.RolesAdded.Get(this.Bot.Guilds[e.Guild.Id]); + embed.Color = EmbedColors.Success; + embed.Author.IconUrl = AuditLogIcons.UserAdded; + } + else if (!RolesAdded && RolesRemoved) + { + embed.Author.Name = this.tKey.RolesRemoved.Get(this.Bot.Guilds[e.Guild.Id]); + embed.Color = EmbedColors.Error; + embed.Author.IconUrl = AuditLogIcons.UserLeft; + } + + embed.Description += $"\n\n{Roles}"; + + _ = this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed)); + } + + if (e.TimeoutBefore != e.TimeoutAfter) + { + var timeAfter = (e.TimeoutAfter ?? DateTime.Today.AddDays(-300)).ToUniversalTime(); + var timeBefore = (e.TimeoutBefore ?? DateTime.Today.AddDays(-300)).ToUniversalTime(); + + if (timeAfter > timeBefore) + _ = this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder() + .WithAuthor(this.tKey.TimedOut.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.UserBanned) + .WithColor(EmbedColors.Error) + .WithFooter($"{this.tKey.UserId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.Member.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithThumbnail(e.Member.AvatarUrl) + .WithDescription($"**{this.tKey.User.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Member.Mention} `{e.Member.GetUsernameWithIdentifier()}`\n" + + $"**{this.tKey.TimedOutUntil.Get(this.Bot.Guilds[e.Guild.Id])}**: {timeAfter.Timestamp(TimestampFormat.LongDateTime)} ({timeAfter.Timestamp()})"))); + + if (timeAfter < timeBefore) + _ = this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder() + .WithAuthor(this.tKey.TimeoutRemoved.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.UserBanRemoved) + .WithColor(EmbedColors.Success) + .WithFooter($"{this.tKey.UserId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.Member.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithThumbnail(e.Member.AvatarUrl) + .WithDescription($"**{this.tKey.User.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Member.Mention} `{e.Member.GetUsernameWithIdentifier()}`"))); + } + + if (e.PendingBefore != e.PendingAfter) + { + try + { + if ((e.PendingBefore is null && e.PendingAfter is true) || (e.PendingAfter is true && e.PendingBefore is false)) + _ = this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder() + .WithAuthor(this.tKey.MembershipApproved.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.UserAdded) + .WithColor(EmbedColors.Success) + .WithFooter($"{this.tKey.UserId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.Member.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithThumbnail(e.Member.AvatarUrl) + .WithDescription($"**{this.tKey.User.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Member.Mention} `{e.Member.GetUsernameWithIdentifier()}`"))); + } + catch { } + } + + if (!this.Bot.Guilds[e.Guild.Id].ActionLog.MemberProfileModified) + return; + + //if (e.AvatarHashBefore != e.AvatarHashAfter) + //{ + // // Normal avatar updates don't seem to fire the member updated event, will keep this code for potential future updates. + + // _ = SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder() + // { + // Author = new DiscordEmbedBuilder.EmbedAuthor { IconUrl = AuditLogIcons.UserUpdated, Name = $"Member Profile Picture updated" }, + // Color = EmbedColors.Warning, + // Footer = new DiscordEmbedBuilder.EmbedFooter { Text = $"User-Id: {e.Member.Id}" }, + // Timestamp = DateTime.UtcNow, + // Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail { Url = e.Member.AvatarUrl }, + // Description = $"**User**: {e.Member.Mention} `{e.Member.GetUsernameWithIdentifier()}`", + // ImageUrl = e.Member.AvatarUrl + // })); + //} + + if (e.GuildAvatarHashBefore != e.GuildAvatarHashAfter) + { + _ = this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder() + .WithAuthor(this.tKey.GuildProfilePictureUpdated.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.UserUpdated) + .WithColor(EmbedColors.Warning) + .WithFooter($"{this.tKey.UserId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.Member.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithThumbnail(e.Member.AvatarUrl) + .WithImageUrl(e.Member.GuildAvatarUrl) + .WithDescription($"**{this.tKey.User.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Member.Mention} `{e.Member.GetUsernameWithIdentifier()}`"))); + } + } + + internal async Task RoleCreated(DiscordClient sender, GuildRoleCreateEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.RolesModified) + return; + + var GeneratePermissions = string.Join(", ", e.Role.Permissions.GetEnumeration().Select(x => $"`{x.ToTranslatedPermissionString(this.Bot.Guilds[e.Guild.Id], this.Bot)}`")); + var Integration = ""; + + if (e.Role.IsManaged) + { + if (e.Role.Tags?.PremiumSubscriber ?? false) + Integration = $"**{this.tKey.Integration.Get(this.Bot.Guilds[e.Guild.Id])}**: `{this.tKey.ServerBooster.Get(this.Bot.Guilds[e.Guild.Id])}`\n\n"; + + if (e.Role.Tags?.BotId is not null and not 0) + { + var bot = await sender.GetUserAsync((ulong)e.Role.Tags.BotId); + + Integration = $"**{this.tKey.Integration.Get(this.Bot.Guilds[e.Guild.Id])}**: {bot.Mention} `{bot.GetUsernameWithIdentifier()}`\n\n"; + } + } + + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.RoleCreated.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.UserAdded) + .WithColor(EmbedColors.Success) + .WithFooter($"{this.tKey.RoleId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.Role.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithDescription($"**{this.tKey.Role.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Role.Mention} `{e.Role.Name}`\n" + + $"**{this.tKey.Color.Get(this.Bot.Guilds[e.Guild.Id])}**: `{e.Role.Color.ToHex()}`\n" + + $"**{this.tKey.RoleMentionable.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Role.IsMentionable.ToPillEmote(this.Bot)}\n" + + $"**{this.tKey.DisplayedRoleMembers.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Role.IsHoisted.ToPillEmote(this.Bot)}\n" + + $"{Integration}" + + $"\n**{this.tKey.Permissions.Get(this.Bot.Guilds[e.Guild.Id])}**: {GeneratePermissions}"); + + var msg = await this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed)); + + if (!this.Bot.Guilds[e.Guild.Id].ActionLog.AttemptGettingMoreDetails) + return; + + for (var i = 0; i < 3; i++) + { + var AuditLogEntries = await e.Guild.GetAuditLogsAsync(actionType: AuditLogActionType.RoleCreate); + + if (AuditLogEntries.Count > 0 && AuditLogEntries.Any(x => ((DiscordAuditLogRoleUpdateEntry)x).Target.Id == e.Role.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id))) + { + var Entry = (DiscordAuditLogRoleUpdateEntry)AuditLogEntries.First(x => ((DiscordAuditLogRoleUpdateEntry)x).Target.Id == e.Role.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id)); + this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs = this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Add(Entry.Id); + + embed.Description += $"\n\n**{this.tKey.CreatedBy.Get(this.Bot.Guilds[e.Guild.Id])}**: {Entry.UserResponsible.Mention} `{Entry.UserResponsible.GetUsernameWithIdentifier()}`"; + embed.Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail { Url = Entry.UserResponsible.AvatarUrl }; + + embed.Footer.Text += $"\n({this.tKey.FooterAuditLogDisclaimer.Get(this.Bot.Guilds[e.Guild.Id]).Build(new TVar("Fields", $"'{this.tKey.CreatedBy.Get(this.Bot.Guilds[e.Guild.Id])}'"))})"; + + _ = msg.ModifyAsync(new DiscordMessageBuilder().WithEmbed(embed)); + break; + } + + await Task.Delay(5000); + } + } + + internal async Task RoleDeleted(DiscordClient sender, GuildRoleDeleteEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.RolesModified) + return; + + var GeneratePermissions = string.Join(", ", e.Role.Permissions.GetEnumeration().Select(x => $"`{x.ToTranslatedPermissionString(this.Bot.Guilds[e.Guild.Id], this.Bot)}`")); + var Integration = ""; + + if (e.Role.IsManaged) + { + if (e.Role.Tags?.PremiumSubscriber ?? false) + Integration = $"**{this.tKey.Integration.Get(this.Bot.Guilds[e.Guild.Id])}**: `{this.tKey.ServerBooster.Get(this.Bot.Guilds[e.Guild.Id])}`\n\n"; + + if (e.Role.Tags.BotId is not null and not 0) + { + var bot = await sender.GetUserAsync((ulong)e.Role.Tags.BotId); + + Integration = $"**{this.tKey.Integration.Get(this.Bot.Guilds[e.Guild.Id])}**: {bot.Mention} `{bot.GetUsernameWithIdentifier()}`\n\n"; + } + } + + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.RoleDeleted.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.UserLeft) + .WithColor(EmbedColors.Error) + .WithFooter($"{this.tKey.RoleId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.Role.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithDescription($"**{this.tKey.Role.Get(this.Bot.Guilds[e.Guild.Id])}**: `{e.Role.Name}`\n" + + $"**{this.tKey.Color.Get(this.Bot.Guilds[e.Guild.Id])}**: `{e.Role.Color.ToHex()}`\n" + + $"**{this.tKey.RoleMentionable.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Role.IsMentionable.ToPillEmote(this.Bot)}\n" + + $"**{this.tKey.DisplayedRoleMembers.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Role.IsHoisted.ToPillEmote(this.Bot)}\n" + + $"{(e.Role.IsManaged ? $"{this.tKey.RoleWasIntegration.Get(this.Bot.Guilds[e.Guild.Id]).Build(true)}\n" : "")}" + + $"{Integration}\n" + + $"\n**{this.tKey.Permissions.Get(this.Bot.Guilds[e.Guild.Id])}**: {GeneratePermissions}"); + + var msg = await this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed)); + + if (!this.Bot.Guilds[e.Guild.Id].ActionLog.AttemptGettingMoreDetails) + return; + + for (var i = 0; i < 3; i++) + { + var AuditLogEntries = await e.Guild.GetAuditLogsAsync(actionType: AuditLogActionType.RoleDelete); + + if (AuditLogEntries.Count > 0 && AuditLogEntries.Any(x => ((DiscordAuditLogRoleUpdateEntry)x).Target.Id == e.Role.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id))) + { + var Entry = (DiscordAuditLogRoleUpdateEntry)AuditLogEntries.First(x => ((DiscordAuditLogRoleUpdateEntry)x).Target.Id == e.Role.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id)); + this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs = this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Add(Entry.Id); + + embed.Description += $"\n\n**{this.tKey.DeletedBy.Get(this.Bot.Guilds[e.Guild.Id])}**: {Entry.UserResponsible.Mention} `{Entry.UserResponsible.GetUsernameWithIdentifier()}`"; + embed.Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail { Url = Entry.UserResponsible.AvatarUrl }; + + embed.Footer.Text += $"\n({this.tKey.FooterAuditLogDisclaimer.Get(this.Bot.Guilds[e.Guild.Id]).Build(new TVar("Fields", $"'{this.tKey.DeletedBy.Get(this.Bot.Guilds[e.Guild.Id])}'"))})"; + + _ = msg.ModifyAsync(new DiscordMessageBuilder().WithEmbed(embed)); + break; + } + + await Task.Delay(5000); + } + } + + internal async Task RoleModified(DiscordClient sender, GuildRoleUpdateEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.RolesModified) + return; + + var BeforePermissions = e.RoleBefore.Permissions.GetEnumeration(); + var AfterPermissions = e.RoleAfter.Permissions.GetEnumeration(); + + var PermissionsAdded = false; + var PermissionsRemoved = false; + var PermissionDifference = ""; + + foreach (var perm in AfterPermissions) + { + if (perm == Permissions.None) + continue; + + if (!BeforePermissions.Contains(perm)) + { + PermissionsAdded = true; + PermissionDifference += $"`+` `{perm.ToTranslatedPermissionString(this.Bot.Guilds[e.Guild.Id], this.Bot)}`\n"; + } + } + + foreach (var perm in BeforePermissions) + { + if (perm == Permissions.None) + continue; + + if (!AfterPermissions.Contains(perm)) + { + PermissionsRemoved = true; + PermissionDifference += $"`-` `{perm.ToTranslatedPermissionString(this.Bot.Guilds[e.Guild.Id], this.Bot)}`\n"; + } + } + + if (PermissionDifference.Length > 0) + if (!PermissionsAdded && PermissionsRemoved) + PermissionDifference = $"\n**{this.tKey.PermissionsRemoved.Get(this.Bot.Guilds[e.Guild.Id])}**:\n{PermissionDifference}"; + else PermissionDifference = PermissionsAdded && !PermissionsRemoved + ? $"\n**{this.tKey.PermissionsAdded.Get(this.Bot.Guilds[e.Guild.Id])}**:\n{PermissionDifference}" + : $"\n**{this.tKey.PermissionsUpdated.Get(this.Bot.Guilds[e.Guild.Id])}**:\n{PermissionDifference}"; + + var Integration = ""; + + if (e.RoleAfter.IsManaged) + { + if (e.RoleAfter.Tags?.PremiumSubscriber ?? false) + Integration = $"**{this.tKey.Integration.Get(this.Bot.Guilds[e.Guild.Id])}**: `{this.tKey.ServerBooster.Get(this.Bot.Guilds[e.Guild.Id])}`\n\n"; + + if (e.RoleAfter.Tags?.BotId is not null and not 0) + { + var bot = await sender.GetUserAsync((ulong)e.RoleAfter.Tags.BotId); + + Integration = $"**{this.tKey.Integration.Get(this.Bot.Guilds[e.Guild.Id])}**: {bot.Mention} `{bot.GetUsernameWithIdentifier()}`\n\n"; + } + } + + if (e.RoleBefore.Name == e.RoleAfter.Name && + e.RoleBefore.Color.ToHex() == e.RoleAfter.Color.ToHex() && + e.RoleBefore.IsMentionable == e.RoleAfter.IsMentionable && + e.RoleBefore.IsHoisted == e.RoleAfter.IsHoisted && + PermissionDifference.IsNullOrWhiteSpace()) + return; + + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.RoleUpdated.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.UserUpdated) + .WithColor(EmbedColors.Warning) + .WithFooter($"{this.tKey.RoleId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.RoleAfter.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithDescription($"**{this.tKey.Role.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.RoleAfter.Mention} {(e.RoleBefore.Name != e.RoleAfter.Name ? $"`{e.RoleBefore.Name}` ➡ `{e.RoleAfter.Name}`" : $"`{e.RoleAfter.Name}`")}\n" + + $"{(e.RoleBefore.Color.ToHex() != e.RoleAfter.Color.ToHex() ? $"**{this.tKey.Color.Get(this.Bot.Guilds[e.Guild.Id])}**: `{e.RoleBefore.Color.ToHex()}` ➡ `{e.RoleAfter.Color.ToHex()}`\n" : "")}" + + $"{(e.RoleBefore.IsMentionable != e.RoleAfter.IsMentionable ? $"**{this.tKey.RoleMentionable.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.RoleBefore.IsMentionable.ToPillEmote(this.Bot)} ➡ {e.RoleAfter.IsMentionable.ToPillEmote(this.Bot)}\n" : "")}" + + $"{(e.RoleBefore.IsHoisted != e.RoleAfter.IsHoisted ? $"**{this.tKey.DisplayedRoleMembers.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.RoleBefore.IsHoisted.ToPillEmote(this.Bot)} ➡ {e.RoleAfter.IsHoisted.ToPillEmote(this.Bot)}\n" : "")}" + + $"{(e.RoleAfter.IsManaged ? $"\n`{this.tKey.Integration.Get(this.Bot.Guilds[e.Guild.Id])}`\n" : "")}" + + $"{Integration}" + + $"{PermissionDifference}"); + + var msg = await this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed)); + + if (!this.Bot.Guilds[e.Guild.Id].ActionLog.AttemptGettingMoreDetails) + return; + + for (var i = 0; i < 3; i++) + { + var AuditLogEntries = await e.Guild.GetAuditLogsAsync(actionType: AuditLogActionType.RoleUpdate); + + if (AuditLogEntries.Count > 0 && AuditLogEntries.Any(x => ((DiscordAuditLogRoleUpdateEntry)x).Target.Id == e.RoleAfter.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id))) + { + var Entry = (DiscordAuditLogRoleUpdateEntry)AuditLogEntries.First(x => ((DiscordAuditLogRoleUpdateEntry)x).Target.Id == e.RoleAfter.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id)); + this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs = this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Add(Entry.Id); + + embed.Description += $"\n\n**{this.tKey.ModifiedBy.Get(this.Bot.Guilds[e.Guild.Id])}**: {Entry.UserResponsible.Mention} `{Entry.UserResponsible.GetUsernameWithIdentifier()}`"; + embed.Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail { Url = Entry.UserResponsible.AvatarUrl }; + + embed.Footer.Text += $"\n({this.tKey.FooterAuditLogDisclaimer.Get(this.Bot.Guilds[e.Guild.Id]).Build(new TVar("Fields", $"'{this.tKey.ModifiedBy.Get(this.Bot.Guilds[e.Guild.Id])}'"))})"; + + _ = msg.ModifyAsync(new DiscordMessageBuilder().WithEmbed(embed)); + break; + } + + await Task.Delay(5000); + } + } + + internal async Task BanAdded(DiscordClient sender, GuildBanAddEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.BanlistModified) + return; + + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.UserBanned.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.UserBanned) + .WithColor(EmbedColors.Error) + .WithFooter($"{this.tKey.UserId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.Member.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithThumbnail(e.Member.AvatarUrl) + .WithDescription($"**{this.tKey.User.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Member.Mention} `{e.Member.GetUsernameWithIdentifier()}`\n" + + $"**{this.tKey.JoinedAt.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Member.JoinedAt.ToTimestamp()} ({e.Member.JoinedAt.ToTimestamp(TimestampFormat.LongDateTime)})"); + + if (e.Member.Roles?.Count > 0) + { + _ = embed.AddField(new DiscordEmbedField( + this.tKey.Roles.Get(this.Bot.Guilds[e.Guild.Id]), + $"{string.Join(", ", e.Member.Roles.Select(x => x.Mention))}" + .TruncateWithIndication(1000))); + } + + var msg = await this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed)); + + if (!this.Bot.Guilds[e.Guild.Id].ActionLog.AttemptGettingMoreDetails) + return; + + for (var i = 0; i < 3; i++) + { + var AuditLogEntries = await e.Guild.GetAuditLogsAsync(actionType: AuditLogActionType.Ban); + + if (AuditLogEntries.Count > 0 && AuditLogEntries.Any(x => ((DiscordAuditLogBanEntry)x).Target.Id == e.Member.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id))) + { + var Entry = (DiscordAuditLogBanEntry)AuditLogEntries.First(x => ((DiscordAuditLogBanEntry)x).Target.Id == e.Member.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id)); + this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs = this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Add(Entry.Id); + + embed.Description += $"\n\n**{this.tKey.BannedBy.Get(this.Bot.Guilds[e.Guild.Id])}**: {Entry.UserResponsible.Mention} `{Entry.UserResponsible.GetUsernameWithIdentifier()}`"; + + if (!string.IsNullOrWhiteSpace(Entry.Reason)) + embed.Description += $"\n**{this.tKey.Reason.Get(this.Bot.Guilds[e.Guild.Id])}**: {Entry.Reason.SanitizeForCode()}"; + + embed.Footer.Text += $"\n({this.tKey.FooterAuditLogDisclaimer.Get(this.Bot.Guilds[e.Guild.Id]).Build(new TVar("Fields", $"'{this.tKey.BannedBy.Get(this.Bot.Guilds[e.Guild.Id])}' & '{this.tKey.Reason.Get(this.Bot.Guilds[e.Guild.Id])}'"))})"; + + _ = msg.ModifyAsync(new DiscordMessageBuilder().WithEmbed(embed)); + break; + } + + await Task.Delay(5000); + } + } + + internal async Task BanRemoved(DiscordClient sender, GuildBanRemoveEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.BanlistModified) + return; + + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.UserUnbanned.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.UserBanRemoved) + .WithColor(EmbedColors.Success) + .WithFooter($"{this.tKey.UserId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.Member.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithThumbnail(e.Member.AvatarUrl) + .WithDescription($"**{this.tKey.User.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Member.Mention} `{e.Member.GetUsernameWithIdentifier()}`"); + + var msg = await this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed)); + + if (!this.Bot.Guilds[e.Guild.Id].ActionLog.AttemptGettingMoreDetails) + return; + + for (var i = 0; i < 3; i++) + { + var AuditLogEntries = await e.Guild.GetAuditLogsAsync(actionType: AuditLogActionType.Unban); + + if (AuditLogEntries.Count > 0 && AuditLogEntries.Any(x => ((DiscordAuditLogBanEntry)x).Target.Id == e.Member.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id))) + { + var Entry = (DiscordAuditLogBanEntry)AuditLogEntries.First(x => ((DiscordAuditLogBanEntry)x).Target.Id == e.Member.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id)); + this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs = this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Add(Entry.Id); + + embed.Description += $"\n\n**{this.tKey.UnbannedBy.Get(this.Bot.Guilds[e.Guild.Id])}**: {Entry.UserResponsible.Mention} `{Entry.UserResponsible.GetUsernameWithIdentifier()}`"; + + embed.Footer.Text += $"\n({this.tKey.FooterAuditLogDisclaimer.Get(this.Bot.Guilds[e.Guild.Id]).Build(new TVar("Fields", $"'{this.tKey.UnbannedBy.Get(this.Bot.Guilds[e.Guild.Id])}'"))})"; + + _ = msg.ModifyAsync(new DiscordMessageBuilder().WithEmbed(embed)); + break; + } + + await Task.Delay(5000); + } + } + + internal async Task GuildUpdated(DiscordClient sender, GuildUpdateEventArgs e) + { + if (!await this.ValidateServer(e.GuildAfter) || !this.Bot.Guilds[e.GuildAfter.Id].ActionLog.GuildModified) + return; + + var Description = ""; + + try + { Description += $"{(e.GuildBefore.Owner.Id != e.GuildAfter.Owner.Id ? $"**{this.tKey.Owner.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: {e.GuildBefore.Owner.Mention} `{e.GuildBefore.Owner.GetUsernameWithIdentifier()}` ➡ {e.GuildAfter.Owner.Mention} `{e.GuildAfter.Owner.GetUsernameWithIdentifier()}`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.Name != e.GuildAfter.Name ? $"**{this.tKey.Name.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: `{e.GuildBefore.Name}` ➡ `{e.GuildAfter.Name}`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.Description != e.GuildAfter.Description ? $"**{this.tKey.Description.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: `{e.GuildBefore.Description}` ➡ `{e.GuildAfter.Description}`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.PreferredLocale != e.GuildAfter.PreferredLocale ? $"**{this.tKey.PreferredLocale.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: `{e.GuildBefore.PreferredLocale}` ➡ `{e.GuildAfter.PreferredLocale}`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.VanityUrlCode != e.GuildAfter.VanityUrlCode ? $"**{this.tKey.VanityUrl.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: `{e.GuildBefore.VanityUrlCode}` ➡ `{e.GuildAfter.VanityUrlCode}`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.IconHash != e.GuildAfter.IconHash ? $"`{this.tKey.IconUpdated.Get(this.Bot.Guilds[e.GuildAfter.Id])}`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.DefaultMessageNotifications != e.GuildAfter.DefaultMessageNotifications ? $"**{this.tKey.DefaultNotificationSettings.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: `{e.GuildBefore.DefaultMessageNotifications}` ➡ `{e.GuildAfter.DefaultMessageNotifications}`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.VerificationLevel != e.GuildAfter.VerificationLevel ? $"**{this.tKey.VerificationLevel.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: `{e.GuildBefore.VerificationLevel}` ➡ `{e.GuildAfter.VerificationLevel}`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.BannerHash != e.GuildAfter.BannerHash ? $"`{this.tKey.BannerUpdated.Get(this.Bot.Guilds[e.GuildAfter.Id])}`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.SplashHash != e.GuildAfter.SplashHash ? $"`{this.tKey.SplashUpdated.Get(this.Bot.Guilds[e.GuildAfter.Id])}`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.HomeHeaderHash != e.GuildAfter.HomeHeaderHash ? $"`{this.tKey.HomeHeaderUpdated.Get(this.Bot.Guilds[e.GuildAfter.Id])}`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.DiscoverySplashHash != e.GuildAfter.DiscoverySplashHash ? $"`{this.tKey.DiscoverySplashUpdated.Get(this.Bot.Guilds[e.GuildAfter.Id])}`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.MfaLevel != e.GuildAfter.MfaLevel ? $"**{this.tKey.RequiredMfaLevel}**: {(e.GuildBefore.MfaLevel == MfaLevel.Enabled).ToPillEmote(this.Bot)} ➡ {(e.GuildAfter.MfaLevel == MfaLevel.Enabled).ToPillEmote(this.Bot)}\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.ExplicitContentFilter != e.GuildAfter.ExplicitContentFilter ? $"**{this.tKey.ExplicitContentFilter.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: `{e.GuildBefore.ExplicitContentFilter}` ➡ `{e.GuildAfter.ExplicitContentFilter}`\n" : "")}"; } + catch { } + try + { Description += $"{((e.GuildBefore.WidgetEnabled ?? false) != (e.GuildAfter.WidgetEnabled ?? false) ? $"**{this.tKey.GuildWidgetEnabled.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: {(e.GuildBefore.WidgetEnabled ?? false).ToPillEmote(this.Bot)} ➡ {(e.GuildAfter.WidgetEnabled ?? false).ToPillEmote(this.Bot)}\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.WidgetChannel?.Id != e.GuildAfter.WidgetChannel?.Id ? $"**{this.tKey.GuildWidgetChannel.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: {e.GuildBefore.WidgetChannel.Mention} `[{e.GuildBefore.WidgetChannel.GetIcon()}{e.GuildBefore.WidgetChannel.Name}]` ➡ {e.GuildAfter.WidgetChannel.Mention} `[{e.GuildAfter.WidgetChannel.GetIcon()}{e.GuildAfter.WidgetChannel.Name}]`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.IsLarge != e.GuildAfter.IsLarge ? $"**{this.tKey.LargeGuild.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: {e.GuildBefore.IsLarge.ToPillEmote(this.Bot)} ➡ {e.GuildAfter.IsLarge.ToPillEmote(this.Bot)}\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.IsNsfw != e.GuildAfter.IsNsfw ? $"**{this.tKey.NsfwGuild.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: {e.GuildBefore.IsNsfw.ToPillEmote(this.Bot)} ➡ {e.GuildAfter.IsNsfw.ToPillEmote(this.Bot)}\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.IsCommunity != e.GuildAfter.IsCommunity ? $"**{this.tKey.CommunityGuild.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: {e.GuildBefore.IsCommunity.ToPillEmote(this.Bot)} ➡ {e.GuildAfter.IsCommunity.ToPillEmote(this.Bot)}\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.HasMemberVerificationGate != e.GuildAfter.HasMemberVerificationGate ? $"**{this.tKey.MembershipScreening.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: {e.GuildBefore.HasMemberVerificationGate.ToPillEmote(this.Bot)} ➡ {e.GuildAfter.HasMemberVerificationGate.ToPillEmote(this.Bot)}\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.HasWelcomeScreen != e.GuildAfter.HasWelcomeScreen ? $"**{this.tKey.WelcomeScreen.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: {e.GuildBefore.HasWelcomeScreen.ToPillEmote(this.Bot)} ➡ {e.GuildAfter.HasWelcomeScreen.ToPillEmote(this.Bot)}\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.PremiumProgressBarEnabled != e.GuildAfter.PremiumProgressBarEnabled ? $"**{this.tKey.BoostProgressBar.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: {e.GuildBefore.PremiumProgressBarEnabled.ToPillEmote(this.Bot)} ➡ {e.GuildAfter.PremiumProgressBarEnabled.ToPillEmote(this.Bot)}\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.RulesChannel?.Id != e.GuildAfter.RulesChannel?.Id ? $"**{this.tKey.RuleChannel.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: {e.GuildBefore.RulesChannel.Mention} `[{e.GuildBefore.RulesChannel.GetIcon()}{e.GuildBefore.RulesChannel.Name}]` ➡ {e.GuildAfter.RulesChannel.Mention} `[{e.GuildAfter.RulesChannel.GetIcon()}{e.GuildAfter.RulesChannel.Name}]`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.AfkTimeout != e.GuildAfter.AfkTimeout ? $"**{this.tKey.AfkTimeout.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: `{TimeSpan.FromSeconds(e.GuildBefore.AfkTimeout.ToDouble()).GetHumanReadable(config: TranslationUtil.GetTranslatedHumanReadableConfig(this.Bot.Guilds[e.GuildAfter.Id], this.Bot))}` ➡ `{TimeSpan.FromSeconds(e.GuildAfter.AfkTimeout ?? 0).GetHumanReadable(config: TranslationUtil.GetTranslatedHumanReadableConfig(this.Bot.Guilds[e.GuildAfter.Id], this.Bot))}`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.AfkChannel?.Id != e.GuildAfter.AfkChannel?.Id ? $"**{this.tKey.AfkChannel.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: {e.GuildBefore.AfkChannel?.Mention} `[{e.GuildBefore.AfkChannel?.GetIcon()}{e.GuildBefore.AfkChannel?.Name}]` ➡ {e.GuildAfter.AfkChannel?.Mention} `[{e.GuildAfter.AfkChannel?.GetIcon()}{e.GuildAfter.AfkChannel?.Name}]`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.SystemChannel?.Id != e.GuildAfter.SystemChannel?.Id ? $"**{this.tKey.SystemChannel.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: {e.GuildBefore.SystemChannel?.Mention} `[{e.GuildBefore.SystemChannel?.GetIcon()}{e.GuildBefore.SystemChannel?.Name}]` ➡ {e.GuildAfter.SystemChannel?.Mention} `[{e.GuildAfter.SystemChannel?.GetIcon()}{e.GuildAfter.SystemChannel?.Name}]`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.PublicUpdatesChannel?.Id != e.GuildAfter.PublicUpdatesChannel?.Id ? $"**{this.tKey.DiscordUpdateChannel.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: {e.GuildBefore.PublicUpdatesChannel?.Mention} `[{e.GuildBefore.PublicUpdatesChannel?.GetIcon()}{e.GuildBefore.PublicUpdatesChannel?.Name}]` ➡ {e.GuildAfter.PublicUpdatesChannel?.Mention} `[{e.GuildAfter.PublicUpdatesChannel?.GetIcon()}{e.GuildAfter.PublicUpdatesChannel?.Name}]`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.SafetyAltersChannel?.Id != e.GuildAfter.SafetyAltersChannel?.Id ? $"**{this.tKey.SafetyAlertsChannel.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: {e.GuildBefore.SafetyAltersChannel?.Mention} `[{e.GuildBefore.SafetyAltersChannel?.GetIcon()}{e.GuildBefore.SafetyAltersChannel?.Name}]` ➡ {e.GuildAfter.SafetyAltersChannel?.Mention} `[{e.GuildAfter.SafetyAltersChannel?.GetIcon()}{e.GuildAfter.SafetyAltersChannel?.Name}]`\n" : "")}"; } + catch { } + try + { Description += $"{(e.GuildBefore.MaxMembers != e.GuildAfter.MaxMembers ? $"**{this.tKey.MaximumMembers.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: `{e.GuildBefore.MaxMembers}` ➡ `{e.GuildAfter.MaxMembers}`\n" : "")}"; } + catch { } + + if (Description.Length == 0) + return; + + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.GuildUpdated.Get(this.Bot.Guilds[e.GuildAfter.Id]), null, AuditLogIcons.GuildUpdated) + .WithColor(EmbedColors.Warning) + .WithTimestamp(DateTime.UtcNow) + .WithThumbnail(e.GuildAfter.IconUrl) + .WithDescription(Description); + + if (e.GuildBefore.IconHash != e.GuildAfter.IconHash) + embed.ImageUrl = e.GuildAfter.IconUrl; + + var msg = await this.SendActionlog(e.GuildAfter, new DiscordMessageBuilder().WithEmbed(embed)); + + if (!this.Bot.Guilds[e.GuildAfter.Id].ActionLog.AttemptGettingMoreDetails) + return; + + for (var i = 0; i < 3; i++) + { + var AuditLogEntries = await e.GuildAfter.GetAuditLogsAsync(actionType: AuditLogActionType.GuildUpdate); + + if (AuditLogEntries.Count > 0 && AuditLogEntries.Any(x => (!this.Bot.Guilds[e.GuildAfter.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id)))) + { + var Entry = (DiscordAuditLogGuildEntry)AuditLogEntries.First(x => !this.Bot.Guilds[e.GuildAfter.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id)); + this.Bot.Guilds[e.GuildAfter.Id].ActionLog.ProcessedAuditLogs = this.Bot.Guilds[e.GuildAfter.Id].ActionLog.ProcessedAuditLogs.Add(Entry.Id); + + embed.Description += $"\n\n**{this.tKey.ModifiedBy.Get(this.Bot.Guilds[e.GuildAfter.Id])}**: {Entry.UserResponsible.Mention} `{Entry.UserResponsible.GetUsernameWithIdentifier()}`"; + + embed.Footer = new(); + embed.Footer.Text += $"\n({this.tKey.FooterAuditLogDisclaimer.Get(this.Bot.Guilds[e.GuildAfter.Id]).Build(new TVar("Fields", $"'{this.tKey.ModifiedBy.Get(this.Bot.Guilds[e.GuildAfter.Id])}'"))})"; + + _ = msg.ModifyAsync(new DiscordMessageBuilder().WithEmbed(embed)); + break; + } + + await Task.Delay(5000); + } + } + + internal async Task ChannelCreated(DiscordClient sender, ChannelCreateEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.ChannelsModified) + return; + + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.ChannelCreated.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.ChannelAdded) + .WithColor(EmbedColors.Success) + .WithFooter($"{this.tKey.ChannelId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.Channel.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithDescription($"**{this.tKey.Name.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Channel.Mention} `[{e.Channel.GetIcon()}{e.Channel.Name}]`"); + + var msg = await this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed)); + + if (!this.Bot.Guilds[e.Guild.Id].ActionLog.AttemptGettingMoreDetails) + return; + + for (var i = 0; i < 3; i++) + { + var AuditLogEntries = await e.Guild.GetAuditLogsAsync(actionType: AuditLogActionType.ChannelCreate); + + if (AuditLogEntries.Count > 0 && AuditLogEntries.Any(x => ((DiscordAuditLogChannelEntry)x).Target.Id == e.Channel.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id))) + { + var Entry = (DiscordAuditLogChannelEntry)AuditLogEntries.First(x => ((DiscordAuditLogChannelEntry)x).Target.Id == e.Channel.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id)); + this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs = this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Add(Entry.Id); + + embed.Description += $"\n\n**{this.tKey.CreatedBy.Get(this.Bot.Guilds[e.Guild.Id])}**: {Entry.UserResponsible.Mention} `{Entry.UserResponsible.GetUsernameWithIdentifier()}`"; + embed.Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail { Url = Entry.UserResponsible.AvatarUrl }; + + embed.Footer.Text += $"\n({this.tKey.FooterAuditLogDisclaimer.Get(this.Bot.Guilds[e.Guild.Id]).Build(new TVar("Fields", $"'{this.tKey.CreatedBy.Get(this.Bot.Guilds[e.Guild.Id])}'"))})"; + + _ = msg.ModifyAsync(new DiscordMessageBuilder().WithEmbed(embed)); + break; + } + + await Task.Delay(5000); + } + } + + internal async Task ChannelDeleted(DiscordClient sender, ChannelDeleteEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.ChannelsModified) + return; + + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.ChannelDeleted.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.ChannelRemoved) + .WithColor(EmbedColors.Error) + .WithFooter($"{this.tKey.ChannelId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.Channel.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithDescription($"**{this.tKey.Name.Get(this.Bot.Guilds[e.Guild.Id])}**: `[{e.Channel.GetIcon()}{e.Channel.Name}]`"); + + var msg = await this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed)); + + if (!this.Bot.Guilds[e.Guild.Id].ActionLog.AttemptGettingMoreDetails) + return; + + for (var i = 0; i < 3; i++) + { + var AuditLogEntries = await e.Guild.GetAuditLogsAsync(actionType: AuditLogActionType.ChannelDelete); + + if (AuditLogEntries.Count > 0 && AuditLogEntries.Any(x => ((DiscordAuditLogChannelEntry)x).Target.Id == e.Channel.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id))) + { + var Entry = (DiscordAuditLogChannelEntry)AuditLogEntries.First(x => ((DiscordAuditLogChannelEntry)x).Target.Id == e.Channel.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id)); + this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs = this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Add(Entry.Id); + + embed.Description += $"\n\n**{this.tKey.DeletedBy.Get(this.Bot.Guilds[e.Guild.Id])}**: {Entry.UserResponsible.Mention} `{Entry.UserResponsible.GetUsernameWithIdentifier()}`"; + embed.Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail { Url = Entry.UserResponsible.AvatarUrl }; + + embed.Footer.Text += $"\n({this.tKey.FooterAuditLogDisclaimer.Get(this.Bot.Guilds[e.Guild.Id]).Build(new TVar("Fields", $"'{this.tKey.DeletedBy.Get(this.Bot.Guilds[e.Guild.Id])}'"))})"; + + _ = msg.ModifyAsync(new DiscordMessageBuilder().WithEmbed(embed)); + break; + } + + await Task.Delay(5000); + } + } + + internal async Task ChannelUpdated(DiscordClient sender, ChannelUpdateEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.ChannelsModified) + return; + + if (e.ChannelBefore?.Name == e.ChannelAfter?.Name && e.ChannelBefore?.IsNsfw == e.ChannelAfter?.IsNsfw) + return; + + var Description = $"{(e.ChannelBefore.Name != e.ChannelAfter.Name ? $"**{this.tKey.Name.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.ChannelBefore.Mention} `[{e.ChannelBefore.GetIcon()}{e.ChannelBefore.Name}]` ➡ `[{e.ChannelAfter.GetIcon()}{e.ChannelAfter.Name}]`\n" : $"{e.ChannelAfter.Mention} `[{e.ChannelAfter}{e.ChannelAfter.Name}]`\n")}" + + $"{(e.ChannelBefore.IsNsfw != e.ChannelAfter.IsNsfw ? $"**{this.tKey.NsfwChannel.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.ChannelBefore.IsNsfw.ToPillEmote(this.Bot)} ➡ {e.ChannelAfter.IsNsfw.ToPillEmote(this.Bot)}\n" : "")}" + + $"{(e.ChannelBefore.DefaultAutoArchiveDuration != e.ChannelAfter.DefaultAutoArchiveDuration ? $"**{this.tKey.DefaultAutoArchiveDuration.Get(this.Bot.Guilds[e.Guild.Id])}**: `{e.ChannelBefore.DefaultAutoArchiveDuration}` ➡ `{e.ChannelAfter.DefaultAutoArchiveDuration}`\n" : "")}" + + $"{(e.ChannelBefore.Bitrate != e.ChannelAfter.Bitrate ? $"**{this.tKey.Bitrate.Get(this.Bot.Guilds[e.Guild.Id])}**: `{e.ChannelBefore.Bitrate?.FileSizeToHumanReadable()}` ➡ `{e.ChannelAfter.Bitrate?.FileSizeToHumanReadable()}`\n" : "")}"; + + if (Description.Length == 0) + return; + + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.ChannelModified.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.ChannelModified) + .WithColor(EmbedColors.Warning) + .WithFooter($"{this.tKey.ChannelId.Get(this.Bot.Guilds[e.Guild.Id])}: {e.ChannelAfter.Id}") + .WithTimestamp(DateTime.UtcNow) + .WithDescription(Description); + + var msg = await this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed)); + + if (!this.Bot.Guilds[e.Guild.Id].ActionLog.AttemptGettingMoreDetails) + return; + + for (var i = 0; i < 3; i++) + { + var AuditLogEntries = await e.Guild.GetAuditLogsAsync(actionType: AuditLogActionType.ChannelUpdate); + + if (AuditLogEntries.Count > 0 && AuditLogEntries.Any(x => ((DiscordAuditLogChannelEntry)x).Target.Id == e.ChannelAfter.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id))) + { + var Entry = (DiscordAuditLogChannelEntry)AuditLogEntries.First(x => ((DiscordAuditLogChannelEntry)x).Target.Id == e.ChannelAfter.Id && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id)); + this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs = this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs = this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Add(Entry.Id); + + embed.Description += $"\n\n**{this.tKey.ModifiedBy.Get(this.Bot.Guilds[e.Guild.Id])}**: {Entry.UserResponsible.Mention} `{Entry.UserResponsible.GetUsernameWithIdentifier()}`"; + embed.Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail { Url = Entry.UserResponsible.AvatarUrl }; + + embed.Footer.Text += $"\n({this.tKey.FooterAuditLogDisclaimer.Get(this.Bot.Guilds[e.Guild.Id]).Build(new TVar("Fields", $"'{this.tKey.ModifiedBy.Get(this.Bot.Guilds[e.Guild.Id])}'"))})"; + + _ = msg.ModifyAsync(new DiscordMessageBuilder().WithEmbed(embed)); + break; + } + + await Task.Delay(5000); + } + } + + internal async Task InviteCreated(DiscordClient sender, InviteCreateEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.InvitesModified) + return; + + _ = this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder() + .WithAuthor(this.tKey.InviteCreated.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.InviteAdded) + .WithColor(EmbedColors.Success) + .WithTimestamp(DateTime.UtcNow) + .WithDescription($"**{this.tKey.Invite.Get(this.Bot.Guilds[e.Guild.Id])}**: `https://discord.gg/{e.Invite.Code}`\n" + + $"**{this.tKey.CreatedBy.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Invite.Inviter?.Mention ?? this.tKey.NoInviter.Get(this.Bot.Guilds[e.Guild.Id]).Build(true)} `{e.Invite.Inviter?.GetUsernameWithIdentifier() ?? "-"}`\n" + + $"**{this.tKey.Channel.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Channel.Mention} `[{e.Channel.GetIcon()}{e.Channel.Name}]`"))); + } + + internal async Task InviteDeleted(DiscordClient sender, InviteDeleteEventArgs e) + { + if (!await this.ValidateServer(e.Guild) || !this.Bot.Guilds[e.Guild.Id].ActionLog.InvitesModified) + return; + + var embed = new DiscordEmbedBuilder() + .WithAuthor(this.tKey.InviteDeleted.Get(this.Bot.Guilds[e.Guild.Id]), null, AuditLogIcons.InviteRemoved) + .WithColor(EmbedColors.Error) + .WithTimestamp(DateTime.UtcNow) + .WithDescription($"**{this.tKey.Invite.Get(this.Bot.Guilds[e.Guild.Id])}**: `https://discord.gg/{e.Invite.Code}`\n" + + $"**{this.tKey.CreatedBy.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Invite.Inviter?.Mention ?? this.tKey.NoInviter.Get(this.Bot.Guilds[e.Guild.Id]).Build(true)} `{e.Invite.Inviter?.GetUsernameWithIdentifier() ?? "-"}`\n" + + $"**{this.tKey.Channel.Get(this.Bot.Guilds[e.Guild.Id])}**: {e.Channel.Mention} `[{e.Channel.GetIcon()}{e.Channel.Name}]`"); + + var msg = await this.SendActionlog(e.Guild, new DiscordMessageBuilder().WithEmbed(embed)); + + + if (!this.Bot.Guilds[e.Guild.Id].ActionLog.AttemptGettingMoreDetails) + return; + + for (var i = 0; i < 3; i++) + { + var AuditLogEntries = await e.Guild.GetAuditLogsAsync(actionType: AuditLogActionType.InviteDelete); + + if (AuditLogEntries.Count > 0 && AuditLogEntries.Any(x => ((DiscordAuditLogInviteEntry)x).Target.Code == e.Invite.Code && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id))) + { + var Entry = (DiscordAuditLogInviteEntry)AuditLogEntries.First(x => ((DiscordAuditLogInviteEntry)x).Target.Code == e.Invite.Code && !this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Contains(x.Id)); + this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs = this.Bot.Guilds[e.Guild.Id].ActionLog.ProcessedAuditLogs.Add(Entry.Id); + + embed.Description += $"\n\n**{this.tKey.DeletedBy.Get(this.Bot.Guilds[e.Guild.Id])}**: {Entry.UserResponsible.Mention} `{Entry.UserResponsible.GetUsernameWithIdentifier()}`"; + embed.Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail { Url = Entry.UserResponsible.AvatarUrl }; + + embed.Footer = new(); + embed.Footer.Text += $"\n({this.tKey.FooterAuditLogDisclaimer.Get(this.Bot.Guilds[e.Guild.Id]).Build(new TVar("Fields", $"'{this.tKey.DeletedBy.Get(this.Bot.Guilds[e.Guild.Id])}'"))})"; + + _ = msg.ModifyAsync(new DiscordMessageBuilder().WithEmbed(embed)); + break; + } + + await Task.Delay(5000); + } + } +} diff --git a/ProjectMakoto/Events/AutoUnarchiveEvents.cs b/ProjectMakoto/Events/AutoUnarchiveEvents.cs new file mode 100644 index 00000000..bd04e55b --- /dev/null +++ b/ProjectMakoto/Events/AutoUnarchiveEvents.cs @@ -0,0 +1,23 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Events; + +internal sealed class AutoUnarchiveEvents(Bot bot) : RequiresTranslation(bot) +{ + internal async Task ThreadUpdated(DiscordClient sender, ThreadUpdateEventArgs e) + { + await Task.Delay(5000); + if (this.Bot.Guilds[e.Guild.Id].AutoUnarchiveThreads.Contains(e.ThreadAfter.Parent.Id)) + { + if (e.ThreadAfter.ThreadMetadata.Archived && (!e.ThreadAfter.ThreadMetadata.Locked ?? false)) + _ = e.ThreadAfter.UnarchiveAsync(); + } + } +} diff --git a/ProjectMakoto/Events/BumpReminderEvents.cs b/ProjectMakoto/Events/BumpReminderEvents.cs new file mode 100644 index 00000000..3c0c031d --- /dev/null +++ b/ProjectMakoto/Events/BumpReminderEvents.cs @@ -0,0 +1,144 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto; + +internal sealed class BumpReminderEvents(Bot bot) : RequiresTranslation(bot) +{ + Translations.events.bumpReminder tKey + => this.Bot.LoadedTranslations.Events.BumpReminder; + + internal async Task MessageCreated(DiscordClient sender, MessageCreateEventArgs e) + { + if (e.Guild is null || e.Channel is null || e.Channel.IsPrivate || this.Bot.Guilds[e.Guild.Id].BumpReminder.ChannelId == 0 || e.Channel.Id != this.Bot.Guilds[e.Guild.Id].BumpReminder.ChannelId) + return; + + var bUser = await sender.GetUserAsync(this.Bot.Guilds[e.Guild.Id].BumpReminder.LastUserId); + + if (!(e.Author.Id == sender.CurrentUser.Id && e.Message.Embeds.Any())) + this.Bot.BumpReminder.SendPersistentMessage(sender, e.Channel, bUser); + + if (e.Author.Id != this.Bot.status.LoadedConfig.Accounts.Disboard || !e.Message.Embeds.Any()) + return; + + if (e.Message.Embeds[0].Description.ToLower().Contains(":thumbsup:")) + { + this.Bot.Guilds[e.Guild.Id].BumpReminder.LastBump = DateTime.UtcNow; + this.Bot.Guilds[e.Guild.Id].BumpReminder.LastReminder = DateTime.UtcNow; + this.Bot.Guilds[e.Guild.Id].BumpReminder.BumpsMissed = 0; + + try + { + DiscordMember _bumper; + + if (e.Message.MessageType is MessageType.ChatInputCommand) + { + _bumper = await e.Message.Interaction.User.ConvertToMember(e.Guild); + } + else + { + var Mentions = e.Message.Embeds[0].Description.ToLower().GetMentions(); + + if (Mentions is null || Mentions.Count is 0) + throw new Exception("No mentions in message"); + + _bumper = await e.Guild.GetMemberAsync(Convert.ToUInt64(Regex.Match(Mentions.First(), @"\d+").Value)); + } + + this.Bot.Guilds[e.Guild.Id].BumpReminder.LastUserId = _bumper.Id; + + _ = e.Channel.SendMessageAsync($"**{this.tKey.ServerBumped.Get(this.Bot.Guilds[e.Guild.Id]).Build(new TVar("User", _bumper.Mention))}**\n\n" + + $"_**{this.tKey.SubscribeRoleNotice.Get(this.Bot.Guilds[e.Guild.Id])}**_"); + + try + { + if (this.Bot.Guilds[e.Guild.Id].Experience.UseExperience && this.Bot.Guilds[e.Guild.Id].Experience.BoostXpForBumpReminder) + _ = this.Bot.ExperienceHandler.ModifyExperience(_bumper, e.Guild, e.Channel, 50); + } + catch { } + + this.Bot.BumpReminder.ScheduleBump(sender, e.Guild.Id); + } + catch (Exception) + { + this.Bot.Guilds[e.Guild.Id].BumpReminder.LastUserId = 0; + this.Bot.BumpReminder.ScheduleBump(sender, e.Guild.Id); + + throw; + } + } + // This no longer works, bump errors are now ephemeral. + + //else + //{ + // if (this.Bot.Guilds[e.Guild.Id].BumpReminder.LastBump < DateTime.UtcNow.AddHours(-2)) + // { + // if (e.Message.Embeds[0].Description.ToLower().Contains("please wait another")) + // { + // string _embedDescription = e.Message.Embeds[0].Description.ToLower(); + + // try + // { + // _embedDescription = _embedDescription.Remove(0, _embedDescription.IndexOf(">")); + // int _minutes = Int32.Parse(Regex.Match(_embedDescription, @"\d+").Value); + + // this.Bot.Guilds[e.Guild.Id].BumpReminder.LastBump = DateTime.UtcNow.AddMinutes(_minutes - 120); + // this.Bot.Guilds[e.Guild.Id].BumpReminder.LastReminder = DateTime.UtcNow.AddMinutes(_minutes - 120); + // this.Bot.Guilds[e.Guild.Id].BumpReminder.LastUserId = 0; + + // e.Channel.SendMessageAsync($"⚠ It seems the last bump was not registered properly.\n" + + // $"The last time the server was bumped was determined to be around {Formatter.Timestamp(this.Bot.Guilds[e.Guild.Id].BumpReminder.LastBump, TimestampFormat.LongDateTime)}.").Add(this.Bot); + + // this.Bot.BumpReminder.ScheduleBump(sender, e.Guild.Id); + // } + // catch (Exception ex) { Log.Debug(ex.ToString()); } + // } + // } + //} + } + + internal async Task MessageDeleted(DiscordClient sender, MessageDeleteEventArgs e) + { + if (e.Guild == null || e.Channel.IsPrivate || this.Bot.Guilds[e.Guild.Id].BumpReminder.ChannelId == 0 || e.Channel.Id != this.Bot.Guilds[e.Guild.Id].BumpReminder.ChannelId) + return; + + if (e.Message.Id == this.Bot.Guilds[e.Guild.Id].BumpReminder.PersistentMessageId) + { + var bUser = await sender.GetUserAsync(this.Bot.Guilds[e.Guild.Id].BumpReminder.LastUserId); + + this.Bot.BumpReminder.SendPersistentMessage(sender, e.Channel, bUser); + } + } + + internal async Task ReactionAdded(DiscordClient sender, MessageReactionAddEventArgs e) + { + if (e.Guild == null || e.Channel.IsPrivate || this.Bot.Guilds[e.Guild.Id].BumpReminder.ChannelId == 0 || e.Channel.Id != this.Bot.Guilds[e.Guild.Id].BumpReminder.ChannelId) + return; + + if (e.Message.Id == this.Bot.Guilds[e.Guild.Id].BumpReminder.MessageId && e.Emoji.ToString() == "✅") + { + var member = await e.Guild.GetMemberAsync(e.User.Id); + + await member.GrantRoleAsync(e.Guild.GetRole(this.Bot.Guilds[e.Guild.Id].BumpReminder.RoleId)); + } + } + + internal async Task ReactionRemoved(DiscordClient sender, MessageReactionRemoveEventArgs e) + { + if (e.Guild == null || e.Channel.IsPrivate || this.Bot.Guilds[e.Guild.Id].BumpReminder.ChannelId == 0 || e.Channel.Id != this.Bot.Guilds[e.Guild.Id].BumpReminder.ChannelId) + return; + + if (e.Message.Id == this.Bot.Guilds[e.Guild.Id].BumpReminder.MessageId && e.Emoji.ToString() == "✅") + { + var member = await e.Guild.GetMemberAsync(e.User.Id); + + await member.RevokeRoleAsync(e.Guild.GetRole(this.Bot.Guilds[e.Guild.Id].BumpReminder.RoleId)); + } + } +} diff --git a/ProjectMakoto/Events/CommandEvents.cs b/ProjectMakoto/Events/CommandEvents.cs new file mode 100644 index 00000000..2fa72480 --- /dev/null +++ b/ProjectMakoto/Events/CommandEvents.cs @@ -0,0 +1,84 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Events; + +internal sealed class CommandEvents(Bot bot) : RequiresTranslation(bot) +{ + internal async Task CommandExecuted(CommandsNextExtension sender, CommandExecutionEventArgs e) + { + Log.Debug("Successfully started execution of '{Prefix}{Name}' for {User} on {Guild} ({ResponseTime}ms)", + e.Context.Prefix, + (e.Command.Parent is not null ? $"{e.Command.Parent.Name} " : "") + e.Command.Name, + e.Context.User.Id, + e.Context.Guild?.Id, + e.Context.Message.CreationTimestamp.GetTimespanSince().Milliseconds); + + try + { + if (e.Command.CustomAttributes.Any(x => x.GetType() == typeof(PreventCommandDeletionAttribute))) + { + if (e.Command.CustomAttributes.OfType().FirstOrDefault().PreventDeleteCommandMessage) + return; + } + } + catch { } + + _ = Task.Delay(2000).ContinueWith(x => + { + _ = e.Context.Message.DeleteAsync(); + }); + } + + internal async Task CommandError(CommandsNextExtension sender, CommandErrorEventArgs e) + { + if (e.Command is not null) + if (e.Exception.GetType() == typeof(ArgumentException)) + { + if (e.Command is not null) + Log.Warning("Failed to execute '{Prefix}{Name}' for {User} on {Guild} ({ResponseTime}ms)", + e.Context.Prefix, + (e.Command.Parent is not null ? $"{e.Command.Parent.Name} " : "") + e.Command.Name, + e.Context.User.Id, + e.Context.Guild?.Id, + e.Context.Message.CreationTimestamp.GetTimespanSince().Milliseconds); + + _ = e.Context.SendSyntaxError(); + + _ = Task.Delay(2000).ContinueWith(x => + { + _ = e.Context.Message.DeleteAsync(); + }); + } + else if (e.Exception.GetType() == typeof(CancelException)) + { + return; + } + else + { + Log.Error("Failed to execute '{Prefix}{Name}' for {User} on {Guild} ({ResponseTime}ms)", + e.Context.Prefix, + (e.Command.Parent is not null ? $"{e.Command.Parent.Name} " : "") + e.Command.Name, + e.Context.User.Id, + e.Context.Guild?.Id, + e.Context.Message.CreationTimestamp.GetTimespanSince().Milliseconds); + + try + { + _ = e.Context.Channel.SendMessageAsync($"{e.Context.User.Mention}\n:warning: `I'm sorry but an unhandled exception occurred while trying to execute your command.`"); + } + catch { } + + _ = Task.Delay(2000).ContinueWith(x => + { + _ = e.Context.Message.DeleteAsync(); + }); + } + } +} diff --git a/ProjectMakoto/Events/CrosspostEvents.cs b/ProjectMakoto/Events/CrosspostEvents.cs new file mode 100644 index 00000000..5431fa82 --- /dev/null +++ b/ProjectMakoto/Events/CrosspostEvents.cs @@ -0,0 +1,57 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Events; + +internal sealed class CrosspostEvents(Bot bot) : RequiresTranslation(bot) +{ + internal async Task MessageCreated(DiscordClient sender, MessageCreateEventArgs e) + { + if (e.Guild is null || e.Channel.IsPrivate) + return; + + if (!this.Bot.Guilds[e.Guild.Id].Crosspost.CrosspostChannels.Contains(e.Channel.Id)) + return; + + if (e.Message.Reference is not null || e.Message.MessageType is MessageType.ChannelPinnedMessage or MessageType.GuildMemberJoin or MessageType.ChannelFollowAdd or MessageType.ChatInputCommand or MessageType.ContextMenuCommand) + return; + + if (e.Channel.Type == ChannelType.News) + { + if (this.Bot.Guilds[e.Guild.Id].Crosspost.ExcludeBots) + if (e.Message.WebhookMessage || e.Message.Author.IsBot) + return; + + if (this.Bot.Guilds[e.Guild.Id].Crosspost.DelayBeforePosting > 3) + _ = e.Message.CreateReactionAsync(DiscordEmoji.FromUnicode("🕒")); + + await Task.Delay(TimeSpan.FromSeconds(this.Bot.Guilds[e.Guild.Id].Crosspost.DelayBeforePosting)); + + if (this.Bot.Guilds[e.Guild.Id].Crosspost.DelayBeforePosting > 3) + _ = e.Message.DeleteReactionsEmojiAsync(DiscordEmoji.FromUnicode("🕒")); + + DiscordMessage msg; + + try + { + msg = await e.Channel.GetMessageAsync(e.Message.Id); + } + catch (DisCatSharp.Exceptions.NotFoundException) + { + return; + } + catch (Exception) + { + throw; + } + + await this.Bot.Guilds[e.Guild.Id].Crosspost.CrosspostWithRatelimit(sender, msg); + } + } +} diff --git a/ProjectMakoto/Events/DiscordEvents.cs b/ProjectMakoto/Events/DiscordEvents.cs new file mode 100644 index 00000000..53aef7e5 --- /dev/null +++ b/ProjectMakoto/Events/DiscordEvents.cs @@ -0,0 +1,64 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Events; + +internal sealed class DiscordEvents(Bot bot) : RequiresTranslation(bot) +{ + Translations.events.genericEvent tKey + => this.Bot.LoadedTranslations.Events.GenericEvent; + + internal async Task GuildCreated(DiscordClient sender, GuildCreateEventArgs e) + { + if (this.Bot.objectedUsers.Contains(e.Guild.OwnerId.Value) || this.Bot.bannedUsers.ContainsKey(e.Guild.OwnerId.Value) || this.Bot.bannedGuilds.ContainsKey(e.Guild?.Id ?? 0)) + { + await Task.Delay(1000); + Log.Information("Leaving guild '{Guild}'..", e.Guild.Id); + await e.Guild.LeaveAsync(); + return; + } + + DiscordChannel channel; + + try + { + channel = e.Guild.SystemChannel is null + ? e.Guild.Channels.Values.OrderBy(x => x.Position).First(x => x.Type == ChannelType.Text && x.Id != x.Guild.RulesChannel?.Id) + : e.Guild.SystemChannel; + } + catch (Exception) { return; } + + if (sender.Guilds.Count >= 100 && (!sender.CurrentUser.IsVerifiedBot || !this.Bot.status.LoadedConfig.AllowMoreThan100Guilds)) + { + _ = await channel.SendMessageAsync(this.tKey.LimitedReached.Get(this.Bot.Guilds[e.Guild.Id]).Build( + new TVar("IntentsUrl", ""), + new TVar("Invite", $"<{this.Bot.status.DevelopmentServerInvite}>"))); + + await Task.Delay(1000); + await e.Guild.LeaveAsync(); + return; + } + + var msg = await channel.SendMessageAsync(this.tKey.SuccessfulJoin.Get(this.Bot.Guilds[e.Guild.Id]).Build(false, true, + new TVar("Bot", sender.CurrentUser.GetUsername()), + new TVar("BotMention", sender.CurrentUser.Mention), + new TVar("Help", sender.GetCommandMention(this.Bot, "help")), + new TVar("Phishing", "`/config phishing`"), + new TVar("TokenDetection", "`/config tokendetection`"), + new TVar("Join", "`/config join`"), + new TVar("Invite", $"<{this.Bot.status.DevelopmentServerInvite}>"), + new TVar("GithubRepo", ""), + new TVar("Timestamp", DateTime.UtcNow.AddMinutes(60).ToTimestamp()))); + + _ = new Func(async () => + { + _ = msg.DeleteAsync(); + }).CreateScheduledTask(DateTime.UtcNow.AddMinutes(60)); + } +} diff --git a/ProjectMakoto/Events/EmbedMessagesEvents.cs b/ProjectMakoto/Events/EmbedMessagesEvents.cs new file mode 100644 index 00000000..a273941d --- /dev/null +++ b/ProjectMakoto/Events/EmbedMessagesEvents.cs @@ -0,0 +1,175 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Events; + +internal sealed class EmbedMessagesEvents(Bot bot) : RequiresTranslation(bot) +{ + Translations.events.embedMessages tKey => this.t.Events.EmbedMessages; + + internal async Task MessageCreated(DiscordClient sender, MessageCreateEventArgs e) + { + if (e.Guild is null) + return; + + var Delete = new DiscordButtonComponent(ButtonStyle.Danger, "DeleteEmbedMessage", this.tKey.Delete.Get(this.Bot.Guilds[e.Guild.Id]), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🗑"))); + + + do + { + if (RegexTemplates.DiscordChannelUrl.IsMatch(e.Message.Content)) + { + if (!this.Bot.Guilds[e.Guild.Id].EmbedMessage.UseEmbedding) + break; + + if (await this.Bot.Users[e.Message.Author.Id].Cooldown.WaitForModerate(new SharedCommandContext(e.Message, this.Bot, "message_embed"), true)) + break; + + var matches = RegexTemplates.DiscordChannelUrl.Matches(e.Message.Content); + + foreach (var b in matches.GroupBy(x => x.Value).Select(y => y.FirstOrDefault()).Take(2)) + { + if (!b.Value.TryParseMessageLink(out var GuildId, out var ChannelId, out var MessageId)) + continue; + + if (GuildId != e.Guild.Id) + continue; + + if (!e.Guild.Channels.ContainsKey(ChannelId)) + continue; + + var channel = e.Guild.GetChannel(ChannelId); + var perms = channel.PermissionsFor(await e.Author.ConvertToMember(e.Guild)); + + if (!perms.HasPermission(Permissions.AccessChannels) || !perms.HasPermission(Permissions.ReadMessageHistory)) + continue; + + if (!channel.TryGetMessage(MessageId, out var message)) + continue; + + var JumpToMessage = new DiscordLinkButtonComponent(message.JumpLink.ToString(), this.t.Common.JumpToMessage.Get(this.Bot.Guilds[e.Guild.Id]), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("💬"))); + + var msg = await e.Message.RespondAsync(new DiscordMessageBuilder().WithEmbed(new DiscordEmbedBuilder + { + Author = new DiscordEmbedBuilder.EmbedAuthor { IconUrl = message.Author.AvatarUrl, Name = $"{message.Author.GetUsernameWithIdentifier()}" }, + Color = message.Author.BannerColor ?? EmbedColors.Info, + Description = $"{message.ConvertToText()}".TruncateWithIndication(2000), + ImageUrl = (message.Attachments?.Count > 0 && (message.Attachments[0].Filename.EndsWith(".png") + || message.Attachments[0].Filename.EndsWith(".jpeg") + || message.Attachments[0].Filename.EndsWith(".jpg") + || message.Attachments[0].Filename.EndsWith(".gif")) ? message.Attachments[0].Url : ""), + Timestamp = message.Timestamp, + }).AddComponents(JumpToMessage, Delete)); + } + } + } while (false); + + if (RegexTemplates.GitHubUrl.IsMatch(e.Message.Content)) + { + if (!this.Bot.Guilds[e.Guild.Id].EmbedMessage.UseGithubEmbedding) + return; + + SharedCommandContext ctx = new(e.Message, this.Bot, "github_embed"); + if (await this.Bot.Users[e.Message.Author.Id].Cooldown.WaitForModerate(ctx, true)) + return; + + ctx.BaseCommand.DeleteOrInvalidate(); + + var matches = RegexTemplates.GitHubUrl.Matches(e.Message.Content); + + foreach (var b in matches.GroupBy(x => x.Value).Select(y => y.FirstOrDefault()).Take(2)) + { + var fileUrl = b.Value; + fileUrl = fileUrl.Replace("github.com", "raw.githubusercontent.com"); + fileUrl = fileUrl.Replace("/blob", ""); + fileUrl = fileUrl[..fileUrl.LastIndexOf('#')]; + + var repoOwner = b.Groups[1].Value; + var repoName = b.Groups[2].Value; + + var relativeFilePath = b.Groups[5].Value; + + var fileEnding = ""; + + try + { + fileEnding = relativeFilePath.Remove(0, relativeFilePath.LastIndexOf('.') + 1); + } + catch { } + + var StartLine = Convert.ToUInt32(b.Groups[6].Value.Replace("L", "")); + var EndLine = Convert.ToUInt32(b.Groups[8].Value.IsNullOrWhiteSpace() ? $"{StartLine}" : b.Groups[8].Value.Replace("L", "")); + + if (EndLine < StartLine) + return; + + var rawFile = await new HttpClient().GetStringAsync(fileUrl); + rawFile = rawFile.ReplaceLineEndings("\n"); + + var lines = rawFile.Split("\n").Skip((int)(StartLine - 1)).Take((int)(EndLine - (StartLine - 1))).Select(x => x.Replace("\t", " ")).ToList(); + + if (!lines.IsNotNullAndNotEmpty()) + return; + + var shortestIndent = -1; + + foreach (var c in lines) + { + var currentIndent = 0; + + foreach (var d in c) + { + if (d is ' ' or '\t') + currentIndent++; + else + break; + } + + if (currentIndent < shortestIndent || shortestIndent == -1) + shortestIndent = currentIndent; + } + + lines = lines.Select(x => x.Remove(0, shortestIndent)).ToList(); + + var content = $"`{relativeFilePath}` {(StartLine != EndLine ? this.tKey.Lines.Get(this.Bot.Guilds[e.Guild.Id]).Build(new TVar("Start", StartLine), new TVar("End", EndLine)) : this.tKey.Line.Get(this.Bot.Guilds[e.Guild.Id]).Build(new TVar("Start", StartLine)))}\n\n" + + $"```{fileEnding}\n" + + $"{string.Join("\n", lines)}\n" + + $"```"; + + content = content.TruncateWithIndication(1997); + + if (!content.EndsWith("```")) + content += "```"; + + var msg = await e.Message.RespondAsync(new DiscordMessageBuilder().WithContent(content).AddComponents(Delete)); + _ = e.Message.ModifySuppressionAsync(true); + } + } + } + + internal async Task ComponentInteractionCreated(DiscordClient sender, ComponentInteractionCreateEventArgs e) + { + if (e.GetCustomId() == "DeleteEmbedMessage") + { + var fullMsg = await e.Message.Refetch(); + + _ = e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + _ = (fullMsg.Reference is not null && fullMsg.ReferencedMessage is null) || + (fullMsg.ReferencedMessage is not null && fullMsg.ReferencedMessage.Author.Id == e.Interaction.User.Id) || + (await e.User.ConvertToMember(e.Interaction.Guild)).Roles.Any(x => (x.CheckPermission(Permissions.ManageMessages) == PermissionLevel.Allowed) || (x.CheckPermission(Permissions.Administrator) == PermissionLevel.Allowed)) + ? fullMsg.DeleteAsync().ContinueWith(x => + { + if (!x.IsCompletedSuccessfully) + _ = e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder().WithContent($"❌ `{this.tKey.FailedToDelete.Get(this.Bot.Guilds[e.Guild.Id])}`").AsEphemeral()); + }) + : e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder().WithContent($"❌ `{this.tKey.NotAuthor.Get(this.Bot.Guilds[e.Guild.Id])}`").AsEphemeral()); + } + } +} diff --git a/ProjectMakoto/Events/ExperienceEvents.cs b/ProjectMakoto/Events/ExperienceEvents.cs new file mode 100644 index 00000000..5f3c2237 --- /dev/null +++ b/ProjectMakoto/Events/ExperienceEvents.cs @@ -0,0 +1,38 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Events; + +internal sealed class ExperienceEvents(Bot bot) : RequiresTranslation(bot) +{ + internal async Task MessageCreated(DiscordClient sender, MessageCreateEventArgs e) + { + if (e.Message.WebhookMessage || e.Guild is null) + return; + + if (!this.Bot.Guilds[e.Guild.Id].Experience.UseExperience) + return; + + if (this.Bot.Guilds[e.Guild.Id].Members[e.Author.Id].Experience.Last_Message.AddSeconds(20) < DateTime.UtcNow && !e.Message.Author.IsBot && !e.Channel.IsPrivate) + { + var exp = this.Bot.ExperienceHandler.CalculateMessageExperience(e.Message); + + if (this.Bot.Guilds[e.Guild.Id].Experience.BoostXpForBumpReminder) + { + exp = (int)Math.Round(((await e.Author.ConvertToMember(e.Guild)).Roles.Any(x => x.Id == this.Bot.Guilds[e.Guild.Id].BumpReminder.RoleId) ? exp * 1.5 : exp), 0); + } + + if (exp > 0) + { + this.Bot.Guilds[e.Guild.Id].Members[e.Author.Id].Experience.Last_Message = DateTime.UtcNow; + _ = this.Bot.ExperienceHandler.ModifyExperience(e.Author, e.Guild, e.Channel, exp); + } + } + } +} diff --git a/ProjectMakoto/Events/GenericGuildEvents.cs b/ProjectMakoto/Events/GenericGuildEvents.cs new file mode 100644 index 00000000..ec2e9223 --- /dev/null +++ b/ProjectMakoto/Events/GenericGuildEvents.cs @@ -0,0 +1,99 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using ProjectMakoto.Entities.Members; + +namespace ProjectMakoto.Events; + +internal sealed class GenericGuildEvents(Bot bot) : RequiresTranslation(bot) +{ + internal async Task GuildMemberAdded(DiscordClient sender, GuildMemberAddEventArgs e) + { + if (this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].FirstJoinDate == DateTime.MinValue) + this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].FirstJoinDate = e.Member.JoinedAt.UtcDateTime; + + if (this.Bot.Guilds[e.Guild.Id].Join.ReApplyNickname) + if (this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].LastLeaveDate.ToUniversalTime().GetTimespanSince().TotalDays < 60) + _ = e.Member.ModifyAsync(x => x.Nickname = this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].SavedNickname).Add(this.Bot); + + this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].LastLeaveDate = DateTime.MinValue; + + if (!this.Bot.Guilds[e.Guild.Id].Join.ReApplyRoles) + return; + + if (e.Member.IsBot) + return; + + if (this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].LastLeaveDate.ToUniversalTime().GetTimespanSince().TotalDays > 60) + return; + + if (this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].MemberRoles.Length > 0) + { + var HighestRoleOnBot = (await e.Guild.GetMemberAsync(sender.CurrentUser.Id)).Roles.OrderByDescending(x => x.Position).First().Position; + + List disallowedRoles = new(); + List deletedRoles = new(); + + List rolesToApply = new(); + + foreach (var b in this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].MemberRoles) + { + if (!e.Guild.Roles.ContainsKey(b.Id)) + { + deletedRoles.Add(b); + continue; + } + + var role = e.Guild.GetRole(b.Id); + + foreach (var perm in Resources.ProtectedPermissions) + if (role.CheckPermission(perm) == PermissionLevel.Allowed) + { + disallowedRoles.Add(b); + continue; + } + + if (role.IsManaged || role.Position >= HighestRoleOnBot) + { + disallowedRoles.Add(b); + continue; + } + + rolesToApply.Add(role); + } + + if (rolesToApply.Count > 0) + _ = e.Member.ReplaceRolesAsync(rolesToApply, "Role Backup").Add(this.Bot); + } + } + + internal async Task GuildMemberRemoved(DiscordClient sender, GuildMemberRemoveEventArgs e) + { + this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].LastLeaveDate = DateTime.UtcNow; + } + + internal async Task GuildMemberUpdated(DiscordClient sender, GuildMemberUpdateEventArgs e) + { + await Task.Delay(2000); + + this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].MemberRoles = e.Member.Roles.Select(x => new MemberRole + { + Id = x.Id, + Name = x.Name, + }).ToArray(); + + this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].SavedNickname = e.Member.Nickname; + } + + internal async Task GuildMemberBanned(DiscordClient sender, GuildBanAddEventArgs e) + { + this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].MemberRoles = Array.Empty(); + this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].SavedNickname = ""; + } +} diff --git a/ProjectMakoto/Events/InviteNoteEvents.cs b/ProjectMakoto/Events/InviteNoteEvents.cs new file mode 100644 index 00000000..add88026 --- /dev/null +++ b/ProjectMakoto/Events/InviteNoteEvents.cs @@ -0,0 +1,20 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Events; + +internal sealed class InviteNoteEvents(Bot bot) : RequiresTranslation(bot) +{ + internal async Task InviteDeleted(DiscordClient sender, InviteDeleteEventArgs e) + { + if (this.Bot.Guilds[e.Guild.Id].InviteNotes.Notes.Any(x => x.Invite == e.Invite.Code)) + this.Bot.Guilds[e.Guild.Id].InviteNotes.Notes = this.Bot.Guilds[e.Guild.Id].InviteNotes.Notes + .Remove(x => x.Invite, this.Bot.Guilds[e.Guild.Id].InviteNotes.Notes.First(x => x.Invite == e.Invite.Code)); + } +} diff --git a/ProjectMakoto/Events/InviteTrackerEvents.cs b/ProjectMakoto/Events/InviteTrackerEvents.cs new file mode 100644 index 00000000..3df34c2a --- /dev/null +++ b/ProjectMakoto/Events/InviteTrackerEvents.cs @@ -0,0 +1,92 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using ProjectMakoto.Entities.Guilds; + +namespace ProjectMakoto.Events; + +internal sealed class InviteTrackerEvents(Bot bot) : RequiresTranslation(bot) +{ + public async static Task UpdateCachedInvites(Bot bot, DiscordGuild guild) + { + Log.Debug("Fetching invites for {Guild}", guild.Id); + + var Invites = await guild.GetInvitesAsync(); + + bot.Guilds[guild.Id].InviteTracker.Cache = Invites.Select(x => new InviteTrackerCacheItem { Code = x.Code, CreatorId = x.Inviter?.Id ?? 0, Uses = x.Uses }).ToArray(); + + Log.Debug("Fetched {Count} invites for {Guild}", bot.Guilds[guild.Id].InviteTracker.Cache.Length, guild.Id); + } + + + + internal async Task GuildCreated(DiscordClient sender, GuildCreateEventArgs e) + { + if (!this.Bot.Guilds[e.Guild.Id].InviteTracker.Enabled) + return; + + await UpdateCachedInvites(this.Bot, e.Guild); + } + + internal async Task InviteCreated(DiscordClient sender, InviteCreateEventArgs e) + { + if (!this.Bot.Guilds[e.Guild.Id].InviteTracker.Enabled) + return; + + await UpdateCachedInvites(this.Bot, e.Guild); + } + + internal async Task InviteDeleted(DiscordClient sender, InviteDeleteEventArgs e) + { + if (!this.Bot.Guilds[e.Guild.Id].InviteTracker.Enabled) + return; + + await UpdateCachedInvites(this.Bot, e.Guild); + } + + internal async Task GuildMemberAdded(DiscordClient sender, GuildMemberAddEventArgs e) + { + if (!this.Bot.Guilds[e.Guild.Id].InviteTracker.Enabled) + return; + + Log.Debug("User '{User}' joined '{Guild}', trying to track invite used..", e.Member.Id, e.Guild.Id); + + List InvitesBefore = new(); + List InvitesAfter = new(); + + foreach (var b in this.Bot.Guilds[e.Guild.Id].InviteTracker.Cache) + InvitesBefore.Add(b); + + await UpdateCachedInvites(this.Bot, e.Guild); + + foreach (var b in this.Bot.Guilds[e.Guild.Id].InviteTracker.Cache) + InvitesAfter.Add(b); + + foreach (var b in InvitesBefore) + { + if (!InvitesAfter.Any(x => x.Code == b.Code)) + { + this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].InviteTracker.Code = b.Code; + this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].InviteTracker.UserId = b.CreatorId; + Log.Debug("User '{User}' joined '{Guild}' with now deleted '{Code}' created by '{Creator}'", e.Member.Id, e.Guild.Id, b.Code, b.CreatorId); + return; + } + + if (InvitesAfter.First(x => x.Code == b.Code).Uses > b.Uses) + { + this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].InviteTracker.Code = b.Code; + this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].InviteTracker.UserId = b.CreatorId; + Log.Debug("User '{User}' joined '{Guild}' with '{Code}' created by '{Creator}'", e.Member.Id, e.Guild.Id, b.Code, b.CreatorId); + return; + } + } + + Log.Debug("Could not track invite for user '{User}' who joined '{Guild}'", e.Member.Id, e.Guild.Id); + } +} diff --git a/ProjectMakoto/Events/JoinEvents.cs b/ProjectMakoto/Events/JoinEvents.cs new file mode 100644 index 00000000..29e90c85 --- /dev/null +++ b/ProjectMakoto/Events/JoinEvents.cs @@ -0,0 +1,85 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Events; + +internal sealed class JoinEvents(Bot bot) : RequiresTranslation(bot) +{ + Translations.events.join tKey + => this.t.Events.Join; + + internal async Task GuildMemberAdded(DiscordClient sender, GuildMemberAddEventArgs e) + { + if (this.Bot.Guilds[e.Guild.Id].Join.AutoBanGlobalBans) + { + if (this.Bot.globalBans.TryGetValue(e.Member.Id, out var globalBanDetails)) + { + _ = e.Member.BanAsync(7, $"{this.tKey.Globalban.Get(this.Bot.Guilds[e.Guild.Id])}: {globalBanDetails.Reason}"); + return; + } + } + + if (this.Bot.Guilds[e.Guild.Id].Join.AutoAssignRoleId != 0) + { + if (e.Guild.Roles.ContainsKey(this.Bot.Guilds[e.Guild.Id].Join.AutoAssignRoleId)) + { + _ = e.Member.GrantRoleAsync(e.Guild.GetRole(this.Bot.Guilds[e.Guild.Id].Join.AutoAssignRoleId)); + } + } + + if (this.Bot.Guilds[e.Guild.Id].Join.JoinlogChannelId != 0) + { + if (e.Guild.Channels.ContainsKey(this.Bot.Guilds[e.Guild.Id].Join.JoinlogChannelId)) + { + _ = e.Guild.GetChannel(this.Bot.Guilds[e.Guild.Id].Join.JoinlogChannelId).SendMessageAsync(new DiscordEmbedBuilder + { + Author = new() + { + IconUrl = AuditLogIcons.UserAdded, + Name = e.Member.GetUsernameWithIdentifier() + }, + Description = $"{this.tKey.UserJoined.Get(this.Bot.Guilds[e.Guild.Id]).Build(new TVar("Guild", $"**{e.Guild.Name}**"))} {this.Bot.status.LoadedConfig.Emojis.JoinEvent.SelectRandom()}", + Color = EmbedColors.Success, + Thumbnail = new() + { + Url = (e.Member.AvatarUrl.IsNullOrWhiteSpace() ? AuditLogIcons.QuestionMark : e.Member.AvatarUrl) + } + }); + } + } + + await this.Bot.Guilds[e.Guild.Id].Members[e.Member.Id].PerformAutoKickChecks(e.Guild, e.Member); + } + + internal async Task GuildMemberRemoved(DiscordClient sender, GuildMemberRemoveEventArgs e) + { + if (this.Bot.Guilds[e.Guild.Id].Join.JoinlogChannelId != 0) + { + if (e.Guild.Channels.ContainsKey(this.Bot.Guilds[e.Guild.Id].Join.JoinlogChannelId)) + { + _ = e.Guild.GetChannel(this.Bot.Guilds[e.Guild.Id].Join.JoinlogChannelId).SendMessageAsync(new DiscordEmbedBuilder + { + Author = new() + { + IconUrl = AuditLogIcons.UserLeft, + Name = e.Member.GetUsernameWithIdentifier() + }, + Description = this.tKey.UserLeft.Get(this.Bot.Guilds[e.Guild.Id]).Build( + new TVar("Guild", $"**{e.Guild.Name}**"), + new TVar("Timestamp", e.Member.JoinedAt.GetTimespanSince().GetHumanReadable(TimeFormat.Days, TranslationUtil.GetTranslatedHumanReadableConfig(this.Bot.Guilds[e.Guild.Id], this.Bot)))), + Color = EmbedColors.Error, + Thumbnail = new() + { + Url = (e.Member.AvatarUrl.IsNullOrWhiteSpace() ? AuditLogIcons.QuestionMark : e.Member.AvatarUrl) + } + }); + } + } + } +} diff --git a/ProjectMakoto/Events/NameNormalizerEvents.cs b/ProjectMakoto/Events/NameNormalizerEvents.cs new file mode 100644 index 00000000..01e59f4b --- /dev/null +++ b/ProjectMakoto/Events/NameNormalizerEvents.cs @@ -0,0 +1,66 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Events; + +internal sealed class NameNormalizerEvents(Bot bot) : RequiresTranslation(bot) +{ + internal async Task GuildMemberAdded(DiscordClient sender, GuildMemberAddEventArgs e) + { + if (!this.Bot.Guilds[e.Guild.Id].NameNormalizer.NameNormalizerEnabled) + return; + + var PingableName = RegexTemplates.AllowedNickname.Replace(e.Member.DisplayName.Normalize(NormalizationForm.FormKC), ""); + + if (PingableName.IsNullOrWhiteSpace()) + PingableName = this.t.Commands.Config.NameNormalizer.DefaultName.Get(this.Bot.Guilds[e.Guild.Id]); + + if (PingableName != e.Member.DisplayName) + _ = e.Member.ModifyAsync(x => x.Nickname = PingableName); + } + + internal async Task GuildMemberUpdated(DiscordClient sender, GuildMemberUpdateEventArgs e) + { + if (!this.Bot.Guilds[e.Guild.Id].NameNormalizer.NameNormalizerEnabled) + return; + + if (e.NicknameBefore != e.NicknameAfter) + { + var PingableName = RegexTemplates.AllowedNickname.Replace(e.Member.DisplayName.Normalize(NormalizationForm.FormKC), ""); + + if (PingableName.IsNullOrWhiteSpace()) + PingableName = this.t.Commands.Config.NameNormalizer.DefaultName.Get(this.Bot.Guilds[e.Guild.Id]); + + if (PingableName != e.Member.DisplayName) + _ = e.Member.ModifyAsync(x => x.Nickname = PingableName); + } + } + + internal async Task UserUpdated(DiscordClient sender, UserUpdateEventArgs e) + { + if (e.UserBefore.GetUsername() == e.UserAfter.GetUsername()) + return; + + foreach (var guild in sender.Guilds) + { + if (!this.Bot.Guilds[guild.Key].NameNormalizer.NameNormalizerEnabled) + return; + + var member = await e.UserAfter.ConvertToMember(guild.Value); + + var PingableName = RegexTemplates.AllowedNickname.Replace(member.DisplayName.Normalize(NormalizationForm.FormKC), ""); + + if (PingableName.IsNullOrWhiteSpace()) + PingableName = this.t.Commands.Config.NameNormalizer.DefaultName.Get(this.Bot.Guilds[guild.Key]); + + if (PingableName != member.DisplayName) + _ = member.ModifyAsync(x => x.Nickname = PingableName); + } + } +} diff --git a/ProjectMakoto/Events/PhishingProtectionEvents.cs b/ProjectMakoto/Events/PhishingProtectionEvents.cs new file mode 100644 index 00000000..aa80f9ee --- /dev/null +++ b/ProjectMakoto/Events/PhishingProtectionEvents.cs @@ -0,0 +1,262 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Events; + +internal sealed class PhishingProtectionEvents(Bot bot) : RequiresTranslation(bot) +{ + Translations.events.phishing tKey + => this.t.Events.Phishing; + + internal async Task MessageCreated(DiscordClient sender, MessageCreateEventArgs e) + { + _ = this.CheckMessage(sender, e.Guild, e.Message).Add(this.Bot); + } + + internal async Task MessageUpdated(DiscordClient sender, MessageUpdateEventArgs e) + { + if (e.MessageBefore?.Content != e.Message?.Content) + _ = this.CheckMessage(sender, e.Guild, e.Message).Add(this.Bot); + } + + private async Task CheckMessage(DiscordClient sender, DiscordGuild guild, DiscordMessage e) + { + var prefix = guild.GetGuildPrefix(this.Bot); + + if (e?.Content?.StartsWith(prefix) ?? false) + foreach (var command in sender.GetCommandsNext().RegisteredCommands) + if (e.Content.StartsWith($"{prefix}{command.Key}")) + return; + + if (e.WebhookMessage || guild is null || e.Author?.Id == sender.CurrentUser.Id || (e.Author?.IsBot ?? true)) + return; + + if (!this.Bot.Guilds[guild.Id].PhishingDetection.DetectPhishing) + return; + + var member = await guild.GetMemberAsync(e.Author.Id); + + async Task CheckDb(Uri uri) + { + if (!this.Bot.Guilds[guild.Id].PhishingDetection.AbuseIpDbReports) + return; + + IPAddress[] parsedIp; + + try + { + parsedIp = await Dns.GetHostAddressesAsync(uri.Host); + } + catch (Exception) + { + return; + } + + var query = await this.Bot.AbuseIpDbClient.QueryIp(parsedIp[0].ToString()); + + if (query.data.abuseConfidenceScore.HasValue && query.data.abuseConfidenceScore.Value > 60) + { + var report_fields = query.data.reports.Select(x => new DiscordEmbedField($"{x.reporterCountryCode.IsoCountryCodeToFlagEmoji()} {x.reporterId}{(x.reportedAt.HasValue ? $" {x.reportedAt.Value.ToTimestamp()}" : "")}", (x.comment.IsNullOrWhiteSpace() ? "No comment provided." : x.comment).FullSanitize().TruncateWithIndication(1000))).ToList(); + + DiscordEmbedBuilder embed = new() + { + Title = this.tKey.AbuseIpDbReport.Get(this.Bot.Guilds[guild.Id]), + Description = $"**{this.tKey.HostWasFoundInAbuseIpDb.Get(this.Bot.Guilds[guild.Id]).Build(new TVar("Host", $"`{uri.Host} ({parsedIp[0]})`"))}**\n" + + $"{(query.data.countryName.IsNullOrWhiteSpace() ? "" : $"**{this.tKey.ConfidenceOfAbuse.Get(this.Bot.Guilds[guild.Id])}**: {query.data.abuseConfidenceScore}%\n\n")}" + + $"{(query.data.countryName.IsNullOrWhiteSpace() ? "" : $"**{this.tKey.Country.Get(this.Bot.Guilds[guild.Id])}**: {query.data.countryCode.IsoCountryCodeToFlagEmoji()} {query.data.countryName}\n")}" + + $"{(query.data.isp.IsNullOrWhiteSpace() ? "" : $"**{this.tKey.ISP.Get(this.Bot.Guilds[guild.Id])}**: {query.data.isp}\n")}" + + $"{(query.data.domain.IsNullOrWhiteSpace() ? "" : $"**{this.tKey.DomainName.Get(this.Bot.Guilds[guild.Id])}**: {query.data.domain}\n")}", + Color = new DiscordColor("#FF0000"), + Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail + { + Url = Resources.AbuseIpDbIcon + }, + }; + + _ = embed.AddFields(report_fields.Take(2)); + + _ = e.RespondAsync(new DiscordMessageBuilder().WithEmbed(embed).AddComponents(new DiscordLinkButtonComponent($"https://www.abuseipdb.com/check/{parsedIp[0]}", this.tKey.OpenInBrowser.Get(this.Bot.Guilds[guild.Id])))); + } + } + + var matches = RegexTemplates.Url.Matches(e.Content); + var parsedMatches = matches.Select(x => new UriBuilder(x.Value)); + + var parsedWords = e.Content.Split(" ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var url in this.Bot.PhishingHosts) + { + foreach (var word in parsedWords) + { + if (word.ToLower() == url.Key.ToLower()) + { + _ = this.PunishMember(guild, member, e, url.Key); + return; + } + + var reg = Regex.Match(word.ToLower(), @"([\S]*\.)?([\S]*)\.([\S]*)"); + + if (reg.Success && reg.Groups[1].Success) + { + var regex = new Regex(Regex.Escape(reg.Groups[1].Value)); + + if (regex.Replace(word.ToLower(), "", 1) == url.Key.ToLower()) + { + _ = this.PunishMember(guild, member, e, url.Key); + return; + } + } + } + } + + foreach (var match in parsedMatches) + { + if (match.Uri.ToString().Contains('⁄')) + { + _ = this.PunishMember(guild, member, e, match.Uri.ToString()); + return; + } + + _ = CheckDb(match.Uri); + } + + foreach (var url in this.Bot.PhishingHosts) + { + foreach (var match in parsedMatches) + { + if (match.Host.ToLower() == url.Key.ToLower()) + { + _ = this.PunishMember(guild, member, e, url.Key); + return; + } + } + } + + if (matches.Count > 0) + { + Dictionary redirectUrls = new(); + + foreach (var match in matches.Cast()) + { + try + { + var unshortenedUrl = await WebTools.UnshortenUrl(match.Value); + var parsedUri = new UriBuilder(unshortenedUrl); + + _ = CheckDb(parsedUri.Uri); + + if (unshortenedUrl != match.Value) + { + foreach (var url in this.Bot.PhishingHosts) + { + if (parsedUri.Host.ToLower() == url.Key.ToLower()) + { + _ = this.PunishMember(guild, member, e, url.Key); + return; + } + } + + if (!this.recentlyResolvedUrls.TryGetValue(unshortenedUrl, out var value) || value.AddSeconds(10) < DateTime.UtcNow) + redirectUrls.Add(match.Value, unshortenedUrl); + } + } + catch (DepthLimitReachedException) + { + if (this.Bot.Guilds[guild.Id].PhishingDetection.WarnOnRedirect) + _ = e.RespondAsync(embed: new DiscordEmbedBuilder + { + Title = $":no_entry: {this.tKey.RedirectDepthLimitError.Get(this.Bot.Guilds[guild.Id])}", + Color = EmbedColors.Error + }); + } + catch (Exception ex) when (ex is TimeoutException || + (ex is HttpRequestException && ex.Message.Contains("Cannot write more bytes"))) + { + if (this.Bot.Guilds[guild.Id].PhishingDetection.WarnOnRedirect) + _ = e.RespondAsync(embed: new DiscordEmbedBuilder + { + Title = $":no_entry: {this.tKey.RedirectCheckTimeoutError.Get(this.Bot.Guilds[guild.Id])}", + Color = EmbedColors.Error + }); + } + catch (Exception ex) + { + Log.Error(ex, "An exception occurred while trying to unshorten url '{url}'", match); + + if (this.Bot.Guilds[guild.Id].PhishingDetection.WarnOnRedirect) + _ = e.RespondAsync(embed: new DiscordEmbedBuilder + { + Title = $":no_entry: {this.tKey.RedirectCheckTimeoutUnknownError.Get(this.Bot.Guilds[guild.Id])}", + Color = EmbedColors.Error + }); + } + } + + if (redirectUrls.Count > 0) + { + foreach (var b in redirectUrls) + if (!this.recentlyResolvedUrls.ContainsKey(b.Value)) + this.recentlyResolvedUrls.Add(b.Value, DateTime.UtcNow); + else + this.recentlyResolvedUrls[b.Value] = DateTime.UtcNow; + + if (this.Bot.Guilds[guild.Id].PhishingDetection.WarnOnRedirect) + _ = e.RespondAsync(embed: new DiscordEmbedBuilder + { + Title = $":warning: {this.tKey.FoundRedirects.Get(this.Bot.Guilds[guild.Id])}", + Description = $"`{string.Join("`\n`", redirectUrls.Select(x => x.Value))}`", + Color = EmbedColors.Warning + }); + } + } + } + + private async Task PunishMember(DiscordGuild guild, DiscordMember member, DiscordMessage e, string url) + { + if (!this.Bot.Guilds[guild.Id].PhishingDetection.DetectPhishing) + return; + + switch (this.Bot.Guilds[guild.Id].PhishingDetection.PunishmentType) + { + case PhishingPunishmentType.Delete: + { + _ = e.DeleteAsync(); + break; + } + case PhishingPunishmentType.Timeout: + { + _ = e.DeleteAsync(); + _ = member.TimeoutAsync(this.Bot.Guilds[guild.Id].PhishingDetection.CustomPunishmentLength, this.Bot.Guilds[guild.Id].PhishingDetection.CustomPunishmentReason.Replace("%R", $"Detected malicious Url [{url}]")); + break; + } + case PhishingPunishmentType.Kick: + { + _ = e.DeleteAsync(); + _ = member.RemoveAsync(this.Bot.Guilds[guild.Id].PhishingDetection.CustomPunishmentReason.Replace("%R", this.tKey.DetectedMaliciousHost.Get(this.Bot.Guilds[guild.Id]).Build(new TVar("Host", url)))); + break; + } + case PhishingPunishmentType.SoftBan: + { + _ = e.DeleteAsync(); + _ = member.BanAsync(7, this.Bot.Guilds[guild.Id].PhishingDetection.CustomPunishmentReason.Replace("%R", this.tKey.DetectedMaliciousHost.Get(this.Bot.Guilds[guild.Id]).Build(new TVar("Host", url)))); + await Task.Delay(1000); + _ = member.UnbanAsync(); + break; + } + case PhishingPunishmentType.Ban: + { + _ = e.DeleteAsync(); + _ = member.BanAsync(7, this.Bot.Guilds[guild.Id].PhishingDetection.CustomPunishmentReason.Replace("%R", this.tKey.DetectedMaliciousHost.Get(this.Bot.Guilds[guild.Id]).Build(new TVar("Host", url)))); + break; + } + } + } + + private Dictionary recentlyResolvedUrls = new(); +} diff --git a/ProjectMakoto/Events/PhishingSubmissionEvents.cs b/ProjectMakoto/Events/PhishingSubmissionEvents.cs new file mode 100644 index 00000000..aa1ff1a7 --- /dev/null +++ b/ProjectMakoto/Events/PhishingSubmissionEvents.cs @@ -0,0 +1,92 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto; + +internal sealed class PhishingSubmissionEvents(Bot bot) : RequiresBotReference(bot) +{ + internal async Task ComponentInteractionCreated(DiscordClient sender, ComponentInteractionCreateEventArgs e) + { + if (this.Bot.SubmittedHosts.ContainsKey(e.Message?.Id ?? 0)) + { + if (!e.User.IsMaintenance(this.Bot.status)) + return; + + await e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate); + + if (e.GetCustomId() == "accept_submission") + { + this.Bot.PhishingHosts.Add(this.Bot.SubmittedHosts[e.Message.Id].Url, new PhishingUrlEntry(this.Bot, this.Bot.SubmittedHosts[e.Message.Id].Url) + { + Origin = Array.Empty(), + Submitter = this.Bot.SubmittedHosts[e.Message.Id].Submitter, + Url = this.Bot.SubmittedHosts[e.Message.Id].Url + }); + + _ = this.Bot.SubmittedHosts.Remove(e.Message.Id); + + try + { + await this.Bot.DatabaseClient.DeleteRow("active_url_submissions", "messageid", $"{e.Message.Id}", this.Bot.DatabaseClient.mainDatabaseConnection); + } + catch { } + + _ = e.Message.DeleteAsync(); + } + else if (e.GetCustomId() == "deny_submission") + { + _ = this.Bot.SubmittedHosts.Remove(e.Message.Id); + + try + { + await this.Bot.DatabaseClient.DeleteRow("active_url_submissions", "messageid", $"{e.Message.Id}", this.Bot.DatabaseClient.mainDatabaseConnection); + } + catch { } + + _ = e.Message.DeleteAsync(); + } + else if (e.GetCustomId() == "ban_user") + { + this.Bot.bannedUsers.Add(this.Bot.SubmittedHosts[e.Message.Id].Submitter, new BanDetails(this.Bot, "banned_users", this.Bot.SubmittedHosts[e.Message.Id].Submitter) + { + Reason = "Too many invalid reported hosts | Manual ban", + Moderator = e.User.Id + }); + + try + { + await this.Bot.DatabaseClient.DeleteRow("active_url_submissions", "messageid", $"{e.Message.Id}", this.Bot.DatabaseClient.mainDatabaseConnection); + } + catch { } + + _ = this.Bot.SubmittedHosts.Remove(e.Message.Id); + + _ = e.Message.DeleteAsync(); + } + else if (e.GetCustomId() == "ban_guild") + { + this.Bot.bannedGuilds.Add(this.Bot.SubmittedHosts[e.Message.Id].GuildOrigin, new BanDetails(this.Bot, "banned_guilds", this.Bot.SubmittedHosts[e.Message.Id].GuildOrigin) + { + Reason = "Too many invalid reported hosts | Manual ban", + Moderator = e.User.Id + }); + + try + { + await this.Bot.DatabaseClient.DeleteRow("active_url_submissions", "messageid", $"{e.Message.Id}", this.Bot.DatabaseClient.mainDatabaseConnection); + } + catch { } + + _ = this.Bot.SubmittedHosts.Remove(e.Message.Id); + + _ = e.Message.DeleteAsync(); + } + } + } +} diff --git a/ProjectMakoto/Events/ReactionRoleEvents.cs b/ProjectMakoto/Events/ReactionRoleEvents.cs new file mode 100644 index 00000000..3683d0f1 --- /dev/null +++ b/ProjectMakoto/Events/ReactionRoleEvents.cs @@ -0,0 +1,48 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Events; + +internal sealed class ReactionRoleEvents(Bot bot) : RequiresTranslation(bot) +{ + internal async Task MessageReactionAdded(DiscordClient sender, MessageReactionAddEventArgs e) + { + if (e.Guild is null || e.Channel is null || e.Channel.IsPrivate) + return; + + if (this.Bot.Guilds[e.Guild.Id].ReactionRoles.Any(x => x.MessageId == e.Message.Id && x.EmojiName == e.Emoji.GetUniqueDiscordName())) + { + var obj = this.Bot.Guilds[e.Guild.Id].ReactionRoles.First(x => x.MessageId == e.Message.Id && x.EmojiName == e.Emoji.GetUniqueDiscordName()); + + if (e.Guild.Roles.ContainsKey(obj.RoleId) && e.User.Id != this.Bot.DiscordClient.CurrentUser.Id) + await (await e.User.ConvertToMember(e.Guild)).GrantRoleAsync(e.Guild.GetRole(obj.RoleId)); + } + } + + internal async Task MessageReactionRemoved(DiscordClient sender, MessageReactionRemoveEventArgs e) + { + if (e.Guild == null || e.Channel.IsPrivate) + return; + + if (this.Bot.Guilds[e.Guild.Id].ReactionRoles.Any(x => x.MessageId == e.Message.Id && x.EmojiName == e.Emoji.GetUniqueDiscordName())) + { + var obj = this.Bot.Guilds[e.Guild.Id].ReactionRoles.First(x => x.MessageId == e.Message.Id && x.EmojiName == e.Emoji.GetUniqueDiscordName()); + + if (e.Guild.Roles.ContainsKey(obj.RoleId) && e.User.Id != this.Bot.DiscordClient.CurrentUser.Id) + { + DiscordMember member; + + try + { member = await e.User.ConvertToMember(e.Guild); } + catch (DisCatSharp.Exceptions.NotFoundException) { return; } + await member.RevokeRoleAsync(e.Guild.GetRole(obj.RoleId)); + } + } + } +} diff --git a/ProjectMakoto/Events/ReminderEvents.cs b/ProjectMakoto/Events/ReminderEvents.cs new file mode 100644 index 00000000..100c6a21 --- /dev/null +++ b/ProjectMakoto/Events/ReminderEvents.cs @@ -0,0 +1,39 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using ProjectMakoto.Entities.Users; + +namespace ProjectMakoto.Events; +internal sealed class ReminderEvents(Bot bot) : RequiresTranslation(bot) +{ + internal async Task ComponentInteractionCreated(DiscordClient sender, ComponentInteractionCreateEventArgs e) + { + if (!e.Channel?.IsPrivate ?? true) + return; + + try + { + var v = JsonConvert.DeserializeObject(e.Id)[0]; + var privateButtonType = (PrivateButtonType)v.ToInt32(); + if (privateButtonType != PrivateButtonType.ReminderSnooze) + return; + } + catch (Exception) + { + return; + } + + var reminder = JsonConvert.DeserializeObject(e.Id); + + _ = new RemindersCommand().ExecuteCommand(e, sender, "reminders", this.Bot, new Dictionary + { + { "description", reminder.Description }, + }).Add(this.Bot); + } +} diff --git a/ProjectMakoto/Events/TokenLeakEvents.cs b/ProjectMakoto/Events/TokenLeakEvents.cs new file mode 100644 index 00000000..609634ad --- /dev/null +++ b/ProjectMakoto/Events/TokenLeakEvents.cs @@ -0,0 +1,110 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using Octokit; + +namespace ProjectMakoto.Events; + +internal sealed class TokenLeakEvents(Bot bot) : RequiresTranslation(bot) +{ + Translations.events.tokenDetection tKey + => this.Bot.LoadedTranslations.Events.TokenDetection; + + internal async Task MessageCreated(DiscordClient sender, MessageCreateEventArgs e) + { + _ = this.CheckMessage(sender, e.Guild, e.Message).Add(this.Bot); + } + + internal async Task MessageUpdated(DiscordClient sender, MessageUpdateEventArgs e) + { + if (e.MessageBefore?.Content != e.Message?.Content) + _ = this.CheckMessage(sender, e.Guild, e.Message).Add(this.Bot); + } + + internal async Task CheckMessage(DiscordClient sender, DiscordGuild guild, DiscordMessage e) + { + var prefix = guild.GetGuildPrefix(this.Bot); + + if (e?.Content?.StartsWith(prefix) ?? false) + foreach (var command in sender.GetCommandsNext().RegisteredCommands) + if (e.Content.StartsWith($"{prefix}{command.Key}")) + return; + + if (e.WebhookMessage || guild is null) + return; + + if (!this.Bot.Guilds[guild.Id].TokenLeakDetection.DetectTokens) + return; + + var matchCollection = RegexTemplates.Token.Matches(e.Content); + + if (!matchCollection.IsNotNullAndNotEmpty()) + return; + + var filtered_matches = matchCollection.GroupBy(x => x.Value).Select, Match>(x => x.First()); + + _ = e.DeleteAsync(); + + var InvalidateCount = 0; + + foreach (var token in filtered_matches) + { + var botId = token.Groups["botid"].Value!; + DiscordUser? botUser = null; + try { botUser = await this.GetBotInfo(sender, botId); } catch { } + + if (botUser is null) + { + Log.Debug("Not uploading detected token, no bot user was fetched."); + continue; + } + + var owner = this.Bot.status.LoadedConfig.Secrets.Github.TokenLeakRepoOwner; + var repo = this.Bot.status.LoadedConfig.Secrets.Github.TokenLeakRepo; + var seconds = (long)DateTime.UtcNow.Subtract(DateTime.MinValue).TotalSeconds; + + if (this.Bot.TokenInvalidator.SearchForString(token.Value).Item1) + { + Log.Debug("Not uploading detected token, token already present in repository."); + continue; + } + + var fileName = $"token_leak_{e.Author.Id}_{guild.Id}_{e.Channel.Id}_{seconds}.md"; + var content = $"## Token of {botUser?.Id.ToString() ?? "unknown"} (Owner {e.Author.Id})\n\nBot {token}"; + + _ = await this.Bot.GithubClient.Repository.Content.CreateFile(owner, repo, $"automatic/{fileName}", new CreateFileRequest("Upload token to invalidate", content, "main")); + InvalidateCount++; + } + + if (InvalidateCount > 0) + _ = this.Bot.TokenInvalidator.Pull(); + + var s = (InvalidateCount > 1 ? "s" : ""); + + _ = e.Channel.SendMessageAsync(new DiscordMessageBuilder().WithEmbed( + new DiscordEmbedBuilder() + .WithColor(EmbedColors.Error) + .WithAuthor(sender.CurrentUser.GetUsername(), null, sender.CurrentUser.AvatarUrl) + .WithDescription(this.tKey.TokenInvalidated.Get(this.Bot.Guilds[e.Guild.Id]).Build(true, false, new TVar("Count", filtered_matches.Count())))) + .WithContent(e.Author.Mention)); + } + + private async Task GetBotInfo(DiscordClient client, string botId) + { + var ulongId = Convert.ToUInt64(Base64Decode(botId + "==")); + var bot = await client.GetUserAsync(ulongId); + return bot; + } + + public static string Base64Decode(string base64) + { + var base64Bytes = Convert.FromBase64String(base64); + return Encoding.UTF8.GetString(base64Bytes); + } +} diff --git a/ProjectMakoto/Events/VcCreatorEvents.cs b/ProjectMakoto/Events/VcCreatorEvents.cs new file mode 100644 index 00000000..a2bd5d76 --- /dev/null +++ b/ProjectMakoto/Events/VcCreatorEvents.cs @@ -0,0 +1,69 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Events; + +internal sealed class VcCreatorEvents(Bot bot) : RequiresTranslation(bot) +{ + Translations.events.vcCreator tKey + => this.Bot.LoadedTranslations.Events.VcCreator; + + internal async Task VoiceStateUpdated(DiscordClient sender, VoiceStateUpdateEventArgs e) + { + if (e.After?.Channel?.Id == this.Bot.Guilds[e.Guild.Id].VcCreator.Channel) + { + try + { + var member = await e.User.ConvertToMember(e.Guild); + + _ = this.Bot.Guilds[e.Guild.Id].VcCreator.LastCreatedChannel.TryAdd(e.User.Id, DateTime.MinValue); + if (e.After.Channel.Parent is null || e.After.Channel.Parent.Children.Count >= 50 || e.Guild.Channels.Count >= 500 || this.Bot.Guilds[e.Guild.Id].VcCreator.LastCreatedChannel[e.User.Id].GetTimespanSince() < TimeSpan.FromSeconds(30)) + { + await member.DisconnectFromVoiceAsync(); + return; + } + + this.Bot.Guilds[e.Guild.Id].VcCreator.LastCreatedChannel[e.User.Id] = DateTime.UtcNow; + + var name = this.tKey.DefaultChannelName.Get(this.Bot.Guilds[e.Guild.Id]).Build(new TVar("User", member.DisplayName.SanitizeForCode())); + + foreach (var b in this.Bot.ProfanityList) + name = name.Replace(b, new String('*', b.Length)); + + var newChannel = await e.Guild.CreateChannelAsync(name, ChannelType.Voice, e.After.Channel.Parent, default, null, 8); + this.Bot.Guilds[e.Guild.Id].VcCreator.CreatedChannels = this.Bot.Guilds[e.Guild.Id].VcCreator.CreatedChannels.Add(new() + { + ChannelId = newChannel.Id, + OwnerId = member.Id + }); + + await member.ModifyAsync(x => x.VoiceChannel = newChannel); + + await Task.Delay(1000); + + _ = await newChannel.SendMessageAsync(new DiscordMessageBuilder() + .WithContent(e.User.Mention) + .WithEmbed(new DiscordEmbedBuilder() + .WithAuthor(e.Guild.Name, "", e.Guild.IconUrl) + .WithColor(EmbedColors.Info) + .WithTimestamp(DateTime.UtcNow) + .WithDescription(this.tKey.DefaultChannelName.Get(this.Bot.Guilds[e.Guild.Id]).Build(true, new TVar("Command", "'/vcc'"))))); + } + catch (Exception) + { + try + { + await (await e.User.ConvertToMember(e.Guild)).DisconnectFromVoiceAsync(); + } + catch { } + throw; + } + } + } +} diff --git a/ProjectMakoto/Events/VoicePrivacyEvents.cs b/ProjectMakoto/Events/VoicePrivacyEvents.cs new file mode 100644 index 00000000..19a590bb --- /dev/null +++ b/ProjectMakoto/Events/VoicePrivacyEvents.cs @@ -0,0 +1,208 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto; + +internal sealed class VoicePrivacyEvents : RequiresTranslation +{ + public VoicePrivacyEvents(Bot bot) : base(bot) + { + this.QueueHandler(); + } + + Translations.events.inVoicePrivacy tKey + => this.t.Events.InVoicePrivacy; + + private List> JobsQueue = new(); + + internal void QueueHandler() + { + _ = Task.Run(async () => + { + while (true) + { + try + { + while (this.JobsQueue.Count <= 0) + Thread.Sleep(1000); + + var task = this.JobsQueue[0]; + _ = this.JobsQueue.Remove(task); + + _ = Task.Run(task).Add(this.Bot); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to run queue item"); + } + } + }).Add(this.Bot); + } + + internal async Task VoiceStateUpdated(DiscordClient sender, VoiceStateUpdateEventArgs e) + { + if (this.Bot.Guilds[e.Guild.Id].InVoiceTextPrivacy.SetPermissionsEnabled) + { + _ = Task.Run(async () => + { + if (e.After?.Channel?.Id != e.Before?.Channel?.Id) + { + if (e.Before is not null && e.Before.Channel is not null) + await e.Before.Channel.DeleteOverwriteAsync(await e.User.ConvertToMember(e.Guild), this.tKey.LeftWithSetPermissions.Get(this.Bot.Guilds[e.Guild.Id])); + + if (e.After is not null && e.After.Channel is not null) + await e.After?.Channel?.AddOverwriteAsync(await e.User.ConvertToMember(e.Guild), Permissions.ReadMessageHistory | Permissions.SendMessages, Permissions.None, this.tKey.JoinedWithSetPermissions.Get(this.Bot.Guilds[e.Guild.Id])); + } + }).Add(this.Bot); + } + + if (this.Bot.Guilds[e.Guild.Id].InVoiceTextPrivacy.ClearTextEnabled) + { + this.JobsQueue.Add(async () => + { + try + { + if (e.After?.Channel?.Id != e.Before?.Channel?.Id) + { + if (e.Before is not null && e.Before.Channel is not null) + { + if (e.Before.Channel.Type != ChannelType.Voice) + return; + + List discordMessages = new(); + discordMessages.AddRange(await e.Before.Channel.GetMessagesAsync(1)); + + if (discordMessages.Count == 0) + return; + + var failcount = 0; + + while (true) + { + try + { + var requestedMsgs = await e.Before.Channel.GetMessagesBeforeAsync(discordMessages.Last().Id, 100); + + if (!requestedMsgs.Any()) + break; + + discordMessages.AddRange(requestedMsgs); + + if (requestedMsgs.Any()) + await Task.Delay(10000); + } + catch (Exception ex) + { + Log.Warning(ex, "An exception occurred while trying to get messages from a channel ({failcount}/{max})", failcount, 3); + + await Task.Delay(10000); + failcount++; + + if (failcount >= 3) + throw; + } + } + + discordMessages = discordMessages.Where(x => x.Author.Id == e.User.Id).ToList(); + + if (discordMessages.Count != 0) + { + failcount = 0; + var BulkDeletions = discordMessages.Where(x => x.Timestamp.GetTimespanSince() < TimeSpan.FromDays(14)).ToList(); + + while (BulkDeletions.Count > 0) + { + try + { + var MessagesToDelete = BulkDeletions.Take(100).ToList(); + await e.Before.Channel.DeleteMessagesAsync(MessagesToDelete, this.tKey.LeftWithDeleteMessages.Get(this.Bot.Guilds[e.Guild.Id])); + + for (var i = 0; i < MessagesToDelete.Count; i++) + { + _ = BulkDeletions.Remove(MessagesToDelete[i]); + } + + if (BulkDeletions.Count != 0) + await Task.Delay(30000); + } + catch (Exception ex) + { + Log.Warning(ex, "An exception occurred while trying to bulk delete messages from a channel ({failcount}/{max})", failcount, 3); + + await Task.Delay(30000); + failcount++; + + if (failcount >= 3) + throw; + } + } + + failcount = 0; + var SingleDeletions = discordMessages.Where(x => x.Timestamp.GetTimespanSince() > TimeSpan.FromDays(14)).ToList(); + + while (SingleDeletions.Count > 0) + { + try + { + var msg = SingleDeletions[0]; + + await e.Before.Channel.DeleteMessageAsync(msg, this.tKey.LeftWithDeleteMessages.Get(this.Bot.Guilds[e.Guild.Id])); + _ = SingleDeletions.Remove(msg); + + if (SingleDeletions.Count != 0) + await Task.Delay(30000); + } + catch (Exception ex) + { + Log.Warning(ex, "An exception occurred while trying to delete a message from a channel ({failcount}/{max})", failcount, 3); + + await Task.Delay(30000); + failcount++; + + if (failcount >= 3) + throw; + } + } + } + } + } + } + catch (DisCatSharp.Exceptions.NotFoundException) { } + catch (DisCatSharp.Exceptions.UnauthorizedException) { } + catch (Exception ex) + { + Log.Error(ex, "Failed to execute a In-Voice Text Privacy Cleaner"); + } + + return; + }); + } + } + + internal async Task ChannelCreated(DiscordClient sender, ChannelCreateEventArgs e) + { + _ = Task.Run(async () => + { + if (this.Bot.Guilds[e.Guild.Id].InVoiceTextPrivacy.SetPermissionsEnabled) + { + if (!e.Guild.Channels.Any(x => x.Value.Type == ChannelType.Voice)) + return; + + foreach (var b in e.Guild.Channels.Where(x => x.Value.Type == ChannelType.Voice)) + { + DiscordOverwrite present = null; + if (b.Value.Parent?.PermissionOverwrites.Any(x => (x.Type == OverwriteType.Role) && (x.Id == e.Guild.EveryoneRole.Id)) ?? false) + present = b.Value.Parent.PermissionOverwrites.First(x => (x.Type == OverwriteType.Role) && (x.Id == e.Guild.EveryoneRole.Id)); + + _ = b.Value.AddOverwriteAsync(e.Guild.EveryoneRole, (present?.Allowed ?? Permissions.None), (present?.Denied ?? Permissions.None) | Permissions.ReadMessageHistory | Permissions.SendMessages, this.tKey.CreatedWithSetPermissions.Get(this.Bot.Guilds[e.Guild.Id])); + } + } + }).Add(this.Bot); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Exceptions/AlreadyAppliedException.cs b/ProjectMakoto/Exceptions/AlreadyAppliedException.cs new file mode 100644 index 00000000..7bbe634c --- /dev/null +++ b/ProjectMakoto/Exceptions/AlreadyAppliedException.cs @@ -0,0 +1,14 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Exceptions; + +public sealed class AlreadyAppliedException(string message) : Exception(message) +{ +} diff --git a/ProjectMakoto/Exceptions/CancelException.cs b/ProjectMakoto/Exceptions/CancelException.cs new file mode 100644 index 00000000..ff7bb729 --- /dev/null +++ b/ProjectMakoto/Exceptions/CancelException.cs @@ -0,0 +1,13 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Exceptions; +public sealed class CancelException(string message = null) : Exception(message) +{ +} diff --git a/ProjectMakoto/Exceptions/ForbiddenException.cs b/ProjectMakoto/Exceptions/ForbiddenException.cs new file mode 100644 index 00000000..a2b21a73 --- /dev/null +++ b/ProjectMakoto/Exceptions/ForbiddenException.cs @@ -0,0 +1,13 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Exceptions; +public sealed class ForbiddenException(string message = "") : Exception(message) +{ +} diff --git a/ProjectMakoto/Exceptions/InternalServerErrorException.cs b/ProjectMakoto/Exceptions/InternalServerErrorException.cs new file mode 100644 index 00000000..f638f814 --- /dev/null +++ b/ProjectMakoto/Exceptions/InternalServerErrorException.cs @@ -0,0 +1,13 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Exceptions; +public sealed class InternalServerErrorException(string message = "") : Exception(message) +{ +} diff --git a/ProjectMakoto/Exceptions/InvalidCallException.cs b/ProjectMakoto/Exceptions/InvalidCallException.cs new file mode 100644 index 00000000..4a85b0bd --- /dev/null +++ b/ProjectMakoto/Exceptions/InvalidCallException.cs @@ -0,0 +1,32 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Exceptions; +public class InvalidCallException : Exception +{ + public InvalidCallException() + { + } + + public InvalidCallException(string? stackTrace) + { + this.StackTrace = stackTrace; + } + + public InvalidCallException(string? message, string stackTrace) : base(message) + { + this.StackTrace = stackTrace; + } + + public InvalidCallException(string? message, Exception? innerException) : base(message, innerException) + { + } + + public override string? StackTrace { get; } +} diff --git a/ProjectMakoto/Exceptions/NotFoundException.cs b/ProjectMakoto/Exceptions/NotFoundException.cs new file mode 100644 index 00000000..642551b6 --- /dev/null +++ b/ProjectMakoto/Exceptions/NotFoundException.cs @@ -0,0 +1,13 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Exceptions; +public sealed class NotFoundException(string message = "") : Exception(message) +{ +} diff --git a/ProjectMakoto/Exceptions/TimedOutException.cs b/ProjectMakoto/Exceptions/TimedOutException.cs new file mode 100644 index 00000000..b864c5b3 --- /dev/null +++ b/ProjectMakoto/Exceptions/TimedOutException.cs @@ -0,0 +1,13 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Exceptions; +public sealed class TimedOutException(string message = "") : Exception(message) +{ +} diff --git a/ProjectMakoto/Exceptions/UnprocessableEntityException.cs b/ProjectMakoto/Exceptions/UnprocessableEntityException.cs new file mode 100644 index 00000000..9d027670 --- /dev/null +++ b/ProjectMakoto/Exceptions/UnprocessableEntityException.cs @@ -0,0 +1,5 @@ +namespace ProjectMakoto.Exceptions; + +public class UnprocessableEntityException(string message) : Exception(message) +{ +} diff --git a/ProjectMakoto/Global.cs b/ProjectMakoto/Global.cs new file mode 100644 index 00000000..6b729631 --- /dev/null +++ b/ProjectMakoto/Global.cs @@ -0,0 +1,69 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +global using System; +global using System.Collections.Generic; +global using System.Data; +global using System.Diagnostics; +global using System.Drawing; +global using System.Globalization; +global using System.IO; +global using System.IO.Compression; +global using System.Linq; +global using System.Net; +global using System.Net.Http; +global using System.Runtime.InteropServices; +global using System.Security.Cryptography; +global using System.Text; +global using System.Text.RegularExpressions; +global using System.Threading; +global using System.Threading.Tasks; +global using System.Collections; +global using System.Reflection; + +global using DisCatSharp; +global using DisCatSharp.ApplicationCommands; +global using DisCatSharp.ApplicationCommands.Attributes; +global using DisCatSharp.ApplicationCommands.Context; +global using DisCatSharp.CommandsNext; +global using DisCatSharp.CommandsNext.Attributes; +global using DisCatSharp.CommandsNext.Converters; +global using DisCatSharp.Entities; +global using DisCatSharp.Enums; +global using DisCatSharp.EventArgs; +global using DisCatSharp.Interactivity; +global using DisCatSharp.Interactivity.Extensions; +global using DisCatSharp.Extensions.TwoFactorCommands; +global using DisCatSharp.Extensions.TwoFactorCommands.ApplicationCommands; + +global using Microsoft.Extensions.DependencyInjection; + +global using ProjectMakoto; +global using ProjectMakoto.Commands; +global using ProjectMakoto.Database; +global using ProjectMakoto.Entities; +global using ProjectMakoto.Enums; +global using ProjectMakoto.Events; +global using ProjectMakoto.Exceptions; +global using ProjectMakoto.Util; +global using ProjectMakoto.Plugins; +global using ProjectMakoto.Util.SystemMonitor; +global using ProjectMakoto.Util.JsonSerializers; + +global using Xorog.UniversalExtensions; +global using Xorog.UniversalExtensions.Entities; +global using Xorog.UniversalExtensions.Enums; +global using Xorog.UniversalExtensions.Converters; + +global using Serilog; +global using Serilog.Events; + +global using MySqlConnector; +global using Newtonsoft.Json; +global using QuickChart; \ No newline at end of file diff --git a/ProjectMakoto/GlobalSuppressions.cs b/ProjectMakoto/GlobalSuppressions.cs new file mode 100644 index 00000000..dca89707 --- /dev/null +++ b/ProjectMakoto/GlobalSuppressions.cs @@ -0,0 +1,25 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using System.Diagnostics.CodeAnalysis; + +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage(null, "IDE0047")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage(null, "DV2001")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage(null, "CS8625")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage(null, "CS8601")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage(null, "CS1998")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage(null, "CS8981")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage(null, "DCS0200")] +[assembly: SuppressMessage("Style", "IDE0300:Simplify collection initialization", Justification = "no")] +[assembly: SuppressMessage("Style", "IDE0028:Simplify collection initialization", Justification = "no")] +[assembly: SuppressMessage("Style", "IDE0301:Simplify collection initialization", Justification = "no")] +[assembly: SuppressMessage("Style", "IDE0305:Simplify collection initialization", Justification = "no")] +[assembly: SuppressMessage("Style", "CA1862", Justification = "no")] +[assembly: SuppressMessage("Style", "CA1510", Justification = "no")] +[assembly: SuppressMessage("Style", "CA1861", Justification = "no")] diff --git a/ProjectMakoto/Main.png b/ProjectMakoto/Main.png new file mode 100644 index 00000000..ca63349d Binary files /dev/null and b/ProjectMakoto/Main.png differ diff --git a/ProjectMakoto/Program.cs b/ProjectMakoto/Program.cs new file mode 100644 index 00000000..89e89c3a --- /dev/null +++ b/ProjectMakoto/Program.cs @@ -0,0 +1,19 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto; + +internal sealed class Program +{ + internal static void Main(string[] args) + { + Bot _bot = new(); + _bot.Init(args).GetAwaiter().GetResult(); + } +} \ No newline at end of file diff --git a/ProjectMakoto/ProjectMakoto.csproj b/ProjectMakoto/ProjectMakoto.csproj new file mode 100644 index 00000000..a239da78 --- /dev/null +++ b/ProjectMakoto/ProjectMakoto.csproj @@ -0,0 +1,145 @@ + + + + Exe + net9.0 + ProjectMakoto + enable + annotations + true + embedded + true + x64 + Debug;Release;x64 + AnyCPU;x64 + ProjectMakoto.Program + False + Project-Makoto + https://github.com/Fortunevale/ProjectMakoto + latest + en + true + x64 + False + + + + 1 + + + + 1 + + + + False + + + + False + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + Always + + + True + True + Resources.resx + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + + + + + + + + + diff --git a/ProjectMakoto/ProjectMakoto.sln b/ProjectMakoto/ProjectMakoto.sln new file mode 100644 index 00000000..3adac39a --- /dev/null +++ b/ProjectMakoto/ProjectMakoto.sln @@ -0,0 +1,297 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.1.31911.260 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectMakoto", "ProjectMakoto.csproj", "{A47FCB9F-FEA3-4810-9C77-4CC47DB5BD19}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{9696CEB9-0A15-4B42-A54E-63DB7F44D560}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + Bot.cs = Bot.cs + Global.cs = Global.cs + Entities\Resources.cs = Entities\Resources.cs + Translations\strings.json = Translations\strings.json + Entities\Translation\Translations.cs = Entities\Translation\Translations.cs + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Xorog.UniversalExtensions", "..\Dependencies\Xorog.UniversalExtensions\Xorog.UniversalExtensions.csproj", "{906F9C70-17B3-4B6D-AFFF-976338D641C4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DisCatSharp", "..\Dependencies\DisCatSharp\DisCatSharp\DisCatSharp.csproj", "{8BD34631-4327-4533-8587-93E38D7A5DD9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DisCatSharp.ApplicationCommands", "..\Dependencies\DisCatSharp\DisCatSharp.ApplicationCommands\DisCatSharp.ApplicationCommands.csproj", "{5C28FAB1-DBAE-4A92-ABF0-13ED9727DD39}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DisCatSharp.CommandsNext", "..\Dependencies\DisCatSharp\DisCatSharp.CommandsNext\DisCatSharp.CommandsNext.csproj", "{AC3138E2-3A59-4626-909D-332F67557188}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DisCatSharp.Common", "..\Dependencies\DisCatSharp\DisCatSharp.Common\DisCatSharp.Common.csproj", "{15738D6B-E031-4ABB-8DFC-CFAD764410D2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DisCatSharp.Configuration", "..\Dependencies\DisCatSharp\DisCatSharp.Configuration\DisCatSharp.Configuration.csproj", "{CD84C9A5-98B9-4395-83E8-51228DB57BA6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DisCatSharp.Experimental", "..\Dependencies\DisCatSharp\DisCatSharp.Experimental\DisCatSharp.Experimental.csproj", "{1A4822DE-C986-4400-B1AB-FA40C8AACB14}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DisCatSharp.Interactivity", "..\Dependencies\DisCatSharp\DisCatSharp.Interactivity\DisCatSharp.Interactivity.csproj", "{020C24D8-DCD7-4AA1-A74A-2296E7CE490F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DisCatSharp.Lavalink", "..\Dependencies\DisCatSharp\DisCatSharp.Lavalink\DisCatSharp.Lavalink.csproj", "{1A20F445-0CE7-4DA0-91EA-8F3AEF89C496}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DisCatSharp.VoiceNext", "..\Dependencies\DisCatSharp\DisCatSharp.VoiceNext\DisCatSharp.VoiceNext.csproj", "{274E405C-AB90-4D5D-9AC6-3C7CF1819E60}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DisCatSharp.VoiceNext.Natives", "..\Dependencies\DisCatSharp\DisCatSharp.VoiceNext.Natives\DisCatSharp.VoiceNext.Natives.csproj", "{370E7196-9D06-43D6-B267-F63F8C38C33F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectMakoto.Plugins.Social", "..\OfficialPlugins\Social\ProjectMakoto.Plugins.Social.csproj", "{40158660-CD40-44A8-8834-077C132B7DDD}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectMakoto.Plugins.Translations", "..\OfficialPlugins\Translations\ProjectMakoto.Plugins.Translations.csproj", "{0AB29534-3D3E-4482-A98A-8F1C01C4B476}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectMakoto.Plugins.Example", "..\OfficialPlugins\Example\ProjectMakoto.Plugins.Example.csproj", "{B387A175-639B-4376-A221-34E733B4C6FC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectMakoto.Plugins.Music", "..\OfficialPlugins\Music\ProjectMakoto.Plugins.Music.csproj", "{C609247B-037D-4E16-9E75-6DA142E4EDAC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectMakoto.Plugins.ScoreSaber", "..\OfficialPlugins\ScoreSaber\ProjectMakoto.Plugins.ScoreSaber.csproj", "{CF74444A-A805-43F2-A5C9-2AF49E472754}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickChart", "..\Dependencies\quickchart-csharp\QuickChart\QuickChart.csproj", "{8EE7D3DD-FC09-42A7-91B3-447FC9944D0E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + x64|Any CPU = x64|Any CPU + x64|x64 = x64|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A47FCB9F-FEA3-4810-9C77-4CC47DB5BD19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A47FCB9F-FEA3-4810-9C77-4CC47DB5BD19}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A47FCB9F-FEA3-4810-9C77-4CC47DB5BD19}.Debug|x64.ActiveCfg = Debug|x64 + {A47FCB9F-FEA3-4810-9C77-4CC47DB5BD19}.Debug|x64.Build.0 = Debug|x64 + {A47FCB9F-FEA3-4810-9C77-4CC47DB5BD19}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A47FCB9F-FEA3-4810-9C77-4CC47DB5BD19}.Release|Any CPU.Build.0 = Release|Any CPU + {A47FCB9F-FEA3-4810-9C77-4CC47DB5BD19}.Release|x64.ActiveCfg = Release|x64 + {A47FCB9F-FEA3-4810-9C77-4CC47DB5BD19}.Release|x64.Build.0 = Release|x64 + {A47FCB9F-FEA3-4810-9C77-4CC47DB5BD19}.x64|Any CPU.ActiveCfg = x64|Any CPU + {A47FCB9F-FEA3-4810-9C77-4CC47DB5BD19}.x64|Any CPU.Build.0 = x64|Any CPU + {A47FCB9F-FEA3-4810-9C77-4CC47DB5BD19}.x64|x64.ActiveCfg = x64|x64 + {A47FCB9F-FEA3-4810-9C77-4CC47DB5BD19}.x64|x64.Build.0 = x64|x64 + {906F9C70-17B3-4B6D-AFFF-976338D641C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {906F9C70-17B3-4B6D-AFFF-976338D641C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {906F9C70-17B3-4B6D-AFFF-976338D641C4}.Debug|x64.ActiveCfg = Debug|x64 + {906F9C70-17B3-4B6D-AFFF-976338D641C4}.Debug|x64.Build.0 = Debug|x64 + {906F9C70-17B3-4B6D-AFFF-976338D641C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {906F9C70-17B3-4B6D-AFFF-976338D641C4}.Release|Any CPU.Build.0 = Release|Any CPU + {906F9C70-17B3-4B6D-AFFF-976338D641C4}.Release|x64.ActiveCfg = Release|x64 + {906F9C70-17B3-4B6D-AFFF-976338D641C4}.Release|x64.Build.0 = Release|x64 + {906F9C70-17B3-4B6D-AFFF-976338D641C4}.x64|Any CPU.ActiveCfg = x64|Any CPU + {906F9C70-17B3-4B6D-AFFF-976338D641C4}.x64|Any CPU.Build.0 = x64|Any CPU + {906F9C70-17B3-4B6D-AFFF-976338D641C4}.x64|x64.ActiveCfg = x64|x64 + {906F9C70-17B3-4B6D-AFFF-976338D641C4}.x64|x64.Build.0 = x64|x64 + {8BD34631-4327-4533-8587-93E38D7A5DD9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8BD34631-4327-4533-8587-93E38D7A5DD9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8BD34631-4327-4533-8587-93E38D7A5DD9}.Debug|x64.ActiveCfg = Debug|Any CPU + {8BD34631-4327-4533-8587-93E38D7A5DD9}.Debug|x64.Build.0 = Debug|Any CPU + {8BD34631-4327-4533-8587-93E38D7A5DD9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8BD34631-4327-4533-8587-93E38D7A5DD9}.Release|Any CPU.Build.0 = Release|Any CPU + {8BD34631-4327-4533-8587-93E38D7A5DD9}.Release|x64.ActiveCfg = Release|Any CPU + {8BD34631-4327-4533-8587-93E38D7A5DD9}.Release|x64.Build.0 = Release|Any CPU + {8BD34631-4327-4533-8587-93E38D7A5DD9}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {8BD34631-4327-4533-8587-93E38D7A5DD9}.x64|Any CPU.Build.0 = Debug|Any CPU + {8BD34631-4327-4533-8587-93E38D7A5DD9}.x64|x64.ActiveCfg = Debug|Any CPU + {8BD34631-4327-4533-8587-93E38D7A5DD9}.x64|x64.Build.0 = Debug|Any CPU + {5C28FAB1-DBAE-4A92-ABF0-13ED9727DD39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C28FAB1-DBAE-4A92-ABF0-13ED9727DD39}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C28FAB1-DBAE-4A92-ABF0-13ED9727DD39}.Debug|x64.ActiveCfg = Debug|Any CPU + {5C28FAB1-DBAE-4A92-ABF0-13ED9727DD39}.Debug|x64.Build.0 = Debug|Any CPU + {5C28FAB1-DBAE-4A92-ABF0-13ED9727DD39}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C28FAB1-DBAE-4A92-ABF0-13ED9727DD39}.Release|Any CPU.Build.0 = Release|Any CPU + {5C28FAB1-DBAE-4A92-ABF0-13ED9727DD39}.Release|x64.ActiveCfg = Release|Any CPU + {5C28FAB1-DBAE-4A92-ABF0-13ED9727DD39}.Release|x64.Build.0 = Release|Any CPU + {5C28FAB1-DBAE-4A92-ABF0-13ED9727DD39}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {5C28FAB1-DBAE-4A92-ABF0-13ED9727DD39}.x64|Any CPU.Build.0 = Debug|Any CPU + {5C28FAB1-DBAE-4A92-ABF0-13ED9727DD39}.x64|x64.ActiveCfg = Debug|Any CPU + {5C28FAB1-DBAE-4A92-ABF0-13ED9727DD39}.x64|x64.Build.0 = Debug|Any CPU + {AC3138E2-3A59-4626-909D-332F67557188}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AC3138E2-3A59-4626-909D-332F67557188}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC3138E2-3A59-4626-909D-332F67557188}.Debug|x64.ActiveCfg = Debug|Any CPU + {AC3138E2-3A59-4626-909D-332F67557188}.Debug|x64.Build.0 = Debug|Any CPU + {AC3138E2-3A59-4626-909D-332F67557188}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AC3138E2-3A59-4626-909D-332F67557188}.Release|Any CPU.Build.0 = Release|Any CPU + {AC3138E2-3A59-4626-909D-332F67557188}.Release|x64.ActiveCfg = Release|Any CPU + {AC3138E2-3A59-4626-909D-332F67557188}.Release|x64.Build.0 = Release|Any CPU + {AC3138E2-3A59-4626-909D-332F67557188}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {AC3138E2-3A59-4626-909D-332F67557188}.x64|Any CPU.Build.0 = Debug|Any CPU + {AC3138E2-3A59-4626-909D-332F67557188}.x64|x64.ActiveCfg = Debug|Any CPU + {AC3138E2-3A59-4626-909D-332F67557188}.x64|x64.Build.0 = Debug|Any CPU + {15738D6B-E031-4ABB-8DFC-CFAD764410D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {15738D6B-E031-4ABB-8DFC-CFAD764410D2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {15738D6B-E031-4ABB-8DFC-CFAD764410D2}.Debug|x64.ActiveCfg = Debug|Any CPU + {15738D6B-E031-4ABB-8DFC-CFAD764410D2}.Debug|x64.Build.0 = Debug|Any CPU + {15738D6B-E031-4ABB-8DFC-CFAD764410D2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {15738D6B-E031-4ABB-8DFC-CFAD764410D2}.Release|Any CPU.Build.0 = Release|Any CPU + {15738D6B-E031-4ABB-8DFC-CFAD764410D2}.Release|x64.ActiveCfg = Release|Any CPU + {15738D6B-E031-4ABB-8DFC-CFAD764410D2}.Release|x64.Build.0 = Release|Any CPU + {15738D6B-E031-4ABB-8DFC-CFAD764410D2}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {15738D6B-E031-4ABB-8DFC-CFAD764410D2}.x64|Any CPU.Build.0 = Debug|Any CPU + {15738D6B-E031-4ABB-8DFC-CFAD764410D2}.x64|x64.ActiveCfg = Debug|Any CPU + {15738D6B-E031-4ABB-8DFC-CFAD764410D2}.x64|x64.Build.0 = Debug|Any CPU + {CD84C9A5-98B9-4395-83E8-51228DB57BA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD84C9A5-98B9-4395-83E8-51228DB57BA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD84C9A5-98B9-4395-83E8-51228DB57BA6}.Debug|x64.ActiveCfg = Debug|Any CPU + {CD84C9A5-98B9-4395-83E8-51228DB57BA6}.Debug|x64.Build.0 = Debug|Any CPU + {CD84C9A5-98B9-4395-83E8-51228DB57BA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD84C9A5-98B9-4395-83E8-51228DB57BA6}.Release|Any CPU.Build.0 = Release|Any CPU + {CD84C9A5-98B9-4395-83E8-51228DB57BA6}.Release|x64.ActiveCfg = Release|Any CPU + {CD84C9A5-98B9-4395-83E8-51228DB57BA6}.Release|x64.Build.0 = Release|Any CPU + {CD84C9A5-98B9-4395-83E8-51228DB57BA6}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {CD84C9A5-98B9-4395-83E8-51228DB57BA6}.x64|Any CPU.Build.0 = Debug|Any CPU + {CD84C9A5-98B9-4395-83E8-51228DB57BA6}.x64|x64.ActiveCfg = Debug|Any CPU + {CD84C9A5-98B9-4395-83E8-51228DB57BA6}.x64|x64.Build.0 = Debug|Any CPU + {1A4822DE-C986-4400-B1AB-FA40C8AACB14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A4822DE-C986-4400-B1AB-FA40C8AACB14}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A4822DE-C986-4400-B1AB-FA40C8AACB14}.Debug|x64.ActiveCfg = Debug|Any CPU + {1A4822DE-C986-4400-B1AB-FA40C8AACB14}.Debug|x64.Build.0 = Debug|Any CPU + {1A4822DE-C986-4400-B1AB-FA40C8AACB14}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A4822DE-C986-4400-B1AB-FA40C8AACB14}.Release|Any CPU.Build.0 = Release|Any CPU + {1A4822DE-C986-4400-B1AB-FA40C8AACB14}.Release|x64.ActiveCfg = Release|Any CPU + {1A4822DE-C986-4400-B1AB-FA40C8AACB14}.Release|x64.Build.0 = Release|Any CPU + {1A4822DE-C986-4400-B1AB-FA40C8AACB14}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {1A4822DE-C986-4400-B1AB-FA40C8AACB14}.x64|Any CPU.Build.0 = Debug|Any CPU + {1A4822DE-C986-4400-B1AB-FA40C8AACB14}.x64|x64.ActiveCfg = Debug|Any CPU + {1A4822DE-C986-4400-B1AB-FA40C8AACB14}.x64|x64.Build.0 = Debug|Any CPU + {020C24D8-DCD7-4AA1-A74A-2296E7CE490F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {020C24D8-DCD7-4AA1-A74A-2296E7CE490F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {020C24D8-DCD7-4AA1-A74A-2296E7CE490F}.Debug|x64.ActiveCfg = Debug|Any CPU + {020C24D8-DCD7-4AA1-A74A-2296E7CE490F}.Debug|x64.Build.0 = Debug|Any CPU + {020C24D8-DCD7-4AA1-A74A-2296E7CE490F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {020C24D8-DCD7-4AA1-A74A-2296E7CE490F}.Release|Any CPU.Build.0 = Release|Any CPU + {020C24D8-DCD7-4AA1-A74A-2296E7CE490F}.Release|x64.ActiveCfg = Release|Any CPU + {020C24D8-DCD7-4AA1-A74A-2296E7CE490F}.Release|x64.Build.0 = Release|Any CPU + {020C24D8-DCD7-4AA1-A74A-2296E7CE490F}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {020C24D8-DCD7-4AA1-A74A-2296E7CE490F}.x64|Any CPU.Build.0 = Debug|Any CPU + {020C24D8-DCD7-4AA1-A74A-2296E7CE490F}.x64|x64.ActiveCfg = Debug|Any CPU + {020C24D8-DCD7-4AA1-A74A-2296E7CE490F}.x64|x64.Build.0 = Debug|Any CPU + {1A20F445-0CE7-4DA0-91EA-8F3AEF89C496}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A20F445-0CE7-4DA0-91EA-8F3AEF89C496}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A20F445-0CE7-4DA0-91EA-8F3AEF89C496}.Debug|x64.ActiveCfg = Debug|Any CPU + {1A20F445-0CE7-4DA0-91EA-8F3AEF89C496}.Debug|x64.Build.0 = Debug|Any CPU + {1A20F445-0CE7-4DA0-91EA-8F3AEF89C496}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A20F445-0CE7-4DA0-91EA-8F3AEF89C496}.Release|Any CPU.Build.0 = Release|Any CPU + {1A20F445-0CE7-4DA0-91EA-8F3AEF89C496}.Release|x64.ActiveCfg = Release|Any CPU + {1A20F445-0CE7-4DA0-91EA-8F3AEF89C496}.Release|x64.Build.0 = Release|Any CPU + {1A20F445-0CE7-4DA0-91EA-8F3AEF89C496}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {1A20F445-0CE7-4DA0-91EA-8F3AEF89C496}.x64|Any CPU.Build.0 = Debug|Any CPU + {1A20F445-0CE7-4DA0-91EA-8F3AEF89C496}.x64|x64.ActiveCfg = Debug|Any CPU + {1A20F445-0CE7-4DA0-91EA-8F3AEF89C496}.x64|x64.Build.0 = Debug|Any CPU + {274E405C-AB90-4D5D-9AC6-3C7CF1819E60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {274E405C-AB90-4D5D-9AC6-3C7CF1819E60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {274E405C-AB90-4D5D-9AC6-3C7CF1819E60}.Debug|x64.ActiveCfg = Debug|Any CPU + {274E405C-AB90-4D5D-9AC6-3C7CF1819E60}.Debug|x64.Build.0 = Debug|Any CPU + {274E405C-AB90-4D5D-9AC6-3C7CF1819E60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {274E405C-AB90-4D5D-9AC6-3C7CF1819E60}.Release|Any CPU.Build.0 = Release|Any CPU + {274E405C-AB90-4D5D-9AC6-3C7CF1819E60}.Release|x64.ActiveCfg = Release|Any CPU + {274E405C-AB90-4D5D-9AC6-3C7CF1819E60}.Release|x64.Build.0 = Release|Any CPU + {274E405C-AB90-4D5D-9AC6-3C7CF1819E60}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {274E405C-AB90-4D5D-9AC6-3C7CF1819E60}.x64|Any CPU.Build.0 = Debug|Any CPU + {274E405C-AB90-4D5D-9AC6-3C7CF1819E60}.x64|x64.ActiveCfg = Debug|Any CPU + {274E405C-AB90-4D5D-9AC6-3C7CF1819E60}.x64|x64.Build.0 = Debug|Any CPU + {370E7196-9D06-43D6-B267-F63F8C38C33F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {370E7196-9D06-43D6-B267-F63F8C38C33F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {370E7196-9D06-43D6-B267-F63F8C38C33F}.Debug|x64.ActiveCfg = Debug|Any CPU + {370E7196-9D06-43D6-B267-F63F8C38C33F}.Debug|x64.Build.0 = Debug|Any CPU + {370E7196-9D06-43D6-B267-F63F8C38C33F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {370E7196-9D06-43D6-B267-F63F8C38C33F}.Release|Any CPU.Build.0 = Release|Any CPU + {370E7196-9D06-43D6-B267-F63F8C38C33F}.Release|x64.ActiveCfg = Release|Any CPU + {370E7196-9D06-43D6-B267-F63F8C38C33F}.Release|x64.Build.0 = Release|Any CPU + {370E7196-9D06-43D6-B267-F63F8C38C33F}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {370E7196-9D06-43D6-B267-F63F8C38C33F}.x64|Any CPU.Build.0 = Debug|Any CPU + {370E7196-9D06-43D6-B267-F63F8C38C33F}.x64|x64.ActiveCfg = Debug|Any CPU + {370E7196-9D06-43D6-B267-F63F8C38C33F}.x64|x64.Build.0 = Debug|Any CPU + {40158660-CD40-44A8-8834-077C132B7DDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {40158660-CD40-44A8-8834-077C132B7DDD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {40158660-CD40-44A8-8834-077C132B7DDD}.Debug|x64.ActiveCfg = Debug|Any CPU + {40158660-CD40-44A8-8834-077C132B7DDD}.Debug|x64.Build.0 = Debug|Any CPU + {40158660-CD40-44A8-8834-077C132B7DDD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {40158660-CD40-44A8-8834-077C132B7DDD}.Release|Any CPU.Build.0 = Release|Any CPU + {40158660-CD40-44A8-8834-077C132B7DDD}.Release|x64.ActiveCfg = Release|Any CPU + {40158660-CD40-44A8-8834-077C132B7DDD}.Release|x64.Build.0 = Release|Any CPU + {40158660-CD40-44A8-8834-077C132B7DDD}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {40158660-CD40-44A8-8834-077C132B7DDD}.x64|Any CPU.Build.0 = Debug|Any CPU + {40158660-CD40-44A8-8834-077C132B7DDD}.x64|x64.ActiveCfg = Debug|Any CPU + {40158660-CD40-44A8-8834-077C132B7DDD}.x64|x64.Build.0 = Debug|Any CPU + {7F04788A-8B14-4C62-A247-3B1F93C01B03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F04788A-8B14-4C62-A247-3B1F93C01B03}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F04788A-8B14-4C62-A247-3B1F93C01B03}.Debug|x64.ActiveCfg = Debug|Any CPU + {7F04788A-8B14-4C62-A247-3B1F93C01B03}.Debug|x64.Build.0 = Debug|Any CPU + {7F04788A-8B14-4C62-A247-3B1F93C01B03}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F04788A-8B14-4C62-A247-3B1F93C01B03}.Release|Any CPU.Build.0 = Release|Any CPU + {7F04788A-8B14-4C62-A247-3B1F93C01B03}.Release|x64.ActiveCfg = Release|Any CPU + {7F04788A-8B14-4C62-A247-3B1F93C01B03}.Release|x64.Build.0 = Release|Any CPU + {7F04788A-8B14-4C62-A247-3B1F93C01B03}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {7F04788A-8B14-4C62-A247-3B1F93C01B03}.x64|Any CPU.Build.0 = Debug|Any CPU + {7F04788A-8B14-4C62-A247-3B1F93C01B03}.x64|x64.ActiveCfg = Debug|Any CPU + {7F04788A-8B14-4C62-A247-3B1F93C01B03}.x64|x64.Build.0 = Debug|Any CPU + {0AB29534-3D3E-4482-A98A-8F1C01C4B476}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0AB29534-3D3E-4482-A98A-8F1C01C4B476}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0AB29534-3D3E-4482-A98A-8F1C01C4B476}.Debug|x64.ActiveCfg = Debug|Any CPU + {0AB29534-3D3E-4482-A98A-8F1C01C4B476}.Debug|x64.Build.0 = Debug|Any CPU + {0AB29534-3D3E-4482-A98A-8F1C01C4B476}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0AB29534-3D3E-4482-A98A-8F1C01C4B476}.Release|Any CPU.Build.0 = Release|Any CPU + {0AB29534-3D3E-4482-A98A-8F1C01C4B476}.Release|x64.ActiveCfg = Release|Any CPU + {0AB29534-3D3E-4482-A98A-8F1C01C4B476}.Release|x64.Build.0 = Release|Any CPU + {0AB29534-3D3E-4482-A98A-8F1C01C4B476}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {0AB29534-3D3E-4482-A98A-8F1C01C4B476}.x64|Any CPU.Build.0 = Debug|Any CPU + {0AB29534-3D3E-4482-A98A-8F1C01C4B476}.x64|x64.ActiveCfg = Debug|Any CPU + {0AB29534-3D3E-4482-A98A-8F1C01C4B476}.x64|x64.Build.0 = Debug|Any CPU + {B387A175-639B-4376-A221-34E733B4C6FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B387A175-639B-4376-A221-34E733B4C6FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B387A175-639B-4376-A221-34E733B4C6FC}.Debug|x64.ActiveCfg = Debug|Any CPU + {B387A175-639B-4376-A221-34E733B4C6FC}.Debug|x64.Build.0 = Debug|Any CPU + {B387A175-639B-4376-A221-34E733B4C6FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B387A175-639B-4376-A221-34E733B4C6FC}.Release|Any CPU.Build.0 = Release|Any CPU + {B387A175-639B-4376-A221-34E733B4C6FC}.Release|x64.ActiveCfg = Release|Any CPU + {B387A175-639B-4376-A221-34E733B4C6FC}.Release|x64.Build.0 = Release|Any CPU + {B387A175-639B-4376-A221-34E733B4C6FC}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {B387A175-639B-4376-A221-34E733B4C6FC}.x64|Any CPU.Build.0 = Debug|Any CPU + {B387A175-639B-4376-A221-34E733B4C6FC}.x64|x64.ActiveCfg = Debug|Any CPU + {B387A175-639B-4376-A221-34E733B4C6FC}.x64|x64.Build.0 = Debug|Any CPU + {C609247B-037D-4E16-9E75-6DA142E4EDAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C609247B-037D-4E16-9E75-6DA142E4EDAC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C609247B-037D-4E16-9E75-6DA142E4EDAC}.Debug|x64.ActiveCfg = Debug|Any CPU + {C609247B-037D-4E16-9E75-6DA142E4EDAC}.Debug|x64.Build.0 = Debug|Any CPU + {C609247B-037D-4E16-9E75-6DA142E4EDAC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C609247B-037D-4E16-9E75-6DA142E4EDAC}.Release|Any CPU.Build.0 = Release|Any CPU + {C609247B-037D-4E16-9E75-6DA142E4EDAC}.Release|x64.ActiveCfg = Release|Any CPU + {C609247B-037D-4E16-9E75-6DA142E4EDAC}.Release|x64.Build.0 = Release|Any CPU + {C609247B-037D-4E16-9E75-6DA142E4EDAC}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {C609247B-037D-4E16-9E75-6DA142E4EDAC}.x64|Any CPU.Build.0 = Debug|Any CPU + {C609247B-037D-4E16-9E75-6DA142E4EDAC}.x64|x64.ActiveCfg = Debug|Any CPU + {C609247B-037D-4E16-9E75-6DA142E4EDAC}.x64|x64.Build.0 = Debug|Any CPU + {CF74444A-A805-43F2-A5C9-2AF49E472754}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CF74444A-A805-43F2-A5C9-2AF49E472754}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CF74444A-A805-43F2-A5C9-2AF49E472754}.Debug|x64.ActiveCfg = Debug|Any CPU + {CF74444A-A805-43F2-A5C9-2AF49E472754}.Debug|x64.Build.0 = Debug|Any CPU + {CF74444A-A805-43F2-A5C9-2AF49E472754}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CF74444A-A805-43F2-A5C9-2AF49E472754}.Release|Any CPU.Build.0 = Release|Any CPU + {CF74444A-A805-43F2-A5C9-2AF49E472754}.Release|x64.ActiveCfg = Release|Any CPU + {CF74444A-A805-43F2-A5C9-2AF49E472754}.Release|x64.Build.0 = Release|Any CPU + {CF74444A-A805-43F2-A5C9-2AF49E472754}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {CF74444A-A805-43F2-A5C9-2AF49E472754}.x64|Any CPU.Build.0 = Debug|Any CPU + {CF74444A-A805-43F2-A5C9-2AF49E472754}.x64|x64.ActiveCfg = Debug|Any CPU + {CF74444A-A805-43F2-A5C9-2AF49E472754}.x64|x64.Build.0 = Debug|Any CPU + {8EE7D3DD-FC09-42A7-91B3-447FC9944D0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8EE7D3DD-FC09-42A7-91B3-447FC9944D0E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8EE7D3DD-FC09-42A7-91B3-447FC9944D0E}.Debug|x64.ActiveCfg = Debug|Any CPU + {8EE7D3DD-FC09-42A7-91B3-447FC9944D0E}.Debug|x64.Build.0 = Debug|Any CPU + {8EE7D3DD-FC09-42A7-91B3-447FC9944D0E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8EE7D3DD-FC09-42A7-91B3-447FC9944D0E}.Release|Any CPU.Build.0 = Release|Any CPU + {8EE7D3DD-FC09-42A7-91B3-447FC9944D0E}.Release|x64.ActiveCfg = Release|Any CPU + {8EE7D3DD-FC09-42A7-91B3-447FC9944D0E}.Release|x64.Build.0 = Release|Any CPU + {8EE7D3DD-FC09-42A7-91B3-447FC9944D0E}.x64|Any CPU.ActiveCfg = Debug|Any CPU + {8EE7D3DD-FC09-42A7-91B3-447FC9944D0E}.x64|Any CPU.Build.0 = Debug|Any CPU + {8EE7D3DD-FC09-42A7-91B3-447FC9944D0E}.x64|x64.ActiveCfg = Debug|Any CPU + {8EE7D3DD-FC09-42A7-91B3-447FC9944D0E}.x64|x64.Build.0 = Debug|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {9D40B445-17E3-40CB-832F-B5D09863CB8E} + EndGlobalSection +EndGlobal diff --git a/ProjectMakoto/Properties/Resources.Designer.cs b/ProjectMakoto/Properties/Resources.Designer.cs new file mode 100644 index 00000000..9505b7e1 --- /dev/null +++ b/ProjectMakoto/Properties/Resources.Designer.cs @@ -0,0 +1,63 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace ProjectMakoto.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal sealed class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("ProjectMakoto.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + } +} diff --git a/ProjectMakoto/Properties/Resources.resx b/ProjectMakoto/Properties/Resources.resx new file mode 100644 index 00000000..41903cb2 --- /dev/null +++ b/ProjectMakoto/Properties/Resources.resx @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/ProjectMakoto/RunTranslationGenerator.sh b/ProjectMakoto/RunTranslationGenerator.sh new file mode 100644 index 00000000..ea66a0d2 --- /dev/null +++ b/ProjectMakoto/RunTranslationGenerator.sh @@ -0,0 +1,4 @@ +cd ../Tools/TranslationSourceGenerator +dotnet restore +dotnet run -- ../../ProjectMakoto/Translations/strings.json ../../ProjectMakoto/Entities/Translation/Translations.cs ProjectMakoto.Entities +sleep 60 \ No newline at end of file diff --git a/ProjectMakoto/Snippets/Project Makoto Command.zip b/ProjectMakoto/Snippets/Project Makoto Command.zip new file mode 100644 index 00000000..816479e3 Binary files /dev/null and b/ProjectMakoto/Snippets/Project Makoto Command.zip differ diff --git a/ProjectMakoto/Snippets/asynctask.snippet b/ProjectMakoto/Snippets/asynctask.snippet new file mode 100644 index 00000000..27b5ae08 --- /dev/null +++ b/ProjectMakoto/Snippets/asynctask.snippet @@ -0,0 +1,19 @@ + + + +
+ AsyncTask + Myself + Generates Task.Run with async + asynctask +
+ + + + { + $selected$ + });]]> + + +
+
diff --git a/ProjectMakoto/Snippets/braces.snippet b/ProjectMakoto/Snippets/braces.snippet new file mode 100644 index 00000000..44a0330e --- /dev/null +++ b/ProjectMakoto/Snippets/braces.snippet @@ -0,0 +1,18 @@ + + + +
+ Braces + Myself + Adds braces to selection + braces +
+ + + + + +
+
diff --git a/ProjectMakoto/Snippets/channelselection.snippet b/ProjectMakoto/Snippets/channelselection.snippet new file mode 100644 index 00000000..a68cb3ce --- /dev/null +++ b/ProjectMakoto/Snippets/channelselection.snippet @@ -0,0 +1,39 @@ + + + +
+ Channel Selection + Myself + Channel Selection + channelsel +
+ + + + + +
+
diff --git a/ProjectMakoto/Snippets/modal.snippet b/ProjectMakoto/Snippets/modal.snippet new file mode 100644 index 00000000..c8be0a80 --- /dev/null +++ b/ProjectMakoto/Snippets/modal.snippet @@ -0,0 +1,31 @@ + + + +
+ Modal with Retry + Myself + Modal with Retry + modal +
+ + + + + +
+
diff --git a/ProjectMakoto/Snippets/roleselection.snippet b/ProjectMakoto/Snippets/roleselection.snippet new file mode 100644 index 00000000..331c6f49 --- /dev/null +++ b/ProjectMakoto/Snippets/roleselection.snippet @@ -0,0 +1,38 @@ + + + +
+ Role Selection + Myself + Role Selection + rolesel +
+ + + + + +
+
diff --git a/ProjectMakoto/Snippets/selection.snippet b/ProjectMakoto/Snippets/selection.snippet new file mode 100644 index 00000000..1e20ad70 --- /dev/null +++ b/ProjectMakoto/Snippets/selection.snippet @@ -0,0 +1,31 @@ + + + +
+ Custom Selection + Myself + Custom Selection + customsel +
+ + + + + +
+
diff --git a/ProjectMakoto/Snippets/task.snippet b/ProjectMakoto/Snippets/task.snippet new file mode 100644 index 00000000..9464018f --- /dev/null +++ b/ProjectMakoto/Snippets/task.snippet @@ -0,0 +1,19 @@ + + + +
+ Task + Myself + Generates Task.Run without async + task +
+ + + + { + $selected$ + });]]> + + +
+
diff --git a/ProjectMakoto/Snippets/taskwithadd.snippet b/ProjectMakoto/Snippets/taskwithadd.snippet new file mode 100644 index 00000000..91e3e5c8 --- /dev/null +++ b/ProjectMakoto/Snippets/taskwithadd.snippet @@ -0,0 +1,19 @@ + + + +
+ TaskAdd + Myself + Generates async Task.Run with Add + task +
+ + + + { + $selected$ + }).Add(_bot._watcher, ctx);]]> + + +
+
diff --git a/ProjectMakoto/Translations/strings.json b/ProjectMakoto/Translations/strings.json new file mode 100644 index 00000000..63f43207 --- /dev/null +++ b/ProjectMakoto/Translations/strings.json @@ -0,0 +1,5066 @@ +{ + "CommandList": [ + { + "Type": 1, + "Names": { + "en": "help", + "de": "hilfe" + }, + "Descriptions": { + "en": "Sends you a list of all available commands, their usage and their description.", + "de": "Sendet dir eine Liste von allen verfügbaren Befehlen, ihr Syntax und ihre Beschreibung." + }, + "Options": [ + { + "Names": { + "en": "command", + "de": "befehl" + }, + "Descriptions": { + "en": "The command to show help for.", + "de": "Der Befehl für den die Hilfe angezeigt werden soll." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "user-info", + "de": "benutzer-info" + }, + "Descriptions": { + "en": "Displays information the bot knows about you or the mentioned user.", + "de": "Zeigt Informationen über dich oder den gewählten Benutzer an." + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user to show information about.", + "de": "Der Benutzer über den Information angezeigt werden sollen." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "guild-info", + "de": "server-info" + }, + "Descriptions": { + "en": "Displays information the bot knows about this or the mentioned server.", + "de": "Zeigt Informationen über diesen oder den gewählten Server an." + }, + "Options": [ + { + "Names": { + "en": "guild", + "de": "server" + }, + "Descriptions": { + "en": "The guild to show information about.", + "de": "Der Server über den Information angezeigt werden sollen." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "reminders", + "de": "erinnerungen" + }, + "Descriptions": { + "en": "Allows you to manage your reminders.", + "de": "Erlaubt es dir deine Erinnerungen zu verwalten." + } + }, + { + "Type": 1, + "Names": { + "en": "avatar", + "de": "profilbild" + }, + "Descriptions": { + "en": "Displays your or the mentioned user's avatar as an embedded image.", + "de": "Zeigt das Profilbild von dir oder dem gewählten Benutzer." + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user to show the avatar from.", + "de": "Der Benutzer von dem das Profilbild angezeigt werden soll." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "banner", + "de": "banner" + }, + "Descriptions": { + "en": "Displays your or the mentioned user's banner as an embedded image.", + "de": "Zeigt das Banner von dir oder dem gewählten Benutzer." + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user to show the avatar from.", + "de": "Der Benutzer von dem das Banner angezeigt werden soll." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "rank", + "de": "rang" + }, + "Descriptions": { + "en": "Shows your or the mentioned user's rank and rank progress.", + "de": "Zeigt dir den Rang von dir oder dem gewählten Benutzer." + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user to show the rank from.", + "de": "Der Benutzer von dem der Rang angezeigt werden soll." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "leaderboard", + "de": "rangliste" + }, + "Descriptions": { + "en": "Displays the current experience rankings on this server.", + "de": "Zeigt dir die aktuelle Rangliste auf diesem Server." + }, + "Options": [ + { + "Names": { + "en": "amount", + "de": "menge" + }, + "Descriptions": { + "en": "The amount of rankings to show.", + "de": "Wie viele Plätze angezeigt werden sollen." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "report-host", + "de": "host-melden" + }, + "Descriptions": { + "en": "Allows you to contribute a new malicious host to our database.", + "de": "Erlaubt es dir, einen neuen bösartigen Host zu melden." + }, + "Options": [ + { + "Names": { + "en": "url", + "de": "url" + }, + "Descriptions": { + "en": "The host you want to report.", + "de": "Den Host den du melden möchtest." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "report-translation", + "de": "übersetzung-melden" + }, + "Descriptions": { + "en": "Allows you to report missing, invalid or incorrect translations in Makoto.", + "de": "Erlaubt es fehlende oder falsche Übersetzungen in Makoto zu melden." + }, + "Options": [ + { + "Names": { + "en": "affected_type", + "de": "betroffener_typ" + }, + "Descriptions": { + "en": "The type of module that is affected.", + "de": "Der Typ des Moduls was betroffen ist." + }, + "Choices": [ + { + "Names": { + "en": "Miscellaneous", + "de": "Sonstiges" + } + }, + { + "Names": { + "en": "Command", + "de": "Befehl" + } + }, + { + "Names": { + "en": "Event", + "de": "Ereignis" + } + } + ] + }, + { + "Names": { + "en": "component", + "de": "komponent" + }, + "Descriptions": { + "en": "The affected component", + "de": "Das Komponent was betroffen ist" + } + }, + { + "Names": { + "en": "report_type", + "de": "meldungs_typ" + }, + "Descriptions": { + "en": "The type of issue you're reporting.", + "de": "Die Art von Meldung" + }, + "Choices": [ + { + "Names": { + "en": "MissingTranslation", + "de": "FehlendeÜbersetzung" + } + }, + { + "Names": { + "en": "IncorrectTranslation", + "de": "FalscheÜbersetzung" + } + }, + { + "Names": { + "en": "ValuesNotFilledIntoString", + "de": "WerteNichtGeladen" + } + }, + { + "Names": { + "en": "Other", + "de": "Sonstiges" + } + } + ] + }, + { + "Names": { + "en": "additional_information", + "de": "weitere_informationen" + }, + "Descriptions": { + "en": "Any additional information you can give us.", + "de": "Jegliche weitere Informationen die du uns dazu geben kannst." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "upload", + "de": "hochladen" + }, + "Descriptions": { + "en": "Upload a file to the bot. Only use when instructed to.", + "de": "Lade eine Datei auf den Bot hoch. Nur verwenden wenn dazu aufgefordert." + }, + "Options": [ + { + "Names": { + "en": "file", + "de": "datei" + }, + "Descriptions": { + "en": "The file you want to upload.", + "de": "Die Datei, die du hochladen möchtest." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "urban-dictionary", + "de": "urban-dictionary" + }, + "Descriptions": { + "en": "Look up a term on Urban Dictionary.", + "de": "Suche nach einem Wort oder einer Phrase auf Urban Dictionary." + }, + "Options": [ + { + "Names": { + "en": "term", + "de": "wort" + }, + "Descriptions": { + "en": "The term you want to look up.", + "de": "Das Wort oder die Phrase, nach der du suchen möchtest." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "language", + "de": "sprache" + }, + "Descriptions": { + "en": "Change the language Makoto uses.", + "de": "Erlaubt es dir die Sprache, die Makoto verwendet, zu ändern." + } + }, + { + "Type": 1, + "Names": { + "en": "credits", + "de": "credits" + }, + "Descriptions": { + "en": "Allows you to view who contributed the bot.", + "de": "Zeigt dir wer Makoto unterstützt hat." + } + }, + { + "Type": 3, + "Names": { + "en": "Steal Emojis", + "de": "Emojis klauen" + }, + "Descriptions": { + "en": "Allows you to steal emojis and stickers from a selected message.", + "de": "Erlaubt es dir Emojis oder Sticker von einer ausgewählten Nachricht zu klauen." + } + }, + { + "Type": 3, + "Names": { + "en": "Add a Reaction Role", + "de": "Eine Reaktionsrolle hinzufügen" + }, + "Descriptions": { + "en": "Allows you to add a reaction role to the selected message.", + "de": "Erlaubt es dir eine Reaktionsrolle zur ausgewählten Nachricht hinzuzufügen." + } + }, + { + "Type": 3, + "Names": { + "en": "Remove a Reaction Role", + "de": "Eine Reaktionsrolle entfernen" + }, + "Descriptions": { + "en": "Allows you to remove a reaction role from the selected message.", + "de": "Erlaubt es dir eine Reaktionsrolle von der ausgewählten Nachricht zu entfernen." + } + }, + { + "Type": 3, + "Names": { + "en": "Remove all Reaction Roles", + "de": "Eine Reaktionsrolle entfernen" + }, + "Descriptions": { + "en": "Allows you to remove all reaction roles from the selected message.", + "de": "Erlaubt es dir alle Reaktionsrollen von der ausgewählten Nachricht zu entfernen." + } + }, + { + "Type": 1, + "Names": { + "en": "purge", + "de": "purge" + }, + "Descriptions": { + "en": "Deletes the specified amount of messages with an optional user filter.", + "de": "Löscht die angegebene Menge an Nachrichten mit einem optionalen Nutzerfilter." + }, + "Options": [ + { + "Names": { + "en": "number", + "de": "anzahl" + }, + "Descriptions": { + "en": "1-2000 | The number of messages to delete.", + "de": "1-2000 | Die Anzahl an Nachricht zu löschen." + } + }, + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "Optional user filter, only delete messages by this user.", + "de": "Optionaler Nutzerfilter, lösche Nachrichten nur von diesem Benutzer." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "guild-purge", + "de": "guild-purge" + }, + "Descriptions": { + "en": "Deletes the specified amount of messages with an user filter in every channel.", + "de": "Löscht die angegebene Menge an Nachrichten mit einem Nutzerfilter in jedem Kanal." + }, + "Options": [ + { + "Names": { + "en": "number", + "de": "anzahl" + }, + "Descriptions": { + "en": "1-2000 | The number of messages to delete in every channel.", + "de": "1-2000 | Die Anzahl an Nachricht zu löschen in jedem Kanal." + } + }, + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "User filter, only delete messages by this user.", + "de": "Nutzerfilter, lösche Nachrichten nur von diesem Benutzer." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "clearbackup", + "de": "backup-entfernen" + }, + "Descriptions": { + "en": "Clears the stored roles and nickname of a user.", + "de": "Löscht die gespeicherten Rollen und den Nutzernamen von einem Benutzer." + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user of who the backup will be deleted.", + "de": "Der Benutzer von dem das Backup gelöscht werden soll." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "timeout", + "de": "timeout" + }, + "Descriptions": { + "en": "Sets a user into timeout.", + "de": "Schickt einen Benutzer in den Timeout." + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user to timeout.", + "de": "Der Benutzer der in den Timeout geschickt werden soll." + } + }, + { + "Names": { + "en": "duration", + "de": "dauer" + }, + "Descriptions": { + "en": "How long this user will be timed out for.", + "de": "Wie lang dieser Benutzer im Timeout sein wird." + } + }, + { + "Names": { + "en": "reason", + "de": "grund" + }, + "Descriptions": { + "en": "The reason why this user is in timeout.", + "de": "Der Grund warum der Benutzer im Timeout ist." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "remove-timeout", + "de": "timeout-entfernen" + }, + "Descriptions": { + "en": "Removes a timeout from the specified user.", + "de": "Entfernt den Timeout von dem angegebenen Benutzer." + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user to remove the timeout from.", + "de": "Der Benutzer von dem der Timeout entfernt werden soll." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "kick", + "de": "kick" + }, + "Descriptions": { + "en": "Kicks the specified user.", + "de": "Kickt den angegebenen Benutzer vom Server." + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user to kick.", + "de": "Der Benutzer der gekickt werden soll." + } + }, + { + "Names": { + "en": "reason", + "de": "grund" + }, + "Descriptions": { + "en": "The reason why this user was kicked.", + "de": "Der Grund warum dieser Benutzer gekickt wurde." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "ban", + "de": "ban" + }, + "Descriptions": { + "en": "Bans the specified user.", + "de": "Bannt den angegebenen Benutzer vom Server." + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user to ban.", + "de": "Der Benutzer der gebannt werden soll." + } + }, + { + "Names": { + "en": "days", + "de": "tage" + }, + "Descriptions": { + "en": "Delete messages younger than x days.", + "de": "Nachrichten löschen die jünger als x Tage sind." + } + }, + { + "Names": { + "en": "reason", + "de": "grund" + }, + "Descriptions": { + "en": "The reason why this user was banned.", + "de": "Der Grund warum dieser Benutzer gebannt wurde." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "softban", + "de": "softban" + }, + "Descriptions": { + "en": "Soft bans the specified user. (Bans & unbans user immediately.)", + "de": "Soft-bannt den angegebenen Benutzer vom Server. (Bannt & entbannt Nutzer)" + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user to ban.", + "de": "Der Benutzer der soft-gebannt werden soll." + } + }, + { + "Names": { + "en": "days", + "de": "tage" + }, + "Descriptions": { + "en": "Delete messages younger than x days.", + "de": "Nachrichten löschen die jünger als x Tage sind." + } + }, + { + "Names": { + "en": "reason", + "de": "grund" + }, + "Descriptions": { + "en": "The reason why this user was soft banned.", + "de": "Der Grund warum dieser Benutzer soft-gebannt wurde." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "unban", + "de": "unban" + }, + "Descriptions": { + "en": "Unbans the specified user.", + "de": "Entbannt den angegebenen Benutzer vom Server." + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user to unban.", + "de": "Der Benutzer der entbannt werden soll." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "follow", + "de": "folgen" + }, + "Descriptions": { + "en": "Allows you to follow an announcement channel from our support server.", + "de": "Erlaubt es dir Neugkeitenkanälen von unserem Supportserver zu folgen." + }, + "Options": [ + { + "Names": { + "en": "channel", + "de": "kanal" + }, + "Descriptions": { + "en": "The channel to follow.", + "de": "Der Kanal dem du folgen möchtest." + }, + "Choices": [ + { + "Names": { + "en": "GithubUpdates", + "de": "GithubUpdates" + } + }, + { + "Names": { + "en": "GlobalBans", + "de": "GlobalBans" + } + }, + { + "Names": { + "en": "News", + "de": "News" + } + } + ] + } + ] + }, + { + "Type": 1, + "Names": { + "en": "moveall", + "de": "moveall" + }, + "Descriptions": { + "en": "Move all users in your Voice Channel to another Voice Channel.", + "de": "Verschiebt alle Benutzer in deinen Sprachkanal zu einem anderen Sprachkanal." + }, + "Options": [ + { + "Names": { + "en": "channel", + "de": "kanal" + }, + "Descriptions": { + "en": "The channel to move to.", + "de": "Der neue Sprachkanal." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "movehere", + "de": "movehere" + }, + "Descriptions": { + "en": "Move all users from another Voice Channel to your Voice Channel.", + "de": "Verschiebt alle Benutzer von einem anderen Sprachkanal zu deinem Sprachkanal." + }, + "Options": [ + { + "Names": { + "en": "channel", + "de": "kanal" + }, + "Descriptions": { + "en": "The channel to move from.", + "de": "Der Sprachkanal." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "customembed", + "de": "customembed" + }, + "Descriptions": { + "en": "Create an embedded message.", + "de": "Erlaubt es dir eine eigene eingebettete Nachricht zu erstellen." + } + }, + { + "Type": 1, + "Names": { + "en": "override-bump-time", + "de": "bump-zeit-überschreiben" + }, + "Descriptions": { + "en": "Allows fixing of the last bump in case Disboard did not properly post a message.", + "de": "Erlaubt es dir die letzte Bump Zeit zu überschreiben." + } + }, + { + "Type": 1, + "Names": { + "en": "developertools", + "de": "entwicklerwerkzeuge" + }, + "Descriptions": { + "en": "Developer Tools used to manage Makoto.", + "de": "Entwicklerwerkzeuge um Makoto zu verwalten." + }, + "Options": [ + { + "Names": { + "en": "command", + "de": "befehl" + }, + "Descriptions": { + "en": "The command you want to run.", + "de": "Der Befehl der ausgeführt werden soll." + } + }, + { + "Names": { + "en": "argument1", + "de": "argument1" + }, + "Descriptions": { + "en": "Argument 1, if required", + "de": "Argument 1, falls nötig" + } + }, + { + "Names": { + "en": "argument2", + "de": "argument2" + }, + "Descriptions": { + "en": "Argument 2, if required", + "de": "Argument 2, falls nötig" + } + }, + { + "Names": { + "en": "argument3", + "de": "argument3" + }, + "Descriptions": { + "en": "Argument 3, if required", + "de": "Argument 3, falls nötig" + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "data", + "de": "daten" + }, + "Descriptions": { + "en": "Allows you to request or manage your user data.", + "de": "Erlaubt es dir deine Nutzerdaten anzufragen oder zu verwalten." + }, + "Commands": [ + { + "Names": { + "en": "request", + "de": "anfragen" + }, + "Descriptions": { + "en": "Allows you to request your user data.", + "de": "Erlaubt es dir deine Nutzerdaten anzufragen." + } + }, + { + "Names": { + "en": "delete", + "de": "löschen" + }, + "Descriptions": { + "en": "Allows you to delete your user data and stop Makoto from further processing your user data.", + "de": "Erlaubt es dir deine Nutzerdaten zu löschen und Makoto die weitere Datenverarbeitung zu verbieten." + } + }, + { + "Names": { + "en": "policy", + "de": "policy" + }, + "Descriptions": { + "en": "Allows you to view how Makoto processes your data.", + "de": "Erklärt wie Makoto deine Daten behandelt und verarbeitet." + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "vcc", + "de": "vcc" + }, + "Descriptions": { + "en": "Allows you to modify your own voice channel.", + "de": "Erlaubt es dir dein persönlichen Sprachkanal zu bearbeiten." + }, + "Commands": [ + { + "Names": { + "en": "open", + "de": "öffnen" + }, + "Descriptions": { + "en": "Opens your channel so new users can freely join.", + "de": "Öffnet dein Sprachkanal für alle Benutzer." + } + }, + { + "Names": { + "en": "close", + "de": "schließen" + }, + "Descriptions": { + "en": "Closes your channel. You have to invite people for them to join.", + "de": "Schließt deinen Sprachkanal für alle Benutzer." + } + }, + { + "Names": { + "en": "name", + "de": "name" + }, + "Descriptions": { + "en": "Changes the name of your channel.", + "de": "Ändert den Namen von deinen Sprachkanal." + }, + "Options": [ + { + "Names": { + "en": "name", + "de": "name" + }, + "Descriptions": { + "en": "The new name.", + "de": "Der neue Name." + } + } + ] + }, + { + "Names": { + "en": "limit", + "de": "limit" + }, + "Descriptions": { + "en": "Changes user limit of your channel.", + "de": "Ändert das Benutzerlimit von deinen Sprachkanal." + }, + "Options": [ + { + "Names": { + "en": "limit", + "de": "limit" + }, + "Descriptions": { + "en": "The new limit.", + "de": "Das neue Limit." + } + } + ] + }, + { + "Names": { + "en": "invite", + "de": "einladen" + }, + "Descriptions": { + "en": "Invites a new person to your channel.", + "de": "Lädt eine neue Person zu deinem Sprachkanal ein." + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user to invite.", + "de": "Der Benutzer der eingeladen werden soll." + } + } + ] + }, + { + "Names": { + "en": "kick", + "de": "kick" + }, + "Descriptions": { + "en": "Kicks a person from your channel.", + "de": "Kickt eine Person aus deinem Sprachkanal." + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user to kick.", + "de": "Der Benutzer der gekickt werden soll." + } + } + ] + }, + { + "Names": { + "en": "ban", + "de": "ban" + }, + "Descriptions": { + "en": "Bans a person from your channel.", + "de": "Bannt eine Person aus deinem Sprachkanal." + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user to ban.", + "de": "Der Benutzer der gebannt werden soll." + } + } + ] + }, + { + "Names": { + "en": "unban", + "de": "unban" + }, + "Descriptions": { + "en": "Unbans a person from your channel.", + "de": "Entbannt eine Person aus deinem Sprachkanal." + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user to unban.", + "de": "Der Benutzer der entbannt werden soll." + } + } + ] + }, + { + "Names": { + "en": "change-owner", + "de": "besitzer-ändern" + }, + "Descriptions": { + "en": "Sets a new person to be the owner of your channel.", + "de": "Setzt einen anderen Benutzer als den Besitzer von deinem Sprachkanal." + }, + "Options": [ + { + "Names": { + "en": "user", + "de": "benutzer" + }, + "Descriptions": { + "en": "The user that will receive the channel.", + "de": "Der Benutzer der den Kanal bekommt." + } + } + ] + } + ] + }, + { + "Type": 1, + "Names": { + "en": "debug", + "de": "debug" + }, + "Descriptions": { + "en": "Debug commands, only registered in this server.", + "de": "Debug Befehle, nur in diesem Server registriert." + }, + "Commands": [ + { + "Names": { + "en": "throw", + "de": "throw" + }, + "Descriptions": { + "en": "throw", + "de": "throw" + } + }, + { + "Names": { + "en": "test", + "de": "test" + }, + "Descriptions": { + "en": "test", + "de": "test" + }, + "Options": [ + { + "Names": { + "en": "test" + }, + "Descriptions": { + "en": "test" + } + } + ] + }, + { + "Names": { + "en": "tfa-register", + "de": "tfa-register" + }, + "Descriptions": { + "en": "tfa-register", + "de": "tfa-register" + } + }, + { + "Names": { + "en": "tfa-test", + "de": "tfa-test" + }, + "Descriptions": { + "en": "tfa-test", + "de": "tfa-test" + } + } + ] + }, + { + "Type": 1, + "Names": { + "en": "config", + "de": "einstellungen" + }, + "Descriptions": { + "en": "Allows you to configure Makoto.", + "de": "Erlaubt es dir Einstellungen von Makoto zu ändern." + }, + "Commands": [ + { + "Names": { + "en": "join", + "de": "beitritt" + }, + "Descriptions": { + "en": "Allows you to review and change settings in the event somebody joins the server.", + "de": "Erlaubt es dir die aktuelle Beitritts-Konfiguration anzusehen oder zu ändern." + } + }, + { + "Names": { + "en": "experience", + "de": "erfahrung" + }, + "Descriptions": { + "en": "Allows you to review and change settings related to experience.", + "de": "Erlaubt es dir die aktuelle Erfahrungs-Konfiguration anzusehen oder zu ändern." + } + }, + { + "Names": { + "en": "levelrewards", + "de": "rang-belohnungen" + }, + "Descriptions": { + "en": "Allows you to review, add and change Level Rewards.", + "de": "Erlaubt es dir die aktuelle Rang Belohnungen Konfiguration anzusehen oder zu bearbeiten." + } + }, + { + "Names": { + "en": "phishing", + "de": "phishing" + }, + "Descriptions": { + "en": "Allows you to review and change settings related to phishing link protection.", + "de": "Erlaubt es dir die aktuellen Phishingschutz Konfiguration anzusehen oder zu bearbeiten." + } + }, + { + "Names": { + "en": "bumpreminder", + "de": "bump-erinnerungen" + }, + "Descriptions": { + "en": "Allows you to review, set up and change settings related to the Bump Reminder.", + "de": "Erlaubt es dir die aktuelle Bump-Erinnerungen Konfiguration anzusehen oder zu bearbeiten." + } + }, + { + "Names": { + "en": "actionlog", + "de": "aktions-protokoll" + }, + "Descriptions": { + "en": "Allows you to review and change settings related to the actionlog.", + "de": "Erlaubt es dir die aktuelle Aktionsprotokoll Konfiguration anzusehen oder zu bearbeiten." + } + }, + { + "Names": { + "en": "autocrosspost", + "de": "autocrosspost" + }, + "Descriptions": { + "en": "Allows you to review and change settings related to automatic crossposting.", + "de": "Erlaubt es dir die aktuelle Auto Crosspost Konfiguration anzusehen oder zu bearbeiten." + } + }, + { + "Names": { + "en": "reactionroles", + "de": "reaktionsrollen" + }, + "Descriptions": { + "en": "Allows you to review and change settings related to Reaction Roles.", + "de": "Erlaubt es dir die aktuelle Reaktionsrollen Konfiguration anzusehen oder zu bearbeiten." + } + }, + { + "Names": { + "en": "invoiceprivacy", + "de": "sprachkanal-privatsphäre" + }, + "Descriptions": { + "en": "Allows you to review and change settings related to In-Voice Text Channel Privacy.", + "de": "Erlaubt es dir die aktuelle In-Voice Text Text Privacy Konfiguration anzusehen oder zu bearbeiten." + } + }, + { + "Names": { + "en": "invitetracker", + "de": "einladungstracker" + }, + "Descriptions": { + "en": "Allows you to review and change settings related to Invite Tracking.", + "de": "Erlaubt es dir die aktuelle Einladungstracker Konfiguration anzusehen oder zu bearbeiten." + } + }, + { + "Names": { + "en": "namenormalizer", + "de": "namen-normalisierer" + }, + "Descriptions": { + "en": "Allows you to review and change settings related to automatic name normalization.", + "de": "Erlaubt es dir die aktuelle Namen-Normalisierer Konfiguration anzusehen oder zu bearbeiten." + } + }, + { + "Names": { + "en": "autounarchive", + "de": "autounarchive" + }, + "Descriptions": { + "en": "Allows you to review and change settings related to automatic thread unarchiving.", + "de": "Erlaubt es dir die aktuelle Konfiguration für das Auto Entarchievieren anzusehen oder zu bearbeiten." + } + }, + { + "Names": { + "en": "embedmessages", + "de": "nachrichten-einbetten" + }, + "Descriptions": { + "en": "Allows you to review and change settings related to automatic message embedding.", + "de": "Erlaubt es dir die aktuelle Konfiguration für das erweiterte Nachrichten einbetten zu bearbeiten." + } + }, + { + "Names": { + "en": "tokendetection", + "de": "token-erkennung" + }, + "Descriptions": { + "en": "Allows you to review and change settings related to automatic token invalidation.", + "de": "Erlaubt es dir die aktuelle Konfiguration für das Token Invalidation anzusehen oder zu bearbeiten." + } + }, + { + "Names": { + "en": "invitenotes", + "de": "einladungsnotizen" + }, + "Descriptions": { + "en": "Allows you to add notes to invite codes.", + "de": "Erlaubt es dir die aktuelle Konfiguration für Einladungsnotizen anzusehen oder zu bearbeiten." + } + }, + { + "Names": { + "en": "vccreator", + "de": "vccreator" + }, + "Descriptions": { + "en": "Allows you to review and change settings related to the Voice Channel Creator.", + "de": "Erlaubt es dir die aktuelle Konfiguration für persönliche Sprachkanäle anzusehen oder zu bearbeiten." + } + }, + { + "Names": { + "en": "guild-prefix", + "de": "server-prefix" + }, + "Descriptions": { + "en": "Allows you to review and change settings related to the guild's prefix.", + "de": "Erlaubt es dir die aktuelle Konfiguration für das Server Prefix anzusehen oder zu bearbeiten." + } + }, + { + "Names": { + "en": "guild-language", + "de": "server-sprache" + }, + "Descriptions": { + "en": "Allows you to review and change settings related to the guild's selected language.", + "de": "Erlaubt es dir die aktuelle Konfiguration für die Serversprache anzusehen oder zu bearbeiten." + } + } + ] + } + ], + "Common": { + "Permissions": { + "None": { + "en": "None", + "de": "Keine" + }, + "All": { + "en": "All", + "de": "Alle" + }, + "CreateInstantInvite": { + "en": "Create Invite", + "de": "Einladung erstellen" + }, + "KickMembers": { + "en": "Kick Members", + "de": "Member kicken" + }, + "BanMembers": { + "en": "Ban Members", + "de": "Member bannen" + }, + "Administrator": { + "en": "Administrator", + "de": "Administrator" + }, + "ManageChannels": { + "en": "Manage Channels", + "de": "Kanäle verwalten" + }, + "ManageGuild": { + "en": "Manage Guild", + "de": "Server verwalten" + }, + "AddReactions": { + "en": "Add Reactions", + "de": "Reaktionen hinzufügen" + }, + "ViewAuditLog": { + "en": "View AuditLog", + "de": "AuditLog ansehen" + }, + "PrioritySpeaker": { + "en": "Priority Speaker", + "de": "Prioritätssprecher" + }, + "Stream": { + "en": "Screenshare", + "de": "Bildschirmübertragung" + }, + "AccessChannels": { + "en": "View Channels", + "de": "Kanäle sehen" + }, + "SendMessages": { + "en": "Send Messages", + "de": "Nachrichten senden" + }, + "SendTtsMessages": { + "en": "Send TTS Messages", + "de": "TTS Nachrichten senden" + }, + "ManageMessages": { + "en": "Mange Messages", + "de": "Nachrichten verwalten" + }, + "EmbedLinks": { + "en": "Embed Links", + "de": "Links einbetten" + }, + "AttachFiles": { + "en": "Attach Files", + "de": "Dateien anhängen" + }, + "ReadMessageHistory": { + "en": "Read Message History", + "de": "Nachrichtenverlauf lesen" + }, + "MentionEveryone": { + "en": "Mention Everyone", + "de": "Alle erwähnen" + }, + "UseExternalEmojis": { + "en": "Use External Emojis", + "de": "Externe Emojis verwenden" + }, + "ViewGuildInsights": { + "en": "View Guild Insights", + "de": "Server-Einblicke anzeigen" + }, + "UseVoice": { + "en": "Use Voice", + "de": "Verbinden" + }, + "Speak": { + "en": "Speak", + "de": "Sprechen" + }, + "MuteMembers": { + "en": "Mute Members", + "de": "Member stummschalten" + }, + "DeafenMembers": { + "en": "Deafen Members", + "de": "Ein- und Ausgabe von Membern deaktivieren" + }, + "MoveMembers": { + "en": "Move Members", + "de": "Member verschieben" + }, + "UseVoiceDetection": { + "en": "Use Voice Detection", + "de": "Sprachaktivierung verwenden" + }, + "ChangeNickname": { + "en": "Change Nickname", + "de": "Nickname ändern" + }, + "ManageNicknames": { + "en": "Manage Nicknames", + "de": "Nicknames verwalten" + }, + "ManageRoles": { + "en": "Manage Roles", + "de": "Rollen bearbeiten" + }, + "ManageWebhooks": { + "en": "Manage Webhooks", + "de": "Webhooks verwalten" + }, + "ManageGuildExpressions": { + "en": "Manage Guild's Sounds", + "de": "Serversounds verwalten" + }, + "UseApplicationCommands": { + "en": "Use Application Commands", + "de": "Anwendungsbefehle verwenden" + }, + "RequestToSpeak": { + "en": "Request To Speak", + "de": "Redeanfrage" + }, + "ManageEvents": { + "en": "Manage Events", + "de": "Events verwalten" + }, + "ManageThreads": { + "en": "Manage Threads", + "de": "Threads verwalten" + }, + "CreatePublicThreads": { + "en": "Create Public Threads", + "de": "Öffentliche Threads erstellen" + }, + "CreatePrivateThreads": { + "en": "Create Private Threads", + "de": "Private Threads erstellen" + }, + "UseExternalStickers": { + "en": "Use External Stickers", + "de": "Externe Sticker verwenden" + }, + "SendMessagesInThreads": { + "en": "Send Messages In Threads", + "de": "Nachrichten in Threads versenden" + }, + "StartEmbeddedActivities": { + "en": "Start Activities", + "de": "Aktivitäten starten" + }, + "ModerateMembers": { + "en": "Moderate Members", + "de": "Member moderieren" + }, + "ViewCreatorMonetizationInsights": { + "en": "View Creator Monetization Insights", + "de": "Creator Monetarisierungseinblicke anzeigen" + }, + "UseSoundboard": { + "en": "Use Soundboard", + "de": "Soundboard verwenden" + }, + "CreateGuildExpressions": { + "en": "Create Guild Expressions", + "de": "Sounds erstellen" + }, + "CreateEvents": { + "en": "Create Events", + "de": "Events erstellen" + }, + "UseExternalSounds": { + "en": "Use External Sounds", + "de": "Externe Sounds verwenden" + }, + "SendVoiceMessages": { + "en": "Send Voice Messages", + "de": "Sprachnachricht senden" + } + }, + "Time": { + "Years": { + "en": "Years", + "de": "Jahre" + }, + "Year": { + "en": "Year", + "de": "Jahr" + }, + "Months": { + "en": "Months", + "de": "Monate" + }, + "Month": { + "en": "Month", + "de": "Monat" + }, + "Days": { + "en": "Days", + "de": "Tage" + }, + "Day": { + "en": "Day", + "de": "Tag" + }, + "Hours": { + "en": "Hours", + "de": "Stunden" + }, + "Hour": { + "en": "Hour", + "de": "Stunde" + }, + "Minutes": { + "en": "Minutes", + "de": "Minuten" + }, + "Minute": { + "en": "Minute", + "de": "Minute" + }, + "Seconds": { + "en": "Seconds", + "de": "Sekunden" + }, + "Second": { + "en": "Second", + "de": "Sekunde" + } + }, + "MissingTranslation": { + "en": "Missing Translation", + "de": "Fehlende Übersetzung" + }, + "Yes": { + "en": "Yes", + "de": "Ja" + }, + "No": { + "en": "No", + "de": "Nein" + }, + "On": { + "en": "On", + "de": "An" + }, + "Off": { + "en": "Off", + "de": "Aus" + }, + "Confirm": { + "en": "Confirm", + "de": "Bestätigen" + }, + "Deny": { + "en": "Deny", + "de": "Ablehnen" + }, + "Submit": { + "en": "Submit", + "de": "Absenden" + }, + "Cancel": { + "en": "Cancel", + "de": "Abbrechen" + }, + "Back": { + "en": "Back", + "de": "Zurück" + }, + "Page": { + "en": "Page", + "de": "Seite" + }, + "PreviousPage": { + "en": "Previous Page", + "de": "Vorherige Seite" + }, + "NextPage": { + "en": "Next Page", + "de": "Nächste Seite" + }, + "Refresh": { + "en": "Refresh", + "de": "Neu laden" + }, + "NotSelected": { + "en": "Not yet selected.", + "de": "Noch nicht ausgewählt." + }, + "Reason": { + "en": "Reason", + "de": "Grund" + }, + "JumpToMessage": { + "en": "Jump to message", + "de": "Zur Nachricht springen" + } + }, + "Commands": { + "Common": { + "Errors": { + "Generic": { + "en": "You dont have permissions to use the command '{CurrentCommand}'. You need to be {Required} to use this command.", + "de": "Du hast nicht die benötigten Berechtigungen um den Befehl '{CurrentCommand}' zu verwenden. Du musst '{Required}' sein." + }, + "NoMember": { + "en": "The user you tagged is required to be on this server for this command to run.", + "de": "Der gewählte Benutzer muss auf dem Server sein um diesen Befehl zu verwenden." + }, + "BotOwner": { + "en": "You dont have permissions to use the command '{CurrentCommand}'. You need to be {Required} to use this command.", + "de": "Du hast nicht die benötigten Berechtigungen um den Befehl '{CurrentCommand}' zu verwenden. Du musst {Required} sein." + }, + "VoiceChannel": { + "en": "You aren't in a voice channel.", + "de": "Du befindest dich nicht in einem Sprachkanal." + }, + "UserBan": { + "en": "You are currently banned from using this bot: {Reason}", + "de": "Die Nutzung des Bots ist aktuell gesperrt für dich: {Reason}" + }, + "GuildBan": { + "en": "This guild is currently banned from using this bot: {Reason}", + "de": "Die Nutzung des Bots ist gesperrt auf diesem Server: {Reason}" + }, + "ExclusivePrefix": { + "en": "This command is exclusive to prefix commands.", + "de": "Dieser Befehl ist limitiert zur Verwendung als Prefixbefehl." + }, + "ExclusiveApp": { + "en": "This command is exclusive to application commands.", + "de": "Dieser Befehl ist limitiert zur Verwendung als Applikationsbefehl." + }, + "Data": { + "en": "You objected to having your data being processed. To run commands, please run '{Command}' again to re-allow data processing.", + "de": "Du hast Datenverarbeitung verweigert. Um Befehle erneut verwenden zu können, verwende '{Command}'." + }, + "BotPermissions": { + "en": "The bot is missing permissions to run this command. Please assign the bot '{Required}' to use this command.", + "de": "Dem Bot fehlen benötigte Berechtigungen. Bitte erteile die Berechtigung '{Required}' um diesen Befehl zu verwenden." + }, + "DirectMessage": { + "en": "I am unable to send you a direct message. Please make sure you have the server's direct messages on and you don't have me blocked.", + "de": "Ich kann dir keine Direktnachricht senden. Bitte stelle sicher dass du mich nicht blockiert hast und Direktnachrichten auf diesem Server an hast." + }, + "UploadInProgress": { + "en": "An upload interaction is already taking place. Please finish it beforehand.", + "de": "Eine Upload Interaktion läuft bereits. Bitte beende diese bevor du eine weitere startest." + }, + "NoRoles": { + "en": "Could not find any roles in your server.", + "de": "Es konnten keine Rollen in deinem Server gefunden werden." + }, + "NoChannels": { + "en": "Could not find any fitting channels in your server.", + "de": "Es konnten keine passenden Kanäle in deinem Server gefunden werden." + }, + "CommandDisabled": { + "en": "The command '{Command}' is currently disabled.", + "de": "Der Befehl '{Command}' ist aktuell deaktiviert." + }, + "UnhandledException": { + "en": [ + "An unhandled exception occured while trying to execute your command.", + "The exception has been reported and we will be working on a resolution soon.", + "{Message}", + "_The message will be deleted {Timestamp}._" + ], + "de": [ + "Es ist eine unerwarteter Fehler aufgetreten.", + "Der Fehler wurde gemeldet und wir werden an einer Lösung arbeiten.", + "{Message}", + "_Diese Nachricht wird {Timestamp} gelöscht._" + ] + } + }, + "Prompts": { + "ConfirmSelection": { + "en": "Confirm Selection", + "de": "Auswahl bestätigen" + }, + "Disable": { + "en": "Disable", + "de": "Deaktivieren" + }, + "CreateRoleForMe": { + "en": "Create one for me", + "de": "Erstelle Rolle für mich" + }, + "SelectARole": { + "en": "Select a role..", + "de": "Eine Rolle auswählen.." + }, + "SelectEveryone": { + "en": "Select @everyone", + "de": "@everyone auswählen" + }, + "SelectedRoleUnavailable": { + "en": "The selected role is managed or unavailable to you. Please select another role.", + "de": "Die gewählte Rolle wird automatisch verwaltet oder ist nicht verfügbar für dich. Bitte wähle eine andere Rolle." + }, + "CreateChannelForMe": { + "en": "Create a new channel", + "de": "Erstelle ein neuen Kanal" + }, + "SelectAChannel": { + "en": "Select a channel..", + "de": "Einen Kanal auswählen.." + }, + "SelectAnOption": { + "en": "Select an option..", + "de": "Eine Option auswählen.." + }, + "ReOpenModal": { + "en": "Re-Open Modal", + "de": "Modal erneut öffnen.." + }, + "WaitingForModalResponse": { + "en": "Waiting for a modal response..", + "de": "Warte auf Modalantwort.." + }, + "SelectATimeSpan": { + "en": "Select a time span", + "de": "Wähle eine Zeitspanne" + }, + "TimespanSeconds": { + "en": "Seconds (max. {Max})", + "de": "Sekunden (max. {Max})" + }, + "TimespanMinutes": { + "en": "Minutes (max. {Max})", + "de": "Minuten (max. {Max})" + }, + "TimespanHours": { + "en": "Hours (max. {Max})", + "de": "Stunden (max. {Max})" + }, + "TimespanDays": { + "en": "Days (max. {Max})", + "de": "Tage (max. {Max})" + }, + "CurrentTimespan": { + "en": "Currently selected Timespan", + "de": "Aktuell-gewählte Zeitspanne" + }, + "ManuallyDefineTimespan": { + "en": "Manually define Timespan", + "de": "Zeitspanne manuell auswählen" + }, + "CurrentDateTime": { + "en": "Currently selected Date & Time", + "de": "Aktuell-gewählter Zeitpunkt" + }, + "SelectADateTime": { + "en": "Select a date and time", + "de": "Ein Datum auswählen" + }, + "ManuallyDefineDateTime": { + "en": "Manually define Date & Time", + "de": "Zeitpunkt manuell auswählen" + }, + "SelectTimezonePrompt": { + "en": "Please select your timezone.", + "de": "Bitte wähle deine Zeitzone aus." + }, + "SelectTimezone": { + "en": "Change timezone", + "de": "Zeitzone ändern" + }, + "DateTimeMinute": { + "en": "Minute", + "de": "Minute" + }, + "DateTimeHour": { + "en": "Hour", + "de": "Stunde" + }, + "DateTimeDay": { + "en": "Day", + "de": "Tag" + }, + "DateTimeMonth": { + "en": "Month", + "de": "Monat" + }, + "DateTimeYear": { + "en": "Year", + "de": "Jahr" + } + }, + "Cooldown": { + "SlowDown": { + "en": "Please slow down. Your previous command is still queued.", + "de": "Bitte warte einen Moment. Dein vorheriger Befehl ist noch in der Warteschlange." + }, + "CancelCommand": { + "en": "Cancel Command", + "de": "Befehl abbrechen" + }, + "WaitingForCooldown": { + "en": "You're still on cooldown for this command. Your command will be executed {Timestamp}.", + "de": "Dein Befehl befindet sich in der Warteschlange. Dein Befehl wird {Timestamp} ausgeführt." + } + }, + "UsedByFooter": { + "en": "Command used by {User}", + "de": "Befehl verwendet von {User}" + }, + "InteractionTimeout": { + "en": "Interaction timed out", + "de": "Interaktionszeit ist abgelaufen" + }, + "InteractionFinished": { + "en": "Interaction finished", + "de": "Interaktion abgeschlossen" + }, + "DirectMessageRedirect": { + "en": "I've sent you a message via direct messages.", + "de": "Ich habe dir eine Nachricht via Direktnachrichten gesendet." + } + }, + "ModuleNames": { + "Utility": { + "en": "Utility", + "de": "Allgemein" + }, + "Social": { + "en": "Social", + "de": "Sozial" + }, + "Music": { + "en": "Music", + "de": "Musik" + }, + "Moderation": { + "en": "Moderation", + "de": "Moderation" + }, + "Configuration": { + "en": "Configuration", + "de": "Konfiguration" + }, + "Unknown": { + "en": "Unknown", + "de": "Unbekannt" + } + }, + "Utility": { + "Help": { + "Module": { + "en": "{Module} Commands", + "de": "{Module} Befehle" + }, + "Disclaimer": { + "en": "Arguments wrapped in `[]` are optional while arguments wrapped in `<>` are required.\n**Do not include the brackets when using commands, they're merely an indicator for requirement.**", + "de": "Argumente in einem `[]` sind optional, Argumente in einem `<>` sind benötigt.\n**Dies sind nur Indikatoren, füge sie nicht hinzu wenn du einen Befehl benutzt.**" + }, + "MissingCommand": { + "en": "No such command found.", + "de": "Dieser Befehl existiert nicht." + } + }, + "Language": { + "Disclaimer": { + "en": "You can choose to override the chosen language on Discord.", + "de": "Du kannst dich dazu entscheiden die gewählte Sprache auf Discord zu überschreiben." + }, + "Response": { + "en": "Current Locale", + "de": "Aktuelle Sprache" + }, + "DisableOverride": { + "en": "Disable the current override", + "de": "Aktuelles Override deaktivieren" + }, + "Selector": { + "en": "Select a new language..", + "de": "Eine neue Sprache wählen.. " + } + }, + "Avatar": { + "Avatar": { + "en": "Avatar of {User}", + "de": "Profilbild von {User}" + }, + "ShowServerProfile": { + "en": "Show Server Profile Picture", + "de": "Server Profilbild anzeigen" + }, + "ShowUserProfile": { + "en": "Show Profile Picture", + "de": "Profilbild anzeigen" + } + }, + "Banner": { + "Banner": { + "en": "Banner of {User}", + "de": "Banner von {User}" + }, + "NoBanner": { + "en": "This user has no banner.", + "de": "Dieser Nutzer hat kein Banner." + } + }, + "Credits": { + "Fetching": { + "en": "Fetching contributors..", + "de": "Lade Unterstützer.." + }, + "Credits": { + "en": [ + "{BotName} is mainly being developed and maintained by {Developer}.", + "", + "These people help me manage and/or test the bot: {DiscordStaffList}", + "", + "{BotName} had some help from the following people:", + "", + "{GitHubContList}", + "", + "{BotName} wouldn't be possible without {Library}, which was made by these people:", + "{LibraryContList} and {LibraryContCount} more.", + "", + "Special thanks go to {PhishingListRepos} who publicly provide a list of phishing urls." + ], + "de": [ + "{BotName} wird hauptsächlich von {Developer} entwickelt.", + "", + "Diese Leute helfen mir, den Bot zu verwalten und/oder zu testen: {DiscordStaffList}", + "", + "{BotName} hatte Hilfe von den folgenden Personen:", + "", + "{GitHubContList}", + "", + "{BotName} wäre nicht möglich ohne {Library}, was von diesen netten Leuten entwickelt wird:", + "{LibraryContList} und {LibraryContCount} weitere.", + "", + "Ein dickes Danke geht an die, die öffentlich eine Liste von schädlichen Domains hosten: {PhishingListRepos}" + ] + } + }, + "EmojiStealer": { + "Emoji": { + "en": "emojis", + "de": "Emojis" + }, + "Sticker": { + "en": "stickers", + "de": "Sticker" + }, + "DownloadingPre": { + "en": "Downloading emojis of this message..", + "de": "Lade Emojis dieser Nachricht herunter.." + }, + "NoEmojis": { + "en": "This message doesn't contain any emojis or stickers.", + "de": "Diese Nachricht enthält keine Emojis oder Sticker." + }, + "DownloadingEmojis": { + "en": "Downloading {Count} emojis of this message..", + "de": "Lade {Count} Emojis herunter.." + }, + "DownloadingStickers": { + "en": "Downloading {Count} stickers of this message..", + "de": "Lade {Count} Sticker herunter.." + }, + "NoSuccessfulDownload": { + "en": "This message doesn't contain any emojis or stickers.", + "de": "Diese Nachricht enthält keine Emojis oder Sticker." + }, + "ReceivePrompt": { + "en": "Select how you want to receive the downloaded {Type}.", + "de": "Wähle wie die {Type} gesendet werden sollen." + }, + "ToggleStickers": { + "en": "Include Stickers", + "de": "Sticker beinhalten" + }, + "AddEmojisToServer": { + "en": "Add Emoji(s) to Server", + "de": "Emoji(s) zum Server hinzufügen" + }, + "AddEmojisAndStickerToServer": { + "en": "Add Emoji(s) and Sticker(s) to Server", + "de": "Emoji(s) und Sticker zum Server hinzufügen" + }, + "DirectMessageZip": { + "en": "Direct Message as Zip File", + "de": "Direktnachricht, als Zip Datei" + }, + "DirectMessageSingle": { + "en": "Direct Message as Single Files", + "de": "Direktnachricht, als einzelne Dateien" + }, + "CurrentChatZip": { + "en": "This chat as Zip File", + "de": "Dieser Kanal, als Zip Datei" + }, + "AddEmojisToServerLoading": { + "en": "Adding {Min}/{Max} emojis to this server..", + "de": "Füge {Min}/{Max} Emojis zum Server hinzu.." + }, + "AddStickersToServerLoading": { + "en": "Adding {Min}/{Max} stickers to this server..", + "de": "Füge {Min}/{Max} Sticker zum Server hinzu.." + }, + "AddToServerLoadingNotice": { + "en": "_The bot most likely hit a rate limit. This can take up to an hour to continue._\n_If you want this to change, go scream at Discord. There's nothing i can do._", + "de": "_Der Bot hat wahrscheinlich ein Rate Limit überschritten._\n_Wenn du willst dass Emojis adden schneller geht, schrei Discord an. Es gibt nichts was ich tun kann._" + }, + "NoMoreRoom": { + "en": "Downloaded and added {Count} emojis to the server. There is no more room for additional emojis.", + "de": "{Count} Emojis heruntergeladen und zum Server hinzugefügt. Es gibt keinen Platz für weitere Emojis." + }, + "SuccessAdded": { + "en": "Downloaded and added {Count} emojis to the server.", + "de": "{Count} Emojis heruntergeladen und zum Server hinzugefügt." + }, + "SendingDm": { + "en": "Sending the {Type} in your DMs..", + "de": "Sende die {Type} in deine Direktnachrichten.." + }, + "SuccessDm": { + "en": "Heyho! Here's the {Type} you requested.", + "de": "Heyho! Hier sind die {Type} die du angefragt hast." + }, + "SuccessDmMain": { + "en": "Downloaded and sent {Count} {Type} to your DMs.", + "de": "{Count} {Type} heruntergeladen und per Direktnachricht an dich gesendet." + }, + "PreparingZip": { + "en": "Preparing your Zip File..", + "de": "Deine Zip Datei wird vorbereitet.." + }, + "SendingZipDm": { + "en": "Sending your Zip File via DM..", + "de": "Deine Zip Datei wird per Direktnachricht gesendet.." + }, + "SendingZipChat": { + "en": "Sending your Zip File in this chat..", + "de": "Deine Zip Datei wird hier gesendet.." + }, + "SuccessChat": { + "en": "Downloaded {Count} {Type}. Attached is a Zip File containing them.", + "de": "{Count} {Type} heruntergeladen und in diesem Chat gesendet." + } + }, + "GuildInfo": { + "Fetching": { + "en": "Fetching guild info..", + "de": "Lade Server Info.." + }, + "MemberTitle": { + "en": "Members", + "de": "Benutzer" + }, + "OnlineMembers": { + "en": "Online Members", + "de": "Online Benutzer" + }, + "MaxMembers": { + "en": "Max Members", + "de": "Maximale Anzahl an Benutzer" + }, + "GuildTitle": { + "en": "Guild Details", + "de": "Server Details" + }, + "Owner": { + "en": "Owner", + "de": "Besitzer" + }, + "Creation": { + "en": "Creation Date", + "de": "Erstellungsdatum" + }, + "Locale": { + "en": "Preferred Locale", + "de": "Bevorzugte Sprache" + }, + "Boosts": { + "en": "Boosts", + "de": "Boosts" + }, + "BoostsNone": { + "en": "None", + "de": "Level 0" + }, + "BoostsTierOne": { + "en": "Tier One", + "de": "Level 1" + }, + "BoostsTierTwo": { + "en": "Tier Two", + "de": "Level 2" + }, + "BoostsTierThree": { + "en": "Tier Three", + "de": "Level 3" + }, + "Widget": { + "en": "Widget", + "de": "Widget" + }, + "Community": { + "en": "Community", + "de": "Community" + }, + "Security": { + "en": "Security", + "de": "Sicherheit" + }, + "MultiFactor": { + "en": "2FA required for mods", + "de": "2-Faktor Authentifizierung benötigt für Mods" + }, + "Screening": { + "en": "Membership Screening", + "de": "Benutzer Screening" + }, + "WelcomeScreen": { + "en": "Welcome Screen", + "de": "Willkommensbildschirm" + }, + "Verification": { + "en": "Verification Level", + "de": "Verifikationslevel" + }, + "VerificationNone": { + "en": "None", + "de": "Aus" + }, + "VerificationLow": { + "en": "Low", + "de": "Niedrig" + }, + "VerificationMedium": { + "en": "Medium", + "de": "Mittel" + }, + "VerificationHigh": { + "en": "High", + "de": "Hoch" + }, + "VerificationHighest": { + "en": "Highest", + "de": "Am Höchsten" + }, + "ExplicitContent": { + "en": "Scan for explicit content of", + "de": "Suche nach explizitem Content von" + }, + "ExplicitContentNone": { + "en": "No members", + "de": "Niemanden" + }, + "ExplicitContentNoRoles": { + "en": "Members without roles", + "de": "Benutzer ohne Rollen" + }, + "ExplicitContentEveryone": { + "en": "All members", + "de": "Jedem" + }, + "Nsfw": { + "en": "NSFW Level", + "de": "NSFW Level" + }, + "NsfwNoRating": { + "en": "No Rating", + "de": "Keine Bewertung" + }, + "NsfwExplicit": { + "en": "Explicit, Only suitable for mature audiences", + "de": "Explizit, Nur geeignet für Erwachsene" + }, + "NsfwSafe": { + "en": "Safe, Suitable for all age groups", + "de": "Sicher, Geeignet für alle Altersgruppen" + }, + "NsfwQuestionable": { + "en": "Questionable, May only be suitable for mature audiences", + "de": "Fragwürdig, Möglicherweise nur geeignet für Erwachsene" + }, + "DefaultNotifications": { + "en": "Default Notifications", + "de": "Standard Nachrichten" + }, + "DefaultNotificationsAll": { + "en": "All messages", + "de": "Alle Nachrichten" + }, + "DefaultNotificationsMentions": { + "en": "Mentions only", + "de": "Nur Makierungen" + }, + "SpecialChannels": { + "en": "Special Channels", + "de": "Spezialkanäle" + }, + "Rules": { + "en": "Rules", + "de": "Regeln" + }, + "CommunityUpdates": { + "en": "Community Updates", + "de": "Community Updates" + }, + "InactiveChannel": { + "en": "Inactive Channel", + "de": "Inaktivitätskanal" + }, + "InactiveTimeout": { + "en": "Inactive Timeout", + "de": "Inaktivitätstimeout" + }, + "SystemMessages": { + "en": "System Messages", + "de": "System Nachrichten" + }, + "SystemMessagesWelcome": { + "en": "Welcome Messages", + "de": "Willkommensnachrichten" + }, + "SystemMessagesWelcomeStickers": { + "en": "Welcome Sticker Replies", + "de": "Stickerantworten für Willkommensnachrichten" + }, + "SystemMessagesBoost": { + "en": "Boost Messages", + "de": "Boost Nachrichten" + }, + "SystemMessagesRole": { + "en": "Role Purchase Message", + "de": "Rollenkaufnachrichten" + }, + "SystemMessagesRoleSticker": { + "en": "Role Purchase Sticker Replies", + "de": "Stickerantworten für Rollenkaufnachrichten" + }, + "SystemMessagesSetupTips": { + "en": "Server Setup Tips", + "de": "Servereinrichtungstipps" + }, + "GuildFeatures": { + "en": "Guild Features", + "de": "Server Features" + }, + "JoinServer": { + "en": "Join Guild", + "de": "Server beitreten" + }, + "GuildPreviewNotice": { + "en": "Info fetched via Discord Guild Preview", + "de": "Info abgerufen durch Discord Server Vorschau" + }, + "GuildWidgetNotice": { + "en": "Info fetched via Discord Guild Widget", + "de": "Info abgerufen durch Discord Server Widget" + }, + "Mee6Notice": { + "en": "Info fetched via Mee6 Leaderboard", + "de": "Info abgerufen durch Mee6 Rangliste" + }, + "NoGuildFound": { + "en": "Could not fetch any information about the server you specified.", + "de": "Es konnten keine Informationen über den angegebenen Server gefunden werden." + }, + "Banner": { + "en": "Banner", + "de": "Banner" + }, + "Splash": { + "en": "Splash", + "de": "Titelbild" + }, + "DiscoverySplash": { + "en": "Discovery Splash", + "de": "Entdeckungs-Splash" + }, + "HomeHeader": { + "en": "Home Header", + "de": "Startseiten-Header" + } + }, + "Leaderboard": { + "Title": { + "en": "Experience Leaderboard", + "de": "Erfahrungsrangliste" + }, + "Disabled": { + "en": "Experience is disabled on this server. Please run '{Command}' to configure the experience system.", + "de": "Erfahrung ist deaktiviert auf diesem Server. Bitte führe '{Command}' aus um das Erfahrungssystem zu konfigurieren." + }, + "Fetching": { + "en": "Loading Leaderboard, please wait..", + "de": "Lade Rangliste, bitte warten.." + }, + "Level": { + "en": "Level {Level} with {Points} XP", + "de": "Level {Level} mit {Points} XP" + }, + "Placement": { + "en": "You're currently on the **{Placement}.** spot on the leaderboard.", + "de": "Du bist aktuell auf **{Placement}.** Platz in der Rangliste." + }, + "NoPoints": { + "en": "No one on this server has collected enough experience to show up on the leaderboard, get to typing!", + "de": "Niemand hat genug Erfahrung gesammelt. Fangt an zu schreiben! ^^" + } + }, + "Rank": { + "Title": { + "en": "Experience", + "de": "Erfahrung" + }, + "Self": { + "en": "You're currently **Level {Level} with {Points} XP**", + "de": "Du bist aktuell **Level {Level} mit {Points} XP**" + }, + "Other": { + "en": "{User} is currently **Level {Level} with {Points} XP**", + "de": "{User} ist aktuell **Level {Level} mit {Points} XP**" + }, + "Progress": { + "en": "Level {Level} Progress", + "de": "Level {Level} Fortschritt" + } + }, + "Reminders": { + "Title": { + "en": "Reminders", + "de": "Erinnerungen" + }, + "NewReminder": { + "en": "New Reminder", + "de": "Neue Erinnerung" + }, + "Snooze": { + "en": "Snooze", + "de": "Schlummern" + }, + "DeleteReminder": { + "en": "Delete a Reminder", + "de": "Eine Erinnerung löschen" + }, + "Count": { + "en": "You have {Count} reminders.", + "de": "Du hast {Count} Erinnerungen." + }, + "CreatedOn": { + "en": "Created on {Guild}", + "de": "Erstellt auf {Guild}" + }, + "CreatedAt": { + "en": "Created at {Timestamp}", + "de": "Erstellt um {Timestamp}" + }, + "DueTime": { + "en": "Due {Relative} ({DateTime})", + "de": "Fällig {Relative} ({DateTime})" + }, + "Notice": { + "en": "For reminders to work, you need to enable Direct Messages on at least one server you share with {Bot}", + "de": "Um dass Erinnerungen funktionieren musst du auf einem Server mit {Bot} teilst, Direktnachrichten aktivieren." + }, + "InvalidDateTime": { + "en": "You specified a date in the past or a date further away than 6 months.", + "de": "Du hast eine Zeit in der Vergangenheit oder länger als 6 Monate in der Zukunft liegt gewählt." + }, + "SetDescription": { + "en": "Set Description", + "de": "Beschreibung auswählen" + }, + "SetDateTime": { + "en": "Set Due Date & Time", + "de": "Erinnerungszeit festlegen" + }, + "Description": { + "en": "Description", + "de": "Beschreibung" + }, + "DateTime": { + "en": "Due Date & Time", + "de": "Erinnerungszeit" + }, + "SentLate": { + "en": "This reminder has been sent late because of a recent bot outage.", + "de": "Diese Erinnerung konnte nicht pünktlich gesendet werden auf Grund von einem Ausfall." + }, + "ReminderNotification": { + "en": "Reminder Notification", + "de": "Erinnerungsnachricht" + } + }, + "ReportHost": { + "Title": { + "en": "Malicious Host Submissions", + "de": "Böswillige Host Meldungen" + }, + "AcceptTos": { + "en": "I accept these conditions", + "de": "Ich akzeptiere diese Regeln" + }, + "Tos": { + "en": [ + "{1}. You may not submit Hosts that are non-malicious.", + "{2}. You may not spam submissions.", + "{3}. You may not submit unregistered hosts.", + "{4}. You accept that your user account and current server will be tracked and visible to Makoto staff.", + "", + "We reserve the right to ban you for any reason that may not be listed.", + "**Failing to follow these conditions may get you or your guild blacklisted from using this bot.**", + "**This includes, but is not limited to, pre-existing guilds with your ownership and future guilds.**" + ], + "de": [ + "{1}. Du darfst keine unschädlichen Hosts melden.", + "{2}. Du darfst Hosts nicht wiederholt melden.", + "{3}. Hosts müssen registriert sein.", + "{4}. Du akzeptierst dass dein Benutzer Account und aktueller Server der Einsendung beigelegt sind.", + "", + "Wir behalten außerdem das Recht dich auch für jeden anderen Grund vom System auszuschließen.", + "**Das Brechen oder Umgehen diesen Regeln wird dazu führen dass du oder dieser Server von der Verwendung des Bots ausgeschlossen wirst.**", + "**Das beinhaltet, ist aber nicht limiert auf, bereits existierende Server die du besitzt und Server die du zukünftig erstellst.**" + ] + }, + "TosChangedNotice": { + "en": "The submission conditions have changed since you last accepted them. Please re-read them and agree to the new condiditions to continue.", + "de": "Die Einsendungsbedingungen haben sich geändert. Bitte lese sie erneut und stimme ihnen zu um fortzufahren." + }, + "Processing": { + "en": "Processing your request..", + "de": "Verarbeite deine Anfrage.." + }, + "CooldownError": { + "en": "You're on cooldown. You submit another host {Timestamp}.", + "de": "Du hast erst einen Host gemeldet. Du kannst einen weiteren Host {Timestamp} melden." + }, + "LimitError": { + "en": "You have 5 open host submissions. Please wait before trying to submit another host.", + "de": "Du hast 5 offene Host Meldungen. Bitte warte bis du weitere Hosts meldest." + }, + "InvalidHost": { + "en": "The host '{Host}' is invalid.", + "de": "Der Host '{Host}' ist ungültig." + }, + "ConfirmHost": { + "en": "You are about to submit the host '{Host}'. Do you want to proceed?", + "de": "Du bist dabei den Host '{Host}' zu melden. Möchtest du fortfahren?" + }, + "DatabaseCheck": { + "en": "Checking if your host is already in the datbase..", + "de": "Überprüfe ob sich der Host bereits in der Datenbank befindet.." + }, + "DatabaseError": { + "en": "The host '{Host}' is already present in the database. Thanks for trying to contribute regardless.", + "de": "Der Host '{Host}' befindet sich bereits in der Datenbank. Wir danken allerdings trotzdem für den Versuch uns zu unterstützen." + }, + "SubmissionCheck": { + "en": "Checking if your host has already been submitted..", + "de": "Überprüfe ob der Host bereits gemeldet wurde.." + }, + "SubmissionError": { + "en": "The host '{Host}' has already been submitted. Thanks for trying to contribute regardless.", + "de": "Der Host '{Host}' wurde bereits gemeldet. Wir danken allerdings trotzdem für den Versuch uns zu unterstützen." + }, + "CreatingSubmission": { + "en": "Creating submission..", + "de": "Erstelle Meldung.." + }, + "SubmissionCreated": { + "en": "Submission created. Thank you for your contribution.", + "de": "Host gemeldet. Vielen Dank für deine Unterstützung." + } + }, + "ReportTranslation": { + "Title": { + "en": "Translation Error Reports", + "de": "Übersetzungsfehler Meldungen" + }, + "AcceptTos": { + "en": "I accept these conditions", + "de": "Ich akzeptiere diese Regeln" + }, + "Tos": { + "en": [ + "{1}. You may not report correct translations.", + "{2}. You may not spam reports.", + "{3}. You accept that your user account and current server will be tracked and visible to Makoto staff.", + "{4}. You accept that Makoto staff may contact you through direct messages or this guild for additional information.", + "", + "We reserve the right to ban you for any reason that may not be listed.", + "**Failing to follow these conditions may get you or your guild blacklisted from using this bot.**", + "**This includes, but is not limited to, pre-existing guilds with your ownership and future guilds.**" + ], + "de": [ + "{1}. Du darfst keine korrekten Text melden.", + "{2}. Du darfst Meldungen nicht wiederholt senden.", + "{3}. Du akzeptierst dass dein Benutzer Account und aktueller Server der Einsendung beigelegt sind.", + "{4}. Du akzeptierst dass das Team von Makoto dich eventuell per Direktnachrichten oder diesen Server kontaktiert für weitere Informationen.", + "", + "Wir behalten außerdem das Recht dich auch für jeden anderen Grund vom System auszuschließen.", + "**Das Brechen oder Umgehen diesen Regeln wird dazu führen dass du oder dieser Server von der Verwendung des Bots ausgeschlossen wirst.**", + "**Das beinhaltet, ist aber nicht limiert auf, bereits existierende Server die du besitzt und Server die du zukünftig erstellst.**" + ] + }, + "TosChangedNotice": { + "en": "The report conditions have changed since you last accepted them. Please re-read them and agree to the new condiditions to continue.", + "de": "Die Meldungsbedingungen haben sich geändert. Bitte lese sie erneut und stimme ihnen zu um fortzufahren." + }, + "ConfirmationPrompt": { + "en": "Please remember, we may follow up on this report through direct messages or this server for more information. Should the report be submitted?", + "de": "Bitte denk daran dass wir eventuell mehr Information benötigen und dich auf Grund dessen über Direktnachrichten oder diesen Server kontaktieren. Soll die Meldung eingereicht werden?" + }, + "RatelimitReached": { + "en": "You've reached your maximum amount of reports per day. Please try again {Timestamp}.", + "de": "Du hast die maxmimale Anzahl an Meldungen pro Tag erreicht. Bitte versuche es erneut {Timestamp}." + }, + "ReportSubmitted": { + "en": "Your report has been submitted. We will check your report as soon as possible. Please remember, we may follow up on this report for more information.", + "de": "Deine Meldung wurde eingereicht. Wir werden die Meldung überprüfen sobald es uns möglich ist. Bitte denk daran dass wir eventuell mehr Information benötigen und dich auf Grund dessen kontaktieren." + } + }, + "Upload": { + "NoInteraction": { + "en": "You have not yet started an upload interactions. Please only run this command when instructed to by the bot.", + "de": "Du hast keine Upload Interaktion gestartet. Bitte führe diesen Befehl nur aus wenn du dazu aufgefordert wirst vom Bot." + }, + "AlreadyUploaded": { + "en": "You already uploaded your file.", + "de": "Du hast bereits eine Datei hochgeladen." + }, + "TimedOut": { + "en": "Your upload interaction timed out {Timestamp}.", + "de": "Deine Upload Interakation ist {Timestamp} abgelaufen." + }, + "Uploaded": { + "en": "Your file has been uploaded.", + "de": "Deine Datei wurde hochgeladen." + } + }, + "UrbanDictionary": { + "AdultContentError": { + "en": "Urban Dictionary can potentially contain Adult Content. Please run this command within a channel marked as Age-Restricted.", + "de": "Urban Dictionary kann möglicherweise Inhalte nur für Erwachsene enthalten. Bitte führe diesen Befehl innerhalb eines Kanals aus welcher als altersbegrenzt makiert ist." + }, + "AdultContentWarning": { + "en": "Urban Dictionary can potentially contain Adult Content. Continue?", + "de": "Urban Dictionary kann möglicherwise Inhalte nur für Erwachsene enthalten. Möchtest du fortfahren?" + }, + "LookingUp": { + "en": "Looking up '{Term}'..", + "de": "Suche nach '{Term}'.." + }, + "LookupFail": { + "en": "Failed to look up '{Term}'.", + "de": "Die Suche nach '{Term}' ist fehlgeschlagen." + }, + "NotExist": { + "en": "'{Term}' was not found in the Urban Dictionary.", + "de": "'{Term}' wurde nicht im Urban Dictionary gefunden." + }, + "WrittenBy": { + "en": "Written by {Author}", + "de": "Geschrieben von {Author}" + }, + "Definition": { + "en": "Definition", + "de": "Definition" + }, + "Example": { + "en": "Example", + "de": "Beispiel" + } + }, + "UserInfo": { + "System": { + "en": "System", + "de": "System" + }, + "Bot": { + "en": "Bot", + "de": "Bot" + }, + "NeverJoined": { + "en": "User never joined this server.", + "de": "Nutzer ist diesem Server noch nie beigetreten." + }, + "NoRoles": { + "en": "User doesn't have any roles.", + "de": "Nutzer hat keine Rollen." + }, + "NoStoredRoles": { + "en": "User doesn't have any stored roles.", + "de": "Nutzer hatte keine Rollen." + }, + "IsBanned": { + "en": "User is currently banned from this server.", + "de": "Nutzer ist aktuell von diesem Server gebannt." + }, + "JoinedBefore": { + "en": "User is currently not in this server.", + "de": "Nutzer ist aktuell nicht auf dem Server." + }, + "GlobalBanned": { + "en": "User is globally banned.", + "de": "Nutzer ist global gebannt." + }, + "BotOwner": { + "en": "{Bot} Owner", + "de": "Besitzer von {Bot}" + }, + "BotStaff": { + "en": "{Bot} Staff", + "de": "Team von {Bot}" + }, + "Owner": { + "en": "This user owns this guild", + "de": "Dieser Nutzer besitzt diesen Server." + }, + "DiscordStaff": { + "en": "Discord Staff", + "de": "Discord Mitarbeiter" + }, + "CertifiedMod": { + "en": "Certified Content Moderator", + "de": "Zertifizierter Content Moderator" + }, + "VerifiedBotDeveloper": { + "en": "Verified Bot Developer", + "de": "Verifizierter Bot Entwickler" + }, + "DiscordPartner": { + "en": "Discord Partner", + "de": "Discord Partner" + }, + "PendingMembership": { + "en": "User's Membership pending", + "de": "Mitgliedschaft noch nicht bestätigt" + }, + "Roles": { + "en": "Roles", + "de": "Rollen" + }, + "Backup": { + "en": "Backup", + "de": "Backup" + }, + "BotNotes": { + "en": "{Bot} Staff Notes", + "de": "{Bot} Team Notizen" + }, + "NoReason": { + "en": "No reason provided.", + "de": "Kein Grund angegeben" + }, + "GlobalBanReason": { + "en": "Global Ban Reason", + "de": "Globaler Banngrund" + }, + "GlobalBanMod": { + "en": "Global Ban Moderator", + "de": "Globaler Bannmoderator" + }, + "GlobalBanDate": { + "en": "Global Ban Date", + "de": "Globales Banndatum" + }, + "BanDetails": { + "en": "Ban Details", + "de": "Banndetails" + }, + "InvitedBy": { + "en": "Invited By", + "de": "Eingeladen von" + }, + "NoInviter": { + "en": "No Inviter found.", + "de": "Kein Einladungsdetails gefunden." + }, + "UsersInvited": { + "en": "Users invited", + "de": "Anzahl von eingeladenen Nutzern" + }, + "ShowProfileInviter": { + "en": "Show Profile of Inviter", + "de": "Profile vom Einladenen ansehen" + }, + "ServerJoinDate": { + "en": "Server Join Date", + "de": "Serverbeitrittsdatum" + }, + "ServerLeaveDate": { + "en": "Server Leave Date", + "de": "Serververlassungsdatum" + }, + "FirstJoinDate": { + "en": "First Join Date", + "de": "Datum des ersten Beitritts" + }, + "AccountCreationDate": { + "en": "Account Creation Date", + "de": "Accounterstellungsdatum" + }, + "ServerBoosterSince": { + "en": "Server Booster Since", + "de": "Serverbooster seit" + }, + "Pronouns": { + "en": "Pronouns", + "de": "Pronomen" + }, + "BannerColor": { + "en": "Banner Color", + "de": "Banner Farbe" + }, + "Presence": { + "en": "Current Presence", + "de": "Aktuelle Presenz" + }, + "Desktop": { + "en": "Desktop", + "de": "PC" + }, + "Mobile": { + "en": "Mobile", + "de": "Telefon" + }, + "Web": { + "en": "Web", + "de": "Internetbrowser" + }, + "Online": { + "en": "Online", + "de": "Online" + }, + "Offline": { + "en": "Offline", + "de": "Offline" + }, + "Idle": { + "en": "Idle", + "de": "Abwesend" + }, + "DoNotDisturb": { + "en": "Do Not Disturb", + "de": "Bitte nicht stören" + }, + "Activities": { + "en": "Current Activities", + "de": "Aktuelle Aktivitäten" + }, + "Playing": { + "en": "Playing", + "de": "Spielt" + }, + "Streaming": { + "en": "Streaming", + "de": "Streamt" + }, + "ListeningTo": { + "en": "Listening to", + "de": "Hört" + }, + "Watching": { + "en": "Watching", + "de": "Schaut" + }, + "Competing": { + "en": "Competing", + "de": "Konkurriert" + }, + "Status": { + "en": "Status", + "de": "Status" + }, + "TimedOutUntil": { + "en": "Timed out until", + "de": "Ausgetimed bis" + }, + "FetchUserError": { + "en": "Failed to fetch user '{User}'", + "de": "Konnte den Nutzer '{User}' nicht abfragen." + } + }, + "Data": { + "Request": { + "TimeError": { + "en": "Your last request was {RequestTimestamp}, please wait until {WaitTimestamp} to request your user data again.", + "de": "Ihre letzte Anfrage war {RequestTimestamp}, bitte warten Sie bis {WaitTimestamp} um Ihre Daten erneut anzufragen." + }, + "Fetching": { + "en": "Fetching all your user data. This might take a moment..", + "de": "Frage alle Ihre gespeicherten Nutzerdaten ab. Dies braucht eventuell einen Moment.." + }, + "Confirm": { + "en": "Hello, {User}. Here's your user data you requested. This may contain sensitive information.", + "de": "Hallo, {User}. Hier sind Ihre angefragten Nutzerdaten. Dies enthält eventuell sensible Informationen." + }, + "DmNotice": { + "en": "I sent your data via direct messages.", + "de": "Ich habe Ihnen ihre Nutzerdaten per Direktnachricht gesendet." + } + }, + "Policy": { + "NoPolicy": { + "en": "The privacy policy was not loaded. This is most likely unintended, please report to {Bot} Staff.", + "de": "Die Privacy Policy wurde nicht geladen. Dies ist wahrscheinlich ungewohlt, bitte melden Sie dies an das {Bot} Team." + } + }, + "Object": { + "ProfileAlreadyDeleted": { + "en": "You already objected to your data being processed and saved. Do you want to reverse that decision?", + "de": "Sie haben bereits das Verarbeiten ihrer Daten verweigert. Möchten Sie diese Entscheidung rückgängig machen?" + }, + "EnablingDataProcessing": { + "en": "Okay, removing you from the objection list..", + "de": "Sie werden von der Verweigerungsliste entfernt.." + }, + "EnablingDataProcessingError": { + "en": "I'm sorry but something went wrong while remove you from the objection list. This exception has been logged and will be fixed asap. Please retry in a few hours.", + "de": "Es ist ein Fehler aufgetreten bei dem Versuch Sie von der Verweigerungsliste zu entfernen. Der Fehler wurde gemeldet, bitte versuchen Sie es in wenigen Stunden erneut." + }, + "EnablingDataProcessingSuccess": { + "en": "You've been removed from our objection list. You're now able to run commands again.", + "de": "Sie wurden von der Verweigerungsliste entfernt. Sie haben erneut Zugriff auf alle Befehle." + }, + "DeletionAlreadyScheduled": { + "en": "You objected to your data being processed {RequestTimestamp}, your data will be deleted {ScheduleTimestamp}. Do you want to cancel your account's deletion?", + "de": "Sie haben das Verarbeiten ihrer Daten {RequestTimestamp} verweigert und ihre Daten werden {ScheduledTimestamp} gelöscht. Möchten Sie den Vorgang abbrechen?" + }, + "DeletionScheduleReversed": { + "en": "Your user data is no longer scheduled for deletion.", + "de": "Ihre Nutzerdaten werden nicht mehr gelöscht." + }, + "ObjectionDisclaimer": { + "en": [ + "This action will delete all data related to your user account and object to further creation of an user account.", + "", + "This will prevent you from using any commands of the bot.", + "This will NOT delete data stored for guilds (see GuildData via '/data request').", + "**Additionally, this action will make the bot leave every server you own.", + "", + "**Are you sure you want to continue?" + ], + "de": [ + "Dies wird alle Nutzerdaten von Ihnen löschen und weiteres Verarbeiten verbieten.", + "", + "Dies wird dazu führen, dass Sie keine Befehle mehr von diesem Bot verwenden können.", + "Dies wird NICHT server-spezifische Daten löschen. (um diese einzusehen, verwenden sie '/data request').", + "", + "Sind Sie sich sicher Sie möchten die Verarbeitung ihrer Daten verbieten?" + ] + }, + "SecondaryConfirm": { + "en": "Please confirm again, are you sure?", + "de": "Bitte bestätigen Sie erneut, sind Sie sich sicher?" + }, + "ProfileDeletionScheduled": { + "en": "Okay, your data was scheduled for deletion in 14 days.", + "de": "Ihre Nutzerdaten werden in 14 Tagen gelöscht." + } + } + }, + "VoiceChannelCreator": { + "NotAVccChannel": { + "en": "You're not in a channel created by the Voice Channel Creator.", + "de": "Du bist nicht in einem Kanal der vom Voice Channel Creator erstellt wurde." + }, + "NotAVccChannelOwner": { + "en": "You don't own this channel.", + "de": "Du besitzt diesen Kanal nicht." + }, + "VictimNotPresent": { + "en": "{User} is not in your Voice Channel.", + "de": "{User} ist nicht in deinem Kanal." + }, + "VictimIsBot": { + "en": "{User} is a bot.", + "de": "{User} ist ein Bot." + }, + "Events": { + "DefaultChannelName": { + "en": "{User}'s Channel", + "de": "{User}s Kanal" + } + }, + "Ban": { + "CannotBanSelf": { + "en": "You cannot ban yourself.", + "de": "Du kannst dich nicht selbst bannen." + }, + "VictimAlreadyBanned": { + "en": "{User} has already been banned from your Voice Channel.", + "de": "{User} ist bereits von deinem Kanal gebannt." + }, + "VictimBanned": { + "en": "{User} has been banned from this channel.", + "de": "{User} wurde von deinem Kanal gebannt." + } + }, + "ChangeOwner": { + "AlreadyOwner": { + "en": "{User} is already the owner.", + "de": "{User} ist bereits der Besitzer." + }, + "ForceAssign": { + "en": "{User} was forcefully assigned as the new owner of this channel.", + "de": "{User} wurde als Besitzer erwzungen." + }, + "Success": { + "en": "{User} now owns this channel.", + "de": "{User} ist der neue Besitzer dieses Kanals." + } + }, + "Close": { + "Success": { + "en": "The channel has been closed.", + "de": "Der Kanal wurde geschlossen." + } + }, + "Invite": { + "CannotInviteSelf": { + "en": "You cannot invite yourself.", + "de": "Du kannst dich nicht selbst einladen." + }, + "AlreadyPresent": { + "en": "{User} is already in your Voice Channel.", + "de": "{User} ist bereits in deinem Kanal." + }, + "PartialSuccess": { + "en": "{User} can now join this channel. However, i was unable to send a Direct Message to them.", + "de": "{User} kann jetzt diesen Kanel beitreten, ich konnte allerdings keine Direktnachricht an {User} versenden." + }, + "Success": { + "en": "{User} has been invited to this channel.", + "de": "{User} wurde zu diesem Kanal angeladen." + }, + "VictimMessage": { + "en": "{User} has invited you to join {Channel}.", + "de": "{User} hat dich dazu eingeladen {Channel} zu joinen." + } + }, + "Kick": { + "CannotKickSelf": { + "en": "You cannot kick yourself.", + "de": "Du kannst dich nicht selbst kicken." + }, + "Success": { + "en": "{User} has been kicked from this channel.", + "de": "{User} wurde vom Kanal gekickt." + } + }, + "Limit": { + "OutsideRange": { + "en": "Input outside of range.", + "de": "Eingabe außerhalb des Limits." + }, + "Success": { + "en": "The channel's user limit has been changed to {Count}.", + "de": "Das Kanallimit wurde auf {Count} geändert." + } + }, + "Name": { + "Cooldown": { + "en": "You're on cooldown for renaming this channel. You can rename it again {Timestamp}.", + "de": "Du musst noch warten bis du diesen Kanal erneut umbennen kannst. Versuche es erneut {Timestamp}." + }, + "Success": { + "en": "The channel has been renamed to {Name}.", + "de": "Der Kanal wurde umbenannt zu {Name}." + } + }, + "Open": { + "Success": { + "en": "The channel has been opened.", + "de": "Der Kanal wurde geöffnet." + } + }, + "Unban": { + "VictimNotBanned": { + "en": "{User} is not banned from your Voice Channel.", + "de": "{User} ist nicht von deinem Kanal gebannt." + }, + "VictimUnbanned": { + "en": "{User} has been unbanned from this channel.", + "de": "{User} wurde von deinem Kanal entbannt." + } + } + } + }, + "Moderation": { + "NoReason": { + "en": "No reason provided.", + "de": "Kein Grund angegeben." + }, + "Ban": { + "AuditLog": { + "en": "{FullUser} banned user: {Reason}", + "de": "{FullUser} hat Nutzer gebannt: {Reason}" + }, + "Banning": { + "en": "Banning {Victim}..", + "de": "Banne {Victim}.." + }, + "Banned": { + "en": "{Victim} was banned for '{Reason}' by {User}.", + "de": "{Victim} wurde von {User} für '{Reason}' gebannt." + }, + "Errored": { + "en": "{Victim} could not be banned.", + "de": "{Victim} konnte nicht gebannt werden." + } + }, + "ClearBackup": { + "IsOnServer": { + "en": "{Victim} is on the server and therefor their stored nickname and roles cannot be cleared.", + "de": "{Victim} ist auf dem Server und daher können deren Nickname und Rollen nicht gelöscht werden." + }, + "Deleted": { + "en": "Deleted stored nickname and roles for {Victim}.", + "de": "Gespeicherter Nickname und Rollen für {Victim} gelöscht." + } + }, + "CustomEmbed": { + "UploadNotice": { + "en": "Please note: Uploads are being moderated. If your upload is determined to be inappropriate or otherwise harming it will be removed and you'll lose access to the entirety of Makoto. This includes the bot being removed from guilds you own or manage. Please keep it safe.", + "de": "Bitte beachte: Hochgeladene Dateien werden moderiert. Falls deine hochgeladene Datei als unangemessen oder andernfalls schädlich erkannt wurde, wird er entfernt und du verliest Zugriff auf Makoto. Dies beinhaltet dass der Bot von Servern entfernt wird welche die besitzt oder verwaltest." + }, + "New": { + "en": "New Embed", + "de": "Neues Embed" + }, + "SetTitleButton": { + "en": "Set Title", + "de": "Title setzen" + }, + "SetAuthorButton": { + "en": "Set Author", + "de": "Autor" + }, + "SetThumbnailButton": { + "en": "Set Thumbnail", + "de": "Thumbnail setzen" + }, + "SetDescriptionButton": { + "en": "Set Description", + "de": "Beschreibung setzen" + }, + "SetImageButton": { + "en": "Set Image", + "de": "Bild setzen" + }, + "SetColorButton": { + "en": "Set Color", + "de": "Farbe setzen" + }, + "SetTimestampButton": { + "en": "Set Timestamp", + "de": "Zeitstempel setzen" + }, + "SetFooterButton": { + "en": "Set Footer", + "de": "Embedfuß setzen" + }, + "AddFieldButton": { + "en": "Add Field", + "de": "Feld hinzufügen" + }, + "ModifyFieldButton": { + "en": "Modify Field", + "de": "Feld bearbeiten" + }, + "RemoveFieldButton": { + "en": "Remove Field", + "de": "Feld entfernen" + }, + "SendEmbedButton": { + "en": "Send Embed", + "de": "Embed senden" + }, + "ContinueTimer": { + "en": "Continuing {Timestamp}..", + "de": "Fahre {Timestamp} fort.." + }, + "UploadImage": { + "en": "Please upload an image via '{Command}'.", + "de": "Bitte lade ein Bild mit dem Befehl '{Command}' hoch." + }, + "ImportingUpload": { + "en": "Importing your upload..", + "de": "Importiere deine hochgeladene Datei.." + }, + "ImportSizeError": { + "en": "Please attach an image below {Size}.", + "de": "Bitte hänge ein Bild unter {Size} an." + }, + "ModifyingTitle": { + "en": "Modifying Title", + "de": "Titel modifizieren" + }, + "TitleField": { + "en": "Title", + "de": "Titel" + }, + "UrlField": { + "en": "Url", + "de": "Url" + }, + "SetNameButton": { + "en": "Set Name", + "de": "Name setzen" + }, + "SetUrlButton": { + "en": "Set Url", + "de": "Url setzen" + }, + "SetIconButton": { + "en": "Set Icon", + "de": "Icon setzen" + }, + "SetAsUserButton": { + "en": "Set as User", + "de": "Als einen Nutzer setzen" + }, + "SetAsServer": { + "en": "Set as Server", + "de": "Als aktuellen Server setzen" + }, + "ModifyingAuthorName": { + "en": "Modifying Author Name", + "de": "Autor Name modifizieren" + }, + "NameField": { + "en": "Name", + "de": "Name" + }, + "ModifyingAuthorUrl": { + "en": "Modifying Author Url", + "de": "Autor Url modifizieren" + }, + "ModifyingAuthorbyUserId": { + "en": "Replacing Author with User", + "de": "Ersetze Autor mit Nutzer" + }, + "UserIdField": { + "en": "User Id", + "de": "Nutzer Id" + }, + "ModifyingDescription": { + "en": "Modifying Description", + "de": "Beschreibung modifizieren" + }, + "DescriptionField": { + "en": "Description", + "de": "Beschreibung" + }, + "ModifyingColor": { + "en": "Modifying Color", + "de": "Farbe modifizieren" + }, + "ColorField": { + "en": "Color", + "de": "Farbe" + }, + "SetTextButton": { + "en": "Set Text", + "de": "Text setzen" + }, + "ModifyingFooterText": { + "en": "Modifying Footer Text", + "de": "Embedfuß- Text modifizieren" + }, + "TextField": { + "en": "Text", + "de": "Text" + }, + "ModifyingField": { + "en": "Modifying Field", + "de": "Feld modifizieren" + }, + "InlineField": { + "en": "Inline", + "de": "Inline" + }, + "NoValidChannels": { + "en": "Couldn't find any text or announcement channels in this server.", + "de": "Konnte keine Text oder News Kanal in dem Server finden." + } + }, + "FollowUpdates": { + "Followed": { + "en": "Successfully followed {Channel}.", + "de": "Erfolgreich {Channel} gefolgt." + }, + "Failed": { + "en": "Could not follow {Channel}.", + "de": "Konnte {Channel} nicht folgen." + } + }, + "GuildPurge": { + "Scanning": { + "en": "Scanning all channels for messages sent by {Victim}..", + "de": "Suche durch alle Kanäle für Nachrichten von {Victim}.." + }, + "Deleting": { + "en": "Found {Count} messages sent by {Victim}. Deleting..", + "de": "{Count} Nachrichten gefunden die von {Victim} versendet wurden. Lösche Nachrichten.." + }, + "Ended": { + "en": "{Min}/{Max} messages across {ChannelCount} channel(s) sent by {Victim} deleted.", + "de": "{Mix}/{Max} Nachrichten von {Victim} in {ChannelCount} Kanälen gelöscht." + } + }, + "Kick": { + "AuditLog": { + "en": "{FullUser} kick user: {Reason}", + "de": "{FullUser} hat Nutzer gekickt: {Reason}" + }, + "Kicking": { + "en": "Kicking {Victim}..", + "de": "Kicke {Victim}.." + }, + "Kicked": { + "en": "{Victim} was kicked for '{Reason}' by {User}.", + "de": "{Victim} wurde für '{Reason}' von {User} gekickt." + }, + "Errored": { + "en": "{Victim} could not be kicked.", + "de": "{Victim} konnte nicht gekickt werden." + } + }, + "ManualBump": { + "NotSetUp": { + "en": "The bump reminder is not set up.", + "de": "Der Bumpreminder ist nicht aktiv." + }, + "Warning": { + "en": "Manually overwriting the last bump time will re-schedule the bump reminder as if the server was just bumped. Are you sure you want to continue?", + "de": "Manuell den letzten Bump zu überschreiben wird das Bumpen vom Server imitieren und die letzte Bumpzeit auf jetzt setzten. Möchtest du fortfahren?" + } + }, + "Move": { + "NotAVc": { + "en": "The channel you selected is not a voice channel.", + "de": "Der Kanal den du gewählt hast ist kein Sprachkanal." + }, + "VcEmpty": { + "en": "The channel you selected is empty.", + "de": "Der Kanal den du gewählt hast ist leer." + }, + "Moving": { + "en": "Moving {Count} users from {Origin} to {Destination}..", + "de": "Verschiebe {Count} Benutzer von {Origin} zu {Destination}.." + }, + "Moved": { + "en": "Moved {Count} users from {Origin} to {Destination}..", + "de": "{Count} Benutzer von {Origin} zu {Destination} verschoben." + } + }, + "Purge": { + "Fetching": { + "en": "Fetching {Count} messages..", + "de": "Lade {Count} Nachrichten.." + }, + "Fetched": { + "en": "Fetched {Count} messages..", + "de": "{Count} Nachrichten geladen.." + }, + "NoMessages": { + "en": "No messages were found with the specified filter.", + "de": "Keine Nachrichten mit dem angegebenen Filter gefunden." + }, + "Deleted": { + "en": "Successfully deleted {Count} messages.", + "de": "Erfolgreich {Count} Nachrichten gelöscht." + }, + "Failed": { + "en": "Failed to delete {Count} messages because they were more than 14 days old.", + "de": "Konnte {Count} Nachrichten nicht löschen da sie älter als 14 Tage sind." + } + }, + "RemoveTimeout": { + "Removing": { + "en": "Removing timeout for {Victim}..", + "de": "Entferne Timeout für {Victim}.." + }, + "Removed": { + "en": "Removed timeout for {Victim}.", + "de": "Timeout von {Victim} entfernt." + }, + "Failed": { + "en": "Couldn't remove timeout for {Victim}.", + "de": "Konnte den Timeout von {Victim} nicht entfernen." + } + }, + "Softban": { + "AuditLog": { + "en": "{FullUser} soft-banned user: {Reason}", + "de": "{FullUser} hat Nutzer gesoftbannt: {Reason}" + }, + "Banning": { + "en": "Softbanning {Victim}..", + "de": "Soft-banne {Victim}.." + }, + "Banned": { + "en": "{Victim} was soft-banned for '{Reason}' by {User}.", + "de": "{Victim} wurde für '{Reason}' gesoft-bannt von {User}." + }, + "Errored": { + "en": "{Victim} could not be soft-banned.", + "de": "{Victim} konnte nicht gesoft-bannt werden." + } + }, + "Timeout": { + "AuditLog": { + "en": "{FullUser} timed user out: {Reason}", + "de": "{FullUser} hat Nutzer in Timeout gesetzt: {Reason}" + }, + "TimingOut": { + "en": "Timing {Victim} out..", + "de": "Time {Victim} aus.." + }, + "TimedOut": { + "en": "{Victim} was timed out for '{Reason}'. The timeout will run out {Timestamp}.", + "de": "{Victim} wurde für '{Reason}' in Timeout versetzt. Der Timeout wird {Timestamp} ablaufen." + }, + "Failed": { + "en": "Couldn't timeout {Victim}.", + "de": "Konnte {Victim} nicht in den Timeout versetzen." + }, + "Invalid": { + "en": "The Duration you specified is invalid.", + "de": "Die Zeitangabe ist ungeültig." + } + }, + "Unban": { + "Removing": { + "en": "Unbanning {Victim}..", + "de": "Entbanne {Victim}.." + }, + "Removed": { + "en": "{Victim} was unbanned.", + "de": "{Victim} wurde entbannt." + }, + "Failed": { + "en": "Couldn't unban {Victim}.", + "de": "Konnte {Victim} nicht entbannen." + } + } + }, + "Config": { + "ActionLog": { + "Title": { + "en": "Actionlog", + "de": "Aktionsprotokoll" + }, + "ActionlogDisabled": { + "en": "The actionlog is disabled.", + "de": "Das Aktionsprotokoll ist deaktiviert." + }, + "ActionLogChannel": { + "en": "Actionlog Channel", + "de": "Aktionsprotokoll Kanal" + }, + "AttemptGatheringMoreDetails": { + "en": "Attempt gathering more details", + "de": "Versuchen weitere Details zu sammeln" + }, + "UserStateUpdates": { + "en": "Joins, Leaves, Kicks", + "de": "Beitritte, Austritte, Rauswürfe" + }, + "UserRoleUpdates": { + "en": "Nickname, Role Updates", + "de": "Nicknames, Rollenänderungen" + }, + "UserProfileUpdates": { + "en": "User Profile Updates", + "de": "Nutzerprofiländerungen" + }, + "MessageDeletions": { + "en": "Message Deletions", + "de": "Nachrichtenlöschungen" + }, + "MessageModifications": { + "en": "Message Modifications", + "de": "Nachrichtenbearbeitung" + }, + "RoleUpdates": { + "en": "Role Updates", + "de": "Rollenänderungen" + }, + "BanUpdates": { + "en": "Banlist Updates", + "de": "Bannlistenänderungen" + }, + "ServerModifications": { + "en": "Server Modifications", + "de": "Serverbearbeitungen" + }, + "ChannelModifications": { + "en": "Channel Modifications", + "de": "Kanalbearbeitungen" + }, + "VoiceChannelUpdates": { + "en": "Voice Channel Updates", + "de": "Sprachkanaländerungen" + }, + "InviteModifications": { + "en": "Invite Modifications", + "de": "Einladungsänderungen" + }, + "DisableActionLogButton": { + "en": "Disable Actionlog", + "de": "Aktionsprotokoll deaktivieren" + }, + "SetChannelButton": { + "en": "Set Channel", + "de": "Kanal setzen" + }, + "ChangeChannelButton": { + "en": "Change Channel", + "de": "Kanal ändern" + }, + "ChangeFilterButton": { + "en": "Edit Filter", + "de": "Filter bearbeiten" + }, + "OptionInaccurate": { + "en": "This option may sometimes be inaccurate.", + "de": "Diese Option verursacht eventuell falsche Informationen." + }, + "NoOptions": { + "en": "No options selected.", + "de": "Keine Optionen ausgewählt." + } + }, + "AutoCrosspost": { + "Title": { + "en": "Auto Crosspost", + "de": "Automatisches Crossposting" + }, + "ExcludeBots": { + "en": "Exclude Bots", + "de": "Bots ausschließen" + }, + "DelayBeforePosting": { + "en": "Delay before crossposting", + "de": "Verzögerung vor dem Crossposting" + }, + "SetDelayButton": { + "en": "Set Delay", + "de": "Verzögerung festlegen" + }, + "ToggleExcludeBotsButton": { + "en": "Toggle Exclude Bots", + "de": "Bots ausschließen umschalten" + }, + "AddChannelButton": { + "en": "Add Channel", + "de": "Kanal hinzufügen" + }, + "RemoveChannelButton": { + "en": "Remove Channel", + "de": "Kanal entfernen" + }, + "DurationLimit": { + "en": "The duration has to be between 1 second and 5 minutes.", + "de": "Die Dauer muss zwischen 1 Sekunde und 5 Minuten liegen." + }, + "ChannelLimit": { + "en": "You cannot add more than 20 channels to crosspost. Need more? Ask for approval on our development server: {Invite}", + "de": "Du kannst nicht mehr als 20 Kanäle zum Crossposting hinzufügen. Brauchst du mehr? Frage um Genehmigung auf unserem Entwicklungsserver an: {Invite}" + }, + "NoCrosspostChannels": { + "en": "No Crosspost Channels are set up.", + "de": "Es sind keine Kanäle für das Crossposting eingerichtet." + } + }, + "AutoUnarchive": { + "Title": { + "en": "Auto Thread Unarchiver", + "de": "Automatischer Thread-Entarchivierer" + }, + "NoChannels": { + "en": "No channels defined.", + "de": "Keine Kanäle definiert." + }, + "Explanation": { + "en": "This module allows you to automatically unarchive threads of certain channels. **You will need to lock threads to actually archive them.**", + "de": "Mit diesem Modul kannst du automatisch Threads bestimmter Kanäle entarchivieren. **Du musst Threads sperren, um sie tatsächlich zu archivieren.**" + }, + "AddChannelButton": { + "en": "Add Channel", + "de": "Kanal hinzufügen" + }, + "RemoveChannelButton": { + "en": "Remove Channel", + "de": "Kanal entfernen" + } + }, + "BumpReminder": { + "Title": { + "en": "Bump Reminder", + "de": "Bump Erinnerungen" + }, + "BumpReminderEnabled": { + "en": "Bump Reminder Enabled", + "de": "Bump-Erinnerungen aktiviert" + }, + "BumpReminderChannel": { + "en": "Bump Reminder Channel", + "de": "Bump-Kanal" + }, + "BumpReminderRole": { + "en": "Bump Reminder Role", + "de": "Bump-Erinnerungsrolle" + }, + "SetupBumpReminderButton": { + "en": "Set up Bump Reminder", + "de": "Bump-Erinnerungen einrichten" + }, + "DisableBumpReminderButton": { + "en": "Disable Bump Reminder", + "de": "Bump-Erinnerungen deaktivieren" + }, + "ChangeChannelButton": { + "en": "Change Channel", + "de": "Kanal ändern" + }, + "ChangeRoleButton": { + "en": "Change Role", + "de": "Rolle ändern" + }, + "DisboardMissing": { + "en": "The Disboard bot is not on this server. Please create a guild listing on Disboard and invite their bot.", + "de": "Der Disboard-Bot ist nicht auf diesem Server. Bitte erstelle einen Eintrag auf Disboard und lade den Bot ein." + }, + "SettingUp": { + "en": "Setting up Bump Reminder in this channel..", + "de": "Bump-Erinnerungen werden diesem Kanal eingerichtet.." + }, + "SelectRole": { + "en": "Please select a role to ping when the server can be bumped.", + "de": "Bitte wähle eine Rolle aus, die benachrichtigt wird, wenn der Server wieder gebumpt werden kann." + }, + "CantUseRole": { + "en": "The role you selected is already being assigned on join or part of a level reward.", + "de": "Die ausgewählte Rolle wird bereits beim Betreten zugewiesen oder ist Teil einer Level-Belohnung." + }, + "ReactionRoleMessage": { + "en": "React to this message with {Emoji} to receive notifications as soon as the server can be bumped again.", + "de": "Reagiere auf diese Nachricht mit {Emoji}, um Benachrichtigungen zu erhalten, sobald der Server wieder gebumpt werden kann." + }, + "SetupComplete": { + "en": "The Bump Reminder has been set up.", + "de": "Die Bump-Erinnerungen wurden eingerichtet." + }, + "Disabled": { + "en": "The Bump Reminder has been disabled.", + "de": "Die Bump-Erinnerungen wurden deaktiviert." + } + }, + "EmbedMessages": { + "Title": { + "en": "Embed Messages", + "de": "Nachrichten einbetten" + }, + "EmbedMessageLinks": { + "en": "Embed Message Links", + "de": "Nachrichtenlink einbetten" + }, + "EmbedGithubCode": { + "en": "Embed Github Code", + "de": "Github-Code einbetten" + }, + "ToggleMessageLinkButton": { + "en": "Toggle Message Link Embeds", + "de": "Nachrichtenlink-Einbettungen umschalten" + }, + "ToggleGithubCodeButton": { + "en": "Toggle Github Code Embeds", + "de": "Github-Code-Einbettungen umschalten" + } + }, + "Experience": { + "Title": { + "en": "Experience", + "de": "Erfahrung" + }, + "ExperienceEnabled": { + "en": "Experience Enabled", + "de": "Erfahrung aktiviert" + }, + "ExperienceBoostForBumpers": { + "en": "Experience Boost for Bumpers", + "de": "Erfahrungsboost für Bumper" + }, + "ToggleExperienceButton": { + "en": "Toggle Experience System", + "de": "Erfahrungssystem umschalten" + }, + "ToggleExperienceBoostButton": { + "en": "Toggle Experience Boost for Bumpers", + "de": "Erfahrungsboost für Bumper umschalten" + } + }, + "InviteNotes": { + "Title": { + "en": "Invite Notes", + "de": "Einladungsnotizen" + }, + "NoNotesDefined": { + "en": "No Invite Notes defined.", + "de": "Keine Einladungsnotizen definiert." + }, + "AddNoteButton": { + "en": "Add Note", + "de": "Notiz hinzufügen" + }, + "RemoveNoteButton": { + "en": "Remove Note", + "de": "Notiz entfernen" + }, + "SetNoteButton": { + "en": "Set Note", + "de": "Notiz setzen" + }, + "SelectInviteButton": { + "en": "Select Invite", + "de": "Einladung auswählen" + }, + "CreateButton": { + "en": "Create Invite Note", + "de": "Einladungsnotiz erstellen" + }, + "Note": { + "en": "Note", + "de": "Notiz" + }, + "Invite": { + "en": "Invite", + "de": "Einladung" + }, + "InviteDescription": { + "en": "Uses: {Uses}; Creator: {Creator}", + "de": "Verwendungen: {Uses}; Ersteller: {Creator}" + } + }, + "InviteTracker": { + "Title": { + "en": "Invite Tracker", + "de": "Einladungsverfolgung" + }, + "InviteTrackerEnabled": { + "en": "Invite Tracker Enabled", + "de": "Einladungsverfolgung aktiviert" + }, + "ToggleInviteTrackerButton": { + "en": "Toggle Invite Tracker", + "de": "Einladungsverfolgung umschalten" + } + }, + "InVoicePrivacy": { + "Title": { + "en": "In-Voice Text Channel Privacy", + "de": "Privatsphäre im Sprach-Textkanal" + }, + "ClearMessagesOnLeave": { + "en": "Clear User's Messages on Leave", + "de": "Nachrichten des Benutzers beim Verlassen löschen" + }, + "SetPermissions": { + "en": "Set Permissions on User Join/Leave", + "de": "Berechtigungen beim Betreten/Verlassen des Benutzers festlegen" + }, + "ToggleMessageDeletionButton": { + "en": "Toggle Message Deletion", + "de": "Nachrichtenlöschung umschalten" + }, + "TogglePermissionProtectionButton": { + "en": "Toggle Permission Protection", + "de": "Berechtigungsschutz umschalten" + }, + "EnabledInVoicePrivacy": { + "en": "Enabled In-Voice Privacy", + "de": "Privatsphäre im Sprachkanal aktiviert" + }, + "DisabledInVoicePrivacy": { + "en": "Disabled In-Voice Privacy", + "de": "Privatsphäre im Sprachkanal deaktiviert" + } + }, + "Join": { + "Title": { + "en": "Join Settings", + "de": "Beitrittseinstellungen" + }, + "Autoban": { + "en": "Autoban Globally Banned Users", + "de": "Global-gebannte Benutzer bannen" + }, + "JoinLogChannel": { + "en": "Joinlog Channel", + "de": "Beitrittsprotokoll-Kanal" + }, + "Role": { + "en": "Role On Join", + "de": "Rolle beim Beitritt" + }, + "ReApplyRoles": { + "en": "Re-Apply Roles on Rejoin", + "de": "Rollen zuweisen beim Wiedereintritt" + }, + "ReApplyNickname": { + "en": "Re-Apply Nickname on Rejoin", + "de": "Nickname zuweisen beim Wiedereintritt" + }, + "SecurityNotice": { + "en": "For security reasons, roles with any of the following permissions never get re-applied: {Permissions}", + "de": "Aus Sicherheitsgründen werden Rollen mit einer den folgenden Berechtigungen nicht erneut zugewiesen: {Permissions}" + }, + "TimeNotice": { + "en": "In addition, if the user left the server 60+ days ago, neither roles nor nicknames will be re-applied.", + "de": "Darüber hinaus werden weder Rollen noch Spitznamen erneut zugewiesen, wenn der Benutzer vor mehr als 60 Tagen den Server verlassen hat." + }, + "ToggleGlobalBansButton": { + "en": "Toggle Global Bans", + "de": "Globale Bannungen umschalten" + }, + "ChangeJoinlogChannelButton": { + "en": "Change Joinlog Channel", + "de": "Beitrittsprotokoll-Kanal ändern" + }, + "ChangeRoleButton": { + "en": "Change Role assigned on join", + "de": "Beim Beitritt zugewiesene Rolle ändern" + }, + "ToggleReApplyRole": { + "en": "Toggle Role Re-Apply", + "de": "Rollen-Neuzuweisung umschalten" + }, + "ToggleReApplyNickname": { + "en": "Toggle Nickname Re-Apply", + "de": "Spitznamen-Neuzuweisung umschalten" + }, + "JoinLogChannelName": { + "en": "joinlog", + "de": "beitrittsprotokoll" + }, + "DisableJoinlog": { + "en": "Disable Joinlog", + "de": "Beitrittsprotokoll deaktivieren" + }, + "AutoAssignRoleName": { + "en": "Automatically Assigned Role", + "de": "Automatisch zugewiesene Rolle" + }, + "DisableRoleOnJoin": { + "en": "Disable Role on join", + "de": "Rolle beim Beitritt deaktivieren" + }, + "CantUseRole": { + "en": "You cannot set the bump reminder role to be automatically assigned on join.", + "de": "Du kannst die Erinnerungsrolle für das Bumpen nicht automatisch beim Beitritt zuweisen." + }, + "AutoKickSpammer": { + "en": "Auto Kick Likely Spammer", + "de": "Mögliche Spammer auto-kicken" + }, + "AutoKickNewAccounts": { + "en": "Kick Accounts younger than", + "de": "Accounts kicken die jünger sind als" + }, + "AutoKickNoRoles": { + "en": "Kick Members without roles after", + "de": "Member ohne Rolle kicken nach" + }, + "ToggleAutoKickSpammer": { + "en": "Toggle Auto Kick Likely Spammer", + "de": "Mögliche Spammer auto-kicken umschalten" + }, + "ChangeAutoKickNewAccounts": { + "en": "Change Auto Kick New Accounts", + "de": "Neue Accounts auto-kicken ändern" + }, + "ChangeAutoKickNoRoles": { + "en": "Change Auto Kick Members without Roles", + "de": "Neue Member ohne Rolle auto-kicken ändern" + }, + "AutoKickNoRolesDurationLimit": { + "en": "The time span has to be between 0 seconds and 30 minutes.", + "de": "Die Zeitspanne muss zwischen 0 Sekunden und 30 Minuten liegen." + }, + "AutoKickNewAccountsDurationLimit": { + "en": "The time span has to be between 0 seconds and 30 days.", + "de": "Die Zeitspanne muss zwischen 0 Sekunden und 30 Tagen liegen." + }, + "AutoKickSpammerReason": { + "en": "Likely Spammer", + "de": "Wahrscheinlicher Spammer" + }, + "AutoKickAccountAgeReason": { + "en": "Account too young", + "de": "Account zu jung" + }, + "AutoKickNoRolesReason": { + "en": "User did not receive any roles", + "de": "Nutzer hat keine Rollen erhalten" + }, + "LowTimeWarning": { + "en": "A time of {Time} may not be sufficient for the user to receive any roles. Please double check the selected time.", + "de": "{Time} sind eventuell nicht genug um eine Rolle zu erhalten, bitte überprüfe die gewählte Zeit." + } + }, + "LevelRewards": { + "Title": { + "en": "Level Rewards", + "de": "Level-Belohnungen" + }, + "Level": { + "en": "Level", + "de": "Level" + }, + "Role": { + "en": "Role", + "de": "Rolle" + }, + "Message": { + "en": "Message", + "de": "Nachricht" + }, + "NoRewardsSetup": { + "en": "No Level Rewards are set up.", + "de": "Es sind keine Level-Belohnungen eingerichtet." + }, + "Loading": { + "en": "Loading Level Rewards..", + "de": "Lade Level-Belohnungen.." + }, + "SelectPrompt": { + "en": "Please select a Level Reward to modify or delete.", + "de": "Bitte wähle eine Level-Belohnung aus, um sie zu bearbeiten oder zu löschen." + }, + "AddNewButton": { + "en": "Add new Level Reward", + "de": "Neue Level-Belohnung hinzufügen" + }, + "ModifyButton": { + "en": "Modify Message", + "de": "Nachricht bearbeiten" + }, + "RemoveButton": { + "en": "Delete", + "de": "Löschen" + }, + "SelectDropdown": { + "en": "Select a Level Reward..", + "de": "Wähle eine Level-Belohnung aus.." + }, + "DefaultCustomText": { + "en": "You received ##Role##!", + "de": "Du hast ##Role## erhalten!" + }, + "SelectRoleButton": { + "en": "Select Role", + "de": "Rolle auswählen" + }, + "SelectLevelButton": { + "en": "Select Level", + "de": "Level auswählen" + }, + "ChangeMessageButton": { + "en": "Change Message", + "de": "Nachricht ändern" + }, + "CantUseRole": { + "en": "You cannot set a bump reminder role to be automatically assigned as a reward.", + "de": "Du kannst die Erinnerungsrolle für das Bumpen nicht als automatische Belohnung festlegen." + }, + "MessageTooLong": { + "en": "Your custom message can't contain more than 256 characters.", + "de": "Deine individuelle Nachricht darf nicht mehr als 256 Zeichen enthalten." + }, + "AddedNewReward": { + "en": "The role {Role} will be assigned at Level {Level}.", + "de": "Die Rolle {Role} wird auf Level {Level} zugewiesen." + } + }, + "PrefixConfigCommand": { + "Title": { + "en": "Prefix Commands", + "de": "Text Befehle" + }, + "PrefixDisabled": { + "en": "Prefix Commands disabled", + "de": "Text Befehle deaktiviert" + }, + "CurrentPrefix": { + "en": "Current Command Prefix", + "de": "Aktuelles Text Befehl Prefix" + }, + "TogglePrefixCommands": { + "en": "Toggle Prefix Commands", + "de": "Text Befehle ein-/ausschalten" + }, + "ChangePrefix": { + "en": "Change Prefix", + "de": "Prefix ändern" + }, + "NewPrefixModalTitle": { + "en": "Select new Prefix", + "de": "Wähle ein neues Prefix" + }, + "NewPrefix": { + "en": "New Prefix", + "de": "Neues Prefix" + } + }, + "GuildLanguage": { + "Title": { + "en": "Guild Language", + "de": "Serversprache" + }, + "Disclaimer": { + "en": "You can choose to override the chosen language on this Discord server.", + "de": "Du kannst dich dazu entscheiden die gewählte Sprache auf diesem Discordserver zu überschreiben." + }, + "Response": { + "en": "Current Locale", + "de": "Aktuelle Sprache" + }, + "DisableOverride": { + "en": "Disable the current override", + "de": "Aktuelles Override deaktivieren" + }, + "Selector": { + "en": "Select a new language..", + "de": "Eine neue Sprache wählen.. " + } + }, + "NameNormalizer": { + "Title": { + "en": "Name Normalizer", + "de": "Namennormalisierer" + }, + "DefaultName": { + "en": "Pingable Name", + "de": "Pingbarer Name" + }, + "NameNormalizerEnabled": { + "en": "Name Normalizer Enabled", + "de": "Namennormalisierer aktiviert" + }, + "ToggleNameNormalizer": { + "en": "Toggle Name Normalizer", + "de": "Namennormalisierer umschalten" + }, + "NormalizeNow": { + "en": "Normalize Everyone's name now", + "de": "Namen aller Mitglieder jetzt normalisieren" + }, + "NormalizerRunning": { + "en": "A name normalizer is already running.", + "de": "Ein Namennormalisierer läuft bereits." + }, + "RenamingAllMembers": { + "en": "Renaming all members. This might take a while..", + "de": "Alle Mitglieder werden umbenannt. Dies könnte eine Weile dauern..." + }, + "RenamedMembers": { + "en": "Renamed {Count} members.", + "de": "{Count} Mitglieder wurden umbenannt." + } + }, + "Phishing": { + "Title": { + "en": "Phishing Protection", + "de": "Phishing-Schutz" + }, + "DetectPhishingLinks": { + "en": "Detect Phishing Links", + "de": "Phishing-Links erkennen" + }, + "RedirectWarning": { + "en": "Redirect Warning", + "de": "Weiterleitungs-Warnung" + }, + "AbuseIpDbReports": { + "en": "AbuseIPDB Reports", + "de": "AbuseIPDB-Berichte" + }, + "PunishmentType": { + "en": "Punishment Type", + "de": "Strafmaß-Art" + }, + "PunishmentTypeDelete": { + "en": "Delete Message", + "de": "Nachricht löschen" + }, + "PunishmentTypeTimeout": { + "en": "Timeout Member", + "de": "Benutzer in Timeout versetzen" + }, + "PunishmentTypeKick": { + "en": "Kick Member", + "de": "Benutzer kicken" + }, + "PunishmentTypeBan": { + "en": "Ban Member", + "de": "Benutzer bannen" + }, + "PunishmentTypeSoftban": { + "en": "Softban Member", + "de": "Benutzer softbannen" + }, + "PunishmentTypeDeleteDescription": { + "en": "Only deletes the message containing the detected phishing link", + "de": "Löscht nur die Nachricht mit dem erkannten Phishing-Link" + }, + "PunishmentTypeTimeoutDescription": { + "en": "Times the user out if a phishing link has been detected", + "de": "Timed den Benutzer aus, wenn ein Phishing-Link erkannt wurde" + }, + "PunishmentTypeKickDescription": { + "en": "Kicks the user if a phishing link has been detected", + "de": "Kickt den Benutzer, wenn ein Phishing-Link erkannt wurde" + }, + "PunishmentTypeBanDescription": { + "en": "Bans the user if a phishing link has been detected", + "de": "Bannt den Benutzer, wenn ein Phishing-Link erkannt wurde" + }, + "PunishmentTypeSoftbanDescription": { + "en": "Softbans the user if a phishing link has been detected", + "de": "Softbannt den Benutzer, wenn ein Phishing-Link erkannt wurde" + }, + "CustomPunishmentReason": { + "en": "Punishment Reason", + "de": "Grund für die Strafe" + }, + "CustomTimeoutLength": { + "en": "Timeout Length", + "de": "Timeout-Dauer" + }, + "ToggleDetection": { + "en": "Toggle Detection", + "de": "Erkennung umschalten" + }, + "ToggleWarning": { + "en": "Toggle Redirect Warning", + "de": "Weiterleitungs-Warnung umschalten" + }, + "ToggleAbuseIpDb": { + "en": "Toggle AbuseIPDB Reports", + "de": "AbuseIPDB-Berichte umschalten" + }, + "ChangePunishmentType": { + "en": "Change Punishment", + "de": "Strafe ändern" + }, + "ChangePunishmentReason": { + "en": "Change Reason", + "de": "Grund ändern" + }, + "ChangeTimeoutLength": { + "en": "Change Timeout Length", + "de": "Sperrdauer ändern" + }, + "DefineNewReason": { + "en": "New reason | Use %R to insert default reason", + "de": "Neuer Grund | Verwende %R, um den Standardgrund einzufügen" + }, + "NotUsingType": { + "en": "You aren't using '{Type}' as your Punishment", + "de": "Du verwendest '{Type}' nicht als Strafe" + }, + "InvalidDuration": { + "en": "The duration has to be between 10 seconds and 28 days.", + "de": "Die Dauer muss zwischen 10 Sekunden und 28 Tagen liegen." + } + }, + "ReactionRoles": { + "Title": { + "en": "Reaction Roles", + "de": "Reaktionsrollen" + }, + "LoadingReactionRoles": { + "en": "Loading Reaction Roles..", + "de": "Lade Reaktionsrollen.." + }, + "AddNewReactionRole": { + "en": "Add a new reaction role", + "de": "Neue Reaktionsrolle hinzufügen" + }, + "RemoveReactionRole": { + "en": "Remove a reaction role", + "de": "Reaktionsrolle entfernen" + }, + "ReactionRoleCount": { + "en": "{Count} reaction roles are set up.", + "de": "{Count} Reaktionsrollen sind eingerichtet." + }, + "SelectMessage": { + "en": "Select Message", + "de": "Nachricht auswählen" + }, + "SelectEmoji": { + "en": "Select Emoji", + "de": "Emoji auswählen" + }, + "SelectRole": { + "en": "Select Role", + "de": "Rolle auswählen" + }, + "Message": { + "en": "Message", + "de": "Nachricht" + }, + "Emoji": { + "en": "Emoji", + "de": "Emoji" + }, + "Role": { + "en": "Role", + "de": "Rolle" + }, + "MessageUrl": { + "en": "Message Url", + "de": "Nachrichten-URL" + }, + "MessageUrlInstructions": { + "en": "Please copy and paste the message link of the message you want the reaction role to be added to.", + "de": "Bitte kopiere und füge den Nachrichtenlink der Nachricht ein, zu der du die Reaktionsrolle hinzufügen möchtest." + }, + "InvalidMessageUrl": { + "en": "This doesn't look correct. A message url should look something like these:", + "de": "Das sieht nicht korrekt aus. Eine Nachrichten-URL sollte etwa so aussehen:" + }, + "MessageUrlWrongGuild": { + "en": "The link you provided leads to another server.", + "de": "Der von dir angegebene Link führt zu einem anderen Server." + }, + "MessageUrlNoChannel": { + "en": "The link you provided leads to a channel that doesn't exist.", + "de": "Der von dir angegebene Link führt zu einem nicht existierenden Kanal." + }, + "MessageUrlNoMessage": { + "en": "The link you provided leads to a message that doesn't exist or the bot has no access to.", + "de": "Der von dir angegebene Link führt zu einer nicht existierenden Nachricht oder der Bot hat keinen Zugriff darauf." + }, + "AddingReactionRole": { + "en": "Adding Reaction Role..", + "de": "Füge Reaktionsrolle hinzu.." + }, + "SelectRolePrompt": { + "en": "Please select the role you want this reaction role to assign below.", + "de": "Bitte wähle unten die Rolle aus, die dieser Reaktionsrolle zugewiesen werden soll." + }, + "NoRoles": { + "en": "Could not find any roles in your server.", + "de": "Es konnten keine Rollen in deinem Server gefunden werden." + }, + "ReactWithEmoji": { + "en": "Please react to the message you're assigning the reaction role to with the emoji you'd like to use.", + "de": "Bitte reagiere auf die Nachricht, der du die Reaktionsrolle zuweisen möchtest, mit dem Emoji, das du verwenden möchtest." + }, + "NoAccessToEmoji": { + "en": "The bot has no access to this emoji. Any emoji of this server and built-in Discord emojis should work.", + "de": "Der Bot hat keinen Zugriff auf dieses Emoji. Jedes Emoji dieses Servers und integrierte Discord-Emojis sollten funktionieren." + }, + "ReactionRoleLimitReached": { + "en": "You've reached the limit of 100 reaction roles per guild. You cannot add more reaction roles unless you remove one.", + "de": "Du hast das Limit von 100 Reaktionsrollen pro Server erreicht. Du kannst keine weiteren Reaktionsrollen hinzufügen, es sei denn, du entfernst eine vorhandene." + }, + "EmojiAlreadyUsed": { + "en": "The specified emoji has already been used for a reaction role on the selected message.", + "de": "Das angegebene Emoji wurde bereits für eine Reaktionsrolle in der ausgewählten Nachricht verwendet." + }, + "RoleAlreadyUsed": { + "en": "The specified role has already been used for a reaction role.", + "de": "Die angegebene Rolle wurde bereits für eine Reaktionsrolle verwendet." + }, + "AddedReactionRole": { + "en": "Added role {Role} to message sent by {User} in {Channel} with emoji {Emoji}.", + "de": "Die Rolle {Role} wurde zur Nachricht von {User} in {Channel} mit dem Emoji {Emoji} hinzugefügt." + }, + "RemovingReactionRole": { + "en": "Removing Reaction Role..", + "de": "Entferne Reaktionsrolle.." + }, + "ReactWithEmojiToRemove": { + "en": "Please react with the emoji you want to remove from the target message.", + "de": "Bitte reagiere mit dem Emoji, das du von der Zielnachricht entfernen möchtest." + }, + "NoReactionRoleFound": { + "en": "The specified message doesn't contain a reaction role with the specified reaction.", + "de": "Die angegebene Nachricht enthält keine Reaktionsrolle mit der angegebenen Reaktion." + }, + "RemovedReactionRole": { + "en": "Removed role {Role} from message sent by {User} in {Channel} with emoji {Emoji}.", + "de": "Die Rolle {Role} wurde von der Nachricht von {User} in {Channel} mit dem Emoji {Emoji} entfernt." + }, + "RemovingAllReactionRoles": { + "en": "Removing all reaction roles..", + "de": "Entferne alle Reaktionsrollen.." + }, + "NoReactionRoles": { + "en": "The specified message doesn't contain any reaction roles.", + "de": "Die angegebene Nachricht enthält keine Reaktionsrollen." + }, + "RemovedAllReactionRoles": { + "en": "Removed all reaction roles from message sent by {User} in {Channel}.", + "de": "Alle Reaktionsrollen wurden von der Nachricht von {User} in {Channel} entfernt." + } + }, + "TokenDetection": { + "Title": { + "en": "Token Detection", + "de": "Token-Erkennung" + }, + "DetectTokens": { + "en": "Detect Tokens", + "de": "Tokens erkennen" + }, + "ToggleTokenDetection": { + "en": "Toggle Token Detection", + "de": "Token-Erkennung umschalten" + } + }, + "VcCreator": { + "Title": { + "en": "Voice Channel Creator", + "de": "Sprachkanal-Ersteller" + }, + "SetVcCreator": { + "en": "Set Voice Channel Creator", + "de": "Sprachkanal-Ersteller festlegen" + }, + "DisableVcCreator": { + "en": "Disable Voice Channel Creator", + "de": "Sprachkanal-Ersteller deaktivieren" + }, + "NoChannels": { + "en": "No Voice Channels found in your server.", + "de": "Keine Sprachkanäle in deinem Server gefunden." + }, + "CreateNewChannel": { + "en": "Create new Channel", + "de": "Neuen Kanal erstellen" + } + } + } + }, + "Events": { + "Actionlog": { + "User": { + "en": "User", + "de": "Benutzer" + }, + "UserId": { + "en": "User-Id", + "de": "Benutzer-ID" + }, + "UserJoined": { + "en": "User joined", + "de": "Benutzer ist beigetreten" + }, + "UserRejoined": { + "en": "User rejoined", + "de": "Benutzer ist erneut beigetreten" + }, + "AccountAge": { + "en": "Account Age", + "de": "Kontoalter" + }, + "StaffNotes": { + "en": "Makoto Staff Notes", + "de": "Makoto-Notizen des Personals" + }, + "InviteNotes": { + "en": "Invite Notes", + "de": "Einladungsnotizen" + }, + "InvitedBy": { + "en": "Invited by", + "de": "Eingeladen von" + }, + "InviteCode": { + "en": "Invite Code", + "de": "Einladungscode" + }, + "InviteNote": { + "en": "Invite Note", + "de": "Einladungsnotiz" + }, + "UserLeft": { + "en": "User left", + "de": "Benutzer hat den Server verlassen" + }, + "JoinedAt": { + "en": "Joined at", + "de": "Beigetreten am" + }, + "Roles": { + "en": "Roles", + "de": "Rollen" + }, + "UserKicked": { + "en": "User kicked", + "de": "Benutzer wurde gekickt" + }, + "KickedBy": { + "en": "Kicked by", + "de": "Gekickt von" + }, + "Reason": { + "en": "Reason", + "de": "Grund" + }, + "FooterAuditLogDisclaimer": { + "en": "Please note that the {Fields} may not be accurate as the bot can't differentiate between similar audit log entries that affect the same things.", + "de": "Bitte beachte, dass die {Fields} möglicherweise nicht korrekt sind, da der Bot keine Unterscheidung zwischen ähnlichen Audit-Log-Einträgen treffen kann, die die gleichen Dinge betreffen." + }, + "MessageDeleted": { + "en": "Message deleted", + "de": "Nachricht gelöscht" + }, + "Channel": { + "en": "Channel", + "de": "Kanal" + }, + "Content": { + "en": "Content", + "de": "Inhalt" + }, + "Attachments": { + "en": "Attachments", + "de": "Anhänge" + }, + "Stickers": { + "en": "Stickers", + "de": "Sticker" + }, + "ReplyTo": { + "en": "Reply to", + "de": "Antworten auf" + }, + "UserJoinedVoiceChannel": { + "en": "User joined Voice Channel", + "de": "Benutzer ist einem Sprachkanal beigetreten" + }, + "UserLeftVoiceChannel": { + "en": "User left Voice Channel", + "de": "Benutzer hat einen Sprachkanal verlassen" + }, + "UserSwitchedVoiceChannel": { + "en": "User switched Voice Channel", + "de": "Benutzer hat den Sprachkanal gewechselt" + }, + "MultipleMessagesDeleted": { + "en": "Multiple Messages deleted", + "de": "Mehrere Nachrichten gelöscht" + }, + "CheckAttachedFileForDeletedMessages": { + "en": "To view the messages, click the button below or download the attached file and view it in your browser.", + "de": "Um die gelöschten Dateien anzuzeigen, klicke den Knopf unter der Nachricht oder lade dir die angehängte Datei herunter und öffne sie mit deinem Browser." + }, + "AffectedUsers": { + "en": "Affected Users", + "de": "Betroffene Benutzer" + }, + "MessageUpdated": { + "en": "Message updated", + "de": "Nachricht aktualisiert" + }, + "Message": { + "en": "Message", + "de": "Nachricht" + }, + "PreviousContent": { + "en": "Previous Content", + "de": "Vorheriger Inhalt" + }, + "NewContent": { + "en": "New Content", + "de": "Neuer Inhalt" + }, + "NicknameUpdated": { + "en": "Nickname updated", + "de": "Spitzname aktualisiert" + }, + "NicknameAdded": { + "en": "Nickname added", + "de": "Spitzname hinzugefügt" + }, + "NicknameRemoved": { + "en": "Nickname removed", + "de": "Spitzname entfernt" + }, + "PreviousNickname": { + "en": "Previous Nickname", + "de": "Vorheriger Spitzname" + }, + "NewNickname": { + "en": "New Nickname", + "de": "Neuer Spitzname" + }, + "RolesUpdated": { + "en": "Roles updated", + "de": "Rollen aktualisiert" + }, + "RolesAdded": { + "en": "Roles added", + "de": "Rollen hinzugefügt" + }, + "RolesRemoved": { + "en": "Roles removed", + "de": "Rollen entfernt" + }, + "MembershipApproved": { + "en": "Membership approved", + "de": "Mitgliedschaft genehmigt" + }, + "GuildProfilePictureUpdated": { + "en": "Member Guild Profile Picture updated", + "de": "Profilbild des Servermitglieds aktualisiert" + }, + "TimedOut": { + "en": "User timed out", + "de": "Nutzer in den Timeout versetzt" + }, + "TimedOutUntil": { + "en": "Timed out until", + "de": "Timeout gültig bis" + }, + "TimeoutRemoved": { + "en": "User timeout removed", + "de": "Timeout vom Nutzer entfernt" + }, + "Integration": { + "en": "Integration", + "de": "Integration" + }, + "ServerBooster": { + "en": "Server Booster", + "de": "Server-Booster" + }, + "RoleCreated": { + "en": "Role created", + "de": "Rolle erstellt" + }, + "Role": { + "en": "Role", + "de": "Rolle" + }, + "Color": { + "en": "Color", + "de": "Farbe" + }, + "RoleId": { + "en": "Role-Id", + "de": "Rollen-ID" + }, + "RoleIsIntegration": { + "en": "This role belongs to an integration and cannot be deleted.", + "de": "Diese Rolle gehört zu einer Integration und kann nicht gelöscht werden." + }, + "RoleMentionable": { + "en": "Role mentionable by everyone", + "de": "Rolle kann von allen erwähnt werden" + }, + "DisplayedRoleMembers": { + "en": "Display role members separately", + "de": "Mitglieder der Rolle separat anzeigen" + }, + "Permissions": { + "en": "Permissions", + "de": "Berechtigungen" + }, + "CreatedBy": { + "en": "Created by", + "de": "Erstellt von" + }, + "RoleDeleted": { + "en": "Role deleted", + "de": "Rolle gelöscht" + }, + "RoleWasIntegration": { + "en": "This role belonged to an integration and was therefore deleted automatically.", + "de": "Diese Rolle gehörte zu einer Integration und wurde daher automatisch gelöscht." + }, + "DeletedBy": { + "en": "Deleted by", + "de": "Gelöscht von" + }, + "PermissionsRemoved": { + "en": "Permissions removed", + "de": "Berechtigungen entfernt" + }, + "PermissionsAdded": { + "en": "Permissions added", + "de": "Berechtigungen hinzugefügt" + }, + "PermissionsUpdated": { + "en": "Permissions updated", + "de": "Berechtigungen aktualisiert" + }, + "RoleUpdated": { + "en": "Role updated", + "de": "Rolle aktualisiert" + }, + "ModifiedBy": { + "en": "Modified by", + "de": "Geändert von" + }, + "BannedBy": { + "en": "Banned by", + "de": "Gebannt von" + }, + "UserBanned": { + "en": "User banned", + "de": "Benutzer gebannt" + }, + "UserUnbanned": { + "en": "User unbanned", + "de": "Benutzer entbannt" + }, + "UnbannedBy": { + "en": "Unbanned by", + "de": "Entbannt von" + }, + "Owner": { + "en": "Owner", + "de": "Besitzer" + }, + "Name": { + "en": "Name", + "de": "Name" + }, + "Description": { + "en": "Description", + "de": "Beschreibung" + }, + "PreferredLocale": { + "en": "Preferred Locale", + "de": "Bevorzugte Sprache" + }, + "VanityUrl": { + "en": "Vanity Url", + "de": "Vanity-URL" + }, + "IconUpdated": { + "en": "Icon updated", + "de": "Symbol aktualisiert" + }, + "DefaultNotificationSettings": { + "en": "Default Notification Settings", + "de": "Standardbenachrichtigungseinstellungen" + }, + "VerificationLevel": { + "en": "Verification Level", + "de": "Verifizierungsstufe" + }, + "BannerUpdated": { + "en": "Banner updated", + "de": "Banner aktualisiert" + }, + "SplashUpdated": { + "en": "Splash updated", + "de": "Splash aktualisiert" + }, + "HomeHeaderUpdated": { + "en": "Home Header updated", + "de": "Startseiten-Header aktualisiert" + }, + "DiscoverySplashUpdated": { + "en": "Discovery Splash updated", + "de": "Discovery Splash aktualisiert" + }, + "RequiredMfaLevel": { + "en": "Require 2-Factor Authentication for Mods", + "de": "Zwei-Faktor-Authentifizierung für Mods erforderlich" + }, + "ExplicitContentFilter": { + "en": "Explicit Content Filter", + "de": "Expliziter Inhaltsfilter" + }, + "GuildWidgetEnabled": { + "en": "Guild Widget Enabled", + "de": "Guild-Widget aktiviert" + }, + "GuildWidgetChannel": { + "en": "Guild Widget Channel", + "de": "Guild-Widget-Kanal" + }, + "LargeGuild": { + "en": "Large Guild", + "de": "Großer Server" + }, + "NsfwGuild": { + "en": "Nsfw Guild", + "de": "NSFW-Server" + }, + "CommunityGuild": { + "en": "Community Guild", + "de": "Community-Server" + }, + "MembershipScreening": { + "en": "Membership Screening", + "de": "Mitgliedschaftsüberprüfung" + }, + "WelcomeScreen": { + "en": "Welcome Screen", + "de": "Willkommensbildschirm" + }, + "BoostProgressBar": { + "en": "Boost Progress Bar", + "de": "Boost-Fortschrittsleiste" + }, + "RuleChannel": { + "en": "Rules Channel", + "de": "Regel-Kanal" + }, + "AfkTimeout": { + "en": "Afk Timeout", + "de": "AFK-Timeout" + }, + "AfkChannel": { + "en": "Afk Channel", + "de": "AFK-Kanal" + }, + "SystemChannel": { + "en": "System Channel", + "de": "System-Kanal" + }, + "DiscordUpdateChannel": { + "en": "Discord Update Channel", + "de": "Discord-Aktualisierungskanal" + }, + "SafetyAlertsChannel": { + "en": "Safety Alerts Channel", + "de": "Sicherheitswarnungen-Kanal" + }, + "MaximumMembers": { + "en": "Maximum Members", + "de": "Maximale Mitglieder" + }, + "GuildUpdated": { + "en": "Guild updated", + "de": "Server aktualisiert" + }, + "ChannelCreated": { + "en": "Channel created", + "de": "Kanal erstellt" + }, + "ChannelDeleted": { + "en": "Channel deleted", + "de": "Kanal gelöscht" + }, + "ChannelModified": { + "en": "Channel updated", + "de": "Kanal aktualisiert" + }, + "ChannelId": { + "en": "Channel-Id", + "de": "Kanal-ID" + }, + "NsfwChannel": { + "en": "Nsfw Channel", + "de": "NSFW-Kanal" + }, + "Bitrate": { + "en": "Bitrate", + "de": "Bitrate" + }, + "DefaultAutoArchiveDuration": { + "en": "Default Auto Archive Duration", + "de": "Standard-Auto-Archivdauer" + }, + "Invite": { + "en": "Invite", + "de": "Einladung" + }, + "InviteCreated": { + "en": "Invite created", + "de": "Einladung erstellt" + }, + "InviteDeleted": { + "en": "Invite deleted", + "de": "Einladung gelöscht" + }, + "NoInviter": { + "en": "No Inviter found.", + "de": "Kein Einlader gefunden." + } + }, + "BumpReminder": { + "ServerBumped": { + "en": "Thanks a lot for supporting the server, {User}!", + "de": "Vielen Dank, dass du den Server unterstützt, {User}!" + }, + "SubscribeRoleNotice": { + "en": "You can subscribe and unsubscribe to the bump reminder notifications at any time by reacting to the pinned message.", + "de": "Du kannst dich jederzeit durch Reaktion auf die angepinnte Nachricht für die Erinnerungen zum Bumpen anmelden oder abmelden." + }, + "NextBumpTime": { + "en": "The server can be bumped {Timestamp}.", + "de": "Der Server kann {Timestamp} gebumpt werden." + }, + "LastBumpBy": { + "en": "The server was last bumped by {User} {RTimestamp} at {FTimestamp}.", + "de": "Der Server wurde zuletzt von {User} {RTimestamp} um {FTimestamp} gebumpt." + }, + "ServerCanBeBump": { + "en": "The server can be bumped!", + "de": "Der Server kann gebumpt werden!" + }, + "BumpReminderDisabled": { + "en": "The bump reminder was disabled for the following reason:", + "de": "Die Bump-Erinnerung wurde aus folgendem Grund deaktiviert:" + }, + "BumpReminderDisabledReactionRemoved": { + "en": "Self Role Message Reaction was removed.", + "de": "Die Reaktion auf die Self-Rolle-Nachricht wurde entfernt." + }, + "BumpReminderDisabledNotPinned": { + "en": "Self Role Message is not pinned.", + "de": "Die Self-Rolle-Nachricht ist nicht angepinnt." + }, + "BumpReminderDisabledMessageDeleted": { + "en": "Self Role Message was deleted.", + "de": "Die Self-Rolle-Nachricht wurde gelöscht." + }, + "LastBumpMissed": { + "en": "The last bump was missed!", + "de": "Der letzte Bump wurde verpasst!" + }, + "BumpNotification": { + "en": "The server can be bumped again!", + "de": "Der Server kann erneut gebumpt werden!" + } + }, + "EmbedMessages": { + "Delete": { + "en": "Delete", + "de": "Löschen" + }, + "Lines": { + "en": "lines {Start} to {End}", + "de": "Zeilen {Start} bis {End}" + }, + "Line": { + "en": "Line {Start}", + "de": "Zeile {Start}" + }, + "FailedToDelete": { + "en": "Failed to delete message.", + "de": "Nachricht konnte nicht gelöscht werden." + }, + "NotAuthor": { + "en": "You are not the message author of the referenced message.", + "de": "Du bist nicht der Autor der referenzierten Nachricht." + } + }, + "GenericEvent": { + "PingMessage": { + "en": [ + "Hi {User}, i'm {Bot}. I support Slash Commands, but additionally you can use me via `;;` or {BotMention}. To get a list of all commands, type {Help}.", + "If you need help, feel free to join our Support and Development Server: {Invite}", + "", + "To find out more about me, check my Github Repository: {GithubRepo}" + ], + "de": [ + "Hallo {User}, ich bin {Bot}. Ich unterstütze Slash-Befehle, aber du kannst mich auch mit `;;` oder {BotMention} verwenden. Um eine Liste aller Befehle zu erhalten, gib {Help} ein.", + "Wenn du Hilfe benötigst, kannst du gerne unserem Support- und Entwicklungsserver beitreten: {Invite}", + "", + "Um mehr über mich zu erfahren, schau dir mein Github-Repository an: {GithubRepo}" + ] + }, + "LimitedReached": { + "en": [ + "Hi, thanks for adding me to your server.", + "Unfortunately, I am not yet verified.", + "", + "Because i need several intents (read more about that here: {IntentsUrl}) like server members and message content, i am unable to operate in more than 99 servers.", + "To see how my verification is going, check our development and support server: {Invite}" + ], + "de": [ + "Hallo, vielen Dank, dass du mich zu deinem Server hinzugefügt hast.", + "Leider bin ich noch nicht verifiziert.", + "", + "Da ich mehrere Intents benötige (hier kannst du mehr darüber lesen: {IntentsUrl}), wie zum Beispiel Servermitglieder und Nachrichteninhalt, kann ich in mehr als 99 Servern nicht funktionieren.", + "Um zu sehen, wie es mit meiner Verifizierung läuft, schau auf unserem Entwicklungs- und Support-Server vorbei: {Invite}" + ] + }, + "SuccessfulJoin": { + "en": [ + "Hi, i'm {Bot}. I support Slash Commands, but additionally you can use me via `;;` or {BotMention}. To get a list of all commands, type {Help}.", + "", + "**Important Notes", + "", + "• **Phishing Protection is enabled by default. To change this run: {Phishing}.", + "• **Automatic User/Bot Token invalidation is enabled by default. If you don't know what this means, just leave it on. If you do know what this means and you don't want it to happen, run: {TokenDetection}.", + "• Every server is opted into a global ban system. When someone is known to break Discord's TOS, us bot staff can quickly scoop them up and ban them even before their account gets terminated by Discord. You can opt out via {Join}.", + "", + "If you need help, feel free to join our Support and Development Server: {Invite}", + "To find out more about me, check my Github Repository: {GithubRepo}", + "", + "_This message will automatically be deleted {Timestamp}._" + ], + "de": [ + "Hallo, ich bin {Bot}. Ich unterstütze Slash-Befehle, aber du kannst mich auch mit `;;` oder {BotMention} verwenden. Um eine Liste aller Befehle zu erhalten, gib {Help} ein.", + "", + "**Wichtige Hinweise**", + "", + "• **Phishing-Schutz ist standardmäßig aktiviert. Um dies zu ändern, führe {Phishing} aus.", + "• **Automatische Ungültigmachung von Benutzer-/Bot-Token ist standardmäßig aktiviert. Wenn du nicht weißt, was das bedeutet, lass es einfach so. Wenn du jedoch weißt, was das bedeutet und nicht möchtest, dass es geschieht, führe {TokenDetection} aus.", + "• Jeder Server ist in ein globales Sperrsystem eingebunden. Wenn jemand gegen die Discord-Nutzungsbedingungen verstößt, können wir Bot-Mitarbeiter sie schnell aufgreifen und sperren, noch bevor ihr Konto von Discord gesperrt wird. Du kannst dies über {Join} ablehnen.", + "", + "Wenn du Hilfe benötigst, kannst du gerne unserem Support- und Entwicklungsserver beitreten: {Invite}", + "Um mehr über mich zu erfahren, schau dir mein Github-Repository an: {GithubRepo}", + "", + "_Diese Nachricht wird automatisch gelöscht {Timestamp}._" + ] + } + }, + "Experience": { + "GainedLevel": { + "en": "Congrats, {User}! You gained {Count} level.", + "de": "Glückwunsch, {User}! Du hast {Count} Level erreicht." + }, + "GainedLevels": { + "en": "Congrats, {User}! You gained {Count} levels.", + "de": "Glückwunsch, {User}! Du hast {Count} Level erreicht." + }, + "NewLevel": { + "en": "You're now on Level {Level}.", + "de": "Du bist jetzt auf Level {Level}." + }, + "AutomaticDeletion": { + "en": "This message will be automatically deleted in {Seconds} seconds.", + "de": "Diese Nachricht wird in {Seconds} Sekunden automatisch gelöscht." + }, + "DisableDirectMessages": { + "en": "Disable Direct Message Experience Notifications", + "de": "Deaktiviere Direktnachricht-Erfahrungsbenachrichtigungen" + }, + "DirectMessagesDisabled": { + "en": "Alright, i will no longer send you any level up notifications via DM. If you wan't to re-enable this, run {Command} on any guild with {Bot}.", + "de": "In Ordnung, ich werde dir keine Level-Benachrichtigungen mehr per Direktnachricht senden. Wenn du dies wieder aktivieren möchtest, führe {Command} in einem beliebigen Server mit {Bot} aus." + } + }, + "InVoicePrivacy": { + "CreatedWithSetPermissions": { + "en": "In-Voice Privacy 'Set permissions' is turned on", + "de": "In-Voice-Privatsphäre 'Berechtigungen festlegen' ist aktiviert" + }, + "LeftWithSetPermissions": { + "en": "Left VC while In-Voice Privacy 'Set permissions' is turned on", + "de": "Verlassen des Sprachkanals, während die In-Voice-Privatsphäre 'Berechtigungen festlegen' aktiviert ist" + }, + "JoinedWithSetPermissions": { + "en": "Joined VC while In-Voice Privacy 'Set permissions' is turned on", + "de": "Beitritt zum Sprachkanal, während die In-Voice-Privatsphäre 'Berechtigungen festlegen' aktiviert ist" + }, + "LeftWithDeleteMessages": { + "en": "Joined VC while In-Voice Privacy 'Delete messages' is turned on", + "de": "Verlassen des Sprachkanals, während die In-Voice-Privatsphäre 'Nachrichten löschen' aktiviert ist" + } + }, + "Join": { + "Globalban": { + "en": "Globalban", + "de": "Globalban" + }, + "UserJoined": { + "en": "has joined {Guild}. Welcome!", + "de": "ist {Guild} beigetreten. Herzlich willkommen!" + }, + "UserLeft": { + "en": [ + "has left {Guild}.", + "They've been on the server for _{Timestamp}_." + ], + "de": [ + "hat {Guild} verlassen.", + "Der Benutzer war _{Timestamp}_ auf dem Server." + ] + } + }, + "Phishing": { + "AbuseIpDbReport": { + "en": "AbuseIPDB Report", + "de": "AbuseIPDB-Bericht" + }, + "HostWasFoundInAbuseIpDb": { + "en": "{Host} was found in AbuseIPDB.", + "de": "{Host} wurde in AbuseIPDB gefunden." + }, + "ConfidenceOfAbuse": { + "en": "Confidence of Abuse", + "de": "Missbrauchswahrscheinlichkeit" + }, + "Country": { + "en": "Country", + "de": "Land" + }, + "ISP": { + "en": "ISP", + "de": "Internetanbieter" + }, + "DomainName": { + "en": "Domain Name", + "de": "Domain-Name" + }, + "OpenInBrowser": { + "en": "Open in Browser", + "de": "Im Browser öffnen" + }, + "RedirectCheckTimeoutError": { + "en": "Couldn't check this link for malicious redirects. Please proceed with caution.", + "de": "Es konnte nicht überprüft werden, ob dieser Link bösartige Weiterleitungen enthält. Bitte gehe vorsichtig vor." + }, + "RedirectDepthLimitError": { + "en": "This link redirects an excessive amount and the unshortening attempt was cancelled. Please proceed with caution.", + "de": "Dieser Link leitet in übermäßigem Maße um, und der Versuch, ihn zu entkürzen, wurde abgebrochen. Bitte gehe vorsichtig vor." + }, + "RedirectCheckTimeoutUnknownError": { + "en": "An unknown error occurred while trying to check for malicious redirects. Please proceed with caution.", + "de": "Beim Versuch, auf bösartige Weiterleitungen zu überprüfen, ist ein unbekannter Fehler aufgetreten. Bitte gehe vorsichtig vor." + }, + "FoundRedirects": { + "en": "Found at least one redirected URLs in this message.", + "de": "In dieser Nachricht wurde mindestens eine weitergeleitete URL gefunden." + }, + "DetectedMaliciousHost": { + "en": "Detected malicious host [{Host}]", + "de": "Bösartiger Host erkannt [{Host}]" + } + }, + "TokenDetection": { + "TokenInvalidated": { + "en": [ + "Heads up!", + "", + "I've detected {Count} authentication token(s) within your last message. The token(s) will soon be invalidated and the owner(s) of the bot(s) will receive an official notification from Discord", + "", + "Please be careful sharing your authentication tokens. If you think you know what you're doing, run '/config tokendetection' to disable this functionality." + ], + "de": [ + "Achtung!", + "", + "Ich habe {Count} Authentifizierungstoken in deiner letzten Nachricht entdeckt. Die Token werden bald ungültig gemacht und die Besitzer der Bots erhalten eine offizielle Benachrichtigung von Discord.", + "", + "Bitte sei vorsichtig beim Teilen deiner Authentifizierungstoken. Wenn du denkst, dass du weißt, was du tust, führe '/config tokendetection' aus, um diese Funktion zu deaktivieren." + ] + } + }, + "VcCreator": { + "DefaultChannelName": { + "en": "{User}'s Channel", + "de": "{User}s Kanal" + }, + "NewChannelNotice": { + "en": [ + "This is your temporary personal channel.", + "", + "If this channel becomes empty, it'll be deleted. Use the {Command} commands to manage this channel." + ], + "de": [ + "Dies ist dein temporärer persönlicher Kanal.", + "", + "Wenn dieser Kanal leer wird, wird er gelöscht. Verwende die {Command} Befehle, um diesen Kanal zu verwalten." + ] + }, + "NewOwner": { + "en": "The channel is now owned by {User}.", + "de": "Der Kanal gehört nun {User}." + }, + "UserJoined": { + "en": "{User} joined.", + "de": "{User} ist beigetreten." + }, + "UserLeft": { + "en": "{User} left.", + "de": "{User} hat den Kanal verlassen." + } + } + } +} \ No newline at end of file diff --git a/ProjectMakoto/Util/BumpReminderHandler.cs b/ProjectMakoto/Util/BumpReminderHandler.cs new file mode 100644 index 00000000..538d3a84 --- /dev/null +++ b/ProjectMakoto/Util/BumpReminderHandler.cs @@ -0,0 +1,119 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto; +internal sealed class BumpReminderHandler(Bot bot) : RequiresBotReference(bot) +{ + Translations.events.bumpReminder tKey + => this.Bot.LoadedTranslations.Events.BumpReminder; + + internal void SendPersistentMessage(DiscordClient client, DiscordChannel channel, DiscordUser bUser = null) + { + var embed = new DiscordEmbedBuilder + { + Author = new DiscordEmbedBuilder.EmbedAuthor + { + IconUrl = channel.Guild.IconUrl, + Name = channel.Guild.Name + }, + Color = EmbedColors.Info, + Description = $"**{this.tKey.NextBumpTime.Get(this.Bot.Guilds[channel.Guild.Id]).Build(new TVar("Timestamp", this.Bot.Guilds[channel.Guild.Id].BumpReminder.LastBump.ToTimestamp()))}**\n\n" + + $"{this.tKey.LastBumpBy.Get(this.Bot.Guilds[channel.Guild.Id]).Build(new TVar("User", $"<@{this.Bot.Guilds[channel.Guild.Id].BumpReminder.LastUserId}>"), new TVar("RTimestamp", this.Bot.Guilds[channel.Guild.Id].BumpReminder.LastBump.ToTimestamp()), new TVar("FTimestamp", this.Bot.Guilds[channel.Guild.Id].BumpReminder.LastBump.ToTimestamp(TimestampFormat.LongDateTime)))}", + Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail { Url = $"{(bUser is null ? AuditLogIcons.QuestionMark : bUser.AvatarUrl)}" } + }; + + if (this.Bot.Guilds[channel.Guild.Id].BumpReminder.LastBump < DateTime.UtcNow.AddHours(-2)) + { + embed.Description = $"**{this.tKey.ServerCanBeBump.Get(this.Bot.Guilds[channel.Guild.Id])}**\n\n" + + $"{this.tKey.LastBumpBy.Get(this.Bot.Guilds[channel.Guild.Id]).Build(new TVar("User", $"<@{this.Bot.Guilds[channel.Guild.Id].BumpReminder.LastUserId}>"), new TVar("RTimestamp", this.Bot.Guilds[channel.Guild.Id].BumpReminder.LastBump.ToTimestamp()), new TVar("FTimestamp", this.Bot.Guilds[channel.Guild.Id].BumpReminder.LastBump.ToTimestamp(TimestampFormat.LongDateTime)))}"; + embed.Color = EmbedColors.AwaitingInput; + } + + _ = channel.SendMessageAsync(embed.Build()).ContinueWith(async x => + { + if (x.IsCompletedSuccessfully) + { + try + { _ = (await channel.GetMessageAsync(this.Bot.Guilds[channel.Guild.Id].BumpReminder.PersistentMessageId)).DeleteAsync().Add(this.Bot); } + catch { } + this.Bot.Guilds[channel.Guild.Id].BumpReminder.PersistentMessageId = x.Result.Id; + + _ = channel.DeleteMessagesAsync((await channel.GetMessagesAsync(100)).Where(y => y.Embeds.Any() && y.Author.Id == client.CurrentUser.Id && y.Id != x.Result.Id)); + } + }); + } + + internal void ScheduleBump(DiscordClient client, ulong ServerId) + { + Log.Debug("Queuing Bump Message for '{Guild}'", ServerId); + + try + { + foreach (var b in ScheduledTaskExtensions.GetScheduledTasks()) + { + if (b.CustomData is not ScheduledTaskIdentifier scheduledTaskIdentifier) + continue; + + if (scheduledTaskIdentifier.Snowflake == ServerId && scheduledTaskIdentifier.Type == "bumpmsg") + b.Delete(); + } + } + catch (Exception ex) + { + Log.Error(ex, "An exception occurred while trying to un-queue previous bump messages for '{Guild}'", ServerId); + } + + _ = new Func(async () => + { + Log.Debug("Executing Bump Message for '{Guild}'", ServerId); + var Guild = await client.GetGuildAsync(ServerId); + + if (!Guild.Channels.ContainsKey(this.Bot.Guilds[ServerId].BumpReminder.ChannelId) || this.Bot.Guilds[ServerId].BumpReminder.BumpsMissed > 168) + { + Log.Debug("'{Guild}' has deleted their bump channel or hasn't bumped 169 times. Disabling bump reminder..", ServerId); + this.Bot.Guilds[ServerId].BumpReminder.Reset(); + return; + } + + var Channel = Guild.GetChannel(this.Bot.Guilds[ServerId].BumpReminder.ChannelId); + + Log.Debug("Checking if Self Role Message still exists, has it's reaction and is pinned in '{Guild}'", ServerId); + + try + { + if (!Channel.TryGetMessage(this.Bot.Guilds[ServerId].BumpReminder.MessageId, out var msg)) + throw new CancelException(this.tKey.BumpReminderDisabledMessageDeleted.Get(this.Bot.Guilds[ServerId])); + + if (!msg.Reactions.Any(x => x.Emoji.ToString() == "✅")) + throw new CancelException(this.tKey.BumpReminderDisabledReactionRemoved.Get(this.Bot.Guilds[ServerId])); + + if (!msg.Pinned) + throw new CancelException(this.tKey.BumpReminderDisabledNotPinned.Get(this.Bot.Guilds[ServerId])); + } + catch (CancelException ex) + { + this.Bot.Guilds[ServerId].BumpReminder.Reset(); + _ = Channel.SendMessageAsync(new DiscordMessageBuilder().WithContent($":warning: `{this.tKey.BumpReminderDisabled.Get(this.Bot.Guilds[ServerId])} {ex.Message}`")); + return; + } + + if (this.Bot.Guilds[ServerId].BumpReminder.LastBump < DateTime.UtcNow.AddHours(-3)) + { + _ = Channel.SendMessageAsync(new DiscordMessageBuilder().WithContent($":warning: <@&{this.Bot.Guilds[ServerId].BumpReminder.RoleId}> {this.tKey.LastBumpMissed.Get(this.Bot.Guilds[ServerId])}")); + this.Bot.Guilds[ServerId].BumpReminder.BumpsMissed++; + } + else + _ = Channel.SendMessageAsync(new DiscordMessageBuilder().WithContent($":bell: <@&{this.Bot.Guilds[ServerId].BumpReminder.RoleId}> {this.tKey.BumpNotification.Get(this.Bot.Guilds[ServerId])}")); + + this.Bot.Guilds[ServerId].BumpReminder.LastReminder = DateTime.UtcNow; + + this.ScheduleBump(client, ServerId); + }).CreateScheduledTask(this.Bot.Guilds[ServerId].BumpReminder.LastReminder.AddHours(2), new ScheduledTaskIdentifier(ServerId, "", "bumpmsg")); + } +} diff --git a/ProjectMakoto/Util/Clients/AbuseIpDbClient.cs b/ProjectMakoto/Util/Clients/AbuseIpDbClient.cs new file mode 100644 index 00000000..cc323fb4 --- /dev/null +++ b/ProjectMakoto/Util/Clients/AbuseIpDbClient.cs @@ -0,0 +1,171 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using System.Collections.Concurrent; + +namespace ProjectMakoto.Util; + +public sealed class AbuseIpDbClient : RequiresBotReference +{ + internal AbuseIpDbClient(Bot bot) : base(bot) + { + this.QueueHandler(); + } + + ~AbuseIpDbClient() + { + this._disposed = true; + } + + bool _disposed = false; + + private readonly ConcurrentDictionary Queue = new(); + + private Dictionary> Cache = new(); + + private int RequestsRemaining = 1; + + private void QueueHandler() + { + _ = Task.Run(async () => + { + HttpClient client = new(); + + while (this.Bot.status.LoadedConfig.Secrets.AbuseIpDbToken.IsNullOrWhiteSpace()) + { + await Task.Delay(5000); + } + + client.DefaultRequestHeaders.Add("Key", this.Bot.status.LoadedConfig.Secrets.AbuseIpDbToken); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + + while (!this._disposed) + { + while (this.RequestsRemaining <= 0) + { + var now = DateTimeOffset.UtcNow; + var tomorrow = new DateTimeOffset(now.Year, now.Month, now.Day, 0, 0, 0, TimeSpan.Zero).AddDays(1); + + Log.Warning("Daily Ratelimit reached for AbuseIPDB. Waiting until {tomorrow}..", tomorrow); + var delay = tomorrow - DateTimeOffset.UtcNow; + + if (delay > TimeSpan.Zero) + await Task.Delay(delay); + + Log.Information("Ratelimit cleared for AbuseIPDB."); + this.RequestsRemaining = 1; + } + + if (this.Queue.IsEmpty || !this.Queue.Any(x => !x.Value.Resolved && !x.Value.Failed)) + { + Thread.Sleep(100); + continue; + } + + var b = this.Queue.First(x => !x.Value.Resolved && !x.Value.Failed); + + try + { + var response = await client.GetAsync(b.Value.Url); + + this.Queue[b.Key].StatusCode = response.StatusCode; + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == HttpStatusCode.NotFound) + throw new Exceptions.NotFoundException(); + + if (response.StatusCode == HttpStatusCode.InternalServerError) + throw new Exceptions.InternalServerErrorException(); + + if (response.StatusCode == HttpStatusCode.Forbidden) + throw new Exceptions.ForbiddenException(); + + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + this.RequestsRemaining = 0; + Log.Error("Daily Ratelimit hit for AbuseIPDB."); + continue; + } + + throw new Exception($"Unhandled, unsuccessful request: {response.StatusCode}"); + } + + this.RequestsRemaining = response.Headers.First(x => x.Key == "X-RateLimit-Remaining").Value.First().ToInt32(); + Log.Debug("{RequestsRemaining} AbuseIPDB requests remaining.", this.RequestsRemaining); + + this.Queue[b.Key].Response = await response.Content.ReadAsStringAsync(); + this.Queue[b.Key].Resolved = true; + } + catch (Exception ex) + { + this.Queue[b.Key].Failed = true; + this.Queue[b.Key].Exception = ex; + } + finally + { + await Task.Delay(1000); + } + } + }).Add(this.Bot).IsVital(); + } + + private async Task MakeRequest(string url) + { + var key = Guid.NewGuid().ToString(); + _ = this.Queue.AddOrUpdate(key, new WebRequestItem { Url = url }, (x1, x2) => { return null; }); + + while (this.Queue.ContainsKey(key) && !this.Queue[key].Resolved && !this.Queue[key].Failed) + await Task.Delay(100); + + if (!this.Queue.TryGetValue(key, out var value)) + throw new Exception("The request has been removed from the queue prematurely."); + + var response = value; + _ = this.Queue.Remove(key, out _); + + if (response.Resolved) + return response.Response; + else if (response.Failed) + throw response.Exception; + else + throw new Exception("This exception should be impossible to get."); + } + + public async Task QueryIp(string Ip, bool bypassCache = false) + { + while (this.Cache.ContainsKey(Ip) && this.Cache[Ip] is null) + await Task.Delay(100); + + if (this.Cache.TryGetValue(Ip, out var value) && value.Item2.AddHours(4).GetTotalSecondsUntil() > 0 && !bypassCache) + return this.Cache[Ip].Item1; + else + _ = this.Cache.Remove(Ip); + + this.Cache.Add(Ip, null); + + string query; + + using (var content = new FormUrlEncodedContent(new Dictionary + { + { "ipAddress", Ip }, + { "maxAgeInDays", "90" }, + { "verbose", "true" }, + })) + { + query = await content.ReadAsStringAsync(); + } + + var rawResponse = await this.MakeRequest($"https://api.abuseipdb.com/api/v2/check?{query}"); + var parsedResponse = JsonConvert.DeserializeObject(rawResponse); + + this.Cache[Ip] = new Tuple(parsedResponse, DateTime.UtcNow); + return parsedResponse; + } +} diff --git a/ProjectMakoto/Util/Clients/ChartGeneration.cs b/ProjectMakoto/Util/Clients/ChartGeneration.cs new file mode 100644 index 00000000..d2b98432 --- /dev/null +++ b/ProjectMakoto/Util/Clients/ChartGeneration.cs @@ -0,0 +1,74 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util; +public class ChartGeneration(Bot bot) : RequiresBotReference(bot) +{ + public Chart GetChart(int Width, int Height, IEnumerable Labels, IEnumerable Datasets, int Min, int Max) + { + var v = $@"{{ + type: 'line', + data: + {{ + labels: + [ + {string.Join(",", Labels.Select(x => $"'{x}'"))} + ], + datasets: + [ + {string.Join(",\n", Datasets.Select(x => + { + return $@"{{ + label: '{x.Name}', + data: [{string.Join(",", x.Data)}], + fill: false, + reverse: {x.Reverse.ToString().ToLower()}, + borderColor: {x.Color ?? "getGradientFillHelper('vertical', ['#4287f5', '#ff0000'])"}, + id: ""{x.Id}"" + }}"; + }))} + ] + + }}, + options: + {{ + legend: + {{ + display: true, + }}, + elements: + {{ + point: + {{ + radius: 0 + }} + }}{(Min == -1 && Max == -1 ? "" : $@" + , + scales: {{ + yAxes: [{{ + ticks: {{ + max: {Max}, + min: {Min} + }} + }}] + }} + ")} + }} + }}"; + + return new(this.Bot.status.LoadedConfig.Secrets.QuickChart.Scheme, this.Bot.status.LoadedConfig.Secrets.QuickChart.Host, this.Bot.status.LoadedConfig.Secrets.QuickChart.Port) + { + Width = Width, + Height = Height, + Config = v + }; + } + + public record Dataset(string Name, IEnumerable Data, string? Color = null, string Id = "yaxis2", bool Reverse = false); +} diff --git a/ProjectMakoto/Util/Clients/GoogleTranslateClient.cs b/ProjectMakoto/Util/Clients/GoogleTranslateClient.cs new file mode 100644 index 00000000..c4790775 --- /dev/null +++ b/ProjectMakoto/Util/Clients/GoogleTranslateClient.cs @@ -0,0 +1,139 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util; + +public sealed class GoogleTranslateClient : RequiresBotReference +{ + internal GoogleTranslateClient(Bot bot) : base(bot) + { + this.QueueHandler(); + } + + ~GoogleTranslateClient() + { + this._disposed = true; + } + + bool _disposed = false; + + internal DateTime LastRequest = DateTime.MinValue; + internal readonly Dictionary Queue = new(); + + private void QueueHandler() + { + _ = Task.Run(async () => + { + HttpClient client = new(); + + client.DefaultRequestHeaders.Add("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.104 Safari/537.36"); + + while (!this._disposed) + { + if (this.Queue.Count == 0 || !this.Queue.Any(x => !x.Value.Resolved && !x.Value.Failed)) + { + await Task.Delay(100); + continue; + } + + var b = this.Queue.First(x => !x.Value.Resolved && !x.Value.Failed); + + try + { + var response = await client.PostAsync(b.Value.Url, null); + + this.Queue[b.Key].StatusCode = response.StatusCode; + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == HttpStatusCode.NotFound) + throw new Exceptions.NotFoundException(""); + + if (response.StatusCode == HttpStatusCode.InternalServerError) + throw new Exceptions.InternalServerErrorException(""); + + if (response.StatusCode == HttpStatusCode.Forbidden) + throw new Exceptions.ForbiddenException(""); + + throw new Exception($"Unsuccessful request: {response.StatusCode}"); + } + + + this.Queue[b.Key].Response = await response.Content.ReadAsStringAsync(); + this.Queue[b.Key].Resolved = true; + } + catch (Exception ex) + { + this.Queue[b.Key].Failed = true; + this.Queue[b.Key].Exception = ex; + } + finally + { + this.LastRequest = DateTime.UtcNow; + await Task.Delay(10000); + } + } + }).Add(this.Bot).IsVital(); + } + + private async Task MakeRequest(string url) + { + var key = Guid.NewGuid().ToString(); + this.Queue.Add(key, new WebRequestItem { Url = url }); + + while (this.Queue.ContainsKey(key) && !this.Queue[key].Resolved && !this.Queue[key].Failed) + await Task.Delay(100); + + if (!this.Queue.TryGetValue(key, out var value)) + throw new Exception("The request has been removed from the queue prematurely."); + + var response = value; + _ = this.Queue.Remove(key); + + if (response.Resolved) + return response.Response; + + if (response.Failed) + throw response.Exception; + + throw new Exception("This exception should be impossible to get."); + } + + public async Task> Translate(string SourceLanguage, string TargetLanguage, string Query) + { + string query; + + using (var content = new FormUrlEncodedContent(new Dictionary + { + { "sl", SourceLanguage }, + { "tl", TargetLanguage }, + { "q", Query }, + })) + { + query = await content.ReadAsStringAsync(); + } + + var translateResponse = await this.MakeRequest($"https://translate.google.com/translate_a/single?client=gtx&{query}&dt=t&ie=UTF-8&oe=UTF-8"); + + var parsedResponse = JsonConvert.DeserializeObject(translateResponse); + var parsedTextStep1 = JsonConvert.DeserializeObject(parsedResponse[0].ToString()); + var translatedText = string.Join(" ", parsedTextStep1.Select(x => JsonConvert.DeserializeObject(x.ToString())[0].ToString())); + + var translationSource = ""; + + if (SourceLanguage == "auto") + { + var parsedLanguageStep1 = JsonConvert.DeserializeObject(parsedResponse[8].ToString()); + var parsedLanguageStep2 = JsonConvert.DeserializeObject(parsedLanguageStep1[0].ToString()); + translationSource = parsedLanguageStep2[0].ToString(); + } + + return new Tuple(translatedText, translationSource); + } +} diff --git a/ProjectMakoto/Util/Clients/OfficialPluginRepository.cs b/ProjectMakoto/Util/Clients/OfficialPluginRepository.cs new file mode 100644 index 00000000..20b3d15d --- /dev/null +++ b/ProjectMakoto/Util/Clients/OfficialPluginRepository.cs @@ -0,0 +1,164 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util; + +public class OfficialPluginRepository : RequiresBotReference +{ + internal OfficialPluginRepository(Bot bot) : base(bot) + { + this.SyncRepository(); + } + + private void SyncRepository() + { + _ = new Func(() => + { + this.SyncRepository(); + return Task.CompletedTask; + }).CreateScheduledTask(DateTime.UtcNow.AddMinutes(30)); + + _ = Task.Run(this.Pull); + } + + internal (bool, PluginManifest?) FindHash(string hash) + { + (var found, var fileInfo) = this.FindFile(hash); + + if (found) + return (true, JsonConvert.DeserializeObject(File.ReadAllText(fileInfo.FullName))); + + return (false, null); + } + + private (bool, FileInfo?) FindFile(string searchQuery, string? startDirectory = null) + { + startDirectory ??= $"GitHub/ProjectMakoto.TrustedPlugins/"; + + foreach (var directory in Directory.GetDirectories(startDirectory)) + { + (var found, var fileInfo) = this.FindFile(searchQuery, directory); + + if (found) + return (true, fileInfo); + } + + foreach (var file in Directory.GetFiles(startDirectory).Where(x => x.EndsWith(".json"))) + { + if (Path.GetFileNameWithoutExtension(file) == searchQuery) + return (true, new FileInfo(file)); + } + + return (false, null); + } + + internal bool PullRunning = false; + + public async Task Pull() + { + try + { + if (this.PullRunning) return; + + this.PullRunning = true; + + if (!this.ExistsOnPath("git")) + { + Log.Warning("Git was not found, cannot sync trusted plugins repository."); + return; + } + + if (!Directory.Exists("GitHub/ProjectMakoto.TrustedPlugins")) + { + _ = Directory.CreateDirectory("GitHub"); + + _ = Process.Start(new ProcessStartInfo() + { + FileName = "git", + Arguments = $"clone https://github.com/Fortunevale/ProjectMakoto.TrustedPlugins.git", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = "GitHub/" + }); + } + + var fetch = Process.Start(new ProcessStartInfo() + { + FileName = "git", + Arguments = $"fetch", + WorkingDirectory = $"GitHub/ProjectMakoto.TrustedPlugins/" + }); + await fetch.WaitForExitAsync(); + + if (fetch.ExitCode != 0) + { + Log.Error("Git fetch exited with a non-zero exit code."); + return; + } + + var pull = Process.Start(new ProcessStartInfo() + { + FileName = "git", + Arguments = $"pull", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = $"GitHub/ProjectMakoto.TrustedPlugins/" + }); + pull.BeginOutputReadLine(); + pull.BeginErrorReadLine(); + + var pullOutput = ""; + + pull.OutputDataReceived += (e, s) => + { + pullOutput += s.Data; + }; + + await pull.WaitForExitAsync(); + + if (pull.ExitCode != 0) + { + Log.Error("Git pull exited with a non-zero exit code."); + return; + } + + if (!pullOutput.Contains("Already up to date.", StringComparison.InvariantCultureIgnoreCase)) + Log.Information("Updated {Repo} repository.", "ProjectMakoto.TrustedPlugins"); + else + Log.Debug("{Repo} repository already up to date.", "ProjectMakoto.TrustedPlugins"); + } + finally + { + this.PullRunning = false; + } + } + + private bool ExistsOnPath(string fileName) + => this.GetFullPath(fileName) != null; + + private string GetFullPath(string fileName) + { + if (Environment.OSVersion.Platform == PlatformID.Win32NT && !fileName.EndsWith(".exe")) + fileName += ".exe"; + + if (File.Exists(fileName)) + return Path.GetFullPath(fileName); + + var values = Environment.GetEnvironmentVariable("PATH"); + foreach (var path in values.Split(Path.PathSeparator)) + { + var fullPath = Path.Combine(path, fileName); + if (File.Exists(fullPath)) + return fullPath; + } + return null; + } +} diff --git a/ProjectMakoto/Util/Clients/SystemMonitor/MonitorClient.cs b/ProjectMakoto/Util/Clients/SystemMonitor/MonitorClient.cs new file mode 100644 index 00000000..127dbfdd --- /dev/null +++ b/ProjectMakoto/Util/Clients/SystemMonitor/MonitorClient.cs @@ -0,0 +1,287 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using System.Collections.ObjectModel; +using ProjectMakoto.Entities.SystemMonitor; + +namespace ProjectMakoto.Util.SystemMonitor; + +public sealed class MonitorClient : RequiresBotReference +{ + internal MonitorClient(Bot bot) : base(bot) + { + if (!bot.status.LoadedConfig.MonitorSystem.Enabled) + return; + + if (File.Exists("cache/monitor.json")) + try + { + this.History = JsonConvert.DeserializeObject>(File.ReadAllText("cache/monitor.json")); + + if (this.History is null) + throw new Exception(); + } + catch (Exception) + { + this.History = new(); + } + + this.InitializeMonitor(); + } + + ~MonitorClient() + { + this._disposed = true; + } + + bool _disposed = false; + + private Dictionary placeholder = new(); + + public IReadOnlyDictionary GetHistory() + { + if (!this.History.IsNotNullAndNotEmpty()) + { + if (this.placeholder.Count == 0) + for (var i = 0; i < 43200; i++) + { + this.placeholder.Add(DateTime.UtcNow.AddSeconds((i * 2) * -1), + new SystemInfo + { + Cpu = new() + { + Load = new Random().Next(0, 50), + Temperature = new Random().Next(30, 50), + }, + Memory = new() + { + Used = new Random().Next(0, 12000), + Total = 24000, + } + }); + } + + return this.placeholder.OrderBy(x => x.Key.Ticks).ToDictionary(x => x.Key, x => x.Value); + } + + return this.History.OrderBy(x => x.Key.Ticks).ToDictionary(x => x.Key, x => x.Value); + } + + public Task GetCurrent() + { + return this.ReadSystemInfoAsync(); + } + + private Dictionary History = new(); + private DateTime LastScanStart = DateTime.UtcNow; + + private void InitializeMonitor() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + if (!new System.Security.Principal.WindowsPrincipal(System.Security.Principal.WindowsIdentity.GetCurrent()).IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator)) + { + Log.Warning("Running under windows, system monitor unavailable."); + return; + } + } + + _ = Task.Run(async () => + { + while (!this._disposed) + { + try + { + this.LastScanStart = DateTime.UtcNow; + var sensors = await this.ReadSystemInfoAsync(); + + Log.Debug(JsonConvert.SerializeObject(sensors, Formatting.Indented)); + + while (this.History.Any(x => x.Key.GetTimespanSince() > TimeSpan.FromDays(1))) + _ = this.History.Remove(this.History.Min(x => x.Key)); + + this.History.Add(DateTime.UtcNow, sensors); + + _ = Directory.CreateDirectory("cache"); + File.WriteAllText("cache/monitor.json", JsonConvert.SerializeObject(this.History)); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to fetch system info"); + + this.LastScanStart = DateTime.UtcNow; + } + + var waitTime = this.LastScanStart.AddSeconds(2).GetTimespanUntil(); + + if (waitTime < TimeSpan.FromSeconds(1)) + waitTime = TimeSpan.FromSeconds(1); + + await Task.Delay(waitTime); + } + }); + } + + private Task ReadSystemInfoAsync() + { + return Task.Run(() => + { + SystemInfo systemInfo = new(); + + if (Environment.OSVersion.Platform == PlatformID.Unix) + { + try + { + ProcessStartInfo info = new() + { + FileName = "bash", + Arguments = $"-c sensors", + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false + }; + + var process = Process.Start(info); + + process.WaitForExit(); + + var output = process.StandardOutput.ReadToEnd().ReplaceLineEndings("\n"); + + var parsedSensors = this.ParseSensors(output); + + systemInfo.Cpu.Temperature = parsedSensors + .FirstOrDefault(x => x.Key == this.Bot.status.LoadedConfig.MonitorSystem.SensorName).Value + .First(x => (x.Type == TrackType.C && x.Key == this.Bot.status.LoadedConfig.MonitorSystem.SensorKey)).Value; + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to execute/parse sensors"); + } + + try + { + ProcessStartInfo info = new() + { + FileName = "bash", + Arguments = $"-c \"awk '{{u=$2+$4; t=$2+$4+$5; if (NR==1){{u1=u; t1=t;}} else print ($2+$4-u1) * 100 / (t-t1); }}' <(grep 'cpu ' /proc/stat) <(sleep 1;grep 'cpu ' /proc/stat)\"", + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false + }; + + var process = Process.Start(info); + + process.WaitForExit(); + + var output = process.StandardOutput.ReadToEnd(); + systemInfo.Cpu.Load = float.Parse(output); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to execute cpu usage"); + } + + try + { + var metrics = MemoryMetricsClient.GetMetrics(); + systemInfo.Memory.Used = (float)metrics.Used; + systemInfo.Memory.Total = (float)metrics.Total; + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to execute memory usage"); + } + + return systemInfo; + } + else + { + Log.Warning("Running on unknown operating system, system monitor not supported."); + return systemInfo; + } + }); + } + + private enum TrackType + { + mV, + V, + RPM, + C, + Unknown + } + + private class TrackDetail + { + internal string Key; + internal decimal Value; + internal TrackType Type; + + public override string ToString() + { + return $"{this.Key}, {this.Value}, {this.Type}"; + } + } + + private ReadOnlyDictionary> ParseSensors(string sensorOutput) + { + Dictionary> parsedTemperatures = new(); + Dictionary> adapterRanges = new(); + Dictionary> adapterList = new(); + + var splitLines = sensorOutput.ReplaceLineEndings("\n").Split('\n'); + for (var i = 0; i < splitLines.Length; i++) + { + if (splitLines[i].IsNullOrWhiteSpace() || splitLines[i].Contains(':') || splitLines[i].StartsWith(' ')) + continue; + + if (adapterRanges.Count != 0) + adapterRanges[adapterRanges.Last().Key] = new Tuple(adapterRanges.Last().Value.Item1, i - 1); + + adapterRanges.Add(splitLines[i].Trim(), new Tuple(i, i)); + } + + if (adapterRanges.Count != 0) + adapterRanges[adapterRanges.Last().Key] = new Tuple(adapterRanges.Last().Value.Item1, splitLines.Length); + + foreach (var adapter in adapterRanges) + adapterList.Add(adapter.Key, splitLines.Skip(adapter.Value.Item1).Take(adapter.Value.Item2 - adapter.Value.Item1).ToList()); + + foreach (var adapter in adapterList) + { + parsedTemperatures.Add(adapter.Key, new()); + var detail = parsedTemperatures[adapter.Key]; + + foreach (var line in adapter.Value) + { + var match = Regex.Match(line, @"^([^:\n]+?): +((?:\+|\-)?[\d.]+?)(?: |°)(RPM|C|V|mV)(?: +?\(|\n| )?"); + + if (!match.Success) + continue; + + detail.Add(new TrackDetail + { + Key = match.Groups[1].Value, + Value = decimal.Parse(match.Groups[2].Value, new CultureInfo("en-US")), + Type = match.Groups[3].Value switch + { + "mV" => TrackType.mV, + "V" => TrackType.V, + "RPM" => TrackType.RPM, + "C" => TrackType.C, + _ => TrackType.Unknown, + } + }); + } + } + + return parsedTemperatures.AsReadOnly(); + } +} + diff --git a/ProjectMakoto/Util/Clients/SystemMonitor/RamUsage.cs b/ProjectMakoto/Util/Clients/SystemMonitor/RamUsage.cs new file mode 100644 index 00000000..e889e557 --- /dev/null +++ b/ProjectMakoto/Util/Clients/SystemMonitor/RamUsage.cs @@ -0,0 +1,88 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util; + +public sealed class MemoryMetrics +{ + public double Total; + public double Used; + public double Free; +} + +public sealed class MemoryMetricsClient +{ + public static MemoryMetrics GetMetrics() + => IsUnix() ? GetUnixMetrics() : GetWindowsMetrics(); + + private static bool IsUnix() + => RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + private static MemoryMetrics GetWindowsMetrics() + { + var output = ""; + + var info = new ProcessStartInfo + { + FileName = "wmic", + Arguments = "OS get FreePhysicalMemory,TotalVisibleMemorySize /Value", + RedirectStandardOutput = true + }; + + using (var process = Process.Start(info)) + { + output = process.StandardOutput.ReadToEnd(); + } + + var lines = output.Trim().Split("\n"); + var freeMemoryParts = lines[0].Split("=", StringSplitOptions.RemoveEmptyEntries); + var totalMemoryParts = lines[1].Split("=", StringSplitOptions.RemoveEmptyEntries); + + var metrics = new MemoryMetrics + { + Total = Math.Round(double.Parse(totalMemoryParts[1]) / 1024, 0), + Free = Math.Round(double.Parse(freeMemoryParts[1]) / 1024, 0) + }; + metrics.Used = metrics.Total - metrics.Free; + + return metrics; + } + + private static MemoryMetrics GetUnixMetrics() + { + var output = ""; + + var info = new ProcessStartInfo("free -m") + { + FileName = "/bin/bash", + Arguments = "-c \"free -m\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + UseShellExecute = false + }; + + using (var process = Process.Start(info)) + { + output = process.StandardOutput.ReadToEnd(); + } + + var lines = output.Split("\n"); + var memory = lines[1].Split(" ", StringSplitOptions.RemoveEmptyEntries); + + var metrics = new MemoryMetrics + { + Total = double.Parse(memory[1]), + Used = double.Parse(memory[2]), + Free = double.Parse(memory[3]) + }; + + return metrics; + } +} diff --git a/ProjectMakoto/Util/Clients/ThreadJoiner/ThreadJoinClient.cs b/ProjectMakoto/Util/Clients/ThreadJoiner/ThreadJoinClient.cs new file mode 100644 index 00000000..5dfc229b --- /dev/null +++ b/ProjectMakoto/Util/Clients/ThreadJoiner/ThreadJoinClient.cs @@ -0,0 +1,70 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util; + +public sealed class ThreadJoinClient +{ + internal ThreadJoinClient() + { + this.QueueHandler(); + } + + ~ThreadJoinClient() + { + this._disposed = true; + } + + bool _disposed = false; + + internal readonly Dictionary Queue = new(); + + private void QueueHandler() + { + _ = Task.Run(async () => + { + while (!this._disposed) + { + if (this.Queue.Count == 0) + { + Thread.Sleep(100); + continue; + } + + var b = this.Queue.First(); + + try + { + await b.Value.JoinAsync(); + + lock (this.Queue) + { + _ = this.Queue.Remove(b.Key); + } + } + finally + { + await Task.Delay(1000); + } + } + }); + } + + public async Task JoinThread(DiscordThreadChannel channel) + { + lock (this.Queue) + { + if (this.Queue.ContainsKey(channel.Id)) + return; + + this.Queue.Add(channel.Id, channel); + return; + } + } +} diff --git a/ProjectMakoto/Util/Clients/ThreadJoiner/ThreadJoinExtensions.cs b/ProjectMakoto/Util/Clients/ThreadJoiner/ThreadJoinExtensions.cs new file mode 100644 index 00000000..46fe2529 --- /dev/null +++ b/ProjectMakoto/Util/Clients/ThreadJoiner/ThreadJoinExtensions.cs @@ -0,0 +1,16 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util; + +internal static class ThreadJoinExtensions +{ + public static void JoinWithQueue(this DiscordThreadChannel channel, ThreadJoinClient client) + => _ = client.JoinThread(channel); +} diff --git a/ProjectMakoto/Util/Clients/TokenInvalidatorRepository.cs b/ProjectMakoto/Util/Clients/TokenInvalidatorRepository.cs new file mode 100644 index 00000000..7ff6bfec --- /dev/null +++ b/ProjectMakoto/Util/Clients/TokenInvalidatorRepository.cs @@ -0,0 +1,156 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util; + +public class TokenInvalidatorRepository : RequiresBotReference +{ + internal TokenInvalidatorRepository(Bot bot) : base(bot) + { + this.SyncRepository(); + } + + private void SyncRepository() + { + _ = new Func(() => + { + this.SyncRepository(); + return Task.CompletedTask; + }).CreateScheduledTask(DateTime.UtcNow.AddMinutes(30)); + + _ = Task.Run(this.Pull); + } + + public (bool, FileInfo?) SearchForString(string searchQuery, string? startDirectory = null) + { + startDirectory ??= $"GitHub/{this.Bot.status.LoadedConfig.Secrets.Github.TokenLeakRepo}/"; + + foreach (var directory in Directory.GetDirectories(startDirectory)) + { + (var found, var fileInfo) = this.SearchForString(searchQuery, directory); + + if (found) + return (true, fileInfo); + } + + foreach (var file in Directory.GetFiles(startDirectory)) + { + var fileContent = File.ReadAllText(file); + + if (fileContent.Contains(searchQuery)) + return (true, new FileInfo(file)); + } + + return (false, null); + } + + bool PullRunning = false; + + public async Task Pull() + { + try + { + if (this.PullRunning) return; + + this.PullRunning = true; + + if (!this.ExistsOnPath("git")) + { + Log.Warning("Git was not found, cannot sync token invalidator repository."); + return; + } + + if (!Directory.Exists($"GitHub/{this.Bot.status.LoadedConfig.Secrets.Github.TokenLeakRepo}")) + { + _ = Directory.CreateDirectory("GitHub"); + + _ = Process.Start(new ProcessStartInfo() + { + FileName = "git", + Arguments = $"clone https://github.com/{this.Bot.status.LoadedConfig.Secrets.Github.TokenLeakRepoOwner}/{this.Bot.status.LoadedConfig.Secrets.Github.TokenLeakRepo}.git", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = "GitHub/" + }); + } + + var fetch = Process.Start(new ProcessStartInfo() + { + FileName = "git", + Arguments = $"fetch", + WorkingDirectory = $"GitHub/{this.Bot.status.LoadedConfig.Secrets.Github.TokenLeakRepo}/" + }); + await fetch.WaitForExitAsync(); + + if (fetch.ExitCode != 0) + { + Log.Error("Git fetch exited with a non-zero exit code."); + return; + } + + var pull = Process.Start(new ProcessStartInfo() + { + FileName = "git", + Arguments = $"pull", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = $"GitHub/{this.Bot.status.LoadedConfig.Secrets.Github.TokenLeakRepo}/" + }); + pull.BeginOutputReadLine(); + pull.BeginErrorReadLine(); + + var pullOutput = ""; + + pull.OutputDataReceived += (e, s) => + { + pullOutput += s.Data; + }; + + await pull.WaitForExitAsync(); + + if (pull.ExitCode != 0) + { + Log.Error("Git pull exited with a non-zero exit code."); + return; + } + + if (!pullOutput.Contains("Already up to date.", StringComparison.InvariantCultureIgnoreCase)) + Log.Information("Updated {TokenLeakRepo} repository.", this.Bot.status.LoadedConfig.Secrets.Github.TokenLeakRepo); + else + Log.Debug("{TokenLeakRepo} repository already up to date.", this.Bot.status.LoadedConfig.Secrets.Github.TokenLeakRepo); + } + finally + { + this.PullRunning = false; + } + } + + private bool ExistsOnPath(string fileName) + => this.GetFullPath(fileName) != null; + + private string GetFullPath(string fileName) + { + if (Environment.OSVersion.Platform == PlatformID.Win32NT && !fileName.EndsWith(".exe")) + fileName += ".exe"; + + if (File.Exists(fileName)) + return Path.GetFullPath(fileName); + + var values = Environment.GetEnvironmentVariable("PATH"); + foreach (var path in values.Split(Path.PathSeparator)) + { + var fullPath = Path.Combine(path, fileName); + if (File.Exists(fullPath)) + return fullPath; + } + return null; + } +} diff --git a/ProjectMakoto/Util/CommandConverters/CustomArgumentConverter.cs b/ProjectMakoto/Util/CommandConverters/CustomArgumentConverter.cs new file mode 100644 index 00000000..5bb649ae --- /dev/null +++ b/ProjectMakoto/Util/CommandConverters/CustomArgumentConverter.cs @@ -0,0 +1,41 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util; + +internal sealed class CustomArgumentConverter +{ + internal sealed class BoolConverter : IArgumentConverter + { + public async Task> ConvertAsync(string value, CommandContext ctx) + { + await Task.Delay(1); + + if (value.ToLower() is "true" or "y" or "enable" or "allow" or "on") + return true; + else if (value.ToLower() is "false" or "n" or "disable" or "disallow" or "off") + return false; + + throw new Exception($"Invalid Argument"); + } + } + + internal sealed class AttachmentConverter : IArgumentConverter + { + public async Task> ConvertAsync(string value, CommandContext ctx) + { + await Task.Delay(1); + + if (!ctx.Message.Attachments?.Any() ?? true) + throw new Exception("No attachment"); + + return ctx.Message.Attachments[0]; + } + } +} diff --git a/ProjectMakoto/Util/ExperienceHandler.cs b/ProjectMakoto/Util/ExperienceHandler.cs new file mode 100644 index 00000000..5e6a5426 --- /dev/null +++ b/ProjectMakoto/Util/ExperienceHandler.cs @@ -0,0 +1,240 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util; + +internal sealed class ExperienceHandler : RequiresTranslation +{ + internal ExperienceHandler(Bot _bot) : base(_bot) + { + } + + Translations.events.experience tKey + => this.Bot.LoadedTranslations.Events.Experience; + + private Dictionary LevelCache = new(); + + internal int CalculateMessageExperience(DiscordMessage message) + { + if (message.Content.Length > 0) + { + if (Regex.IsMatch(message.Content, @"^(-|>>|;;|\$|\!|\!d|owo |/)")) + return 0; + } + + var Points = 1; + + if (message.ReferencedMessage is not null) + Points += 2; + + if (message.Attachments is not null && string.IsNullOrWhiteSpace(message.Content)) + Points -= 1; + + if (RegexTemplates.Url.IsMatch(message.Content)) + { + var ModifiedString = RegexTemplates.Url.Replace(message.Content, ""); + + if (ModifiedString.Length > 10) + Points += 1; + + if (ModifiedString.Length > 25) + Points += 1; + + if (ModifiedString.Length > 50) + Points += 1; + + if (ModifiedString.Length > 75) + Points += 1; + } + else + { + if (message.Content.Length > 10) + Points += 1; + + if (message.Content.Length > 25) + Points += 1; + + if (message.Content.Length > 50) + Points += 1; + + if (message.Content.Length > 75) + Points += 1; + } + + return Points; + } + + internal async Task ModifyExperience(ulong user, DiscordGuild guild, DiscordChannel channel, int Amount) => await this.ModifyExperience(await guild.GetMemberAsync(user), guild, channel, Amount); + internal async Task ModifyExperience(DiscordUser user, DiscordGuild guild, DiscordChannel channel, int Amount) => await this.ModifyExperience(await user.ConvertToMember(guild), guild, channel, Amount); + + internal async Task ModifyExperience(DiscordMember user, DiscordGuild guild, DiscordChannel channel, int Amount) + { + if (user.IsBot) + return; + + if (!this.Bot.Guilds[guild.Id].Experience.UseExperience) + return; + + if (this.Bot.Guilds[guild.Id].Members[user.Id].Experience.Points is > (long.MaxValue - 10000) or < (long.MinValue + 10000)) + { + Log.Warning("Member '{User}' on '{Guild}' is within 10000 points of the experience limit. Resetting.", user.Id, guild.Id); + this.Bot.Guilds[guild.Id].Members[user.Id].Experience.Points = 1; + } + + this.Bot.Guilds[guild.Id].Members[user.Id].Experience.Points += Amount; + + var PreviousLevel = this.Bot.Guilds[guild.Id].Members[user.Id].Experience.Level; + + this.CheckExperience(user.Id, guild); + + if (this.Bot.Guilds[guild.Id].Members[user.Id].Experience.Level != PreviousLevel && channel != null && channel.Type is ChannelType.Text or ChannelType.PublicThread or ChannelType.PrivateThread) + { + DiscordEmbedBuilder embed = null; + + if (this.Bot.Guilds[guild.Id].Members[user.Id].Experience.Level > PreviousLevel) + { + var build = this.Bot.Guilds[guild.Id].Members[user.Id].Experience.Level - PreviousLevel is 1 + ? $":stars: {this.tKey.GainedLevel.Get(this.Bot.Guilds[guild.Id]).Build(new TVar("User", user.Mention), new TVar("Count", 1))}\n" + + $"{this.tKey.NewLevel.Get(this.Bot.Guilds[guild.Id]).Build(new TVar("Level", this.Bot.Guilds[guild.Id].Members[user.Id].Experience.Level))}" + : $":stars: {this.tKey.GainedLevel.Get(this.Bot.Guilds[guild.Id]).Build(new TVar("User", user.Mention), new TVar("Count", this.Bot.Guilds[guild.Id].Members[user.Id].Experience.Level - PreviousLevel))}\n" + + $"{this.tKey.NewLevel.Get(this.Bot.Guilds[guild.Id]).Build(new TVar("Level", this.Bot.Guilds[guild.Id].Members[user.Id].Experience.Level))}"; + + var delete_delay = 10000; + + if (this.Bot.Guilds[guild.Id].LevelRewards.Any(x => x.Level <= this.Bot.Guilds[guild.Id].Members[user.Id].Experience.Level)) + { + build += "\n\n"; + + foreach (var reward in this.Bot.Guilds[guild.Id].LevelRewards.ToList().Where(x => x.Level <= this.Bot.Guilds[guild.Id].Members[user.Id].Experience.Level)) + { + if (!guild.Roles.ContainsKey(reward.RoleId)) + { + this.Bot.Guilds[guild.Id].LevelRewards = this.Bot.Guilds[guild.Id].LevelRewards.Remove(x => x.RoleId.ToString(), reward); + continue; + } + + if (user.Roles.Any(x => x.Id == reward.RoleId)) + continue; + + delete_delay = 20000; + + await user.GrantRoleAsync(guild.GetRole(reward.RoleId)); + + build += $"`{reward.Message.Replace("##Role##", $"{guild.GetRole(reward.RoleId).Name}").SanitizeForCode()}`\n"; + } + } + + embed = new DiscordEmbedBuilder + { + Author = new DiscordEmbedBuilder.EmbedAuthor + { + IconUrl = guild.IconUrl, + Name = guild.Name + }, + Title = "", + Description = build, + Timestamp = DateTime.UtcNow, + Color = new DiscordColor("#4287f5"), + Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail { Url = user.AvatarUrl }, + Footer = new DiscordEmbedBuilder.EmbedFooter + { + Text = this.tKey.AutomaticDeletion.Get(this.Bot.Guilds[guild.Id]).Build(new TVar("Seconds", delete_delay / 1000)) + } + }; + + if (channel is not null) + { + _ = channel.SendMessageAsync($"{user.Mention}", embed).ContinueWith(async x => + { + if (!x.IsCompletedSuccessfully) + return; + + await Task.Delay(delete_delay); + _ = (await x).DeleteAsync(); + }); + } + else + { + DiscordMessage msg; + + async Task RunInteraction(DiscordClient s, ComponentInteractionCreateEventArgs e) + { + _ = Task.Run(async () => + { + if (msg.Id == e.Message.Id) + { + this.Bot.DiscordClient.ComponentInteractionCreated -= RunInteraction; + + this.Bot.Users[user.Id].ExperienceUser.DirectMessageOptOut = true; + + _ = await msg.ModifyAsync(new DiscordMessageBuilder().WithEmbed(embed)); + + _ = await (await user.CreateDmChannelAsync()).SendMessageAsync(this.tKey.AutomaticDeletion.Get(this.Bot.Users[user.Id]).Build( + new TVar("Command", "`/levelrewards-optin`"), + new TVar("Bot", guild.CurrentMember.Mention))); + } + }).Add(this.Bot); + } + + IEnumerable discordComponents = new List + { + { new DiscordButtonComponent(ButtonStyle.Secondary, "opt-out-experience-dm", this.tKey.DisableDirectMessages.Get(this.Bot.Users[user.Id]), false, new DiscordComponentEmoji(DiscordEmoji.FromUnicode("⛔"))) }, + }; + + msg = await (await user.CreateDmChannelAsync()).SendMessageAsync(new DiscordMessageBuilder().WithEmbed(embed).AddComponents(discordComponents)); + + this.Bot.DiscordClient.ComponentInteractionCreated += RunInteraction; + + try + { + await Task.Delay(3600000); + embed.Footer.Text += $" • {this.t.Commands.Common.InteractionTimeout.Get(this.Bot.Users[user.Id])}"; + _ = await msg.ModifyAsync(new DiscordMessageBuilder().WithEmbed(embed)); + + this.Bot.DiscordClient.ComponentInteractionCreated -= RunInteraction; + } + catch { } + } + } + } + } + + internal void CheckExperience(ulong user, DiscordGuild guild) + { + var PreviousRequiredRepuationForNextLevel = this.CalculateLevelRequirement(this.Bot.Guilds[guild.Id].Members[user].Experience.Level - 1); + var RequiredRepuationForNextLevel = this.CalculateLevelRequirement(this.Bot.Guilds[guild.Id].Members[user].Experience.Level); + + while (RequiredRepuationForNextLevel <= this.Bot.Guilds[guild.Id].Members[user].Experience.Points) + { + this.Bot.Guilds[guild.Id].Members[user].Experience.Level++; + + PreviousRequiredRepuationForNextLevel = this.CalculateLevelRequirement(this.Bot.Guilds[guild.Id].Members[user].Experience.Level - 1); + RequiredRepuationForNextLevel = this.CalculateLevelRequirement(this.Bot.Guilds[guild.Id].Members[user].Experience.Level); + } + + while (PreviousRequiredRepuationForNextLevel >= this.Bot.Guilds[guild.Id].Members[user].Experience.Points) + { + this.Bot.Guilds[guild.Id].Members[user].Experience.Level--; + + PreviousRequiredRepuationForNextLevel = this.CalculateLevelRequirement(this.Bot.Guilds[guild.Id].Members[user].Experience.Level - 1); + } + } + + internal long CalculateLevelRequirement(long Level) + { + if (!this.LevelCache.TryGetValue(Level, out var value)) + { + var v = (long)Math.Ceiling(Math.Pow((double)Level, 1.60) * 92); + value = v; + this.LevelCache.TryAdd(Level, value); + } + + return value; + } +} diff --git a/ProjectMakoto/Util/Extensions/DiscordExtensions.cs b/ProjectMakoto/Util/Extensions/DiscordExtensions.cs new file mode 100644 index 00000000..c48c6caf --- /dev/null +++ b/ProjectMakoto/Util/Extensions/DiscordExtensions.cs @@ -0,0 +1,824 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using Serilog.Events; + +namespace ProjectMakoto.Util; + +public static class DiscordExtensions +{ + private static string? LoadedHtml = null; + + public static IReadOnlyDictionary GetGuilds(this DiscordShardedClient client) + { + var Guilds = new Dictionary(); + + foreach (var shard in client.ShardClients) + foreach (var guild in shard.Value.Guilds) + Guilds.Add(guild.Key, guild.Value); + + return Guilds.GroupBy(x => x.Key).Select(x => x.First()).ToDictionary().AsReadOnly(); + } + + public static DiscordClient? GetFirstShard(this DiscordShardedClient client) + => client.ShardClients.FirstOrDefault(_ => true, new KeyValuePair(0, null)).Value; + + public static string ConvertToText(this DiscordMessage msg) + { + return ($"{(msg.Content?.Length > 0 ? msg.Content : string.Empty)}" + + $"{(msg.Attachments?.Count > 0 ? $"\n## _Attachments_\n{string.Join("\n", msg.Attachments.Select(x => $"[{x.Filename.FullSanitize()}]({x.Url})"))}" : string.Empty)}" + + $"{(msg.Embeds?.Count > 0 ? $"\n## _Embeds_\n{string.Join("\n", msg.Embeds.Select(embed => + { + return ($"{(embed.Title?.Length > 0 ? $"\n### {embed.Title}" : string.Empty)}" + + $"{(embed.Author?.Name?.Length > 0 ? $"\n### {embed.Author.Name.FullSanitize()}" : string.Empty)}" + + $"{(embed.Description?.Length > 0 ? $"\n{embed.Description}" : string.Empty)}" + + $"{(embed.Fields?.Count > 0 ? $"\n\n{string.Join("\n", embed.Fields.Select(field => $"**{field.Name.FullSanitize()}**\n{field.Value}"))}" : string.Empty)}" + + $"{(embed.Footer?.Text?.Length > 0 ? $"\n\n_{embed.Footer.Text.FullSanitize()}_" : string.Empty)}").TrimStart(); + }))}" : string.Empty)}").TrimStart(); + } + + public static string GenerateHtmlFromMessages(this IEnumerable messages, Bot bot) + { + var sanitizer = new Ganss.Xss.HtmlSanitizer(new() + { + AllowedSchemes = new SortedSet { "http", "https" }, + }); + + string Sanitize(string? str) + { + if (str is null) + return null; + + return sanitizer.Sanitize(str.Replace("<", "<").Replace(">", ">")); + } + + LoadedHtml ??= File.ReadAllText("Assets/DiscordMessages.html"); + + var currentFieldIndex = 0; + int GetFieldIndex(bool inline) + { + if (!inline) + return 1; + + currentFieldIndex++; + return currentFieldIndex; + } + + var messageStrings = messages.OrderBy(x => x.Id.GetSnowflakeTime().Ticks).Select(msg => + { + var messageBuilder = + $"" + + $"{(msg.ReferencedMessage is not null ? + $"{Sanitize(msg.ReferencedMessage.Content.TruncateWithIndication(100)) + .ConvertMarkdownToHtml(bot)}" + + $"" : "")}" + + $"{Sanitize(msg.Content).ConvertMarkdownToHtml(bot)}" + + $"{string.Join("", msg.Embeds?.Select(embed => + { + currentFieldIndex = 0; + + string? videoId = null; + + if (embed.Provider?.Name == "YouTube") + { + try + { + videoId = RegexTemplates.YouTubeUrl.Match(msg.Content).Groups[5].Value; + } + catch { } + } + + if (msg.Flags?.HasMessageFlag(MessageFlags.SuppressedEmbeds) ?? false) + return ""; + + if (embed.Type is "image" && (embed.Provider is null || embed.Provider.Name.IsNullOrWhiteSpace())) + { + return $""; + } + + if (embed.Type is "video" && (embed.Provider is null || embed.Provider.Name.IsNullOrWhiteSpace())) + { + return $""; + } + + return $"" + + $"{((embed.Description?.Length > 0 && videoId is null) ? $"{Sanitize(embed.Description).ConvertMarkdownToHtml(bot, true)}" : "")}" + + $"{(embed.Fields?.Count > 0 ? $"{string.Join("", embed.Fields.Select(field => + { + if (!field.Inline) + currentFieldIndex = 0; + + return $"{Sanitize(field.Value).ConvertMarkdownToHtml(bot, true)}"; + }))}" : "")}" + + $"{(embed.Footer is not null ? $"" + + $"{Sanitize(embed.Footer?.Text)}" : "")}" + + $""; + }))}" + + $"{string.Join("", msg.Attachments?.Select(x => + { + var tempUrl = x.Url.TruncateAt(true, '?'); + var type = string.Empty; + var alt = x.Description; + + if (x.Url.EndsWith(".jpg", StringComparison.InvariantCultureIgnoreCase) || + x.Url.EndsWith(".jpeg", StringComparison.InvariantCultureIgnoreCase) || + x.Url.EndsWith(".png", StringComparison.InvariantCultureIgnoreCase) || + x.Url.EndsWith(".webp", StringComparison.InvariantCultureIgnoreCase) || + x.Url.EndsWith(".gifv", StringComparison.InvariantCultureIgnoreCase) || + x.Url.EndsWith(".gif", StringComparison.InvariantCultureIgnoreCase)) + type = "image"; + else if (x.Url.EndsWith(".webm", StringComparison.InvariantCultureIgnoreCase) || + x.Url.EndsWith(".mp4", StringComparison.InvariantCultureIgnoreCase)) + type = "video"; + else if (x.Url.EndsWith(".wav", StringComparison.InvariantCultureIgnoreCase) || + x.Url.EndsWith(".ogg", StringComparison.InvariantCultureIgnoreCase) || + x.Url.EndsWith(".mp3", StringComparison.InvariantCultureIgnoreCase)) + type = "audio"; + else + { + type = "file"; + alt = tempUrl[(tempUrl.LastIndexOf('/') + 1)..]; + } + + return $""; + }))}" + + $"{(msg.Components?.Count > 0 ? $"{string.Join("", msg.Components.OfType().Select(c1 => + { + return $"{string.Join("", c1.Components.Select(c2 => + { + if (c2.Type is not ComponentType.Button) + return string.Empty; + + if (c2 is DiscordLinkButtonComponent linkButton) + return $"" + + $"{Sanitize(linkButton.Label)}" + + $""; + + var button = c2 as DiscordButtonComponent; + + return $"" + + $"{Sanitize(button.Label)}" + + $""; + }))}"; + }))}" : "")}" + + $"{string.Join("", msg.Stickers?.Select(x => + { + var tempUrl = x.Url.TruncateAt(true, '?'); + var type = "image"; + var alt = x.Description; + + return $""; + }))}" + + $""; + + return messageBuilder; + }).ToArray(); + + if (messageStrings.Length == 0) + return string.Empty; + + return LoadedHtml + .Replace("<--! RawMessages -->", Uri.EscapeDataString(JsonConvert.SerializeObject(messages.OrderBy(x => x.Id.GetSnowflakeTime().Ticks), new JsonSerializerSettings + { + Formatting = Formatting.Indented, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + Error = (serializer, err) => + { + Log.Error(err.ErrorContext.Error, "Failed to serialize member '{member}' at '{path}'", err.ErrorContext.Member, err.ErrorContext.Path); + err.ErrorContext.Handled = true; + }, + }))) + .Replace("<--! RawMessageFileName -->", $"{Guid.NewGuid()}.txt") + .Replace("", "Chat History") + .Replace("", messages.Count()) + .Replace("", $"{messages.First().Channel.GetIcon()}{Sanitize(messages.First().Channel.Name)} ({messages.First().Channel.Id})") + .Replace("", $"{Sanitize(messages.First().Channel.Guild.Name)} ({messages.First().Channel.Guild.Id})") + .Replace("", DateTime.UtcNow.ToString()) + .Replace("", bot.DiscordClient.CurrentUser.GetUsernameWithIdentifier()) + .Replace("", string.Join("\n", messageStrings)); + } + + public static string ConvertMarkdownToHtml(this string? md, Bot bot, bool isEmbed = false) + { + if (md.IsNullOrWhiteSpace()) + return md; + + md = md.ReplaceLineEndings("\n"); + + if (isEmbed) + md = string.Join("\n", md.Split("\n").Select(x => $"
{x}
")); + + md = Regex.Replace(md, @"(? + { + return $"{e.Groups[1].Value + .Replace("*", "\\*").Replace("_", "\\_").Replace(">", "\\>").Replace("<", "\\<").Replace("~", "\\~").Replace("`", "\\`").Replace("|", "\\|").Replace(" ", " ")}"; + }, RegexOptions.Compiled); + + md = Regex.Replace(md, @"(? + { + var lang = ""; + + if (e.Groups[1].Success) + lang = e.Groups[1].Value; + + return $"
{e.Groups[2].Value.Replace(" ", " ")}
"; + }, RegexOptions.Compiled | RegexOptions.Multiline); + + md = Regex.Replace(md, @"(? + { + return $"{e.Groups[1].Value + .Replace("*", "\\*").Replace("_", "\\_").Replace(">", "\\>").Replace("<", "\\<").Replace("~", "\\~").Replace("`", "\\`").Replace("|", "\\|")}"; + }, RegexOptions.Compiled); + + md = Regex.Replace(md, @"(? + { + return $"{e.Groups[1].Value}"; + }, RegexOptions.Compiled); + + md = Regex.Replace(md, @"(? + { + return $"{e.Groups[1].Value}"; + }, RegexOptions.Compiled); + + md = Regex.Replace(md, @"(? + { + return $"{e.Groups[1].Value}"; + }, RegexOptions.Compiled); + + md = Regex.Replace(md, @"(? + { + return $"{e.Groups[1].Value}"; + }, RegexOptions.Compiled); + + md = Regex.Replace(md, @"(? + { + return $"{e.Groups[1].Value}"; + }, RegexOptions.Compiled); + + md = Regex.Replace(md, @"^(? + { + return $"{e.Groups[1].Value}"; + }, RegexOptions.Compiled | RegexOptions.Multiline); + + md = Regex.Replace(md, @"(? + { + return $""; + }, RegexOptions.Compiled); + + md = Regex.Replace(md, @"(? + { + return $"{e.Groups[1].Value}"; + }, RegexOptions.Compiled); + + md = Regex.Replace(md, @"(<)?(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=;]*))", (e) => + { + var url = e.Groups[2].Value; + + if ((e.Groups[1]?.Success ?? false) && url.Contains(">")) + url = url[..url.IndexOf(">")]; + + return $"{url}"; + }, RegexOptions.Compiled); + + md = Regex.Replace(md, @"(? + { + try + { + return $"{bot.DiscordClient!.GetFirstShard().GetUserAsync(e.Groups[1].Value.ToUInt64()).GetAwaiter().GetResult().GetUsername()}"; + } + catch (Exception) + { + return $"@{e.Groups[1].Value}"; + } + }, RegexOptions.Compiled); + + md = Regex.Replace(md, @"(? + { + try + { + var channel = bot.DiscordClient!.GetFirstShard().GetChannelAsync(e.Groups[1].Value.ToUInt64()).GetAwaiter().GetResult(); + var type = channel.Type switch + { + ChannelType.Voice => "voice", + ChannelType.Stage => "voice", + ChannelType.Forum => "forum", + ChannelType.GuildMedia => "forum", + ChannelType.PublicThread => "thread", + ChannelType.PrivateThread => "thread", + ChannelType.NewsThread => "thread", + _ => "channel" + }; + + + return $"{channel.Name}"; + } + catch (Exception) + { + return $"@{e.Groups[1].Value}"; + } + }, RegexOptions.Compiled); + + md = Regex.Replace(md, @"(? + { + var url = $"https://cdn.discordapp.com/emojis/{e.Groups[3].Value}.{(e.Groups[1].Success ? "gif" : "png")}"; + + return $""; + }, RegexOptions.Compiled); + + md = Regex.Replace(md, @"(? + { + if (!DiscordEmoji.TryFromName(bot.DiscordClient.GetFirstShard(), e.Value, false, false, out var emoji)) + return e.Value; + else + try + { + return $"{emoji.UnicodeEmoji}"; + } + catch (Exception) + { + return e.Value; + } + }, RegexOptions.Compiled); + + md = md.Replace("\\*", "*"); + md = md.Replace("\\_", "_"); + md = md.Replace("\\>", ">"); + md = md.Replace("\\<", "<"); + md = md.Replace("\\~", "~"); + md = md.Replace("\\`", "`"); + md = md.Replace("\\|", "|"); + + return md; // .ReplaceLineEndings("
") + } + + public static Permissions[] GetEnumeration(this Permissions perms) + => Enum.GetValues(perms.GetType()).Cast().Where(x => perms.HasFlag(x)).Select(x => (Permissions)x.ToInt64()).ToArray(); + + public static Guild GetDbEntry(this DiscordGuild guild, Bot bot) + => bot.Guilds[guild.Id]; + + public static User GetDbEntry(this DiscordUser user, Bot bot) + => bot.Users[user.Id]; + + public static bool HasAnyPermission(this Permissions permissions, params Permissions[] list) + => list.Any(x => permissions.HasPermission(x)); + + public static string GetGuildPrefix(this DiscordGuild guild, Bot bot) + { + try + { + return bot?.Guilds[guild?.Id ?? 0]?.PrefixSettings?.Prefix?.IsNullOrWhiteSpace() ?? true ? ";;" : bot.Guilds[guild.Id].PrefixSettings.Prefix; + } + catch (Exception) + { + return ";;"; + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "DCS0101:[Discord] InExperiment", Justification = "")] + public static string GetUsername(this DiscordUser user) + => user.IsMigrated ? user.GlobalName : user.Username; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "DCS0101:[Discord] InExperiment", Justification = "")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "DCS0102:[Discord] Deprecated", Justification = "")] + public static string GetUsernameWithIdentifier(this DiscordUser user) + => user.IsMigrated ? $"{user.GlobalName} ({user.Username})" : user.UsernameWithDiscriminator; + + public static string ToTranslatedPermissionString(this Permissions perm, Guild guild, Bot _bot) + => GetTranslationObject(perm, _bot) == _bot.LoadedTranslations.Common.MissingTranslation ? perm.ToPermissionString().LogString(LogEventLevel.Warning, "Missing Translation") : GetTranslationObject(perm, _bot).Get(guild); + + public static string ToTranslatedPermissionString(this Permissions perm, DiscordGuild guild, Bot _bot) + => GetTranslationObject(perm, _bot) == _bot.LoadedTranslations.Common.MissingTranslation ? perm.ToPermissionString().LogString(LogEventLevel.Warning, "Missing Translation") : GetTranslationObject(perm, _bot).Get(guild); + + public static string ToTranslatedPermissionString(this Permissions perm, User user, Bot _bot) + => GetTranslationObject(perm, _bot) == _bot.LoadedTranslations.Common.MissingTranslation ? perm.ToPermissionString().LogString(LogEventLevel.Warning, "Missing Translation") : GetTranslationObject(perm, _bot).Get(user); + + public static string ToTranslatedPermissionString(this Permissions perm, DiscordUser user, Bot _bot) + => GetTranslationObject(perm, _bot) == _bot.LoadedTranslations.Common.MissingTranslation ? perm.ToPermissionString().LogString(LogEventLevel.Warning, "Missing Translation") : GetTranslationObject(perm, _bot).Get(user); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "DCS0101:[Discord] InExperiment", Justification = "")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "DCS0103:[Discord] Unreleased", Justification = "")] + private static SingleTranslationKey GetTranslationObject(Permissions perm, Bot _bot) + => perm switch + { + Permissions.None => _bot.LoadedTranslations.Common.Permissions.None, + Permissions.All => _bot.LoadedTranslations.Common.Permissions.All, + Permissions.CreateInstantInvite => _bot.LoadedTranslations.Common.Permissions.CreateInstantInvite, + Permissions.KickMembers => _bot.LoadedTranslations.Common.Permissions.KickMembers, + Permissions.BanMembers => _bot.LoadedTranslations.Common.Permissions.BanMembers, + Permissions.Administrator => _bot.LoadedTranslations.Common.Permissions.Administrator, + Permissions.ManageChannels => _bot.LoadedTranslations.Common.Permissions.ManageChannels, + Permissions.ManageGuild => _bot.LoadedTranslations.Common.Permissions.ManageGuild, + Permissions.AddReactions => _bot.LoadedTranslations.Common.Permissions.AddReactions, + Permissions.ViewAuditLog => _bot.LoadedTranslations.Common.Permissions.ViewAuditLog, + Permissions.PrioritySpeaker => _bot.LoadedTranslations.Common.Permissions.PrioritySpeaker, + Permissions.Stream => _bot.LoadedTranslations.Common.Permissions.Stream, + Permissions.AccessChannels => _bot.LoadedTranslations.Common.Permissions.AccessChannels, + Permissions.SendMessages => _bot.LoadedTranslations.Common.Permissions.SendMessages, + Permissions.SendTtsMessages => _bot.LoadedTranslations.Common.Permissions.SendTtsMessages, + Permissions.ManageMessages => _bot.LoadedTranslations.Common.Permissions.ManageMessages, + Permissions.EmbedLinks => _bot.LoadedTranslations.Common.Permissions.EmbedLinks, + Permissions.AttachFiles => _bot.LoadedTranslations.Common.Permissions.AttachFiles, + Permissions.ReadMessageHistory => _bot.LoadedTranslations.Common.Permissions.ReadMessageHistory, + Permissions.MentionEveryone => _bot.LoadedTranslations.Common.Permissions.MentionEveryone, + Permissions.UseExternalEmojis => _bot.LoadedTranslations.Common.Permissions.UseExternalEmojis, + Permissions.ViewGuildInsights => _bot.LoadedTranslations.Common.Permissions.ViewGuildInsights, + Permissions.UseVoice => _bot.LoadedTranslations.Common.Permissions.UseVoice, + Permissions.Speak => _bot.LoadedTranslations.Common.Permissions.Speak, + Permissions.MuteMembers => _bot.LoadedTranslations.Common.Permissions.MuteMembers, + Permissions.DeafenMembers => _bot.LoadedTranslations.Common.Permissions.DeafenMembers, + Permissions.MoveMembers => _bot.LoadedTranslations.Common.Permissions.MoveMembers, + Permissions.UseVoiceDetection => _bot.LoadedTranslations.Common.Permissions.UseVoiceDetection, + Permissions.ChangeNickname => _bot.LoadedTranslations.Common.Permissions.ChangeNickname, + Permissions.ManageNicknames => _bot.LoadedTranslations.Common.Permissions.ManageNicknames, + Permissions.ManageRoles => _bot.LoadedTranslations.Common.Permissions.ManageRoles, + Permissions.ManageWebhooks => _bot.LoadedTranslations.Common.Permissions.ManageWebhooks, + Permissions.ManageGuildExpressions => _bot.LoadedTranslations.Common.Permissions.ManageGuildExpressions, + Permissions.UseApplicationCommands => _bot.LoadedTranslations.Common.Permissions.UseApplicationCommands, + Permissions.RequestToSpeak => _bot.LoadedTranslations.Common.Permissions.RequestToSpeak, + Permissions.ManageEvents => _bot.LoadedTranslations.Common.Permissions.ManageEvents, + Permissions.ManageThreads => _bot.LoadedTranslations.Common.Permissions.ManageThreads, + Permissions.CreatePublicThreads => _bot.LoadedTranslations.Common.Permissions.CreatePublicThreads, + Permissions.CreatePrivateThreads => _bot.LoadedTranslations.Common.Permissions.CreatePrivateThreads, + Permissions.UseExternalStickers => _bot.LoadedTranslations.Common.Permissions.UseExternalStickers, + Permissions.SendMessagesInThreads => _bot.LoadedTranslations.Common.Permissions.SendMessagesInThreads, + Permissions.StartEmbeddedActivities => _bot.LoadedTranslations.Common.Permissions.StartEmbeddedActivities, + Permissions.ModerateMembers => _bot.LoadedTranslations.Common.Permissions.ModerateMembers, + Permissions.ViewCreatorMonetizationInsights => _bot.LoadedTranslations.Common.Permissions.ViewCreatorMonetizationInsights, + Permissions.UseSoundboard => _bot.LoadedTranslations.Common.Permissions.UseSoundboard, + Permissions.CreateGuildExpressions => _bot.LoadedTranslations.Common.Permissions.CreateGuildExpressions, + Permissions.CreateEvents => _bot.LoadedTranslations.Common.Permissions.CreateEvents, + Permissions.UseExternalSounds => _bot.LoadedTranslations.Common.Permissions.UseExternalSounds, + Permissions.SendVoiceMessages => _bot.LoadedTranslations.Common.Permissions.SendVoiceMessages, + _ => _bot.LoadedTranslations.Common.MissingTranslation, + }; + + public static DiscordEmoji UnicodeToEmoji(this string str) + => DiscordEmoji.FromUnicode(str); + + public static string GetCustomId(this InteractivityResult e) + => e.Result.GetCustomId(); + + public static string GetCustomId(this ComponentInteractionCreateEventArgs e) + => e.Interaction.Data.CustomId; + + public static DiscordComponentEmoji ToComponent(this DiscordEmoji emoji) + => new(emoji); + + public static Task Refetch(this DiscordMessage msg) + => msg.Channel.GetMessageAsync(msg.Id, true); + + public static int GetRoleHighestPosition(this DiscordMember member) + => member is null ? -1 : (member.IsOwner ? 9999 : (!member.Roles.Any() ? 0 : member.Roles.OrderByDescending(x => x.Position).First().Position)); + + public static string GetUniqueDiscordName(this DiscordEmoji emoji) + => $"{emoji.GetDiscordName().Replace(":", "")}:{emoji.Id}"; + + public static DiscordEmoji ToEmote(this bool b, Bot client) + => b ? DiscordEmoji.FromUnicode("✅") : EmojiTemplates.GetError(client); + + public static DiscordEmoji ToPillEmote(this bool? b, Bot client) + => b?.ToPillEmote(client) ?? false.ToPillEmote(client); + + public static DiscordEmoji ToPillEmote(this bool b, Bot client) + => b ? EmojiTemplates.GetPillOn(client) : EmojiTemplates.GetPillOff(client); + + public static string ToEmotes(this long i) + => DigitsToEmotes(i.ToString()); + + public static string ToEmotes(this int i) + => DigitsToEmotes(i.ToString()); + + public static string ToTimestamp(this DateTime dateTime, TimestampFormat format = TimestampFormat.RelativeTime) + => Formatter.Timestamp(dateTime, format); + + public static string ToTimestamp(this DateTimeOffset dateTime, TimestampFormat format = TimestampFormat.RelativeTime) + => Formatter.Timestamp(dateTime, format); + + public static string GetCommandMention(this DiscordClient client, Bot bot, string command) + => (bot.status.LoadedConfig.IsDev ? + client.GetApplicationCommands().GuildCommands.FirstOrDefault(x => x.Key == bot.status.LoadedConfig.Discord.DevelopmentGuild).Value.ToList() : + client.GetApplicationCommands().GlobalCommands.ToList()) + .First(x => x.Name == command).Mention; + + public static IReadOnlyList GetCommandList(this DiscordClient client, Bot bot) + => (bot.status.LoadedConfig.IsDev ? + client.GetApplicationCommands().GuildCommands.FirstOrDefault(x => x.Key == bot.status.LoadedConfig.Discord.DevelopmentGuild).Value : + client.GetApplicationCommands().GlobalCommands); + + public static string GetIcon(this DiscordChannel discordChannel) => discordChannel.Type switch + { + ChannelType.Text => "#", + ChannelType.Voice => "🔊", + ChannelType.Group => "👥", + ChannelType.Private => "👤", + ChannelType.GuildDirectory or ChannelType.Category => "📁", + ChannelType.News => "📣", + ChannelType.Store => "🛒", + ChannelType.NewsThread or ChannelType.PrivateThread or ChannelType.PublicThread => "🗣", + ChannelType.Stage => "🎤", + ChannelType.Forum => "📄", + _ => "❔", + }; + + public static List>? GetEmotes(this string content) + { + if (Regex.IsMatch(content, @"<(a?):([\w]*):(\d*)>", RegexOptions.ExplicitCapture)) + { + var matchCollection = Regex.Matches(content, @"<(a?):([\w]*):(\d*)>"); + return matchCollection.Select>(x => new Tuple(Convert.ToUInt64(x.Groups[3].Value), x.Groups[2].Value, !x.Groups[1].Value.IsNullOrWhiteSpace())).GroupBy, ulong>(x => x.Item1).Select>, Tuple>(y => y.First>()).ToList>(); + } + else + return new List>(); + } + + public static List? GetMentions(this string content) + { + return Regex.IsMatch(content, @"(<@\d*>)") ? Regex.Matches(content, @"(<@\d*>)").Select(x => x.Value).ToList() : (List)null; + } + + public static List> PrepareEmbedFields(this List> list, string startingText = "", string endingText = "") + { + if (startingText.Length > 1024) + throw new Exception("startingText cant be more than 1024 characters"); + + if (endingText.Length > 1024) + throw new Exception("endingText cant be more than 1024 characters"); + + List> fields = new(); + var currentBuild = startingText; + var lastTitle = list.First().Key; + + foreach (var field in list) + { + if (currentBuild.Length + field.Value.Length >= 1024 || field.Key != lastTitle) + { + fields.Add(new KeyValuePair(lastTitle, currentBuild)); + currentBuild = ""; + } + + lastTitle = field.Key; + currentBuild += $"{field.Value}\n"; + } + + if (currentBuild.Length + endingText.Length >= 1024) + { + fields.Add(new KeyValuePair(lastTitle, currentBuild)); + currentBuild = ""; + } + + currentBuild += endingText; + + if (currentBuild.Length >= 0) + { + fields.Add(new KeyValuePair(lastTitle, currentBuild)); + } + + return fields; + } + + public static List PrepareEmbeds(this List> embedFields, DiscordEmbedBuilder template = null, bool InvisibleOnDuplicateTitles = false) + { + template ??= new(); + + List embeds = new(); + + DiscordEmbedBuilder currentBuilder = new(template); + + int CalculateCharacterLimit() + { + var currentCount = (currentBuilder.Title?.Length ?? 0) + + (currentBuilder.Description?.Length ?? 0) + + (currentBuilder.Author?.Name.Length ?? 0) + + (currentBuilder.Footer?.Text.Length ?? 0); + + foreach (var field in currentBuilder.Fields) + currentCount += field.Name.Length + field.Value.Length; + + return currentCount; + } + + foreach (var field in embedFields) + { + if ((currentBuilder.Fields.Any()) && field.Key != (currentBuilder.Fields.LastOrDefault(x => x.Name != "‍", null)?.Name ?? "")) + { + embeds.Add(currentBuilder); + currentBuilder = new(template); + } + + if (CalculateCharacterLimit() + field.Key.Length + field.Value.Length > 6000) + { + embeds.Add(currentBuilder); + currentBuilder = new(template); + } + + if (InvisibleOnDuplicateTitles && currentBuilder.Fields.Any(x => x.Name == field.Key)) + _ = currentBuilder.AddField(new DiscordEmbedField("‍", field.Value)); + else + _ = currentBuilder.AddField(new DiscordEmbedField(field.Key, field.Value)); + } + + embeds.Add(currentBuilder); + return embeds; + } + + public static DiscordEmoji GetClosestColorEmoji(this DiscordColor discordColor, DiscordClient client) + { + Dictionary colorArray = new() + { + { Color.FromArgb(49, 55, 61) , ":black_circle:" }, + { Color.FromArgb(85, 172, 238) , ":blue_circle:" }, + { Color.FromArgb(192, 105, 79) , ":brown_circle:" }, + { Color.FromArgb(120, 177, 89) , ":green_circle:" }, + { Color.FromArgb(244, 144, 12) , ":orange_circle:" }, + { Color.FromArgb(170, 142, 214) , ":purple_circle:" }, + { Color.FromArgb(221, 46, 68) , ":red_circle:" }, + { Color.FromArgb(230, 231, 232) , ":white_circle:" }, + { Color.FromArgb(253, 203, 88) , ":yellow_circle:" }, + }; + + var color = ColorTools.GetClosestColor(colorArray.Select(x => x.Key).ToList(), Color.FromArgb(discordColor.R, discordColor.G, discordColor.B)); + + return DiscordEmoji.FromName(client, colorArray[color]); + } + + public static bool TryGetMessage(this DiscordChannel channel, ulong id, out DiscordMessage discordMessage) + { + try + { + var msg = channel.GetMessageAsync(id).Result; + discordMessage = msg; + return true; + } + catch (DisCatSharp.Exceptions.NotFoundException) + { + discordMessage = null; + return false; + } + catch (DisCatSharp.Exceptions.UnauthorizedException) + { + discordMessage = null; + return false; + } + catch (Exception) + { + discordMessage = null; + return false; + } + } + + public static bool TryParseMessageLink(this string link, out ulong GuildId, out ulong ChannelId, out ulong MessageId) + { + try + { + if (!RegexTemplates.DiscordChannelUrl.IsMatch(link)) + throw new Exception("Not a discord channel url"); + + var processed = link.Remove(0, link.IndexOf("channels/") + 9); + + GuildId = Convert.ToUInt64(processed.Remove(processed.IndexOf('/'), processed.Length - processed.IndexOf('/'))); + processed = processed.Remove(0, processed.IndexOf('/') + 1); + + ChannelId = Convert.ToUInt64(processed.Remove(processed.IndexOf('/'), processed.Length - processed.IndexOf('/'))); + processed = processed.Remove(0, processed.IndexOf('/') + 1); + + MessageId = Convert.ToUInt64(processed); + + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to process channel link"); + + GuildId = 0; + ChannelId = 0; + MessageId = 0; + return false; + } + } + + + + private static string DigitsToEmotes(string str) + { + return str.Replace("0", "0️⃣") + .Replace("1", "1️⃣") + .Replace("2", "2️⃣") + .Replace("3", "3️⃣") + .Replace("4", "4️⃣") + .Replace("5", "5️⃣") + .Replace("6", "6️⃣") + .Replace("7", "7️⃣") + .Replace("8", "8️⃣") + .Replace("9", "9️⃣"); + } + + public static Task ParseStringAsUser(string str, DiscordClient client) + { + if (str.IsDigitsOnly()) + return client.GetUserAsync(UInt64.Parse(str)); + else + { + var reg = RegexTemplates.UserMention.Match(str); + + if (reg.Success) + return client.GetUserAsync(UInt64.Parse(reg.Groups[3].Value)); + } + + throw new ArgumentException(""); + } + + public static ulong[] ParseStringAsIdArray(string str) + { + char[] chars = [' ', ',']; + + var Ids = str + .Split(chars, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(x => + { + var ret = x; + + foreach (var c in chars) + { + ret = ret.Replace(c.ToString(), string.Empty); + } + + return ret; + }); + return Ids.Select(x => UInt64.Parse(x)).ToArray(); + } + + public static async Task ParseStringAsUserArray(string str, DiscordClient client) + { + var Ids = ParseStringAsIdArray(str); + + if (Ids.Length == 0) + throw new ArgumentException(""); + + var Users = new List(); + + foreach (var b in Ids) + if (client.TryGetUser(b, out var user)) + Users.Add(user); + + return Users.ToArray(); + } +} diff --git a/ProjectMakoto/Util/Extensions/GenericExtensions.cs b/ProjectMakoto/Util/Extensions/GenericExtensions.cs new file mode 100644 index 00000000..dbf8bf03 --- /dev/null +++ b/ProjectMakoto/Util/Extensions/GenericExtensions.cs @@ -0,0 +1,377 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Events; + +namespace ProjectMakoto.Util; + +public static class GenericExtensions +{ + public static int IndexOf(this IEnumerable enumerable, T obj) + { + var found = false; + var h = -1; + + foreach (var b in enumerable) + { + var equal = false; + h++; + + if (typeof(T) == typeof(string)) + if ((b as string) == (obj as string)) + equal = true; + + if (b.Equals(obj)) + equal = true; + + if (equal) + { + found = true; + break; + } + } + + + return found ? h : -1; + } + + public static void AddRange(this List list, params T[] items) + => list.AddRange(items); + + public static bool TryGetFileInfo(string fileName, out FileInfo file) + { + if (File.Exists(fileName)) + { + file = new FileInfo(fileName); + return true; + } + + var environmentVariables = Environment.GetEnvironmentVariables().ConvertToDictionary(); + var paths = Environment.OSVersion.Platform switch + { + PlatformID.Win32S or PlatformID.Win32Windows or PlatformID.Win32NT or PlatformID.WinCE => environmentVariables.First(x => x.Key.ToLower() == "path").Value.Split(';'), + PlatformID.Unix => environmentVariables.First(x => x.Key.ToLower() == "path").Value.Split(':'), + _ => throw new NotImplementedException(), + }; + + foreach (var path in paths) + { + var currentFilePath = Path.Combine(path, fileName); + if (File.Exists(currentFilePath)) + { + file = new FileInfo(currentFilePath); + return true; + } + + currentFilePath += ".exe"; + + if (File.Exists(currentFilePath)) + { + file = new FileInfo(currentFilePath); + return true; + } + } + + file = null; + return false; + } + + public static Dictionary ConvertToDictionary(this IDictionary iDic) + { + var dic = new Dictionary(); + var enumerator = iDic.GetEnumerator(); + while (enumerator.MoveNext()) + { + dic[(T1)enumerator.Key] = (T2)enumerator.Value; + } + return dic; + } + + /// + /// Adds an element to the given array and returns a new array. + /// + /// + /// + /// + /// + public static T[] Add(this T[] array, T addObject) + => array.Append(addObject).ToArray(); + + /// + /// Adds a range of elements to the given array and returns a new array. + /// + /// + /// + /// + /// + public static T[] AddRange(this T[] array, IEnumerable addObjects) + => array.Concat(addObjects).ToArray(); + + /// + /// Updates an element in the given array and returns a new array. + /// If element is not in the list, adds it. + /// + /// + /// + /// The predicate to get a unique identifier from . + /// The object to look for and update. + /// + public static T[] Update(this T[] array, Func equalPredicate, T newObject) + => array.Where(x => equalPredicate.Invoke(x) != equalPredicate.Invoke(newObject)).Append(newObject).ToArray(); + + /// + /// + /// Default predicate of .ToString() + /// + public static T[] Update(this T[] array, T newObject) + => Update(array, x => x.ToString(), newObject); + + /// + /// Removes an object from a given array and returns a new array. + /// Does nothing if element is not in the list. + /// + /// + /// + /// The predicate to get a unique identifier from . + /// The object to look for and remove. + /// + public static T[] Remove(this T[] array, Func equalPredicate, T removeObject) + => array.Where(x => equalPredicate.Invoke(x) != equalPredicate.Invoke(removeObject)).ToArray(); + + /// + /// + /// Default predicate of .ToString() + /// + public static T[] Remove(this T[] array, T removeObject) + => Remove(array, x => x.ToString(), removeObject); + + /// + /// Shortens the string to the specified length and adds, by default, a '..' at the end if the string was shortened. + /// + /// + /// + /// + /// + public static string TruncateWithIndication(this string value, int maxLength, string customFinish = "..") + { + return string.IsNullOrEmpty(value) + ? value + : value.Length <= maxLength ? value : $"{value[..(maxLength - customFinish.Length)]}{customFinish}"; + } + + /// + /// Adds data to the Data dictionary and returns the original exception. + /// + /// + /// + /// + /// + public static Exception AddData(this Exception exception, string key, object? data) + { + exception.Data.Add(key, data); + return exception; + } + + + public static bool ContainsTask(this IReadOnlyList? tasks, string type, ulong snowflake, string id) + => tasks.Where(x => + { + if (x.CustomData is not ScheduledTaskIdentifier scheduledTaskIdentifier) + return false; + + if (scheduledTaskIdentifier.Type != type) + return false; + + return scheduledTaskIdentifier.Snowflake == snowflake; + }).Any(x => ((ScheduledTaskIdentifier)x.CustomData).Id == id); + + public static bool EqualsTask(this ScheduledTask? task, string type, ulong snowflake, string id) + { + if (task.CustomData is not ScheduledTaskIdentifier scheduledTaskIdentifier) + return false; + + if (scheduledTaskIdentifier.Type != type) + return false; + + if (scheduledTaskIdentifier.Id != id) + return false; + + return true; + } + + /// + /// Logs a given string and returns it for easier debugging. + /// + /// + /// + /// + /// + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2254:Template should be a static expression", Justification = "")] + public static string LogString(this string str, LogEventLevel lvl, string additionalInfo) + { + switch (lvl) + { + case LogEventLevel.Fatal: + Log.Fatal($"String {{0}} logged: {additionalInfo}", str); + break; + case LogEventLevel.Error: + Log.Error($"String {{0}} logged: {additionalInfo}", str); + break; + case LogEventLevel.Warning: + Log.Warning($"String {{0}} logged: {additionalInfo}", str); + break; + case LogEventLevel.Information: + Log.Information($"String {{0}} logged: {additionalInfo}", str); + break; + case LogEventLevel.Debug: + Log.Debug($"String {{0}} logged: {additionalInfo}", str); + break; + case LogEventLevel.Verbose: + Log.Verbose($"String {{0}} logged: {additionalInfo}", str); + break; + default: + throw new NotImplementedException("The specified log level is not implemented"); + } + return str; + } + + public static string FileSizeToHumanReadable(this int size) + => GetHumanReadableSize((long)size); + + public static string FileSizeToHumanReadable(this uint size) + => GetHumanReadableSize((long)size); + + public static string FileSizeToHumanReadable(this long size) + => GetHumanReadableSize((long)size); + + public static string FileSizeToHumanReadable(this ulong size) + => GetHumanReadableSize((long)size); + + private static string GetHumanReadableSize(this long size) + { + string[] sizes = { "B", "KB", "MB", "GB", "TB" }; + var order = 0; + while (size >= 1024 && order < sizes.Length - 1) + { + order++; + size /= 1024; + } + return String.Format("{0:0.##} {1}", size, sizes[order]); + } + + /// + /// Gets a SHA256 hash of a given string. + /// + /// + /// + public static string GetSHA256(this string value) + { + StringBuilder Sb = new(); + var enc = Encoding.UTF8; + var result = SHA256.HashData(enc.GetBytes(value)); + + foreach (var b in result) + _ = Sb.Append(b.ToString("x2")); + + return Sb.ToString(); + } + + public static string IsValidHexColor(this string str, string Default = "#FFFFFF") + => !str.IsNullOrWhiteSpace() && Regex.IsMatch(str, @"^(#([a-fA-f0-9]{6}))$") ? str : Default; + + public static string ToHex(this DiscordColor c) + => ColorTools.ToHex(c.R, c.G, c.B); + + public static string SanitizeForCode(this string str) + => str.Replace("`", "´"); + + public static string TruncateAt(this string str, params char[] chars) + => str.TruncateAt(false, chars); + + public static string TruncateAt(this string str, params string[] strings) + => str.TruncateAt(false, strings); + + /// + /// Truncates a given string at the first (or last if is ) instance of any of the specified . + /// + /// + /// + /// + /// + public static string TruncateAt(this string str, bool Reverse, params char[] chars) + { + if (!chars.IsNotNullAndNotEmpty() || !chars.Any(x => str.Contains(x))) + return str; + + var indexes = chars.Select(x => new KeyValuePair(x, !Reverse ? str.IndexOf(x) : str.LastIndexOf(x))).ToList(); + + return str[..(!Reverse ? indexes.Min(x => x.Value) : indexes.Max(x => x.Value))]; + } + + + /// + /// Truncates a given string at the first (or last if is ) instance of any of the specified . + /// + /// + /// + /// + /// + public static string TruncateAt(this string str, bool Reverse, params string[] strings) + { + if (!strings.IsNotNullAndNotEmpty() || !strings.Any(x => str.Contains(x))) + return str; + + var indexes = strings.Select(x => new KeyValuePair(x, !Reverse ? str.IndexOf(x) : str.LastIndexOf(x))).ToList(); + + return str[..(!Reverse ? indexes.Min(x => x.Value) : indexes.Max(x => x.Value))]; + } + + /// + /// Fully sanitizes a string. + /// Escapes all markdown, removes all mentions and replaces ` with ´. + /// + /// + /// + public static string FullSanitize(this string str) + { + var proc = str; + + proc = proc.Replace("`", "´"); + + try + { proc = RegexTemplates.UserMention.Replace(proc, ""); } + catch { } + try + { proc = RegexTemplates.ChannelMention.Replace(proc, ""); } + catch { } + + proc = proc.Replace("@everyone", ""); + proc = proc.Replace("@here", ""); + + return Formatter.Sanitize(proc); + } + + /// + /// Creates a new stream of the given string. + /// + /// + /// + public static Stream ToStream(this string s) + { + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + writer.Write(s); + writer.Flush(); + stream.Position = 0; + return stream; + } +} \ No newline at end of file diff --git a/ProjectMakoto/Util/Extensions/InteractionExtensions.cs b/ProjectMakoto/Util/Extensions/InteractionExtensions.cs new file mode 100644 index 00000000..20a18437 --- /dev/null +++ b/ProjectMakoto/Util/Extensions/InteractionExtensions.cs @@ -0,0 +1,25 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util; + +public static class InteractionExtensions +{ + public static string GetModalValueByCustomId(this DiscordInteraction interaction, string customId) + => interaction.Data.Components.First(x => x.CustomId == customId).Value; + + public static Task> WaitForButtonAsync(this SharedCommandContext context, TimeSpan? timeOutOverride = null) + => context.Client.GetInteractivity().WaitForButtonAsync(context.ResponseMessage, context.User, timeOutOverride); + + public static async Task> WaitForButtonAsync(this InteractionContext context, TimeSpan? timeOutOverride = null) + => await context.Client.GetInteractivity().WaitForButtonAsync(await context.GetOriginalResponseAsync(), context.User, timeOutOverride); + + public static async Task> WaitForButtonAsync(this ContextMenuContext context, TimeSpan? timeOutOverride = null) + => await context.Client.GetInteractivity().WaitForButtonAsync(await context.GetOriginalResponseAsync(), context.User, timeOutOverride); +} \ No newline at end of file diff --git a/ProjectMakoto/Util/Extensions/PreMadeEmbedsExtensions.cs b/ProjectMakoto/Util/Extensions/PreMadeEmbedsExtensions.cs new file mode 100644 index 00000000..219dc3ad --- /dev/null +++ b/ProjectMakoto/Util/Extensions/PreMadeEmbedsExtensions.cs @@ -0,0 +1,166 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util; + +public static class PreMadeEmbedsExtensions +{ + public static DiscordEmbedBuilder AsLoading(this DiscordEmbedBuilder b, SharedCommandContext ctx, string CustomText = "", string CustomFooterText = "") + { + b.Author = MakeDefaultAuthor(ctx.Client, CustomText); + b.Author.IconUrl = StatusIndicatorIcons.Loading; + + b.Color = EmbedColors.Processing; + b.Footer = ctx.GenerateUsedByFooter(CustomFooterText); + b.Timestamp = DateTime.UtcNow; + + return b; + } + + public static DiscordEmbedBuilder AsInfo(this DiscordEmbedBuilder b, SharedCommandContext ctx, string CustomText = "", string CustomFooterText = "") + { + b.Author = MakeDefaultAuthor(ctx.Client, CustomText); + + b.Color = EmbedColors.Info; + b.Footer = ctx.GenerateUsedByFooter(CustomFooterText); + b.Timestamp = DateTime.UtcNow; + + return b; + } + + public static DiscordEmbedBuilder AsAwaitingInput(this DiscordEmbedBuilder b, SharedCommandContext ctx, string CustomText = "", string CustomFooterText = "") + { + b.Author = MakeDefaultAuthor(ctx.Client, CustomText); + + b.Color = EmbedColors.AwaitingInput; + b.Footer = ctx.GenerateUsedByFooter(CustomFooterText); + b.Timestamp = DateTime.UtcNow; + + return b; + } + + public static DiscordEmbedBuilder AsError(this DiscordEmbedBuilder b, SharedCommandContext ctx, string CustomText = "", string CustomFooterText = "") + { + b.Author = MakeDefaultAuthor(ctx.Client, CustomText); + b.Author.IconUrl = StatusIndicatorIcons.Error; + + b.Color = EmbedColors.Error; + b.Footer = ctx.GenerateUsedByFooter(CustomFooterText); + b.Timestamp = DateTime.UtcNow; + + return b; + } + + public static DiscordEmbedBuilder AsWarning(this DiscordEmbedBuilder b, SharedCommandContext ctx, string CustomText = "", string CustomFooterText = "") + { + b.Author = MakeDefaultAuthor(ctx.Client, CustomText); + b.Author.IconUrl = StatusIndicatorIcons.Warning; + + b.Color = EmbedColors.Warning; + b.Footer = ctx.GenerateUsedByFooter(CustomFooterText); + b.Timestamp = DateTime.UtcNow; + + return b; + } + + public static DiscordEmbedBuilder AsSuccess(this DiscordEmbedBuilder b, SharedCommandContext ctx, string CustomText = "", string CustomFooterText = "") + { + b.Author = MakeDefaultAuthor(ctx.Client, CustomText); + b.Author.IconUrl = StatusIndicatorIcons.Success; + + b.Color = EmbedColors.Success; + b.Footer = ctx.GenerateUsedByFooter(CustomFooterText); + b.Timestamp = DateTime.UtcNow; + + return b; + } + + public static DiscordEmbedBuilder.EmbedAuthor MakeDefaultAuthor(DiscordClient client, string CustomText = "") => new() + { + Name = $"{(CustomText.IsNullOrWhiteSpace() ? "" : $"{CustomText} • ")}{client.CurrentUser.GetUsername()}", + IconUrl = client.CurrentUser.AvatarUrl + }; + + public static DiscordEmbedBuilder.EmbedFooter GenerateUsedByFooter(this SharedCommandContext ctx, string addText = "", string customIcon = "") + => new() + { + IconUrl = (!customIcon.IsNullOrWhiteSpace() ? customIcon : ctx.User.AvatarUrl), + Text = $"{ctx.Bot.LoadedTranslations.Commands.Common.UsedByFooter.Get(ctx.DbUser).Build(new TVar("User", ctx.User.GetUsernameWithIdentifier()))}{(string.IsNullOrEmpty(addText) ? "" : $" • {addText}")}" + }; + + public static DiscordEmbedBuilder.EmbedFooter GenerateUsedByFooter(this CommandContext ctx, string addText = "", string customIcon = "") + => new() + { + IconUrl = (!customIcon.IsNullOrWhiteSpace() ? customIcon : ctx.User.AvatarUrl), + Text = $"{((Bot)ctx.Services.GetService(typeof(Bot))).LoadedTranslations.Commands.Common.UsedByFooter.Get(ctx.User).Build(new TVar("User", ctx.User.GetUsernameWithIdentifier()))}{(string.IsNullOrEmpty(addText) ? "" : $" • {addText}")}" + }; + + public static async Task SendSyntaxError(this CommandContext ctx, string CustomArguments = "") + { + var embed = new DiscordEmbedBuilder + { + Author = new DiscordEmbedBuilder.EmbedAuthor + { + IconUrl = ctx.Guild.IconUrl, + Name = ctx.Guild.Name + }, + Title = "", + Description = $"**`{ctx.Prefix}{ctx.Command.Name}{CustomArguments}{(ctx.RawArgumentString != "" ? $" {ctx.RawArgumentString.SanitizeForCode().Replace("\\", "")}" : "")}` is not a valid way of using this command.**\nUse it like this instead: `{ctx.Prefix}{ctx.Command.GenerateUsage()}`\n\nArguments wrapped in `[]` are optional while arguments wrapped in `<>` are required.\n**Do not include the brackets when using commands, they're merely an indicator for requirement.**", + Footer = ctx.GenerateUsedByFooter(), + Timestamp = DateTime.UtcNow, + Color = EmbedColors.Error + }; + + if (ctx.Client.GetCommandsNext() + .RegisteredCommands[ctx.Command.Name].Overloads[0].Arguments[0].Type.Name is "DiscordUser" or "DiscordMember") + embed.Description += "\n\n_Tip: Make sure you copied the user id and not a server, channel or message id._"; + + var msg = await ctx.Channel.SendMessageAsync(embed: embed, content: ctx.User.Mention); + + return msg; + } + + public static string GenerateUsage(this Command cmd) + { + var Usage = cmd.Name; + + if (cmd.Overloads.Count > 0) + { + foreach (var b in cmd.Overloads[0].Arguments) + { + Usage += $" "; + + if (b.IsOptional) + Usage += "["; + else + Usage += "<"; + + if (b.Description is not null and not "") + Usage += b.Description; + else + Usage += b.Type.Name; + + if (b.IsOptional) + Usage += "]"; + else + Usage += ">"; + } + + Usage = Usage.Replace("DiscordUser", "@User") + .Replace("DiscordMember", "@Member") + .Replace("DiscordChannel", "#Channel") + .Replace("DiscordRole", "@Role") + .Replace("Boolean", "true/false") + .Replace("Int32", "Number") + .Replace("Int64", "Number") + .Replace("String", "Text"); + } + return Usage.SanitizeForCode(); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Util/Extensions/TranslationUtil.cs b/ProjectMakoto/Util/Extensions/TranslationUtil.cs new file mode 100644 index 00000000..35f5a920 --- /dev/null +++ b/ProjectMakoto/Util/Extensions/TranslationUtil.cs @@ -0,0 +1,179 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +global using ProjectMakoto.Entities.Translation; + +namespace ProjectMakoto.Util; + +public static class TranslationUtil +{ + /// /> + public static string Build(this string str) + => str.Build(false, null); + + /// /> + public static string Build(this string str, params TVar[] vars) + => str.Build(false, vars); + + /// + /// Build a translation string. + /// + /// + /// Whether to embed the string as inline code + /// A list of variables to replace. + /// + public static string Build(this string str, bool Code = false, params TVar[] vars) + { + if (str.IsNullOrEmpty()) + return str; + + if (Code && !str.StartsWith('_')) + str = $"`{str}`"; + else + Code = false; + + vars ??= Array.Empty(); + + foreach (var b in vars) + { + if (b.Replacement is null) + Log.Warning("TVar is null on ValueName {0}", b.ValName); + + var newText = b.Replacement?.ToString() ?? ""; + + if (b.Replacement is EmbeddedLink embeddedLink) + { + newText = $"[{(Code ? $"`{embeddedLink.Text}`" : embeddedLink.Text)}]({embeddedLink.Url})"; + + if (b.Sanitize) + newText = newText.SanitizeForCode(); + + str = str.Replace($"{{{b.ValName}}}", $"`{newText}`"); + continue; + } + + if (newText.StartsWith('<') && newText.EndsWith('>') && Code) + { + if (b.Sanitize) + newText = newText.SanitizeForCode(); + + str = str.Replace($"{{{b.ValName}}}", $"`{newText}`"); + continue; + } + + if (b.Sanitize) + newText = newText.FullSanitize(); + + str = str.Replace($"{{{b.ValName}}}", newText); + } + + if (str.StartsWith("``")) + str = str[1..]; + + if (str.EndsWith("``")) + str = str[..(str.Length - 1)]; + + if (str.StartsWith("`<")) + str = str[1..]; + + if (str.StartsWith("`[") && vars.Any(x => x.Replacement is EmbeddedLink)) + str = str[1..]; + + if (str.EndsWith(">`")) + str = str[..(str.Length - 1)]; + + return str; + } + + /// + public static string Build(this string[] array) + => array.Build(false, false, null); + + /// + public static string Build(this string[] array, params TVar[] vars) + => array.Build(false, false, vars); + + /// + /// Builds a string array into a string, used for MultiTranslationKeys. + /// + /// + /// Whether to prefix and suffix ` on non-empty lines. + /// Whether to make lines prefixing ** bold. + /// + public static string Build(this string[] array, bool Code = false, bool UseBoldMarker = false, params TVar[] Tvars) + => string.Join("\n", array.Select(x => + { + var boldLine = false; + + var y = x; + + if (y.StartsWith("**") && UseBoldMarker) + { + boldLine = true; + y = y.Remove(0, 2); + } + + y = y.Build(Code, Tvars); + + return x.IsNullOrWhiteSpace() ? x : $"{(boldLine ? "**" : "")}{y}{(boldLine ? "**" : "")}"; + })); + + /// + /// Runs Replace on every string in a string array and returns the new array. + /// + /// + /// + /// + /// + public static string[] Replace(this string[] array, string old, object @new) + => array.Select(x => x.Replace(old, @new)).ToArray(); + + /// + /// Calculates maximum character count for given list of translation keys. + /// + /// + /// + /// + public static int CalculatePadding(User user, params SingleTranslationKey[] pairs) + { + var pad = 0; + + foreach (var b in pairs) + { + var length = b.Get(user).Length; + + if (length > pad) + pad = length; + } + + return pad; + } + + public static HumanReadableTimeFormatConfig GetTranslatedHumanReadableConfig(User user, Bot bot, bool MustIncludeAll = false) + => new() + { + DaysString = bot.LoadedTranslations.Common.Time.Days.Get(user), + HoursString = bot.LoadedTranslations.Common.Time.Hours.Get(user), + MinutesString = bot.LoadedTranslations.Common.Time.Minutes.Get(user), + SecondsString = bot.LoadedTranslations.Common.Time.Seconds.Get(user), + MustIncludeMinutes = MustIncludeAll, + MustIncludeSeconds = MustIncludeAll, + }; + + public static HumanReadableTimeFormatConfig GetTranslatedHumanReadableConfig(Guild guild, Bot bot, bool MustIncludeAll = false) + => new() + { + DaysString = bot.LoadedTranslations.Common.Time.Days.Get(guild), + HoursString = bot.LoadedTranslations.Common.Time.Hours.Get(guild), + MinutesString = bot.LoadedTranslations.Common.Time.Minutes.Get(guild), + SecondsString = bot.LoadedTranslations.Common.Time.Seconds.Get(guild), + MustIncludeMinutes = MustIncludeAll, + MustIncludeSeconds = MustIncludeAll, + }; +} diff --git a/ProjectMakoto/Util/Extensions/UserExtensions.cs b/ProjectMakoto/Util/Extensions/UserExtensions.cs new file mode 100644 index 00000000..fa0b4512 --- /dev/null +++ b/ProjectMakoto/Util/Extensions/UserExtensions.cs @@ -0,0 +1,41 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util; + +public static class UserExtensions +{ + public static bool IsTeamOwner(this DiscordMember member, Status _status) + => (member as DiscordUser).IsTeamOwner(_status); + + public static bool IsTeamOwner(this DiscordUser user, Status _status) + { + return _status.TeamOwner == user.Id; + } + + public static bool IsMaintenance(this DiscordMember member, Status _status) + => (member as DiscordUser).IsMaintenance(_status); + + public static bool IsMaintenance(this DiscordUser user, Status _status) + { + return _status.TeamMembers.Contains(user.Id); + } + + public static bool IsAdmin(this DiscordMember member, Status _status) + { + return (member.Roles.Any(x => x.CheckPermission(Permissions.Administrator) == PermissionLevel.Allowed || x.CheckPermission(Permissions.ManageGuild) == PermissionLevel.Allowed)) || + (member.IsMaintenance(_status)) || + member.IsOwner; + } + + public static bool IsDJ(this DiscordMember member, Status _status) + { + return member.IsAdmin(_status) || member.Roles.Any(x => x.Name.ToLower() == "dj"); + } +} diff --git a/ProjectMakoto/Util/Initializers/CommandCompiler.cs b/ProjectMakoto/Util/Initializers/CommandCompiler.cs new file mode 100644 index 00000000..aef1de19 --- /dev/null +++ b/ProjectMakoto/Util/Initializers/CommandCompiler.cs @@ -0,0 +1,518 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using System.Collections.Immutable; +using System.Runtime.Loader; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace ProjectMakoto.Util.Initializers; +internal static class CommandCompiler +{ + internal static List AssemblyReferences = new(); + + internal static async Task<(Assembly compiledCommands, CompilationType Type)[]> BuildCommands(Bot bot, string applicationHash, IEnumerable moduleList, KeyValuePair? plugin = null) + { + var isPlugin = plugin != null; + plugin ??= new KeyValuePair("Built-In", null); + List<(Assembly compiledCommands, CompilationType Type)> assemblyList = new(); + + string currentHash; + + if (plugin.Value.Key != "Built-In") + currentHash = HashingExtensions.ComputeSHA256Hash(plugin.Value.Value.LoadedFile); + else + currentHash = applicationHash; + + if (bot.status.LoadedConfig.CommandCache.TryGetValue(plugin.Value.Key, out var supplierInfo) && + currentHash == supplierInfo.LastKnownHash && + applicationHash == bot.status.LoadedConfig.DontModify.LastKnownHash && + supplierInfo.CompiledCommands.All(x => File.Exists(x.Key)) && + supplierInfo.CompiledCommands.Count != 0) + { + if (isPlugin) + Log.Information("Loading {0} Commands from Plugin from '{1}' ({2}) from compiled assemblies..", + supplierInfo.CompiledCommands.Count, + plugin.Value.Value.Name, + plugin.Value.Value.Version.ToString()); + else + Log.Information("Loading {0} Commands from compiled assemblies..", + supplierInfo.CompiledCommands.Count); + + foreach (var b in supplierInfo.CompiledCommands) + { + AssemblyLoadContext loadContext = new(null); + using var file = new FileStream(b.Key, FileMode.Open, FileAccess.Read); + var assembly = loadContext.LoadFromStream(file); + + assemblyList.Add((assembly, b.Value)); + } + + return assemblyList.ToArray(); + } + + _ = bot.status.LoadedConfig.CommandCache.TryAdd(plugin.Value.Key, new()); + supplierInfo = bot.status.LoadedConfig.CommandCache[plugin.Value.Key]; + supplierInfo.LastKnownHash = currentHash; + + if (supplierInfo.CompiledCommands.Count != 0) + _ = FileExtensions.CleanupFilesAndDirectories(new(), supplierInfo.CompiledCommands.Select(x => x.Key).ToList()); + + supplierInfo.CompiledCommands = new(); + + (string Code, CompilationType Type, string ModuleName)[][] getClassCode() + { + var classHeader = GetFileHeader(); + + string createCodeWithDefaultClass(IEnumerable code, MakotoCommandType supportedType, int? Priority) + { + var inheritType = supportedType switch + { + MakotoCommandType.SlashCommand or MakotoCommandType.ContextMenu => typeof(ApplicationCommandsModule), + MakotoCommandType.PrefixCommand => typeof(BaseCommandModule), + _ => throw new NotImplementedException() + }; + + return $$""" + {{classHeader}} + + {{(Priority is not null ? $"[{typeof(ModulePriorityAttribute).FullName}({Priority})]" : "")}} + public sealed class {{GetUniqueCodeCompatibleName()}} : {{inheritType.FullName}} + { + public {{typeof(Bot).FullName}} _bot { private get; set; } + + {{string.Join("\n\n", code)}} + } + """; + } + + (string Code, CompilationType Type, string ModuleName)[] getModuleDefinition(MakotoModule module) + { + module.Registered = true; + + var rawSlashCommandList = module.Commands + .Where(x => x.SupportedCommandTypes.Contains(MakotoCommandType.SlashCommand)) + .Select(x => getMethodDefinition(x, module, null, MakotoCommandType.SlashCommand)); + + var rawPrefixCommandList = module.Commands + .Where(x => x.SupportedCommandTypes.Contains(MakotoCommandType.PrefixCommand)) + .Select(x => getMethodDefinition(x, module, null, MakotoCommandType.PrefixCommand)); + + var rawContextCommandList = module.Commands + .Where(x => x.SupportedCommandTypes.Contains(MakotoCommandType.ContextMenu)) + .Select(x => getMethodDefinition(x, module, null, MakotoCommandType.ContextMenu)); + + var rawCodeList = new List<(string Code, CompilationType Type, string ModuleName)>(); + + if (rawSlashCommandList.Any()) + rawCodeList.Add((createCodeWithDefaultClass(rawSlashCommandList, MakotoCommandType.SlashCommand, module.Priority), CompilationType.App, module.Name)); + + if (rawContextCommandList.Any()) + rawCodeList.Add((createCodeWithDefaultClass(rawContextCommandList, MakotoCommandType.ContextMenu, module.Priority), CompilationType.App, module.Name)); + + if (rawPrefixCommandList.Any()) + rawCodeList.Add((createCodeWithDefaultClass(rawPrefixCommandList, MakotoCommandType.PrefixCommand, module.Priority), CompilationType.Prefix, module.Name)); + + return rawCodeList.ToArray(); + } + + string getMethodDefinition(MakotoCommand command, MakotoModule module, MakotoCommand? parent, MakotoCommandType supportedType) + { + command.Registered = true; + var TaskName = GetUniqueCodeCompatibleName(); + + if (!command.SupportedCommandTypes.Contains(supportedType)) + return string.Empty; + + string getAttribute() + { + switch (supportedType) + { + case MakotoCommandType.SlashCommand: + if (command.IsGroup) + return $$""" + [{{typeof(SlashCommandGroupAttribute).FullName}}("{{command.Name}}", "{{command.Description}}"{{(command.RequiredPermissions is null ? "" : $", {(long)command.RequiredPermissions}")}}, dmPermission: {{command.AllowPrivateUsage.ToString().ToLower()}}, isNsfw: {{command.IsNsfw.ToString().ToLower()}})] + """; + else + return $$""" + [{{typeof(SlashCommandAttribute).FullName}}("{{command.Name}}", "{{command.Description}}"{{(command.RequiredPermissions is null ? "" : $", {(long)command.RequiredPermissions}")}}, dmPermission: {{command.AllowPrivateUsage.ToString().ToLower()}}, isNsfw: {{command.IsNsfw.ToString().ToLower()}})] + """; + case MakotoCommandType.PrefixCommand: + if (command.IsGroup) + return $$""" + [{{typeof(GroupAttribute).FullName}}("{{command.Name}}"), {{typeof(DescriptionAttribute).FullName}}("{{command.Description}}") + {{(command.Aliases.IsNotNullAndNotEmpty() ? $", {typeof(AliasesAttribute).FullName}({string.Join(", ", command.Aliases.Select(x => $"\"{x}\""))})" : "")}}] + """; + else + return $$""" + [{{typeof(CommandAttribute).FullName}}("{{command.AlternativeName ?? command.Name}}"), {{typeof(DescriptionAttribute).FullName}}("{{command.Description}}") + {{(command.Aliases.IsNotNullAndNotEmpty() ? $", {typeof(AliasesAttribute).FullName}({string.Join(", ", command.Aliases.Select(x => $"\"{x}\""))})" : "")}}] + """; + case MakotoCommandType.ContextMenu: + return $$""" + [{{typeof(ContextMenuAttribute).FullName}}({{typeof(ApplicationCommandType).FullName}}.{{Enum.GetName(typeof(ApplicationCommandType), command.ContextMenuType)}}, "{{command.Name}}", dmPermission: {{command.AllowPrivateUsage.ToString().ToLower()}}, isNsfw: {{command.IsNsfw.ToString().ToLower()}})] + """; + + default: + throw new NotImplementedException(); + } + } + + string getPopulationMethods() + { + var contextType = supportedType switch + { + MakotoCommandType.SlashCommand => typeof(InteractionContext), + MakotoCommandType.PrefixCommand => typeof(CommandContext), + MakotoCommandType.ContextMenu => typeof(ContextMenuContext), + _ => throw new NotImplementedException(), + }; + + return isPlugin ? $$""" + private static {{typeof(Type).FullName}} {{TaskName}}_CommandType { get; set; } + private static {{typeof(MethodInfo).FullName}} {{TaskName}}_CommandMethod { get; set; } + public static void Populate_{{TaskName}}({{typeof(Bot).FullName}} _bot) + { + Log.Debug("Populating execution properties for '{CommandName}':'{taskname}'", "{{command.Name}}","{{TaskName}}"); + {{(parent is null ? $"{TaskName}_CommandType = _bot.PluginCommandModules[\"{plugin.Value.Key}\"].First(x => x.Name == \"{module.Name}\").Commands.First(x => x.Name == \"{command.Name}\").Command;" : $"{TaskName}_CommandType = _bot.PluginCommandModules[\"{plugin.Value.Key}\"].First(x => x.Name == \"{module.Name}\").Commands.First(x => x.Name == \"{parent.Name}\").SubCommands.First(x => x.Name == \"{command.Name}\").Command;")}} + {{TaskName}}_CommandMethod = {{TaskName}}_CommandType.GetMethods().First(x => x.Name == "ExecuteCommand" && x.GetParameters().Any(param => param.ParameterType == typeof({{contextType.FullName}}))); + } + """ : + $$""" + private static {{typeof(Type).FullName}} {{TaskName}}_CommandType { get; set; } + private static {{typeof(MethodInfo).FullName}} {{TaskName}}_CommandMethod { get; set; } + public static void Populate_{{TaskName}}({{typeof(Bot).FullName}} _bot) + { + Log.Debug("Populating execution properties for '{CommandName}':'{taskname}' ({module}, {command})", "{{command.Name}}","{{TaskName}}","{{module.Name}}","{{command.Name}}"); + {{(parent is null ? $"{TaskName}_CommandType = _bot.CommandModules.First(x => x.Name == \"{module.Name}\").Commands.First(x => x.Name == \"{command.Name}\").Command;" : $"{TaskName}_CommandType = _bot.CommandModules.First(x => x.Name == \"{module.Name}\").Commands.First(x => x.Name == \"{parent.Name}\").SubCommands.First(x => x.Name == \"{command.Name}\").Command;")}} + {{TaskName}}_CommandMethod = {{TaskName}}_CommandType.GetMethods().First(x => x.Name == "ExecuteCommand" && x.GetParameters().Any(param => param.ParameterType == typeof({{contextType.FullName}}))); + } + """; + } + + switch (supportedType) + { + case MakotoCommandType.SlashCommand: + if (command.IsGroup) + return $$""" + {{getAttribute()}} + public sealed class {{GetUniqueCodeCompatibleName()}} : {{typeof(ApplicationCommandsModule).FullName}} + { + public {{typeof(Bot).FullName}} _bot { private get; set; } + + {{string.Join("\n\n", command.SubCommands.Select(x => $$""" + {{getMethodDefinition(x, module, command, supportedType)}} + """))}} + } + """; + else + return $$""" + {{getAttribute()}} + public {{typeof(Task).FullName}} {{TaskName}}_Execute({{typeof(InteractionContext).FullName}} ctx{{(command.Overloads?.Length > 0 ? ", " : "")}} + {{string.Join(", ", command.Overloads?.Select(x => $"[{typeof(OptionAttribute).FullName}(\"{x.Name}\", \"{x.Description}\", {(x.AutoCompleteType != null).ToString().ToLower()})" + + $"{(x.ChannelType is not null ? $", {typeof(ChannelTypesAttribute).FullName}(({typeof(ChannelType).FullName}){(int)x.ChannelType})" : "")}" + + $"{(x.MinimumValue is not null ? $", {typeof(MinimumValueAttribute).FullName}({x.MinimumValue})" : "")}" + + $"{(x.MaximumValue is not null ? $", {typeof(MaximumValueAttribute).FullName}({x.MaximumValue})" : "")}" + + $"{(x.AutoCompleteType is not null ? $", {typeof(AutocompleteAttribute).FullName}(typeof({x.AutoCompleteType.FullName + .Replace('+', '.')}))" : "")}" + + $"] {x.Type.Name}{(x.Required ? "" : "?")} {x.Name} {(x.Required ? "" : " = null")}"))}}) + { + try + { + {{typeof(Task).FullName}} t = ({{typeof(Task).FullName}}){{TaskName}}_CommandMethod.Invoke({{typeof(Activator).FullName}}.CreateInstance({{TaskName}}_CommandType), + new {{typeof(object[]).FullName}} + { ctx, _bot, new Dictionary + { + {{string.Join(",\n", command.Overloads?.Select(x => $"{{ \"{x.Name}\", {x.Name} }}"))}} + }, {{command.IsEphemeral.ToString().ToLower()}}, true, false + }); + + t.Add(_bot, ctx); + } + catch ({{typeof(Exception).FullName}} ex) + { + Log.Error(ex, $"Failed to execute plugin's application command"); + } + + return {{typeof(Task).FullName}}.CompletedTask; + } + + {{getPopulationMethods()}} + """; + case MakotoCommandType.PrefixCommand: + if (command.IsGroup) + return $$""" + {{getAttribute()}} + public sealed class {{GetUniqueCodeCompatibleName()}} : {{typeof(BaseCommandModule).FullName}} + { + public {{typeof(Bot).FullName}} _bot { private get; set; } + + {{(command.UseDefaultHelp ? $$""" + + [{{typeof(GroupCommandAttribute).FullName}}, {{typeof(CommandAttribute).FullName}}("help"), {{typeof(DescriptionAttribute).FullName}}("Sends a list of available sub-commands")] + public async {{typeof(Task).FullName}} Help({{typeof(CommandContext).FullName}} ctx) + => {{typeof(PrefixCommandUtil).FullName}}.SendGroupHelp(_bot, ctx, "{{command.Name}}").Add(_bot, ctx); + """ : "")}} + + {{string.Join("\n\n", command.SubCommands.Select(x => $$""" + {{getMethodDefinition(x, module, command, supportedType)}} + """))}} + } + """; + else + return $$""" + {{getAttribute()}} + public {{typeof(Task).FullName}} {{TaskName}}_Execute({{typeof(CommandContext).FullName}} ctx{{(command.Overloads?.Length > 0 ? ", " : "")}}{{string.Join(", ", command.Overloads?.Select(x => $"{(x.UseRemainingString ? $"[{typeof(RemainingTextAttribute).FullName}]" : "")} [{typeof(DescriptionAttribute).FullName}(\"{x.Description}\")] {x.Type.Name}{(x.Required ? "" : "?")} {x.Name} {(x.Required ? "" : " = null")}") ?? [])}}) + { + try + { + {{typeof(Task).FullName}} t = ({{typeof(Task).FullName}}){{TaskName}}_CommandMethod.Invoke({{typeof(Activator).FullName}}.CreateInstance({{TaskName}}_CommandType), + new {{typeof(object[]).FullName}} + { ctx, _bot, new Dictionary + { + {{string.Join(",\n", command.Overloads?.Select(x => $"{{ \"{x.Name}\", {x.Name} }}") ?? [])}} + } + }); + + t.Add(_bot, ctx); + } + catch ({{typeof(Exception).FullName}} ex) + { + Log.Error(ex, $"Failed to execute plugin's command"); + } + + return {{typeof(Task).FullName}}.CompletedTask; + } + + {{getPopulationMethods()}} + """; + case MakotoCommandType.ContextMenu: + return $$""" + {{(!command.AlternativeName.IsNullOrWhiteSpace() ? $"[{typeof(PrefixCommandAlternativeAttribute).FullName}(\"{command.AlternativeName}\")]" : "")}} + {{getAttribute()}} + public {{typeof(Task).FullName}} {{TaskName}}_Execute({{typeof(ContextMenuContext).FullName}} ctx) + { + try + { + {{typeof(Task).FullName}} t = ({{typeof(Task).FullName}}){{TaskName}}_CommandMethod.Invoke({{typeof(Activator).FullName}}.CreateInstance({{TaskName}}_CommandType), + new {{typeof(object[]).FullName}} + { ctx, _bot, new Dictionary + { + {{(command.ContextMenuType == ApplicationCommandType.Message ? "{ \"message\", ctx.TargetMessage }" : "{ \"user\", ctx.TargetMember ?? ctx.TargetUser }")}} + }, {{command.IsEphemeral.ToString().ToLower()}}, true, false + }); + + t.Add(_bot, ctx); + } + catch ({{typeof(Exception).FullName}} ex) + { + Log.Error(ex, $"Failed to execute plugin's command"); + } + + return {{typeof(Task).FullName}}.CompletedTask; + } + + {{getPopulationMethods()}} + """; + + default: + throw new NotImplementedException(); + } + } + + var rawModules = moduleList.Select(x => getModuleDefinition(x)).ToArray(); + + return rawModules; + } + + var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) +#if DEBUG + .WithOptimizationLevel(OptimizationLevel.Debug) +#else + .WithOptimizationLevel(OptimizationLevel.Release) +#endif + .WithDeterministic(true); + + if (isPlugin) + Log.Information("Compiling {0} Commands from Plugin from '{1}' ({2}).", + moduleList.Count(), + plugin.Value.Value.Name, + plugin.Value.Value.Version.ToString()); + else + Log.Information("Compiling {0} Built-In Commands..", + moduleList.Count()); + + + foreach (var modules in getClassCode()) + { + foreach (var classCode in modules) + { + var compilation = CSharpCompilation.Create(CommandCompiler.GetUniqueCodeCompatibleName() + $"_{Regex.Replace($"{classCode.ModuleName}_{Enum.GetName(classCode.Type)}", @"[^a-zA-Z0-9_]", "")}") + .AddSyntaxTrees(SyntaxFactory.ParseSyntaxTree(classCode.Code)) + .AddReferences(AssemblyReferences) + .WithOptions(options); + + var data = new CompilationData(classCode.Type, classCode.Code, moduleList, plugin.Value.Key); + + try + { + using (var stream = new MemoryStream()) + { + var result = compilation.Emit(stream); + if (!result.Success) + { + Log.Error("Failed to emit compilation\n{diagnostics}", + JsonConvert.SerializeObject(result.Diagnostics.Select(x => $"{x.Id}: {x.GetMessage()}: {x.Location}: {data.code[x.Location.SourceSpan.Start..x.Location.SourceSpan.End]}"), Formatting.Indented)); + + Exception exception = new(); + exception.Data.Add("diagnostics", result.Diagnostics); + throw exception; + } + + var assemblyBytes = stream.ToArray(); + var assembly = Assembly.Load(assemblyBytes); + assemblyList.Add((assembly, data.type)); + + _ = Directory.CreateDirectory("CompiledCommands"); + + var path = $"CompiledCommands/{assembly.GetName().Name}.dll"; + using (var fileStream = new FileStream(path, FileMode.Create, FileAccess.ReadWrite)) + { + _ = stream.Seek(0, SeekOrigin.Begin); + await stream.CopyToAsync(fileStream); + await fileStream.FlushAsync(); + + supplierInfo.CompiledCommands.Add(path, data.type); + } + +#if DEBUG + File.WriteAllText($"CompiledCommands/{assembly.GetName().Name}.cs", classCode.Code); +#endif + + Log.Debug("Compiled class with {cmdCount} commands for '{plugin}' of type '{type}'", data.moduleList.Sum(x => x.Commands.Count()), data.Identifier, data.type); + Log.Verbose($"\n{data.code}"); + } + } + catch (Exception ex) + { + ImmutableArray? diagnostics = null; + + try + { + diagnostics = (ImmutableArray)ex.Data["diagnostics"]; + } + catch { } + + Log.Error(ex, "Failed Compilation of class type '{type}'", data.type); + Log.Verbose($"\n{data.code}"); + + await Task.Delay(1000); + + if (diagnostics.HasValue) + { + Console.WriteLine(); + for (var i = 0; i < data.code.Length; i++) + { + var foundDiagnostic = diagnostics.Value.FirstOrDefault(x => i >= x.Location.SourceSpan.Start && i <= x.Location.SourceSpan.End, null); + + if (foundDiagnostic is not null) + switch (foundDiagnostic.Severity) + { + case DiagnosticSeverity.Hidden: + Console.ForegroundColor = ConsoleColor.Gray; + break; + case DiagnosticSeverity.Info: + Console.ForegroundColor = ConsoleColor.Cyan; + break; + case DiagnosticSeverity.Warning: + Console.ForegroundColor = ConsoleColor.Yellow; + break; + case DiagnosticSeverity.Error: + Console.ForegroundColor = ConsoleColor.Red; + break; + default: + break; + } + else + Console.ForegroundColor = ConsoleColor.White; + + Console.Write(data.code[i]); + } + Console.WriteLine(); + } + +#if DEBUG + File.WriteAllText($"CompiledCommands/failed.cs", classCode.Code); +#endif + + _ = Console.ReadLine(); + } + } + } + + return assemblyList.ToArray(); + } + + internal static void RegisterAssemblies(Bot _bot, IReadOnlyDictionary cNext, IReadOnlyDictionary appCommands, Action translationContext, IEnumerable<(Assembly compiledAssembly, CompilationType type)> assemblyList) + { + foreach (var (compiledAssembly, type) in assemblyList) + { + foreach (var parentType in compiledAssembly.GetTypes()) + { + foreach (var method in parentType.GetMethods()) + { + if (method.Name.StartsWith("Populate")) + _ = method.Invoke(null, new object[] { _bot }); + } + + foreach (var subType in parentType.GetNestedTypes()) + { + foreach (var method in subType.GetMethods()) + { + if (method.Name.StartsWith("Populate")) + _ = method.Invoke(null, new object[] { _bot }); + } + } + } + } + + foreach (var (compiledAssembly, type) in assemblyList) + { + switch (type) + { + case CompilationType.Prefix: + cNext.RegisterCommands(compiledAssembly.GetTypes().First(x => x.BaseType == typeof(BaseCommandModule))); + break; + + case CompilationType.App: + if (_bot.status.LoadedConfig.IsDev) + appCommands.RegisterGuildCommands(compiledAssembly.GetTypes().First(x => x.BaseType == typeof(ApplicationCommandsModule)), _bot.status.LoadedConfig.Discord.DevelopmentGuild, translationContext); + else + appCommands.RegisterGlobalCommands(compiledAssembly.GetTypes().First(x => x.BaseType == typeof(ApplicationCommandsModule)), translationContext); + break; + } + } + } + + private static string GetUniqueCodeCompatibleName() + => $"a{Guid.NewGuid().ToString().ToLower().Replace("-", "")}"; + + private static string? FileHeaderCache { get; set; } = null; + private static string GetFileHeader() + { + FileHeaderCache ??= """ + // This file was auto generated and is part of Project Makoto. + + namespace ProjectMakoto; + + """ + string.Join("\n", File.ReadAllText("Global.cs").ReplaceLineEndings("\n").Split("\n").Where(x => !x.StartsWith("//"))).Replace("global ", ""); + + return FileHeaderCache; + } +} diff --git a/ProjectMakoto/Util/Initializers/ConfigLoader.cs b/ProjectMakoto/Util/Initializers/ConfigLoader.cs new file mode 100644 index 00000000..2747b24d --- /dev/null +++ b/ProjectMakoto/Util/Initializers/ConfigLoader.cs @@ -0,0 +1,95 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util.Initializers; +internal static class ConfigLoader +{ + internal static async Task Load(Bot bot) + { + if (!File.Exists("config.json")) + new Config().Save(); + + _ = Task.Run(async () => + { + DateTime lastModify = new(); + + bot.status.LoadedConfig = JsonConvert.DeserializeObject(await File.ReadAllTextAsync("config.json")); + await Task.Delay(500); + bot.status.LoadedConfig.Save(); + + await Task.Delay(10000); + + while (true) + { + try + { + FileInfo fileInfo = new("config.json"); + + if (lastModify != fileInfo.LastWriteTimeUtc || bot.status.LoadedConfig is null) + { + try + { + Log.Debug("Reloading config.."); + bot.status.LoadedConfig = JsonConvert.DeserializeObject(await File.ReadAllTextAsync("config.json")); + Log.Information("Config reloaded."); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to reload config"); + } + } + + lastModify = fileInfo.LastWriteTimeUtc; + + await Task.Delay(1000); + } + catch (Exception ex) + { + Log.Error(ex, "An exception occurred while trying to reload the config.json"); + await Task.Delay(10000); + } + } + }).Add(bot); + + while (bot.status.LoadedConfig is null) + await Task.Delay(100); + + foreach (var field in typeof(Config.DiscordConfig).GetFields()) + { + if (field.FieldType != typeof(ulong)) + continue; + + var v = (ulong)field.GetValue(bot.status.LoadedConfig.Discord); + if (v is not 0UL) + continue; + + Log.Error("No {0} provided.", field.Name); + await Task.Delay(1000); + Console.Write("> "); + field.SetValue(bot.status.LoadedConfig.Discord, Convert.ToUInt64(Console.ReadLine())); + } + + foreach (var field in typeof(Config.ChannelsConfig).GetFields()) + { + if (field.FieldType != typeof(ulong)) + continue; + + var v = (ulong)field.GetValue(bot.status.LoadedConfig.Channels); + if (v is not 0UL) + continue; + + Log.Error("No {0} provided.", field.Name); + await Task.Delay(1000); + Console.Write("> "); + field.SetValue(bot.status.LoadedConfig.Channels, Convert.ToUInt64(Console.ReadLine())); + + bot.status.LoadedConfig.Save(); + } + } +} diff --git a/ProjectMakoto/Util/Initializers/DependencyLoader.cs b/ProjectMakoto/Util/Initializers/DependencyLoader.cs new file mode 100644 index 00000000..ceb6c4a7 --- /dev/null +++ b/ProjectMakoto/Util/Initializers/DependencyLoader.cs @@ -0,0 +1,27 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using FFMpegCore; + +namespace ProjectMakoto.Util.Initializers; +internal static class DependencyLoader +{ + public static async Task Load(Bot bot) + { + if (!GenericExtensions.TryGetFileInfo("ffmpeg", out var ffmpegInfo)) + throw new FileNotFoundException("Please install ffmpeg.", "ffmpeg"); + + GlobalFFOptions.Configure(new FFOptions + { + BinaryFolder = ffmpegInfo.Directory.FullName, + TemporaryFilesFolder = Path.GetTempPath(), + }); + } +} + \ No newline at end of file diff --git a/ProjectMakoto/Util/Initializers/DisCatSharpExtensionsLoader.cs b/ProjectMakoto/Util/Initializers/DisCatSharpExtensionsLoader.cs new file mode 100644 index 00000000..9b65b1cf --- /dev/null +++ b/ProjectMakoto/Util/Initializers/DisCatSharpExtensionsLoader.cs @@ -0,0 +1,330 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using Microsoft.Extensions.Logging; + +namespace ProjectMakoto.Util.Initializers; +internal static class DisCatSharpExtensionsLoader +{ + static Bot bot = null; + static List singleCommandTranslations = new(); + static List groupCommandTranslations = new(); + + internal static void GetCommandTranslations(ApplicationCommandsTranslationContext x) + { + if (singleCommandTranslations.IsNotNullAndNotEmpty() && groupCommandTranslations.IsNotNullAndNotEmpty()) + { + x.AddSingleTranslation(JsonConvert.SerializeObject(singleCommandTranslations)); + x.AddGroupTranslation(JsonConvert.SerializeObject(groupCommandTranslations)); + return; + } + + object CreateTranslationRecursively(Type typeToCreate, CommandTranslation translation) + { + try + { + var nameValues = translation.Names; + + if (nameValues is null) + return null; + + var descriptionValues = translation.Descriptions; + var typeValue = translation.Type; + var optionsValues = translation.Options; + var choicesValues = translation.Choices; + var groupsValues = translation.Groups; + var commandsValues = translation.Commands; + + Log.Verbose("Creating instance of '{type}'", typeToCreate.Name); + var translator = Activator.CreateInstance(typeToCreate); + + var createTypeProperties = typeToCreate.GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance); + createTypeProperties.First(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "name")).SetValue(translator, nameValues["en"]); + + if (typeToCreate == typeof(DisCatSharp.ApplicationCommands.Entities.GroupTranslator) || typeToCreate == typeof(DisCatSharp.ApplicationCommands.Entities.CommandTranslator)) + createTypeProperties.First(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "type")).SetValue(translator, (ApplicationCommandType?)typeValue); + + if (createTypeProperties.Any(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "description"))) + createTypeProperties.First(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "description")).SetValue(translator, descriptionValues["en"]); + + Dictionary NameTranslationDictionary = new(); + foreach (var nameTranslation in nameValues ?? new()) + { + if (nameTranslation.Key == "en") + { + NameTranslationDictionary.Add("en-GB", nameTranslation.Value); + NameTranslationDictionary.Add("en-US", nameTranslation.Value); + continue; + } + + NameTranslationDictionary.Add(nameTranslation.Key, nameTranslation.Value); + } + if (createTypeProperties.Any(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "name_translations"))) + createTypeProperties.First(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "name_translations")).SetValue(translator, NameTranslationDictionary); + + Dictionary DescriptionTranslationDictionary = new(); + foreach (var descriptionTranslations in descriptionValues ?? new()) + { + if (descriptionTranslations.Key == "en") + { + DescriptionTranslationDictionary.Add("en-GB", descriptionTranslations.Value); + DescriptionTranslationDictionary.Add("en-US", descriptionTranslations.Value); + continue; + } + + DescriptionTranslationDictionary.Add(descriptionTranslations.Key, descriptionTranslations.Value); + } + if (createTypeProperties.Any(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "description_translations"))) + createTypeProperties.First(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "description_translations")).SetValue(translator, DescriptionTranslationDictionary); + + if (commandsValues is not null && createTypeProperties.Any(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "commands"))) + { + Log.Verbose("Creating sub-command translations for command '{name}'", nameValues.First()); + + var commandProperty = createTypeProperties.First(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "commands")); + commandProperty.SetValue(translator, new List()); + foreach (var value in commandsValues) + { + var obj = (DisCatSharp.ApplicationCommands.Entities.CommandTranslator)CreateTranslationRecursively(typeof(DisCatSharp.ApplicationCommands.Entities.CommandTranslator), value); + + if (obj is null) + continue; + + ((List)commandProperty.GetValue(translator)).Add(obj); + } + + if (((List)commandProperty.GetValue(translator)).Count == 0) + commandProperty.SetValue(translator, null); + } + + if (optionsValues is not null && createTypeProperties.Any(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "options"))) + { + Log.Verbose("Creating option translations for command '{name}'", nameValues.First()); + + var optionProperty = createTypeProperties.First(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "options")); + optionProperty.SetValue(translator, new List()); + foreach (var value in optionsValues) + { + var obj = (DisCatSharp.ApplicationCommands.Entities.OptionTranslator)CreateTranslationRecursively(typeof(DisCatSharp.ApplicationCommands.Entities.OptionTranslator), value); + + if (obj is null) + continue; + + ((List)optionProperty.GetValue(translator)).Add(obj); + } + + if (((List)optionProperty.GetValue(translator)).Count == 0) + optionProperty.SetValue(translator, null); + } + + if (choicesValues is not null && createTypeProperties.Any(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "choices"))) + { + Log.Verbose("Creating choice translations for command '{name}'", nameValues.First()); + + var choiceProperty = createTypeProperties.First(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "choices")); + choiceProperty.SetValue(translator, new List()); + foreach (var value in choicesValues) + { + var obj = (DisCatSharp.ApplicationCommands.Entities.ChoiceTranslator)CreateTranslationRecursively(typeof(DisCatSharp.ApplicationCommands.Entities.ChoiceTranslator), value); + + if (obj is null) + continue; + + ((List)choiceProperty.GetValue(translator)).Add(obj); + } + + if (((List)choiceProperty.GetValue(translator)).Count == 0) + choiceProperty.SetValue(translator, null); + } + + if (groupsValues is not null && createTypeProperties.Any(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "groups"))) + { + Log.Verbose("Creating group translations for command '{name}'", nameValues.First()); + + var groupProperty = createTypeProperties.First(x => x.GetCustomAttributes().Any(attr => attr is JsonPropertyAttribute attribute && attribute.PropertyName == "groups")); + groupProperty.SetValue(translator, new List()); + + foreach (var value in groupsValues) + { + var obj = (DisCatSharp.ApplicationCommands.Entities.SubGroupTranslator)CreateTranslationRecursively(typeof(DisCatSharp.ApplicationCommands.Entities.SubGroupTranslator), value); + + if (obj is null) + continue; + + ((List)groupProperty.GetValue(translator)).Add(obj); + } + + if (((List)groupProperty.GetValue(translator)).Count == 0) + groupProperty.SetValue(translator, null); + } + + return translator; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to generate DCS-Compatible Translations"); + throw; + } + } + + foreach (var translation in bot.LoadedTranslations.CommandList) + singleCommandTranslations.Add( + (DisCatSharp.ApplicationCommands.Entities.CommandTranslator)CreateTranslationRecursively(typeof(DisCatSharp.ApplicationCommands.Entities.CommandTranslator), translation)); + + foreach (var translation in bot.LoadedTranslations.CommandList) + groupCommandTranslations.Add( + (DisCatSharp.ApplicationCommands.Entities.GroupTranslator)CreateTranslationRecursively(typeof(DisCatSharp.ApplicationCommands.Entities.GroupTranslator), translation)); + + x.AddSingleTranslation(JsonConvert.SerializeObject(singleCommandTranslations)); + x.AddGroupTranslation(JsonConvert.SerializeObject(groupCommandTranslations)); + } + + public static async Task Load(Bot bot) + { + DisCatSharpExtensionsLoader.bot = bot; + + if (bot.status.LoadedConfig.Secrets.Discord.Token.Length <= 0) + { + Log.Fatal("No discord token provided"); + await Task.Delay(1000); + Environment.Exit((int)ExitCodes.NoToken); + return; + } + + Log.Debug("Registering DiscordClient.."); + + bot.DiscordClient = new DiscordShardedClient(new DiscordConfiguration + { + Token = bot.status.LoadedConfig.Secrets.Discord.Token, + TokenType = TokenType.Bot, + MinimumLogLevel = Microsoft.Extensions.Logging.LogLevel.Trace, + Intents = DiscordIntents.All, + AutoReconnect = true, + LoggerFactory = bot.msLoggerFactory, + HttpTimeout = TimeSpan.FromSeconds(60), + MessageCacheSize = 4096, + EnableSentry = true, + ReportMissingFields = bot.status.LoadedConfig.IsDev, + AttachUserInfo = true, + DeveloperUserId = 411950662662881290, + DisableUpdateCheck = true, + }); + + bot.ExperienceHandler = new(bot); + + Log.Debug("Registering CommandsNext.."); + + var cNext = await bot.DiscordClient.UseCommandsNextAsync(new CommandsNextConfiguration + { + EnableDefaultHelp = false, + EnableMentionPrefix = false, + IgnoreExtraArguments = true, + EnableDms = false, + ServiceProvider = new ServiceCollection() + .AddSingleton(bot) + .BuildServiceProvider(), + PrefixResolver = new PrefixResolverDelegate(bot.GetPrefix) + }); + + Log.Debug("Registering DisCatSharp TwoFactor.."); + + var tfa = bot.DiscordClient.UseTwoFactorAsync(new TwoFactorConfiguration + { + ResponseConfiguration = new TwoFactorResponseConfiguration + { + ShowResponse = false, + AuthenticatorAccountPrefix = "Project Makoto" + }, + Issuer = "Project Makoto", + }); + + DiscordEventHandler.SetupEvents(bot); + bot.DiscordClient.GuildDownloadCompleted += bot.GuildDownloadCompleted; + + Log.Debug("Registering Interactivity.."); + _ = await bot.DiscordClient.UseInteractivityAsync(new InteractivityConfiguration { }); + + var appCommands = await bot.DiscordClient.UseApplicationCommandsAsync(new ApplicationCommandsConfiguration + { + ServiceProvider = new ServiceCollection() + .AddSingleton(bot) + .BuildServiceProvider(), + EnableDefaultHelp = false, + EnableLocalization = true, + DebugStartup = true + }); + + if (bot.status.CurrentAppHash != bot.status.LoadedConfig.DontModify.LastKnownHash) + { + Log.Debug("Clearing cached Commands.."); + await FileExtensions.CleanupFilesAndDirectories(new(), Directory.GetFiles("CompiledCommands").ToList()); + } + + await BasePlugin.RaisePreLogin(bot, bot.DiscordClient); + + Log.Debug("Compiling Built-In Commands.."); + var commandModules = Commands.Commands.GetList(); + bot._CommandModules = commandModules; + var assemblies = await CommandCompiler.BuildCommands(bot, bot.status.CurrentAppHash, commandModules, null); + CommandCompiler.RegisterAssemblies(bot, cNext, appCommands, GetCommandTranslations, assemblies); + + Log.Debug("Registering Debug Commands.."); + appCommands.RegisterGuildCommands(bot.status.LoadedConfig.Discord.DevelopmentGuild, GetCommandTranslations); + + Log.Debug("Registering Command Converters.."); + cNext.RegisterConverter(new CustomArgumentConverter.BoolConverter()); + cNext.RegisterConverter(new CustomArgumentConverter.AttachmentConverter()); + + var commandsNextTypes = new List(); + var applicationCommandTypes = new List(); + + await Util.Initializers.PluginLoader.LoadPluginCommands(bot, cNext, appCommands); + + _ = Task.Run(async () => + { + while (!bot.status.DiscordInitialized) + await Task.Delay(100); + + Stopwatch sw = new(); + sw.Start(); + + _ = bot.DiscordClient.UpdateStatusAsync(userStatus: UserStatus.Online, activity: new DiscordActivity("Registering commands..", ActivityType.Custom)); + + var applicationCommandsExtension = bot.DiscordClient.GetFirstShard().GetApplicationCommands(); + while (applicationCommandsExtension?.RegisteredCommands?.Count == 0 && sw.ElapsedMilliseconds < TimeSpan.FromMinutes(5).TotalMilliseconds) + await Task.Delay(1000); + + if (applicationCommandsExtension?.RegisteredCommands?.Count == 0) + { + Log.Fatal("Commands did not register."); + _ = bot.ExitApplication(true); + return; + } + + bot.status.DiscordCommandsRegistered = true; + + while (true) + { + try + { + if (bot.DatabaseClient.Disposed) + return; + + await bot.DiscordClient.UpdateStatusAsync(activity: new DiscordActivity($"{bot.DiscordClient.GetGuilds().Count.ToString("N0", CultureInfo.CreateSpecificCulture("en-US"))} guilds | Up for {Math.Round((DateTime.UtcNow - bot.status.startupTime).TotalHours, 1).ToString(CultureInfo.CreateSpecificCulture("en-US"))}h", ActivityType.Custom)); + await Task.Delay(30000); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to update user status"); + await Task.Delay(30000); + } + } + }); + } +} diff --git a/ProjectMakoto/Util/Initializers/ListLoader.cs b/ProjectMakoto/Util/Initializers/ListLoader.cs new file mode 100644 index 00000000..ca7b4ad0 --- /dev/null +++ b/ProjectMakoto/Util/Initializers/ListLoader.cs @@ -0,0 +1,54 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util.Initializers; +internal static class ListLoader +{ + public static async Task Load(Bot bot) + { + bot.CountryCodes = new(); + var cc = JsonConvert.DeserializeObject>(await File.ReadAllTextAsync("Assets/Countries.json")); + foreach (var b in cc) + { + bot.CountryCodes._List.Add(b[2], new CountryCodes.CountryInfo + { + Name = b[0], + ContinentCode = b[1], + ContinentName = b[1].ToLower() switch + { + "af" => "Africa", + "an" => "Antarctica", + "as" => "Asia", + "eu" => "Europe", + "na" => "North America", + "oc" => "Oceania", + "sa" => "South America", + _ => "Invalid Continent" + } + }); + } + + Log.Debug("Loaded {Count} countries", bot.CountryCodes.List.Count); + + bot.LanguageCodes = new(); + var lc = JsonConvert.DeserializeObject>(await File.ReadAllTextAsync("Assets/Languages.json")); + foreach (var b in lc) + { + bot.LanguageCodes._List.Add(new LanguageCodes.LanguageInfo + { + Code = b[0], + Name = b[1], + }); + } + Log.Debug("Loaded {Count} languages", bot.LanguageCodes.List.Count); + + bot.ProfanityList = JsonConvert.DeserializeObject>(await new HttpClient().GetStringAsync("https://raw.githubusercontent.com/zacanger/profane-words/master/words.json")); + Log.Debug("Loaded {Count} profanity words", bot.ProfanityList.Count); + } +} diff --git a/ProjectMakoto/Util/Initializers/PluginLoader.cs b/ProjectMakoto/Util/Initializers/PluginLoader.cs new file mode 100644 index 00000000..e2a72933 --- /dev/null +++ b/ProjectMakoto/Util/Initializers/PluginLoader.cs @@ -0,0 +1,269 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using System.Collections.Immutable; +using System.Runtime.Loader; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace ProjectMakoto.Util.Initializers; +internal static class PluginLoader +{ + internal static async Task LoadPlugins(Bot bot, bool InitializeLoadedPlugins = true, string PluginDirectory = "Plugins") + { + if (InitializeLoadedPlugins && !bot.status.LoadedConfig.EnablePlugins) + return; + + if (InitializeLoadedPlugins) + { + Log.Debug("Updating official plugin repository.."); + + await bot.OfficialPlugins.Pull(); + await Task.Delay(500); + while (bot.OfficialPlugins.PullRunning) + await Task.Delay(1000); + } + + Log.Debug("Loading Plugins from '{PluginDirectory}'..", PluginDirectory); + + if (!Directory.Exists(PluginDirectory)) + _ = Directory.CreateDirectory(PluginDirectory); + + foreach (var pluginFile in Directory.GetFiles(PluginDirectory).Where(x => x.EndsWith(".pmpl"))) + { + if (new DirectoryInfo(pluginFile).Name.StartsWith('.')) + continue; + + var pluginName = Path.GetFileName(pluginFile); + + Log.Debug("Loading Plugin '{Name}'..", pluginName); + + var pluginHash = HashingExtensions.ComputeSHA256Hash(new FileInfo(pluginFile)); + + using var pluginFileStream = new FileStream(pluginFile, FileMode.Open, FileAccess.ReadWrite); + using var zipArchive = new ZipArchive(pluginFileStream, ZipArchiveMode.Update); + var isOfficial = false; + + if (InitializeLoadedPlugins) + { + var (found, remoteInfo) = bot.OfficialPlugins.FindHash(pluginHash); + + if (!found && bot.status.LoadedConfig.OnlyLoadOfficialPlugins) + { + Log.Warning("Skipped loading of unofficial plugin '{Name}'.", pluginName); + continue; + } + + if (found) + { + Log.Information("'{Name}' is an official plugin: {Hash}", pluginName, pluginHash); + + using var localManifestStream = zipArchive.GetEntry("manifest.json").Open(); + using var localManifestStreamReader = new StreamReader(localManifestStream); + var localManifestText = localManifestStreamReader.ReadToEnd(); + + var localInfo = JsonConvert.DeserializeObject(localManifestText); + + if (localInfo.Name != remoteInfo.Name || + localInfo.Description != remoteInfo.Description || + localInfo.Author != remoteInfo.Author || + localInfo.AuthorId != remoteInfo.AuthorId || + localInfo.Version != localInfo.Version) + { + Log.Warning("Skipped loading of official plugin '{Name}', manifest mismatches.", pluginName); + continue; + } + + isOfficial = true; + } + } + + var referenceFiles = zipArchive.Entries; + Assembly? resolveAssemblyEvent(object? obj, ResolveEventArgs arg) + { + var name = $"{new AssemblyName(arg.Name).Name}.dll"; + var assemblyFile = referenceFiles.Where(x => x.Name.EndsWith(name)).FirstOrDefault(); + if (assemblyFile != null) + { + using var assemblyStream = assemblyFile.Open(); + return AssemblyLoadContext.Default.LoadFromStream(assemblyStream); + } + + throw new Exception($"Could not locate: '{name}' ({arg.RequestingAssembly?.FullName})"); + } + + AppDomain.CurrentDomain.AssemblyResolve += resolveAssemblyEvent; + + try + { + var count = 0; + + foreach (var assemblyEntry in referenceFiles.Where(x => x.Name.EndsWith(".dll"))) + { + AssemblyLoadContext pluginLoadContext = new(null); + + var assemblyName = Path.GetFileNameWithoutExtension(assemblyEntry.Name); + + if (AppDomain.CurrentDomain.GetAssemblies().Select(x => x.GetName()).Any(x => x.Name == assemblyName)) + { + Log.Verbose("{Assembly} already loaded, skipping", assemblyName); + continue; + } + + using var assemblyStream = assemblyEntry.Open(); + var assembly = AssemblyLoadContext.Default.LoadFromStream(assemblyStream); + + try + { + foreach (var type in assembly.GetTypes()) + { + if (typeof(BasePlugin).IsAssignableFrom(type)) + { + Log.Debug("Loading Plugin from '{0}'", assemblyEntry); + + count++; + var result = Activator.CreateInstance(type) as BasePlugin; + result.LoadedFile = new FileInfo(pluginFile); + result.OfficialPlugin = isOfficial; + + if (result.SupportedPluginApis == null || !result.SupportedPluginApis.Contains(BasePlugin.CurrentApiVersion)) + throw new IndexOutOfRangeException($"Plugin does not support Api Version {BasePlugin.CurrentApiVersion}"); + + bot._Plugins.Add(assemblyName, result); + + UniversalExtensions.LoadAllReferencedAssemblies(assembly.GetReferencedAssemblies()); + + _ = assemblyStream.Seek(0, SeekOrigin.Begin); + CommandCompiler.AssemblyReferences.Add(MetadataReference.CreateFromStream(assemblyStream)); + break; + } + } + } + catch (Exception ex) + { + _ = bot._Plugins.Remove(assemblyName); + Log.Error(ex, "Failed to load Plugin '{0}' from '{1}'", assemblyName, assemblyEntry); + } + } + + if (count == 0) + { + Log.Warning("Cannot load Plugin '{0}': Plugin Assembly does not contain type that inherits BasePlugin.", pluginName); + continue; + } + + Log.Information("Loaded Plugin from '{0}'", pluginName); + } + finally + { + AppDomain.CurrentDomain.AssemblyResolve -= resolveAssemblyEvent; + } + } + + Log.Information("Loaded {0} Plugins.", bot.Plugins.Count); + + foreach (var b in bot.Plugins) + { + if (b.Value.Name.IsNullOrWhiteSpace()) + { + Log.Warning("Skipped loading Plugin '{0}': Missing Name.", b.Key); + continue; + } + + if (b.Value.Description.IsNullOrWhiteSpace()) + { + Log.Warning("Skipped loading Plugin '{0}': Missing Description.", b.Key); + continue; + } + + if (b.Value.Author.IsNullOrWhiteSpace()) + { + Log.Warning("Skipped loading Plugin '{0}': Missing Author.", b.Key); + continue; + } + + if (b.Value.Version is null) + { + Log.Warning("Skipped loading Plugin '{0}': Missing Version.", b.Key); + continue; + } + + Log.Debug("Initializing Plugin '{0}' ({1})..", b.Value.Name, b.Key); + + try + { + if (InitializeLoadedPlugins) + b.Value.Load(bot); + Log.Information("Initialized Plugin from '{0}': '{1}' (v{2}).", b.Key, b.Value.Name, b.Value.Version.ToString()); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to initialize Plugin from '{0}': '{1}' (v{2}).", b.Key, b.Value.Name, b.Value.Version.ToString()); + } + + try + { + if (InitializeLoadedPlugins) + await b.Value.CheckForUpdates(); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to check updates for '{PluginName}'", b.Value.Name); + } + + try + { + if (InitializeLoadedPlugins) + { + var (path, type) = b.Value.LoadTranslations(); + + if (path != null) + { + using var stream = b.Value.LoadedFile.Open(FileMode.Open); + using var zip = new ZipArchive(stream); + using var file = zip.GetEntry(path).Open(); + using var reader = new StreamReader(file); + + b.Value.UsesTranslations = true; + b.Value.Translations = (ITranslations)JsonConvert.DeserializeObject(reader.ReadToEnd(), type); + + foreach (var item in b.Value.Translations.CommandList) + bot.LoadedTranslations.CommandList = bot.LoadedTranslations.CommandList.Add(item); + } + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to load translations for '{PluginName}'", b.Value.Name); + } + } + } + + internal static async Task LoadPluginCommands(Bot bot, IReadOnlyDictionary cNext, IReadOnlyDictionary appCommands) + { + foreach (var plugin in bot.Plugins) + { + var pluginModules = await plugin.Value.RegisterCommands(); + bot._PluginCommandModules.Add(plugin.Key, pluginModules.ToList()); + var assemblies = await CommandCompiler.BuildCommands(bot, bot.status.CurrentAppHash, pluginModules, plugin); + CommandCompiler.RegisterAssemblies(bot, cNext, appCommands, plugin.Value.EnableCommandTranslations, assemblies); + } + + bot.status.LoadedConfig.DontModify.LastKnownHash = bot.status.CurrentAppHash; + bot.status.LoadedConfig.Save(); + } +} + +internal record CompilationData(CompilationType type, string code, IEnumerable moduleList, string Identifier); + +public enum CompilationType +{ + App, + Prefix +} \ No newline at end of file diff --git a/ProjectMakoto/Util/Initializers/PostLoginTaskLoader.cs b/ProjectMakoto/Util/Initializers/PostLoginTaskLoader.cs new file mode 100644 index 00000000..0823ee6f --- /dev/null +++ b/ProjectMakoto/Util/Initializers/PostLoginTaskLoader.cs @@ -0,0 +1,68 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util.Initializers; +internal class PostLoginTaskLoader +{ + public static async Task Load(Bot bot) + { + var guild = await bot.DiscordClient.GetShard(bot.status.LoadedConfig.Discord.AssetsGuild).GetGuildAsync(bot.status.LoadedConfig.Discord.AssetsGuild); + var emojis = await guild.GetEmojisAsync(); + + foreach (var field in typeof(Config.EmojiConfig).GetFields()) + { + if (field.FieldType != typeof(ulong)) + continue; + + var v = (ulong)field.GetValue(bot.status.LoadedConfig.Emojis); + if (v is not 0UL) + if (emojis.Any(x => x.Id == v)) + continue; + + try + { + if (emojis.Any(x => x.Name == field.Name)) + { + Log.Information("Missing '{emojiName}' Emoji but Guild '{guild}' contains emoji with same name. Using that..", field.Name, guild.Name); + + field.SetValue(bot.status.LoadedConfig.Emojis, emojis.First(x => x.Name == field.Name).Id); + bot.status.LoadedConfig.Save(); + continue; + } + + Log.Information("Uploading '{emojiName}' Emoji to '{guild}'..", field.Name, guild.Name); + + var fileName = $"Assets/Emojis/Upload/{field.Name}.png"; + + if (!Directory.GetFiles("Assets/Emojis/Upload/", "*", + new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive }) + .Select(x => x.Replace("\\", "//")) + .Any(x => x.Contains(fileName))) + fileName = $"Assets/Emojis/Upload/{field.Name}.gif"; + + if (!Directory.GetFiles("Assets/Emojis/Upload/", "*", + new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive }) + .Select(x => x.Replace("\\", "//")) + .Any(x => x.Contains(fileName))) + throw new FileNotFoundException($"The emoji file for '{field.Name}' could not be found."); + + using var fileStream = new FileStream(fileName, FileMode.Open, FileAccess.Read); + var emoji = await guild.CreateEmojiAsync(field.Name, fileStream); + + field.SetValue(bot.status.LoadedConfig.Emojis, emoji.Id); + + bot.status.LoadedConfig.Save(); + } + catch (Exception ex) + { + Log.Error(ex, "Could not upload emoji"); + } + } + } +} diff --git a/ProjectMakoto/Util/Initializers/SyncTasks.cs b/ProjectMakoto/Util/Initializers/SyncTasks.cs new file mode 100644 index 00000000..fa19c480 --- /dev/null +++ b/ProjectMakoto/Util/Initializers/SyncTasks.cs @@ -0,0 +1,259 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using ProjectMakoto.Entities.Members; + +namespace ProjectMakoto.Util.Initializers; +internal static class SyncTasks +{ + internal static async Task GuildDownloadCompleted(Bot bot, DiscordClient sender, GuildDownloadCompletedEventArgs e) + { + _ = Task.Run(async () => + { + bot.status.DiscordGuildDownloadCompleted = true; + + Log.Information("I'm on {GuildsCount} guilds.", e.Guilds.Count); + + _ = Task.Run(async () => + { + foreach (var user in bot.Users) + { + var userCache = new Dictionary(); + + if (user.Value.LegacyBlockedUsers.Length > 0) + { + for (var i = 0; i < user.Value.LegacyBlockedUsers.Length; i++) + { + var b = user.Value.LegacyBlockedUsers[i]; + + if (!userCache.TryGetValue(b, out var victim)) + { + if (bot.DiscordClient.GetFirstShard().TryGetUser(b, out var fetched)) + userCache.Add(b, fetched); + else + userCache.Add(b, null); + + victim = userCache[b]; + } + + if (victim is null || victim.Id == bot.DiscordClient.CurrentUser.Id || victim.Id == user.Key || victim.IsBot || (victim.Flags?.HasFlag(UserFlags.Staff) ?? false)) + { + Log.Debug("Removing '{victim}' from '{owner}' blocklist", b, user.Value.Id); + i--; + user.Value.LegacyBlockedUsers = user.Value.LegacyBlockedUsers.Remove(x => x.ToString(), b); + } + } + } + } + }).Add(bot); + + for (var i = 0; i < 501; i++) + { + _ = bot.ExperienceHandler.CalculateLevelRequirement(i); + } + + foreach (var guild in e.Guilds) + { + if (!bot.Guilds.ContainsKey(guild.Key)) + bot.Guilds.Add(guild.Key, new Guild(bot, guild.Key)); + + if (bot.Guilds[guild.Key].BumpReminder.ChannelId != 0) + { + bot.BumpReminder.ScheduleBump(sender, guild.Key); + } + + if (bot.Guilds[guild.Key].Crosspost.CrosspostChannels.Length != 0) + { + _ = Task.Run(async () => + { + for (var i = 0; i < bot.Guilds[guild.Key].Crosspost.CrosspostChannels.Length; i++) + { + if (guild.Value is null) + return; + + var ChannelId = bot.Guilds[guild.Key].Crosspost.CrosspostChannels[i]; + + Log.Debug("Checking channel '{ChannelId}' for missing crossposts..", ChannelId); + + if (!guild.Value.Channels.ContainsKey(ChannelId)) + return; + + var Messages = await guild.Value.GetChannel(ChannelId).GetMessagesAsync(20); + + if (Messages.Any(x => x.Flags.HasValue && !x.Flags.Value.HasMessageFlag(MessageFlags.Crossposted))) + foreach (var msg in Messages.Where(x => x.Flags.HasValue && !x.Flags.Value.HasMessageFlag(MessageFlags.Crossposted))) + { + Log.Debug("Handling missing crosspost message '{msg}' in '{ChannelId}' for '{guild}'..", msg.Id, msg.ChannelId, guild.Key); + + var WaitTime = bot.Guilds[guild.Value.Id].Crosspost.DelayBeforePosting - msg.Id.GetSnowflakeTime().GetTotalSecondsSince(); + + if (WaitTime > 0) + await Task.Delay(TimeSpan.FromSeconds(WaitTime)); + + if (bot.Guilds[guild.Value.Id].Crosspost.DelayBeforePosting > 3) + _ = msg.DeleteReactionsEmojiAsync(DiscordEmoji.FromUnicode("🕒")); + + await bot.Guilds[guild.Key].Crosspost.CrosspostWithRatelimit(sender, msg); + } + } + }).Add(bot); + } + } + + _ = BasePlugin.RaisePreSyncTasksExecution(bot, e.Guilds.Values.Where(x => x != null)); + + try + { + await ExecuteSyncTasks(bot, bot.DiscordClient); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to run sync tasks"); + } + + _ = BasePlugin.RaisePostSyncTasksExecution(bot, e.Guilds.Values.Where(x => x != null)); + }).Add(bot); + } + + internal static async Task ExecuteSyncTasks(Bot bot, DiscordShardedClient shardedClient) + { + var Guilds = shardedClient.GetGuilds(); + + ObservableList runningTasks = new(); + + void runningTasksUpdated(object sender, ObservableListUpdate e) + { + if (e is not null && e.NewItems is not null) + foreach (var b in e.NewItems) + { + _ = b.Add(bot); + } + } + + runningTasks.ItemsChanged += runningTasksUpdated; + + var startupTasksSuccess = 0; + + foreach (var guild in Guilds) + { + while (runningTasks.Count >= 4 && !runningTasks.Any(x => x.IsCompleted)) + await Task.Delay(100); + + foreach (var task in runningTasks.ToList()) + if (task.IsCompleted) + _ = runningTasks.Remove(task); + + runningTasks.Add(Task.Run(async () => + { + Log.Debug("Performing sync tasks for '{guild}'..", guild.Key); + + if (bot.objectedUsers.Contains(guild.Value.OwnerId.Value) || bot.bannedUsers.ContainsKey(guild.Value.OwnerId.Value) || bot.bannedGuilds.ContainsKey(guild.Key)) + { + Log.Information("Leaving guild '{guild}'..", guild.Key); + await guild.Value.LeaveAsync(); + return; + } + + var guildMembers = await guild.Value.GetAllMembersAsync(); + var guildBans = await guild.Value.GetBansAsync(); + + foreach (var member in guildMembers) + { + bot.ExperienceHandler.CheckExperience(member.Id, guild.Value); + + if (bot.Guilds[guild.Key].Members[member.Id].FirstJoinDate == DateTime.MinValue) + bot.Guilds[guild.Key].Members[member.Id].FirstJoinDate = member.JoinedAt.UtcDateTime; + + if (bot.Guilds[guild.Key].Members[member.Id].LastLeaveDate != DateTime.MinValue) + bot.Guilds[guild.Key].Members[member.Id].LastLeaveDate = DateTime.MinValue; + + bot.Guilds[guild.Key].Members[member.Id].MemberRoles = member.Roles.Select(x => new MemberRole() + { + Id = x.Id, + Name = x.Name, + }).ToArray(); + + bot.Guilds[guild.Key].Members[member.Id].SavedNickname = member.Nickname; + + await bot.Guilds[guild.Key].Members[member.Id].PerformAutoKickChecks(guild.Value, member); + } + + foreach (var databaseMember in bot.Guilds[guild.Key].Members) + { + if (!guildMembers.Any(x => x.Id == databaseMember.Key)) + { + if (bot.Guilds[guild.Key].Members[databaseMember.Key].LastLeaveDate == DateTime.MinValue) + bot.Guilds[guild.Key].Members[databaseMember.Key].LastLeaveDate = DateTime.UtcNow; + } + } + + foreach (var banEntry in guildBans) + { + if (!bot.Guilds[guild.Key].Members.ContainsKey(banEntry.User.Id)) + continue; + + if (bot.Guilds[guild.Key].Members[banEntry.User.Id].MemberRoles.Length > 0) + bot.Guilds[guild.Key].Members[banEntry.User.Id].MemberRoles = Array.Empty(); + + if (bot.Guilds[guild.Key].Members[banEntry.User.Id].SavedNickname != "") + bot.Guilds[guild.Key].Members[banEntry.User.Id].SavedNickname = ""; + } + + if (bot.Guilds[guild.Key].InviteTracker.Enabled) + { + await InviteTrackerEvents.UpdateCachedInvites(bot, guild.Value); + } + + startupTasksSuccess++; + })); + } + + foreach (var guild in Guilds) + { + try + { + List Threads = new(); + + while (true) + { + var t = await guild.Value.GetActiveThreadsAsync(); + + foreach (var b in t.ReturnedThreads.Values) + { + if (!Threads.Contains(b) && b is not null) + Threads.Add(b); + } + + if (!t.HasMore) + break; + + Log.Debug("Requesting more threads for '{guild}'", guild.Key); + } + + foreach (var b in Threads.Where(x => x.CurrentMember is null)) + { + Log.Debug("Joining thread on '{guild}': {thread}", guild.Key, b.Id); + b.JoinWithQueue(bot.ThreadJoinClient); + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to join threads on '{guild}'", guild.Key); + } + } + + while (runningTasks.Any(x => !x.IsCompleted)) + await Task.Delay(100); + + runningTasks.ItemsChanged -= runningTasksUpdated; + runningTasks.Clear(); + + Log.Information("Sync Tasks successfully finished for {startupTasksSuccess}/{GuildCount} guilds.", startupTasksSuccess, Guilds.Count); + } +} diff --git a/ProjectMakoto/Util/Initializers/TranslationLoader.cs b/ProjectMakoto/Util/Initializers/TranslationLoader.cs new file mode 100644 index 00000000..de370179 --- /dev/null +++ b/ProjectMakoto/Util/Initializers/TranslationLoader.cs @@ -0,0 +1,103 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util.Initializers; +internal static class TranslationLoader +{ + internal static async Task Load(Bot _bot) + { + _bot.LoadedTranslations = JsonConvert.DeserializeObject(await File.ReadAllTextAsync("Translations/strings.json"), new JsonSerializerSettings { NullValueHandling = NullValueHandling.Include }); + Log.Debug("Loaded translations"); + + Dictionary CalculateTranslationProgress(object? obj, string name, bool isCommandList = false) + { + if (obj is null) + { + if (!isCommandList) + Log.Warning("A Translation Group was not loaded: {name}.", name); + return new Dictionary(); + } + + Dictionary counts = new(); + + var objType = obj.GetType(); + var fields = objType.GetFields(); + + foreach (var field in fields) + { + var fieldValue = field.GetValue(obj); + var elems = fieldValue as IList; + if (elems is not null) + { + foreach (var item in elems) + { + foreach (var b in CalculateTranslationProgress(item, field.Name, field.Name == "CommandList" || isCommandList)) + { + _ = counts.TryAdd(b.Key, 0); + counts[b.Key] += b.Value; + } + } + } + else + { + if (field.FieldType.Assembly == objType.Assembly) + { + if (field.FieldType == typeof(SingleTranslationKey)) + { + if (fieldValue is not null) + foreach (var b in ((SingleTranslationKey)fieldValue).t) + { + _ = counts.TryAdd(b.Key, 0); + counts[b.Key]++; + } + } + else if (field.FieldType == typeof(MultiTranslationKey)) + { + if (fieldValue is not null) + foreach (var b in ((MultiTranslationKey)fieldValue).t) + { + _ = counts.TryAdd(b.Key, 0); + counts[b.Key]++; + } + } + + foreach (var b in CalculateTranslationProgress(fieldValue, field.Name, field.Name == "CommandList" || isCommandList)) + { + _ = counts.TryAdd(b.Key, 0); + counts[b.Key] += b.Value; + } + } + else + { + if (field.FieldType == typeof(SingleTranslationKey)) + { + foreach (var b in ((SingleTranslationKey)fieldValue).t) + { + _ = counts.TryAdd(b.Key, 0); + counts[b.Key]++; + } + } + else if (field.FieldType == typeof(MultiTranslationKey)) + { + foreach (var b in ((MultiTranslationKey)fieldValue).t) + { + _ = counts.TryAdd(b.Key, 0); + counts[b.Key]++; + } + } + } + } + } + + return counts; + } + _bot.LoadedTranslations.Progress = CalculateTranslationProgress(_bot.LoadedTranslations, "root"); + Log.Debug("Loaded translations: {0}", string.Join("; ", _bot.LoadedTranslations.Progress.Select(x => $"{x.Key}:{x.Value}"))); + } +} diff --git a/ProjectMakoto/Util/JsonSerializers/ReminderSnoozeMinifiedSerializer.cs b/ProjectMakoto/Util/JsonSerializers/ReminderSnoozeMinifiedSerializer.cs new file mode 100644 index 00000000..bce66225 --- /dev/null +++ b/ProjectMakoto/Util/JsonSerializers/ReminderSnoozeMinifiedSerializer.cs @@ -0,0 +1,39 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using Newtonsoft.Json.Linq; +using ProjectMakoto.Entities.Users; + +namespace ProjectMakoto.Util.JsonSerializers; +public sealed class ReminderSnoozeMinifiedSerializer : JsonConverter +{ + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var cnv = (ReminderSnoozeButton)value; + serializer.Serialize(writer, new object[] { cnv.Type, cnv.Description }); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var json = JArray.Load(reader); + var objects = json.Values().ToArray(); + + return (PrivateButtonType)objects[0].ToInt32() != PrivateButtonType.ReminderSnooze + ? throw new InvalidDataException() + : (object)new ReminderSnoozeButton + { + Description = objects[1].ToString(), + }; + } + + public override bool CanConvert(Type objectType) + { + return typeof(JsonConverter).IsAssignableFrom(objectType); + } +} diff --git a/ProjectMakoto/Util/PhishingUrlHandler.cs b/ProjectMakoto/Util/PhishingUrlHandler.cs new file mode 100644 index 00000000..ed16c981 --- /dev/null +++ b/ProjectMakoto/Util/PhishingUrlHandler.cs @@ -0,0 +1,152 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using System.Net.Http.Headers; + +namespace ProjectMakoto.Util; + +internal sealed class PhishingUrlHandler(Bot bot) : RequiresBotReference(bot) +{ + public async Task UpdatePhishingUrlDatabase() + { + try + { + _ = new Func(async () => + { + _ = this.UpdatePhishingUrlDatabase(); + }).CreateScheduledTask(DateTime.UtcNow.AddHours(12)); + + var urls = await this.GetUrls(); + var listFailed = false; + + foreach (var (Url, Origins, ListFailed) in urls.GroupBy(x => x.Url).First()) + { + if (ListFailed) + listFailed = true; + + if (!this.Bot.PhishingHosts.ContainsKey(Url)) + { + this.Bot.PhishingHosts.Add(Url, new PhishingUrlEntry(this.Bot, Url) + { + Url = Url, + Origin = Origins + }); + continue; + } + + if (this.Bot.PhishingHosts.ContainsKey(Url)) + { + if (this.Bot.PhishingHosts[Url].Origin?.Length != Origins.Length) + { + this.Bot.PhishingHosts[Url].Origin = Origins; + continue; + } + } + } + + if (!listFailed) + foreach (var b in this.Bot.PhishingHosts) + { + if (b.Value.Submitter != 0) + continue; + + if (!urls.Any(x => x.Url == b.Key)) + _ = this.Bot.PhishingHosts.Remove(b.Key); + } + + urls.Clear(); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to update Phishing Urls"); + } + } + + private async Task> GetUrls() + { + List WhitelistedDomains = new(); + Dictionary> SanitizedMatches = new(); + var listFailed = false; + + foreach (var url in new string[] + { + "https://raw.githubusercontent.com/nikolaischunk/discord-tokenlogger-link-list/main/domain-list.json", + "https://raw.githubusercontent.com/nikolaischunk/discord-phishing-links/main/suspicious-list.json", + "https://raw.githubusercontent.com/DevSpen/links/master/src/links.txt", + "https://raw.githubusercontent.com/PoorPocketsMcNewHold/SteamScamSites/master/steamscamsite.txt", + "https://fortunevale.de/discord-scam-urls.txt", + "https://raw.githubusercontent.com/sk-cat/fluffy-blocklist/main/phisising/Discord.txt", + "https://raw.githubusercontent.com/sk-cat/fluffy-blocklist/main/phisising/Facebook.txt", + "https://raw.githubusercontent.com/sk-cat/fluffy-blocklist/main/phisising/Steam.txt", + "https://raw.githubusercontent.com/Vytrah/videogame-scam-blocklist/main/list.txt" + }) + { + try + { + var list = await this.DownloadList(url); + + foreach (var b in list) + { + if (SanitizedMatches.ContainsKey(b)) + SanitizedMatches.First(x => x.Key == b).Value.Add(url); + else + SanitizedMatches.Add(b, new List { url }); + } + + await Task.Delay(1000); + } + catch (Exception ex) + { + listFailed = true; + Log.Warning(ex, "An exception occurred while trying to download URLs from '{url}'", url); + } + } + + try + { + var urls = await this.DownloadList("https://fortunevale.de/discord-scam-urls-whitelist.txt"); + WhitelistedDomains.AddRange(urls); + } + catch (Exception ex) { throw new Exception($"An exception occurred while trying to download URLs from 'https://fortunevale.de/discord-scam-urls-whitelist.txt'", ex); } + + try + { + if (WhitelistedDomains is null || WhitelistedDomains.Count == 0) + throw new Exception($"An exception occurred while trying to remove white listed URLs from blacklist: WhitelistedDomains is empty or null"); + + foreach (var b in WhitelistedDomains) + _ = SanitizedMatches.Remove(b); + } + catch (Exception ex) { throw new Exception($"Failed to remove whitelisted domains from blacklist", ex); } + + return SanitizedMatches.Select(x => (x.Key, x.Value.ToArray(), listFailed)).ToList(); + } + + private async Task> DownloadList(string url) + { + HttpClient client = new(); + + var productValue = new ProductInfoHeaderValue("ProjectMakoto", this.Bot.status.RunningVersion); + var commentValue = new ProductInfoHeaderValue("(+https://fortunevale.de)"); + + client.DefaultRequestHeaders.UserAgent.Add(productValue); + client.DefaultRequestHeaders.UserAgent.Add(commentValue); + + var urls = await client.GetStringAsync(url); + + return urls.Split(new string[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.ToLower() + .Replace("'", "") + .Replace("\"", "") + .Replace(",", "") + .Replace("127.0.0.1", "").Trim()) + .ToList() + .Where(x => !x.StartsWith('#') && !x.StartsWith('!') && x.Contains('.')).ToList(); + } +} \ No newline at end of file diff --git a/ProjectMakoto/Util/PrefixCommandUtil.cs b/ProjectMakoto/Util/PrefixCommandUtil.cs new file mode 100644 index 00000000..b275419f --- /dev/null +++ b/ProjectMakoto/Util/PrefixCommandUtil.cs @@ -0,0 +1,18 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +namespace ProjectMakoto.Util; +public sealed class PrefixCommandUtil +{ + public static async Task SendGroupHelp(Bot _bot, CommandContext ctx, string CommandName) + => _ = new HelpCommand().ExecuteCommand(ctx, _bot, new Dictionary + { + { "command", CommandName } + }); +} diff --git a/ProjectMakoto/Util/TaskWatcher/TaskWatcher.cs b/ProjectMakoto/Util/TaskWatcher/TaskWatcher.cs new file mode 100644 index 00000000..9a0ff653 --- /dev/null +++ b/ProjectMakoto/Util/TaskWatcher/TaskWatcher.cs @@ -0,0 +1,329 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using Serilog; +using Serilog.Events; +using Xorog.UniversalExtensions.EventArgs; + +namespace ProjectMakoto.Util; + +public sealed class TaskWatcher +{ + internal TaskWatcher() + { + this.Start(); + } + + private List TaskList = new(); + + internal void Start() + { + _ = Task.Run(async () => + { + while (true) + { + Thread.Sleep(100); + + if (this.TaskList is null) + { + Environment.Exit((int)ExitCodes.VitalTaskFailed); + } + + if (this.TaskList.Count <= 0) + { + continue; + } + + for (var i = 0; i < this.TaskList.Count; i++) + { + var b = this.TaskList[i]; + + if (b is null) + { + lock (this.TaskList) { _ = this.TaskList.Remove(b); } + i--; + continue; + } + + if (!b.Task.IsCompleted) + continue; + + lock (this.TaskList) { _ = this.TaskList.Remove(b); } + i--; + + if (b.Task.IsCompletedSuccessfully) + { + Log.Verbose("Successfully executed Task:{Id} '{Uuid}' in {Elapsed}ms, Task Count now at {Count}.", + b.Task.Id, b.GetName(), b.CreationTime.GetTimespanSince().TotalMilliseconds.ToString("N0", CultureInfo.CreateSpecificCulture("en-US")), this.TaskList.Count); + + if (b.CustomData is SharedCommandContext sctx) + { + Log.Information("Successfully executed '{Prefix}{Name}' for '{User}' on '{Guild}'", + sctx?.Prefix, + sctx?.CommandName, + sctx?.User?.Id, + sctx?.Guild?.Id); + } + else if (b.CustomData is CommandContext cctx) + { + Log.Information("Successfully executed '{Prefix}{Name}' for '{User}' on '{Guild}'", + cctx?.Prefix, + cctx?.Command.Parent is not null ? $"{cctx.Command.Parent.Name} " : "" + cctx.Command.Name, + cctx?.User?.Id, + cctx?.Guild?.Id); + } + else if (b.CustomData is InteractionContext ictx) + { + Log.Information("Successfully executed '/{Name}' for '{User}' on '{Guild}'", + ictx?.FullCommandName, + ictx?.User?.Id, + ictx?.Guild?.Id); + } + else if (b.CustomData is ContextMenuContext cmctx) + { + Log.Information("Successfully executed '{Name}' for '{User}' on '{Guild}'", + cmctx?.FullCommandName, + cmctx?.User?.Id, + cmctx?.Guild?.Id); + } + + continue; + } + + if (b.CustomData is not null) + { + var Exception = (b.Task.Exception?.GetType() != typeof(AggregateException) ? b.Task.Exception : b.Task.Exception.InnerException); + + if (Exception is DisCatSharp.Exceptions.BadRequestException badReq) + { + try + { Log.Error("Web Request: {Request}", (JsonConvert.SerializeObject(badReq?.WebRequest, Formatting.Indented).Replace("\\", ""))); } + catch { } + try + { Log.Error("Web Response: {Response}", badReq.WebResponse.Response.Replace("\\", "")); } + catch { } + } + + if (b.CustomData is SharedCommandContext sctx) + { + Log.Error(b.Task.Exception, "Failed to execute '{Prefix}{Name}' for '{User}' on '{Guild}', Task Count now at {Count}.", + sctx?.Prefix, + sctx?.CommandName, + sctx?.User?.Id, + sctx?.Guild?.Id, + this.TaskList.Count); + + try + { + _ = sctx.BaseCommand.RespondOrEdit(new DiscordMessageBuilder() + .WithContent(sctx.User.Mention) + .AddEmbed(new DiscordEmbedBuilder() + .WithDescription(sctx.BaseCommand.GetString(sctx.t.Commands.Common.Errors.UnhandledException, true, + new TVar("Message", $"```diff\n-{(Exception?.Message?.SanitizeForCode() ?? "No message captured.")}\n```"), + new TVar("Timestamp", DateTime.UtcNow.AddSeconds(11).ToTimestamp()))) + .AsError(sctx))) + .ContinueWith(x => + { + if (!x.IsCompletedSuccessfully) + return; + + _ = Task.Delay(10000).ContinueWith(_ => + { + sctx.BaseCommand.DeleteOrInvalidate(); + }); + }); + } + catch (Exception ex) { Log.Error(ex, "Failed to notify user about unhandled exception."); } + } + else + { + Log.Error(b.Task.Exception, "Task '{UUID}' failed to execute", b.GetName()); + } + } + else + { + Log.Error(b.Task.Exception, "Task '{UUID}' failed to execute", b.GetName()); + } + + if (b.IsVital) + { + await Task.Delay(1000); + Environment.Exit((int)ExitCodes.VitalTaskFailed); + return; + } + } + } + }).ContinueWith(async x => + { + if (!x.IsCompletedSuccessfully) + { + Log.Error(x.Exception, "TaskWatcher failed to execute"); + await Task.Delay(1000); + Environment.Exit((int)ExitCodes.VitalTaskFailed); + return; + } + }); + } + + internal TaskInfo AddToList(TaskInfo taskInfo) + { + Log.Verbose("Started Task:{uuid}, Task Count now at {Count}.", taskInfo.GetName(), this.TaskList.Count + 1); + lock (this.TaskList) { this.TaskList.Add(taskInfo); } + + return taskInfo; + } + + internal async void LogHandler(Bot bot, object? sender, LogEvent e, int depth = 0) + { + var message = e.RenderMessage(); + + switch (e.Level) + { + case LogEventLevel.Fatal: + { + if (message.ToLower().Contains("'not authenticated.'")) + { + bot.status.DiscordDisconnections++; + + if (bot.status.DiscordDisconnections >= 3) + { + _ = bot.ExitApplication(); + } + else + { + try + { + await Task.Delay(10000); + await bot.DiscordClient.StopAsync(); + await bot.DiscordClient.StartAsync(); + } + catch (Exception ex) + { + Log.Fatal(ex, "Failed to reconnect to discord"); + _ = bot.ExitApplication(); + } + } + } + break; + } + default: + break; + } + + switch (e.Level) + { + case LogEventLevel.Fatal: + case LogEventLevel.Error: + { + try + { + if (bot.status.DiscordInitialized) + { + if (message is "[111] Connection terminated (4000, ''), reconnecting" + or "[111] Connection terminated (-1, ''), reconnecting" + or "[111] Connection terminated (1001, 'CloudFlare WebSocket proxy restarting'), reconnecting") + break; + + var channel = bot.DiscordClient.GetGuilds()[bot.status.LoadedConfig.Discord.DevelopmentGuild].GetChannel(bot.status.LoadedConfig.Channels.ExceptionLog); + + if (channel is null) + { + if (depth > 10) + { + Log.Warning("Could not notify of exception in channel"); + return; + } + + await Task.Delay(1000); + this.LogHandler(bot, sender, e, depth++); + return; + } + + var template = new DiscordEmbedBuilder() + .WithColor(e.Level == LogEventLevel.Fatal ? new DiscordColor("#FF0000") : EmbedColors.Error) + .WithTitle(e.Level.GetName().ToLower().FirstLetterToUpper()) + .WithTimestamp(e.Timestamp); + + List embeds = new(); + + if (e.Exception is not null) + { + void BuildEmbed(Exception ex, bool First) + { + var embed = new DiscordEmbedBuilder(template); + + if (First) + _ = embed.WithDescription($"`{message.SanitizeForCode()}`"); + else + { + embed.Title = ""; + } + + _ = embed.AddField(new DiscordEmbedField("Message", $"```{ex.Message.SanitizeForCode()}```")); + if (!ex.StackTrace.IsNullOrWhiteSpace()) + { + var regex = @"((?:(?:(?:[A-Z]:\\)|(?:\/))[^\\\/]*[\\\/]).*):line (\d{0,10})"; + var b = Regex.Matches(ex.StackTrace, regex); + + if (b.Count > 0) + { + _ = embed.AddField(new DiscordEmbedField("Stack Trace", $"```{Regex.Replace(ex.StackTrace, "in " + regex, "").Replace(" at ", "")}```".TruncateWithIndication(1024, "``` Stack Trace too long, please check logs."))); + _ = embed.AddField(new DiscordEmbedField(b.Count > 1 ? "Files & Lines" : "File & Line", $"{string.Join("\n\n", b.Select(x => $"[`{x.Groups[1].Value[(x.Groups[1].Value.LastIndexOf("ProjectMakoto"))..].Replace("\\", "/")}`]" + + $"(https://github.com/{bot.status.LoadedConfig.Secrets.Github.Username}/{bot.status.LoadedConfig.Secrets.Github.Repository}/blob/{(bot.status.LoadedConfig.Secrets.Github.Branch.IsNullOrWhiteSpace() ? "main" : bot.status.LoadedConfig.Secrets.Github.Branch)}/{x.Groups[1].Value[(x.Groups[1].Value.LastIndexOf("ProjectMakoto"))..].Replace("\\", "/")}#L{x.Groups[2]}) at `Line {x.Groups[2]}`"))}".TruncateWithIndication(1024, "`"))); + } + else + { + _ = embed.AddField(new DiscordEmbedField("Stack Trace", $"```{ex.StackTrace?.SanitizeForCode()}```".TruncateWithIndication(1024, "```"))); + } + } + else + { + _ = embed.AddField(new DiscordEmbedField("Stack Trace", $"```No Stack Trace captured.```")); + } + + _ = embed.AddField(new DiscordEmbedField("Type", $"`{ex?.GetType().FullName ?? "No Type captured."}`".TruncateWithIndication(1024, "`"), true)); + _ = embed.AddField(new DiscordEmbedField("Source", $"`{ex.Source?.SanitizeForCode() ?? "No Source captured."}`".TruncateWithIndication(1024, "`"), true)); + _ = embed.AddField(new DiscordEmbedField("Throwing Method", $"`{ex.TargetSite?.Name ?? "No Method captured"}` in `{ex.TargetSite?.DeclaringType?.Name ?? "No Type captured."}`".TruncateWithIndication(1024, "`"), true)); + _ = embed.WithFooter(ex.HResult.ToString()); + + if ((ex.Data?.Keys?.Count ?? 0) > 0) + _ = embed.AddFields(ex.Data.Keys.Cast().ToDictionary(k => k.ToString(), v => ex.Data[v]).Select(x => new DiscordEmbedField(x.Key, x.Value.ToString().TruncateWithIndication(1024)))); + + embeds.Add(embed); + + if (ex is AggregateException aggr) + foreach (var b in aggr.InnerExceptions) + { + BuildEmbed(b, false); + } + else if (ex.InnerException is not null) + BuildEmbed(ex.InnerException, false); + } + + BuildEmbed(e.Exception, true); + } + + var index = 0; + + while (index < embeds.Count) + { + _ = channel.SendMessageAsync(new DiscordMessageBuilder().AddEmbeds(embeds.Take(25).Select(x => x.Build()))); + index += 25; + } + } + } + catch { } + break; + } + } + } + + internal void TaskStarted(Bot bot, object sender, ScheduledTaskStartedEventArgs e) + => _ = e.Task.Add(bot); +} diff --git a/ProjectMakoto/Util/TaskWatcher/TaskWatcherExtensions.cs b/ProjectMakoto/Util/TaskWatcher/TaskWatcherExtensions.cs new file mode 100644 index 00000000..54e73b31 --- /dev/null +++ b/ProjectMakoto/Util/TaskWatcher/TaskWatcherExtensions.cs @@ -0,0 +1,56 @@ +// Project Makoto +// Copyright (C) 2024 Fortunevale +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY + +using System.Runtime.CompilerServices; + +namespace ProjectMakoto.Util; + +public static class TaskWatcherExtensions +{ + /// + /// Add Task to Watcher without any Context + /// + public static TaskInfo Add(this Task task, Bot bot, [CallerMemberName] string callingMember = "", [CallerFilePath] string callingFile = "", [CallerLineNumber] int callingLine = -1) + => bot.Watcher.AddToList(new TaskInfo(task) + { + CallingMethod = callingMember, + CallingFile = Shorten(callingFile), + CallingLine = callingLine + }); + + /// + /// Add Task to Watcher with Custom Data + /// + public static TaskInfo Add(this Task task, Bot bot, object? customData, [CallerMemberName] string callingMember = "", [CallerFilePath] string callingFile = "", [CallerLineNumber] int callingLine = -1) + => bot.Watcher.AddToList(new TaskInfo(task, customData) + { + CallingMethod = callingMember, + CallingFile = Shorten(callingFile), + CallingLine = callingLine + }); + + /// + /// Mark this Task as vital to the operation of this program. Program will exit if failed. + /// + internal static TaskInfo IsVital(this TaskInfo info) + { + info.IsVital = true; + return info; + } + + private static string Shorten(string callingFile) + { + var v = callingFile.LastIndexOf("ProjectMakoto"); + + return v == -1 ? callingFile : callingFile[v..]; + } + + public static TaskAwaiter GetAwaiter(this TaskInfo info) + => info.Task.GetAwaiter(); +} diff --git a/README.md b/README.md new file mode 100644 index 00000000..ed2d2999 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +

Makoto

+

+

A feature packed discord bot!

+

+ +

+

+

+ +

+ +## What is Makoto? + +**Makoto is a multi-purpose Discord Bot written in C# using .NET 8.** +

+Makoto has a lot of features, current notable features are: +- **No premium features**. (This may change in the future, it'll depend on how viable a hosting this bot is without them. The source code itself will always stay available and you could simply host your own Makoto instance.) +

+- **Music Playback** +- Customizable **protection against phishing** and other malicious websites, with little to no false positives. +- Easy to set up **Reaction Roles**. +- An easy to use **emoji and sticker stealer**. +- A **Bump Reminder** with a subscriber role to never miss bumping your server. +

+- Important **Moderation Features** such as a **detailed Actionlog**, commands to quickly clean up the chat(s) like `purge` or `guild-purge` and more. +- Quick and Easy **Message Translation** through the `Apps` Context Menu. +- **[ScoreSaber API](https://scoresaber.com)** Integration. +- An **experience** system with **role rewards**. +- **Social Commands** like `hug`, `pat` and a few more. +- **Automatic Nickname Normalization**, allowing quickly mentioning people with non-standard characters in their usernames. +- **Invite Tracking** so you can track a Raid's origin with just a few commands***. +

+- A system to **backup** user's **roles** and **nickname** when they leave*. +- **Custom Embed Creator** within Discord. +- **Embeds** for **message links** and **github code**. +- **Automatic Thread Unarchiving**, allowing threads to stay open for as long as you want them to. +- **Automatic Crossposting** so you can have automatic feeds in announcement channels. +- Additional Privacy and Security Features such as the **In-Voice Chat Privacy** or the **automatic bot/user token invalidation**. +

+##### \* Roles with any significant Permissions like Administrator won't be re-applied. In addition, if the user hasn't been on the server for more than 60 days, neither the roles nor the nickname will be reapplied. Also the `clearbackup` command gives moderators ability to remove stored roles. + +##### \** A guild-purge is similar to a purge command. However, instead of scanning just one channel for messages by the specified user, it scans all channels. + +##### \*** This depends on how users can join your server. If they join through invites by, for example, Disboard or through the Vanity Invite, it won't be as easy to track them down. +

+## Getting Makoto + +## [Click here to invite the bot](https://discord.com/api/oauth2/authorize?client_id=947716263394824213&permissions=8&scope=bot%20applications.commands) + +- Phishing Protection is enabled by default, people will be banned if they send a link known to be malicious. To change this, run `/config phishing`. +- Automatic User/Bot Token invalidation is turned on by default. If you don't know what this means, just leave it on. If you know what this means and you don't want this happen, run `/config tokendetection` to disable it. +- Every new server is automatically opted into a global ban system. When someone is known to break Discord's TOS or Community Guidelines, they'll be banned on join or when the ban happens. They will not be banned when the bot is freshly added to your server. To change this behaviour you can use `/config join`. +- You can join a support server [here](https://s.aitsys.dev/makotoguild). +

+## Building, Debugging and Deployment + +We have a step by step guide you can follow to start contributing to or running Makoto [here](./CONTRIBUTING.md). +## Credits + +- [DisCatSharp](https://github.com/Aiko-IT-Systems/DisCatSharp) by Aiko-IT-Systems +- [Lavalink](https://github.com/freyacodes/Lavalink) by freyacodes +- [LibreTranslate](https://github.com/LibreTranslate/LibreTranslate) by LibreTranslate +#### Phishing Link Repositories +- [discord-tokenlogger-link-list](https://github.com/nikolaischunk/discord-tokenlogger-link-list/) by nikolaischunk +- [links](https://github.com/DevSpen/links/) by DevSpen +- [SteamScamSites](https://github.com/PoorPocketsMcNewHold/SteamScamSites/) by PoorPocketsMcNewHold +- [fluffy-blocklist](https://github.com/sk-cat/fluffy-blocklist/) by sk-cat +- [videogame-scam-blocklist](https://github.com/Vytrah/videogame-scam-blocklist/) by Vytrah diff --git a/ResetDevToPreview.sh b/ResetDevToPreview.sh new file mode 100644 index 00000000..cac3e3b7 --- /dev/null +++ b/ResetDevToPreview.sh @@ -0,0 +1,19 @@ +echo "Resetting dev branch in 5 seconds.." +sleep 2 +echo "Resetting dev branch in 3 seconds.." +sleep 1 +echo "Resetting dev branch in 2 seconds.." +sleep 1 +echo "Resetting dev branch in 1 seconds.." +sleep 1 +echo "Resetting dev branch.." +echo "Pulling preview branch.." +git checkout preview +git pull origin preview +echo "Hard resetting dev branch.." +git checkout dev +git reset --hard origin/preview +echo "Pushing reset branch.." +git push --force +sleep 1 +exit \ No newline at end of file diff --git a/ResetPreviewToMain.sh b/ResetPreviewToMain.sh new file mode 100644 index 00000000..b63378b5 --- /dev/null +++ b/ResetPreviewToMain.sh @@ -0,0 +1,19 @@ +echo "Resetting preview branch in 5 seconds.." +sleep 2 +echo "Resetting preview branch in 3 seconds.." +sleep 1 +echo "Resetting preview branch in 2 seconds.." +sleep 1 +echo "Resetting preview branch in 1 seconds.." +sleep 1 +echo "Resetting preview branch.." +echo "Pulling main branch.." +git checkout main +git pull origin main +echo "Hard resetting preview branch.." +git checkout preview +git reset --hard origin/main +echo "Pushing reset branch.." +git push --force +sleep 1 +exit \ No newline at end of file diff --git a/SecretsIgnore.txt b/SecretsIgnore.txt new file mode 100644 index 00000000..7793254f --- /dev/null +++ b/SecretsIgnore.txt @@ -0,0 +1,4 @@ +.*\.pdn +Dependencies/* +OfficialPlugins/* +Tools/ \ No newline at end of file diff --git a/SetupGit.sh b/SetupGit.sh new file mode 100644 index 00000000..73c49127 --- /dev/null +++ b/SetupGit.sh @@ -0,0 +1,2 @@ +git config --local include.path ../.gitconfig +sleep 5 \ No newline at end of file diff --git a/TRANSLATING.md b/TRANSLATING.md new file mode 100644 index 00000000..e0144a1a --- /dev/null +++ b/TRANSLATING.md @@ -0,0 +1,118 @@ +

Makoto

+

+

A feature packed discord bot!

+ +

+

+

+ +

+ +## Creating/modifying Translations + +- All translation can be found in `ProjectMakoto/Translations/strings.json`. +- When adding a new command, add a new entry to the `CommandList` key in `Commands`. + - Remember to include all subcommands, options and choices. + - You can find an example [here](#command-list-reference). +- You should use the [Translation Generator](#translation-generator), if not you need to manually update `ProjectMakoto/Entities/Translation/Translations.cs`. + - If you add, remove, rename or change the type of keys. +- Adding a new translation is quite simple and you do not need the Translation Generator to do so: Simply locate the key you want to add a translation for, add a comma at the end of the last translation and add a valid locale as key name. + +A group usually looks like this: +```json +"TranslationGroup": { + "SingleTranslationKey": { // Single Translation Keys are usually used for single-line translations. + "en": "Example", + "de": "Beispiel" + }, + "MultiTranslationKey": { // Multi Translation Keys are usually used for multi-line translations or variations. + "en": [ + "Example Line 1", + "Example Line 2" + ], + "de": [ + "Beispielzeile 1", + "Beispielzeile 2" + ] + } +} +``` + +Placeholders can be added via `{Placeholder}`, these are values that get replaced at runtime. + +## Translation Generator + +- Makoto has a tool allowing you to automatically generate the class used to reference the translations. +- You can build and start this tool by using the `RunTranslationGenerator.sh` in `ProjectMakoto` or it's official plugins. + +### Command List Reference + +- Valid Locales are: `en`, `de`, `da`, `fr`, `hr`, `it`, `lt`, `hu`, `nl`, `no`, `pl`, `ro`, `fi`, `vi`, `tr`, `cs`, `el`, `bg`, `ru`, `uk`, `hi`, `th`, `ja`, `ko`, `pt-BR`, `sv-SE`, `zh-CN`, `zh-TW`, `es-ES`. + - `en-GB` and `en-US` use `en`. + - You can also find a (probably) more up to date version [here](https://docs.dcs.aitsys.dev/articles/modules/application_commands/translations/reference#valid-locales). + +```json +{ + "Type": 1, // 1 = Slash Command, 2 = User Context Menu, 3 = Message Context Menu + "Names": { + "en": "example", + "de": "beispiel" + }, + "Descriptions": { // If the type is not 1, Descriptions will not be sent to Discord, but are still required for the help command. + "en": "Example Description", + "de": "Beispiel Beschreibung" + }, + "Options": [ // Options are user-selected input. + { + "Names": { + "en": "example1", + "de": "beispiel1" + }, + "Descriptions": { + "en": "Example Description 2", + "de": "Beispiel Beschreibung 2" + }, + "Choices": [ // Choices are enums, provided by the bot. + { + "Names": { + "en": "example", + "de": "beispiel" + } + } + ] + }, + { + "Names": { + "en": "example2", + "de": "beispiel2" + }, + "Descriptions": { + "en": "Example Description 2", + "de": "Beispiel Beschreibung 2" + } + } + ], + "Commands": [ // Commands are Sub-Commands of a group. + { + "Names": { + "en": "example1", + "de": "beispiel1" + }, + "Descriptions": { + "en": "Example Description 2", + "de": "Beispiel Beschreibung 2" + } + }, + { + "Names": { + "en": "example2", + "de": "beispiel2" + }, + "Descriptions": { + "en": "Example Description 2", + "de": "Beispiel Beschreibung 2" + } + } + ] +} +``` \ No newline at end of file diff --git a/TestRun.sh b/TestRun.sh new file mode 100644 index 00000000..3e7bc226 --- /dev/null +++ b/TestRun.sh @@ -0,0 +1 @@ +act -s GITHUB_TOKEN="$(gh auth token)" push -P self-hosted=ghcr.io/catthehacker/ubuntu:act-latest "$@" \ No newline at end of file diff --git a/Tools b/Tools new file mode 160000 index 00000000..9f9113fd --- /dev/null +++ b/Tools @@ -0,0 +1 @@ +Subproject commit 9f9113fdadbf3d2e95f570a8edbe0673c789ddba diff --git a/UpdateSubmodules.sh b/UpdateSubmodules.sh new file mode 100644 index 00000000..09d12bb7 --- /dev/null +++ b/UpdateSubmodules.sh @@ -0,0 +1,5 @@ +echo "Updating submodules.." +git submodule update --depth 1 --remote +echo "Done" +sleep 10 +exit \ No newline at end of file diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 00000000..ad4bc9b0 --- /dev/null +++ b/_typos.toml @@ -0,0 +1,6 @@ +[files] +extend-exclude = ["*.pdn","*.json","Xorog.*"] + +[default.extend-words] +optin = "optin" +Experiation = "Experiation" diff --git a/event.json b/event.json new file mode 100644 index 00000000..97ddbc44 --- /dev/null +++ b/event.json @@ -0,0 +1,3 @@ +{ + "act": true +} \ No newline at end of file diff --git a/hooks/commit-msg b/hooks/commit-msg new file mode 100644 index 00000000..082db8cc --- /dev/null +++ b/hooks/commit-msg @@ -0,0 +1,34 @@ +#!/bin/sh +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo "Duplicate Signed-off-by lines." >&2 + exit 1 +} + +if ! head -1 "$1" | grep -qE "^(Merge branch .*)|((feat|fix|ci|chore|docs|test|style|refactor|revert|perf)(\(.+?\))?!?: .{1,})$"; then + echo "Your commit message is invalid. It must contain any of these prefixes:feat, fix, ci, chore, docs, test, style, refactor, perf or revert. You can suffix a ! to signal a breaking change and/or define the scope of the commit via (scope)." >&2 + exit 1 +fi + +if ! head -1 "$1" | grep -qE "^(Merge branch .*)|(.{1,50})$"; then + echo "Your commit message is too long." >&2 + exit 1 +fi diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..6c349b29 --- /dev/null +++ b/renovate.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ], + "baseBranches": ["dev"], + "prHourlyLimit": 9, + "bumpVersion": "prerelease", + "packageRules": [ + { + "excludePackagePatterns": [ "Extensions" ], + "matchPackagePrefixes": [ "DisCatSharp" ], + "groupName": "discatsharp", + "automerge": true + } + ], + "git-submodules": { + "enabled": true + } +}