From 30d1c2bb6e5d6dc80c5b13c570f8d42f18491f21 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Fri, 25 Feb 2022 03:41:46 -0500
Subject: [PATCH 01/83] Start rewrite

---
 .editorconfig                                 |   44 -
 .github/workflows/dotnet.yml                  |   25 -
 .gitignore                                    |   46 +-
 Fergun.sln                                    |   38 +-
 README.md                                     |  157 +-
 Tests/Fergun.Tests/AiDungeonTests.cs          |  204 --
 Tests/Fergun.Tests/DictionaryTests.cs         |   25 -
 Tests/Fergun.Tests/Fergun.Tests.csproj        |   22 -
 Tests/Fergun.Tests/GScraperTests.cs           |   40 -
 Tests/Fergun.Tests/GoogleTtsTests.cs          |   42 -
 Tests/Fergun.Tests/HastebinTests.cs           |   22 -
 Tests/Fergun.Tests/LyricsTests.cs             |   23 -
 Tests/Fergun.Tests/OpenTriviaDbTests.cs       |   88 -
 Tests/Fergun.Tests/PriorityOrderer.cs         |   43 -
 Tests/Fergun.Tests/TestPriorityAttribute.cs   |   15 -
 Tests/Fergun.Tests/TranslatorTests.cs         |   41 -
 Tests/Fergun.Tests/UrbanDictionaryTests.cs    |   34 -
 Tests/Fergun.Tests/WaybackMachineTests.cs     |   42 -
 Tests/Fergun.Tests/XkcdTests.cs               |   32 -
 nuget.config                                  |    7 -
 src/APIs/AIDungeon/AiDungeonApi.cs            |  155 -
 src/APIs/AIDungeon/AiDungeonException.cs      |   21 -
 src/APIs/AIDungeon/Enums.cs                   |   58 -
 src/APIs/AIDungeon/IAiDungeonEntity.cs        |    7 -
 src/APIs/AIDungeon/IAiDungeonRequest.cs       |    9 -
 src/APIs/AIDungeon/Requests.cs                |  149 -
 src/APIs/AIDungeon/Responses.cs               |   85 -
 src/APIs/ApiFlash.cs                          |  139 -
 src/APIs/Dictionary/DictionaryApi.cs          |   98 -
 src/APIs/Dictionary/Responses.cs              |   60 -
 src/APIs/Genius/GeniusApi.cs                  |   35 -
 src/APIs/Genius/Responses.cs                  |  140 -
 src/APIs/Hastebin.cs                          |   52 -
 src/APIs/OpenTriviaDB/Enums.cs                |   70 -
 src/APIs/OpenTriviaDB/Responses.cs            |  116 -
 src/APIs/OpenTriviaDB/TriviaApi.cs            |  142 -
 src/APIs/UrbanDictionary/Responses.cs         |   48 -
 src/APIs/UrbanDictionary/UrbanApi.cs          |   26 -
 src/APIs/WaybackMachine/WaybackApi.cs         |   30 -
 src/APIs/WaybackMachine/WaybackResponse.cs    |   31 -
 src/Attributes/AlwaysEnabledAttribute.cs      |   12 -
 src/Attributes/ExampleAttribute.cs            |   18 -
 src/Attributes/GitHashInfoAttribute.cs        |   15 -
 src/Attributes/LongRunningAttribute.cs        |   43 -
 src/Attributes/OrderAttribute.cs              |   18 -
 .../Preconditions/DisabledAttribute.cs        |   35 -
 .../Preconditions/RatelimitAttribute.cs       |  190 --
 .../RequireLowerHierarchyAttribute.cs         |   53 -
 .../UserMustBeInVoiceAttribute.cs             |   58 -
 src/Config/AidAdventure.cs                    |   51 -
 src/Config/DatabaseConfig.cs                  |   61 -
 src/Config/FergunConfig.cs                    |  284 --
 src/Config/FergunDatabase.cs                  |  291 --
 src/Config/GuildConfig.cs                     |   91 -
 src/Config/IBlacklistEntity.cs                |   20 -
 src/Config/IIdentity.cs                       |   15 -
 src/Config/MongoConfig.cs                     |   80 -
 src/Config/UserConfig.cs                      |   58 -
 src/Constants.cs                              |  208 +-
 src/Extensions/ChannelExtensions.cs           |  154 +-
 src/Extensions/Extensions.cs                  |  428 +--
 src/Extensions/ImageExtensions.cs             |   35 -
 src/Extensions/InteractionExtensions.cs       |   52 +
 src/Extensions/JsonExtensions.cs              |   28 -
 src/Extensions/ListExtensions.cs              |   15 +
 src/Extensions/MessageExtensions.cs           |  107 +-
 src/Extensions/StringExtensions.cs            |  143 +-
 src/Extensions/TimeSpanExtensions.cs          |   27 -
 src/Extensions/TimestampExtensions.cs         |   16 +
 src/Fergun.csproj                             |   77 +-
 src/FergunClient.cs                           |  480 ----
 src/FergunResult.cs                           |   35 +-
 src/Modules/AIDungeon.cs                      | 1112 -------
 src/Modules/EvaluationEnvironment.cs          |   20 -
 src/Modules/FergunBase.cs                     |  182 --
 .../Handlers/BraveAutocompleteHandler.cs      |   44 +
 .../Handlers/DuckDuckGoAutocompleteHandler.cs |   49 +
 .../Handlers/GoogleAutocompleteHandler.cs     |   47 +
 .../Handlers/TranslateAutocompleteHandler.cs  |   49 +
 .../Handlers/YouTubeAutocompleteHandler.cs    |   47 +
 src/Modules/MessageModule.cs                  |  180 ++
 src/Modules/Moderation.cs                     |  261 --
 src/Modules/Music.cs                          |  410 ---
 src/Modules/Other.cs                          |  963 -------
 src/Modules/Owner.cs                          |  430 ---
 src/Modules/Text.cs                           |  105 -
 src/Modules/UserModule.cs                     |   77 +
 src/Modules/Utility.cs                        | 2559 -----------------
 src/Modules/UtilityModule.cs                  |  514 ++++
 src/Program.cs                                |  145 +-
 src/Readers/UserTypeReader.cs                 |  116 -
 src/Responses/XkcdComic.cs                    |   40 -
 src/Services/BotListService.cs                |  190 --
 src/Services/CommandCacheService.cs           |  416 ---
 src/Services/CommandHandlingService.cs        |  407 ---
 src/Services/InteractionHandlingService.cs    |  123 +
 src/Services/LogService.cs                    |  167 --
 src/Services/MessageCacheService.cs           | 1198 --------
 src/Services/MusicService.cs                  |  586 ----
 src/Services/ReliabilityService.cs            |  174 --
 src/Utils/CommandUtils.cs                     |  224 +-
 src/Utils/GuildUtils.cs                       |  108 -
 src/Utils/StringUtils.cs                      |   65 -
 src/appsettings.json                          |    4 +
 src/strings.Designer.cs                       |   72 -
 src/strings.ar.resx                           | 2189 --------------
 src/strings.es.resx                           | 2179 --------------
 src/strings.resx                              | 2180 --------------
 src/strings.ru.resx                           | 2181 --------------
 src/strings.tr.resx                           | 2180 --------------
 110 files changed, 1540 insertions(+), 26112 deletions(-)
 delete mode 100644 .editorconfig
 delete mode 100644 .github/workflows/dotnet.yml
 delete mode 100644 Tests/Fergun.Tests/AiDungeonTests.cs
 delete mode 100644 Tests/Fergun.Tests/DictionaryTests.cs
 delete mode 100644 Tests/Fergun.Tests/Fergun.Tests.csproj
 delete mode 100644 Tests/Fergun.Tests/GScraperTests.cs
 delete mode 100644 Tests/Fergun.Tests/GoogleTtsTests.cs
 delete mode 100644 Tests/Fergun.Tests/HastebinTests.cs
 delete mode 100644 Tests/Fergun.Tests/LyricsTests.cs
 delete mode 100644 Tests/Fergun.Tests/OpenTriviaDbTests.cs
 delete mode 100644 Tests/Fergun.Tests/PriorityOrderer.cs
 delete mode 100644 Tests/Fergun.Tests/TestPriorityAttribute.cs
 delete mode 100644 Tests/Fergun.Tests/TranslatorTests.cs
 delete mode 100644 Tests/Fergun.Tests/UrbanDictionaryTests.cs
 delete mode 100644 Tests/Fergun.Tests/WaybackMachineTests.cs
 delete mode 100644 Tests/Fergun.Tests/XkcdTests.cs
 delete mode 100644 nuget.config
 delete mode 100644 src/APIs/AIDungeon/AiDungeonApi.cs
 delete mode 100644 src/APIs/AIDungeon/AiDungeonException.cs
 delete mode 100644 src/APIs/AIDungeon/Enums.cs
 delete mode 100644 src/APIs/AIDungeon/IAiDungeonEntity.cs
 delete mode 100644 src/APIs/AIDungeon/IAiDungeonRequest.cs
 delete mode 100644 src/APIs/AIDungeon/Requests.cs
 delete mode 100644 src/APIs/AIDungeon/Responses.cs
 delete mode 100644 src/APIs/ApiFlash.cs
 delete mode 100644 src/APIs/Dictionary/DictionaryApi.cs
 delete mode 100644 src/APIs/Dictionary/Responses.cs
 delete mode 100644 src/APIs/Genius/GeniusApi.cs
 delete mode 100644 src/APIs/Genius/Responses.cs
 delete mode 100644 src/APIs/Hastebin.cs
 delete mode 100644 src/APIs/OpenTriviaDB/Enums.cs
 delete mode 100644 src/APIs/OpenTriviaDB/Responses.cs
 delete mode 100644 src/APIs/OpenTriviaDB/TriviaApi.cs
 delete mode 100644 src/APIs/UrbanDictionary/Responses.cs
 delete mode 100644 src/APIs/UrbanDictionary/UrbanApi.cs
 delete mode 100644 src/APIs/WaybackMachine/WaybackApi.cs
 delete mode 100644 src/APIs/WaybackMachine/WaybackResponse.cs
 delete mode 100644 src/Attributes/AlwaysEnabledAttribute.cs
 delete mode 100644 src/Attributes/ExampleAttribute.cs
 delete mode 100644 src/Attributes/GitHashInfoAttribute.cs
 delete mode 100644 src/Attributes/LongRunningAttribute.cs
 delete mode 100644 src/Attributes/OrderAttribute.cs
 delete mode 100644 src/Attributes/Preconditions/DisabledAttribute.cs
 delete mode 100644 src/Attributes/Preconditions/RatelimitAttribute.cs
 delete mode 100644 src/Attributes/Preconditions/RequireLowerHierarchyAttribute.cs
 delete mode 100644 src/Attributes/Preconditions/UserMustBeInVoiceAttribute.cs
 delete mode 100644 src/Config/AidAdventure.cs
 delete mode 100644 src/Config/DatabaseConfig.cs
 delete mode 100644 src/Config/FergunConfig.cs
 delete mode 100644 src/Config/FergunDatabase.cs
 delete mode 100644 src/Config/GuildConfig.cs
 delete mode 100644 src/Config/IBlacklistEntity.cs
 delete mode 100644 src/Config/IIdentity.cs
 delete mode 100644 src/Config/MongoConfig.cs
 delete mode 100644 src/Config/UserConfig.cs
 delete mode 100644 src/Extensions/ImageExtensions.cs
 create mode 100644 src/Extensions/InteractionExtensions.cs
 delete mode 100644 src/Extensions/JsonExtensions.cs
 create mode 100644 src/Extensions/ListExtensions.cs
 delete mode 100644 src/Extensions/TimeSpanExtensions.cs
 create mode 100644 src/Extensions/TimestampExtensions.cs
 delete mode 100644 src/FergunClient.cs
 delete mode 100644 src/Modules/AIDungeon.cs
 delete mode 100644 src/Modules/EvaluationEnvironment.cs
 delete mode 100644 src/Modules/FergunBase.cs
 create mode 100644 src/Modules/Handlers/BraveAutocompleteHandler.cs
 create mode 100644 src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs
 create mode 100644 src/Modules/Handlers/GoogleAutocompleteHandler.cs
 create mode 100644 src/Modules/Handlers/TranslateAutocompleteHandler.cs
 create mode 100644 src/Modules/Handlers/YouTubeAutocompleteHandler.cs
 create mode 100644 src/Modules/MessageModule.cs
 delete mode 100644 src/Modules/Moderation.cs
 delete mode 100644 src/Modules/Music.cs
 delete mode 100644 src/Modules/Other.cs
 delete mode 100644 src/Modules/Owner.cs
 delete mode 100644 src/Modules/Text.cs
 create mode 100644 src/Modules/UserModule.cs
 delete mode 100644 src/Modules/Utility.cs
 create mode 100644 src/Modules/UtilityModule.cs
 delete mode 100644 src/Readers/UserTypeReader.cs
 delete mode 100644 src/Responses/XkcdComic.cs
 delete mode 100644 src/Services/BotListService.cs
 delete mode 100644 src/Services/CommandCacheService.cs
 delete mode 100644 src/Services/CommandHandlingService.cs
 create mode 100644 src/Services/InteractionHandlingService.cs
 delete mode 100644 src/Services/LogService.cs
 delete mode 100644 src/Services/MessageCacheService.cs
 delete mode 100644 src/Services/MusicService.cs
 delete mode 100644 src/Services/ReliabilityService.cs
 delete mode 100644 src/Utils/GuildUtils.cs
 delete mode 100644 src/Utils/StringUtils.cs
 create mode 100644 src/appsettings.json
 delete mode 100644 src/strings.Designer.cs
 delete mode 100644 src/strings.ar.resx
 delete mode 100644 src/strings.es.resx
 delete mode 100644 src/strings.resx
 delete mode 100644 src/strings.ru.resx
 delete mode 100644 src/strings.tr.resx

diff --git a/.editorconfig b/.editorconfig
deleted file mode 100644
index 606412f..0000000
--- a/.editorconfig
+++ /dev/null
@@ -1,44 +0,0 @@
-root = true
-
-[*]
-
-charset = utf-8
-insert_final_newline = false
-indent_size = 4
-indent_style = space
-trim_trailing_whitespace = true
-
-[*.cs]
-
-dotnet_sort_system_directives_first = true
-
-dotnet_style_qualification_for_event = false:silent
-dotnet_style_qualification_for_field = false:silent
-dotnet_style_qualification_for_method = false:silent
-dotnet_style_qualification_for_property = false:silent
-
-csharp_style_var_for_built_in_types = false
-csharp_style_var_when_type_is_apparent = true
-csharp_style_var_elsewhere = true
-
-csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
-csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
-csharp_style_inlined_variable_declaration = true:suggestion
-
-dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion
-dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields
-dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style
-
-dotnet_naming_symbols.private_internal_fields.applicable_kinds = field
-dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal
-
-dotnet_naming_style.camel_case_underscore_style.required_prefix = _
-dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case
-
-csharp_new_line_before_catch = true
-csharp_new_line_before_else = true
-csharp_new_line_before_finally = true
-csharp_new_line_before_members_in_anonymous_types = true
-csharp_new_line_before_members_in_object_initializers = true
-csharp_new_line_before_open_brace = all
-csharp_new_line_between_query_expression_clauses = true
\ No newline at end of file
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
deleted file mode 100644
index b2b0109..0000000
--- a/.github/workflows/dotnet.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-name: .NET
-
-on:
-  push:
-    branches: [ master ]
-  pull_request:
-    branches: [ master ]
-
-jobs:
-  build:
-
-    runs-on: ubuntu-latest
-
-    steps:
-    - uses: actions/checkout@v2
-    - name: Setup .NET
-      uses: actions/setup-dotnet@v1
-      with:
-        dotnet-version: 6.0.x
-    - name: Restore dependencies
-      run: dotnet restore
-    - name: Build
-      run: dotnet build --no-restore
-    - name: Test
-      run: dotnet test --no-restore --verbosity normal
diff --git a/.gitignore b/.gitignore
index fb05c02..1ee5385 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,12 +3,6 @@
 ##
 ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
 
-# Development module
-**/Modules/Dev.cs
-
-# Lavalink
-Lavalink/
-
 # User-specific files
 *.rsuser
 *.suo
@@ -19,6 +13,9 @@ Lavalink/
 # User-specific files (MonoDevelop/Xamarin Studio)
 *.userprefs
 
+# Mono auto generated files
+mono_crash.*
+
 # Build results
 [Dd]ebug/
 [Dd]ebugPublic/
@@ -26,12 +23,14 @@ Lavalink/
 [Rr]eleases/
 x64/
 x86/
+[Ww][Ii][Nn]32/
 [Aa][Rr][Mm]/
 [Aa][Rr][Mm]64/
 bld/
 [Bb]in/
 [Oo]bj/
 [Ll]og/
+[Ll]ogs/
 
 # Visual Studio 2015/2017 cache/options directory
 .vs/
@@ -45,9 +44,10 @@ Generated\ Files/
 [Tt]est[Rr]esult*/
 [Bb]uild[Ll]og.*
 
-# NUNIT
+# NUnit
 *.VisualState.xml
 TestResult.xml
+nunit-*.xml
 
 # Build Results of an ATL Project
 [Dd]ebugPS/
@@ -62,6 +62,9 @@ project.lock.json
 project.fragment.lock.json
 artifacts/
 
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
 # StyleCop
 StyleCopReport.xml
 
@@ -128,9 +131,6 @@ _ReSharper*/
 *.[Rr]e[Ss]harper
 *.DotSettings.user
 
-# JustCode is a .NET coding add-in
-.JustCode
-
 # TeamCity is a build add-in
 _TeamCity*
 
@@ -141,6 +141,11 @@ _TeamCity*
 .axoCover/*
 !.axoCover/settings.json
 
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
 # Visual Studio code coverage results
 *.coverage
 *.coveragexml
@@ -188,6 +193,8 @@ PublishScripts/
 
 # NuGet Packages
 *.nupkg
+# NuGet Symbol Packages
+*.snupkg
 # The packages folder can be ignored because of Package Restore
 **/[Pp]ackages/*
 # except build/, which is used as an MSBuild target.
@@ -212,6 +219,8 @@ BundleArtifacts/
 Package.StoreAssociation.xml
 _pkginfo.txt
 *.appx
+*.appxbundle
+*.appxupload
 
 # Visual Studio cache files
 # files ending in .cache can be ignored
@@ -261,7 +270,9 @@ ServiceFabricBackup/
 *.bim.layout
 *.bim_*.settings
 *.rptproj.rsuser
-*- Backup*.rdl
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
 
 # Microsoft Fakes
 FakesAssemblies/
@@ -297,10 +308,6 @@ paket-files/
 # FAKE - F# Make
 .fake/
 
-# JetBrains Rider
-.idea/
-*.sln.iml
-
 # CodeRush personal settings
 .cr/personal
 
@@ -344,3 +351,12 @@ ASALocalRun/
 
 # BeatPulse healthcheck temp database
 healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
diff --git a/Fergun.sln b/Fergun.sln
index d6412b1..2192950 100644
--- a/Fergun.sln
+++ b/Fergun.sln
@@ -1,47 +1,25 @@
 
 Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 16
-VisualStudioVersion = 16.0.29806.167
+# Visual Studio Version 17
+VisualStudioVersion = 17.1.31911.260
 MinimumVisualStudioVersion = 10.0.40219.1
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Fergun", "src\Fergun.csproj", "{FF19917E-6D1C-4EBD-A553-F73456BE0138}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C33EBAE0-0E14-4465-9108-814C568171D0}"
-	ProjectSection(SolutionItems) = preProject
-		.editorconfig = .editorconfig
-		nuget.config = nuget.config
-	EndProjectSection
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Fergun.Tests", "Tests\Fergun.Tests\Fergun.Tests.csproj", "{5CC63A1D-B041-458D-96F7-BAA0FC33434C}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fergun", "src\Fergun.csproj", "{2084CFF0-83BC-46FA-A969-479DE6282984}"
 EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
-		DebugLabs|Any CPU = DebugLabs|Any CPU
 		Release|Any CPU = Release|Any CPU
-		ReleaseLabs|Any CPU = ReleaseLabs|Any CPU
 	EndGlobalSection
 	GlobalSection(ProjectConfigurationPlatforms) = postSolution
-		{FF19917E-6D1C-4EBD-A553-F73456BE0138}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{FF19917E-6D1C-4EBD-A553-F73456BE0138}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{FF19917E-6D1C-4EBD-A553-F73456BE0138}.DebugLabs|Any CPU.ActiveCfg = DebugLabs|Any CPU
-		{FF19917E-6D1C-4EBD-A553-F73456BE0138}.DebugLabs|Any CPU.Build.0 = DebugLabs|Any CPU
-		{FF19917E-6D1C-4EBD-A553-F73456BE0138}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{FF19917E-6D1C-4EBD-A553-F73456BE0138}.Release|Any CPU.Build.0 = Release|Any CPU
-		{FF19917E-6D1C-4EBD-A553-F73456BE0138}.ReleaseLabs|Any CPU.ActiveCfg = ReleaseLabs|Any CPU
-		{FF19917E-6D1C-4EBD-A553-F73456BE0138}.ReleaseLabs|Any CPU.Build.0 = ReleaseLabs|Any CPU
-		{5CC63A1D-B041-458D-96F7-BAA0FC33434C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{5CC63A1D-B041-458D-96F7-BAA0FC33434C}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{5CC63A1D-B041-458D-96F7-BAA0FC33434C}.DebugLabs|Any CPU.ActiveCfg = DebugLabs|Any CPU
-		{5CC63A1D-B041-458D-96F7-BAA0FC33434C}.DebugLabs|Any CPU.Build.0 = DebugLabs|Any CPU
-		{5CC63A1D-B041-458D-96F7-BAA0FC33434C}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{5CC63A1D-B041-458D-96F7-BAA0FC33434C}.Release|Any CPU.Build.0 = Release|Any CPU
-		{5CC63A1D-B041-458D-96F7-BAA0FC33434C}.ReleaseLabs|Any CPU.ActiveCfg = ReleaseLabs|Any CPU
-		{5CC63A1D-B041-458D-96F7-BAA0FC33434C}.ReleaseLabs|Any CPU.Build.0 = ReleaseLabs|Any CPU
+		{2084CFF0-83BC-46FA-A969-479DE6282984}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{2084CFF0-83BC-46FA-A969-479DE6282984}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{2084CFF0-83BC-46FA-A969-479DE6282984}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{2084CFF0-83BC-46FA-A969-479DE6282984}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
-		SolutionGuid = {C45D081F-C4E5-4F74-B770-CBEC38915AC1}
+		SolutionGuid = {9A586EE0-5827-4A7C-8383-549C6D9D021C}
 	EndGlobalSection
 EndGlobal
diff --git a/README.md b/README.md
index 08a4140..e706c34 100644
--- a/README.md
+++ b/README.md
@@ -1,158 +1,3 @@
 # Fergun
-[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Discord](https://discord.com/api/guilds/460627183501574144/widget.png)](https://discord.gg/V3TgaZRUPX)
 
-Fergun is a multipurpose and multilanguage bot with lots of useful commands (Utility, Music, Moderation, and AI Dungeon).
-
-You can invite Fergun to your Discord server clicking [here](https://discord.com/oauth2/authorize?client_id=680507783359365121&permissions=204860486&scope=bot%20applications.commands).
-
-Have any questions or need help with the bot? Join the [support server](https://discord.gg/5w5GEKE).
-
-## Supported Languages
-| Language | Human Translation |
-| --       | --  |
-| English  | ✅ |
-| Spanish  | ✅ |
-| Arabic   | ❌ |
-| Turkish  | ❌ |
-| Russian  | ❌ |
-
-
-## Setup
-### 0. Prerequisites
-* A Discord bot application (You can create one [here](https://discord.com/developers/applications)).
-
-* [.NET 6 SDK](https://dotnet.microsoft.com/download)
-
-* A MongoDB server (You can get a Free Tier cluster [here](https://docs.atlas.mongodb.com/tutorial/deploy-free-tier-cluster/) or install the system to your local machine [here](https://docs.mongodb.com/manual/administration/install-community/)).
-
-
-### 1. Building the bot
-* Clone  the repository:
-  `git clone https://github.com/d4n3436/Fergun.git`
-
-  Or [download from GitHub](https://github.com/d4n3436/Fergun/archive/master.zip).
-
-* Build the bot (change `Release`/`ReleaseLabs` to `Debug`/`DebugLabs`<sup id="7-interactions-support">[note](#f1)</sup> in a debug build):
-  ```
-  cd Fergun
-  dotnet build -c Release
-  ```
-
-### 2. Setting up a local Lavalink server (Optional)
-If you want to use the music module with a local Lavalink server, follow these steps:
-*  [Install JDK 11+](https://www.oracle.com/java/technologies/javase-jdk11-downloads.html)
-
-* Create a folder in the build folder called "Lavalink" (change `Release`/`ReleaseLabs` to `Debug`/`DebugLabs`<sup id="7-interactions-support">[note](#f1)</sup> in a debug build):
-  ```
-  cd src/bin/Release/net6.0
-  mkdir Lavalink
-  ```
-  
-* Download the [Lavalink binaries](https://github.com/freyacodes/Lavalink/releases/latest/download/Lavalink.jar) and save it in the folder:
- 
-  `wget https://github.com/freyacodes/Lavalink/releases/latest/download/Lavalink.jar -O Lavalink.jar`
- 
-  [Nightly binaries](https://ci.fredboat.com/repository/download/Lavalink_Build/lastSuccessful/Lavalink.jar?guest=1):
- 
-  `wget https://ci.fredboat.com/repository/download/Lavalink_Build/lastSuccessful/Lavalink.jar?guest=1 -O Lavalink.jar`
- 
- * Download [application.yml](https://raw.githubusercontent.com/freyacodes/Lavalink/master/LavalinkServer/application.yml.example%20-O%20application.yml) and save it in the folder:
- 
-   `wget https://raw.githubusercontent.com/freyacodes/Lavalink/master/LavalinkServer/application.yml.example -O application.yml`
- 
-   Be sure to save it as `application.yml` and not `application.yml.example`.
-
-
-### 3. Running the bot
-* Go to the build folder if you haven't done it before (change `Release`/`ReleaseLabs` to `Debug`/`DebugLabs`<sup id="7-interactions-support">[note](#f1)</sup> in a debug build):
- 
-  `cd src/bin/Release/net6.0`
- 
-* Start the bot by double clicking `Fergun.exe` or with the command `dotnet Fergun.dll`.
-
-* You will see the error message: "No config file found. Creating default config file." The error is self explanatory.
-
-* In the build folder, open the file `botconfig.json` with a text editor.
-
-* Copy your bot token and paste it in the `Token` or `DevToken` field (Release or Debug build).
-
-* Fill the database login information in `DatabaseConfig` (If you're using a local database and no authentication then you don't have to change anything).
-
-* If you're using a remote Lavalink server you may also want to change the `Hostname` and `Authorization` fields in `LavaConfig`.
-
-* Start the bot again, now the bot should be running with the minimal config.
-
-Note: If you set up a local Lavalink server the bot should be running the server automatically.
-
-
-### 4. Testing and changing the default prefix
-The default bot prefix is `f!` (`f!!` in Debug builds), a `@mention` can also be used as a prefix.
-
-To test the bot use the `ping` command: `f!ping`.
-You should see an embed with the response times.
-
-To change the default (global) bot prefix use `globalprefix <newPrefix>`: `f!globalprefix !`.
-This will set the global prefix to `!` and save it in the database.
-
-To change the prefix in the current server simply use `prefix <newPrefix>`.
-
-To change the language in the current server use `language`.
-
-To shut down the bot use `logout`.
-
-
-### 5. More configuration
-`botconfig.json` documentation:
-
-| Key | Description | How to get one / Notes
-|--|--|--|
-| `Token` | The bot token. | [Create a Discord application](https://discord.com/developers/applications).
-| `DevToken` | The development bot token, used in Debug builds. | ^
-| `TopGgApiToken` | The top.gg API token, used to update the bot server count in top.gg. | [Add a bot in top.gg](https://top.gg/bot/new), then [here](https://top.gg/api/docs).
-| `DiscordBotsApiToken` | The Discord Bots API token, used to update the bot server count in discord.bots.gg. | [Add a bot in discord.bots.gg](https://discord.bots.gg/bots/add), then [here](https://discord.bots.gg/docs).
-| `GeniusApiToken` | The Genius API token, used in the commands `lyrics` and `spotify`. | https://docs.genius.com
-| `AiDungeonToken` | The AI Dungeon user token, used in the AI Dungeon module. | See [below](#6-obtaining-the-ai-dungeon-token).
-| `DeepAiApiKey` | The DeepAI API key, used in `resize`. | https://deepai.org/api-docs
-| `ApiFlashAccessKey` | The ApiFlash access key, used in `screenshot` and `archive`. | https://apiflash.com
-| `WolframAlphaAppId` | The WolframAlpha App ID, used in `wolframalpha`. | https://products.wolframalpha.com/api
-| `EmbedColor` | The raw value of the color the bot will use in its embeds. | The default value is 16750877 or orange :)
-| `SupportServer` | The support server invite. | Get a server invite.
-| `LogChannel` | The ID of the channel the bot will send error logs. | Create a text channel and copy the ID.
-| `PresenceIntent` | Whether the Guild Presences intent should be used. Used in multiple commands, required in `spotify`. | If your bot is in more than 100 servers this requires [verification and whitelisting](https://support.discord.com/hc/en-us/articles/360040720412).
-| `ServerMembersIntent` | Whether the Guild Members intent should be used. Used in user join/leave/kick events and for downloading the entire member list. | If your bot is in more than 100 servers this requires [verification and whitelisting](https://support.discord.com/hc/en-us/articles/360040720412)
-| `MessageCacheSize` | The message cache size, used in commands that gets cached messages in a channel. | The default value is 100, setting this to 0 disables the message cache.
-| `MessagesToSearchLimit` | The number of messages to search in a channel. | This is used in commands that searches for a Url in the messages of a channel.
-| `AlwaysDownloadUsers` | Whether all users should be downloaded to the cache. | `ServerMembersIntent` is required for this to work.
-| `UseReliabilityService` | Whether the reliability service should be used. | The reliability service is a service that shutdowns the bot in case of a deadlock.<br/>The service requires that the bot is being run by a daemon that handles Exit Code 1 as a restart.<br/>Daemon for [Powershell](https://gitlab.com/snippets/21444) and [Bash](https://stackoverflow.com/a/697064).
-| `UseCommandCacheService` | Whether the command cache service should be used. | The command cache service is a service that tracks command (user) messages and the bot response messages.<br/>When a command message is modified or deleted, the bot will also modify or delete the corresponding response message automatically.
-| `UseMessageCacheService` | Whether the message cache service should be used. | The command message service is a service that stores deleted and modified messages temporarily.<br/>This service is used in the "snipe" commands.
-| `MinimumCommandTime` | The minimum hours since a command has to be used in a server for the messages to be cached there. | This is used in the optimized message cache.<br/> Setting this to 0 disables this requirement.
-| `DonationUrl` | The donation Url. | ...
-| `TotalShards` | The total number of shards to use. | If this is set to null, the bot will get the recommended shard count from Discord.
-| `LogLevel` | The minimum log level. | The default value is 4 (Verbose).
-| `DatabaseConfig` | The database configuration. | ...
-| `LavaConfig` | The Lavalink server configuration | ...
-| `(...)Emote` | The emotes that are used in some commands. | `LoadingEmote` is used in a "Loading" message.<br/>`MongoDbEmote` and `WebSocketEmote` are used in `ping`.<br/>`BoosterEmote` and `UserFlagsEmotes` are used in `userinfo`.<br/>The rest are used in `serverinfo`.
-
-### 6. Obtaining the AI Dungeon token
-
-(These steps may differ depending on what web browser you're using. Here I'll use Google Chrome.)
-
-* Go to https://play.aidungeon.io in your web browser and log in.
-* Open the Developer tools (press F12).
-* Go to the `Network` tab and click the `WS` button.
-* Press F5. A `subscriptions` connection should appear.
-* Click it, go to the `Messages` tab and scroll up to the first message.
-* Click the first message. The token is the value of the `token` key.
-
-### 7. Interactions support
-<b id="f1"></b>
-Fergun now supports interactions with Discord.Net Labs. It is currently used to replace reactions with buttons/select menus in paginators and selections. To be able to use interactions you will need to build the bot using either the `ReleaseLabs` or the `DebugLabs` configuration.
-
-Update: Now that Fergun is using Discord.Net 3.0, it is no longer necessary to use the `ReleaseLabs`/`DebugLabs` configurations. These configurations will be kept if someone wants to use Discord.Net Labs instead of Discord.Net.
-
-## Contributing
-Feel free to report bugs or request new features via issues or pull requests. Requesting new commands may or may not be accepted depending on the utility and usability of that command.
-
-## License
-Fergun is licensed under the [MIT license](LICENSE).
+Rewrite WIP
\ No newline at end of file
diff --git a/Tests/Fergun.Tests/AiDungeonTests.cs b/Tests/Fergun.Tests/AiDungeonTests.cs
deleted file mode 100644
index c5a687f..0000000
--- a/Tests/Fergun.Tests/AiDungeonTests.cs
+++ /dev/null
@@ -1,204 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using Fergun.APIs.AIDungeon;
-using Xunit;
-
-namespace Fergun.Tests
-{
-    public class AiDungeonFixture
-    {
-        public string Token { get; set; }
-
-        public string[] InitialPromptWords { get; set; }
-
-        public string NormalAdventureId { get; set; }
-
-        public long LastActionId { get; set; }
-    }
-
-    [CollectionDefinition(nameof(AiDungeonTests), DisableParallelization = true)]
-    public class AiDungeonTestsCollectionDefinition : ICollectionFixture<AiDungeonFixture>
-    {
-    }
-
-    // These tests have to run sequentially because first we need to create an anonymous account, then create, use, and delete an adventure, in that order.
-    [TestCaseOrderer("Fergun.Tests.PriorityOrderer", "Fergun.Tests")]
-    [Collection(nameof(AiDungeonTests))]
-    public class AiDungeonTests
-    {
-        private readonly AiDungeonFixture _fixture;
-
-        public AiDungeonTests(AiDungeonFixture fixture)
-        {
-            _fixture = fixture;
-        }
-
-        [Fact]
-        [TestPriority(0)]
-        public async Task GetAnonymousAccountTest()
-        {
-            var api = new AiDungeonApi();
-            // Act
-            var account = await api.CreateAnonymousAccountAsync();
-            _fixture.Token = account.AccessToken;
-
-            // Assert
-            Assert.NotNull(_fixture.Token);
-
-            api = new AiDungeonApi(_fixture.Token);
-
-            // Get game settings id
-            var user = await api.GetAccountInfoAsync();
-
-            string gameId = user.GameSettings.Id.ToString();
-
-            // Disable safe mode
-            await api.DisableSafeModeAsync(gameId);
-        }
-
-        [Fact]
-        [TestPriority(1)]
-        public async Task CreateAdventureTest()
-        {
-            var api = new AiDungeonApi(_fixture.Token);
-            var rng = new Random();
-
-            // Get mode list
-            var scenario = await api.GetScenarioAsync(AiDungeonApi.AllScenariosId);
-
-            Assert.NotEmpty(scenario.Options);
-
-            var filteredModeList = scenario.Options
-                .Where(x => !x.Title?.Contains("custom", StringComparison.OrdinalIgnoreCase) ?? false)
-                .ToList();
-
-            string id = filteredModeList[rng.Next(filteredModeList.Count)].PublicId.ToString();
-
-            // Get scenario list from a random mode
-            scenario = await api.GetScenarioAsync(id);
-
-            Assert.NotEmpty(scenario.Options);
-
-            var filteredScenarioList = scenario.Options
-                .Where(x => !x.Title?.Contains("custom", StringComparison.OrdinalIgnoreCase) ?? false)
-                .ToList();
-
-            id = filteredScenarioList[rng.Next(filteredScenarioList.Count)].PublicId.ToString();
-
-            // Get character from a random scenario
-            scenario = await api.GetScenarioAsync(id);
-
-            // Create adventure
-            var adventure = await api.CreateAdventureAsync(scenario.Id.ToString(), scenario.Prompt?.Replace("${character.name}", "Fergun", StringComparison.OrdinalIgnoreCase));
-
-            string publicId = adventure.PublicId?.ToString();
-
-            Assert.NotNull(publicId);
-
-            // Get adventure
-            adventure = await api.GetAdventureAsync(publicId);
-
-            _fixture.NormalAdventureId = adventure.PublicId?.ToString();
-
-            var actions = adventure.Actions.Where(x => !string.IsNullOrEmpty(x.Text)).ToList();
-
-            Assert.NotEmpty(actions);
-
-            // Get initial prompt
-            string initialPrompt = actions[^1].Text;
-            if (actions.Count > 1)
-            {
-                actions.RemoveAt(actions.Count - 1);
-                initialPrompt = string.Concat(actions.Select(x => x.Text)) + initialPrompt;
-            }
-
-            // Get initial prompt words
-            _fixture.InitialPromptWords = initialPrompt.Split(' ');
-        }
-
-        [Theory]
-        [MemberData(nameof(Actions))]
-        [TestPriority(2)]
-        public async Task PlayAdventureTest(ActionType actionType)
-        {
-            // Arrange
-            if (actionType == ActionType.Alter)
-                return;
-
-            var api = new AiDungeonApi(_fixture.Token);
-            var rng = new Random();
-            string text = "";
-
-            // Generate random text based on the initial prompt
-            for (int i = 0; i < 20; i++)
-            {
-                text += $" {_fixture.InitialPromptWords[rng.Next(_fixture.InitialPromptWords.Length)]}";
-            }
-
-            // Act
-            var response = await api.SendActionAsync(_fixture.NormalAdventureId, actionType, text);
-
-            // Assert
-            Assert.NotEmpty(response.Actions);
-        }
-
-        [Fact]
-        [TestPriority(3)]
-        public async Task GetAdventureTest()
-        {
-            // Arrange
-            var api = new AiDungeonApi(_fixture.Token);
-
-            // Act
-            var adventure = await api.GetAdventureAsync(_fixture.NormalAdventureId);
-
-            // Assert
-            Assert.NotEmpty(adventure.Actions);
-
-            _fixture.LastActionId = adventure.Actions[^1].Id;
-        }
-
-        [Fact]
-        [TestPriority(4)]
-        public async Task AlterAdventureTest()
-        {
-            // Arrange
-            var api = new AiDungeonApi(_fixture.Token);
-            var rng = new Random();
-            string text = "";
-
-            // Generate random text based on the initial prompt
-            for (int i = 0; i < 20; i++)
-            {
-                text += $" {_fixture.InitialPromptWords[rng.Next(_fixture.InitialPromptWords.Length)]}";
-            }
-
-            // Act
-            var adventure = await api.SendActionAsync(_fixture.NormalAdventureId, ActionType.Alter, text, _fixture.LastActionId);
-
-            // Assert
-            Assert.NotEmpty(adventure.Actions);
-        }
-
-        [Fact]
-        [TestPriority(5)]
-        public async Task DeleteAdventureTest()
-        {
-            // Arrange
-            var api = new AiDungeonApi(_fixture.Token);
-
-            // Act
-            await api.DeleteAdventureAsync(_fixture.NormalAdventureId);
-        }
-
-        public static IEnumerable<object[]> Actions()
-        {
-            foreach (var value in Enum.GetValues(typeof(ActionType)))
-            {
-                yield return new[] { value };
-            }
-        }
-    }
-}
\ No newline at end of file
diff --git a/Tests/Fergun.Tests/DictionaryTests.cs b/Tests/Fergun.Tests/DictionaryTests.cs
deleted file mode 100644
index 5d9acfb..0000000
--- a/Tests/Fergun.Tests/DictionaryTests.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using System.Threading.Tasks;
-using Fergun.APIs.Dictionary;
-using Xunit;
-
-namespace Fergun.Tests
-{
-    public class DictionaryTests
-    {
-        [Theory]
-        [InlineData("make", "en", false)]
-        [InlineData("day", "en", false)]
-        [InlineData("tabla", "es", false)]
-        [InlineData("jeux", "fr", false)]
-        [InlineData("mixture", "ru", true)]
-        [InlineData("result", "de", true)]
-        public async Task ResultNotEmptyTest(string word, string language, bool fallback)
-        {
-            // Act
-            var results = await DictionaryApi.GetDefinitionsAsync(word, language, fallback);
-
-            // Assert
-            Assert.NotEmpty(results);
-        }
-    }
-}
\ No newline at end of file
diff --git a/Tests/Fergun.Tests/Fergun.Tests.csproj b/Tests/Fergun.Tests/Fergun.Tests.csproj
deleted file mode 100644
index 598d7c7..0000000
--- a/Tests/Fergun.Tests/Fergun.Tests.csproj
+++ /dev/null
@@ -1,22 +0,0 @@
-<Project Sdk="Microsoft.NET.Sdk">
-
-  <PropertyGroup>
-    <TargetFramework>net6.0</TargetFramework>
-    <IsPackable>false</IsPackable>
-    <Configurations>Debug;Release;DebugLabs;ReleaseLabs</Configurations>
-  </PropertyGroup>
-
-    <ItemGroup>
-    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
-    <PackageReference Include="xunit" Version="2.4.1" />
-    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
-      <PrivateAssets>all</PrivateAssets>
-      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
-    </PackageReference>
-  </ItemGroup>
-
-  <ItemGroup>
-    <ProjectReference Include="..\..\src\Fergun.csproj" />
-  </ItemGroup>
-
-</Project>
diff --git a/Tests/Fergun.Tests/GScraperTests.cs b/Tests/Fergun.Tests/GScraperTests.cs
deleted file mode 100644
index d5df6f5..0000000
--- a/Tests/Fergun.Tests/GScraperTests.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using System.Net.Http;
-using System.Threading.Tasks;
-using GScraper;
-using GScraper.Brave;
-using GScraper.DuckDuckGo;
-using GScraper.Google;
-using Xunit;
-
-namespace Fergun.Tests
-{
-    public class GScraperTests
-    {
-        [Theory]
-        [InlineData("Hello world", SafeSearchLevel.Off)]
-        [InlineData("Discord", SafeSearchLevel.Moderate)]
-        [InlineData("Dogs", SafeSearchLevel.Strict)]
-        [InlineData("Cats", SafeSearchLevel.Off)]
-        public async Task GScraperAvailableTest(string query, SafeSearchLevel safeSearch)
-        {
-            // Arrange
-            var googleScraper = new GoogleScraper();
-
-            var ddgClient = new HttpClient();
-            ddgClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:77.0) Gecko/20100101 Firefox/77.0");
-            var ddgScraper = new DuckDuckGoScraper(ddgClient);
-
-            var braveScraper = new BraveScraper();
-
-            // Act
-            var googleImages = await googleScraper.GetImagesAsync(query, safeSearch);
-            var ddgImages = await ddgScraper.GetImagesAsync(query, safeSearch);
-            var braveImages = await braveScraper.GetImagesAsync(query, safeSearch);
-
-            // Assert
-            Assert.NotEmpty(googleImages);
-            Assert.NotEmpty(ddgImages);
-            Assert.NotEmpty(braveImages);
-        }
-    }
-}
\ No newline at end of file
diff --git a/Tests/Fergun.Tests/GoogleTtsTests.cs b/Tests/Fergun.Tests/GoogleTtsTests.cs
deleted file mode 100644
index 7acd1ca..0000000
--- a/Tests/Fergun.Tests/GoogleTtsTests.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-using System;
-using System.IO;
-using System.Threading.Tasks;
-using Fergun.APIs;
-using GTranslate.Translators;
-using Xunit;
-
-namespace Fergun.Tests
-{
-    public class GoogleTtsTests
-    {
-        [Theory]
-        [InlineData("Hello world", "en", false)]
-        [InlineData("Hola mundo", "es", false)]
-        [InlineData("The quick brown fox jumps over the lazy dog.", "en", true)]
-        [InlineData("La rapida volpe marrone salta sopra il cane pigro.", "it", true)]
-        [InlineData("El zorro marrón rápido salta sobre el perro perezoso.", "es", false)]
-        [InlineData("Discord is an American VoIP, instant messaging and digital distribution platform designed for creating communities.", "en", true)]
-        public async Task TtsAvailableTest(string text, string language, bool slow)
-        {
-            // Act
-            var translator = new GoogleTranslator2();
-            var stream = await translator.TextToSpeechAsync(text, language, slow);
-
-            await using var ms = new MemoryStream();
-            await stream.CopyToAsync(ms);
-            // Assert
-            Assert.NotEmpty(ms.ToArray());
-        }
-
-        [Theory]
-        [InlineData(null, "es")]
-        [InlineData("Hello world", null)]
-        public async Task TtsInvalidArgumentTest(string text, string language)
-        {
-            var translator = new GoogleTranslator2();
-
-            // Act and Assert
-            await Assert.ThrowsAsync<ArgumentNullException>(async () => await translator.TextToSpeechAsync(text, language));
-        }
-    }
-}
\ No newline at end of file
diff --git a/Tests/Fergun.Tests/HastebinTests.cs b/Tests/Fergun.Tests/HastebinTests.cs
deleted file mode 100644
index 6ca7ee1..0000000
--- a/Tests/Fergun.Tests/HastebinTests.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using System.Threading.Tasks;
-using Fergun.APIs;
-using Xunit;
-
-namespace Fergun.Tests
-{
-    public class HastebinTests
-    {
-        [Theory]
-        [InlineData("hi")]
-        [InlineData("The quick brown fox jumps over the lazy dog.")]
-        [InlineData("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.")]
-        public async Task HastebinAvailableTest(string content)
-        {
-            // Act
-            var response = await Hastebin.UploadAsync(content);
-
-            // Assert
-            Assert.False(string.IsNullOrEmpty(response));
-        }
-    }
-}
\ No newline at end of file
diff --git a/Tests/Fergun.Tests/LyricsTests.cs b/Tests/Fergun.Tests/LyricsTests.cs
deleted file mode 100644
index f54eb2d..0000000
--- a/Tests/Fergun.Tests/LyricsTests.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using System.Threading.Tasks;
-using Fergun.Utils;
-using Xunit;
-
-namespace Fergun.Tests
-{
-    public class LyricsTests
-    {
-        [Theory(Skip = "Temporarily disabled until a solution for the IP block is found")]
-        [InlineData("https://genius.com/Luis-fonsi-despacito-lyrics", true)]
-        [InlineData("https://genius.com/Eminem-rap-god-lyrics", true)]
-        [InlineData("https://genius.com/Ed-sheeran-shape-of-you-lyrics", false)]
-        [InlineData("https://genius.com/Queen-bohemian-rhapsody-lyrics", false)]
-        public async Task LyricsAvailableTest(string url, bool keepHeaders)
-        {
-            // Act
-            string lyrics = await CommandUtils.ParseGeniusLyricsAsync(url, keepHeaders);
-
-            // Assert
-            Assert.False(string.IsNullOrWhiteSpace(lyrics));
-        }
-    }
-}
\ No newline at end of file
diff --git a/Tests/Fergun.Tests/OpenTriviaDbTests.cs b/Tests/Fergun.Tests/OpenTriviaDbTests.cs
deleted file mode 100644
index 096b38e..0000000
--- a/Tests/Fergun.Tests/OpenTriviaDbTests.cs
+++ /dev/null
@@ -1,88 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading.Tasks;
-using Fergun.APIs.OpenTriviaDB;
-using Xunit;
-
-namespace Fergun.Tests
-{
-    public class OpenTriviaDbTests
-    {
-        [Theory]
-        [InlineData(50, QuestionCategory.Any, QuestionDifficulty.Any, QuestionType.Any, ResponseEncoding.Default)]
-        [InlineData(10, QuestionCategory.Computers, QuestionDifficulty.Easy, QuestionType.Multiple, ResponseEncoding.url3986)]
-        [InlineData(30, QuestionCategory.VideoGames, QuestionDifficulty.Hard, QuestionType.Multiple, ResponseEncoding.base64)]
-        [InlineData(1, QuestionCategory.Animals, QuestionDifficulty.Medium, QuestionType.Boolean, ResponseEncoding.urlLegacy)]
-        public async Task QuestionsNotEmptyTest(uint amount, QuestionCategory category, QuestionDifficulty difficulty, QuestionType type, ResponseEncoding encoding)
-        {
-            // Act
-            var response = await TriviaApi.RequestQuestionsAsync(amount, category, difficulty, type, encoding);
-
-            // Assert
-            Assert.NotEmpty(response.Questions);
-        }
-
-        [Theory]
-        [InlineData(0)]
-        [InlineData(300)]
-        [InlineData(100)]
-        public async Task QuestionsInvalidAmountTest(uint amount)
-        {
-            // Act and Assert
-            await Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () => await TriviaApi.RequestQuestionsAsync(amount));
-        }
-
-        [Fact]
-        public async Task CategoryListNotEmptyTest()
-        {
-            // Act
-            var response = await TriviaApi.RequestCategoryListAsync();
-
-            // Assert
-            Assert.NotEmpty(response.CategoryList);
-        }
-
-        [Fact]
-        public async Task GlobalQuestionCountNotEmptyTest()
-        {
-            // Act
-            var response = await TriviaApi.RequestGlobalQuestionCountAsync();
-
-            // Assert
-            Assert.NotEmpty(response.CategoriesQuestionCount);
-            Assert.NotNull(response.Overall);
-        }
-
-        [Theory]
-        [MemberData(nameof(Categories))]
-        public async Task NumberOfQuestionsInCategoryNotNullTest(QuestionCategory category)
-        {
-            // Arrange
-            if (category == QuestionCategory.Any) return;
-
-            // Act
-            var response = await TriviaApi.RequestNumberOfQuestionsInCategoryAsync(category);
-
-            // Assert
-            Assert.NotNull(response.CategoryQuestionCount);
-        }
-
-        [Fact]
-        public async Task SessionTokenNotNullTest()
-        {
-            // Act
-            var response = await TriviaApi.SendSessionTokenCommandAsync(TokenCommand.Request);
-
-            // Assert
-            Assert.NotNull(response.Token);
-        }
-
-        public static IEnumerable<object[]> Categories()
-        {
-            foreach (var value in Enum.GetValues(typeof(QuestionCategory)))
-            {
-                yield return new[] { value };
-            }
-        }
-    }
-}
\ No newline at end of file
diff --git a/Tests/Fergun.Tests/PriorityOrderer.cs b/Tests/Fergun.Tests/PriorityOrderer.cs
deleted file mode 100644
index 3e1c1d9..0000000
--- a/Tests/Fergun.Tests/PriorityOrderer.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using Xunit.Abstractions;
-using Xunit.Sdk;
-
-namespace Fergun.Tests
-{
-    internal class PriorityOrderer : ITestCaseOrderer
-    {
-        public IEnumerable<TTestCase> OrderTestCases<TTestCase>(IEnumerable<TTestCase> testCases) where TTestCase : ITestCase
-        {
-            var sortedMethods = new SortedDictionary<int, List<TTestCase>>();
-
-            foreach (var testCase in testCases)
-            {
-                int priority = 0;
-
-                foreach (var attr in testCase.TestMethod.Method.GetCustomAttributes(typeof(TestPriorityAttribute).AssemblyQualifiedName))
-                    priority = attr.GetNamedArgument<int>("Priority");
-
-                GetOrCreate(sortedMethods, priority).Add(testCase);
-            }
-
-            foreach (var list in sortedMethods.Keys.Select(priority => sortedMethods[priority]))
-            {
-                list.Sort((x, y) => StringComparer.OrdinalIgnoreCase.Compare(x.TestMethod.Method.Name, y.TestMethod.Method.Name));
-                foreach (var testCase in list)
-                    yield return testCase;
-            }
-        }
-
-        private static TValue GetOrCreate<TKey, TValue>(IDictionary<TKey, TValue> dictionary, TKey key) where TValue : new()
-        {
-            if (dictionary.TryGetValue(key, out var result)) return result;
-
-            result = new TValue();
-            dictionary[key] = result;
-
-            return result;
-        }
-    }
-}
\ No newline at end of file
diff --git a/Tests/Fergun.Tests/TestPriorityAttribute.cs b/Tests/Fergun.Tests/TestPriorityAttribute.cs
deleted file mode 100644
index 39d5543..0000000
--- a/Tests/Fergun.Tests/TestPriorityAttribute.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using System;
-
-namespace Fergun.Tests
-{
-    [AttributeUsage(AttributeTargets.Method)]
-    internal class TestPriorityAttribute : Attribute
-    {
-        public TestPriorityAttribute(int priority)
-        {
-            Priority = priority;
-        }
-
-        public int Priority { get; }
-    }
-}
\ No newline at end of file
diff --git a/Tests/Fergun.Tests/TranslatorTests.cs b/Tests/Fergun.Tests/TranslatorTests.cs
deleted file mode 100644
index 04c9649..0000000
--- a/Tests/Fergun.Tests/TranslatorTests.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using GTranslate.Translators;
-using Xunit;
-
-namespace Fergun.Tests
-{
-    public class TranslatorTests
-    {
-        [Theory]
-        [InlineData("Hello World", "es")]
-        [InlineData("Hola Mundo", "en")]
-        [InlineData("The quick brown fox jumps over the lazy dog.", "it")]
-        [InlineData("El zorro marrón rápido salta sobre el perro perezoso.", "pt")]
-        [InlineData("Discord is an American VoIP, instant messaging and digital distribution platform designed for creating communities.", "de", "en")]
-        public async Task TranslationNotEmptyTest(string text, string toLanguage, string fromLanguage = null)
-        {
-            // Arrange
-            using var translator = new AggregateTranslator();
-
-            // Act
-            var result = await translator.TranslateAsync(text, toLanguage, fromLanguage);
-
-            // Assert
-            Assert.NotEmpty(result.Translation);
-        }
-
-        [Theory]
-        [InlineData(null, "en")]
-        [InlineData("Hello world", null)]
-        [InlineData("object", "", "po")]
-        public async Task TranslationInvalidParamsTest(string text, string toLanguage, string fromLanguage = null)
-        {
-            // Arrange
-            using var translator = new AggregateTranslator();
-
-            // Act and Assert
-            await Assert.ThrowsAnyAsync<ArgumentException>(async () => await translator.TranslateAsync(text, toLanguage, fromLanguage));
-        }
-    }
-}
\ No newline at end of file
diff --git a/Tests/Fergun.Tests/UrbanDictionaryTests.cs b/Tests/Fergun.Tests/UrbanDictionaryTests.cs
deleted file mode 100644
index 93aa81f..0000000
--- a/Tests/Fergun.Tests/UrbanDictionaryTests.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using System.Threading.Tasks;
-using Fergun.APIs.UrbanDictionary;
-using Xunit;
-
-namespace Fergun.Tests
-{
-    public class UrbanDictionaryTests
-    {
-        [Theory]
-        [InlineData("lol")]
-        [InlineData("afaik")]
-        [InlineData("cringe")]
-        [InlineData("pog")]
-        [InlineData("gg")]
-        public async Task SearchNotEmptyTest(string word)
-        {
-            // Act
-            var response = await UrbanApi.SearchWordAsync(word);
-
-            // Assert
-            Assert.NotEmpty(response.Definitions);
-        }
-
-        [Fact]
-        public async Task RandomSearchNotEmptyTest()
-        {
-            // Act
-            var response = await UrbanApi.GetRandomWordsAsync();
-
-            // Assert
-            Assert.NotEmpty(response.Definitions);
-        }
-    }
-}
\ No newline at end of file
diff --git a/Tests/Fergun.Tests/WaybackMachineTests.cs b/Tests/Fergun.Tests/WaybackMachineTests.cs
deleted file mode 100644
index 1a21bda..0000000
--- a/Tests/Fergun.Tests/WaybackMachineTests.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using Fergun.APIs.WaybackMachine;
-using Xunit;
-
-namespace Fergun.Tests
-{
-    public class WaybackMachineTests
-    {
-        [Theory]
-        [InlineData("google.com", 2000)]
-        [InlineData("youtube.com", 2009)]
-        [InlineData("facebook.com", 2015)]
-        [InlineData("twitter.com", 2020)]
-        public async Task SnapshotNotNullTest(string url, ulong timestamp)
-        {
-            // Act
-            var result = await WaybackApi.GetSnapshotAsync(url, timestamp);
-
-            // Assert
-            Assert.NotNull(result);
-        }
-
-        [Theory]
-        [InlineData("", 2010)]
-        [InlineData(null, 2020)]
-        public async Task SnapshotInvalidUrlTest(string url, ulong timestamp)
-        {
-            // Act and Assert
-            await Assert.ThrowsAsync<ArgumentNullException>(async () => await WaybackApi.GetSnapshotAsync(url, timestamp));
-        }
-
-        [Theory]
-        [InlineData("google.com", 1)]
-        [InlineData("youtube.com", 100000000000000)]
-        public async Task SnapshotInvalidTimestampTest(string url, ulong timestamp)
-        {
-            // Act and Assert
-            await Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () => await WaybackApi.GetSnapshotAsync(url, timestamp));
-        }
-    }
-}
\ No newline at end of file
diff --git a/Tests/Fergun.Tests/XkcdTests.cs b/Tests/Fergun.Tests/XkcdTests.cs
deleted file mode 100644
index c9daa87..0000000
--- a/Tests/Fergun.Tests/XkcdTests.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using System.Net.Http;
-using System.Threading.Tasks;
-using Fergun.Responses;
-using Newtonsoft.Json;
-using Xunit;
-
-namespace Fergun.Tests
-{
-    public class XkcdTests
-    {
-        [Fact]
-        public async Task XkcdAvailableTest()
-        {
-            // Arrange
-            using var httpClient = new HttpClient();
-
-            // Act
-            string response = await httpClient.GetStringAsync("https://xkcd.com/info.0.json"); // Last comic
-            var comic = JsonConvert.DeserializeObject<XkcdComic>(response);
-
-            // Assert
-            Assert.NotNull(comic);
-
-            // Act
-            response = await httpClient.GetStringAsync($"https://xkcd.com/{comic.Num - 1}/info.0.json"); // Previous comic
-            var prevComic = JsonConvert.DeserializeObject<XkcdComic>(response);
-
-            // Assert
-            Assert.NotNull(prevComic);
-        }
-    }
-}
\ No newline at end of file
diff --git a/nuget.config b/nuget.config
deleted file mode 100644
index a938b8a..0000000
--- a/nuget.config
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<configuration>
-  <packageSources>
-    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
-    <add key="Discord.NET" value="https://www.myget.org/F/discord-net/api/v3/index.json" />
-  </packageSources>
-</configuration>
\ No newline at end of file
diff --git a/src/APIs/AIDungeon/AiDungeonApi.cs b/src/APIs/AIDungeon/AiDungeonApi.cs
deleted file mode 100644
index 6ff25ee..0000000
--- a/src/APIs/AIDungeon/AiDungeonApi.cs
+++ /dev/null
@@ -1,155 +0,0 @@
-#nullable enable
-using System;
-using System.IO;
-using System.Net.Http;
-using System.Text;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using System.Threading.Tasks;
-
-namespace Fergun.APIs.AIDungeon
-{
-    // AI Dungeon GraphQL API wrapper
-    public class AiDungeonApi
-    {
-        private readonly HttpClient _httpClient;
-        private readonly string? _token;
-        public const string ApiEndpoint = "https://api.aidungeon.io/graphql";
-        public const string AllScenariosId = "edd5fdc0-9c81-11ea-a76c-177e6c0711b5";
-        private static readonly JsonSerializerOptions _defaultSerializerOptions = new JsonSerializerOptions
-        {
-            PropertyNameCaseInsensitive = true,
-            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
-            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
-            NumberHandling = JsonNumberHandling.AllowReadingFromString,
-        };
-
-        public AiDungeonApi() : this(new HttpClient())
-        {
-        }
-
-        public AiDungeonApi(HttpClient httpClient)
-        {
-            _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
-        }
-
-        public AiDungeonApi(string token) : this(new HttpClient(), token)
-        {
-        }
-
-        public AiDungeonApi(HttpClient httpClient, string token) : this(httpClient)
-        {
-            _token = token ?? throw new ArgumentNullException(nameof(token));
-        }
-
-        private async Task<Stream> SendRequestAsync<TVariables>(IAiDungeonRequest<TVariables> request, bool requireToken = true)
-            => await SendRequestAsync(JsonSerializer.Serialize(request, _defaultSerializerOptions), requireToken);
-
-        private async Task<Stream> SendRequestAsync(string json, bool requireToken = true)
-        {
-            EnsureToken(_token, requireToken);
-
-            using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(ApiEndpoint));
-            using var content = new StringContent(json, Encoding.UTF8, "application/json");
-            request.Headers.TryAddWithoutValidation("X-Access-Token", _token);
-            request.Content = content;
-
-            var response = await _httpClient.SendAsync(request);
-            return await response.Content.ReadAsStreamAsync();
-        }
-
-        public async Task<AiDungeonAccount> CreateAnonymousAccountAsync()
-        {
-            var request = new AiDungeonAnonymousAccountRequest();
-            var response = await SendRequestAsync(request, false);
-
-            return DeserializeToEntity<AiDungeonAccount>(response, "createAnonymousAccount");
-        }
-
-        public async Task<AiDungeonUser> GetAccountInfoAsync()
-        {
-            var request = new AiDungeonAccountInfoRequest();
-            var response = await SendRequestAsync(request);
-
-            return DeserializeToEntity<AiDungeonUser>(response, "user");
-        }
-
-        public async Task<AiDungeonUser> DisableSafeModeAsync(string id)
-        {
-            var request = new AiDungeonAccountGameSettingsRequest(id, true);
-            var response = await SendRequestAsync(request);
-
-            return DeserializeToEntity<AiDungeonUser>(response, "saveGameSettings");
-        }
-
-        public async Task<AiDungeonScenario> GetScenarioAsync(string scenarioId)
-        {
-            var request = new AiDungeonRequest(scenarioId, RequestType.GetScenario);
-            var response = await SendRequestAsync(request);
-
-            return DeserializeToEntity<AiDungeonScenario>(response, "scenario");
-        }
-
-        // aka add adventure
-        public async Task<AiDungeonAdventure> CreateAdventureAsync(string scenarioId, string? prompt = null)
-        {
-            var request = new AiDungeonRequest(scenarioId, RequestType.CreateAdventure, prompt);
-            var response = await SendRequestAsync(request);
-
-            return DeserializeToEntity<AiDungeonAdventure>(response, "addAdventure");
-        }
-
-        public async Task<AiDungeonAdventure> GetAdventureAsync(string publicId)
-        {
-            var request = new AiDungeonRequest(publicId, RequestType.GetAdventure);
-            var response = await SendRequestAsync(request);
-
-            return DeserializeToEntity<AiDungeonAdventure>(response, "adventure");
-        }
-
-        // aka add action
-        public async Task<AiDungeonAdventure> SendActionAsync(string publicId, ActionType type, string? text = null, long actionId = 0)
-        {
-            var request = new AiDungeonRequest(publicId, type, text, actionId);
-            await SendRequestAsync(request);
-
-            return await GetAdventureAsync(publicId);
-        }
-
-        public async Task<AiDungeonAdventure> DeleteAdventureAsync(string publicId)
-        {
-            var request = new AiDungeonRequest(publicId, RequestType.DeleteAdventure);
-            var response = await SendRequestAsync(request);
-
-            return DeserializeToEntity<AiDungeonAdventure>(response, "deleteAdventure");
-        }
-
-        private static TEntity DeserializeToEntity<TEntity>(Stream stream, string propertyName) where TEntity : IAiDungeonEntity
-        {
-            using var document = JsonDocument.Parse(stream);
-            var root = document.RootElement;
-
-            if (root.TryGetProperty("errors", out var errors) && errors.ValueKind == JsonValueKind.Array)
-            {
-                using var enumerator = errors.EnumerateArray();
-                if (enumerator.MoveNext() && enumerator.Current.TryGetProperty("message", out var message) && message.ValueKind == JsonValueKind.String)
-                {
-                    throw new AiDungeonException(message.GetString());
-                }
-            }
-
-            return root
-                .GetProperty("data")
-                .GetProperty(propertyName)
-                .Deserialize<TEntity>(_defaultSerializerOptions) ?? throw new AiDungeonException("Failed to deserialize the response data.");
-        }
-
-        private static void EnsureToken(string? token, bool requireToken)
-        {
-            if (requireToken && token == null)
-            {
-                throw new InvalidOperationException("Cannot send this request because requireToken is true and token is null.");
-            }
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/AIDungeon/AiDungeonException.cs b/src/APIs/AIDungeon/AiDungeonException.cs
deleted file mode 100644
index 3a022e6..0000000
--- a/src/APIs/AIDungeon/AiDungeonException.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using System;
-
-namespace Fergun.APIs.AIDungeon
-{
-    public class AiDungeonException : Exception
-    {
-        public AiDungeonException()
-        {
-        }
-
-        public AiDungeonException(string message)
-            : base(message)
-        {
-        }
-
-        public AiDungeonException(string message, Exception innerException)
-            : base(message, innerException)
-        {
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/AIDungeon/Enums.cs b/src/APIs/AIDungeon/Enums.cs
deleted file mode 100644
index d63730b..0000000
--- a/src/APIs/AIDungeon/Enums.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-namespace Fergun.APIs.AIDungeon
-{
-    public enum ActionType
-    {
-        /// <summary>
-        /// Do an action.
-        /// </summary>
-        Do,
-
-        /// <summary>
-        /// Say something.
-        /// </summary>
-        Say,
-
-        /// <summary>
-        /// Describe the place.
-        /// </summary>
-        Story,
-
-        /// <summary>
-        /// Generate more story (no input).
-        /// </summary>
-        Continue,
-
-        /// <summary>
-        /// Undo the last action.
-        /// </summary>
-        Undo,
-
-        /// <summary>
-        /// Redo the last action.
-        /// </summary>
-        Redo,
-
-        /// <summary>
-        /// Edit the last action.
-        /// </summary>
-        Alter,
-
-        /// <summary>
-        /// Edit the memory context.
-        /// </summary>
-        Remember,
-
-        /// <summary>
-        /// Retry the last action and generate a new response.
-        /// </summary>
-        Retry
-    }
-
-    public enum RequestType
-    {
-        GetScenario,
-        GetAdventure,
-        CreateAdventure,
-        DeleteAdventure
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/AIDungeon/IAiDungeonEntity.cs b/src/APIs/AIDungeon/IAiDungeonEntity.cs
deleted file mode 100644
index 285177b..0000000
--- a/src/APIs/AIDungeon/IAiDungeonEntity.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace Fergun.APIs.AIDungeon
-{
-    public interface IAiDungeonEntity
-    {
-        long Id { get; set; }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/AIDungeon/IAiDungeonRequest.cs b/src/APIs/AIDungeon/IAiDungeonRequest.cs
deleted file mode 100644
index cdb8a34..0000000
--- a/src/APIs/AIDungeon/IAiDungeonRequest.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace Fergun.APIs.AIDungeon
-{
-    public interface IAiDungeonRequest<TVariables>
-    {
-        string Query { get; set; }
-
-        TVariables Variables { get; set; }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/AIDungeon/Requests.cs b/src/APIs/AIDungeon/Requests.cs
deleted file mode 100644
index a6009a7..0000000
--- a/src/APIs/AIDungeon/Requests.cs
+++ /dev/null
@@ -1,149 +0,0 @@
-#nullable enable
-using System;
-
-namespace Fergun.APIs.AIDungeon
-{
-    public class AiDungeonRequest : IAiDungeonRequest<AiDungeonPayloadVariables>
-    {
-        public AiDungeonRequest(string id, RequestType requestType, string? prompt = null)
-        {
-            if (string.IsNullOrEmpty(id))
-            {
-                throw new ArgumentNullException(nameof(id));
-            }
-
-            Query = requestType switch
-            {
-                RequestType.CreateAdventure => "mutation ($scenarioId: String, $prompt: String, $memory: String) {\n  addAdventure(scenarioId: $scenarioId, prompt: $prompt, memory: $memory) {\n    id\n    publicId\n    title\n    description\n    musicTheme\n    tags\n    nsfw\n    published\n    createdAt\n    updatedAt\n    deletedAt\n    publicId\n    __typename\n  }\n}\n",
-                RequestType.GetScenario => "query ($publicId: String) {\n  scenario(publicId: $publicId) {\n    memory\n    ...SelectOptionScenario\n    __typename\n  }\n}\n\nfragment SelectOptionScenario on Scenario {\n  id\n  prompt\n  publicId\n  options {\n    id\n    publicId\n    title\n    __typename\n  }\n  __typename\n}\n",
-                RequestType.GetAdventure => "query ($publicId: String) {\n  adventure(publicId: $publicId) {\n    id\n    publicId\n    title\n    description\n    nsfw\n    published\n    actions {\n      id\n      text\n      undoneAt\n      deletedAt\n    }\n    undoneWindow {\n      id\n      text\n      undoneAt\n      deletedAt\n    }\n    createdAt\n    updatedAt\n    deletedAt\n  }\n}",
-                RequestType.DeleteAdventure => "mutation ($publicId: String) {\n  deleteAdventure(publicId: $publicId) {\n    id\n    publicId\n    deletedAt\n    __typename\n  }\n}\n",
-                _ => throw new ArgumentException("Unknown request type.")
-            };
-
-            Variables = requestType == RequestType.CreateAdventure
-                ? new AiDungeonPayloadVariables { ScenarioId = id, Prompt = prompt }
-                : new AiDungeonPayloadVariables { PublicId = id };
-        }
-
-        public AiDungeonRequest(string publicId, ActionType action, string? text = null, long actionId = 0)
-        {
-            if (string.IsNullOrEmpty(publicId))
-            {
-                throw new ArgumentNullException(nameof(publicId));
-            }
-
-            var inputData = new AiDungeonInputData
-            {
-                PublicId = publicId
-            };
-
-            if (!string.IsNullOrEmpty(text) &&
-                action != ActionType.Continue &&
-                action != ActionType.Undo &&
-                action != ActionType.Redo &&
-                action != ActionType.Retry)
-            {
-                inputData.Text = text;
-            }
-
-            string query;
-            // Alter is weird
-            if (actionId != 0 && action == ActionType.Alter)
-            {
-                inputData.ActionId = actionId.ToString();
-                query = "mutation ($input: AlterInput) {\n  editAction(input: $input) {\n    time\n    message\n    __typename\n  }\n}\n";
-            }
-            else
-            {
-                inputData.Type = action.ToString().ToLowerInvariant();
-                inputData.ChoicesMode = false;
-                query = "mutation ($input: ActionInput) {\n  addAction(input: $input) {\n    message\n    time\n    hasBannedWord\n    returnedInput\n    __typename\n  }\n}\n";
-            }
-
-            Variables = new AiDungeonPayloadVariables { Input = inputData };
-            Query = query;
-        }
-
-        public AiDungeonPayloadVariables Variables { get; set; }
-
-        public string Query { get; set; }
-    }
-
-    public class AiDungeonAnonymousAccountRequest : IAiDungeonRequest<EmptyPayloadVariables>
-    {
-        public EmptyPayloadVariables Variables { get; set; } = new EmptyPayloadVariables();
-
-        public string Query { get; set; } = "mutation {\n  createAnonymousAccount {\n    id\n    accessToken\n  }\n}\n";
-    }
-
-    public class AiDungeonAccountInfoRequest : IAiDungeonRequest<EmptyPayloadVariables>
-    {
-        public EmptyPayloadVariables Variables { get; set; } = new EmptyPayloadVariables();
-
-        public string Query { get; set; } = "{\n  user {\n    id\n    username\n    ...ContentListUser\n  }\n}\n\nfragment ContentListUser on User {\n  ...ContentCardUser\n}\n\nfragment ContentCardUser on User {\n  id\n  username\n  gameSettings {\n    id\n    nsfwGeneration\n    unrestrictedInput\n  }\n}\n";
-    }
-
-    public class AiDungeonAccountGameSettingsRequest : IAiDungeonRequest<AccountGameSettingsPayloadVariables>
-    {
-        public AiDungeonAccountGameSettingsRequest(string id, bool nsfwGeneration)
-        {
-            Variables = new AccountGameSettingsPayloadVariables
-            {
-                Input = new AccountGameSettingsInput
-                {
-                    Id = id,
-                    NsfwGeneration = nsfwGeneration
-                }
-            };
-        }
-
-        public AccountGameSettingsPayloadVariables Variables { get; set; }
-
-        public string Query { get; set; } = "mutation ($input: GameSettingsInput) {\n  saveGameSettings(input: $input) {\n    id\n    gameSettings {\n      id\n      ...GameSettingsGameSettings\n    }\n  }\n}\n\nfragment GameSettingsGameSettings on GameSettings {\n  modelType\n  nsfwGeneration\n}\n";
-    }
-
-    public class AccountGameSettingsPayloadVariables
-    {
-        public AccountGameSettingsInput Input { get; set; } = new AccountGameSettingsInput();
-    }
-
-    public class AccountGameSettingsInput
-    {
-        public string Id { get; set; } = "";
-
-        public bool NsfwGeneration { get; set; }
-    }
-
-    public class EmptyPayloadVariables
-    {
-    }
-
-    public class AiDungeonPayloadVariables
-    {
-        public string? PublicId { get; set; }
-
-        public string? ScenarioId { get; set; }
-
-        public AiDungeonInputData? Input { get; set; }
-
-        public string? Prompt { get; set; }
-    }
-
-    public class AiDungeonInputData
-    {
-        public string? PublicId { get; set; }
-
-        public string? Type { get; set; }
-
-        public string? Text { get; set; }
-
-        public string? CharacterName { get; set; }
-
-        public bool? ChoicesMode { get; set; }
-
-        public string? Memory { get; set; }
-
-        public string? ActionId { get; set; }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/AIDungeon/Responses.cs b/src/APIs/AIDungeon/Responses.cs
deleted file mode 100644
index 32e75fd..0000000
--- a/src/APIs/AIDungeon/Responses.cs
+++ /dev/null
@@ -1,85 +0,0 @@
-#nullable enable
-using System;
-using System.Collections.Generic;
-
-namespace Fergun.APIs.AIDungeon
-{
-    public class AiDungeonScenario : IAiDungeonEntity
-    {
-        public long Id { get; set; }
-
-        public Guid PublicId { get; set; }
-
-        public string? Memory { get; set; }
-
-        public string? Prompt { get; set; }
-
-        public string? Title { get; set; }
-
-        public IReadOnlyList<AiDungeonScenario> Options { get; set; } = Array.Empty<AiDungeonScenario>();
-    }
-
-    public class AiDungeonAdventure : IAiDungeonEntity
-    {
-        public long Id { get; set; }
-
-        public Guid? PublicId { get; set; }
-
-        public string? Title { get; set; }
-
-        public string? Description { get; set; }
-
-        public bool? Nsfw { get; set; }
-
-        public bool? Published { get; set; }
-
-        public IReadOnlyList<AiDungeonAction> Actions { get; set; } = Array.Empty<AiDungeonAction>();
-
-        public IReadOnlyList<AiDungeonAction> UndoneWindow { get; set; } = Array.Empty<AiDungeonAction>();
-
-        public DateTimeOffset? CreatedAt { get; set; }
-
-        public DateTimeOffset? UpdatedAt { get; set; }
-
-        public DateTimeOffset? DeletedAt { get; set; }
-    }
-
-    public class AiDungeonAction : IAiDungeonEntity
-    {
-        public long Id { get; set; }
-
-        public string Text { get; set; } = "";
-
-        public DateTimeOffset? UndoneAt { get; set; }
-
-        public DateTimeOffset? DeletedAt { get; set; }
-    }
-
-    public class AiDungeonAccount : IAiDungeonEntity
-    {
-        public long Id { get; set; }
-
-        public string AccessToken { get; set; } = "";
-    }
-
-    public class AiDungeonUser : IAiDungeonEntity
-    {
-        public long Id { get; set; }
-
-        public string? Username { get; set; }
-
-        public AiDungeonGameSettings GameSettings { get; set; } = new AiDungeonGameSettings();
-    }
-
-    // partial data
-    public class AiDungeonGameSettings : IAiDungeonEntity
-    {
-        public long Id { get; set; }
-
-        public bool NsfwGeneration { get; set; }
-
-        public bool UnrestrictedInput { get; set; }
-
-        public string? ModelType { get; set; }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/ApiFlash.cs b/src/APIs/ApiFlash.cs
deleted file mode 100644
index b062604..0000000
--- a/src/APIs/ApiFlash.cs
+++ /dev/null
@@ -1,139 +0,0 @@
-using System;
-using System.Net.Http;
-using System.Threading.Tasks;
-using Newtonsoft.Json;
-
-namespace Fergun.APIs
-{
-    public static class ApiFlash
-    {
-        public const string ApiEndpoint = "https://api.apiflash.com/v1/urltoimage";
-
-        private static readonly HttpClient _httpClient = new HttpClient();
-
-        /// <summary>
-        /// Takes a screenshot from an Url.
-        /// </summary>
-        /// <param name="accessKey">A valid access key allowing you to make API calls.</param>
-        /// <param name="url">The complete URL of the website for which you want to capture a screenshot. The URL must include the protocol (http:// or https://) to be processed correctly.</param>
-        /// <param name="format">The image format of the captured screenshot. Either jpeg or png.</param>
-        /// <param name="failOnStatus">A comma separated list of HTTP status codes that should make the API call fail instead of returning a screenshot.
-        /// Hyphen separated HTTP status codes can be used to define ranges. For example 400,404,500-511 would make the API call fail if the URL returns 400, 404 or any status code between 500 and 511.</param>
-        /// <param name="ttl">Number of seconds the screenshot is cached. From 0 seconds to 2592000 seconds (30 days).</param>
-        /// <param name="fresh">Force the API to capture a fresh new screenshot instead of returning a screenshot from the cache.</param>
-        /// <param name="fullPage">Set this parameter to true to capture the entire page of the target website.</param>
-        /// <param name="scrollPage">Set this parameter to true to scroll through the entire page before capturing a screenshot. This is useful to trigger animations or lazy loaded elements.</param>
-        /// <returns>A task containing a ApiFlashResponse object with the Url of the image or the error message.</returns>
-        public static async Task<ApiFlashResponse> UrlToImageAsync(string accessKey, string url, FormatType format = FormatType.Jpeg, string failOnStatus = "",
-            uint ttl = 86400, bool fresh = false, bool fullPage = false, bool scrollPage = false)
-        {
-            if (string.IsNullOrEmpty(accessKey))
-            {
-                throw new ArgumentNullException(nameof(accessKey));
-            }
-            if (string.IsNullOrEmpty(url))
-            {
-                throw new ArgumentNullException(nameof(url));
-            }
-            if (!Uri.IsWellFormedUriString(url, UriKind.Absolute))
-            {
-                throw new ArgumentException("The Url is not well formed.", nameof(url));
-            }
-
-            string q = $"access_key={accessKey}"
-                       + $"&url={Uri.EscapeDataString(url)}"
-                       + $"&response_type={ResponseType.Json.ToString().ToLowerInvariant()}";
-
-            if (format != FormatType.Jpeg)
-            {
-                q += $"&format={format.ToString().ToLowerInvariant()}";
-            }
-            if (!string.IsNullOrEmpty(failOnStatus)) // check valid
-            {
-                q += $"&fail_on_status={failOnStatus}";
-            }
-            if (ttl != 86400)
-            {
-                if (ttl > 2592000)
-                {
-                    throw new ArgumentOutOfRangeException(nameof(ttl), ttl, "Value must be between 0 and 2592000.");
-                }
-                q += $"&ttl={ttl}";
-            }
-            if (fresh)
-            {
-                q += "&fresh=true";
-            }
-            if (fullPage)
-            {
-                q += "&full_page=true";
-            }
-            if (scrollPage)
-            {
-                q += "&scroll_page=true";
-            }
-
-            string json = await _httpClient.GetStringAsync($"{ApiEndpoint}?{q}");
-            return JsonConvert.DeserializeObject<ApiFlashResponse>(json);
-        }
-
-        public static async Task<ApiFlashQuotaResponse> GetQuotaAsync(string accessKey)
-        {
-            if (string.IsNullOrEmpty(accessKey))
-            {
-                throw new ArgumentNullException(nameof(accessKey));
-            }
-
-            string json = await _httpClient.GetStringAsync($"{ApiEndpoint}/quota?access_key={accessKey}");
-            return JsonConvert.DeserializeObject<ApiFlashQuotaResponse>(json);
-        }
-
-        public enum FormatType
-        {
-            Jpeg,
-            Png
-        }
-
-        public enum ResponseType
-        {
-            Image,
-            Json
-        }
-    }
-
-    public class ApiFlashResponse
-    {
-        /// <summary>
-        /// Url of the screenshot.
-        /// </summary>
-        [JsonProperty("url", NullValueHandling = NullValueHandling.Ignore)]
-        public string Url { get; set; }
-
-        /// <summary>
-        /// The error message on fail.
-        /// </summary>
-        [JsonProperty("error_message", NullValueHandling = NullValueHandling.Ignore)]
-        public string ErrorMessage { get; set; }
-    }
-
-    public class ApiFlashQuotaResponse
-    {
-        /// <summary>
-        /// The maximum number of API calls you can make per billing period.
-        /// </summary>
-        [JsonProperty("limit")]
-        public int Limit { get; set; }
-
-        /// <summary>
-        /// The number of API calls remaining for the current billing period.
-        /// </summary>
-        [JsonProperty("remaining")]
-        public int Remaining { get; set; }
-
-        /// <summary>
-        /// The time, in UTC epoch seconds, at which the current billing period ends and the remaining number of API calls resets.
-        /// </summary>
-        [JsonProperty("reset")]
-        public uint Reset { get; set; }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/Dictionary/DictionaryApi.cs b/src/APIs/Dictionary/DictionaryApi.cs
deleted file mode 100644
index 2389ed9..0000000
--- a/src/APIs/Dictionary/DictionaryApi.cs
+++ /dev/null
@@ -1,98 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net;
-using System.Net.Http;
-using System.Threading.Tasks;
-using Newtonsoft.Json;
-
-namespace Fergun.APIs.Dictionary
-{
-    /// <summary>
-    /// Represents a dictionary API.
-    /// </summary>
-    public static class DictionaryApi
-    {
-        /// <summary>
-        /// Returns the API endpoint.
-        /// </summary>
-        public const string ApiEndpoint = "https://api.dictionaryapi.dev/api/v2/entries/";
-
-        /// <summary>
-        /// Returns the default language.
-        /// </summary>
-        public const string DefaultLanguage = "en";
-
-        private static readonly HttpClient _httpClient = new HttpClient { BaseAddress = new Uri(ApiEndpoint) };
-
-        /// <summary>
-        /// Gets word definitions using the provided word and language.
-        /// </summary>
-        /// <param name="word">The word to get its definitions.</param>
-        /// <param name="language">A language in <see cref="SupportedLanguages"/>.</param>
-        /// <param name="fallback">Whether to fallback to <see cref="DefaultLanguage"/> if there are no results in <paramref name="language"/>.</param>
-        /// <returns>A task representing the asynchronous operation. The result contains a read-only list of <see cref="DefinitionCategory"/> objects.</returns>
-        public static async Task<IReadOnlyList<DefinitionCategory>> GetDefinitionsAsync(string word, string language = DefaultLanguage, bool fallback = false)
-        {
-            if (string.IsNullOrEmpty(word))
-            {
-                throw new ArgumentNullException(nameof(word));
-            }
-
-            if (string.IsNullOrEmpty(language))
-            {
-                throw new ArgumentNullException(nameof(language));
-            }
-
-            language = language.ToLowerInvariant();
-            if (!SupportedLanguages.Contains(language))
-            {
-                language = DefaultLanguage;
-            }
-
-            HttpResponseMessage response;
-            while (true)
-            {
-                response = await _httpClient.GetAsync(new Uri($"{language}/{Uri.EscapeDataString(word)}", UriKind.Relative));
-
-                // No definitions found.
-                if (response.StatusCode == HttpStatusCode.NotFound)
-                {
-                    if (!fallback || language == DefaultLanguage)
-                    {
-                        return Array.Empty<DefinitionCategory>();
-                    }
-
-                    // Fallback to the default language.
-                    language = DefaultLanguage;
-                    fallback = false;
-                    continue;
-                }
-
-                break;
-            }
-
-            string json = await response.Content.ReadAsStringAsync();
-            return JsonConvert.DeserializeObject<IReadOnlyList<DefinitionCategory>>(json);
-        }
-
-        /// <summary>
-        /// Gets a read-only list containing the supported languages.
-        /// </summary>
-        public static IReadOnlyList<string> SupportedLanguages { get; } = new[]
-        {
-            "en",
-            "hi",
-            "es",
-            "fr",
-            "ja",
-            "ru",
-            "de",
-            "it",
-            "ko",
-            "pt-BR",
-            "ar",
-            "tr"
-        };
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/Dictionary/Responses.cs b/src/APIs/Dictionary/Responses.cs
deleted file mode 100644
index 3874ac0..0000000
--- a/src/APIs/Dictionary/Responses.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-using System.Collections.Generic;
-using Newtonsoft.Json;
-
-namespace Fergun.APIs.Dictionary
-{
-    public class DefinitionCategory
-    {
-        [JsonProperty("word")]
-        public string Word { get; set; }
-
-        [JsonProperty("phonetics")]
-        public IReadOnlyList<Phonetic> Phonetics { get; set; } = new List<Phonetic>();
-
-        [JsonProperty("origin")]
-        public string Origin { get; set; }
-
-        [JsonProperty("meanings")]
-        public IReadOnlyList<Meaning> Meanings { get; set; } = new List<Meaning>();
-    }
-
-    public class Meaning
-    {
-        [JsonProperty("partOfSpeech")]
-        public string PartOfSpeech { get; set; }
-
-        [JsonProperty("definitions")]
-        public IReadOnlyList<DefinitionInfo> Definitions { get; set; } = new List<DefinitionInfo>();
-    }
-
-    public class DefinitionInfo
-    {
-        [JsonProperty("definition")]
-        public string Definition { get; set; }
-
-        [JsonProperty("example")]
-        public string Example { get; set; }
-
-        [JsonProperty("synonyms")]
-        public IReadOnlyList<string> Synonyms { get; set; } = new List<string>();
-
-        [JsonProperty("antonyms")]
-        public IReadOnlyList<string> Antonyms { get; set; } = new List<string>();
-    }
-
-    public class Phonetic
-    {
-        [JsonProperty("text")]
-        public string Text { get; set; }
-
-        [JsonProperty("audio")]
-        public string Audio { get; set; }
-    }
-
-    public class SimpleDefinitionInfo : DefinitionInfo
-    {
-        public string Word { get; set; }
-
-        public string PartOfSpeech { get; set; }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/Genius/GeniusApi.cs b/src/APIs/Genius/GeniusApi.cs
deleted file mode 100644
index b2354a7..0000000
--- a/src/APIs/Genius/GeniusApi.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using System;
-using System.Net.Http;
-using System.Net.Http.Headers;
-using System.Threading.Tasks;
-using Newtonsoft.Json;
-
-namespace Fergun.APIs.Genius
-{
-    public class GeniusApi
-    {
-        public static string ApiEndpoint => "https://api.genius.com";
-
-        private readonly string _apiToken;
-
-        public GeniusApi(string apiToken)
-        {
-            _apiToken = apiToken;
-        }
-
-        public async Task<GeniusResponse> SearchAsync(string query)
-        {
-            if (string.IsNullOrEmpty(_apiToken))
-            {
-                throw new InvalidOperationException("You must provide a valid token.");
-            }
-            string response;
-            using (var client = new HttpClient())
-            {
-                client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiToken);
-                response = await client.GetStringAsync(new Uri($"{ApiEndpoint}/search?q={Uri.EscapeDataString(query)}"));
-            }
-            return JsonConvert.DeserializeObject<GeniusResponse>(response);
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/Genius/Responses.cs b/src/APIs/Genius/Responses.cs
deleted file mode 100644
index 613c9ce..0000000
--- a/src/APIs/Genius/Responses.cs
+++ /dev/null
@@ -1,140 +0,0 @@
-using System.Collections.Generic;
-using Newtonsoft.Json;
-
-namespace Fergun.APIs.Genius
-{
-    public class GeniusResponse
-    {
-        [JsonProperty("meta")]
-        public Meta Meta { get; set; }
-
-        [JsonProperty("response")]
-        public Response Response { get; set; }
-    }
-
-    public class Meta
-    {
-        [JsonProperty("status")]
-        public int Status { get; set; }
-    }
-
-    public class Response
-    {
-        [JsonProperty("hits")]
-        public List<Hit> Hits { get; set; }
-    }
-
-    public class Hit
-    {
-        [JsonProperty("highlights")]
-        public List<object> Highlights { get; set; }
-
-        [JsonProperty("index")]
-        public string Index { get; set; }
-
-        [JsonProperty("type")]
-        public string Type { get; set; }
-
-        [JsonProperty("result")]
-        public Result Result { get; set; }
-    }
-
-    public class Result
-    {
-        [JsonProperty("annotation_count")]
-        public int AnnotationCount { get; set; }
-
-        [JsonProperty("api_path")]
-        public string ApiPath { get; set; }
-
-        [JsonProperty("full_title")]
-        public string FullTitle { get; set; }
-
-        [JsonProperty("header_image_thumbnail_url")]
-        public string HeaderImageThumbnailUrl { get; set; }
-
-        [JsonProperty("header_image_url")]
-        public string HeaderImageUrl { get; set; }
-
-        [JsonProperty("id")]
-        public int Id { get; set; }
-
-        [JsonProperty("lyrics_owner_id")]
-        public int LyricsOwnerId { get; set; }
-
-        [JsonProperty("lyrics_state")]
-        public string LyricsState { get; set; }
-
-        [JsonProperty("path")]
-        public string Path { get; set; }
-
-        [JsonProperty("pyongs_count")]
-        public int? PyongsCount { get; set; }
-
-        [JsonProperty("song_art_image_thumbnail_url")]
-        public string SongArtImageThumbnailUrl { get; set; }
-
-        [JsonProperty("song_art_image_url")]
-        public string SongArtImageUrl { get; set; }
-
-        [JsonProperty("stats")]
-        public Stats Stats { get; set; }
-
-        [JsonProperty("title")]
-        public string Title { get; set; }
-
-        [JsonProperty("title_with_featured")]
-        public string TitleWithFeatured { get; set; }
-
-        [JsonProperty("url")]
-        public string Url { get; set; }
-
-        [JsonProperty("primary_artist")]
-        public PrimaryArtist PrimaryArtist { get; set; }
-    }
-
-    public class PrimaryArtist
-    {
-        [JsonProperty("api_path")]
-        public string ApiPath { get; set; }
-
-        [JsonProperty("header_image_url")]
-        public string HeaderImageUrl { get; set; }
-
-        [JsonProperty("id")]
-        public int Id { get; set; }
-
-        [JsonProperty("image_url")]
-        public string ImageUrl { get; set; }
-
-        [JsonProperty("is_meme_verified")]
-        public bool IsMemeVerified { get; set; }
-
-        [JsonProperty("is_verified")]
-        public bool IsVerified { get; set; }
-
-        [JsonProperty("name")]
-        public string Name { get; set; }
-
-        [JsonProperty("url")]
-        public string Url { get; set; }
-
-        [JsonProperty("iq", NullValueHandling = NullValueHandling.Ignore)]
-        public int? Iq { get; set; }
-    }
-
-    public class Stats
-    {
-        [JsonProperty("unreviewed_annotations")]
-        public int UnreviewedAnnotations { get; set; }
-
-        [JsonProperty("concurrents", NullValueHandling = NullValueHandling.Ignore)]
-        public int? Concurrents { get; set; }
-
-        [JsonProperty("hot")]
-        public bool Hot { get; set; }
-
-        [JsonProperty("pageviews", NullValueHandling = NullValueHandling.Ignore)]
-        public int? PageViews { get; set; }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/Hastebin.cs b/src/APIs/Hastebin.cs
deleted file mode 100644
index 6bcec43..0000000
--- a/src/APIs/Hastebin.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Net.Http;
-using System.Text;
-using System.Threading.Tasks;
-using Newtonsoft.Json;
-
-namespace Fergun.APIs
-{
-    public static class Hastebin
-    {
-        public const string HastebinEndpoint = "https://hastebin.com";
-
-        public const string HatebinEndpoint = "https://hatebin.com";
-
-        private static readonly HttpClient _client = new HttpClient();
-
-        public static async Task<string> UploadAsync(string content)
-        {
-            using var stringContent = new StringContent(content, Encoding.UTF8);
-            var response = await _client.PostAsync(new Uri($"{HastebinEndpoint}/documents"), stringContent);
-
-            if (!response.IsSuccessStatusCode)
-            {
-                // Fallback to Hatebin
-
-                using var formContent = new FormUrlEncodedContent(new Dictionary<string, string>
-                {
-                    { "text", content }
-                });
-
-                response = await _client.PostAsync(new Uri($"{HatebinEndpoint}/index.php"), formContent);
-                response.EnsureSuccessStatusCode();
-
-                string key = await response.Content.ReadAsStringAsync();
-                return GetUrl(HatebinEndpoint, key);
-            }
-
-            string json = await response.Content.ReadAsStringAsync();
-            var temp = JsonConvert.DeserializeObject<HastebinResponse>(json);
-            return GetUrl(HastebinEndpoint, temp.Key);
-        }
-
-        private static string GetUrl(string endpoint, string key) => $"{endpoint}/{key.Trim()}";
-
-        private class HastebinResponse
-        {
-            [JsonProperty]
-            public string Key { get; private set; }
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/OpenTriviaDB/Enums.cs b/src/APIs/OpenTriviaDB/Enums.cs
deleted file mode 100644
index abea4a8..0000000
--- a/src/APIs/OpenTriviaDB/Enums.cs
+++ /dev/null
@@ -1,70 +0,0 @@
-namespace Fergun.APIs.OpenTriviaDB
-{
-    public enum QuestionCategory
-    {
-        Any,
-        GeneralKnowledge = 9,
-        Books,
-        Film,
-        Music,
-        MusicalsAndTheatres,
-        Television,
-        VideoGames,
-        BoardGames,
-        ScienceAndNature,
-        Computers,
-        Mathematics,
-        Mythology,
-        Sports,
-        Geography,
-        History,
-        Politics,
-        Art,
-        Celebrities,
-        Animals,
-        Vehicles,
-        Comics,
-        Gadgets,
-        AnimeAndManga,
-        CartoonsAndAnimations
-    }
-
-    public enum QuestionDifficulty
-    {
-        Any,
-        Easy,
-        Medium,
-        Hard
-    }
-
-    public enum QuestionType
-    {
-        Any,
-        Multiple,
-        Boolean
-    }
-
-    public enum ResponseEncoding
-    {
-        // Default Encoding (HTML Codes)
-        Default, // Unlike the other default values on the other enums, this can't really be passed as an argument because it returns response code 2.
-        urlLegacy,
-        url3986,
-        base64
-    }
-
-    public enum TokenCommand
-    {
-        Request,
-        Reset
-    }
-
-    public enum ResponseCode
-    {
-        Success,
-        NoResults,
-        InvalidParameter,
-        TokenNotFound,
-        TokenEmpty
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/OpenTriviaDB/Responses.cs b/src/APIs/OpenTriviaDB/Responses.cs
deleted file mode 100644
index 33ef2ba..0000000
--- a/src/APIs/OpenTriviaDB/Responses.cs
+++ /dev/null
@@ -1,116 +0,0 @@
-using System.Collections.Generic;
-using Newtonsoft.Json;
-
-namespace Fergun.APIs.OpenTriviaDB
-{
-    // Data classes
-    // These contain information about a certain part of a request response
-    public class QuestionData
-    {
-        [JsonProperty("category")]
-        public string Category { get; set; }
-
-        [JsonProperty("type")]
-        public string Type { get; set; }
-
-        [JsonProperty("difficulty")]
-        public string Difficulty { get; set; }
-
-        [JsonProperty("question")]
-        public string Question { get; set; }
-
-        [JsonProperty("correct_answer")]
-        public string CorrectAnswer { get; set; }
-
-        [JsonProperty("incorrect_answers")]
-        public List<string> IncorrectAnswers { get; set; }
-    }
-
-    public class CategoryData
-    {
-        [JsonProperty("id")]
-        public uint Id { get; set; }
-
-        [JsonProperty("name")]
-        public string Name { get; set; }
-    }
-
-    public class CategoryQuestionCount
-    {
-        [JsonProperty("total_question_count")]
-        public uint TotalQuestionCount { get; set; }
-
-        [JsonProperty("total_easy_question_count")]
-        public uint EasyQuestionCount { get; set; }
-
-        [JsonProperty("total_medium_question_count")]
-        public uint MediumQuestionCount { get; set; }
-
-        [JsonProperty("total_hard_question_count")]
-        public uint HardQuestionCount { get; set; }
-    }
-
-    public class CategoryQuestionData
-    {
-        [JsonProperty("total_num_of_questions")]
-        public uint TotalNumberOfQuestions { get; set; }
-
-        [JsonProperty("total_num_of_pending_questions")]
-        public uint TotalNumberOfPendingQuestions { get; set; }
-
-        [JsonProperty("total_num_of_verified_questions")]
-        public uint TotalNumberOfVerifiedQuestions { get; set; }
-
-        [JsonProperty("total_num_of_rejected_questions")]
-        public uint TotalNumberOfRejectedQuestions { get; set; }
-    }
-
-    /** Response Classes **/
-    // When you send a request to the API you will get an instance of one of these classes as a response
-
-    public class QuestionsResponse
-    {
-        [JsonProperty("response_code")]
-        public uint ResponseCode { get; set; }
-
-        [JsonProperty("results")]
-        public List<QuestionData> Questions { get; set; }
-    }
-
-    public class SessionTokenResponse
-    {
-        [JsonProperty("response_code")]
-        public string ResponseCode { get; set; }
-
-        // This value isn't included on a token reset command.
-        [JsonProperty("response_message", NullValueHandling = NullValueHandling.Ignore)]
-        public string ResponseMessage { get; set; }
-
-        [JsonProperty("token")]
-        public string Token { get; set; }
-    }
-
-    public class CategoryListResponse
-    {
-        [JsonProperty("trivia_categories")]
-        public List<CategoryData> CategoryList { get; set; }
-    }
-
-    public class NumberOfQuestionsInCategoryResponse
-    {
-        [JsonProperty("category_id")]
-        public uint CategoryId { get; set; }
-
-        [JsonProperty("category_question_count")]
-        public CategoryQuestionCount CategoryQuestionCount { get; set; }
-    }
-
-    public class GlobalQuestionCountResponse
-    {
-        [JsonProperty("overall")]
-        public CategoryQuestionData Overall { get; set; }
-
-        [JsonProperty("categories")]
-        public Dictionary<uint, CategoryQuestionData> CategoriesQuestionCount { get; set; }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/OpenTriviaDB/TriviaApi.cs b/src/APIs/OpenTriviaDB/TriviaApi.cs
deleted file mode 100644
index 4b374cd..0000000
--- a/src/APIs/OpenTriviaDB/TriviaApi.cs
+++ /dev/null
@@ -1,142 +0,0 @@
-using System;
-using System.Net.Http;
-using System.Threading.Tasks;
-using Newtonsoft.Json;
-
-namespace Fergun.APIs.OpenTriviaDB
-{
-    public static class TriviaApi
-    {
-        public const string ApiEndpoint = "https://opentdb.com/api.php";
-        public const string ApiTokenEndpoint = "https://opentdb.com/api_token.php";
-        public const string ApiCategoryEndpoint = "https://opentdb.com/api_category.php";
-        public const string ApiCategoryCountEndpoint = "https://opentdb.com/api_count.php";
-        public const string ApiGlobalCountEndpoint = "https://opentdb.com/api_count_global.php";
-
-        private static readonly HttpClient _httpClient = new HttpClient();
-
-        /// <summary>
-        /// Requests questions from the API.
-        /// </summary>
-        /// <param name="amount">The amount of questions to request.</param>
-        /// <param name="category">The category of the questions. If left empty the questions will have mixed categories.</param>
-        /// <param name="difficulty">The difficulty of the questions. Easy, Medium, or Hard. If left empty the questions received will have mixed difficulty levels.</param>
-        /// <param name="type">The type of questions. Multiple or Boolean. If left empty the questions will have mixed types.</param>
-        /// <param name="encoding">The type of encoding used in the response. Default, urlLegacy, url3986, or base64. If left empty it will use the default encoding (HTML Codes).</param>
-        /// <param name="sessionToken">A session token. This token prevents the API from giving you the same question twice until 6 hours of inactivity or you reset the token.</param>
-        /// <returns>A <see cref="QuestionsResponse"/> object.</returns>
-        /// <exception cref="System.ArgumentOutOfRangeException">Thrown when <paramref name="amount"/> is out of range.</exception>
-        public static async Task<QuestionsResponse> RequestQuestionsAsync(uint amount,
-                                                         QuestionCategory category = QuestionCategory.Any,
-                                                         QuestionDifficulty difficulty = QuestionDifficulty.Any,
-                                                         QuestionType type = QuestionType.Any,
-                                                         ResponseEncoding encoding = ResponseEncoding.Default,
-                                                         string sessionToken = "")
-        {
-            string json = await _httpClient.GetStringAsync(GenerateApiUrl(amount, category, difficulty, type, encoding, sessionToken));
-            return JsonConvert.DeserializeObject<QuestionsResponse>(json);
-        }
-
-        public static string GenerateApiUrl(uint amount,
-                                            QuestionCategory category = QuestionCategory.Any,
-                                            QuestionDifficulty difficulty = QuestionDifficulty.Any,
-                                            QuestionType type = QuestionType.Any,
-                                            ResponseEncoding encoding = ResponseEncoding.Default,
-                                            string sessionToken = "")
-        {
-            if (amount < 1 || amount > 50)
-            {
-                throw new ArgumentOutOfRangeException(nameof(amount), amount, "Amount must be between 1 and 50.");
-            }
-
-            string query = $"amount={amount}";
-
-            if (category != QuestionCategory.Any)
-            {
-                query += $"&category={category:D}";
-            }
-
-            if (difficulty != QuestionDifficulty.Any)
-            {
-                query += $"&difficulty={difficulty.ToString().ToLowerInvariant()}";
-            }
-
-            if (type != QuestionType.Any)
-            {
-                query += $"&type={type.ToString().ToLowerInvariant()}";
-            }
-
-            if (encoding != ResponseEncoding.Default)
-            {
-                query += $"&encode={encoding}";
-            }
-
-            if (!string.IsNullOrEmpty(sessionToken))
-            {
-                query += $"&token={sessionToken}";
-            }
-
-            return $"{ApiEndpoint}?{query}";
-        }
-
-        /// <summary>
-        /// Sends a command to the Token API endpoint.
-        /// </summary>
-        /// <param name="command">The command to send. It can be "Request" (Requests a session token) or "Reset" (Resets the provided session token)</param>
-        /// <param name="sessionToken">Resets the provided session token, only if one is passed and command is set to "Reset".</param>
-        /// <returns>A <see cref="SessionTokenResponse"/> object.</returns>
-        public static async Task<SessionTokenResponse> SendSessionTokenCommandAsync(TokenCommand command, string sessionToken = "")
-        {
-            string query = $"command={command.ToString().ToLowerInvariant()}";
-
-            if (command == TokenCommand.Reset)
-            {
-                if (string.IsNullOrEmpty(sessionToken))
-                {
-                    throw new ArgumentException("You must pass a session token when requesting a reset.", nameof(sessionToken));
-                }
-                query += $"&token={sessionToken}";
-            }
-
-            string json = await _httpClient.GetStringAsync($"{ApiTokenEndpoint}?{query}");
-            return JsonConvert.DeserializeObject<SessionTokenResponse>(json);
-        }
-
-        /// <summary>
-        /// Requests the list of categories and IDs in the database.
-        /// </summary>
-        /// <returns>A <see cref="CategoryListResponse"/> object.</returns>
-        public static async Task<CategoryListResponse> RequestCategoryListAsync()
-        {
-            string json = await _httpClient.GetStringAsync(ApiCategoryEndpoint);
-            return JsonConvert.DeserializeObject<CategoryListResponse>(json);
-        }
-
-        /// <summary>
-        /// Requests the number of questions in the database, in a specific category.
-        /// </summary>
-        /// <param name="category">The category to request.</param>
-        /// <returns>A <see cref="NumberOfQuestionsInCategoryResponse"/> object.</returns>
-        /// <exception cref="ArgumentException">Thrown when <paramref name="category"/> is <see cref="QuestionCategory.Any"/>.</exception>
-        public static async Task<NumberOfQuestionsInCategoryResponse> RequestNumberOfQuestionsInCategoryAsync(QuestionCategory category)
-        {
-            if (category == QuestionCategory.Any)
-            {
-                throw new ArgumentException("You must specify a category.", nameof(category));
-            }
-
-            string json = await _httpClient.GetStringAsync($"{ApiCategoryCountEndpoint}?category={category:D}");
-            return JsonConvert.DeserializeObject<NumberOfQuestionsInCategoryResponse>(json);
-        }
-
-        /// <summary>
-        /// Requests the total number of questions in the database.
-        /// </summary>
-        /// <returns>A <see cref="GlobalQuestionCountResponse"/> object.</returns>
-        public static async Task<GlobalQuestionCountResponse> RequestGlobalQuestionCountAsync()
-        {
-            string json = await _httpClient.GetStringAsync(ApiGlobalCountEndpoint);
-            return JsonConvert.DeserializeObject<GlobalQuestionCountResponse>(json);
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/UrbanDictionary/Responses.cs b/src/APIs/UrbanDictionary/Responses.cs
deleted file mode 100644
index 130803b..0000000
--- a/src/APIs/UrbanDictionary/Responses.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-using System;
-using System.Collections.Generic;
-using Newtonsoft.Json;
-
-namespace Fergun.APIs.UrbanDictionary
-{
-    public class UrbanResponse
-    {
-        [JsonProperty("list")]
-        public List<DefinitionInfo> Definitions { get; set; }
-    }
-
-    public class DefinitionInfo
-    {
-        [JsonProperty("definition")]
-        public string Definition { get; set; }
-
-        [JsonProperty("permalink")]
-        public string Permalink { get; set; }
-
-        [JsonProperty("thumbs_up")]
-        public int ThumbsUp { get; set; }
-
-        [JsonProperty("sound_urls", NullValueHandling = NullValueHandling.Ignore)]
-        public List<string> SoundUrls { get; set; }
-
-        [JsonProperty("author")]
-        public string Author { get; set; }
-
-        [JsonProperty("word")]
-        public string Word { get; set; }
-
-        [JsonProperty("defid")]
-        public int DefinitionId { get; set; }
-
-        [JsonProperty("current_vote")]
-        public string CurrentVote { get; set; }
-
-        [JsonProperty("written_on")]
-        public DateTimeOffset WrittenOn { get; set; }
-
-        [JsonProperty("example")]
-        public string Example { get; set; }
-
-        [JsonProperty("thumbs_down")]
-        public int ThumbsDown { get; set; }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/UrbanDictionary/UrbanApi.cs b/src/APIs/UrbanDictionary/UrbanApi.cs
deleted file mode 100644
index 18b0024..0000000
--- a/src/APIs/UrbanDictionary/UrbanApi.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using System;
-using System.Net.Http;
-using System.Threading.Tasks;
-using Newtonsoft.Json;
-
-namespace Fergun.APIs.UrbanDictionary
-{
-    public static class UrbanApi
-    {
-        public const string ApiEndpoint = "https://api.urbandictionary.com/v0";
-
-        private static readonly HttpClient _httpClient = new HttpClient();
-
-        public static async Task<UrbanResponse> SearchWordAsync(string word)
-        {
-            string response = await _httpClient.GetStringAsync($"{ApiEndpoint}/define?term={Uri.EscapeDataString(word)}");
-            return JsonConvert.DeserializeObject<UrbanResponse>(response);
-        }
-
-        public static async Task<UrbanResponse> GetRandomWordsAsync()
-        {
-            string response = await _httpClient.GetStringAsync($"{ApiEndpoint}/random");
-            return JsonConvert.DeserializeObject<UrbanResponse>(response);
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/WaybackMachine/WaybackApi.cs b/src/APIs/WaybackMachine/WaybackApi.cs
deleted file mode 100644
index d72f28f..0000000
--- a/src/APIs/WaybackMachine/WaybackApi.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using System;
-using System.Net.Http;
-using System.Threading.Tasks;
-using Newtonsoft.Json;
-
-namespace Fergun.APIs.WaybackMachine
-{
-    public static class WaybackApi
-    {
-        public const string ApiEndpoint = "https://archive.org/wayback/available";
-
-        private static readonly HttpClient _httpClient = new HttpClient { BaseAddress = new Uri(ApiEndpoint) };
-
-        public static async Task<WaybackResponse> GetSnapshotAsync(string url, ulong timestamp)
-        {
-            if (string.IsNullOrEmpty(url))
-            {
-                throw new ArgumentNullException(nameof(url));
-            }
-            double length = Math.Floor(Math.Log10(timestamp) + 1);
-            if (length < 4 || length > 14)
-            {
-                throw new ArgumentOutOfRangeException(nameof(timestamp), timestamp, "Timestamp length must be between 1 and 14.");
-            }
-
-            string json = await _httpClient.GetStringAsync(new Uri($"?url={url}&timestamp={timestamp}", UriKind.Relative));
-            return JsonConvert.DeserializeObject<WaybackResponse>(json);
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/APIs/WaybackMachine/WaybackResponse.cs b/src/APIs/WaybackMachine/WaybackResponse.cs
deleted file mode 100644
index 8e056db..0000000
--- a/src/APIs/WaybackMachine/WaybackResponse.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using Newtonsoft.Json;
-
-namespace Fergun.APIs.WaybackMachine
-{
-    public class WaybackResponse
-    {
-        [JsonProperty("archived_snapshots")]
-        public ArchivedSnapshots ArchivedSnapshots { get; private set; }
-    }
-
-    public class ArchivedSnapshots
-    {
-        [JsonProperty("closest", NullValueHandling = NullValueHandling.Ignore)]
-        public Snapshot Closest { get; private set; }
-    }
-
-    public class Snapshot
-    {
-        [JsonProperty("available")]
-        public bool Available { get; private set; }
-
-        [JsonProperty("url")]
-        public string Url { get; set; }
-
-        [JsonProperty("timestamp")]
-        public string Timestamp { get; private set; }
-
-        [JsonProperty("status")]
-        public string Status { get; private set; }
-    }
-}
\ No newline at end of file
diff --git a/src/Attributes/AlwaysEnabledAttribute.cs b/src/Attributes/AlwaysEnabledAttribute.cs
deleted file mode 100644
index 6813ad6..0000000
--- a/src/Attributes/AlwaysEnabledAttribute.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using System;
-
-namespace Fergun.Attributes
-{
-    /// <summary>
-    /// Marks a command or module to be always enabled.
-    /// </summary>
-    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
-    public sealed class AlwaysEnabledAttribute : Attribute
-    {
-    }
-}
\ No newline at end of file
diff --git a/src/Attributes/ExampleAttribute.cs b/src/Attributes/ExampleAttribute.cs
deleted file mode 100644
index 094f299..0000000
--- a/src/Attributes/ExampleAttribute.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System;
-
-namespace Fergun.Attributes
-{
-    /// <summary>
-    /// Attaches an example to your command.
-    /// </summary>
-    [AttributeUsage(AttributeTargets.Method)]
-    public sealed class ExampleAttribute : Attribute
-    {
-        public string Example { get; }
-
-        public ExampleAttribute(string example)
-        {
-            Example = example;
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Attributes/GitHashInfoAttribute.cs b/src/Attributes/GitHashInfoAttribute.cs
deleted file mode 100644
index d68e5a7..0000000
--- a/src/Attributes/GitHashInfoAttribute.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using System;
-
-namespace Fergun.Attributes
-{
-    [AttributeUsage(AttributeTargets.Assembly)]
-    public sealed class GitHashInfoAttribute : Attribute
-    {
-        public GitHashInfoAttribute(string gitHash)
-        {
-            GitHash = gitHash;
-        }
-
-        public string GitHash { get; }
-    }
-}
\ No newline at end of file
diff --git a/src/Attributes/LongRunningAttribute.cs b/src/Attributes/LongRunningAttribute.cs
deleted file mode 100644
index 2d60db1..0000000
--- a/src/Attributes/LongRunningAttribute.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Fergun.Services;
-using Fergun.Utils;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Fergun.Attributes
-{
-    /// <summary>
-    /// An attribute that sends the typing state to the current channel (useful for long-running commands).
-    /// </summary>
-    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)]
-    public sealed class LongRunningAttribute : PreconditionAttribute
-    {
-        public override async Task<PreconditionResult> CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services)
-        {
-            IUserMessage response;
-            ulong messageId = 0;
-            bool? found = services.GetService<CommandCacheService>()?.TryGetValue(context.Message.Id, out messageId);
-            var cache = services.GetService<MessageCacheService>();
-
-            if ((found ?? false) && (response = (IUserMessage)await context.Channel.GetMessageAsync(cache, messageId)) != null)
-            {
-                await response.ModifyAsync(x =>
-                {
-                    x.Content = null;
-                    x.Embed = new EmbedBuilder()
-                        .WithDescription($"{FergunClient.Config.LoadingEmote} {GuildUtils.Locate("Loading", context.Channel)}")
-                        .WithColor(FergunClient.Config.EmbedColor)
-                        .Build();
-                    x.Components = new ComponentBuilder().Build(); // Remove components
-                });
-            }
-            else
-            {
-                await context.Channel.TriggerTypingAsync();
-            }
-            return PreconditionResult.FromSuccess();
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Attributes/OrderAttribute.cs b/src/Attributes/OrderAttribute.cs
deleted file mode 100644
index ea00296..0000000
--- a/src/Attributes/OrderAttribute.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System;
-
-namespace Fergun.Attributes
-{
-    /// <summary>
-    /// Marks the order of a module, a lower value equals higher order.
-    /// </summary>
-    [AttributeUsage(AttributeTargets.Class)]
-    public sealed class OrderAttribute : Attribute
-    {
-        public int Order { get; }
-
-        public OrderAttribute(int order)
-        {
-            Order = order;
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Attributes/Preconditions/DisabledAttribute.cs b/src/Attributes/Preconditions/DisabledAttribute.cs
deleted file mode 100644
index 2ff7be2..0000000
--- a/src/Attributes/Preconditions/DisabledAttribute.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using Discord.Commands;
-
-namespace Fergun.Attributes.Preconditions
-{
-    /// <summary>
-    /// Disables the command or module globally or on a specific guild.
-    /// </summary>
-    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
-    public sealed class DisabledAttribute : RequireContextAttribute
-    {
-        public DisabledAttribute() : base(ContextType.Guild)
-        {
-        }
-
-        public DisabledAttribute(params ulong[] guildIds) : this()
-        {
-            _guildIds = guildIds;
-        }
-
-        /// <inheritdoc />
-        public override string ErrorMessage { get; set; }
-
-        private readonly ulong[] _guildIds = Array.Empty<ulong>();
-
-        /// <inheritdoc />
-        public override Task<PreconditionResult> CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services)
-        {
-            return _guildIds.Length == 0 || Array.Exists(_guildIds, x => x == context.Guild.Id)
-                ? Task.FromResult(PreconditionResult.FromError(ErrorMessage ?? "Disabled command / module."))
-                : Task.FromResult(PreconditionResult.FromSuccess());
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Attributes/Preconditions/RatelimitAttribute.cs b/src/Attributes/Preconditions/RatelimitAttribute.cs
deleted file mode 100644
index c333e39..0000000
--- a/src/Attributes/Preconditions/RatelimitAttribute.cs
+++ /dev/null
@@ -1,190 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Fergun.Utils;
-
-namespace Fergun.Attributes.Preconditions
-{
-    /// <summary>
-    ///     Sets how often a user is allowed to use this command
-    ///     or any command in this module.
-    /// </summary>
-    /// <remarks>
-    ///     <note type="warning">
-    ///         This is backed by an in-memory collection
-    ///         and will not persist with restarts.
-    ///     </note>
-    /// </remarks>
-    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)]
-    public sealed class RatelimitAttribute : PreconditionAttribute
-    {
-        /// <inheritdoc />
-        public override string ErrorMessage { get; set; }
-
-        public uint InvokeLimit { get; }
-
-        public TimeSpan InvokeLimitPeriod { get; }
-
-        private readonly bool _noLimitInDMs;
-        private readonly bool _noLimitForAdmins;
-        private readonly bool _applyPerGuild;
-        private readonly Dictionary<(ulong, ulong?), CommandTimeout> _invokeTracker = new Dictionary<(ulong, ulong?), CommandTimeout>();
-
-        /// <summary>
-        ///     Sets how often a user is allowed to use this command. </summary>
-        /// <param name="times">
-        ///     The number of times a user may use the command within a certain period.
-        /// </param>
-        /// <param name="period">
-        ///     The amount of time since first invoke a user has until the limit is lifted.
-        /// </param>
-        /// <param name="measure">
-        ///     The scale in which the <paramref name="period"/> parameter should be measured.
-        /// </param>
-        /// <param name="flags">
-        ///     Flags to set behavior of the ratelimit.
-        /// </param>
-        public RatelimitAttribute(
-            uint times, double period, Measure measure,
-            RatelimitOptions flags = RatelimitOptions.None)
-        {
-            InvokeLimit = times;
-            _noLimitInDMs = (flags & RatelimitOptions.NoLimitInDMs) == RatelimitOptions.NoLimitInDMs;
-            _noLimitForAdmins = (flags & RatelimitOptions.NoLimitForAdmins) == RatelimitOptions.NoLimitForAdmins;
-            _applyPerGuild = (flags & RatelimitOptions.ApplyPerGuild) == RatelimitOptions.ApplyPerGuild;
-
-            InvokeLimitPeriod = measure switch
-            {
-                Measure.Days => TimeSpan.FromDays(period),
-                Measure.Hours => TimeSpan.FromHours(period),
-                Measure.Minutes => TimeSpan.FromMinutes(period),
-                _ => throw new ArgumentOutOfRangeException(nameof(period), "Argument was not within the valid range.")
-            };
-        }
-
-        /// <summary>
-        ///     Sets how often a user is allowed to use this command.
-        /// </summary>
-        /// <param name="times">
-        ///     The number of times a user may use the command within a certain period.
-        /// </param>
-        /// <param name="period">
-        ///     The amount of time since first invoke a user has until the limit is lifted.
-        /// </param>
-        /// <param name="flags">
-        ///     Flags to set the behavior of the ratelimit.
-        /// </param>
-        /// <remarks>
-        ///     <note type="warning">
-        ///         This is a convenient constructor overload for use with the dynamic
-        ///         command builders, but not with the Class &amp; Method-style commands.
-        ///     </note>
-        /// </remarks>
-        public RatelimitAttribute(
-            uint times, TimeSpan period,
-            RatelimitOptions flags = RatelimitOptions.None)
-        {
-            InvokeLimit = times;
-            _noLimitInDMs = (flags & RatelimitOptions.NoLimitInDMs) == RatelimitOptions.NoLimitInDMs;
-            _noLimitForAdmins = (flags & RatelimitOptions.NoLimitForAdmins) == RatelimitOptions.NoLimitForAdmins;
-            _applyPerGuild = (flags & RatelimitOptions.ApplyPerGuild) == RatelimitOptions.ApplyPerGuild;
-
-            InvokeLimitPeriod = period;
-        }
-
-        /// <inheritdoc />
-        public override async Task<PreconditionResult> CheckPermissionsAsync(
-            ICommandContext context, CommandInfo command, IServiceProvider services)
-        {
-            if ((await context.Client.GetApplicationInfoAsync()).Owner.Id == context.User.Id)
-                return PreconditionResult.FromSuccess();
-
-            if (_noLimitInDMs && context.Channel is IPrivateChannel)
-                return PreconditionResult.FromSuccess();
-
-            if (_noLimitForAdmins && context.User is IGuildUser gu && gu.GuildPermissions.Administrator)
-                return PreconditionResult.FromSuccess();
-
-            var now = DateTimeOffset.UtcNow;
-            var key = _applyPerGuild ? (context.User.Id, context.Guild?.Id) : (context.User.Id, null);
-
-            var timeout = _invokeTracker.TryGetValue(key, out var t)
-                          && now - t.FirstInvoke < InvokeLimitPeriod ? t : new CommandTimeout(now);
-
-            timeout.TimesInvoked++;
-
-            if (timeout.TimesInvoked <= InvokeLimit)
-            {
-                _invokeTracker[key] = timeout;
-                return PreconditionResult.FromSuccess();
-            }
-
-            double cooldown = (InvokeLimitPeriod - (now - timeout.FirstInvoke)).TotalSeconds;
-
-            return PreconditionResult.FromError(
-                ErrorMessage ?? "(Cooldown) " + string.Format(GuildUtils.Locate("Ratelimited", context.Channel), Math.Round(cooldown, 2).ToString(CultureInfo.InvariantCulture)));
-        }
-
-        private sealed class CommandTimeout
-        {
-            public uint TimesInvoked { get; set; }
-            public DateTimeOffset FirstInvoke { get; }
-
-            public CommandTimeout(DateTimeOffset timeStarted)
-            {
-                FirstInvoke = timeStarted;
-            }
-        }
-    }
-
-    /// <summary>
-    ///     Determines the scale of the period parameter.
-    /// </summary>
-    public enum Measure
-    {
-        /// <summary>
-        ///     Period is measured in days.
-        /// </summary>
-        Days,
-
-        /// <summary>
-        ///     Period is measured in hours.
-        /// </summary>
-        Hours,
-
-        /// <summary>
-        ///     Period is measured in minutes.
-        /// </summary>
-        Minutes
-    }
-
-    /// <summary>
-    ///     Determines the behavior of the <see cref="RatelimitAttribute"/>.
-    /// </summary>
-    [Flags]
-    public enum RatelimitOptions
-    {
-        /// <summary>
-        ///     Set none of the flags.
-        /// </summary>
-        None = 0,
-
-        /// <summary>
-        ///     Set whether or not there is no limit to the command in DMs.
-        /// </summary>
-        NoLimitInDMs = 1 << 0,
-
-        /// <summary>
-        ///     Set whether or not there is no limit to the command for guild admins.
-        /// </summary>
-        NoLimitForAdmins = 1 << 1,
-
-        /// <summary>
-        ///     Set whether or not to apply a limit per guild.
-        /// </summary>
-        ApplyPerGuild = 1 << 2
-    }
-}
\ No newline at end of file
diff --git a/src/Attributes/Preconditions/RequireLowerHierarchyAttribute.cs b/src/Attributes/Preconditions/RequireLowerHierarchyAttribute.cs
deleted file mode 100644
index 8470105..0000000
--- a/src/Attributes/Preconditions/RequireLowerHierarchyAttribute.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Fergun.Extensions;
-
-namespace Fergun.Attributes.Preconditions
-{
-    /// <summary>
-    ///     Indicates this parameter must be a <see cref="IGuildUser"/>
-    ///     whose Hierarchy value must be
-    ///     lower than that of the Bot.
-    /// </summary>
-    [AttributeUsage(AttributeTargets.Parameter)]
-    public sealed class RequireLowerHierarchyAttribute : ParameterPreconditionAttribute
-    {
-        public string ErrorMessage { get; }
-
-        public bool IgnoreNotGuildContext { get; }
-
-        public RequireLowerHierarchyAttribute()
-        {
-        }
-
-        public RequireLowerHierarchyAttribute(string errorMessage)
-        {
-            ErrorMessage = errorMessage;
-        }
-
-        public RequireLowerHierarchyAttribute(string errorMessage, bool ignoreNotGuildContext) : this(errorMessage)
-        {
-            IgnoreNotGuildContext = ignoreNotGuildContext;
-        }
-
-        /// <inheritdoc />
-        public override async Task<PreconditionResult> CheckPermissionsAsync(
-            ICommandContext context, ParameterInfo parameter, object value, IServiceProvider services)
-        {
-            if (!(value is IGuildUser user))
-            {
-                return IgnoreNotGuildContext
-                    ? PreconditionResult.FromSuccess()
-                    : PreconditionResult.FromError("Command requires Guild context.");
-            }
-
-            int botHierarchy = (await user.Guild.GetCurrentUserAsync()).GetHierarchy();
-            int userHierarchy = user.GetHierarchy();
-            return botHierarchy > userHierarchy
-                ? PreconditionResult.FromSuccess()
-                : PreconditionResult.FromError(ErrorMessage ?? "Specified user must be lower in hierarchy.");
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Attributes/Preconditions/UserMustBeInVoiceAttribute.cs b/src/Attributes/Preconditions/UserMustBeInVoiceAttribute.cs
deleted file mode 100644
index 456f25c..0000000
--- a/src/Attributes/Preconditions/UserMustBeInVoiceAttribute.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-using System;
-using System.Linq;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Fergun.Utils;
-using Microsoft.Extensions.DependencyInjection;
-using Victoria;
-
-namespace Fergun.Attributes.Preconditions
-{
-    /// <summary>
-    ///     Indicates that this command can only be used while
-    ///     the user is in a voice channel in the same Guild,
-    ///     and the Lavalink node is connected.
-    ///     This precondition automatically applies <see cref="RequireContextAttribute"/>.
-    /// </summary>
-    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)]
-    public sealed class UserMustBeInVoiceAttribute : RequireContextAttribute
-    {
-        /// <inheritdoc />
-        public override string ErrorMessage { get; set; }
-
-        public UserMustBeInVoiceAttribute()
-            : base(ContextType.Guild)
-        {
-        }
-
-        public UserMustBeInVoiceAttribute(params string[] exceptions)
-            : this()
-        {
-            _exceptions = exceptions;
-        }
-
-        private readonly string[] _exceptions;
-
-        /// <inheritdoc />
-        public override async Task<PreconditionResult> CheckPermissionsAsync(
-            ICommandContext context, CommandInfo command, IServiceProvider services)
-        {
-            var baseResult = await base.CheckPermissionsAsync(context, command, services);
-            if (!baseResult.IsSuccess)
-                return baseResult;
-
-            if (Array.Exists(_exceptions, x => x == command.Name))
-                return PreconditionResult.FromSuccess();
-
-            var lavaNode = services.GetService<LavaNode>();
-            if (lavaNode == null || !lavaNode.IsConnected)
-                return PreconditionResult.FromError(ErrorMessage ?? GuildUtils.Locate("LavalinkNotConnected", context.Channel));
-
-            var current = (context.User as IVoiceState)?.VoiceChannel?.Id;
-            return (await context.Guild.GetVoiceChannelsAsync()).Any(v => v.Id == current)
-                ? PreconditionResult.FromSuccess()
-                : PreconditionResult.FromError(ErrorMessage ?? GuildUtils.Locate("UserNotInVC", context.Channel));
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Config/AidAdventure.cs b/src/Config/AidAdventure.cs
deleted file mode 100644
index b92acae..0000000
--- a/src/Config/AidAdventure.cs
+++ /dev/null
@@ -1,51 +0,0 @@
-using MongoDB.Bson;
-using MongoDB.Bson.Serialization.Attributes;
-
-namespace Fergun
-{
-    /// <summary>
-    /// Represents an AI Dungeon adventure.
-    /// </summary>
-    [BsonIgnoreExtraElements]
-    public class AidAdventure : IIdentity
-    {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="AidAdventure"/> class with the provided values.
-        /// </summary>
-        /// <param name="id">The Id of the adventure.</param>
-        /// <param name="publicId">The public Id of the adventure.</param>
-        /// <param name="ownerId">The owner Id of the adventure.</param>
-        /// <param name="isPublic">Whether the adventure is public.</param>
-        public AidAdventure(long id, string publicId, ulong ownerId, bool isPublic)
-        {
-            Id = id;
-            PublicId = publicId;
-            OwnerId = ownerId;
-            IsPublic = isPublic;
-        }
-
-        /// <inheritdoc/>
-        [BsonId]
-        public ObjectId ObjectId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the Id of this adventure.
-        /// </summary>
-        public long Id { get; set; }
-
-        /// <summary>
-        /// Gets or sets the public Id of this adventure.
-        /// </summary>
-        public string PublicId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the owner Id of this adventure.
-        /// </summary>
-        public ulong OwnerId { get; set; }
-
-        /// <summary>
-        /// Gets or sets whether this adventure is public.
-        /// </summary>
-        public bool IsPublic { get; set; }
-    }
-}
\ No newline at end of file
diff --git a/src/Config/DatabaseConfig.cs b/src/Config/DatabaseConfig.cs
deleted file mode 100644
index 436e3f2..0000000
--- a/src/Config/DatabaseConfig.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using System;
-using System.Collections.Generic;
-using MongoDB.Bson;
-using MongoDB.Bson.Serialization.Attributes;
-
-namespace Fergun
-{
-    /// <summary>
-    /// Represents the bot configuration in the database.
-    /// </summary>
-    public static class DatabaseConfig
-    {
-        /// <summary>
-        /// Gets the bot global prefix.
-        /// </summary>
-        public static string GlobalPrefix => FergunClient.IsDebugMode ? GetConfig().DevGlobalPrefix : GetConfig().GlobalPrefix;
-
-        /// <summary>
-        /// Gets the bot language.
-        /// </summary>
-        public static string Language => GetConfig().Language;
-
-        /// <summary>
-        /// Gets a dictionary containing the command stats.
-        /// </summary>
-        public static IDictionary<string, int> CommandStats => GetConfig().CommandStats ?? new Dictionary<string, int>();
-
-        /// <summary>
-        /// Gets a dictionary containing the commands that have been disabled globally.
-        /// </summary>
-        public static IDictionary<string, string> GloballyDisabledCommands => GetConfig().GloballyDisabledCommands ?? new Dictionary<string, string>();
-
-        /// <summary>
-        /// Modifies the database config with the specified properties.
-        /// </summary>
-        /// <param name="action">A delegate containing the properties to modify the config with.</param>
-        public static void Update(Action<BaseDatabaseConfig> action)
-        {
-            var cfg = GetConfig();
-            action(cfg);
-            FergunClient.Database.InsertOrUpdateDocument(Constants.ConfigCollection, cfg);
-        }
-
-        private static BaseDatabaseConfig GetConfig()
-        {
-            return FergunClient.Database.GetFirstDocument<BaseDatabaseConfig>(Constants.ConfigCollection) ?? new BaseDatabaseConfig();
-        }
-    }
-
-    [BsonIgnoreExtraElements]
-    public class BaseDatabaseConfig : IIdentity
-    {
-        [BsonId]
-        public ObjectId ObjectId { get; set; }
-        public string GlobalPrefix { get; set; } = Constants.DefaultPrefix;
-        public string DevGlobalPrefix { get; set; } = Constants.DefaultDevPrefix;
-        public string Language { get; set; }
-        public IDictionary<string, int> CommandStats { get; set; } = new Dictionary<string, int>();
-        public IDictionary<string, string> GloballyDisabledCommands { get; set; } = new Dictionary<string, string>();
-    }
-}
\ No newline at end of file
diff --git a/src/Config/FergunConfig.cs b/src/Config/FergunConfig.cs
deleted file mode 100644
index a9b929c..0000000
--- a/src/Config/FergunConfig.cs
+++ /dev/null
@@ -1,284 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.Linq;
-using Discord;
-using Newtonsoft.Json;
-using Victoria;
-
-namespace Fergun
-{
-    /// <summary>
-    /// Represents the bot configuration.
-    /// </summary>
-    public class FergunConfig
-    {
-        /// <summary>
-        /// Gets the bot token.
-        /// </summary>
-        [JsonProperty]
-        public string Token { get; private set; }
-
-        /// <summary>
-        /// Gets the development bot token.
-        /// </summary>
-        /// <remarks>
-        /// This token will be automatically used in debug builds.
-        /// </remarks>
-        [JsonProperty]
-        public string DevToken { get; private set; }
-
-        /// <summary>
-        /// Gets the Top.gg API token.
-        /// </summary>
-        [JsonProperty]
-        public string TopGgApiToken { get; private set; }
-
-        /// <summary>
-        /// Gets the Discord Bots API token.
-        /// </summary>
-        [JsonProperty]
-        public string DiscordBotsApiToken { get; private set; }
-
-        /// <summary>
-        /// Gets the Genius API token.
-        /// </summary>
-        [JsonProperty]
-        public string GeniusApiToken { get; private set; }
-
-        /// <summary>
-        /// Gets the AI Dungeon user token.
-        /// </summary>
-        [JsonProperty]
-        public string AiDungeonToken { get; private set; }
-
-        /// <summary>
-        /// Gets the DeepAI API key.
-        /// </summary>
-        [JsonProperty]
-        public string DeepAiApiKey { get; private set; }
-
-        /// <summary>
-        /// Gets the ApiFlash access key.
-        /// </summary>
-        [JsonProperty]
-        public string ApiFlashAccessKey { get; private set; }
-
-        /// <summary>
-        /// Gets the WolframAlpha App ID.
-        /// </summary>
-        [JsonProperty]
-        public string WolframAlphaAppId { get; private set; }
-
-        /// <summary>
-        /// Gets the raw value of the color the bot will use in its embeds.
-        /// </summary>
-        [JsonProperty]
-        public uint EmbedColor { get; private set; } = Constants.DefaultEmbedColor;
-
-        /// <summary>
-        /// Gets the support server invite.
-        /// </summary>
-        [JsonProperty]
-        public string SupportServer { get; private set; }
-
-        /// <summary>
-        /// Gets the ID of the log channel.
-        /// </summary>
-        [JsonProperty]
-        public ulong LogChannel { get; private set; }
-
-        /// <summary>
-        /// Gets whether the <see cref="GatewayIntents.GuildPresences"/> intent should be used.
-        /// </summary>
-        [JsonProperty]
-        public bool PresenceIntent { get; private set; }
-
-        /// <summary>
-        /// Gets whether the <see cref="GatewayIntents.GuildMembers"/> intent should be used.
-        /// </summary>
-        [JsonProperty]
-        public bool ServerMembersIntent { get; private set; }
-
-        /// <summary>
-        /// Gets the message cache size. If <see cref="UseMessageCacheService"/> is set to <see langword="true"/>, this property will be used in the optimized cache service instead.
-        /// </summary>
-        [JsonProperty]
-        public int MessageCacheSize { get; private set; } = 100;
-
-        /// <summary>
-        /// Gets the number of messages to search in a channel.
-        /// </summary>
-        /// <remarks>
-        /// This property is used in commands that searches for a Url in the messages of a channel.
-        /// </remarks>
-        [JsonProperty]
-        public int MessagesToSearchLimit { get; private set; } = 100;
-
-        /// <summary>
-        /// Gets whether all users should be downloaded to the cache.
-        /// </summary>
-        [JsonProperty]
-        public bool AlwaysDownloadUsers { get; private set; }
-
-        /// <summary>
-        /// Gets whether the reliability service should be used.
-        /// </summary>
-        [JsonProperty]
-        public bool UseReliabilityService { get; private set; }
-
-        /// <summary>
-        /// Gets whether the command cache service should be used.
-        /// </summary>
-        [JsonProperty]
-        public bool UseCommandCacheService { get; private set; } = true;
-
-        /// <summary>
-        /// Gets whether the optimized message cache service should be used.
-        /// </summary>
-        [JsonProperty]
-        public bool UseMessageCacheService { get; private set; } = true;
-
-        /// <summary>
-        /// Gets the minimum hours since a command has to be used in a guild for the messages to be cached there. Setting this to 0 disables this requirement.
-        /// </summary>
-        /// <remarks>
-        /// This is used in the optimized message cache.
-        /// </remarks>
-        [JsonProperty]
-        public int MinimumCommandTime { get; private set; } = Constants.MinCommandTime;
-
-        /// <summary>
-        /// Gets the donation Url.
-        /// </summary>
-        [JsonProperty]
-        public string DonationUrl { get; private set; }
-
-        /// <summary>
-        /// Gets the total number of shards to use.
-        /// </summary>
-        [JsonProperty]
-        public int? TotalShards { get; private set; }
-
-        /// <summary>
-        /// Gets the minimum log level.
-        /// </summary>
-        [JsonProperty]
-        public LogSeverity LogLevel { get; private set; } = LogSeverity.Verbose;
-
-        /// <summary>
-        /// Gets the MongoDB server authentication info.
-        /// </summary>
-        [JsonProperty]
-        public MongoConfig DatabaseConfig { get; private set; } = MongoConfig.Default;
-
-        /// <summary>
-        /// Gets the Lavalink server configuration.
-        /// </summary>
-        [JsonProperty]
-        public LavaConfig LavaConfig { get; private set; } = new LavaConfig();
-
-        /// <summary>
-        /// Gets the loading emote.
-        /// </summary>
-        [JsonProperty]
-        public string LoadingEmote { get; private set; }
-
-        /// <summary>
-        /// Gets the online emote.
-        /// </summary>
-        [JsonProperty]
-        public string OnlineEmote { get; private set; }
-
-        /// <summary>
-        /// Gets  the idle emote.
-        /// </summary>
-        [JsonProperty]
-        public string IdleEmote { get; private set; }
-
-        /// <summary>
-        /// Gets the do not disturb emote.
-        /// </summary>
-        [JsonProperty]
-        public string DndEmote { get; private set; }
-
-        /// <summary>
-        /// Gets the streaming emote.
-        /// </summary>
-        [JsonProperty]
-        public string StreamingEmote { get; private set; }
-
-        /// <summary>
-        /// Gets the offline emote.
-        /// </summary>
-        [JsonProperty]
-        public string OfflineEmote { get; private set; }
-
-        /// <summary>
-        /// Gets the text channel emote.
-        /// </summary>
-        [JsonProperty]
-        public string TextEmote { get; private set; }
-
-        /// <summary>
-        /// Gets the voice channel emote.
-        /// </summary>
-        [JsonProperty]
-        public string VoiceEmote { get; private set; }
-
-        /// <summary>
-        /// Gets the MongoDB emote.
-        /// </summary>
-        [JsonProperty]
-        public string MongoDbEmote { get; private set; }
-
-        /// <summary>
-        /// Gets the websocket emote.
-        /// </summary>
-        [JsonProperty]
-        public string WebSocketEmote { get; private set; }
-
-        /// <summary>
-        /// Gets the Nitro booster emote.
-        /// </summary>
-        [JsonProperty]
-        public string BoosterEmote { get; internal set; }
-
-        /// <summary>
-        /// Gets the first page emote. Used in paginators.
-        /// </summary>
-        [JsonProperty]
-        public string FirstPageEmote { get; private set; }
-
-        /// <summary>
-        /// Gets the previous page emote. Used in paginators.
-        /// </summary>
-        [JsonProperty]
-        public string PreviousPageEmote { get; private set; }
-
-        /// <summary>
-        /// Gets the next page emote. Used in paginators.
-        /// </summary>
-        [JsonProperty]
-        public string NextPageEmote { get; private set; }
-
-        /// <summary>
-        /// Gets the last page emote. Used in paginators.
-        /// </summary>
-        [JsonProperty]
-        public string LastPageEmote { get; private set; }
-
-        /// <summary>
-        /// Gets the stop paginator emote. Used in paginators.
-        /// </summary>
-        [JsonProperty]
-        public string StopPaginatorEmote { get; private set; }
-
-        /// <summary>
-        /// Gets user flags emotes.
-        /// </summary>
-        [JsonProperty]
-        public IReadOnlyDictionary<string, string> UserFlagsEmotes { get; private set; }
-            = new ReadOnlyDictionary<string, string>(Enum.GetNames(typeof(UserProperties)).ToDictionary(x => x, x => (string)null));
-    }
-}
\ No newline at end of file
diff --git a/src/Config/FergunDatabase.cs b/src/Config/FergunDatabase.cs
deleted file mode 100644
index 399f2be..0000000
--- a/src/Config/FergunDatabase.cs
+++ /dev/null
@@ -1,291 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Linq.Expressions;
-using System.Threading.Tasks;
-using MongoDB.Bson;
-using MongoDB.Driver;
-using MongoDB.Driver.Core.Clusters;
-
-namespace Fergun
-{
-    /// <summary>
-    /// Represents the bot database.
-    /// </summary>
-    public class FergunDatabase
-    {
-        private readonly MongoClient _client;
-        private readonly IMongoDatabase _database;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="FergunDatabase"/> class with the provided database name.
-        /// </summary>
-        /// <param name="database">The name of the database.</param>
-        public FergunDatabase(string database)
-        {
-            _client = new MongoClient();
-            _database = _client.GetDatabase(database);
-        }
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="FergunDatabase"/> class with the provided database name and connection string.
-        /// </summary>
-        /// <param name="database">The name of the database.</param>
-        /// <param name="url">The connection string.</param>
-        public FergunDatabase(string database, string url)
-        {
-            _client = new MongoClient(new MongoUrlBuilder(url).ToMongoUrl());
-            _database = _client.GetDatabase(database);
-        }
-
-        /// <summary>
-        /// Gets whether the bot is connected to the database.
-        /// </summary>
-        public bool IsConnected
-        {
-            get
-            {
-                try
-                {
-                    _client.ListDatabaseNames();
-                    return _client.Cluster.Description.State == ClusterState.Connected;
-                }
-                catch (MongoException) { return false; }
-            }
-        }
-
-        /// <summary>
-        /// Inserts a document.
-        /// </summary>
-        /// <param name="collection">The collection.</param>
-        /// <param name="document">The document.</param>
-        public void InsertDocument<T>(string collection, T document)
-        {
-            var c = _database.GetCollection<T>(collection);
-            c.InsertOne(document);
-        }
-
-        /// <summary>
-        /// Inserts a document asynchronously.
-        /// </summary>
-        /// <param name="collection">The collection.</param>
-        /// <param name="document">The document.</param>
-        public async Task InsertDocumentAsync<T>(string collection, T document)
-        {
-            var c = _database.GetCollection<T>(collection);
-            await c.InsertOneAsync(document);
-        }
-
-        /// <summary>
-        /// Inserts multiple documents.
-        /// </summary>
-        /// <param name="collection">The collection.</param>
-        /// <param name="documents">The documents.</param>
-        public void InsertDocuments<T>(string collection, IEnumerable<T> documents)
-        {
-            var c = _database.GetCollection<T>(collection);
-            c.InsertMany(documents);
-        }
-
-        /// <summary>
-        /// Inserts multiple documents asynchronously.
-        /// </summary>
-        /// <param name="collection">The collection.</param>
-        /// <param name="documents">The documents.</param>
-        public async Task InsertDocumentsAsync<T>(string collection, IEnumerable<T> documents)
-        {
-            var c = _database.GetCollection<T>(collection);
-            await c.InsertManyAsync(documents);
-        }
-
-        /// <summary>
-        /// Inserts or updates a document.
-        /// </summary>
-        /// <typeparam name="T">A type that inherits from <see cref="IIdentity"/>.</typeparam>
-        /// <param name="collection">The collection.</param>
-        /// <param name="document">The document.</param>
-        public void InsertOrUpdateDocument<T>(string collection, T document) where T : IIdentity
-        {
-            var c = _database.GetCollection<T>(collection);
-            if (document.ObjectId == ObjectId.Empty)
-            {
-                c.InsertOne(document); // driver creates ObjectId under the hood
-            }
-            else
-            {
-                c.ReplaceOne(x => x.ObjectId == document.ObjectId, document);
-            }
-        }
-
-        /// <summary>
-        /// Inserts or updates a document asynchronously.
-        /// </summary>
-        /// <typeparam name="T">A type that inherits from <see cref="IIdentity"/>.</typeparam>
-        /// <param name="collection">The collection.</param>
-        /// <param name="document">The document.</param>
-        public async Task InsertOrUpdateDocumentAsync<T>(string collection, T document) where T : IIdentity
-        {
-            var c = _database.GetCollection<T>(collection);
-            if (document.ObjectId == ObjectId.Empty)
-            {
-                await c.InsertOneAsync(document); // driver creates ObjectId under the hood
-            }
-            else
-            {
-                await c.ReplaceOneAsync(x => x.ObjectId == document.ObjectId, document);
-            }
-        }
-
-        /// <summary>
-        /// Gets the first document in the collection.
-        /// </summary>
-        /// <param name="collection">The collection.</param>
-        public T GetFirstDocument<T>(string collection)
-        {
-            var c = _database.GetCollection<T>(collection);
-            return c.Find(new BsonDocument()).FirstOrDefault();
-        }
-
-        /// <summary>
-        /// Gets the first document in the collection asynchronously.
-        /// </summary>
-        /// <param name="collection">The collection.</param>
-        public async Task<T> GetFirstDocumentAsync<T>(string collection)
-        {
-            var c = _database.GetCollection<T>(collection);
-            return await (await c.FindAsync(new BsonDocument())).FirstOrDefaultAsync();
-        }
-
-        /// <summary>
-        /// Gets all the documents in the collection.
-        /// </summary>
-        /// <param name="collection">The collection.</param>
-        public IEnumerable<T> GetAllDocuments<T>(string collection)
-        {
-            var c = _database.GetCollection<T>(collection);
-            return c.Find(new BsonDocument()).ToEnumerable();
-        }
-
-        /// <summary>
-        /// Gets all the documents in the collection asynchronously.
-        /// </summary>
-        /// <param name="collection">The collection.</param>
-        public async Task<IAsyncEnumerable<T>> GetAllDocumentsAsync<T>(string collection)
-        {
-            var c = _database.GetCollection<T>(collection);
-            return (await c.FindAsync(new BsonDocument())).ToEnumerable().ToAsyncEnumerable();
-        }
-
-        /// <summary>
-        /// Deletes a document.
-        /// </summary>
-        /// <typeparam name="T">A type that inherits from <see cref="IIdentity"/>.</typeparam>
-        /// <param name="collection">The collection.</param>
-        /// <param name="document">The document.</param>
-        public bool DeleteDocument<T>(string collection, T document) where T : IIdentity
-        {
-            var c = _database.GetCollection<T>(collection);
-            var result = c.DeleteOne(x => x.ObjectId == document.ObjectId);
-            return result.IsAcknowledged;
-        }
-
-        /// <summary>
-        /// Deletes a document asynchronously.
-        /// </summary>
-        /// <typeparam name="T">A type that inherits from <see cref="IIdentity"/>.</typeparam>
-        /// <param name="collection">The collection.</param>
-        /// <param name="document">The document.</param>
-        public async Task<bool> DeleteDocumentAsync<T>(string collection, T document) where T : IIdentity
-        {
-            var c = _database.GetCollection<T>(collection);
-            var result = await c.DeleteOneAsync(x => x.ObjectId == document.ObjectId);
-            return result.IsAcknowledged;
-        }
-
-        /// <summary>
-        /// Finds a document.
-        /// </summary>
-        /// <param name="collection">The collection.</param>
-        /// <param name="filter">The filter.</param>
-        public T FindDocument<T>(string collection, Expression<Func<T, bool>> filter) where T : class
-        {
-            var c = _database.GetCollection<T>(collection);
-            return c.Find(filter).Limit(1).FirstOrDefault();
-        }
-
-        /// <summary>
-        /// Finds a document asynchronously.
-        /// </summary>
-        /// <param name="collection">The collection.</param>
-        /// <param name="filter">The filter.</param>
-        public async Task<T> FindDocumentAsync<T>(string collection, Expression<Func<T, bool>> filter) where T : class
-        {
-            var c = _database.GetCollection<T>(collection);
-            return await (await c.FindAsync(filter)).FirstOrDefaultAsync();
-        }
-
-        /// <summary>
-        /// Finds multiple documents.
-        /// </summary>
-        /// <param name="collection">The collection.</param>
-        /// <param name="filter">The filter.</param>
-        public IEnumerable<T> FindManyDocuments<T>(string collection, Expression<Func<T, bool>> filter) where T : class
-        {
-            var c = _database.GetCollection<T>(collection);
-            return c.Find(filter).ToEnumerable();
-        }
-
-        /// <summary>
-        /// Finds multiple documents asynchronously.
-        /// </summary>
-        /// <param name="collection">The collection.</param>
-        /// <param name="filter">The filter.</param>
-        public async Task<IAsyncEnumerable<T>> FindManyDocumentsAsync<T>(string collection, Expression<Func<T, bool>> filter) where T : class
-        {
-            var c = _database.GetCollection<T>(collection);
-            return (await c.FindAsync(filter)).ToEnumerable().ToAsyncEnumerable();
-        }
-
-        /// <summary>
-        /// Renames a collection.
-        /// </summary>
-        /// <param name="oldName">The old name.</param>
-        /// <param name="newName">The new name.</param>
-        public void RenameCollection(string oldName, string newName)
-        {
-            _database.RenameCollection(oldName, newName);
-        }
-
-        /// <summary>
-        /// Renames a collection asynchronously.
-        /// </summary>
-        /// <param name="oldName">The old name.</param>
-        /// <param name="newName">The new name.</param>
-        public async Task RenameCollectionAsync(string oldName, string newName)
-        {
-            await _database.RenameCollectionAsync(oldName, newName);
-        }
-
-        /// <summary>
-        /// Runs a command.
-        /// </summary>
-        /// <param name="command">The command.</param>
-        /// <returns>The result of the commands.</returns>
-        public string RunCommand(string command)
-        {
-            try
-            {
-                var result = _database.RunCommand<BsonDocument>(BsonDocument.Parse(command));
-                return result.ToJson();
-            }
-            catch (FormatException)
-            {
-                return null;
-            }
-            catch (MongoCommandException)
-            {
-                return null;
-            }
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Config/GuildConfig.cs b/src/Config/GuildConfig.cs
deleted file mode 100644
index 9fef9f9..0000000
--- a/src/Config/GuildConfig.cs
+++ /dev/null
@@ -1,91 +0,0 @@
-using System.Collections.Generic;
-using MongoDB.Bson;
-using MongoDB.Bson.Serialization.Attributes;
-
-namespace Fergun
-{
-    /// <summary>
-    /// Represents a Discord server configuration in the database.
-    /// </summary>
-    [BsonIgnoreExtraElements]
-    public class GuildConfig : IBlacklistEntity, IIdentity
-    {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="GuildConfig"/> class with the provided Id.
-        /// </summary>
-        /// <param name="id">The server Id.</param>
-        public GuildConfig(ulong id)
-        {
-            Id = id;
-        }
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="GuildConfig"/> class with the provided values.
-        /// </summary>
-        /// <param name="id">The server Id.</param>
-        /// <param name="isBlacklisted">Whether the server is blacklisted.</param>
-        /// <param name="blacklistReason">The reason of the blacklist.</param>
-        /// <param name="prefix">The prefix of the server.</param>
-        /// <param name="language">The language of the server.</param>
-        /// <param name="disabledCommands">A list of disabled commands for the server.</param>
-        /// <param name="aidAutoTranslate">Whether the response of the AI Dungeon API should be translated to the server's language.</param>
-        /// <param name="trackSelection">Whether the track selection message should be sent instead of playing the first result automatically in the server.</param>
-        public GuildConfig(ulong id, bool isBlacklisted = false, string blacklistReason = null,
-            string prefix = null, string language = null, ICollection<string> disabledCommands = null,
-            bool aidAutoTranslate = false, bool trackSelection = false)
-            : this(id)
-        {
-            IsBlacklisted = isBlacklisted;
-            BlacklistReason = blacklistReason;
-            Prefix = prefix;
-            Language = language;
-            DisabledCommands = disabledCommands;
-            AidAutoTranslate = aidAutoTranslate;
-            TrackSelection = trackSelection;
-        }
-
-        private ICollection<string> _disabledCommands;
-
-        /// <inheritdoc/>
-        [BsonId]
-        public ObjectId ObjectId { get; set; }
-
-        /// <inheritdoc/>
-        public ulong Id { get; set; }
-
-        /// <inheritdoc/>
-        public bool IsBlacklisted { get; set; }
-
-        /// <inheritdoc/>
-        public string BlacklistReason { get; set; }
-
-        /// <summary>
-        /// Gets or sets the prefix of this server.
-        /// </summary>
-        public string Prefix { get; set; }
-
-        /// <summary>
-        /// Gets or sets the language of this server.
-        /// </summary>
-        public string Language { get; set; } = DatabaseConfig.Language ?? Constants.DefaultLanguage;
-
-        /// <summary>
-        /// Gets or sets a collection of disabled commands for this server.
-        /// </summary>
-        public ICollection<string> DisabledCommands
-        {
-            get => _disabledCommands ??= new List<string>();
-            set => _disabledCommands = value;
-        }
-
-        /// <summary>
-        /// Gets or sets whether the response of the AI Dungeon API should be translated to this server's language.
-        /// </summary>
-        public bool AidAutoTranslate { get; set; } = Constants.AidAutoTranslateDefault;
-
-        /// <summary>
-        /// Gets or sets whether the track selection message should be sent instead of playing the first result automatically in this server.
-        /// </summary>
-        public bool TrackSelection { get; set; } = Constants.TrackSelectionDefault;
-    }
-}
\ No newline at end of file
diff --git a/src/Config/IBlacklistEntity.cs b/src/Config/IBlacklistEntity.cs
deleted file mode 100644
index e1d59bd..0000000
--- a/src/Config/IBlacklistEntity.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using Discord;
-
-namespace Fergun
-{
-    /// <summary>
-    /// Represents an entity that can be blacklisted.
-    /// </summary>
-    public interface IBlacklistEntity : IEntity<ulong>
-    {
-        /// <summary>
-        /// Gets or sets whether this entity is blacklisted.
-        /// </summary>
-        public bool IsBlacklisted { get; set; }
-
-        /// <summary>
-        /// Gets or sets the reason of the blacklist.
-        /// </summary>
-        public string BlacklistReason { get; set; }
-    }
-}
\ No newline at end of file
diff --git a/src/Config/IIdentity.cs b/src/Config/IIdentity.cs
deleted file mode 100644
index 4dac231..0000000
--- a/src/Config/IIdentity.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using MongoDB.Bson;
-
-namespace Fergun
-{
-    /// <summary>
-    /// Represents an object that can be identified with an ObjectId.
-    /// </summary>
-    public interface IIdentity
-    {
-        /// <summary>
-        /// Gets or sets the ObjectId.
-        /// </summary>
-        ObjectId ObjectId { get; set; }
-    }
-}
\ No newline at end of file
diff --git a/src/Config/MongoConfig.cs b/src/Config/MongoConfig.cs
deleted file mode 100644
index 98f2a07..0000000
--- a/src/Config/MongoConfig.cs
+++ /dev/null
@@ -1,80 +0,0 @@
-using System;
-using Newtonsoft.Json;
-
-namespace Fergun
-{
-    /// <summary>
-    /// Represents a simple MongoDB auth config.
-    /// </summary>
-    public class MongoConfig
-    {
-        /// <summary>
-        /// Gets the user.
-        /// </summary>
-        [JsonProperty]
-        public string User { get; private set; }
-
-        /// <summary>
-        /// Gets the password.
-        /// </summary>
-        [JsonProperty]
-        public string Password { get; private set; }
-
-        /// <summary>
-        /// Gets the host where the MongoDB server is running. The default is <c>localhost</c>.
-        /// </summary>
-        [JsonProperty]
-        public string Host { get; private set; } = "localhost";
-
-        /// <summary>
-        /// Gets the port where the MongoDB server is running. The default is <c>27017</c>.
-        /// </summary>
-        [JsonProperty]
-        public int Port { get; private set; } = 27017;
-
-        /// <summary>
-        /// Gets the authentication database to use if an user and password is passed. The default is <c>admin</c>.
-        /// </summary>
-        [JsonProperty]
-        public string AuthDatabase { get; private set; } = "admin";
-
-        /// <summary>
-        /// Gets whether the hostname corresponds to a DNS SRV record (+srv).
-        /// </summary>
-        [JsonProperty]
-        public bool IsSrv { get; private set; }
-
-        /// <summary>
-        /// Gets a <see cref="MongoConfig"/> instance with the default values.
-        /// </summary>
-        public static MongoConfig Default => new MongoConfig();
-
-        /// <summary>
-        /// Gets the connection string.
-        /// </summary>
-        [JsonIgnore]
-        public string ConnectionString
-        {
-            get
-            {
-                string cs = $"mongodb{(IsSrv ? "+srv" : "")}://";
-                if (!string.IsNullOrEmpty(User) && !string.IsNullOrEmpty(Password))
-                {
-                    cs += $"{Uri.EscapeDataString(User)}:{Uri.EscapeDataString(Password)}@";
-                }
-                cs += Host;
-                if (!IsSrv)
-                {
-                    cs += $":{Port}";
-                }
-                if (!string.IsNullOrEmpty(User) && !string.IsNullOrEmpty(Password))
-                {
-                    cs += $"/{AuthDatabase}";
-                }
-                return cs;
-            }
-        }
-
-        public override string ToString() => ConnectionString;
-    }
-}
\ No newline at end of file
diff --git a/src/Config/UserConfig.cs b/src/Config/UserConfig.cs
deleted file mode 100644
index be1b603..0000000
--- a/src/Config/UserConfig.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-using MongoDB.Bson;
-using MongoDB.Bson.Serialization.Attributes;
-
-namespace Fergun
-{
-    /// <summary>
-    /// Represents a user configuration in the database.
-    /// </summary>
-    [BsonIgnoreExtraElements]
-    public class UserConfig : IBlacklistEntity, IIdentity
-    {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="UserConfig"/> class with the provided user Id.
-        /// </summary>
-        /// <param name="id">The user Id.</param>
-        public UserConfig(ulong id)
-        {
-            Id = id;
-        }
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="UserConfig"/> class with the provided values.
-        /// </summary>
-        /// <param name="id">The user Id.</param>
-        /// <param name="isBlacklisted">Whether the user is blacklisted.</param>
-        /// <param name="blacklistReason">The reason of the blacklist.</param>
-        /// <param name="triviaPoints">The Trivia points of the user.</param>
-        public UserConfig(ulong id, bool isBlacklisted = false, string blacklistReason = null, int triviaPoints = 0) : this(id)
-        {
-            IsBlacklisted = isBlacklisted;
-            BlacklistReason = blacklistReason;
-            TriviaPoints = triviaPoints;
-        }
-
-        /// <inheritdoc/>
-        [BsonId]
-        public ObjectId ObjectId { get; set; }
-
-        /// <inheritdoc/>
-        public ulong Id { get; set; }
-
-        /// <inheritdoc/>
-        public bool IsBlacklisted { get; set; }
-
-        /// <inheritdoc/>
-        public string BlacklistReason { get; set; }
-
-        /// <summary>
-        /// Gets or sets the Trivia points of this user.
-        /// </summary>
-        public int TriviaPoints { get; set; }
-
-        /// <summary>
-        /// Gets or sets whether the user has opted out the temporary collection of deleted/edited messages in the "snipe" commands.
-        /// </summary>
-        public bool IsOptedOutSnipe { get; set; }
-    }
-}
\ No newline at end of file
diff --git a/src/Constants.cs b/src/Constants.cs
index 42ebb34..2655072 100644
--- a/src/Constants.cs
+++ b/src/Constants.cs
@@ -1,208 +1,24 @@
-using System;
-using System.Reflection;
-using Discord;
-using Discord.Commands;
-using Discord.WebSocket;
-using Fergun.Attributes;
+namespace Fergun;
 
-namespace Fergun
+public static class Constants
 {
-    public static class Constants
-    {
-        public static string Version { get; } = Assembly.GetExecutingAssembly()
-            .GetCustomAttribute<AssemblyInformationalVersionAttribute>()
-            .InformationalVersion;
+    public const string SpotifyLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/838833381298143334/spotify.png";
 
-        public const string Changelog = @"**v1.8**
-Additions:
-- Added GScraper, a scraping library for Google Images, DuckDuckGo and Brave.
-- [img2] Added new command (DuckDuckGo image search).
-- [img3] Added new command (Brave image search).
-- [privacy] Added new command (privacy policy and opt out options)
-- [userinfo] Added user badges.
-- [youtube] Added pagination support.
-- [wikipedia] Added pagination support.
-- [channelinfo] Added support for thread and stage channels.
-- [roleinfo] Added role icon as thumbnail.
-- Added icons to image search commands.
-- Added Discord timestamps to multiple commands.
-- Added embeds to messages with attachments (`color`, `invert`, `screenshot`).
-- Added fallback pastebin to Hastebin (Hatebin).
-- Added multiple configuration options.
-- Added an optimized message cache.
-- Added sharding support.
+    public const string GoogleLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/890326437268168704/unknown.png";
 
-Changes:
-- Updated the runtime to .NET 6, with lots of performance and memory improvements.
-- Replaced the included translators with GTranslate, which includes new languages and a new translator (Yandex.Translate).
-- Replaced System.Drawing.Commom with ImageSharp.
-- Replaced the old interactive service with Fergun.Interactive.
-- Replaced OCR.Space API with Bing Visual Search internal API, which is free and doesn't require an API key.
-- Rewritten the AI Dungeon API wrapper, improving the response time of AI Dungeon commands.
-- [img] Use images from Google Images.
-- Snipe commands can now be opt out with `privacy`.
-- [wikipedia] Use the localized logo.
-- [stats] Display the git commit hash and sharding info.
-- [help] Ignore the command group when searching for a command.
-- [avatar, userinfo] Try to use the user's banner color instead of downloading the avatar and getting the average color whenever possible.
-- Improved the reusage of interactive messages.
-- Improved the way the bot resolves the users from the command messages.
-- Improved the handling of edited command messages with attachments.
-- Updated multiple comands to benefit from interactions (buttons, select menus).
-- Now image search commands use the highest safe search level on non-NSFW channels.
-- Now possible edited command messages won't be processed if they are 4 hours older.
-- Lots of bug fixes.
-- Other minor changes.
+    public const string GoogleTranslateLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/838833843917029446/googletranslate.png";
 
-Removals:
-- Removed the command `identify`, it stopped working a long time ago.
-- [config] Removed CaptionBot autotranslate option.";
+    public const string BingTranslatorLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/944755269034991666/BingTranslator.png";
 
-        public static string GitHash { get; } = Assembly.GetExecutingAssembly()
-            .GetCustomAttribute<GitHashInfoAttribute>()?
-            .GitHash;
+    public const string MicrosoftAzureLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/944745954605686864/Microsoft_Azure.png";
 
-        public static DiscordSocketConfig ClientConfig { get; } = new DiscordSocketConfig
-        {
-            LogLevel = LogSeverity.Verbose,
-            UseSystemClock = false,
-            GatewayIntents =
-            GatewayIntents.Guilds |
+    public const string YandexTranslateLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/857013120358416394/YandexTranslate.png";
 
-            // Moderation commands
-            GatewayIntents.GuildBans |
+    public const string DuckDuckGoLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/890323046286651402/unknown.png";
 
-            // General + Moderation commands
-            GatewayIntents.GuildMessages |
+    public const string BraveLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/890323194504937522/unknown.png";
 
-            // Music commands
-            GatewayIntents.GuildVoiceStates |
+    public const string BadTranslatorLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/944755022816763914/unknown.png";
 
-            // DM support
-            GatewayIntents.DirectMessages
-        };
-
-        public static CommandServiceConfig CommandServiceConfig { get; } = new CommandServiceConfig
-        {
-            LogLevel = LogSeverity.Verbose,
-            CaseSensitiveCommands = false,
-            IgnoreExtraArgs = true
-        };
-
-        public static TimeSpan HttpClientTimeout => TimeSpan.FromSeconds(60);
-
-        public static TimeSpan PaginatorTimeout => TimeSpan.FromMinutes(10);
-
-        public const GuildPermission InvitePermissions =
-            GuildPermission.ViewChannel |
-            GuildPermission.SendMessages |
-            GuildPermission.EmbedLinks |
-            GuildPermission.ReadMessageHistory |
-
-            // Moderation commands
-            GuildPermission.KickMembers |
-            GuildPermission.BanMembers |
-            GuildPermission.ChangeNickname |
-            GuildPermission.ManageNicknames |
-
-            // Paginator + Moderation commands
-            GuildPermission.AddReactions |
-            GuildPermission.ManageMessages |
-
-            // Commands that uses external emojis
-            GuildPermission.UseExternalEmojis |
-
-            // Some utility commands
-            GuildPermission.AttachFiles |
-
-            // Music commands
-            GuildPermission.Connect |
-            GuildPermission.Speak;
-
-        public const ChannelPermission MinimumRequiredPermissions = ChannelPermission.SendMessages | ChannelPermission.EmbedLinks;
-
-        public const string GitHubRepository = "https://github.com/d4n3436/Fergun";
-
-        public const string DevelopmentModuleName = "Dev";
-
-        public const string BotConfigFile = "botconfig.json";
-
-        public const string FergunDatabase = "FergunDB";
-
-        public const string ConfigCollection = "Config";
-
-        public const string GuildConfigCollection = "GuildConfig";
-
-        public const string UserConfigCollection = "UserConfig";
-
-        public const string AidAdventuresCollection = "AIDAdventures";
-
-        public const int AttachmentSizeLimit = 8 * 1024 * 1024;
-
-        public const double GlobalRatelimitPeriod = 10.0 / 60.0; // 1/6 of a minute or 10 seconds
-
-        public const int GlobalCommandUsesPerPeriod = 3;
-
-        public const double DefaultIgnoreTime = 0.6;
-
-        public const double MentionIgnoreTime = 1;
-
-        public const double CooldownIgnoreTime = 4;
-
-        public const double BlacklistIgnoreTime = 60 * 5;
-
-        public const uint MaxTrackLoops = 20;
-
-        public const int CommandCacheClearInterval = 14400000;
-
-        public const int MaxCommandCacheLongevity = 4;
-
-        public const int MessageCacheCapacity = 200;
-
-        public const int MessageCacheClearInterval = 3600000;
-
-        public const int MaxMessageCacheLongevity = 6;
-
-        public const int MinCommandTime = 12;
-
-        public const int MaxPrefixLength = 10;
-
-        public const string DefaultLanguage = "en";
-
-        public const string DefaultPrefix = "f!";
-
-        public const string DefaultDevPrefix = "f!!";
-
-        public const uint DefaultEmbedColor = 16750877;
-
-        public const int MaxTracksToDisplay = 10;
-
-        // Command config defaults
-        public const bool AidAutoTranslateDefault = false;
-
-        public const bool TrackSelectionDefault = false;
-
-        // Logos
-        public const string AiDungeonLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/838834077905059850/aidungeon.png";
-
-        public const string SpotifyLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/838833381298143334/spotify.png";
-
-        public const string GoogleLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/890326437268168704/unknown.png";
-
-        public const string GoogleTranslateLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/838833843917029446/googletranslate.png";
-
-        public const string BingTranslatorLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/944755269034991666/BingTranslator.png";
-
-        public const string MicrosoftAzureLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/944745954605686864/Microsoft_Azure.png";
-
-        public const string YandexTranslateLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/857013120358416394/YandexTranslate.png";
-
-        public const string DuckDuckGoLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/890323046286651402/unknown.png";
-
-        public const string BraveLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/890323194504937522/unknown.png";
-
-        public const string BadTranslatorLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/944755022816763914/unknown.png";
-
-        public const string WolframAlphaLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/838834461638131722/wolframalpha.png";
-    }
+    public const string ChromeUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36";
 }
\ No newline at end of file
diff --git a/src/Extensions/ChannelExtensions.cs b/src/Extensions/ChannelExtensions.cs
index ffd3dc5..f2d9438 100644
--- a/src/Extensions/ChannelExtensions.cs
+++ b/src/Extensions/ChannelExtensions.cs
@@ -1,152 +1,10 @@
-using System.Linq;
-using System.Text.RegularExpressions;
-using System.Threading.Tasks;
-using Discord;
-using Fergun.Services;
-using Fergun.Utils;
+using Discord;
 
-namespace Fergun.Extensions
-{
-    public static class ChannelExtensions
-    {
-        private static readonly Regex _linkRegex = new Regex(
-            @"^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$",
-            RegexOptions.IgnoreCase | RegexOptions.Compiled);
-
-        /// <summary>
-        /// Tries to delete a message.
-        /// </summary>
-        /// <param name="channel">The source channel.</param>
-        /// <param name="message">The message.</param>
-        /// <param name="cache">The message cache service.</param>
-        public static async Task<bool> TryDeleteMessageAsync(this IMessageChannel channel, IMessage message, MessageCacheService cache = null)
-            => await channel.TryDeleteMessageAsync(message.Id, cache);
-
-        /// <summary>
-        /// Tries to delete a message.
-        /// </summary>
-        /// <param name="channel">The source channel.</param>
-        /// <param name="messageId">The message Id.</param>
-        /// <param name="cache">The message cache service.</param>
-        public static async Task<bool> TryDeleteMessageAsync(this IMessageChannel channel, ulong messageId, MessageCacheService cache = null)
-        {
-            var message = await channel.GetMessageAsync(cache, messageId);
-            return await message.TryDeleteAsync();
-        }
-
-        public static bool IsPrivate(this IMessageChannel channel) => channel is IPrivateChannel;
-
-        /// <summary>
-        /// Gets the last url in the last <paramref name="messageCount"/> messages.
-        /// </summary>
-        /// <param name="channel">The channel to search.</param>
-        /// <param name="messageCount">The number of messages to search.</param>
-        /// <param name="cache">The message cache service.</param>
-        /// <param name="onlyImage">Get only urls of images.</param>
-        /// <param name="message">An optional message to search first before searching in the channel.</param>
-        /// <param name="url">An optional url to use before searching in the channel.</param>
-        /// <param name="maxSize">The maximum file size in bytes, <see cref="Constants.AttachmentSizeLimit"/> by default.</param>
-        /// <returns>A task that represents an asynchronous search operation.</returns>
-        public static async Task<(string url, UrlFindResult result)> GetLastUrlAsync(this IMessageChannel channel,  int messageCount,
-            MessageCacheService cache = null, bool onlyImage = false, IMessage message = null, string url = null, long maxSize = Constants.AttachmentSizeLimit)
-        {
-            long? size = null;
-            if (message != null && message.Attachments.Count > 0)
-            {
-                var attachment = message.Attachments.First();
-                if (onlyImage && attachment.Width == null && attachment.Height == null)
-                {
-                    return (null, UrlFindResult.AttachmentNotImage);
-                }
-                url = attachment.Url;
-            }
-            if (url != null)
-            {
-                if (onlyImage && !await StringUtils.IsImageUrlAsync(url))
-                {
-                    return (null, UrlFindResult.UrlNotImage);
-                }
-                size = await StringUtils.GetUrlContentLengthAsync(url);
-                return size > maxSize ? (null, UrlFindResult.UrlFileTooLarge) : (url, UrlFindResult.UrlFound);
-            }
-
-            // Get the last x messages of the current channel
-            var messages = await channel.GetMessagesAsync(cache, messageCount).FlattenAsync();
+namespace Fergun.Extensions;
 
-            // Try to get the last message with any attachment, embed image url or that contains a url
-            var filtered = messages.FirstOrDefault(x =>
-            x.Attachments.Any(y => !onlyImage || y.Width != null && y.Height != null)
-            || x.Embeds.Any(y => !onlyImage || y.Image != null || y.Thumbnail != null)
-            || _linkRegex.IsMatch(x.Content));
-
-            // No results
-            if (filtered == null)
-            {
-                return (null, UrlFindResult.UrlNotFound);
-            }
-
-            // Note: attachments and embeds can contain text but I'm prioritizing the previous ones
-            // Priority order: attachments > embeds > text (message content)
-            if (filtered.Attachments.Count > 0)
-            {
-                url = filtered.Attachments.First().Url;
-                size = filtered.Attachments.First().Size;
-            }
-            else if (filtered.Embeds.Count > 0)
-            {
-                var embed = filtered.Embeds.First();
-                var image = embed.Image;
-                var thumbnail = embed.Thumbnail;
-                if (onlyImage)
-                {
-                    if (image?.Height != null && image.Value.Width != null)
-                    {
-                        url = image.Value.Url;
-                    }
-                    else if (thumbnail?.Height != null && thumbnail.Value.Width != null)
-                    {
-                        url = thumbnail.Value.Url;
-                    }
-                    else
-                    {
-                        return (null, UrlFindResult.UrlNotFound);
-                    }
-
-                    // the image can still be invalid
-                    if (!await StringUtils.IsImageUrlAsync(url))
-                    {
-                        return (null, UrlFindResult.UrlNotFound);
-                    }
-                }
-                else
-                {
-                    url = embed.Url ?? image?.Url ?? thumbnail?.Url;
-                }
-            }
-            else
-            {
-                string match = _linkRegex.Match(filtered.Content).Value;
-                if (onlyImage && !await StringUtils.IsImageUrlAsync(match))
-                {
-                    return (null, UrlFindResult.UrlNotFound);
-                }
-                url = match;
-            }
-            if (filtered.Attachments.Count == 0)
-            {
-                size = await StringUtils.GetUrlContentLengthAsync(url);
-            }
-
-            return size > maxSize ? (null, UrlFindResult.UrlFileTooLarge) : (url, UrlFindResult.UrlFound);
-        }
-    }
+public static class ChannelExtensions
+{
+    public static bool IsPrivate(this IChannel channel) => channel is IPrivateChannel;
 
-    public enum UrlFindResult
-    {
-        UrlFound,
-        UrlNotFound,
-        UrlNotImage,
-        UrlFileTooLarge,
-        AttachmentNotImage
-    }
+    public static bool IsNsfw(this IMessageChannel channel) => channel is ITextChannel { IsNsfw: true };
 }
\ No newline at end of file
diff --git a/src/Extensions/Extensions.cs b/src/Extensions/Extensions.cs
index f9fec20..ebb1858 100644
--- a/src/Extensions/Extensions.cs
+++ b/src/Extensions/Extensions.cs
@@ -1,397 +1,57 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Reflection;
-using System.Text;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Discord.WebSocket;
-using Fergun.Attributes;
-using Fergun.Attributes.Preconditions;
-using Fergun.Services;
-using Fergun.Utils;
+using Discord;
+using Fergun.Interactive.Pagination;
 using Microsoft.Extensions.DependencyInjection;
-using Newtonsoft.Json;
-using Newtonsoft.Json.Serialization;
-using Victoria;
-using Victoria.Responses.Search;
+using Microsoft.Extensions.Logging;
+using Polly;
+using Polly.Extensions.Http;
 
-namespace Fergun.Extensions
-{
-    public static class Extensions
-    {
-        // Copy pasted from SocketGuildUser Hierarchy property to be used with RestGuildUser
-        public static int GetHierarchy(this IGuildUser user)
-        {
-            if (user.Guild.OwnerId == user.Id)
-            {
-                return int.MaxValue;
-            }
-
-            int maxPos = 0;
-            for (int i = 0; i < user.RoleIds.Count; i++)
-            {
-                var role = user.Guild.GetRole(user.RoleIds.ElementAt(i));
-                if (role != null && role.Position > maxPos)
-                {
-                    maxPos = role.Position;
-                }
-            }
-
-            return maxPos;
-        }
-
-        public static void Shuffle<T>(this IList<T> list, Random rng = null)
-        {
-            rng ??= Random.Shared;
-
-            int n = list.Count;
-            while (n > 1)
-            {
-                n--;
-                int k = rng.Next(n + 1);
-                var value = list[k];
-                list[k] = list[n];
-                list[n] = value;
-            }
-        }
-
-        public static string GetFriendlyName(this Type type)
-        {
-            if (type == typeof(bool))
-                return "bool";
-            if (type == typeof(byte))
-                return "byte";
-            if (type == typeof(sbyte))
-                return "sbyte";
-            if (type == typeof(short))
-                return "short";
-            if (type == typeof(ushort))
-                return "ushort";
-            if (type == typeof(char))
-                return "char";
-            if (type == typeof(int))
-                return "int";
-            if (type == typeof(uint))
-                return "uint";
-            if (type == typeof(long))
-                return "long";
-            if (type == typeof(ulong))
-                return "ulong";
-            if (type == typeof(float))
-                return "float";
-            if (type == typeof(double))
-                return "double";
-            if (type == typeof(decimal))
-                return "decimal";
-            if (type == typeof(string))
-                return "string";
-            if (type == typeof(object))
-                return "object";
-
-            if (!type.IsGenericType) return type.Name;
-
-            string arguments = string.Join(", ", type.GetGenericArguments().Select(GetFriendlyName).ToArray());
-            if (type.Name.Contains("Nullable", StringComparison.OrdinalIgnoreCase))
-            {
-                return arguments + "?";
-            }
-
-            return $"{type.Name.Split('`')[0]}<{arguments}>";
-        }
-
-        public static string ToTrackLink(this LavaTrack track, bool withTime = true)
-        {
-            return Format.Url(track.Title, track.Url) + (withTime ? $" ({track.Duration.ToShortForm()})" : "");
-        }
-
-        public static async Task<SearchResponse> SearchAsync(this LavaNode<LavaPlayer> player, string query, SearchType searchType = SearchType.YouTube)
-        {
-            var search = await player.SearchAsync(searchType, query);
-            if (search.Tracks.Count != 0 && search.Status != SearchStatus.NoMatches && search.Status != SearchStatus.LoadFailed)
-            {
-                return search;
-            }
-
-            searchType = searchType switch
-            {
-                SearchType.YouTube => SearchType.YouTubeMusic,
-                SearchType.YouTubeMusic => SearchType.SoundCloud,
-                SearchType.SoundCloud => SearchType.Direct,
-                SearchType.Direct => SearchType.YouTube,
-                _ => SearchType.YouTube
-            };
-
-            if (searchType != SearchType.YouTube)
-                return await player.SearchAsync(query, searchType);
-
-            return default;
-        }
-
-        public static Embed ToHelpEmbed(this CommandInfo command, string language, string prefix)
-        {
-            var builder = new EmbedBuilder
-            {
-                Title = command.Name,
-                Description = GuildUtils.Locate(command.Summary ?? "NoDescription", language),
-                Color = new Color(FergunClient.Config.EmbedColor)
-            };
-
-            if (command.Parameters.Count > 0)
-            {
-                // Add parameters: param1 (type) (Optional): description
-                var field = new StringBuilder();
-                foreach (var parameter in command.Parameters)
-                {
-                    field.Append($"{parameter.Name} ({parameter.Type.GetFriendlyName()})");
-                    if (parameter.IsOptional)
-                    {
-                        field.Append(' ');
-                        field.Append(GuildUtils.Locate("Optional", language));
-                    }
-
-                    field.Append($": {GuildUtils.Locate(parameter.Summary ?? "NoDescription", language)}\n");
-                }
-                builder.AddField(GuildUtils.Locate("Parameters", language), field.ToString());
-            }
-
-            // Add usage field (`prefix group command <param1> [param2...]`)
-            var usage = new StringBuilder('`' + prefix);
-            if (!string.IsNullOrEmpty(command.Module.Group))
-            {
-                usage.Append(command.Module.Group);
-                usage.Append(' ');
-            }
-            usage.Append(command.Name);
-            foreach (var parameter in command.Parameters)
-            {
-                usage.Append(' ');
-                usage.Append(parameter.IsOptional ? '[' : '<');
-                usage.Append(parameter.Name);
-                if (parameter.IsRemainder || parameter.IsMultiple)
-                {
-                    usage.Append("...");
-                }
-
-                usage.Append(parameter.IsOptional ? ']' : '>');
-            }
-            usage.Append('`');
-            builder.AddField(GuildUtils.Locate("Usage", language), usage.ToString());
-
-            // Add example if the command has parameters
-            if (command.Parameters.Count > 0)
-            {
-                var attribute = command.Attributes.OfType<ExampleAttribute>().FirstOrDefault();
-                if (attribute != null)
-                {
-                    string example = prefix;
-                    if (!string.IsNullOrEmpty(command.Module.Group))
-                    {
-                        example += command.Module.Group + ' ';
-                    }
-                    example += $"{command.Name} {attribute.Example}";
-                    builder.AddField(GuildUtils.Locate("Example", language), example);
-                }
-            }
-
-            // Add notes if present
-            if (!string.IsNullOrEmpty(command.Remarks))
-            {
-                builder.AddField(GuildUtils.Locate("Notes", language), GuildUtils.Locate(command.Remarks, language));
-            }
-
-            var modulePreconditions = command.Module.Preconditions;
-            var commandPreconditions = command.Preconditions;
-
-            // Add ratelimit info if present
-            var ratelimit = commandPreconditions.Concat(modulePreconditions).OfType<RatelimitAttribute>().FirstOrDefault();
-
-            if (ratelimit != null)
-            {
-                builder.AddField("Ratelimit", string.Format(GuildUtils.Locate("RatelimitUses", language), ratelimit.InvokeLimit, ratelimit.InvokeLimitPeriod.ToShortForm2()));
-            }
-
-            // Add required permissions if there's any
-            var preconditions = modulePreconditions
-                .Concat(commandPreconditions).Where(x => !(x is RatelimitAttribute) && !(x is LongRunningAttribute) && !(x is DisabledAttribute));
-
-            var list = new StringBuilder();
-            foreach (var precondition in preconditions)
-            {
-                string name = precondition.GetType().Name;
-                list.Append(Format.Code(name.Substring(0, name.Length - 9)));
-                switch (precondition)
-                {
-                    case RequireContextAttribute requireContext:
-                        list.Append($": {requireContext.Contexts}");
-                        break;
-
-                    case RequireUserPermissionAttribute requireUser:
-                        list.Append($": {requireUser.GuildPermission?.ToString() ?? requireUser.ChannelPermission?.ToString()}");
-                        break;
-
-                    case RequireBotPermissionAttribute requireBot:
-                        list.Append($": {requireBot.GuildPermission?.ToString() ?? requireBot.ChannelPermission?.ToString()}");
-                        break;
-                }
-                list.Append('\n');
-            }
-
-            if (list.Length != 0)
-            {
-                builder.AddField(GuildUtils.Locate("Requirements", language), list.ToString());
-            }
+namespace Fergun.Extensions;
 
-            // Add aliases if present
-            if (command.Aliases.Count > 1)
-            {
-                builder.AddField(GuildUtils.Locate("Alias", language), string.Join(", ", command.Aliases.Skip(1)));
-            }
-
-            // Add footer with info about required and optional parameters
-            if (command.Parameters.Count > 0)
-            {
-                builder.WithFooter(GuildUtils.Locate("HelpFooter2", language));
-            }
-
-            return builder.Build();
-        }
-
-        /// <summary>
-        /// Gets the last url in the last <paramref name="messageCount"/> messages.
-        /// </summary>
-        /// <param name="context">The channel to search.</param>
-        /// <param name="messageCount">The number of messages to search.</param>
-        /// <param name="cache">The message cache service.</param>
-        /// <param name="onlyImage">Get only urls of images.</param>
-        /// <param name="url">An optional url to use before searching in the channel.</param>
-        /// <param name="maxSize">The maximum file size in bytes, <see cref="Constants.AttachmentSizeLimit"/> by default.</param>
-        /// <returns>A task that represents an asynchronous search operation.</returns>
-        public static Task<(string, UrlFindResult)> GetLastUrlAsync(this ICommandContext context, int messageCount, MessageCacheService cache = null,
-            bool onlyImage = false, string url = null, long maxSize = Constants.AttachmentSizeLimit)
-        {
-            return context.Channel.GetLastUrlAsync(messageCount, cache, onlyImage, context.Message, url, maxSize);
-        }
-
-        public static bool IsSourceUserAndChannel(this ICommandContext context, SocketMessage message)
-            => message.Author.Id == context.User.Id && message.Channel.Id == context.Channel.Id;
-
-        public static bool IsNsfw(this ICommandContext context)
-        {
-            // Considering a DM channel a SFW channel.
-            return context.Channel is ITextChannel textChannel && textChannel.IsNsfw;
-        }
-
-        public static string Display(this ICommandContext context, bool displayUser = false)
-        {
-            return context.Channel.Display() + (displayUser ? $"/{context.User}" : "");
-        }
-
-        public static string Display(this IChannel channel)
-        {
-            return (channel is IGuildChannel guildChannel ? $"{guildChannel.Guild.Name}/" : "") + channel.Name;
-        }
-
-        public static string Display(this IMessage message)
-        {
-            return message.Channel.Display() + $"/{message.Author}";
-        }
-
-        public static string ToDiscordTimestamp(this DateTimeOffset dateTime, char style = 'f')
-        {
-            return dateTime.ToUnixTimeSeconds().ToDiscordTimestamp(style);
-        }
-
-        public static string ToDiscordTimestamp(this DateTimeOffset? dateTime, char style = 'f')
-        {
-            return dateTime.GetValueOrDefault().ToDiscordTimestamp(style);
-        }
-
-        public static string ToDiscordTimestamp(this ulong timestamp, char style = 'f')
-        {
-            return ((long)timestamp).ToDiscordTimestamp(style);
-        }
-
-        public static string ToDiscordTimestamp(this long timestamp, char style = 'f')
-        {
-            return $"<t:{timestamp}:{style}>";
-        }
-
-        public static IServiceCollection AddSingletonIf<TService>(this IServiceCollection services, bool condition, TService implementationInstance) where TService : class
-        {
-            return condition ? services.AddSingleton(implementationInstance) : services;
-        }
-
-        public static IServiceCollection AddSingletonIf<TService>(this IServiceCollection services, bool condition,
-            Func<IServiceProvider, TService> implementationFactory) where TService : class
-        {
-            return condition ? services.AddSingleton(implementationFactory) : services;
-        }
-
-        public static string Dump<T>(this T obj, int maxDepth = 2)
-        {
-            try
-            {
-                using var strWriter = new StringWriter();
-                using var jsonWriter = new CustomJsonTextWriter(strWriter);
-                var resolver = new CustomContractResolver(() => jsonWriter.CurrentDepth <= maxDepth);
-                var serializer = new JsonSerializer
-                {
-                    ContractResolver = resolver,
-                    ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
-                    Formatting = Formatting.Indented
-                };
-                serializer.Serialize(jsonWriter, obj);
-                return strWriter.ToString();
-            }
-            catch (JsonSerializationException)
-            {
-                return null;
-            }
-        }
-    }
-
-    public class CustomJsonTextWriter : JsonTextWriter
+public static class Extensions
+{
+    public static IHttpClientBuilder AddRetryPolicy(this IHttpClientBuilder builder)
+        => builder.AddTransientHttpErrorPolicy(policyBuilder
+            => policyBuilder.OrTransientHttpStatusCode().WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))));
+
+    public static LogLevel ToLogLevel(this LogSeverity logSeverity)
+        => logSeverity switch
+        {
+            LogSeverity.Critical => LogLevel.Critical,
+            LogSeverity.Error => LogLevel.Error,
+            LogSeverity.Warning => LogLevel.Warning,
+            LogSeverity.Info => LogLevel.Information,
+            LogSeverity.Verbose => LogLevel.Debug,
+            LogSeverity.Debug => LogLevel.Trace,
+            _ => throw new ArgumentOutOfRangeException(nameof(logSeverity), logSeverity.ToString())
+        };
+
+    public static string Display(this IInteractionContext context)
     {
-        public CustomJsonTextWriter(TextWriter textWriter) : base(textWriter)
-        {
-        }
+        string displayMessage = context.Channel.Name;
 
-        public int CurrentDepth { get; private set; }
+        if (context.Channel is IGuildChannel guildChannel)
+            displayMessage += $"/{guildChannel.Guild.Name}";
 
-        public override void WriteStartObject()
-        {
-            CurrentDepth++;
-            base.WriteStartObject();
-        }
-
-        public override void WriteEndObject()
-        {
-            CurrentDepth--;
-            base.WriteEndObject();
-        }
+        return displayMessage;
     }
 
-    public class CustomContractResolver : DefaultContractResolver
+    /// <summary>
+    /// Adds Fergun emotes.
+    /// </summary>
+    /// <param name="builder">A paginator builder.</param>
+    /// <returns>This builder.</returns>
+    public static TBuilder WithFergunEmotes<TPaginator, TBuilder>(this PaginatorBuilder<TPaginator, TBuilder> builder)
+        where TPaginator : Paginator
+        where TBuilder : PaginatorBuilder<TPaginator, TBuilder>
     {
-        private readonly Func<bool> _includeProperty;
+        builder.Options.Clear();
 
-        public CustomContractResolver(Func<bool> includeProperty)
-        {
-            _includeProperty = includeProperty;
-        }
+        builder.AddOption(Emoji.Parse("⏮️"), PaginatorAction.SkipToStart);
+        builder.AddOption(Emoji.Parse("◀️"), PaginatorAction.Backward);
+        builder.AddOption(Emoji.Parse("▶️"), PaginatorAction.Forward);
+        builder.AddOption(Emoji.Parse("⏭️"), PaginatorAction.SkipToEnd);
+        builder.AddOption(Emoji.Parse("🛑"), PaginatorAction.Exit);
 
-        protected override JsonProperty CreateProperty(
-            MemberInfo member, MemberSerialization memberSerialization)
-        {
-            var property = base.CreateProperty(member, memberSerialization);
-            var shouldSerialize = property.ShouldSerialize;
-            property.ShouldSerialize = obj => _includeProperty() &&
-                                              (shouldSerialize == null ||
-                                               shouldSerialize(obj));
-            return property;
-        }
+        return (TBuilder)builder;
     }
 }
\ No newline at end of file
diff --git a/src/Extensions/ImageExtensions.cs b/src/Extensions/ImageExtensions.cs
deleted file mode 100644
index 123f8c0..0000000
--- a/src/Extensions/ImageExtensions.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using SixLabors.ImageSharp;
-using SixLabors.ImageSharp.PixelFormats;
-
-namespace Fergun.Extensions
-{
-    public static class ImageExtensions
-    {
-        public static Color GetAverageColor(this Image<Rgba32> image)
-        {
-            int r = 0;
-            int g = 0;
-            int b = 0;
-
-            for (int y = 0; y < image.Height; y++)
-            {
-                var rowSpan = image.GetPixelRowSpan(y);
-                for (int x = 0; x < rowSpan.Length; x++)
-                {
-                    var pixel = rowSpan[x];
-                    r += pixel.R;
-                    g += pixel.G;
-                    b += pixel.B;
-                }
-            }
-
-            int total = image.Width * image.Height;
-
-            r /= total;
-            g /= total;
-            b /= total;
-
-            return Color.FromRgb((byte)r, (byte)g, (byte)b);
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Extensions/InteractionExtensions.cs b/src/Extensions/InteractionExtensions.cs
new file mode 100644
index 0000000..edc67e9
--- /dev/null
+++ b/src/Extensions/InteractionExtensions.cs
@@ -0,0 +1,52 @@
+using System.Diagnostics.CodeAnalysis;
+using Discord;
+using GTranslate;
+
+namespace Fergun.Extensions;
+
+public static class InteractionExtensions
+{
+    public static async Task RespondWarningAsync(this IDiscordInteraction interaction, string message, bool ephemeral = false)
+    {
+        var embed = new EmbedBuilder()
+            .WithDescription($"âš  {message}")
+            .WithColor(Color.Orange)
+            .Build();
+
+        await interaction.RespondAsync(embed: embed, ephemeral: ephemeral);
+    }
+
+    public static async Task FollowupWarning(this IDiscordInteraction interaction, string message, bool ephemeral = false)
+    {
+        var embed = new EmbedBuilder()
+            .WithDescription($"âš  {message}")
+            .WithColor(Color.Orange)
+            .Build();
+
+        await interaction.FollowupAsync(embed: embed, ephemeral: ephemeral);
+    }
+
+    public static string GetTwoLetterLanguageCode(this IDiscordInteraction interaction, string defaultLanguage = "en")
+    {
+        string language = interaction.UserLocale ?? interaction.GuildLocale;
+        if (string.IsNullOrEmpty(language))
+            return defaultLanguage;
+
+        int index = language.IndexOf('-');
+        if (index != -1)
+        {
+            language = language[..index];
+        }
+
+        return language;
+    }
+
+    public static bool TryGetLanguage(this IDiscordInteraction interaction, [MaybeNullWhen(false)] out Language language)
+    {
+        string lang = interaction.GetTwoLetterLanguageCode();
+        return Language.TryGetLanguage(lang, out language);
+    }
+
+    public static string GetLocale(this IDiscordInteraction interaction, string defaultLocale = "en-US")
+        => interaction.UserLocale ?? interaction.GuildLocale ?? defaultLocale;
+}
\ No newline at end of file
diff --git a/src/Extensions/JsonExtensions.cs b/src/Extensions/JsonExtensions.cs
deleted file mode 100644
index 39c0928..0000000
--- a/src/Extensions/JsonExtensions.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text.Json;
-
-namespace Fergun.Extensions
-{
-    public static class JsonExtensions
-    {
-        public static IEnumerable<JsonElement> EnumerateArrayOrEmpty(this JsonElement element)
-            => element.ValueKind == JsonValueKind.Array ? element.EnumerateArray(): Enumerable.Empty<JsonElement>();
-
-        public static JsonElement FirstOrDefault(this JsonElement element)
-            => element.ValueKind == JsonValueKind.Array ? element.EnumerateArray().FirstOrDefault() : default;
-
-        public static JsonElement FirstOrDefault(this JsonElement element, Func<JsonElement, bool> predicate)
-            => element.ValueKind == JsonValueKind.Array ? element.EnumerateArray().FirstOrDefault(predicate) : default;
-
-        public static JsonElement GetPropertyOrDefault(this JsonElement element, string propertyName)
-            => element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var value) ? value : default;
-
-        public static string GetStringOrDefault(this JsonElement element)
-            => element.ValueKind == JsonValueKind.String ? element.GetString() : default;
-
-        public static int GetInt32OrDefault(this JsonElement element)
-            => element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out int value) ? value : default;
-    }
-}
\ No newline at end of file
diff --git a/src/Extensions/ListExtensions.cs b/src/Extensions/ListExtensions.cs
new file mode 100644
index 0000000..84318f8
--- /dev/null
+++ b/src/Extensions/ListExtensions.cs
@@ -0,0 +1,15 @@
+namespace Fergun.Extensions;
+
+public static class ListExtensions
+{
+    public static void Shuffle<T>(this IList<T> list)
+    {
+        int n = list.Count;
+        while (n > 1)
+        {
+            n--;
+            int k = Random.Shared.Next(n + 1);
+            (list[k], list[n]) = (list[n], list[k]);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Extensions/MessageExtensions.cs b/src/Extensions/MessageExtensions.cs
index 080e206..6778155 100644
--- a/src/Extensions/MessageExtensions.cs
+++ b/src/Extensions/MessageExtensions.cs
@@ -1,96 +1,39 @@
-using System.Threading.Tasks;
+using System.Text;
 using Discord;
-using Discord.Net;
-using Discord.WebSocket;
-using Fergun.Services;
 
-namespace Fergun.Extensions
+namespace Fergun.Extensions;
+
+public static class MessageExtensions
 {
-    public static class MessageExtensions
+    public static string GetText(this IMessage message)
     {
-        /// <summary>
-        /// Tries to delete this message.
-        /// </summary>
-        /// <param name="message">The message.</param>
-        /// <param name="cache">The message cache service.</param>
-        public static async Task<bool> TryDeleteAsync(this IMessage message, MessageCacheService cache = null)
+        var builder = new StringBuilder(message.Content.Length + 1);
+        builder.Append(message.Content);
+        builder.Append('\n');
+        if (message.Embeds.Count > 0)
         {
-            if (message == null) return false;
-
-            message = await message.Channel.GetMessageAsync(cache, message.Id);
-
-            if (message == null) return false;
-
-            if (message.Channel is SocketGuildChannel guildChannel)
+            var embed = message.Embeds.First();
+            builder.Append(embed.Author?.Name);
+            builder.Append('\n');
+            builder.Append(embed.Title);
+            builder.Append('\n');
+            builder.Append(embed.Description);
+            builder.Append('\n');
+
+            foreach (var field in embed.Fields)
             {
-                if (!guildChannel.Guild.CurrentUser.GetPermissions(guildChannel).ManageMessages)
+                string name = field.Name.Trim().Replace("\u200b", "");
+                string value = field.Value.Trim().Replace("\u200b", "");
+                if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(value))
                 {
-                    // Missing permissions
-                    return false;
+                    builder.Append($"{name}: {value}");
+                    builder.Append('\n');
                 }
             }
-            else
-            {
-                // Not possible to delete other user's messages in DM
-                if (message.Source == MessageSource.User) return false;
-            }
-
-            try
-            {
-                await message.DeleteAsync();
-                return true;
-            }
-            catch (HttpException)
-            {
-                return false;
-            }
-        }
-
-        /// <summary>
-        /// Tries to remove all reactions from this message.
-        /// </summary>
-        public static async Task<bool> TryRemoveAllReactionsAsync(this IMessage message, MessageCacheService cache = null)
-        {
-            if (message == null) return false;
-
-            // get the updated message with the reactions
-            message = await message.Channel.GetMessageAsync(cache, message.Id);
-
-            if (message == null || message.Reactions.Count == 0) return false;
-
-            bool manageMessages = message.Channel is SocketGuildChannel guildChannel &&
-                                  guildChannel.Guild.CurrentUser.GetPermissions(guildChannel).ManageMessages;
 
-            if (!manageMessages) return false;
-
-            await message.RemoveAllReactionsAsync();
-            return true;
+            builder.Append(embed.Footer?.Text);
         }
 
-        /// <summary>
-        /// Modifies this message or re-sends it if no longer exists.
-        /// </summary>
-        /// <returns>A new message or a modified one.</returns>
-        public static async Task<IUserMessage> ModifyOrResendAsync(this IUserMessage message, string content = null, Embed embed = null,
-            AllowedMentions allowedMentions = null, MessageComponent component = null, MessageCacheService cache = null)
-        {
-            component ??= new ComponentBuilder().Build(); // remove message components if null
-
-            bool isValid = await message.Channel.GetMessageAsync(cache, message.Id) != null;
-            if (!isValid)
-            {
-                return await message.Channel.SendMessageAsync(content, embed: embed, allowedMentions: allowedMentions, components: component);
-            }
-
-            await message.ModifyAsync(x =>
-            {
-                x.Content = content;
-                x.Embed = embed;
-                x.Components = component;
-                x.AllowedMentions = allowedMentions ?? Optional.Create<AllowedMentions>();
-            });
-
-            return await message.Channel.GetMessageAsync(cache, message.Id) as IUserMessage;
-        }
+        return builder.ToString();
     }
 }
\ No newline at end of file
diff --git a/src/Extensions/StringExtensions.cs b/src/Extensions/StringExtensions.cs
index c5214f7..11cc02a 100644
--- a/src/Extensions/StringExtensions.cs
+++ b/src/Extensions/StringExtensions.cs
@@ -1,143 +1,6 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
+namespace Fergun.Extensions;
 
-namespace Fergun.Extensions
+public static class StringExtensions
 {
-    public static class StringExtensions
-    {
-        public static string Repeat(this string text, int count)
-        {
-            return string.Concat(Enumerable.Repeat(text, count));
-        }
-
-        public static string RepeatToLength(this string text, int length)
-        {
-            return Repeat(text, length / text.Length + 1).Truncate(length);
-        }
-
-        public static string Reverse(this string text)
-        {
-            char[] chars = text.ToCharArray();
-            Array.Reverse(chars);
-            return new string(chars);
-        }
-
-        public static string ReverseWords(this string text)
-        {
-            return string.Join(' ', text.Split(' ').Reverse());
-        }
-
-        public static string ReverseEachLine(this string text)
-        {
-            return string.Join("\r\n", text.Split('\r', '\n').Reverse());
-        }
-
-        public static string Randomize(this string input, Random rng = null)
-        {
-            var arr = input.ToCharArray();
-            arr.Shuffle(rng);
-            return new string(arr);
-        }
-
-        public static string ToRandomCase(this string text, Random rng = null)
-        {
-            rng ??= Random.Shared;
-
-            return string.Create(text.Length, (input: text, rng), (chars, state) =>
-            {
-                for (int i = 0; i < chars.Length; i++)
-                {
-                    chars[i] = state.rng.Next(2) == 0 ? char.ToUpperInvariant(state.input[i]) : char.ToLowerInvariant(state.input[i]);
-                }
-            });
-        }
-
-        /// <summary>
-        /// Converts a string to its full width form.
-        /// </summary>
-        /// <param name="text">The string to convert.</param>
-        public static string ToFullWidth(this string text)
-        {
-            return string.Create(text.Length, text, (chars, state) =>
-            {
-                for (int i = 0; i < chars.Length; i++)
-                {
-                    if (0x21 <= state[i] && state[i] <= 0x7E) // ASCII chars, excluding space
-                        chars[i] = (char)(state[i] + 0xFEE0);
-                    else if (state[i] == 0x20)
-                        chars[i] = (char)0x3000;
-                }
-            });
-        }
-
-        /// <summary>
-        /// Truncates a string to the specified length.
-        /// </summary>
-        /// <param name="text">The string to truncate.</param>
-        /// <param name="maxLength">The maximum length of the string.</param>
-        /// <returns>The truncated string.</returns>
-        public static string Truncate(this string text, int maxLength)
-        {
-            return text?.Substring(0, Math.Min(text.Length, maxLength));
-        }
-
-        public static bool ContainsAny(this string text, IEnumerable<string> containsKeywords, StringComparison comparisonType)
-        {
-            return containsKeywords.Any(keyword => text.Contains(keyword, comparisonType));
-        }
-
-        public static bool TryBase64Decode(this string text, out string decoded)
-        {
-            var buffer = new Span<byte>(new byte[text.Length]);
-            bool success = Convert.TryFromBase64String(text, buffer, out int bytesWritten);
-
-            decoded = success ? Encoding.UTF8.GetString(buffer.Slice(0, bytesWritten)) : null;
-
-            return success;
-        }
-
-        public static string ToTitleCase(this string text)
-        {
-            return string.Create(text.Length, text, (chars, state) =>
-            {
-                state.AsSpan().ToLowerInvariant(chars);
-                chars[0] = char.ToUpperInvariant(state[0]);
-            });
-        }
-
-        public static IEnumerable<string> SplitBySeparatorWithLimit(this string text, char separator, int maxLength)
-        {
-            var sb = new StringBuilder();
-            foreach (var part in text.Split(separator))
-            {
-                if (part.Length + sb.Length >= maxLength)
-                {
-                    yield return sb.ToString();
-                    sb.Clear();
-                }
-
-                sb.Append(part);
-                sb.Append(separator);
-            }
-            if (sb.Length != 0)
-            {
-                yield return sb.ToString();
-            }
-        }
-
-        public static int ToColor(this string str)
-        {
-            int hash = 0;
-            foreach (char ch in str)
-            {
-                hash = ch + ((hash << 5) - hash);
-            }
-            return hash;
-            //string c = (hash & 0x00FFFFFF).ToString("X4").ToUpperInvariant();
-
-            //return "00000".Substring(0, 6 - c.Length) + c;
-        }
-    }
+    public static bool ContainsAny(this string str, string str0, string str1) => str.Contains(str0) || str.Contains(str1);
 }
\ No newline at end of file
diff --git a/src/Extensions/TimeSpanExtensions.cs b/src/Extensions/TimeSpanExtensions.cs
deleted file mode 100644
index 4396ff9..0000000
--- a/src/Extensions/TimeSpanExtensions.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-using System;
-
-namespace Fergun.Extensions
-{
-    public static class TimeSpanExtensions
-    {
-        /// <summary>
-        /// Formats a TimeSpan to mm:ss or hh:mm:ss.
-        /// </summary>
-        public static string ToShortForm(this TimeSpan t) => t.ToString((t.Hours > 0 ? @"hh\:" : "") + @"mm\:ss");
-
-        /// <summary>
-        /// Formats a TimeSpan to ss, mm ss, hh mm ss or dd hh mm ss.
-        /// </summary>
-        public static string ToShortForm2(this TimeSpan t)
-        {
-            string format = "d'd 'h'h 'm'm 's's'";
-            if (t.Days == 0)
-                format = "h'h 'm'm 's's'";
-            if (t.Days == 0 && t.Hours == 0)
-                format = "m'm 's's'";
-            if (t.Days == 0 && t.Hours == 0 && t.Minutes == 0)
-                format = "s's'";
-            return t.ToString(format);
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Extensions/TimestampExtensions.cs b/src/Extensions/TimestampExtensions.cs
new file mode 100644
index 0000000..ec0e1ee
--- /dev/null
+++ b/src/Extensions/TimestampExtensions.cs
@@ -0,0 +1,16 @@
+namespace Fergun.Extensions;
+
+public static class TimestampExtensions
+{
+    public static string ToDiscordTimestamp(this DateTimeOffset dateTime, char style = 'f')
+        => dateTime.ToUnixTimeSeconds().ToDiscordTimestamp(style);
+
+    public static string ToDiscordTimestamp(this DateTimeOffset? dateTime, char style = 'f')
+        => dateTime.GetValueOrDefault().ToDiscordTimestamp(style);
+
+    public static string ToDiscordTimestamp(this ulong timestamp, char style = 'f')
+        => ((long)timestamp).ToDiscordTimestamp(style);
+
+    public static string ToDiscordTimestamp(this long timestamp, char style = 'f')
+        => $"<t:{timestamp}:{style}>";
+}
\ No newline at end of file
diff --git a/src/Fergun.csproj b/src/Fergun.csproj
index dd74157..aeca764 100644
--- a/src/Fergun.csproj
+++ b/src/Fergun.csproj
@@ -3,74 +3,25 @@
   <PropertyGroup>
     <OutputType>Exe</OutputType>
     <TargetFramework>net6.0</TargetFramework>
-    <Version>1.8</Version>
-    <LangVersion>10</LangVersion>
-    <NeutralLanguage>en</NeutralLanguage>
-    <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
-    <Authors>d4n3436</Authors>
-    <Configurations>Debug;Release;DebugLabs;ReleaseLabs</Configurations>
-    <PackageProjectUrl>https://github.com/d4n3436/Fergun</PackageProjectUrl>
-    <RepositoryUrl>https://github.com/d4n3436/Fergun</RepositoryUrl>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <Version>2.0-alpha</Version>
   </PropertyGroup>
 
-  <Target Name="SetBuildHash" BeforeTargets="InitializeSourceControlInformation">
-    <Exec Command="git describe --always --exclude=* --abbrev=7" ConsoleToMSBuild="True">
-      <Output PropertyName="GitHash" TaskParameter="ConsoleOutput" />
-    </Exec>
-
-    <PropertyGroup>
-      <GitHashInfoFile>$(IntermediateOutputPath)GitHashInfo.cs</GitHashInfoFile>
-    </PropertyGroup>
-
-    <ItemGroup>
-      <Compile Include="$(GitHashInfoFile)" />
-    </ItemGroup>
-
-    <ItemGroup>
-      <AssemblyAttributes Include="Fergun.Attributes.GitHashInfo">
-        <_Parameter1>$(GitHash)</_Parameter1>
-      </AssemblyAttributes>
-    </ItemGroup>
-
-    <WriteCodeFragment Language="C#" OutputFile="$(GitHashInfoFile)" AssemblyAttributes="@(AssemblyAttributes)" />
-  </Target>
-
-  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='DebugLabs|AnyCPU'">
-    <DefineConstants>$(DefineConstants)TRACE;DEBUG;DNETLABS</DefineConstants>
-  </PropertyGroup>
-
-  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='ReleaseLabs|AnyCPU'">
-    <DefineConstants>$(DefineConstants)TRACE;RELEASE;DNETLABS</DefineConstants>
-    <Optimize>True</Optimize>
-  </PropertyGroup>
-
-  <Choose>
-    <When Condition="$(DefineConstants.Contains('DNETLABS'))">
-      <ItemGroup>
-        <PackageReference Include="Discord.Net.Labs.Commands" Version="3.6.1" />
-        <PackageReference Include="Discord.Net.Labs.WebSocket" Version="3.6.1" />
-        <PackageReference Include="Fergun.Interactive.Labs" Version="1.4.1" />
-      </ItemGroup>
-    </When>
-    <Otherwise>
-      <ItemGroup>
-        <PackageReference Include="Discord.Net.Commands" Version="3.3.0" />
-        <PackageReference Include="Discord.Net.WebSocket" Version="3.3.0" />
-        <PackageReference Include="Fergun.Interactive" Version="1.4.1" />
-      </ItemGroup>
-    </Otherwise>
-  </Choose>
-
   <ItemGroup>
-    <PackageReference Include="CoreCLR-NCalc" Version="2.2.92" />
+    <PackageReference Include="Discord.Addons.Hosting" Version="5.1.0" />
+    <PackageReference Include="Discord.Net.Interactions" Version="3.3.2" />
+    <PackageReference Include="Fergun.Interactive" Version="1.4.1" />
     <PackageReference Include="GScraper" Version="1.0.2" />
     <PackageReference Include="GTranslate" Version="2.0.0" />
-    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.0.1" />
-    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
-    <PackageReference Include="MongoDB.Driver" Version="2.14.1" />
-    <PackageReference Include="SixLabors.ImageSharp" Version="1.0.4" />
-    <PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta13" />
-    <PackageReference Include="Victoria" Version="5.2.4" />
+    <PackageReference Include="Humanizer.Core" Version="2.14.1" />
+    <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.0" />
+    <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.1" />
+    <PackageReference Include="Polly.Caching.Memory" Version="3.0.2" />
+    <PackageReference Include="Serilog.Extensions.Hosting" Version="4.2.0" />
+    <PackageReference Include="Serilog.Extensions.Logging.File" Version="2.0.0" />
+    <PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
+    <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
     <PackageReference Include="YoutubeExplode" Version="6.0.7" />
   </ItemGroup>
 
diff --git a/src/FergunClient.cs b/src/FergunClient.cs
deleted file mode 100644
index 2809424..0000000
--- a/src/FergunClient.cs
+++ /dev/null
@@ -1,480 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.Diagnostics;
-using System.Globalization;
-using System.IO;
-using System.Linq;
-using System.Net.Http;
-using System.Runtime.InteropServices;
-using System.Threading;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Discord.WebSocket;
-using Fergun.Extensions;
-using Fergun.Interactive;
-using Fergun.Services;
-using Fergun.Utils;
-using GTranslate.Translators;
-using Microsoft.Extensions.DependencyInjection;
-using Newtonsoft.Json;
-using Victoria;
-
-namespace Fergun
-{
-    public class FergunClient
-    {
-        public static FergunDatabase Database { get; private set; }
-        public static FergunConfig Config { get; private set; }
-        public static DateTimeOffset Uptime { get; private set; }
-        public static bool IsDebugMode { get; private set; }
-        public static string TopGgBotPage { get; private set; }
-        public static string InviteLink { get; private set; }
-        public static IReadOnlyDictionary<string, CultureInfo> Languages { get; private set; }
-
-        private DiscordShardedClient _client;
-        private LogService _logService;
-        private bool _firstConnect = true;
-
-        public FergunClient()
-        {
-#if DEBUG
-            IsDebugMode = true;
-#endif
-            _logService = new LogService();
-        }
-
-        public async Task InitializeAsync()
-        {
-            await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Bot", $"Fergun v{Constants.Version}"));
-
-            Languages = new ReadOnlyDictionary<string, CultureInfo>(GetAvailableCultures().ToDictionary(x => x.TwoLetterISOLanguageName, x => x));
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Bot", $"{Languages.Count} available language(s) ({string.Join(", ", Languages.Keys)})."));
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Config", "Loading the config..."));
-            Config = await LoadConfigAsync<FergunConfig>(Path.Combine(AppContext.BaseDirectory, Constants.BotConfigFile));
-
-            if (Config == null)
-            {
-                Console.Write("Closing in 30 seconds... Press any key to exit now.");
-                await ExitWithInputTimeoutAsync(30, 1);
-            }
-            try
-            {
-                if (Config != null)
-                    TokenUtils.ValidateToken(TokenType.Bot, IsDebugMode ? Config.DevToken : Config.Token);
-            }
-            catch (ArgumentException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Critical, "Config", $"Failed to validate {(IsDebugMode ? "dev " : "")}bot token", e));
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Config", $"Make sure the value in key {(IsDebugMode ? "Dev" : "")}Token, in the config file ({Constants.BotConfigFile}) is valid."));
-
-                Console.Write("Closing in 30 seconds... Press any key to exit now.");
-                await ExitWithInputTimeoutAsync(30, 1);
-            }
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Database", "Connecting to the database..."));
-            bool isDbConnected = false;
-            Exception dbException = null;
-            try
-            {
-                Database = new FergunDatabase(Constants.FergunDatabase, Config!.DatabaseConfig.ConnectionString);
-                isDbConnected = Database.IsConnected;
-            }
-            catch (TimeoutException e)
-            {
-                dbException = e;
-            }
-
-            if (isDbConnected)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Database", "Connected to the database successfully."));
-            }
-            else
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Critical, "Database", "Could not connect to the database.", dbException));
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Database", "Ensure the MongoDB server you're trying to log in is running"));
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Database", $"and make sure the server credentials in the config file ({Constants.BotConfigFile}) are correct."));
-
-                Console.Write("Closing in 30 seconds... Press any key to exit now.");
-                await ExitWithInputTimeoutAsync(30, 1);
-            }
-
-            GuildUtils.Initialize();
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Bot", $"Using presence intent: {Config!.PresenceIntent}"));
-            if (Config.PresenceIntent)
-            {
-                Constants.ClientConfig.GatewayIntents |= GatewayIntents.GuildPresences;
-            }
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Bot", $"Using server members intent: {Config.ServerMembersIntent}"));
-            if (Config.ServerMembersIntent)
-            {
-                Constants.ClientConfig.GatewayIntents |= GatewayIntents.GuildMembers;
-            }
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Bot", $"Using reliability service: {Config.UseReliabilityService}"));
-            if (Config.UseReliabilityService)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Bot", "The bot will be shut down in case of deadlock. Remember to use a daemon!"));
-            }
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Bot", $"Using command cache service: {Config.UseCommandCacheService}"));
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Bot", $"Using message cache service: {Config.UseMessageCacheService}"));
-
-            Constants.ClientConfig.AlwaysDownloadUsers = Config.AlwaysDownloadUsers;
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Bot", $"Always download users: {Constants.ClientConfig.AlwaysDownloadUsers}"));
-
-            Constants.ClientConfig.MessageCacheSize = Config.UseMessageCacheService ? 0 : Config.MessageCacheSize;
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Bot", $"Message cache size: {Config.MessageCacheSize}"));
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Bot", $"Messages to search limit: {Config.MessagesToSearchLimit}"));
-
-            Constants.ClientConfig.TotalShards = Config.TotalShards;
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Bot", $"Total shards: {Constants.ClientConfig.TotalShards?.ToString() ?? "Automatic"}"));
-
-            _client = new DiscordShardedClient(Constants.ClientConfig);
-            _client.ShardReady += ShardReady;
-            _client.JoinedGuild += JoinedGuild;
-            _client.LeftGuild += LeftGuild;
-
-            if (Config.LavaConfig.Hostname == "127.0.0.1" || Config.LavaConfig.Hostname == "0.0.0.0" || Config.LavaConfig.Hostname == "localhost")
-            {
-                bool hasLavalink = File.Exists(Path.Combine(AppContext.BaseDirectory, "Lavalink", "Lavalink.jar"));
-                if (hasLavalink)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Lavalink", "Using local lavalink server. Updating and starting Lavalink..."));
-                    await UpdateLavalinkAsync();
-                    await StartLavalinkAsync();
-                }
-                else
-                {
-                    // Ignore all log messages from Victoria
-                    Config.LavaConfig.LogSeverity = LogSeverity.Critical;
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Lavalink", "Lavalink.jar not found."));
-                }
-            }
-            else
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Lavalink", "Using remote lavalink server."));
-            }
-
-            _logService.Dispose();
-
-            var services = SetupServices();
-            _logService = services.GetRequiredService<LogService>();
-
-            // Resolve dependencies
-            services.GetService<ReliabilityService>();
-            services.GetService<BotListService>();
-
-            await services.GetRequiredService<CommandHandlingService>().InitializeAsync();
-
-            await _client.LoginAsync(TokenType.Bot, IsDebugMode ? Config.DevToken : Config.Token, false);
-            await _client.StartAsync();
-
-            if (!IsDebugMode)
-            {
-                await _client.SetActivityAsync(new Game($"{DatabaseConfig.GlobalPrefix}help"));
-            }
-
-            // Block this task until the program is closed.
-            await Task.Delay(Timeout.Infinite);
-        }
-
-        private async Task<T> LoadConfigAsync<T>(string path) where T : class, new()
-        {
-            T config = null;
-
-            if (File.Exists(path))
-            {
-                try
-                {
-                    config = JsonConvert.DeserializeObject<T>(File.ReadAllText(path));
-                    if (config == null)
-                    {
-                        await _logService.LogAsync(new LogMessage(LogSeverity.Critical, "Config", "Unknown error reading/deserializing the config file."));
-                    }
-                    else
-                    {
-                        await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Config", "Loaded the config successfully."));
-                    }
-                }
-                catch (IOException e)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Critical, "Config", "Error reading the config file", e));
-                }
-                catch (JsonException e)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Critical, "Config", "Error deserializing the config file", e));
-                }
-            }
-            else
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Critical, "Config", "No config file found. Creating default config file."));
-
-                // Create a default config file.
-                try
-                {
-                    File.WriteAllText(path, JsonConvert.SerializeObject(new T(), Formatting.Indented));
-                }
-                catch (IOException) { }
-            }
-
-            return config;
-        }
-
-        private static async Task ExitWithInputTimeoutAsync(int timeout, int exitCode)
-        {
-            await Task.WhenAny(Task.Delay(TimeSpan.FromSeconds(timeout)), Task.Run(() => Console.ReadKey(true)));
-            Environment.Exit(exitCode);
-        }
-
-        private static async Task StartLavalinkAsync()
-        {
-            var processList = Process.GetProcessesByName("java");
-            if (processList.Length == 0)
-            {
-                string lavalinkFile = Path.Combine(AppContext.BaseDirectory, "Lavalink", "Lavalink.jar");
-                if (!File.Exists(lavalinkFile)) return;
-
-                var process = new ProcessStartInfo
-                {
-                    FileName = "java",
-                    Arguments = $"-jar \"{Path.Combine(AppContext.BaseDirectory, "Lavalink")}/Lavalink.jar\"",
-                    WorkingDirectory = Path.Combine(AppContext.BaseDirectory, "Lavalink"),
-                    UseShellExecute = true,
-                    CreateNoWindow = false,
-                    WindowStyle = ProcessWindowStyle.Minimized
-                };
-                if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
-                {
-                    // Try to get the java exe path
-                    var exePath = Environment.GetEnvironmentVariable("PATH")
-                        ?.Split(Path.PathSeparator)
-                        .FirstOrDefault(x => File.Exists(Path.Combine(x, "java.exe")));
-
-                    if (exePath != null)
-                    {
-                        process.FileName = Path.Combine(exePath, "java.exe");
-                    }
-                }
-                Process.Start(process);
-                await Task.Delay(2000);
-            }
-        }
-
-        private async Task UpdateLavalinkAsync()
-        {
-            string lavalinkDir = Path.Combine(AppContext.BaseDirectory, "Lavalink");
-            string lavalinkFile = Path.Combine(lavalinkDir, "Lavalink.jar");
-            string versionFile = Path.Combine(lavalinkDir, "VERSION.txt");
-            string remoteVersion;
-            using var httpClient = new HttpClient();
-
-            try
-            {
-                remoteVersion = await httpClient.GetStringAsync("https://ci.fredboat.com/repository/download/Lavalink_Build/lastSuccessful/VERSION.txt?guest=1");
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "LLUpdater", "An error occurred while downloading VERSION.txt", e));
-                return;
-            }
-
-            if (File.Exists(versionFile))
-            {
-                string localVersion;
-                try
-                {
-                    localVersion = File.ReadAllText(versionFile);
-                }
-                catch (IOException e)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "LLUpdater", "An error occurred while reading local VERSION.txt", e));
-                    return;
-                }
-                if (localVersion != remoteVersion)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Info, "LLUpdater", "A new dev build has been found."));
-                }
-                else
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Info, "LLUpdater", "Lavalink is up to date."));
-                    return;
-                }
-            }
-            else
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "LLUpdater", "Local VERSION.txt not found or can't be read. Assuming the remote version is newer than the local..."));
-            }
-
-            var processList = Process.GetProcessesByName("java");
-            if (processList.Length != 0)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "LLUpdater", "There's a running instance of Lavalink (or a java app) and it's not possible to kill it since it's probably in use."));
-                return;
-            }
-
-            try
-            {
-                File.Move(lavalinkFile, Path.ChangeExtension(lavalinkFile, ".jar.bak"), true);
-            }
-            catch (IOException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "LLUpdater", "An error occurred while renaming local Lavalink.jar", e));
-                return;
-            }
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Info, "LLUpdater", "Downloading the new dev build of Lavalink..."));
-            try
-            {
-                var response = await httpClient.GetAsync("https://ci.fredboat.com/repository/download/Lavalink_Build/lastSuccessful/Lavalink.jar?guest=1");
-                await using var stream = await response.Content.ReadAsStreamAsync();
-                var file = new FileInfo(lavalinkFile);
-                await using var fileStream = file.OpenWrite();
-                await stream.CopyToAsync(fileStream);
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "LLUpdater", "An error occurred while downloading the new dev build", e));
-                try
-                {
-                    if (File.Exists(lavalinkFile))
-                    {
-                        File.Delete(lavalinkFile);
-                    }
-                    File.Move(lavalinkFile, Path.ChangeExtension(lavalinkFile, ".jar"));
-                }
-                catch (IOException) { }
-                return;
-            }
-            try
-            {
-                File.WriteAllText(versionFile, remoteVersion);
-            }
-            catch (IOException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "LLUpdater", "An error occurred while updating local VERSION.txt", e));
-            }
-            await _logService.LogAsync(new LogMessage(LogSeverity.Info, "LLUpdater", "Finished updating Lavalink."));
-        }
-
-        private IServiceProvider SetupServices()
-        {
-            return new ServiceCollection()
-                .AddSingleton(Constants.CommandServiceConfig)
-                .AddSingleton(Config.LavaConfig)
-                .AddSingleton(_client)
-                .AddSingleton<CommandService>()
-                .AddSingleton(s => new LogService(_client, s.GetRequiredService<CommandService>(), Config.LogLevel))
-                .AddSingleton<LavaNode>()
-                .AddSingleton<InteractiveService>()
-                .AddSingleton<MusicService>()
-                .AddSingleton<GoogleTranslator>()
-                .AddSingleton<GoogleTranslator2>()
-                .AddSingleton<BingTranslator>()
-                .AddSingleton<MicrosoftTranslator>()
-                .AddSingleton<YandexTranslator>()
-                .AddSingleton<AggregateTranslator>()
-                .AddSingleton<CommandHandlingService>()
-                .AddSingleton(s => Config.UseMessageCacheService && Config.MessageCacheSize > 0
-                ? new MessageCacheService(s.GetRequiredService<DiscordShardedClient>(), Config.MessageCacheSize,
-                s.GetRequiredService<LogService>().LogAsync,
-                Constants.MessageCacheClearInterval,
-                Constants.MaxMessageCacheLongevity, Config.MinimumCommandTime)
-                : MessageCacheService.Disabled)
-                .AddSingleton(s => Config.UseCommandCacheService
-                ? new CommandCacheService(s.GetRequiredService<DiscordShardedClient>(), Constants.MessageCacheCapacity,
-                s.GetRequiredService<CommandHandlingService>().HandleCommandAsync,
-                s.GetRequiredService<LogService>().LogAsync, Constants.CommandCacheClearInterval,
-                Constants.MaxCommandCacheLongevity, s.GetRequiredService<MessageCacheService>())
-                : CommandCacheService.Disabled)
-                .AddSingletonIf(Config.UseReliabilityService,
-                s => new ReliabilityService(s.GetRequiredService<DiscordShardedClient>(), s.GetRequiredService<LogService>().LogAsync))
-                .AddSingletonIf(!IsDebugMode,
-                s => new BotListService(s.GetRequiredService<DiscordShardedClient>(), Config.TopGgApiToken, Config.DiscordBotsApiToken,
-                logger: s.GetRequiredService<LogService>().LogAsync))
-                .BuildServiceProvider();
-        }
-
-        private static IEnumerable<CultureInfo> GetAvailableCultures()
-        {
-            var result = new List<CultureInfo>();
-
-            var cultures = CultureInfo.GetCultures(CultureTypes.AllCultures);
-            foreach (var culture in cultures)
-            {
-                try
-                {
-                    if (culture.Equals(CultureInfo.InvariantCulture)) continue;
-                    var rs = strings.ResourceManager.GetResourceSet(culture, true, false);
-                    if (rs != null)
-                    {
-                        result.Add(culture);
-                    }
-                }
-                catch (CultureNotFoundException) { }
-            }
-            return result;
-        }
-
-        private async Task ShardReady(DiscordSocketClient client)
-        {
-            await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Bot", $"Shard {client.ShardId} is online!"));
-
-            if (!_firstConnect)
-                return;
-
-            _firstConnect = false;
-            Uptime = DateTimeOffset.UtcNow;
-
-            if (!IsDebugMode)
-            {
-                InviteLink = $"https://discord.com/oauth2/authorize?client_id={_client.CurrentUser.Id}&permissions={(ulong)Constants.InvitePermissions}&scope=bot%20applications.commands";
-
-                if (!string.IsNullOrEmpty(Config.TopGgApiToken))
-                {
-                    TopGgBotPage = $"https://top.gg/bot/{_client.CurrentUser.Id}";
-                }
-            }
-        }
-
-        private async Task JoinedGuild(SocketGuild guild)
-        {
-            var config = Database.FindDocument<GuildConfig>(Constants.GuildConfigCollection, x => x.Id == guild.Id);
-            if (config != null && config.IsBlacklisted)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "JoinGuild", $"Someone tried to add me to the blacklisted guild \"{guild.Name}\" ({guild.Id})"));
-                await guild.LeaveAsync();
-            }
-            else
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "JoinGuild", $"Bot joined the guild \"{guild.Name}\" ({guild.Id})"));
-                if (guild.PreferredLocale != null)
-                {
-                    string languageCode = guild.PreferredCulture.TwoLetterISOLanguageName;
-                    if (Languages.ContainsKey(languageCode) && languageCode != (DatabaseConfig.Language ?? Constants.DefaultLanguage))
-                    {
-                        await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "JoinGuild", $"A preferred language ({languageCode}) was found in the guild {guild.Id}. Saving the preferred language in the database..."));
-
-                        config = new GuildConfig(guild.Id, language: languageCode);
-                        Database.InsertOrUpdateDocument(Constants.GuildConfigCollection, config);
-                    }
-                }
-            }
-        }
-
-        private async Task LeftGuild(SocketGuild guild)
-        {
-            await _logService.LogAsync(new LogMessage(LogSeverity.Info, "LeftGuild", $"Bot left the guild \"{guild.Name}\" ({guild.Id})"));
-            var config = Database.FindDocument<GuildConfig>(Constants.GuildConfigCollection, x => x.Id == guild.Id);
-            if (config != null && !config.IsBlacklisted)
-            {
-                Database.DeleteDocument(Constants.GuildConfigCollection, config);
-                GuildUtils.PrefixCache.TryRemove(guild.Id, out _);
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "LeftGuild", $"Deleted config of guild {guild.Id}"));
-            }
-        }
-    }
-}
diff --git a/src/FergunResult.cs b/src/FergunResult.cs
index 09dc9ee..83ba2b5 100644
--- a/src/FergunResult.cs
+++ b/src/FergunResult.cs
@@ -1,32 +1,15 @@
-using Discord;
-using Discord.Commands;
+using Discord.Interactions;
 
-namespace Fergun
+namespace Fergun;
+
+public class FergunResult : RuntimeResult
 {
-    public class FergunResult : RuntimeResult
+    /// <inheritdoc />
+    private FergunResult(InteractionCommandError? error, string reason) : base(error, reason)
     {
-        /// <summary>
-        /// Gets whether this result is silent.
-        /// </summary>
-        public bool IsSilent { get; }
-
-        /// <summary>
-        /// Gets a response message associated to this result.
-        /// Currently used for detached response messages
-        /// (messages that don't have an explicit user command message) in commands that use interactive messages.
-        /// </summary>
-        public IUserMessage ResponseMessage { get; }
-
-        public FergunResult(CommandError? error, string reason, bool isSilent, IUserMessage responseMessage) : base(error, reason)
-        {
-            IsSilent = isSilent;
-            ResponseMessage = responseMessage;
-        }
+    }
 
-        public static FergunResult FromError(string reason, bool isSilent = false, IUserMessage responseMessage = null)
-            => new FergunResult(CommandError.Unsuccessful, reason, isSilent, responseMessage);
+    public static FergunResult FromSuccess(string? reason = null) => new(null, reason ?? "");
 
-        public static FergunResult FromSuccess(string reason = null, bool isSilent = false, IUserMessage responseMessage = null)
-            => new FergunResult(null, reason, isSilent, responseMessage);
-    }
+    public static FergunResult FromError(string reason) => new(InteractionCommandError.Unsuccessful, reason);
 }
\ No newline at end of file
diff --git a/src/Modules/AIDungeon.cs b/src/Modules/AIDungeon.cs
deleted file mode 100644
index 2f3ff58..0000000
--- a/src/Modules/AIDungeon.cs
+++ /dev/null
@@ -1,1112 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net.Http;
-using System.Text;
-using System.Text.Encodings.Web;
-using System.Text.Json;
-using System.Threading;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Discord.WebSocket;
-using Fergun.APIs;
-using Fergun.APIs.AIDungeon;
-using Fergun.Attributes;
-using Fergun.Attributes.Preconditions;
-using Fergun.Extensions;
-using Fergun.Interactive;
-using Fergun.Interactive.Selection;
-using Fergun.Services;
-using GTranslate.Translators;
-
-namespace Fergun.Modules
-{
-    using ActionType = APIs.AIDungeon.ActionType;
-
-    [Order(4)]
-    [RequireBotPermission(Constants.MinimumRequiredPermissions)]
-    [Name("AIDungeon"), Group("aid"), Ratelimit(2, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-    public class AIDungeon : FergunBase
-    {
-        private static AiDungeonApi _api;
-        private static readonly ConcurrentDictionary<uint, SemaphoreSlim> _queue = new ConcurrentDictionary<uint, SemaphoreSlim>();
-        private static IReadOnlyDictionary<string, string> _modes;
-
-        private readonly AggregateTranslator _translator;
-        private readonly CommandService _cmdService;
-        private readonly LogService _logService;
-        private readonly MessageCacheService _messageCache;
-        private readonly InteractiveService _interactive;
-
-        public AIDungeon(CommandService commands, LogService logService, MessageCacheService messageCache, InteractiveService interactive, AggregateTranslator translator)
-        {
-            _api ??= new AiDungeonApi(new HttpClient { Timeout = TimeSpan.FromMinutes(1) }, FergunClient.Config.AiDungeonToken ?? "");
-            _cmdService = commands;
-            _logService = logService;
-            _messageCache = messageCache;
-            _interactive = interactive;
-            _translator = translator;
-        }
-
-        [Command("info")]
-        [Summary("aidinfoSummary")]
-        public async Task<RuntimeResult> Info()
-        {
-            var builder = new EmbedBuilder()
-                .WithTitle(Locate("AIDHelp"))
-                .AddField(Locate("AboutAIDTitle"), Locate("AboutAIDText"))
-                .AddField(Locate("AIDHowToPlayTitle"), Locate("AIDHowToPlayText"))
-                .AddField(Locate("InputTypes"), Locate("InputTypesList"));
-
-            var aidCommands = _cmdService.Modules.FirstOrDefault(x => x.Name == "AIDungeon");
-            if (aidCommands == null)
-            {
-                return FergunResult.FromError(Locate("AnErrorOccurred"));
-            }
-
-            var list = new StringBuilder();
-            foreach (var command in aidCommands.Commands)
-            {
-                list.Append($"`{command.Name}");
-                foreach (var parameter in command.Parameters)
-                {
-                    list.Append(' ');
-                    list.Append(parameter.IsOptional ? '[' : '<');
-                    list.Append(parameter.Name);
-                    if (parameter.IsRemainder || parameter.IsMultiple)
-                    {
-                        list.Append("...");
-                    }
-
-                    list.Append(parameter.IsOptional ? ']' : '>');
-                }
-                list.Append($"`: {Locate(command.Summary)}\n\n");
-            }
-
-            builder.AddField(Locate("Commands"), list.ToString())
-                .WithFooter(Locate("HelpFooter2"), Constants.AiDungeonLogoUrl)
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("new", RunMode = RunMode.Async), Ratelimit(1, 1, Measure.Minutes)]
-        [Summary("aidnewSummary")]
-        [Alias("create")]
-        public async Task<RuntimeResult> New()
-        {
-            if (string.IsNullOrEmpty(FergunClient.Config.AiDungeonToken))
-            {
-                return FergunResult.FromError(string.Format(Locate("ValueNotSetInConfig"), nameof(FergunConfig.AiDungeonToken)));
-            }
-
-            if (_modes == null)
-            {
-                await Context.Channel.TriggerTypingAsync();
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", "New: Downloading the mode list..."));
-
-                AiDungeonScenario scenario;
-                try
-                {
-                    scenario = await _api.GetScenarioAsync(AiDungeonApi.AllScenariosId);
-                }
-                catch (Exception e)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Error, "Command", "New: Exception requesting scenario", e));
-                    return FergunResult.FromError(e.Message);
-                }
-
-                if (scenario.Options.Count == 0)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "New: The mode list is empty."));
-                    return FergunResult.FromError(Locate("ErrorInAPI"));
-                }
-
-                _modes = scenario
-                    .Options
-                    .GroupBy(x => x.Title.Truncate(100), StringComparer.OrdinalIgnoreCase)
-                    .Select(x => x.First())
-                    .ToDictionary(x => x.Title.Truncate(100), x => x.PublicId.ToString());
-            }
-            else
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", "New: Using cached mode list..."));
-            }
-
-            var builder = new EmbedBuilder()
-                .WithAuthor(Context.User)
-                .WithTitle(Locate("AIDungeonWelcome"))
-                .WithDescription($"\u2139 {string.Format(Locate("ModeSelect"), GetPrefix())}")
-                .WithThumbnailUrl(Constants.AiDungeonLogoUrl)
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            var warningBuilder = new EmbedBuilder()
-                .WithColor(FergunClient.Config.EmbedColor)
-                .WithDescription($"\u26a0 {Locate("ReplyTimeout")} {Locate("CreationCanceled")}");
-
-            var selectionBuilder = new SelectionBuilder<int>()
-                .AddUser(Context.User)
-                .WithInputType(InputType.SelectMenus)
-                .WithOptions(Enumerable.Range(0, _modes.Count).ToArray())
-                .WithStringConverter(x => _modes.ElementAt(x).Key.ToTitleCase())
-                .WithSelectionPage(PageBuilder.FromEmbedBuilder(builder))
-                .WithTimeoutPage(PageBuilder.FromEmbedBuilder(warningBuilder))
-                .WithActionOnTimeout(ActionOnStop.ModifyMessage | ActionOnStop.DisableInput);
-
-            var result = await SendSelectionAsync(selectionBuilder.Build(), TimeSpan.FromMinutes(1));
-
-            if (!result.IsSuccess)
-            {
-                return FergunResult.FromError($"{Locate("ReplyTimeout")} {Locate("CreationCanceled")}", true);
-            }
-
-            int modeIndex = result.Value;
-
-            AdventureCreationData creationResponse;
-            if (_modes.Keys.ElementAt(modeIndex) == "custom")
-            {
-                creationResponse = await CreateCustomAdventureAsync(modeIndex, builder, result.Message);
-            }
-            else
-            {
-                creationResponse = await CreateAdventureAsync(modeIndex, builder, result.Message);
-            }
-
-            if (creationResponse.ErrorMessage != null)
-            {
-                return FergunResult.FromError(creationResponse.ErrorMessage, creationResponse.IsSilent,
-                    creationResponse.IsSilent ? null : creationResponse.Message);
-            }
-
-            var actions = creationResponse.Adventure.Actions.Where(x => !string.IsNullOrEmpty(x.Text)).ToList();
-
-            if (actions.Count == 0)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "New: The action list is empty."));
-                return FergunResult.FromError(Locate("ErrorInAPI"), false, creationResponse.Message);
-            }
-
-            string initialPrompt = actions[^1].Text;
-            if (actions.Count > 1)
-            {
-                actions.RemoveAt(actions.Count - 1);
-                initialPrompt = string.Concat(actions.Select(x => x.Text)) + initialPrompt;
-            }
-
-            if (AutoTranslate() && !string.IsNullOrEmpty(initialPrompt))
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"New: Translating text to \"{GetLanguage()}\"."));
-                initialPrompt = await TranslateWithFallbackAsync(initialPrompt, "en", GetLanguage());
-            }
-
-            builder.Description = initialPrompt.Truncate(EmbedBuilder.MaxDescriptionLength);
-            builder.WithFooter($"ID: {creationResponse.Adventure.Id} - Tip: {string.Format(Locate("FirstTip"), GetPrefix())}", Constants.AiDungeonLogoUrl);
-
-            await creationResponse.Message.ModifyOrResendAsync(embed: builder.Build(), cache: _messageCache);
-
-            var dbAdventure = new AidAdventure(creationResponse.Adventure.Id, creationResponse.Adventure.PublicId?.ToString(), Context.User.Id, false);
-            FergunClient.Database.InsertDocument(Constants.AidAdventuresCollection, dbAdventure);
-
-            return FergunResult.FromSuccess();
-        }
-
-        private async Task<AdventureCreationData> CreateAdventureAsync(int modeIndex, EmbedBuilder builder, IUserMessage message)
-        {
-            var loadingEmbed = new EmbedBuilder()
-                .WithDescription($"{FergunClient.Config.LoadingEmote} {Locate("Loading")}")
-                .WithColor(FergunClient.Config.EmbedColor)
-                .Build();
-
-            message = await message.ModifyOrResendAsync(embed: loadingEmbed, cache: _messageCache);
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"New: Downloading the character list for mode: {_modes.Keys.ElementAt(modeIndex)} ({_modes.Values.ElementAt(modeIndex)})"));
-
-            AiDungeonScenario scenario;
-            try
-            {
-                scenario = await _api.GetScenarioAsync(_modes.Values.ElementAt(modeIndex));
-            }
-            catch (Exception e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Error, "Command", "New: Exception requesting scenario", e));
-                return new AdventureCreationData(e.Message, message);
-            }
-
-            if (scenario.Options.Count == 0)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "New: The scenario list is empty."));
-                return new AdventureCreationData(Locate("ErrorInAPI"), message);
-            }
-
-            var characters = scenario
-                .Options
-                .GroupBy(x => x.Title.Truncate(100), StringComparer.OrdinalIgnoreCase)
-                .Select(x => x.First())
-                .ToDictionary(x => x.Title.Truncate(100), x => x.PublicId.ToString());
-
-            builder.Title = "AI Dungeon";
-            builder.Description = Locate("CharacterSelect");
-
-            var options = Enumerable.Range(0, characters.Count)
-                .Select(x => new SelectMenuOptionBuilder().WithValue(x.ToString()).WithLabel(characters.ElementAt(x).Key.ToTitleCase()))
-                .ToList();
-
-            var components = new ComponentBuilder()
-                .WithSelectMenu("foobar", options)
-                .Build();
-
-            message = await message.ModifyOrResendAsync(embed: builder.Build(), component: components, cache: _messageCache);
-            var result = await _interactive.NextMessageComponentAsync(x => x.User.Id == Context.User.Id && x.Message.Id == message.Id, timeout: TimeSpan.FromMinutes(1));
-
-            if (!result.IsSuccess)
-            {
-                return new AdventureCreationData($"{Locate("ReplyTimeout")} {Locate("CreationCanceled")}", message);
-            }
-
-            int characterIndex = int.Parse(result.Value!.Data.Values.First());
-
-            var modal = new ModalBuilder()
-                .WithTitle("AI Dungeon")
-                .AddTextInput("Enter your character's name", "output", TextInputStyle.Short, Context.User.Username, 1, 100, true, Context.User.Username)
-                .WithCustomId($"modal{message.Id}")
-                .Build();
-
-            await result.Value!.RespondWithModalAsync(modal);
-
-            components = new ComponentBuilder()
-                .WithSelectMenu("foobar", options, disabled: true)
-                .Build();
-
-            await message.ModifyAsync(x => x.Components = components);
-            var result2 = await _interactive.NextInteractionAsync(x => x is SocketModal m && x.User.Id == Context.User.Id && m.Data.CustomId == $"modal{message.Id}", null, TimeSpan.FromMinutes(2));
-
-            if (!result2.IsSuccess)
-            {
-                return new AdventureCreationData($"{Locate("ReplyTimeout")} {Locate("CreationCanceled")}", message);
-            }
-
-            string name = (result2.Value as SocketModal)!.Data.Components.First(x => x.CustomId == "output").Value;
-
-            builder.Title = "AI Dungeon";
-            builder.Description = FergunClient.Config.LoadingEmote + " " + string.Format(Locate("GeneratingNewAdventure"), _modes.Keys.ElementAt(modeIndex), characters.Keys.ElementAt(characterIndex));
-
-            await result2.Value!.DeferAsync();
-            message = await message.ModifyOrResendAsync(embed: builder.Build(), cache: _messageCache);
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"New: Getting info for character: {characters.Keys.ElementAt(characterIndex)} ({characters.Values.ElementAt(characterIndex)})"));
-
-            try
-            {
-                scenario = await _api.GetScenarioAsync(characters.Values.ElementAt(characterIndex));
-            }
-            catch (Exception e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Error, "Command", "New: Exception requesting scenario", e));
-                return new AdventureCreationData(e.Message, message);
-            }
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"New: Creating new adventure with character Id: {scenario.Id}"));
-
-            AiDungeonAdventure adventure;
-            try
-            {
-                adventure = await _api.CreateAdventureAsync(scenario.Id.ToString(),
-                    scenario.Prompt?.Replace("${character.name}", name, StringComparison.OrdinalIgnoreCase));
-            }
-            catch (Exception e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Error, "Command", "New: Exception creating adventure", e));
-                return new AdventureCreationData(e.Message, message);
-            }
-
-            string publicId = adventure.PublicId?.ToString();
-            if (publicId == null)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "New: publicId is null."));
-                return new AdventureCreationData(Locate("ErrorInAPI"), message);
-            }
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"New: Getting adventure with publicId: {publicId}"));
-
-            try
-            {
-                adventure = await _api.GetAdventureAsync(publicId);
-            }
-            catch (Exception e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Error, "Command", "New: Exception requesting adventure", e));
-                return new AdventureCreationData(e.Message, message);
-            }
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"New: Created adventure ({_modes.Keys.ElementAt(modeIndex)}, {characters.Keys.ElementAt(characterIndex)})" +
-                $" (Id: {adventure.Id}, playPublicId: {adventure.PublicId})"));
-
-            return new AdventureCreationData(adventure, message);
-        }
-
-        private async Task<AdventureCreationData> CreateCustomAdventureAsync(int modeIndex, EmbedBuilder builder, IUserMessage message)
-        {
-            builder.Title = Locate("CustomCharacterCreation");
-            builder.Description = Locate("CustomCharacterPrompt");
-
-            message = await message.ModifyOrResendAsync(embed: builder.Build(), cache: _messageCache);
-
-            var result = await _interactive.NextMessageAsync(Context.IsSourceUserAndChannel, null, TimeSpan.FromMinutes(5));
-
-            if (!result.IsSuccess)
-            {
-                return new AdventureCreationData($"{Locate("ReplyTimeout")} {Locate("CreationCanceled")}", message);
-            }
-
-            string customText = result.Value!.Content;
-
-            await result.Value.TryDeleteAsync();
-
-            builder.Title = "AI Dungeon";
-            builder.Description = $"{FergunClient.Config.LoadingEmote} {Locate("GeneratingNewCustomAdventure")}";
-
-            message = await message.ModifyOrResendAsync(embed: builder.Build(), cache: _messageCache);
-
-            // Get custom adventure ID
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"New: Getting custom adventure ID... ({_modes.Values.ElementAt(modeIndex)})"));
-            AiDungeonScenario scenario;
-            try
-            {
-                scenario = await _api.GetScenarioAsync(_modes.Values.ElementAt(modeIndex));
-            }
-            catch (Exception e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Error, "Command", "New: Exception requesting scenario", e));
-                return new AdventureCreationData(e.Message, message);
-            }
-
-            // Create new adventure with that ID
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"New: Creating custom adventure ({scenario.Id})"));
-
-            AiDungeonAdventure adventure;
-            try
-            {
-                adventure = await _api.CreateAdventureAsync(scenario.Id.ToString());
-            }
-            catch (Exception e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Error, "Command", "New: Exception creating adventure", e));
-                return new AdventureCreationData(e.Message, message);
-            }
-
-            string publicId = adventure.PublicId?.ToString();
-            if (publicId == null)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "New: publicId is null."));
-                return new AdventureCreationData(Locate("ErrorInAPI"), message);
-            }
-
-            if (AutoTranslate())
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"New: Translating text from \"{GetLanguage()}\" to English."));
-                customText = await TranslateWithFallbackAsync(customText, GetLanguage(), "en");
-            }
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"New: Sending action request (publicId: {publicId}, actionType: {ActionType.Story})"));
-
-            try
-            {
-                adventure = await _api.SendActionAsync(publicId, ActionType.Story, customText);
-            }
-            catch (Exception e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Error, "Command", "New: Exception sending action", e));
-                return new AdventureCreationData(e.Message, message);
-            }
-
-            return new AdventureCreationData(adventure, message);
-        }
-
-        private async Task<string> SendAidCommandAsync(uint adventureId, string promptText, ActionType actionType = ActionType.Continue, string text = "", long actionId = 0)
-        {
-            if (string.IsNullOrEmpty(FergunClient.Config.AiDungeonToken))
-            {
-                return string.Format(Locate("ValueNotSetInConfig"), nameof(FergunConfig.AiDungeonToken));
-            }
-
-            var savedAdventure = FergunClient.Database.FindDocument<AidAdventure>(Constants.AidAdventuresCollection, x => x.Id == adventureId);
-            // check the id
-            string checkResult = await CheckIdAsync(savedAdventure);
-            if (checkResult != null)
-            {
-                return checkResult;
-            }
-
-            // For the continue command
-            if (actionType == ActionType.Continue && !string.IsNullOrEmpty(text))
-            {
-                // Split the text in two, the first part should be the input type, and the second part, the text.
-                string[] splitText = text.Split(' ', 2);
-                if (Enum.TryParse(splitText[0], true, out actionType))
-                {
-                    if (splitText.Length != 1)
-                    {
-                        text = splitText[1];
-                    }
-                    if (actionType != ActionType.Do
-                        && actionType != ActionType.Say
-                        && actionType != ActionType.Story)
-                    {
-                        actionType = ActionType.Do;
-                    }
-                }
-                else
-                {
-                    // if the parse fails, keep the text and set the input type to Do (the default).
-                    actionType = ActionType.Do;
-                }
-            }
-
-            var builder = new EmbedBuilder()
-                .WithAuthor(Context.User)
-                .WithTitle("AI Dungeon")
-                .WithDescription($"{FergunClient.Config.LoadingEmote} {Locate(promptText)}")
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            _queue.TryAdd(adventureId, new SemaphoreSlim(1));
-
-            bool wasWaiting = false;
-            if (_queue[adventureId].CurrentCount == 0)
-            {
-                wasWaiting = true;
-                builder.Description = $"{FergunClient.Config.LoadingEmote} {Locate("WaitingQueue")}";
-            }
-            var message = await ReplyAsync(embed: builder.Build());
-
-            AiDungeonAdventure adventure;
-            try
-            {
-                await _queue[adventureId].WaitAsync();
-
-                if (wasWaiting)
-                {
-                    builder.Description = $"{FergunClient.Config.LoadingEmote} {Locate(promptText)}";
-                    await message.ModifyOrResendAsync(embed: builder.Build(), cache: _messageCache);
-                }
-
-                // if a text is passed
-                if (!string.IsNullOrEmpty(text) && AutoTranslate())
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"AID Action: Translating text from \"{GetLanguage()}\" to English."));
-                    text = await TranslateWithFallbackAsync(text, GetLanguage(), "en");
-                }
-
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command",
-                    $"AID Action: Sending action request (Id: {savedAdventure.Id}, publicId: {savedAdventure.PublicId}, actionType: {actionType})"));
-
-                adventure = await _api.SendActionAsync(savedAdventure.PublicId, actionType, text, actionId);
-            }
-            catch (Exception e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Error, "Command", "AID Action: Exception sending action", e));
-                return e.Message;
-            }
-            finally
-            {
-                _queue[adventureId].Release();
-            }
-
-            var actions = adventure.Actions;
-            if (actions.Count == 0)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "AID Action: Action list is empty."));
-                return "Action list is empty.";
-            }
-
-            string textToShow;
-
-            if (actionType != ActionType.Remember)
-            {
-                textToShow = actions[^1].Text;
-                if (actionType == ActionType.Do ||
-                    actionType == ActionType.Say ||
-                    actionType == ActionType.Story)
-                {
-                    textToShow = actions[^2].Text + textToShow;
-                }
-
-                if (!string.IsNullOrEmpty(textToShow) && AutoTranslate())
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"AID Action: Translating text to \"{GetLanguage()}\"."));
-                    textToShow = await TranslateWithFallbackAsync(textToShow, "en", GetLanguage());
-                }
-            }
-            else
-            {
-                textToShow = $"{Locate("TheAIWillNowRemember")}\n{text}";
-            }
-
-            builder.WithDescription(textToShow.Truncate(EmbedBuilder.MaxDescriptionLength))
-                .WithFooter($"ID: {adventureId} - Tip: {GetTip()}", Constants.AiDungeonLogoUrl);
-
-            await message.ModifyOrResendAsync(embed: builder.Build(), cache: _messageCache);
-
-            return null;
-        }
-
-        [Command("continue", RunMode = RunMode.Async)]
-        [Summary("continueSummary")]
-        [Alias("next")]
-        [Example("2582734 save the princess")]
-        public async Task<RuntimeResult> Continue([Summary("continueParam1")] uint adventureId,
-            [Remainder, Summary("continueParam2")] string text = "")
-        {
-            string result = await SendAidCommandAsync(adventureId, "GeneratingStory", text: text);
-
-            return result != null
-                ? FergunResult.FromError(result)
-                : FergunResult.FromSuccess();
-        }
-
-        [Command("undo", RunMode = RunMode.Async)]
-        [Summary("undoSummary")]
-        [Alias("revert")]
-        [Example("2582734")]
-        public async Task<RuntimeResult> Undo([Summary("undoParam1")] uint adventureId)
-        {
-            string result = await SendAidCommandAsync(adventureId, "RevertingLastAction", ActionType.Undo);
-
-            return result != null
-                ? FergunResult.FromError(result)
-                : FergunResult.FromSuccess();
-        }
-
-        [Command("redo", RunMode = RunMode.Async)]
-        [Summary("redoSummary")]
-        [Example("2582734")]
-        public async Task<RuntimeResult> Redo([Summary("redoParam1")] uint adventureId)
-        {
-            string result = await SendAidCommandAsync(adventureId, "RedoingLastAction", ActionType.Redo);
-
-            return result != null
-                ? FergunResult.FromError(result)
-                : FergunResult.FromSuccess();
-        }
-
-        [Command("remember", RunMode = RunMode.Async)]
-        [Summary("rememberSummary")]
-        [Example("2582734 there's a dragon waiting for me")]
-        public async Task<RuntimeResult> Remember([Summary("rememberParam1")] uint adventureId,
-            [Remainder, Summary("rememberParam2")] string text)
-        {
-            string result = await SendAidCommandAsync(adventureId, "EditingStoryContext", ActionType.Remember, text);
-
-            return result != null
-                ? FergunResult.FromError(result)
-                : FergunResult.FromSuccess();
-        }
-
-        [Command("alter", RunMode = RunMode.Async)]
-        [Summary("alterSummary")]
-        [Alias("edit")]
-        [Example("2582734")]
-        public async Task<RuntimeResult> Alter([Summary("alterParam1")] uint adventureId)
-        {
-            if (string.IsNullOrEmpty(FergunClient.Config.AiDungeonToken))
-            {
-                return FergunResult.FromError(string.Format(Locate("ValueNotSetInConfig"), nameof(FergunConfig.AiDungeonToken)));
-            }
-
-            var savedAdventure = FergunClient.Database.FindDocument<AidAdventure>(Constants.AidAdventuresCollection, x => x.Id == adventureId);
-            if (savedAdventure == null)
-            {
-                return FergunResult.FromError(Locate("IDNotFound"));
-            }
-
-            var builder = new EmbedBuilder()
-                .WithDescription($"{FergunClient.Config.LoadingEmote} {Locate("Loading")}")
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            var message = await ReplyAsync(embed: builder.Build());
-
-            AiDungeonAdventure adventure;
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Alter: Getting adventure (Id: {savedAdventure.Id},  publicId: {savedAdventure.PublicId})"));
-            try
-            {
-                adventure = await _api.GetAdventureAsync(savedAdventure.PublicId);
-            }
-            catch (Exception e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Error, "Command", "Alter: Exception requesting adventure", e));
-                return FergunResult.FromError(e.Message);
-            }
-
-            var actions = adventure.Actions;
-            if (actions.Count == 0)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Alter: Action list is empty."));
-                return FergunResult.FromError("Action list is empty");
-            }
-
-            var lastAction = actions[^1];
-            string oldOutput = lastAction.Text;
-
-            if (AutoTranslate())
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Alter: Translating text to \"{GetLanguage()}\"."));
-                oldOutput = await TranslateWithFallbackAsync(oldOutput, "en", GetLanguage());
-            }
-
-            builder = new EmbedBuilder()
-                .WithDescription("Press the button below to start.")
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            var button = new ComponentBuilder()
-                .WithButton("Start", "foobar")
-                .Build();
-
-            await message.ModifyOrResendAsync(embed: builder.Build(), component: button, cache: _messageCache);
-
-            var res = await _interactive.NextMessageComponentAsync(x => x.User.Id == Context.User.Id && x.Message.Id == message.Id, timeout: TimeSpan.FromSeconds(30));
-            if (!res.IsSuccess)
-            {
-                return FergunResult.FromError($"{Locate("ReplyTimeout")} {Locate("EditCanceled")}");
-            }
-
-            var modal = new ModalBuilder()
-                .WithTitle("AI Dungeon")
-                .AddTextInput(Locate("NewOutputPrompt"), "output", TextInputStyle.Paragraph, oldOutput.Truncate(100), 1, 1000, true)
-                .WithCustomId($"modal{message.Id}")
-                .Build();
-
-            await res.Value!.RespondWithModalAsync(modal);
-
-            button = new ComponentBuilder()
-                .WithButton("Start", "foobar", disabled: true)
-                .Build();
-
-            await message.ModifyAsync(x => x.Components = button);
-
-            var result = await _interactive.NextInteractionAsync(x => x is SocketModal m && x.User.Id == Context.User.Id && m.Data.CustomId == $"modal{message.Id}", null, TimeSpan.FromMinutes(5));
-
-            if (!result.IsSuccess)
-            {
-                return FergunResult.FromError($"{Locate("ReplyTimeout")} {Locate("EditCanceled")}");
-            }
-
-            await result.Value!.DeferAsync();
-            string newOutput = (result.Value as SocketModal)!.Data.Components.First(x => x.CustomId == "output").Value;
-
-            string commandResult = await SendAidCommandAsync(adventureId, "Loading", ActionType.Alter, newOutput, lastAction.Id);
-
-            return commandResult != null
-                ? FergunResult.FromError(commandResult)
-                : FergunResult.FromSuccess();
-        }
-
-        [Command("retry", RunMode = RunMode.Async)]
-        [Summary("retrySummary")]
-        [Example("2582734")]
-        public async Task<RuntimeResult> Retry([Summary("retryParam1")] uint adventureId)
-        {
-            string result = await SendAidCommandAsync(adventureId, "GeneratingNewResponse", ActionType.Retry);
-
-            return result != null
-                ? FergunResult.FromError(result)
-                : FergunResult.FromSuccess();
-        }
-
-        [Command("makepublic")]
-        [Summary("makepublicSummary")]
-        [Example("2582734")]
-        public async Task<RuntimeResult> MakePublic([Summary("makepublicParam1")] uint adventureId)
-        {
-            var adventure = FergunClient.Database.FindDocument<AidAdventure>(Constants.AidAdventuresCollection, x => x.Id == adventureId);
-
-            if (adventure == null)
-            {
-                return FergunResult.FromError(Locate("IDNotFound"));
-            }
-            if (Context.User.Id != adventure.OwnerId)
-            {
-                return FergunResult.FromError(Locate("NotIDOwner"));
-            }
-            if (adventure.IsPublic)
-            {
-                return FergunResult.FromError(Locate("IDAlreadyPublic"));
-            }
-
-            adventure.IsPublic = true;
-            FergunClient.Database.InsertOrUpdateDocument(Constants.AidAdventuresCollection, adventure);
-            await SendEmbedAsync(Locate("IDNowPublic"));
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("makeprivate")]
-        [Summary("makeprivateSummary")]
-        [Example("2582734")]
-        public async Task<RuntimeResult> MakePrivate([Summary("makeprivateParam1")] uint adventureId)
-        {
-            var adventure = FergunClient.Database.FindDocument<AidAdventure>(Constants.AidAdventuresCollection, x => x.Id == adventureId);
-
-            if (adventure == null)
-            {
-                return FergunResult.FromError(Locate("IDNotFound"));
-            }
-            if (Context.User.Id != adventure.OwnerId)
-            {
-                return FergunResult.FromError(Locate("NotIDOwner"));
-            }
-            if (!adventure.IsPublic)
-            {
-                return FergunResult.FromError(Locate("IDAlreadyPrivate"));
-            }
-
-            adventure.IsPublic = false;
-            FergunClient.Database.InsertOrUpdateDocument(Constants.AidAdventuresCollection, adventure);
-            await SendEmbedAsync(Locate("IDNowPrivate"));
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("idlist", RunMode = RunMode.Async)]
-        [Alias("ids", "list")]
-        [Summary("idlistSummary")]
-        [Example("Discord#1234")]
-        public async Task<RuntimeResult> IdList([Summary("idlistParam1")] IUser user = null)
-        {
-            user ??= Context.User;
-            var adventures = FergunClient.Database.FindManyDocuments<AidAdventure>(Constants.AidAdventuresCollection, x => x.OwnerId == user.Id).ToArray();
-
-            if (adventures.Length == 0)
-            {
-                return FergunResult.FromError(string.Format(Locate(user.Id == Context.User.Id ? "SelfNoIDs" : "NoIDs"), user));
-            }
-
-            var builder = new EmbedBuilder()
-                .WithTitle(string.Format(Locate("IDList"), user))
-                .AddField("ID", string.Join("\n", adventures.Select(x => x.Id)), true)
-                .AddField(Locate("IsPublic"), string.Join("\n", adventures.Select(x => Locate(x.IsPublic))), true)
-                .WithFooter(Locate("IDListFooter"), Constants.AiDungeonLogoUrl)
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("idinfo", RunMode = RunMode.Async)]
-        [Summary("idinfoSummary")]
-        [Example("2582734")]
-        public async Task<RuntimeResult> IdInfo([Summary("idinfoParam1")] uint adventureId)
-        {
-            if (string.IsNullOrEmpty(FergunClient.Config.AiDungeonToken))
-            {
-                return FergunResult.FromError(string.Format(Locate("ValueNotSetInConfig"), nameof(FergunConfig.AiDungeonToken)));
-            }
-
-            var savedAdventure = FergunClient.Database.FindDocument<AidAdventure>(Constants.AidAdventuresCollection, x => x.Id == adventureId);
-            if (savedAdventure == null)
-            {
-                return FergunResult.FromError(Locate("IDNotFound"));
-            }
-
-            AiDungeonAdventure adventure;
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Idinfo: Getting adventure (Id: {savedAdventure.Id},  publicId: {savedAdventure.PublicId})"));
-            try
-            {
-                adventure = await _api.GetAdventureAsync(savedAdventure.PublicId);
-            }
-            catch (Exception e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Error, "Command", "Idinfo: Exception requesting adventure", e));
-                return FergunResult.FromError(e.Message);
-            }
-
-            string initialPrompt;
-            var actions = adventure.Actions;
-
-            if (actions.Count == 0)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Idinfo: Action list is empty."));
-                initialPrompt = "???";
-            }
-            else
-            {
-                initialPrompt = actions[0].Text;
-                if (actions.Count > 1)
-                {
-                    initialPrompt += actions[1].Text;
-                }
-            }
-
-            var idOwner = Context.IsPrivate ? null : Context.Guild.GetUser(savedAdventure.OwnerId);
-
-            var builder = new EmbedBuilder()
-                .WithTitle(Locate("IDInfo"))
-                .WithDescription(initialPrompt.Truncate(EmbedBuilder.MaxDescriptionLength))
-                .AddField(Locate("IsPublic"), Locate(savedAdventure.IsPublic), true)
-                .AddField(Locate("Owner"), idOwner?.ToString() ?? Locate("NotAvailable"), true)
-                .WithFooter($"ID: {adventureId} - {Locate("CreatedAt")}:", Constants.AiDungeonLogoUrl)
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            builder.Timestamp = adventure.CreatedAt;
-
-            await ReplyAsync(embed: builder.Build());
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("delete", RunMode = RunMode.Async)]
-        [Summary("deleteSummary")]
-        [Alias("remove")]
-        [Example("2582734")]
-        public async Task<RuntimeResult> Delete([Summary("deleteParam1")] uint adventureId)
-        {
-            if (string.IsNullOrEmpty(FergunClient.Config.AiDungeonToken))
-            {
-                return FergunResult.FromError(string.Format(Locate("ValueNotSetInConfig"), nameof(FergunConfig.AiDungeonToken)));
-            }
-
-            var adventure = FergunClient.Database.FindDocument<AidAdventure>(Constants.AidAdventuresCollection, x => x.Id == adventureId);
-
-            if (adventure == null)
-            {
-                return FergunResult.FromError(Locate("IDNotFound"));
-            }
-            if (Context.User.Id != adventure.OwnerId)
-            {
-                return FergunResult.FromError(Locate("NotIDOwner"));
-            }
-
-            var builder = new EmbedBuilder()
-                .WithDescription(Locate("AdventureDeletionPrompt"))
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            var warningBuilder = new EmbedBuilder()
-                .WithColor(FergunClient.Config.EmbedColor)
-                .WithDescription($"\u26a0 {Locate("ReplyTimeout")}");
-
-            var selection = new EmoteSelectionBuilder()
-                .AddOption(new Emoji("🗑"))
-                .AddOption(new Emoji("❌"))
-                .AddUser(Context.User)
-                .WithSelectionPage(PageBuilder.FromEmbedBuilder(builder))
-                .WithTimeoutPage(PageBuilder.FromEmbedBuilder(warningBuilder))
-                .WithAllowCancel(true)
-                .WithActionOnTimeout(ActionOnStop.ModifyMessage | ActionOnStop.DisableInput)
-                .WithActionOnCancellation(ActionOnStop.DisableInput)
-                .Build();
-
-            var result = await SendSelectionAsync(selection, TimeSpan.FromSeconds(30));
-
-            if (!result.IsSuccess)
-            {
-                return FergunResult.FromError(Locate("ReplyTimeout"), true);
-            }
-
-            builder = new EmbedBuilder()
-                .WithDescription($"{FergunClient.Config.LoadingEmote} {Locate("DeletingAdventure")}")
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await result.Message.ModifyOrResendAsync(embed: builder.Build(), cache: _messageCache);
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Delete: Deleting adventure (Id: {adventure.Id},  publicId: {adventure.PublicId})"));
-
-            try
-            {
-                await _api.DeleteAdventureAsync(adventure.PublicId);
-            }
-            catch (Exception e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Error, "Command", "Delete: Exception deleting adventure", e));
-                return FergunResult.FromError(e.Message, false, result.Message);
-            }
-
-            FergunClient.Database.DeleteDocument(Constants.AidAdventuresCollection, adventure);
-
-            await result.Message.ModifyOrResendAsync(embed: builder.WithDescription(Locate("AdventureDeleted")).Build(), cache: _messageCache);
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("dump", RunMode = RunMode.Async), Ratelimit(1, 2, Measure.Minutes)]
-        [Summary("dumpSummary")]
-        [Alias("export")]
-        [Example("2582734")]
-        public async Task<RuntimeResult> Dump([Summary("dumpParam1")] uint adventureId)
-        {
-            if (string.IsNullOrEmpty(FergunClient.Config.AiDungeonToken))
-            {
-                return FergunResult.FromError(string.Format(Locate("ValueNotSetInConfig"), nameof(FergunConfig.AiDungeonToken)));
-            }
-
-            var savedAdventure = FergunClient.Database.FindDocument<AidAdventure>(Constants.AidAdventuresCollection, x => x.Id == adventureId);
-            string checkResult = await CheckIdAsync(savedAdventure);
-            if (checkResult != null)
-            {
-                return FergunResult.FromError(checkResult);
-            }
-
-            var builder = new EmbedBuilder()
-                .WithDescription($"{FergunClient.Config.LoadingEmote} {Locate("DumpingAdventure")}")
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            var message = await ReplyAsync(embed: builder.Build());
-
-            AiDungeonAdventure adventure;
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Dump: Getting adventure (Id: {savedAdventure.Id},  publicId: {savedAdventure.PublicId})"));
-            try
-            {
-                adventure = await _api.GetAdventureAsync(savedAdventure.PublicId);
-            }
-            catch (Exception e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Error, "Command", "Dump: Exception requesting adventure", e));
-                return FergunResult.FromError(e.Message);
-            }
-
-            var actions = adventure.Actions;
-            if (actions.Count == 0)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Dump: Action list is empty."));
-                return FergunResult.FromError("Action list is empty.");
-            }
-
-            try
-            {
-                var options = new JsonSerializerOptions
-                {
-                    WriteIndented = true,
-                    Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
-                    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
-                };
-
-                var hastebinUrl = await Hastebin.UploadAsync(JsonSerializer.Serialize(actions, options));
-                builder.Description = Format.Url(Locate("HastebinLink"), hastebinUrl);
-            }
-            catch (Exception e) when (e is HttpRequestException || e is TaskCanceledException)
-            {
-                return FergunResult.FromError(Locate("AnErrorOccurred"));
-            }
-
-            await message.ModifyOrResendAsync(embed: builder.Build(), cache: _messageCache);
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("give"), Ratelimit(1, 1, Measure.Minutes)]
-        [Summary("giveSummary")]
-        [Alias("transfer")]
-        [Example("2582734")]
-        public async Task<RuntimeResult> Give([Summary("giveParam1")] uint adventureId, [Remainder, Summary("giveParam2")] IUser user)
-        {
-            var adventure = FergunClient.Database.FindDocument<AidAdventure>(Constants.AidAdventuresCollection, x => x.Id == adventureId);
-
-            if (adventure == null)
-            {
-                return FergunResult.FromError(Locate("IDNotFound"));
-            }
-            if (Context.User.Id != adventure.OwnerId)
-            {
-                return FergunResult.FromError(Locate("NotIDOwner"));
-            }
-            if (adventure.PublicId == null)
-            {
-                return FergunResult.FromError(Locate("PublicIdNull"));
-            }
-            if (Context.User.Id == user.Id)
-            {
-                return FergunResult.FromError(Locate("CannotGiveYourself"));
-            }
-            if (user.IsBot)
-            {
-                return FergunResult.FromError(Locate("CannotGiveToBot"));
-            }
-
-            adventure = new AidAdventure(adventure.Id, adventure.PublicId, user.Id, true);
-            FergunClient.Database.InsertOrUpdateDocument(Constants.AidAdventuresCollection, adventure);
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Give: Transferred adventure ID from {Context.User} ({Context.User.Id}) to {user} ({user.Id})."));
-
-            await SendEmbedAsync("✅ " + string.Format(Locate("GaveId"), user));
-
-            return FergunResult.FromSuccess();
-        }
-
-        private async Task<string> CheckIdAsync(AidAdventure adventure)
-        {
-            if (adventure == null)
-            {
-                return Locate("IDNotFound");
-            }
-            if (adventure.PublicId == null)
-            {
-                return Locate("PublicIdNull");
-            }
-            if (!adventure.IsPublic && Context.User.Id != adventure.OwnerId)
-            {
-                return string.Format(Locate("IDNotPublic"), await Context.Client.Rest.GetUserAsync(adventure.OwnerId));
-            }
-            return null;
-        }
-
-        private string GetTip()
-        {
-            var tips = Locate("AIDTips").Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.None);
-            return tips[Random.Shared.Next(tips.Length)];
-        }
-
-        private bool AutoTranslate()
-        {
-            if (GetLanguage() == "en") // AI Dungeon Language
-            {
-                return false;
-            }
-            if (Context.IsPrivate)
-            {
-                return true;
-            }
-            return GetGuildConfig()?.AidAutoTranslate ?? Constants.AidAutoTranslateDefault;
-        }
-
-        // Fallback to original text if fails
-        private async Task<string> TranslateWithFallbackAsync(string text, string fromLanguage, string toLanguage)
-        {
-            try
-            {
-                var result = await _translator.TranslateAsync(text, toLanguage, fromLanguage);
-                return result.Translation;
-            }
-            catch (Exception e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Failed to translate text to \"{toLanguage}\"", e));
-                return text;
-            }
-        }
-
-        private class AdventureCreationData
-        {
-            public AdventureCreationData(string errorMessage, IUserMessage message)
-            {
-                ErrorMessage = errorMessage;
-                Message = message;
-            }
-
-            public AdventureCreationData(string errorMessage, bool isSilent)
-            {
-                ErrorMessage = errorMessage;
-                IsSilent = isSilent;
-            }
-
-            public AdventureCreationData(AiDungeonAdventure adventure, IUserMessage message)
-            {
-                Adventure = adventure;
-                Message = message;
-            }
-
-            public string ErrorMessage { get; }
-
-            public bool IsSilent { get; }
-
-            public AiDungeonAdventure Adventure { get; }
-
-            public IUserMessage Message { get; }
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Modules/EvaluationEnvironment.cs b/src/Modules/EvaluationEnvironment.cs
deleted file mode 100644
index af59200..0000000
--- a/src/Modules/EvaluationEnvironment.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using Discord.Commands;
-using Discord.WebSocket;
-
-namespace Fergun.Modules
-{
-    public sealed class EvaluationEnvironment
-    {
-        public ShardedCommandContext Context { get; }
-        public SocketUserMessage Message => Context.Message;
-        public ISocketMessageChannel Channel => Context.Channel;
-        public SocketGuild Guild => Context.Guild;
-        public SocketUser User => Context.User;
-        public DiscordShardedClient Client => Context.Client;
-
-        public EvaluationEnvironment(ShardedCommandContext context)
-        {
-            Context = context;
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Modules/FergunBase.cs b/src/Modules/FergunBase.cs
deleted file mode 100644
index 2b6f3eb..0000000
--- a/src/Modules/FergunBase.cs
+++ /dev/null
@@ -1,182 +0,0 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Fergun.Interactive;
-using Fergun.Interactive.Pagination;
-using Fergun.Interactive.Selection;
-using Fergun.Services;
-using Fergun.Utils;
-
-namespace Fergun.Modules
-{
-    /// <inheritdoc/>
-    public abstract class FergunBase : FergunBase<ShardedCommandContext>
-    {
-    }
-
-    /// <summary>
-    /// The command module base that Fergun uses in its modules.
-    /// </summary>
-    public abstract class FergunBase<T> : CommandCacheModuleBase<T>
-        where T : ShardedCommandContext
-    {
-        /// <summary>
-        /// Gets or sets the interactive service.
-        /// </summary>
-        public InteractiveService Interactive { get; set; }
-
-        /// <summary>
-        /// Gets or sets the message cache service.
-        /// </summary>
-        public MessageCacheService MessageCache { get; set; }
-
-        /// <inheritdoc cref="InteractiveService.SendPaginatorAsync(Paginator, IMessageChannel, TimeSpan?, Action{IUserMessage}, bool, CancellationToken)"/>
-        public async Task<InteractiveMessageResult> SendPaginatorAsync(Paginator paginator, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
-        {
-            if (Cache.TryGetValue(Context.Message.Id, out ulong messageId))
-            {
-                if (Interactive.TryRemoveCallback(messageId, out var callback))
-                {
-                    callback.Dispose();
-                }
-
-                var response = (IUserMessage)await Context.Channel.GetMessageAsync(MessageCache, messageId).ConfigureAwait(false);
-
-                return await Interactive.SendPaginatorAsync(paginator, response, timeout, cancellationToken: cancellationToken).ConfigureAwait(false);
-            }
-
-            return await Interactive.SendPaginatorAsync(paginator, Context.Channel, timeout, AddMessageToCache, cancellationToken: cancellationToken).ConfigureAwait(false);
-
-            void AddMessageToCache(IUserMessage message)
-            {
-                if (!Cache.IsDisabled)
-                {
-                    Cache.Add(Context.Message, message);
-                }
-            }
-        }
-
-        /// <inheritdoc cref="InteractiveService.SendSelectionAsync{TOption}(BaseSelection{TOption}, IMessageChannel, TimeSpan?, Action{IUserMessage}, CancellationToken)"/>
-        public async Task<InteractiveMessageResult<TOption>> SendSelectionAsync<TOption>(BaseSelection<TOption> selection,
-            TimeSpan? timeout = null, IUserMessage message = null, CancellationToken cancellationToken = default)
-        {
-            ulong messageId = message?.Id ?? (Cache.TryGetValue(Context.Message.Id, out ulong temp) ? temp : 0);
-            if (messageId != 0)
-            {
-                if (Interactive.TryRemoveCallback(messageId, out var callback))
-                {
-                    callback.Dispose();
-                }
-
-                var response = message ?? (IUserMessage)await Context.Channel.GetMessageAsync(MessageCache, messageId).ConfigureAwait(false);
-
-                return await Interactive.SendSelectionAsync(selection, response, timeout, cancellationToken: cancellationToken).ConfigureAwait(false);
-            }
-
-            return await Interactive.SendSelectionAsync(selection, Context.Channel, timeout, AddMessageToCache, cancellationToken).ConfigureAwait(false);
-
-            void AddMessageToCache(IUserMessage msg)
-            {
-                if (!Cache.IsDisabled)
-                {
-                    Cache.Add(Context.Message, msg);
-                }
-            }
-        }
-
-        /// <inheritdoc/>
-        protected override async Task<IUserMessage> ReplyAsync(string message = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null,
-            MessageReference messageReference = null, MessageComponent component = null, ISticker[] stickers = null, Embed[] embeds = null)
-        {
-            component ??= new ComponentBuilder().Build(); // remove message components if null
-
-            if (Cache.IsDisabled)
-            {
-                return await base.ReplyAsync(message, isTTS, embed, options, allowedMentions, messageReference, component, stickers, embeds);
-            }
-
-            IUserMessage response;
-            bool found = Cache.TryGetValue(Context.Message.Id, out ulong messageId);
-            if (found && (response = (IUserMessage)await Context.Channel.GetMessageAsync(MessageCache, messageId)) != null)
-            {
-                if (Interactive.TryRemoveCallback(messageId, out var callback))
-                {
-                    callback.Dispose();
-                }
-
-                await response.ModifyAsync(x =>
-                {
-                    x.Content = message;
-                    x.Embed = embed;
-                    x.Attachments = Array.Empty<FileAttachment>();
-                    x.AllowedMentions = allowedMentions ?? Optional.Create<AllowedMentions>();
-                    x.Components = component;
-                }).ConfigureAwait(false);
-
-                response = (IUserMessage)await Context.Channel.GetMessageAsync(MessageCache, messageId).ConfigureAwait(false);
-            }
-            else
-            {
-                response = await Context.Channel.SendMessageAsync(message, isTTS, embed, options, allowedMentions, messageReference, component).ConfigureAwait(false);
-                Cache.Add(Context.Message, response);
-            }
-            return response;
-        }
-
-        /// <summary>
-        /// Returns the prefix of the source channel.
-        /// </summary>
-        /// <returns>The prefix of the channel.</returns>
-        public string GetPrefix() => GuildUtils.GetPrefix(Context.Channel);
-
-        /// <summary>
-        /// Returns the configuration of a guild using the source channel.
-        /// </summary>
-        /// <returns>The configuration of the guild, or <c>null</c> if the guild cannot be found in the database.</returns>
-        public GuildConfig GetGuildConfig() => GuildUtils.GetGuildConfig(Context.Channel);
-
-        /// <summary>
-        /// Returns the language of the source channel.
-        /// </summary>
-        /// <returns>The language of the source channel.</returns>
-        public string GetLanguage() => GuildUtils.GetLanguage(Context.Channel);
-
-        /// <summary>
-        /// Returns the localized value of a resource key.
-        /// </summary>
-        /// <param name="key">The resource key to localize.</param>
-        /// <returns>The localized text, or <paramref name="key"/> if the value cannot be found.</returns>
-        public string Locate(string key) => GuildUtils.Locate(key, Context.Channel);
-
-        /// <summary>
-        /// Returns the localized value of a boolean.
-        /// </summary>
-        /// <param name="boolean">The boolean to localize.</param>
-        /// <returns>The localized boolean.</returns>
-        public string Locate(bool boolean) => GuildUtils.Locate(boolean ? "Yes" : "No", Context.Channel);
-
-        /// <summary>
-        /// Returns the localized value of a resource key in the specified language.
-        /// </summary>
-        /// <param name="key">The resource key to localize.</param>
-        /// <param name="language">The language to localize the resource key.</param>
-        /// <returns>The localized text, or <paramref name="key"/> if the value cannot be found.</returns>
-        public string Locate(string key, string language) => GuildUtils.Locate(key, language);
-
-        /// <summary>
-        /// Sends or edits an embed to the source channel, and adds the response to the cache if the message is new.
-        /// </summary>
-        /// <param name="text">The embed description.</param>
-        /// <returns>A task that represents the send or edit operation. The task contains the sent or edited message.</returns>
-        public async Task<IUserMessage> SendEmbedAsync(string text)
-        {
-            var builder = new EmbedBuilder()
-                .WithDescription(text)
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            return await ReplyAsync(embed: builder.Build());
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Modules/Handlers/BraveAutocompleteHandler.cs b/src/Modules/Handlers/BraveAutocompleteHandler.cs
new file mode 100644
index 0000000..0e239c3
--- /dev/null
+++ b/src/Modules/Handlers/BraveAutocompleteHandler.cs
@@ -0,0 +1,44 @@
+using System.Text.Json;
+using Discord;
+using Discord.Interactions;
+using Microsoft.Extensions.DependencyInjection;
+using Polly;
+using Polly.Registry;
+
+namespace Fergun.Modules.Handlers;
+
+public class BraveAutocompleteHandler : AutocompleteHandler
+{
+    /// <inheritdoc />
+    public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context,
+        IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
+    {
+        var value = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
+
+        if (string.IsNullOrEmpty(value))
+            return AutocompletionResult.FromSuccess();
+
+        var client = services
+            .GetRequiredService<IHttpClientFactory>()
+            .CreateClient("autocomplete");
+
+        var policy = services
+            .GetRequiredService<IReadOnlyPolicyRegistry<string>>()
+            .Get<IAsyncPolicy<HttpResponseMessage>>("AutocompletePolicy");
+
+        string url = $"https://search.brave.com/api/suggest?q={Uri.EscapeDataString(value)}&source=web";
+
+        var response = await policy.ExecuteAsync(_ => client.GetAsync(new Uri(url)), new Context(url));
+        var bytes = await response.Content.ReadAsByteArrayAsync();
+
+        using var document = JsonDocument.Parse(bytes);
+
+        var results = document
+            .RootElement[1]
+            .EnumerateArray()
+            .Select(x => new AutocompleteResult(x.GetString(), x.GetString()))
+            .Take(25);
+
+        return AutocompletionResult.FromSuccess(results);
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs b/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs
new file mode 100644
index 0000000..55b2b65
--- /dev/null
+++ b/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs
@@ -0,0 +1,49 @@
+using System.Text.Json;
+using Discord;
+using Discord.Interactions;
+using Fergun.Extensions;
+using Microsoft.Extensions.DependencyInjection;
+using Polly;
+using Polly.Registry;
+
+namespace Fergun.Modules.Handlers;
+
+public class DuckDuckGoAutocompleteHandler : AutocompleteHandler
+{
+    /// <inheritdoc />
+    public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context,
+        IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
+    {
+        var value = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
+
+        if (string.IsNullOrEmpty(value))
+            return AutocompletionResult.FromSuccess();
+
+        var client = services
+            .GetRequiredService<IHttpClientFactory>()
+            .CreateClient("autocomplete");
+
+        var policy = services
+            .GetRequiredService<IReadOnlyPolicyRegistry<string>>()
+            .Get<IAsyncPolicy<HttpResponseMessage>>("AutocompletePolicy");
+
+        bool isNsfw = context.Channel.IsNsfw();
+        client.DefaultRequestHeaders.TryAddWithoutValidation("cookie", $"p={(isNsfw ? -2 : 1)}");
+
+        string locale = autocompleteInteraction.GetLocale("wt-wt").ToLowerInvariant();
+        string url = $"https://duckduckgo.com/ac/?q={Uri.EscapeDataString(value)}&kl={locale}";
+
+        var response = await policy.ExecuteAsync(_ => client.GetAsync(new Uri(url)), new Context($"{url}-nsfw:{isNsfw}"));
+        var bytes = await response.Content.ReadAsByteArrayAsync();
+
+        using var document = JsonDocument.Parse(bytes);
+
+        var results = document
+            .RootElement
+            .EnumerateArray()
+            .Select(x => new AutocompleteResult(x.GetProperty("phrase").GetString(), x.GetProperty("phrase").GetString()))
+            .Take(25);
+
+        return AutocompletionResult.FromSuccess(results);
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/Handlers/GoogleAutocompleteHandler.cs b/src/Modules/Handlers/GoogleAutocompleteHandler.cs
new file mode 100644
index 0000000..bdaf60f
--- /dev/null
+++ b/src/Modules/Handlers/GoogleAutocompleteHandler.cs
@@ -0,0 +1,47 @@
+using System.Text.Json;
+using Discord;
+using Discord.Interactions;
+using Fergun.Extensions;
+using Humanizer;
+using Microsoft.Extensions.DependencyInjection;
+using Polly;
+using Polly.Registry;
+
+namespace Fergun.Modules.Handlers;
+
+public class GoogleAutocompleteHandler : AutocompleteHandler
+{
+    /// <inheritdoc />
+    public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context,
+        IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
+    {
+        var value = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim().Truncate(100, string.Empty);
+
+        if (string.IsNullOrEmpty(value))
+            return AutocompletionResult.FromSuccess();
+
+        var client = services
+            .GetRequiredService<IHttpClientFactory>()
+            .CreateClient("autocomplete");
+
+        var policy = services
+            .GetRequiredService<IReadOnlyPolicyRegistry<string>>()
+            .Get<IAsyncPolicy<HttpResponseMessage>>("AutocompletePolicy");
+
+        string language = autocompleteInteraction.GetTwoLetterLanguageCode();
+
+        string url = $"https://www.google.com/complete/search?q={Uri.EscapeDataString(value)}&client=chrome&hl={language}&xhr=t";
+        var response = await policy.ExecuteAsync(_ => client.GetAsync(new Uri(url)), new Context(url));
+        var bytes = await response.Content.ReadAsByteArrayAsync();
+
+        using var document = JsonDocument.Parse(bytes);
+
+        var results = document
+            .RootElement[1]
+            .EnumerateArray()
+            .Select(x => new AutocompleteResult(x.GetString(), x.GetString()))
+            .Take(25);
+
+        return AutocompletionResult.FromSuccess(results);
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/Handlers/TranslateAutocompleteHandler.cs b/src/Modules/Handlers/TranslateAutocompleteHandler.cs
new file mode 100644
index 0000000..308ca31
--- /dev/null
+++ b/src/Modules/Handlers/TranslateAutocompleteHandler.cs
@@ -0,0 +1,49 @@
+using Discord;
+using Discord.Interactions;
+using Fergun.Extensions;
+using GTranslate;
+
+namespace Fergun.Modules.Handlers;
+
+public class TranslateAutocompleteHandler : AutocompleteHandler
+{
+    /// <inheritdoc />
+    public override Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context,
+        IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
+    {
+        var value = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
+
+        IEnumerable<Language> languages = Language
+            .LanguageDictionary
+            .Values
+            .Where(x => x.Name.StartsWith(value, StringComparison.OrdinalIgnoreCase) ||
+                        x.ISO6391.StartsWith(value, StringComparison.OrdinalIgnoreCase) ||
+                        x.ISO6393.StartsWith(value, StringComparison.OrdinalIgnoreCase))
+            .OrderBy(x => x.Name);
+
+        if (parameter.Name == "source")
+        {
+            string? target = autocompleteInteraction.Data.Options.FirstOrDefault(x => x.Name == "target")?.Value as string;
+
+            if (string.IsNullOrWhiteSpace(target) || !Language.TryGetLanguage(target, out var language))
+                return Task.FromResult(AutocompletionResult.FromSuccess());
+
+            // Return only languages that supports both target and source languages
+            languages = languages
+                .Where(x => x.IsServiceSupported(language.SupportedServices));
+        }
+        else
+        {
+            if (context.Interaction.TryGetLanguage(out var language))
+            {
+                languages = languages.Where(x => !x.Equals(language)).Prepend(language);
+            }
+        }
+
+        var results = languages
+            .Select(x => new AutocompleteResult($"{x.Name} ({x.ISO6391})", x.ISO6391))
+            .Take(25);
+
+        return Task.FromResult(AutocompletionResult.FromSuccess(results));
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/Handlers/YouTubeAutocompleteHandler.cs b/src/Modules/Handlers/YouTubeAutocompleteHandler.cs
new file mode 100644
index 0000000..57f0dfc
--- /dev/null
+++ b/src/Modules/Handlers/YouTubeAutocompleteHandler.cs
@@ -0,0 +1,47 @@
+using System.Text.Json;
+using Discord;
+using Discord.Interactions;
+using Fergun.Extensions;
+using Humanizer;
+using Microsoft.Extensions.DependencyInjection;
+using Polly;
+using Polly.Registry;
+
+namespace Fergun.Modules.Handlers;
+
+public class YouTubeAutocompleteHandler : AutocompleteHandler
+{
+    /// <inheritdoc />
+    public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context,
+        IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
+    {
+        var value = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim().Truncate(100, string.Empty);
+
+        if (string.IsNullOrEmpty(value))
+            return AutocompletionResult.FromSuccess();
+
+        var client = services
+            .GetRequiredService<IHttpClientFactory>()
+            .CreateClient("autocomplete");
+
+        var policy = services
+            .GetRequiredService<IReadOnlyPolicyRegistry<string>>()
+            .Get<IAsyncPolicy<HttpResponseMessage>>("AutocompletePolicy");
+
+        string language = autocompleteInteraction.GetTwoLetterLanguageCode();
+        string url = $"https://suggestqueries-clients6.youtube.com/complete/search?client=youtube&hl={language}&gs_ri=youtube&ds=yt&q={Uri.EscapeDataString(value)}&xhr=t";
+
+        var response = await policy.ExecuteAsync(_ => client.GetAsync(new Uri(url)), new Context(url));
+        var bytes = await response.Content.ReadAsByteArrayAsync();
+
+        using var document = JsonDocument.Parse(bytes);
+
+        var results = document
+            .RootElement[1]
+            .EnumerateArray()
+            .Select(x => new AutocompleteResult(x[0].GetString(), x[0].GetString()))
+            .Take(25);
+
+        return AutocompletionResult.FromSuccess(results);
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/MessageModule.cs b/src/Modules/MessageModule.cs
new file mode 100644
index 0000000..daab86b
--- /dev/null
+++ b/src/Modules/MessageModule.cs
@@ -0,0 +1,180 @@
+using Discord;
+using Discord.Interactions;
+using Fergun.Extensions;
+using GTranslate;
+using GTranslate.Results;
+using GTranslate.Translators;
+using Humanizer;
+using Microsoft.Extensions.Logging;
+
+namespace Fergun.Modules;
+
+public class MessageModule : InteractionModuleBase<ShardedInteractionContext>
+{
+    private readonly ILogger<MessageModule> _logger;
+    private readonly AggregateTranslator _translator;
+    private readonly GoogleTranslator _googleTranslator;
+    private readonly GoogleTranslator2 _googleTranslator2;
+    private readonly BingTranslator _bingTranslator;
+    private readonly MicrosoftTranslator _microsoftTranslator;
+    private readonly YandexTranslator _yandexTranslator;
+    private static readonly Lazy<Language[]> _lazyFilteredLanguages = new(() => Language.LanguageDictionary
+        .Values
+        .Where(x => x.SupportedServices == (TranslationServices.Google | TranslationServices.Bing | TranslationServices.Yandex | TranslationServices.Microsoft))
+        .ToArray());
+
+    public MessageModule(ILogger<MessageModule> logger, AggregateTranslator translator, GoogleTranslator googleTranslator,
+        GoogleTranslator2 googleTranslator2, BingTranslator bingTranslator, MicrosoftTranslator microsoftTranslator, YandexTranslator yandexTranslator)
+    {
+        _logger = logger;
+        _translator = translator;
+        _googleTranslator = googleTranslator;
+        _googleTranslator2 = googleTranslator2;
+        _bingTranslator = bingTranslator;
+        _microsoftTranslator = microsoftTranslator;
+        _yandexTranslator = yandexTranslator;
+    }
+
+    [MessageCommand("Get Reference")]
+    public async Task GetReferencedMessage(IUserMessage message)
+    {
+        if (message.Type != MessageType.Reply)
+        {
+            await Context.Interaction.RespondWarningAsync("Message is not an inline reply.", true);
+            return;
+        }
+
+        if (message.Reference?.MessageId.IsSpecified is not true)
+        {
+            await Context.Interaction.RespondWarningAsync("Unable to get the referenced message.", true);
+            return;
+        }
+
+        string url = $"https://discord.com/channels/{message.Reference.GuildId.ToNullable()?.ToString() ?? "@me"}/{message.Reference.ChannelId}/{message.Reference.MessageId}";
+
+        var button = new ComponentBuilder()
+            .WithButton("Jump to message", style: ButtonStyle.Link, url: url)
+            .Build();
+
+        await RespondAsync("\u200b", ephemeral: true, components: button);
+    }
+
+    [MessageCommand("TTS")]
+    public async Task TTS(IUserMessage message)
+    {
+        string text = message.GetText();
+
+        if (string.IsNullOrWhiteSpace(text))
+        {
+            await Context.Interaction.RespondWarningAsync("The message must contain text.", true);
+            return;
+        }
+
+        string target = Context.Interaction.GetTwoLetterLanguageCode();
+
+        if (!Language.TryGetLanguage(target, out var language) || !GoogleTranslator2.TextToSpeechLanguages.Contains(language))
+        {
+            await Context.Interaction.RespondWarningAsync($"Language \"{target}\" not supported.", true);
+            return;
+        }
+
+        await DeferAsync();
+
+        try
+        {
+            await using var stream = await _googleTranslator2.TextToSpeechAsync(text, language);
+            await Context.Interaction.FollowupWithFileAsync(new FileAttachment(stream, "tts.mp3"));
+        }
+        catch (HttpRequestException e)
+        {
+            _logger.LogWarning(e, "TTS: Error while getting TTS");
+            await Context.Interaction.FollowupWarning("An error occurred.");
+        }
+        catch (TaskCanceledException e)
+        {
+            _logger.LogWarning(e, "TTS: Error while getting TTS");
+            await Context.Interaction.FollowupWarning("Request timed out.");
+        }
+    }
+
+    [MessageCommand("Bad Translator")]
+    public async Task BadTranslator(IUserMessage message)
+    {
+        string text = message.GetText();
+
+        if (string.IsNullOrWhiteSpace(text))
+        {
+            await Context.Interaction.RespondWarningAsync("The message must contain text.", true);
+            return;
+        }
+
+        await DeferAsync();
+
+        // Create an aggregated translator manually so we can randomize the initial order of the translators and shift them.
+        // Bing Translator is not included because it only allows max. 1000 chars per translation
+        var translators = new ITranslator[] { _googleTranslator, _googleTranslator2, _microsoftTranslator, _yandexTranslator };
+        translators.Shuffle();
+        var badTranslator = new AggregateTranslator(translators);
+
+        var languageChain = new List<ILanguage>();
+        const int chainCount = 8;
+        ILanguage? sourceLanguage = null;
+        for (int i = 0; i < chainCount; i++)
+        {
+            ILanguage targetLanguage;
+            if (i == chainCount - 1)
+            {
+                targetLanguage = sourceLanguage!;
+            }
+            else
+            {
+                // Get unique and random languages.
+                do
+                {
+                    targetLanguage = _lazyFilteredLanguages.Value[Random.Shared.Next(_lazyFilteredLanguages.Value.Length)];
+                } while (languageChain.Contains(targetLanguage));
+            }
+
+            // Shift the translators to avoid spamming them and get more variety
+            var last = translators[^1];
+            Array.Copy(translators, 0, translators, 1, translators.Length - 1);
+            translators[0] = last;
+
+            ITranslationResult result;
+            try
+            {
+                _logger.LogInformation("Translating to: {target}", targetLanguage.ISO6391);
+                result = await badTranslator.TranslateAsync(text, targetLanguage);
+            }
+            catch (Exception e) when (e is TranslatorException or HttpRequestException)
+            {
+                _logger.LogWarning(e, "Error translating");
+                await Context.Interaction.FollowupWarning(e.Message);
+                return;
+            }
+
+            if (i == 0)
+            {
+                sourceLanguage = result.SourceLanguage;
+                _logger.LogDebug("Badtranslator: Original language: {source}", sourceLanguage.ISO6391);
+                languageChain.Add(sourceLanguage);
+            }
+
+            _logger.LogDebug("Badtranslator: Translated from {source} to {target}, Service: {service}", result.SourceLanguage.ISO6391, result.TargetLanguage.ISO6391, result.Service);
+
+            text = result.Translation;
+            languageChain.Add(targetLanguage);
+        }
+
+        string embedText = $"**Language Chain**\n{string.Join(" -> ", languageChain.Select(x => x.ISO6391))}\n\n**Result**\n";
+
+        var embed = new EmbedBuilder()
+            .WithTitle("Bad translator")
+            .WithDescription($"{embedText}{text.Truncate(EmbedBuilder.MaxDescriptionLength - embedText.Length)}")
+            .WithThumbnailUrl(Constants.BadTranslatorLogoUrl)
+            .WithColor(Color.Orange)
+            .Build();
+
+        await FollowupAsync(embed: embed);
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/Moderation.cs b/src/Modules/Moderation.cs
deleted file mode 100644
index ae8c78d..0000000
--- a/src/Modules/Moderation.cs
+++ /dev/null
@@ -1,261 +0,0 @@
-using System;
-using System.Linq;
-using System.Net;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Discord.Net;
-using Fergun.Attributes;
-using Fergun.Attributes.Preconditions;
-using Fergun.Extensions;
-using Fergun.Interactive;
-using Fergun.Services;
-
-namespace Fergun.Modules
-{
-    [Order(2)]
-    [RequireBotPermission(Constants.MinimumRequiredPermissions)]
-    [Ratelimit(Constants.GlobalCommandUsesPerPeriod, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-    [RequireContext(ContextType.Guild, ErrorMessage = "NotSupportedInDM")]
-    public class Moderation : FergunBase
-    {
-        private readonly MessageCacheService _messageCache;
-        private readonly InteractiveService _interactive;
-
-        public Moderation(MessageCacheService messageCache, InteractiveService interactive)
-        {
-            _messageCache ??= messageCache;
-            _interactive ??= interactive;
-        }
-
-        [RequireUserPermission(GuildPermission.BanMembers, ErrorMessage = "UserRequireBanMembers")]
-        [RequireBotPermission(GuildPermission.BanMembers, ErrorMessage = "BotRequireBanMembers")]
-        [Command("ban")]
-        [Summary("banSummary")]
-        [Alias("hardban")]
-        [Example("Fergun#6839")]
-        public async Task<RuntimeResult> Ban([Summary("banParam1"), RequireLowerHierarchy("UserNotLowerHierarchy")] IUser user,
-            [Remainder, Summary("banParam2")] string reason = null)
-        {
-            if (user.Id == Context.User.Id)
-            {
-                await ReplyAsync(Locate("BanSameUser"));
-                return FergunResult.FromSuccess();
-            }
-            if (await Context.Guild.GetBanAsync(user) != null)
-            {
-                return FergunResult.FromError(Locate("AlreadyBanned"));
-            }
-            if (!(user is IGuildUser))
-            {
-                return FergunResult.FromError(Locate("UserNotFound"));
-            }
-
-            await Context.Guild.AddBanAsync(user, 0, reason?.Truncate(512));
-            await SendEmbedAsync(string.Format(Locate("Banned"), user.Mention));
-            return FergunResult.FromSuccess();
-        }
-
-        [RequireUserPermission(ChannelPermission.ManageMessages, ErrorMessage = "UserRequireManageMessages")]
-        [RequireBotPermission(ChannelPermission.ManageMessages, ErrorMessage = "BotRequireManageMessages")]
-        [Command("clear", RunMode = RunMode.Async)]
-        [Summary("clearSummary")]
-        [Alias("purge", "prune")]
-        [Remarks("clearRemarks")]
-        [Example("10")]
-        public async Task<RuntimeResult> Clear([Summary("clearParam1")] int count,
-            [Remainder, Summary("clearParam2")] IUser user = null)
-        {
-            count = Math.Min(count, DiscordConfig.MaxMessagesPerBatch);
-            if (count < 1)
-            {
-                return FergunResult.FromError(string.Format(Locate("NumberOutOfIndex"), 1, DiscordConfig.MaxMessagesPerBatch));
-            }
-
-            var messages = await Context.Channel.GetMessagesAsync(_messageCache, Context.Message, Direction.Before, count).Flatten().ToListAsync();
-
-            // Get the total message count before being filtered.
-            int totalMessages = messages.Count;
-
-            if (totalMessages == 0)
-            {
-                return FergunResult.FromError(Locate("NothingToDelete"));
-            }
-
-            if (user != null)
-            {
-                // Get messages by user
-                messages.RemoveAll(x => x.Author.Id != user.Id);
-                if (messages.Count == 0)
-                {
-                    return FergunResult.FromError(string.Format(Locate("ClearNotFound"), user.Mention, count));
-                }
-            }
-
-            // Get messages younger than 2 weeks
-            messages.RemoveAll(x => x.CreatedAt <= DateTimeOffset.UtcNow.Subtract(TimeSpan.FromDays(14)));
-            if (messages.Count == 0)
-            {
-                return FergunResult.FromError(Locate("MessagesOlderThan2Weeks"));
-            }
-
-            try
-            {
-                await ((ITextChannel)Context.Channel).DeleteMessagesAsync(messages.Append(Context.Message));
-            }
-            catch (HttpException e) when (e.HttpCode == HttpStatusCode.NotFound) { }
-
-            string message = user != null
-                ? string.Format(Locate("DeletedMessagesByUser"), messages.Count, user.Mention)
-                : string.Format(Locate("DeletedMessages"), messages.Count);
-
-            if (totalMessages != messages.Count)
-            {
-                message += "\n" + string.Format(Locate("SomeMessagesNotDeleted"), totalMessages - messages.Count);
-            }
-
-            var builder = new EmbedBuilder
-            {
-                Description = message,
-                Color = new Color(FergunClient.Config.EmbedColor)
-            };
-
-            _ = _interactive.DelayedSendMessageAndDeleteAsync(Context.Channel, null, TimeSpan.FromSeconds(5), embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        [RequireUserPermission(GuildPermission.BanMembers, ErrorMessage = "UserRequireBanMembers")]
-        [RequireBotPermission(GuildPermission.BanMembers, ErrorMessage = "BotRequireBanMembers")]
-        [Command("hackban")]
-        [Summary("hackbanSummary")]
-        [Example("666963870385923507 spam")]
-        public async Task<RuntimeResult> Hackban([Summary("hackbanParam1")] ulong userId,
-            [Remainder, Summary("hackbanParam2")] string reason = null)
-        {
-            if (await Context.Guild.GetBanAsync(userId) != null)
-            {
-                return FergunResult.FromError(Locate("AlreadyBanned"));
-            }
-            var user = await Context.Client.Rest.GetUserAsync(userId);
-            if (user == null)
-            {
-                return FergunResult.FromError(Locate("InvalidID"));
-            }
-            var guildUser = await Context.Client.Rest.GetGuildUserAsync(Context.Guild.Id, userId);
-            if (guildUser != null && Context.Guild.CurrentUser.Hierarchy <= guildUser.GetHierarchy())
-            {
-                return FergunResult.FromError(Locate("UserNotLowerHierarchy"));
-            }
-
-            await Context.Guild.AddBanAsync(userId, 0, reason?.Truncate(512));
-            await SendEmbedAsync(string.Format(Locate("Hackbanned"), user));
-            return FergunResult.FromSuccess();
-        }
-
-        [RequireUserPermission(GuildPermission.KickMembers, ErrorMessage = "UserRequireKickMembers")]
-        [RequireBotPermission(GuildPermission.KickMembers, ErrorMessage = "BotRequireKickMembers")]
-        [Command("kick")]
-        [Summary("kickSummary")]
-        [Example("Fergun#6839 test")]
-        public async Task<RuntimeResult> Kick(
-            [Summary("kickParam1"), RequireLowerHierarchy("UserNotLowerHierarchy")] IUser user,
-            [Remainder, Summary("kickParam2")] string reason = null)
-        {
-            if (!(user is IGuildUser guildUser))
-            {
-                return FergunResult.FromError(Locate("UserNotFound"));
-            }
-            await guildUser.KickAsync(reason?.Truncate(512));
-            await SendEmbedAsync(string.Format(Locate("Kicked"), user.Mention));
-
-            return FergunResult.FromSuccess();
-        }
-
-        [RequireUserPermission(GuildPermission.ManageNicknames, ErrorMessage = "UserRequireManageNicknames")]
-        [RequireBotPermission(GuildPermission.ManageNicknames, ErrorMessage = "BotRequireManageNicknames")]
-        [Command("nick")]
-        [Summary("nickSummary")]
-        [Alias("nickname")]
-        [Example("Fergun#6839 fer")]
-        public async Task<RuntimeResult> Nick(
-            [Summary("nickParam1"), RequireLowerHierarchy("UserNotLowerHierarchy")] IUser user,
-            [Remainder, Summary("nickParam2")] string newNick = null)
-        {
-            if (!(user is IGuildUser guildUser))
-            {
-                return FergunResult.FromError(Locate("UserNotFound"));
-            }
-
-            newNick = newNick?.Truncate(32);
-            if (guildUser.Nickname == newNick)
-            {
-                return newNick == null
-                    ? FergunResult.FromSuccess()
-                    : FergunResult.FromError(Locate("CurrentNewNickEqual"));
-            }
-
-            await guildUser.ModifyAsync(x => x.Nickname = newNick);
-            if (Context.Guild.CurrentUser.GuildPermissions.AddReactions)
-            {
-                await Context.Message.AddReactionAsync(new Emoji("\u2705"));
-            }
-            return FergunResult.FromSuccess();
-        }
-
-        [RequireUserPermission(GuildPermission.BanMembers, ErrorMessage = "UserRequireBanMembers")]
-        [RequireBotPermission(GuildPermission.BanMembers, ErrorMessage = "BotRequireBanMembers")]
-        [Command("softban")]
-        [Summary("softbanSummary")]
-        [Example("Fergun#6839 10 test")]
-        public async Task<RuntimeResult> Softban(
-            [Summary("softbanParam1"), RequireLowerHierarchy("UserNotLowerHierarchy", true)] IUser user,
-            [Summary("softbanParam2")] uint days = 7,
-            [Remainder, Summary("softbanParam3")] string reason = null)
-        {
-            if (user.Id == Context.User.Id)
-            {
-                await ReplyAsync(Locate("SoftbanSameUser"));
-                return FergunResult.FromSuccess();
-            }
-
-            if (await Context.Guild.GetBanAsync(user) != null)
-            {
-                return FergunResult.FromError(Locate("AlreadyBanned"));
-            }
-            if (user is IGuildUser guildUser && Context.Guild.CurrentUser.Hierarchy <= guildUser.GetHierarchy())
-            {
-                return FergunResult.FromError(Locate("UserNotLowerHierarchy"));
-            }
-            if (days > 7)
-            {
-                return FergunResult.FromError(string.Format(Locate("MustBeLowerThan"), nameof(days), 7));
-            }
-
-            await Context.Guild.AddBanAsync(user.Id, (int)days, reason?.Truncate(512));
-            await Context.Guild.RemoveBanAsync(user.Id);
-            await SendEmbedAsync(string.Format(Locate("Softbanned"), user));
-
-            return FergunResult.FromSuccess();
-        }
-
-        [RequireUserPermission(GuildPermission.BanMembers, ErrorMessage = "UserRequireBanMembers")]
-        [RequireBotPermission(GuildPermission.BanMembers, ErrorMessage = "BotRequireBanMembers")]
-        [Command("unban")]
-        [Summary("unbanSummary")]
-        [Example("666963870385923507")]
-        public async Task<RuntimeResult> Unban([Summary("unbanParam1")] ulong userId)
-        {
-            var ban = await Context.Guild.GetBanAsync(userId);
-            if (ban == null)
-            {
-                return FergunResult.FromError(Locate("UserNotBanned"));
-            }
-
-            await Context.Guild.RemoveBanAsync(userId);
-            await SendEmbedAsync(string.Format(Locate("Unbanned"), ban.User));
-
-            return FergunResult.FromSuccess();
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Modules/Music.cs b/src/Modules/Music.cs
deleted file mode 100644
index d010cdc..0000000
--- a/src/Modules/Music.cs
+++ /dev/null
@@ -1,410 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net.Http;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Discord.WebSocket;
-using Fergun.APIs.Genius;
-using Fergun.Attributes;
-using Fergun.Attributes.Preconditions;
-using Fergun.Extensions;
-using Fergun.Interactive;
-using Fergun.Interactive.Pagination;
-using Fergun.Interactive.Selection;
-using Fergun.Services;
-using Fergun.Utils;
-using Victoria;
-using Victoria.Enums;
-
-namespace Fergun.Modules
-{
-    [Order(3)]
-    [RequireBotPermission(Constants.MinimumRequiredPermissions)]
-    [Ratelimit(Constants.GlobalCommandUsesPerPeriod, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-    [UserMustBeInVoice("lyrics", "spotify")]
-    public class Music : FergunBase
-    {
-        private static GeniusApi _geniusApi;
-        private readonly MusicService _musicService;
-        private readonly LogService _logService;
-        private readonly MessageCacheService _messageCache;
-        private readonly LavaNode _lavaNode;
-
-        public Music(MusicService musicService, LogService logService, MessageCacheService messageCache, LavaNode lavaNode)
-        {
-            _musicService = musicService;
-            _logService = logService;
-            _messageCache = messageCache;
-            _lavaNode = lavaNode;
-            _geniusApi ??= new GeniusApi(FergunClient.Config.GeniusApiToken);
-        }
-
-        [RequireBotPermission(GuildPermission.Connect, ErrorMessage = "BotRequireConnect")]
-        [Command("join", RunMode = RunMode.Async)]
-        [Summary("joinSummary")]
-        public async Task<RuntimeResult> Join()
-        {
-            await SendEmbedAsync(await _musicService.JoinAsync(Context.Guild, ((SocketGuildUser)Context.User).VoiceChannel, Context.Channel as ITextChannel));
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("leave")]
-        [Summary("leaveSummary")]
-        [Alias("disconnect", "quit", "exit")]
-        public async Task Leave()
-        {
-            var user = (SocketGuildUser)Context.User;
-            bool connected = await _musicService.LeaveAsync(Context.Guild, user.VoiceChannel);
-            await SendEmbedAsync(!connected ? Locate("BotNotConnected") : string.Format(Locate("LeftVC"), Format.Bold(user.VoiceChannel.Name)));
-        }
-
-        [Command("loop")]
-        [Summary("loopSummary")]
-        [Example("10")]
-        public async Task Loop([Summary("loopParam1")] uint? count = null)
-        {
-            await SendEmbedAsync(_musicService.Loop(count, Context.Guild, Context.Channel as ITextChannel));
-        }
-
-        [LongRunning]
-        [Command("lyrics", RunMode = RunMode.Async)]
-        [Summary("lyricsSummary")]
-        [Alias("l")]
-        [Example("never gonna give you up")]
-        public async Task<RuntimeResult> Lyrics([Remainder, Summary("lyricsParam1")] string query = null)
-        {
-            if (string.IsNullOrEmpty(FergunClient.Config.GeniusApiToken))
-            {
-                return FergunResult.FromError(string.Format(Locate("ValueNotSetInConfig"), nameof(FergunConfig.GeniusApiToken)));
-            }
-
-            bool keepHeaders = false;
-            if (string.IsNullOrWhiteSpace(query))
-            {
-                bool hasPlayer = _lavaNode.TryGetPlayer(Context.Guild, out var player);
-                if (hasPlayer && player.PlayerState == PlayerState.Playing)
-                {
-                    query = player.Track.Title.Contains(player.Track.Author, StringComparison.OrdinalIgnoreCase)
-                        ? player.Track.Title
-                        : $"{player.Track.Author} - {player.Track.Title}";
-                }
-                else
-                {
-                    var spotify = Context.User.Activities?.OfType<SpotifyGame>().FirstOrDefault();
-                    if (spotify == null)
-                    {
-                        return FergunResult.FromError(Locate("LyricsQueryNotPassed"));
-                    }
-                    query = $"{string.Join(", ", spotify.Artists)} - {spotify.TrackTitle}";
-                }
-            }
-            else if (query.EndsWith("-headers", StringComparison.OrdinalIgnoreCase))
-            {
-                query = query.Substring(0, query.Length - 8);
-                keepHeaders = true;
-            }
-
-            query = query.Trim();
-            GeniusResponse genius;
-            try
-            {
-                genius = await _geniusApi.SearchAsync(query);
-            }
-            catch (HttpRequestException e)
-            {
-                return FergunResult.FromError(e.Message);
-            }
-            catch (TaskCanceledException)
-            {
-                return FergunResult.FromError(Locate("RequestTimedOut"));
-            }
-            if (genius.Meta.Status != 200)
-            {
-                return FergunResult.FromError(Locate("AnErrorOccurred"));
-            }
-            if (genius.Response.Hits.Count == 0)
-            {
-                return FergunResult.FromError(string.Format(Locate("LyricsNotFound"), Format.Code(query.Replace("`", string.Empty, StringComparison.OrdinalIgnoreCase))));
-            }
-
-            var result = genius.Response.Hits[0].Result;
-            string lyrics = await CommandUtils.ParseGeniusLyricsAsync(result.Url, keepHeaders);
-
-            if (string.IsNullOrWhiteSpace(lyrics))
-            {
-                return FergunResult.FromError(string.Format(Locate("ErrorParsingLyrics"), Format.Code(query.Replace("`", string.Empty, StringComparison.OrdinalIgnoreCase))));
-            }
-
-            var splitLyrics = lyrics.SplitBySeparatorWithLimit('\n', EmbedBuilder.MaxDescriptionLength).ToArray();
-            string links = $"{Format.Url("Genius", result.Url)} - {Format.Url(Locate("ArtistPage"), genius.Response.Hits[0].Result.PrimaryArtist.Url)}";
-            string paginatorFooter = $"{Locate("LyricsByGenius")} - {Locate("PaginatorFooter")}";
-
-            Task<PageBuilder> GeneratePageAsync(int index)
-            {
-                var pageBuilder = new PageBuilder()
-                    .WithAuthor(Context.User)
-                    .WithColor(new Color(FergunClient.Config.EmbedColor))
-                    .WithTitle(result.FullTitle)
-                    .WithDescription(splitLyrics[index].Truncate(EmbedBuilder.MaxDescriptionLength))
-                    .AddField("Links", links)
-                    .WithFooter(string.Format(paginatorFooter, index + 1, splitLyrics.Length));
-
-                return Task.FromResult(pageBuilder);
-            }
-
-            var paginator = new LazyPaginatorBuilder()
-                .AddUser(Context.User)
-                .WithOptions(CommandUtils.GetFergunPaginatorEmotes(FergunClient.Config))
-                .WithMaxPageIndex(splitLyrics.Length - 1)
-                .WithPageFactory(GeneratePageAsync)
-                .WithFooter(PaginatorFooter.None)
-                .WithActionOnCancellation(ActionOnStop.DisableInput)
-                .WithActionOnTimeout(ActionOnStop.DisableInput)
-                .WithDeletion(DeletionOptions.Valid)
-                .Build();
-
-            _ = SendPaginatorAsync(paginator, Constants.PaginatorTimeout);
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("move")]
-        [Summary("moveSummary")]
-        public async Task Move()
-        {
-            await SendEmbedAsync(await _musicService.MoveAsync(Context.Guild, ((SocketGuildUser)Context.User).VoiceChannel, Context.Channel as ITextChannel));
-        }
-
-        [Command("nowplaying")]
-        [Summary("nowplayingSummary")]
-        [Alias("np")]
-        public async Task NowPlaying()
-        {
-            await SendEmbedAsync(_musicService.GetCurrentTrack(Context.Guild, Context.Channel as ITextChannel));
-        }
-
-        [Command("pause")]
-        [Summary("pauseSummary")]
-        public async Task Pause()
-        {
-            await SendEmbedAsync(await _musicService.PauseOrResumeAsync(Context.Guild, Context.Channel as ITextChannel));
-        }
-
-        [RequireBotPermission(GuildPermission.Speak, ErrorMessage = "BotRequireSpeak")]
-        [LongRunning]
-        [Command("play", RunMode = RunMode.Async)]
-        [Summary("playSummary")]
-        [Alias("p")]
-        [Example("darude sandstorm")]
-        public async Task<RuntimeResult> Play([Remainder, Summary("playParam1")] string query)
-        {
-            var user = (SocketGuildUser)Context.User;
-            string response;
-            IReadOnlyList<LavaTrack> tracks;
-            try
-            {
-                (response, tracks) = await _musicService.PlayAsync(query, Context.Guild, user.VoiceChannel, Context.Channel as ITextChannel);
-            }
-            catch (NullReferenceException e) // Catch nullref caused by bug in Victoria
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Error playing music", e));
-                return FergunResult.FromError(Locate("AnErrorOccurred"));
-            }
-
-            if (tracks == null)
-            {
-                await SendEmbedAsync(response);
-            }
-            else
-            {
-                IUserMessage message = null;
-                LavaTrack selectedTrack;
-                bool trackSelection = GetGuildConfig()?.TrackSelection ?? Constants.TrackSelectionDefault;
-                if (trackSelection)
-                {
-                    string list = "";
-                    int count = Math.Min(Constants.MaxTracksToDisplay, tracks.Count);
-
-                    for (int i = 0; i < count; i++)
-                    {
-                        list += $"{i + 1}. {tracks[i].ToTrackLink()}\n";
-                    }
-
-                    var builder = new EmbedBuilder()
-                        .WithAuthor(user)
-                        .WithTitle(Locate("SelectTrack"))
-                        .WithDescription(list)
-                        .WithColor(FergunClient.Config.EmbedColor);
-
-                    var warningBuilder = new EmbedBuilder()
-                        .WithColor(FergunClient.Config.EmbedColor)
-                        .WithDescription($"\u26a0 {Locate("ReplyTimeout")}");
-
-                    var selectionBuilder = new SelectionBuilder<int>()
-                        .WithOptions(Enumerable.Range(0, count).ToArray())
-                        .WithStringConverter(x => (x + 1).ToString())
-                        .WithSelectionPage(PageBuilder.FromEmbedBuilder(builder))
-                        .WithTimeoutPage(PageBuilder.FromEmbedBuilder(warningBuilder))
-                        .WithActionOnTimeout(ActionOnStop.ModifyMessage | ActionOnStop.DisableInput)
-                        .AddUser(Context.User);
-
-                    var result = await SendSelectionAsync(selectionBuilder.Build(), TimeSpan.FromMinutes(1));
-
-                    if (!result.IsSuccess)
-                    {
-                        return FergunResult.FromError(Locate("ReplyTimeout"), true);
-                    }
-
-                    selectedTrack = tracks[result.Value];
-                    message = result.Message;
-                }
-                else
-                {
-                    selectedTrack = tracks[0];
-                }
-                var result2 = await _musicService.PlayTrack(Context.Guild, user.VoiceChannel, Context.Channel as ITextChannel, selectedTrack);
-                var builder2 = new EmbedBuilder()
-                    .WithDescription(result2)
-                    .WithColor(FergunClient.Config.EmbedColor);
-
-                if (message == null)
-                {
-                    await ReplyAsync(embed: builder2.Build());
-                }
-                else
-                {
-                    await message.ModifyOrResendAsync(embed: builder2.Build(), cache: _messageCache);
-                }
-            }
-            return FergunResult.FromSuccess();
-        }
-
-        [LongRunning]
-        [Command("spotify", RunMode = RunMode.Async)]
-        [Summary("spotifySummary")]
-        [Example("Fergun#6839")]
-        public async Task<RuntimeResult> Spotify([Remainder, Summary("spotifyParam1")] IUser user = null)
-        {
-            if (!FergunClient.Config.PresenceIntent)
-            {
-                return FergunResult.FromError(Locate("NoPresenceIntent"));
-            }
-
-            user ??= Context.User;
-            var spotify = user.Activities?.OfType<SpotifyGame>().FirstOrDefault();
-            if (spotify == null)
-            {
-                return FergunResult.FromError(string.Format(Locate("NoSpotifyStatus"), user));
-            }
-
-            string lyricsUrl = "?";
-            if (!string.IsNullOrEmpty(FergunClient.Config.GeniusApiToken))
-            {
-                try
-                {
-                    var genius = await _geniusApi.SearchAsync($"{string.Join(", ", spotify.Artists)} - {spotify.TrackTitle}");
-                    if (genius.Meta.Status == 200 && genius.Response.Hits.Count > 0)
-                    {
-                        lyricsUrl = Format.Url(Locate("ClickHere"), genius.Response.Hits[0].Result.Url);
-                    }
-                }
-                catch (HttpRequestException e)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Spotify: Error calling Genius API", e));
-                }
-            }
-
-            var builder = new EmbedBuilder()
-                .WithAuthor("Spotify", Constants.SpotifyLogoUrl)
-                .WithThumbnailUrl(spotify.AlbumArtUrl)
-
-                .AddField(Locate("Title"), spotify.TrackTitle, true)
-                .AddField(Locate("Artist"), string.Join(", ", spotify.Artists), true)
-                .AddField("\u200b", "\u200b", true)
-
-                .AddField(Locate("Album"), spotify.AlbumTitle, true)
-                .AddField(Locate("Duration"), spotify.Duration?.ToShortForm() ?? "?", true)
-                .AddField("\u200b", "\u200b", true)
-
-                .AddField(Locate("Lyrics"), lyricsUrl, true)
-                .AddField(Locate("TrackUrl"), Format.Url(Locate("ClickHere"), spotify.TrackUrl), true)
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("queue")]
-        [Summary("queueSummary")]
-        [Alias("q")]
-        public async Task Queue()
-        {
-            await SendEmbedAsync(_musicService.GetQueue(Context.Guild, Context.Channel as ITextChannel));
-        }
-
-        [Command("remove")]
-        [Summary("removeSummary")]
-        [Alias("delete")]
-        [Example("2")]
-        public async Task Remove([Summary("removeParam1")] int index)
-        {
-            await SendEmbedAsync(_musicService.RemoveAt(Context.Guild, Context.Channel as ITextChannel, index));
-        }
-
-        [Command("replay")]
-        [Summary("replaySummary")]
-        public async Task Replay()
-        {
-            await SendEmbedAsync(await _musicService.ReplayAsync(Context.Guild, Context.Channel as ITextChannel));
-        }
-
-        [Command("resume")]
-        [Summary("resumeSummary")]
-        public async Task Resume()
-        {
-            await SendEmbedAsync(await _musicService.ResumeAsync(Context.Guild, Context.Channel as ITextChannel));
-        }
-
-        [Command("seek")]
-        [Summary("seekSummary")]
-        [Alias("skipto", "goto")]
-        [Example("3:14")]
-        public async Task Seek([Summary("seekParam1")] string time)
-        {
-            await SendEmbedAsync(await _musicService.SeekAsync(Context.Guild, Context.Channel as ITextChannel, time));
-        }
-
-        [Command("shuffle")]
-        [Summary("shuffleSummary")]
-        public async Task Shuffle()
-        {
-            await SendEmbedAsync(_musicService.Shuffle(Context.Guild, Context.Channel as ITextChannel));
-        }
-
-        [Command("skip")]
-        [Summary("skipSummary")]
-        [Alias("s")]
-        public async Task Skip()
-        {
-            await SendEmbedAsync(await _musicService.SkipAsync(Context.Guild, Context.Channel as ITextChannel));
-        }
-
-        [Command("stop")]
-        [Summary("stopSummary")]
-        public async Task Stop()
-        {
-            await SendEmbedAsync(await _musicService.StopAsync(Context.Guild, Context.Channel as ITextChannel));
-        }
-
-        [Command("volume")]
-        [Summary("volumeSummary")]
-        [Example("70")]
-        public async Task Volume([Summary("volumeParam1")] int volume)
-        {
-            await SendEmbedAsync(await _musicService.SetVolumeAsync(volume, Context.Guild, Context.Channel as ITextChannel));
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Modules/Other.cs b/src/Modules/Other.cs
deleted file mode 100644
index b048409..0000000
--- a/src/Modules/Other.cs
+++ /dev/null
@@ -1,963 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.Globalization;
-using System.IO;
-using System.Linq;
-using System.Net.Http;
-using System.Runtime.InteropServices;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Fergun.APIs.OpenTriviaDB;
-using Fergun.Attributes;
-using Fergun.Attributes.Preconditions;
-using Fergun.Extensions;
-using Fergun.Interactive;
-using Fergun.Interactive.Pagination;
-using Fergun.Interactive.Selection;
-using Fergun.Services;
-using Fergun.Utils;
-
-namespace Fergun.Modules
-{
-    [Order(5)]
-    [RequireBotPermission(Constants.MinimumRequiredPermissions)]
-    [Ratelimit(Constants.GlobalCommandUsesPerPeriod, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-    public class Other : FergunBase
-    {
-        private static readonly string[] _triviaCategories = Enum.GetNames(typeof(QuestionCategory)).Select(x => x.ToLowerInvariant()).Skip(1).ToArray();
-        private static readonly string[] _triviaDifficulties = Enum.GetNames(typeof(QuestionDifficulty)).Select(x => x.ToLowerInvariant()).ToArray();
-        private static readonly HttpClient _httpClient = new HttpClient { Timeout = Constants.HttpClientTimeout };
-        private readonly CommandService _cmdService;
-        private readonly LogService _logService;
-        private readonly MessageCacheService _messageCache;
-        private readonly InteractiveService _interactive;
-
-        public Other(CommandService commands, LogService logService, MessageCacheService messageCache, InteractiveService interactive)
-        {
-            _cmdService ??= commands;
-            _logService ??= logService;
-            _messageCache ??= messageCache;
-            _interactive ??= interactive;
-        }
-
-        [Command("changelog")]
-        [Summary("changelogSummary")]
-        [Alias("update")]
-        public async Task<RuntimeResult> Changelog()
-        {
-            var builder = new EmbedBuilder()
-                .WithTitle("Fergun Changelog")
-                .WithDescription(Constants.Changelog)
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        [LongRunning]
-        [Command("code", RunMode = RunMode.Async), Ratelimit(1, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-        [Summary("codeSummary")]
-        [Alias("source")]
-        [Example("img")]
-        public async Task<RuntimeResult> Code([Remainder, Summary("codeParam1")] string commandName)
-        {
-            var command = _cmdService.Commands.FirstOrDefault(x => x.Aliases.Any(y => y == commandName.ToLowerInvariant()) && x.Module.Name != Constants.DevelopmentModuleName);
-            if (command == null)
-            {
-                return FergunResult.FromError(string.Format(Locate("CommandNotFound"), GetPrefix()));
-            }
-
-            string link = $"{Constants.GitHubRepository}/raw/master/src/Modules/{command.Module.Name}.cs";
-            string code;
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Code: Downloading code from: {link}"));
-            try
-            {
-                code = await _httpClient.GetStringAsync(new Uri(link));
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error downloading the code for module: {command.Module.Name}", e));
-                return FergunResult.FromError(e.Message);
-            }
-            catch (TaskCanceledException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error downloading the code for module: {command.Module.Name}", e));
-                return FergunResult.FromError(Locate("RequestTimedOut"));
-            }
-
-            // Not the best way to get the line number of a method, but it just works ¯\_(ツ)_/¯
-            bool found = false;
-            var lines = code.Replace("\r", "", StringComparison.OrdinalIgnoreCase).Split('\n');
-            for (int i = 0; i < lines.Length; i++)
-            {
-                if (lines[i].Contains($"[Command(\"{command.Name}\"", StringComparison.OrdinalIgnoreCase))
-                {
-                    found = true;
-                }
-                if (found && lines[i].Contains("public async Task", StringComparison.OrdinalIgnoreCase))
-                {
-                    await ReplyAsync($"{Constants.GitHubRepository}/blob/master/src/Modules/{command.Module.Name}.cs#L{i + 1}");
-                    return FergunResult.FromSuccess();
-                }
-            }
-
-            return FergunResult.FromError(Locate("CouldNotFindLine"));
-        }
-
-        [Command("cmdstats", RunMode = RunMode.Async)]
-        [Summary("cmdstatsSummary")]
-        [Alias("commandstats")]
-        public async Task<RuntimeResult> CmdStats()
-        {
-            var stats = DatabaseConfig.CommandStats.OrderByDescending(x => x.Value);
-            int i = 1;
-            string current = "";
-            var splitStats = new List<string>();
-
-            foreach (var pair in stats)
-            {
-                string command = $"{i}. {Format.Code(pair.Key)}: {pair.Value}\n";
-                if (command.Length + current.Length > EmbedFieldBuilder.MaxFieldValueLength / 2)
-                {
-                    splitStats.Add(current);
-                    current = command;
-                }
-                else
-                {
-                    current += command;
-                }
-                i++;
-            }
-            if (!string.IsNullOrEmpty(current))
-            {
-                splitStats.Add(current);
-            }
-
-            if (splitStats.Count == 0)
-            {
-                return FergunResult.FromError(Locate("AnErrorOccurred"));
-            }
-
-            string creationDate = Context.Client.CurrentUser.CreatedAt.ToDiscordTimestamp('D');
-            string commandStatsInfo = string.Format(Locate("CommandStatsInfo"), creationDate);
-            string paginatorFooter = Locate("PaginatorFooter");
-
-            Task<PageBuilder> GeneratePageAsync(int index)
-            {
-                var pageBuilder = new PageBuilder()
-                    .WithAuthor(Context.User)
-                    .WithColor(new Color(FergunClient.Config.EmbedColor))
-                    .WithTitle(commandStatsInfo)
-                    .WithDescription(splitStats[index])
-                    .WithFooter(string.Format(paginatorFooter, index + 1, splitStats.Count));
-
-                return Task.FromResult(pageBuilder);
-            }
-
-            var paginator = new LazyPaginatorBuilder()
-                .AddUser(Context.User)
-                .WithOptions(CommandUtils.GetFergunPaginatorEmotes(FergunClient.Config))
-                .WithMaxPageIndex(splitStats.Count - 1)
-                .WithPageFactory(GeneratePageAsync)
-                .WithFooter(PaginatorFooter.None)
-                .WithActionOnCancellation(ActionOnStop.DisableInput)
-                .WithActionOnTimeout(ActionOnStop.DisableInput)
-                .WithDeletion(DeletionOptions.Valid)
-                .Build();
-
-            _ = SendPaginatorAsync(paginator, Constants.PaginatorTimeout);
-
-            await Task.CompletedTask;
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("cringe")]
-        [Summary("cringeSummary")]
-        public async Task Cringe()
-        {
-            await ReplyAsync("https://cdn.discordapp.com/attachments/838832564583661638/838834775417421874/cringe.mp4");
-        }
-
-        [AlwaysEnabled]
-        [RequireContext(ContextType.Guild, ErrorMessage = "NotSupportedInDM")]
-        [RequireUserPermission(GuildPermission.ManageGuild, ErrorMessage = "UserRequireManageServer")]
-        [Command("disable", RunMode = RunMode.Async)]
-        [Summary("disableSummary")]
-        [Example("img")]
-        public async Task<RuntimeResult> Disable([Remainder, Summary("disableParam1")] string commandName)
-        {
-            var command = _cmdService.Commands.FirstOrDefault(x => x.Aliases.Any(y => y == commandName.ToLowerInvariant()) && x.Module.Name != Constants.DevelopmentModuleName);
-            if (command != null)
-            {
-                if (command.Attributes.Concat(command.Module.Attributes).Any(x => x is AlwaysEnabledAttribute))
-                {
-                    return FergunResult.FromError(string.Format(Locate("NonDisableable"), Format.Code(command.Name)));
-                }
-            }
-            else
-            {
-                return FergunResult.FromError(string.Format(Locate("CommandNotFound"), GetPrefix()));
-            }
-
-            string complete = command.Module.Group == null ? command.Name : $"{command.Module.Group} {command.Name}";
-            var guild = GetGuildConfig() ?? new GuildConfig(Context.Guild.Id);
-            if (guild.DisabledCommands.Contains(complete))
-            {
-                return FergunResult.FromError(string.Format(Locate("AlreadyDisabled"), Format.Code(complete)));
-            }
-
-            guild.DisabledCommands.Add(complete);
-            FergunClient.Database.InsertOrUpdateDocument(Constants.GuildConfigCollection, guild);
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Disable: Disabled command \"{complete}\" in server {Context.Guild.Id}."));
-
-            await SendEmbedAsync("\u2705 " + string.Format(Locate("CommandDisabled"), Format.Code(complete)));
-
-            return FergunResult.FromSuccess();
-        }
-
-        [AlwaysEnabled]
-        [RequireContext(ContextType.Guild, ErrorMessage = "NotSupportedInDM")]
-        [RequireUserPermission(GuildPermission.ManageGuild, ErrorMessage = "UserRequireManageServer")]
-        [Command("enable", RunMode = RunMode.Async)]
-        [Summary("enableSummary")]
-        [Example("img")]
-        public async Task<RuntimeResult> Enable([Remainder, Summary("enableParam1")] string commandName)
-        {
-            var command = _cmdService.Commands.FirstOrDefault(x => x.Aliases.Any(y => y == commandName.ToLowerInvariant()) && x.Module.Name != Constants.DevelopmentModuleName);
-            if (command != null)
-            {
-                if (command.Attributes.Concat(command.Module.Attributes).Any(x => x is AlwaysEnabledAttribute))
-                {
-                    return FergunResult.FromError(string.Format(Locate("AlreadyEnabled"), Format.Code(command.Name)));
-                }
-            }
-            else
-            {
-                return FergunResult.FromError(string.Format(Locate("CommandNotFound"), GetPrefix()));
-            }
-
-            string complete = command.Module.Group == null ? command.Name : $"{command.Module.Group} {command.Name}";
-            var guild = GetGuildConfig() ?? new GuildConfig(Context.Guild.Id);
-            if (guild.DisabledCommands.Contains(complete))
-            {
-                guild.DisabledCommands.Remove(complete);
-                FergunClient.Database.InsertOrUpdateDocument(Constants.GuildConfigCollection, guild);
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Enable: Enabled command \"{complete}\" in server {Context.Guild.Id}."));
-
-                await SendEmbedAsync("\u2705 " + string.Format(Locate("CommandEnabled"), Format.Code(complete)));
-            }
-            else
-            {
-                return FergunResult.FromError(string.Format(Locate("AlreadyEnabled"), Format.Code(complete)));
-            }
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("inspirobot")]
-        [Summary("inspirobotSummary")]
-        public async Task InspiroBot()
-        {
-            string img = await _httpClient.GetStringAsync("https://inspirobot.me/api?generate=true");
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Inspirobot: Generated url: {img}"));
-
-            var builder = new EmbedBuilder()
-                .WithTitle("InspiroBot")
-                .WithImageUrl(img)
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-        }
-
-        [Command("invite")]
-        [Summary("inviteSummary")]
-        public async Task<RuntimeResult> Invite()
-        {
-            if (FergunClient.IsDebugMode)
-            {
-                return FergunResult.FromError("No");
-            }
-
-            await SendEmbedAsync(Format.Url(Locate("InviteLink"), FergunClient.InviteLink));
-            return FergunResult.FromSuccess();
-        }
-
-        [RequireContext(ContextType.Guild, ErrorMessage = "NotSupportedInDM")]
-        [RequireUserPermission(GuildPermission.ManageGuild, ErrorMessage = "UserRequireManageServer")]
-        [Command("language", RunMode = RunMode.Async), Ratelimit(2, 0.5, Measure.Minutes)]
-        [Summary("languageSummary")]
-        [Alias("lang")]
-        public async Task<RuntimeResult> Language()
-        {
-            if (FergunClient.Languages.Count <= 1)
-            {
-                return FergunResult.FromError(Locate("NoAvailableLanguages"));
-            }
-
-            var guild = GetGuildConfig() ?? new GuildConfig(Context.Guild.Id);
-
-            var builder = new EmbedBuilder()
-                .WithTitle(Locate("LanguageSelection"))
-                .WithDescription(Locate("LanguagePrompt"))
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            var warningBuilder = new EmbedBuilder()
-                .WithColor(FergunClient.Config.EmbedColor)
-                .WithDescription($"\u26a0 {Locate("ReplyTimeout")}");
-
-            var selectionBuilder = new LanguageSelectionBuilder()
-                .WithOptions(FergunClient.Languages)
-                .AddUser(Context.User)
-                .WithGuildLanguage(GetLanguage())
-                .WithActionOnSuccess(ActionOnStop.DeleteInput)
-                .WithActionOnTimeout(ActionOnStop.ModifyMessage | ActionOnStop.DeleteInput)
-                .WithSelectionPage(PageBuilder.FromEmbedBuilder(builder))
-                .WithTimeoutPage(PageBuilder.FromEmbedBuilder(warningBuilder));
-
-            var result = await SendSelectionAsync(selectionBuilder.Build(), TimeSpan.FromMinutes(1));
-
-            if (!result.IsSuccess)
-            {
-                return FergunResult.FromError(Locate("ReplyTimeout"), true);
-            }
-
-            guild.Language = result.Value.Key;
-            FergunClient.Database.InsertOrUpdateDocument(Constants.GuildConfigCollection, guild);
-
-            builder.WithTitle(Locate("LanguageSelection"))
-                .WithDescription($"✅ {Locate("NewLanguage")}");
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Language: Updated language to: \"{guild.Language}\" in {Context.Guild.Name}"));
-
-            await result.Message.ModifyAsync(x => x.Embed = builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        [AlwaysEnabled]
-        [RequireContext(ContextType.Guild, ErrorMessage = "NotSupportedInDM")]
-        [RequireUserPermission(GuildPermission.ManageGuild, ErrorMessage = "UserRequireManageServer")]
-        [Command("prefix")]
-        [Summary("prefixSummary")]
-        [Alias("setprefix")]
-        [Example("!")]
-        public async Task<RuntimeResult> Prefix([Summary("prefixParam1")] string newPrefix)
-        {
-            if (FergunClient.IsDebugMode)
-            {
-                return FergunResult.FromError("No");
-            }
-
-            if (newPrefix == GetPrefix())
-            {
-                return FergunResult.FromError(Locate("PrefixSameCurrentTarget"));
-            }
-            if (newPrefix.Length > Constants.MaxPrefixLength)
-            {
-                return FergunResult.FromError(string.Format(Locate("PrefixTooLarge"), Constants.MaxPrefixLength));
-            }
-
-            // null prefix = use the global prefix
-            var guild = GetGuildConfig() ?? new GuildConfig(Context.Guild.Id);
-            guild.Prefix = newPrefix == DatabaseConfig.GlobalPrefix ? null : newPrefix;
-
-            FergunClient.Database.InsertOrUpdateDocument(Constants.GuildConfigCollection, guild);
-            GuildUtils.PrefixCache[Context.Guild.Id] = newPrefix;
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Prefix: Updated prefix to: \"{newPrefix}\" in {Context.Guild.Name}"));
-
-            await SendEmbedAsync(string.Format(Locate("NewPrefix"), newPrefix));
-            return FergunResult.FromSuccess();
-        }
-
-        [AlwaysEnabled]
-        [LongRunning]
-        [Command("privacy", RunMode = RunMode.Async), Ratelimit(1, 1, Measure.Minutes)]
-        [Summary("privacySummary")]
-        public async Task<RuntimeResult> Privacy()
-        {
-            string listToShow = "";
-            string[] configList = Locate("PrivacyConfigList").Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.None);
-            for (int i = 0; i < configList.Length; i++)
-            {
-                listToShow += $"**{i + 1}.** {configList[i]}\n";
-            }
-
-            var userConfig = GuildUtils.UserConfigCache.GetValueOrDefault(Context.User.Id, new UserConfig(Context.User.Id));
-
-            string valueList = Locate(userConfig.IsOptedOutSnipe);
-
-            var builder = new EmbedBuilder()
-                .WithTitle(Locate("PrivacyPolicy"))
-                .AddField(Locate("WhatDataWeCollect"), Locate("WhatDataWeCollectList"))
-                .AddField(Locate("WhenWeCollectData"), Locate("WhenWeCollectDataList"))
-                .AddField(Locate("PrivacyConfig"), Locate("PrivacyConfigInfo"))
-                .AddField(Locate("Option"), listToShow, true)
-                .AddField(Locate("Value"), valueList, true)
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            var selection = new EmoteSelectionBuilder<int>()
-                .AddUser(Context.User)
-                .AddOption(new Emoji("1\ufe0f\u20e3"), 1)
-                .AddOption(new Emoji("❌"), -1)
-                .WithAllowCancel(true)
-                .WithActionOnSuccess(ActionOnStop.DisableInput)
-                .WithActionOnTimeout(ActionOnStop.DisableInput)
-                .WithActionOnCancellation(ActionOnStop.DisableInput)
-                .WithSelectionPage(PageBuilder.FromEmbedBuilder(builder))
-                .Build();
-
-            var result = await SendSelectionAsync(selection, TimeSpan.FromMinutes(2));
-
-            if (!result.IsSuccess) return FergunResult.FromSuccess();
-
-            userConfig = GuildUtils.UserConfigCache.GetValueOrDefault(Context.User.Id, new UserConfig(Context.User.Id));
-            userConfig.IsOptedOutSnipe = !userConfig.IsOptedOutSnipe;
-            FergunClient.Database.InsertOrUpdateDocument(Constants.UserConfigCollection, userConfig);
-            GuildUtils.UserConfigCache[Context.User.Id] = userConfig;
-
-            valueList = Locate(userConfig.IsOptedOutSnipe);
-
-            builder.Fields[4] = new EmbedFieldBuilder { Name = Locate("Value"), Value = valueList, IsInline = true };
-
-            await result.Message.ModifyAsync(x => x.Embed = builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("reaction")]
-        [Summary("reactionSummary")]
-        [Alias("react")]
-        [Example(":thonk: 635356699263887700")]
-        public async Task<RuntimeResult> Reaction([Summary("reactionParam1")] string reaction,
-            [Summary("reactionParam2")] ulong? messageId = null)
-        {
-            IMessage msg;
-            if (messageId == null)
-            {
-                msg = Context.Message;
-            }
-            else
-            {
-                msg = await Context.Channel.GetMessageAsync(_messageCache, messageId.Value);
-                if (msg == null)
-                {
-                    return FergunResult.FromError(Locate("InvalidMessageID"));
-                }
-            }
-            try
-            {
-                IEmote emote;
-                if (reaction.Length > 2)
-                {
-                    if (!Emote.TryParse(reaction, out var tempEmote))
-                    {
-                        return FergunResult.FromError(Locate("InvalidReaction"));
-                    }
-                    emote = tempEmote;
-                }
-                else
-                {
-                    emote = new Emoji(reaction);
-                }
-                await msg.AddReactionAsync(emote);
-                return FergunResult.FromSuccess();
-            }
-            catch (ArgumentException) // Invalid emote format. (Parameter 'text')
-            {
-                return FergunResult.FromError(Locate("InvalidReaction"));
-            }
-            catch (Discord.Net.HttpException) // The server responded with error 400: BadRequest
-            {
-                return FergunResult.FromError(Locate("InvalidReaction"));
-            }
-        }
-
-        [Command("say", RunMode = RunMode.Async)]
-        [Summary("saySummary")]
-        [Example("hey")]
-        public async Task Say([Remainder, Summary("sayParam1")] string text)
-        {
-            await ReplyAsync(text, allowedMentions: AllowedMentions.None);
-        }
-
-        [LongRunning]
-        [Command("stats", RunMode = RunMode.Async)]
-        [Summary("statsSummary")]
-        [Alias("info", "botinfo")]
-        public async Task Stats()
-        {
-            long temp;
-            var owner = (await Context.Client.GetApplicationInfoAsync()).Owner;
-            var cpuUsage = (int)await CommandUtils.GetCpuUsageForProcessAsync();
-            string cpu = null;
-            long? totalRamUsage = null;
-            long processRamUsage = 0;
-            long? totalRam = null;
-            string os = RuntimeInformation.OSDescription;
-
-            if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
-            {
-                // CPU Name
-                if (File.Exists("/proc/cpuinfo"))
-                {
-                    cpu = File.ReadAllLines("/proc/cpuinfo")
-                        .FirstOrDefault(x => x.StartsWith("model name", StringComparison.OrdinalIgnoreCase))?
-                        .Split(':')?
-                        .ElementAtOrDefault(1)?
-                        .Trim();
-                }
-
-                if (string.IsNullOrWhiteSpace(cpu))
-                {
-                    cpu = CommandUtils.RunCommand("lscpu")?
-                        .Split('\n')?
-                        .FirstOrDefault(x => x.StartsWith("model name", StringComparison.OrdinalIgnoreCase))?
-                        .Split(':')?
-                        .ElementAtOrDefault(1)?
-                        .Trim();
-
-                    if (string.IsNullOrWhiteSpace(cpu))
-                    {
-                        cpu = "?";
-                    }
-                }
-
-                // OS Name
-                if (File.Exists("/etc/lsb-release"))
-                {
-                    var distroInfo = File.ReadAllLines("/etc/lsb-release");
-                    os = distroInfo.ElementAtOrDefault(3)?.Split('=').ElementAtOrDefault(1)?.Trim('\"');
-                }
-
-                // Total RAM & total RAM usage
-                var output = CommandUtils.RunCommand("free -m")?.Split(Environment.NewLine);
-                var memory = output?.ElementAtOrDefault(1)?.Split(' ', StringSplitOptions.RemoveEmptyEntries);
-
-                if (long.TryParse(memory?.ElementAtOrDefault(1), out temp)) totalRam = temp;
-                if (long.TryParse(memory?.ElementAtOrDefault(2), out temp)) totalRamUsage = temp;
-
-                // Process RAM usage
-                processRamUsage = Process.GetCurrentProcess().WorkingSet64 / 1024 / 1024;
-            }
-            else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
-            {
-                // CPU Name
-                cpu = CommandUtils.RunCommand("wmic cpu get name")
-                    ?.Trim()
-                    .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
-                    .ElementAtOrDefault(1);
-
-                // Total RAM & total RAM usage
-                var output = CommandUtils.RunCommand("wmic OS get FreePhysicalMemory,TotalVisibleMemorySize /Value")
-                    ?.Trim()
-                    .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
-
-                if (output?.Length > 1)
-                {
-                    long freeRam = 0;
-                    var split = output[0].Split('=', StringSplitOptions.RemoveEmptyEntries);
-                    if (split.Length > 1 && long.TryParse(split[1], out temp))
-                    {
-                        freeRam = temp / 1024;
-                    }
-
-                    split = output[1].Split('=', StringSplitOptions.RemoveEmptyEntries);
-                    if (split.Length > 1 && long.TryParse(split[1], out temp))
-                    {
-                        totalRam = temp / 1024;
-                    }
-
-                    if (totalRam != null && freeRam != 0)
-                    {
-                        totalRamUsage = totalRam - freeRam;
-                    }
-                }
-
-                // Process RAM usage
-                processRamUsage = Process.GetCurrentProcess().PrivateMemorySize64 / 1024 / 1024;
-            }
-            else
-            {
-                // TODO: Get system info from the remaining platforms
-            }
-
-            int totalUsers = 0;
-            foreach (var guild in Context.Client.Guilds)
-            {
-                totalUsers += guild.MemberCount;
-            }
-
-            int totalUsersInShard = 0;
-            int shardId = Context.IsPrivate ? 0 : Context.Client.GetShardIdFor(Context.Guild);
-            foreach (var guild in Context.Client.GetShard(shardId).Guilds)
-            {
-                totalUsersInShard += guild.MemberCount;
-            }
-
-            string version = $"v{Constants.Version}";
-
-            if (Constants.GitHash != null && Constants.GitHash.Length == 7)
-            {
-                version += $" ({Format.Url(Constants.GitHash, $"{Constants.GitHubRepository}/commit/{Constants.GitHash}")})";
-            }
-
-            string libraryName = "Discord.Net";
-
-#if DNETLABS
-            libraryName += " Labs";
-#endif
-
-            var elapsed = DateTimeOffset.UtcNow - FergunClient.Uptime;
-
-            var builder = new EmbedBuilder()
-                .WithTitle("Fergun Stats")
-
-                .AddField(Locate("OperatingSystem"), os, true)
-                .AddField("\u200b", "\u200b", true)
-                .AddField("CPU", cpu, true)
-
-                .AddField(Locate("CPUUsage"), cpuUsage + "%", true)
-                .AddField("\u200b", "\u200b", true)
-                .AddField(Locate("RAMUsage"),
-                    $"{processRamUsage}MB ({(totalRam == null ? 0 : Math.Round((double)processRamUsage / totalRam.Value * 100, 2))}%) " +
-                    $"/ {(totalRamUsage == null || totalRam == null ? "?MB" : $"{totalRamUsage}MB ({Math.Round((double)totalRamUsage.Value / totalRam.Value * 100, 2)}%)")} " +
-                    $"/ {totalRam?.ToString() ?? "?"}MB", true)
-
-                .AddField(Locate("Library"), $"{libraryName}\nv{DiscordConfig.Version}", true)
-                .AddField("\u200b", "\u200b", true)
-                .AddField(Locate("BotVersion"), version, true)
-
-                .AddField(Locate("TotalServers"), $"{Context.Client.Guilds.Count} (Shard: {Context.Client.GetShard(shardId).Guilds.Count})", true)
-                .AddField("\u200b", "\u200b", true)
-                .AddField(Locate("TotalUsers"), $"{totalUsers} (Shard: {totalUsersInShard})", true)
-
-                .AddField("Shard ID", shardId, true)
-                .AddField("\u200b", "\u200b", true)
-                .AddField("Shards", Context.Client.Shards.Count, true)
-
-                .AddField("Uptime", elapsed.ToShortForm2(), true)
-                .AddField("\u200b", "\u200b", true)
-                .AddField(Locate("BotOwner"), owner, true);
-
-            MessageComponent component = null;
-
-            if (!FergunClient.IsDebugMode)
-            {
-                component = CommandUtils.BuildLinks(Context.Channel);
-            }
-            builder.WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build(), component: component);
-        }
-
-        [Command("support")]
-        [Summary("supportSummary")]
-        [Alias("bugs", "contact", "report")]
-        public async Task Support()
-        {
-            var owner = (await Context.Client.GetApplicationInfoAsync()).Owner;
-            if (string.IsNullOrEmpty(FergunClient.Config.SupportServer))
-            {
-                await SendEmbedAsync(string.Format(Locate("ContactInfoNoServer"), owner));
-            }
-            else
-            {
-                await SendEmbedAsync(string.Format(Locate("ContactInfo"), FergunClient.Config.SupportServer, owner));
-            }
-        }
-
-        [Command("tcdne")]
-        [Summary("tcdneSummary")]
-        [Alias("tcde")]
-        public async Task Tcdne()
-        {
-            var builder = new EmbedBuilder
-            {
-                Title = Locate("tcdneSummary"),
-                ImageUrl = $"https://thiscatdoesnotexist.com/?{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
-                Color = new Color(FergunClient.Config.EmbedColor)
-            };
-
-            await ReplyAsync(embed: builder.Build());
-        }
-
-        [Command("tpdne")]
-        [Summary("tpdneSummary")]
-        [Alias("tpde")]
-        public async Task Tpdne()
-        {
-            var builder = new EmbedBuilder
-            {
-                Title = Locate("tpdneSummary"),
-                ImageUrl = $"https://www.thispersondoesnotexist.com/image?{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
-                Color = new Color(FergunClient.Config.EmbedColor)
-            };
-
-            await ReplyAsync(embed: builder.Build());
-        }
-
-        [LongRunning]
-        [Command("trivia", RunMode = RunMode.Async), Ratelimit(1, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-        [Summary("triviaSummary")]
-        [Example("computers")]
-        public async Task<RuntimeResult> Trivia([Summary("triviaParam1")] string category = null)
-        {
-            if (!string.IsNullOrEmpty(category))
-            {
-                category = category.ToLowerInvariant();
-            }
-            var builder = new EmbedBuilder();
-            if (category == "categories")
-            {
-                builder.WithTitle(Locate("CategoryList"))
-                    .WithDescription(string.Join("\n", _triviaCategories))
-                    .WithColor(FergunClient.Config.EmbedColor);
-
-                await ReplyAsync(embed: builder.Build());
-                return FergunResult.FromSuccess();
-            }
-            if (category == "reset")
-            {
-                var owner = (await Context.Client.GetApplicationInfoAsync()).Owner;
-                if (Context.User.Id != owner.Id)
-                {
-                    return FergunResult.FromError(Locate("BotOwnerOnly"));
-                }
-
-                foreach (var user in GuildUtils.UserConfigCache.Values)
-                {
-                    GuildUtils.UserConfigCache[user.Id].TriviaPoints = 0;
-                    user.TriviaPoints = 0;
-                    FergunClient.Database.InsertOrUpdateDocument(Constants.UserConfigCollection, user);
-                }
-                return FergunResult.FromSuccess();
-            }
-
-            var userConfig = GuildUtils.UserConfigCache.GetValueOrDefault(Context.User.Id, new UserConfig(Context.User.Id));
-
-            if (category == "leaderboard" || category == "ranks")
-            {
-                if (Context.IsPrivate)
-                {
-                    return FergunResult.FromError(Locate("NotSupportedInDM"));
-                }
-                string userList = "";
-                string pointsList = "";
-                int totalUsers = 0;
-                foreach (var user in GuildUtils.UserConfigCache.Values.Take(15))
-                {
-                    var guildUser = await Context.Client.Rest.GetGuildUserAsync(Context.Guild.Id, user.Id);
-                    if (guildUser == null) continue;
-
-                    totalUsers++;
-                    userList += $"{guildUser}\n";
-                    pointsList += $"{user.TriviaPoints}\n";
-                }
-
-                builder.WithTitle(Locate("TriviaLeaderboard"))
-                    .AddField(Locate("User"), totalUsers == 0 ? "?" : userList, true)
-                    .AddField(Locate("Points"), totalUsers == 0 ? "?" : pointsList, true)
-                    .WithColor(FergunClient.Config.EmbedColor);
-                await ReplyAsync(embed: builder.Build());
-
-                return FergunResult.FromSuccess();
-            }
-
-            int index = 0;
-            if (category != null)
-            {
-                index = Array.FindIndex(_triviaCategories, x => x == category);
-                if (index <= -1)
-                {
-                    index = 0;
-                }
-            }
-            var trivia = await TriviaApi.RequestQuestionsAsync(1, category == null ? QuestionCategory.Any : (QuestionCategory)(index + 9), encoding: ResponseEncoding.url3986);
-
-            if (trivia.ResponseCode != 0)
-            {
-                return FergunResult.FromError(trivia.ResponseCode == 4 ? Locate("AllQuestionsAnswered") : $"{Locate("TriviaError")} {trivia.ResponseCode}: {(ResponseCode)trivia.ResponseCode}");
-            }
-            var question = trivia.Questions[0];
-
-            var options = new List<string>(question.IncorrectAnswers)
-            {
-                question.CorrectAnswer
-            };
-
-            options.Shuffle();
-
-            string optionsText = "";
-            var selections = new Dictionary<IEmote, int>();
-
-            for (int i = 0; i < options.Count; i++)
-            {
-                optionsText += $"{i + 1}. {Uri.UnescapeDataString(options[i])}\n";
-                selections.Add(new Emoji($"{i + 1}\ufe0f\u20e3"), i);
-            }
-
-            int time = Array.IndexOf(_triviaDifficulties, question.Difficulty) * 5 + (question.Type == "multiple" ? 10 : 5);
-
-            builder.WithAuthor(Context.User)
-                .WithTitle("Trivia")
-                .AddField(Locate("Category"), Uri.UnescapeDataString(question.Category), true)
-                .AddField(Locate("Type"), Uri.UnescapeDataString(question.Type), true)
-                .AddField(Locate("Difficulty"), Uri.UnescapeDataString(question.Difficulty), true)
-                .AddField(Locate("Question"), Uri.UnescapeDataString(question.Question))
-                .AddField(Locate("Options"), optionsText)
-                .WithFooter(string.Format(Locate("TimeLeft"), time))
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            var selection = new EmoteSelectionBuilder<int>()
-                .WithOptions(selections)
-                .WithSelectionPage(PageBuilder.FromEmbedBuilder(builder))
-                .AddUser(Context.User)
-                .Build();
-
-            var result = await SendSelectionAsync(selection, TimeSpan.FromSeconds(time));
-            if (result.IsCanceled) return FergunResult.FromSuccess();
-
-            int option = result.IsSuccess ? result.Value.Value : -1;
-
-            builder = new EmbedBuilder();
-
-            if (option == -1 || options[option] != question.CorrectAnswer)
-            {
-                userConfig.TriviaPoints--;
-                builder.Title = $"❌ {Locate(option == -1 ? "TimesUp" : "Incorrect")}";
-                builder.Description = $"{Locate("Lost1Point")}\n{Locate("TheAnswerIs")} {Format.Code(Uri.UnescapeDataString(question.CorrectAnswer))}";
-            }
-            else
-            {
-                userConfig.TriviaPoints++;
-                builder.Title = $"✅ {Locate("CorrectAnswer")}";
-                builder.Description = Locate("Won1Point");
-            }
-
-            builder.WithFooter($"{Locate("Points")}: {userConfig.TriviaPoints}")
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            FergunClient.Database.InsertOrUpdateDocument(Constants.UserConfigCollection, userConfig);
-            GuildUtils.UserConfigCache[Context.User.Id] = userConfig;
-            await result.Message.ModifyOrResendAsync(embed: builder.Build(), cache: _messageCache);
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("uptime")]
-        [Summary("uptimeSummary")]
-        public async Task Uptime()
-        {
-            var elapsed = DateTimeOffset.UtcNow - FergunClient.Uptime;
-
-            var builder = new EmbedBuilder
-            {
-                Title = "Bot uptime",
-                Description = elapsed.ToShortForm2(),
-                Color = new Color(FergunClient.Config.EmbedColor)
-            };
-
-            await ReplyAsync(embed: builder.Build());
-        }
-
-        [Command("vote")]
-        [Summary("voteSummary")]
-        public async Task<RuntimeResult> Vote()
-        {
-            if (FergunClient.IsDebugMode)
-            {
-                return FergunResult.FromError("No");
-            }
-            if (FergunClient.TopGgBotPage == null)
-            {
-                return FergunResult.FromError(Locate("NowhereToVote"));
-            }
-
-            var builder = new EmbedBuilder
-            {
-                Description = string.Format(Locate("Vote"), $"{FergunClient.TopGgBotPage}/vote"),
-                Color = new Color(FergunClient.Config.EmbedColor)
-            };
-
-            await ReplyAsync(embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-    }
-
-    internal class LanguageSelectionBuilder : BaseSelectionBuilder<LanguageSelection, KeyValuePair<string, CultureInfo>, LanguageSelectionBuilder>
-    {
-        public new IDictionary<string, CultureInfo> Options { get; set; } = new Dictionary<string, CultureInfo>();
-
-        public override InputType InputType { get; set; } = InputType.SelectMenus;
-
-        public override Func<KeyValuePair<string, CultureInfo>, string> StringConverter { get; set; } = x => x.Key;
-
-        public string GuildLanguage { get; set; }
-
-        public LanguageSelectionBuilder WithGuildLanguage(string guildLanguage)
-        {
-            GuildLanguage = guildLanguage;
-            return this;
-        }
-
-        public LanguageSelectionBuilder WithOptions(IDictionary<string, CultureInfo> options)
-        {
-            Options = options;
-            base.Options = Options;
-            return this;
-        }
-
-        public LanguageSelectionBuilder WithOptions(IReadOnlyDictionary<string, CultureInfo> options)
-        {
-            Options = new Dictionary<string, CultureInfo>(options);
-            base.Options = Options;
-            return this;
-        }
-
-        public override LanguageSelection Build() => new(this);
-    }
-
-    /// <summary>
-    /// Custom selection for <see cref="Other.Language"/>
-    /// </summary>
-    internal class LanguageSelection : BaseSelection<KeyValuePair<string, CultureInfo>>
-    {
-        internal LanguageSelection(LanguageSelectionBuilder builder) : base(builder)
-        {
-            GuildLanguage = builder.GuildLanguage;
-        }
-
-        public string GuildLanguage { get; set; }
-
-        public override ComponentBuilder GetOrAddComponents(bool disableAll, ComponentBuilder builder = null)
-        {
-            builder ??= new ComponentBuilder();
-            var options = new List<SelectMenuOptionBuilder>();
-
-            foreach (var selection in Options)
-            {
-                string id = StringConverter?.Invoke(selection);
-
-                var option = new SelectMenuOptionBuilder()
-                    .WithLabel(selection.Value.EnglishName)
-                    .WithDescription(selection.Value.NativeName)
-                    .WithDefault(id == GuildLanguage)
-                    .WithValue(id);
-
-                options.Add(option);
-            }
-
-            var selectMenu = new SelectMenuBuilder()
-                .WithCustomId("foobar")
-                .WithOptions(options);
-
-            builder.WithSelectMenu(selectMenu);
-
-            return builder;
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Modules/Owner.cs b/src/Modules/Owner.cs
deleted file mode 100644
index f418bdf..0000000
--- a/src/Modules/Owner.cs
+++ /dev/null
@@ -1,430 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.Linq;
-using System.Reflection;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Discord.WebSocket;
-using Fergun.Attributes;
-using Fergun.Extensions;
-using Fergun.Interactive;
-using Fergun.Interactive.Pagination;
-using Fergun.Services;
-using Fergun.Utils;
-using Microsoft.CodeAnalysis.CSharp.Scripting;
-using Microsoft.CodeAnalysis.Scripting;
-
-namespace Fergun.Modules
-{
-    [Order(6)]
-    [AlwaysEnabled]
-    [RequireBotPermission(Constants.MinimumRequiredPermissions)]
-    [RequireOwner(ErrorMessage = "BotOwnerOnly")]
-    public class Owner : FergunBase
-    {
-        private static IEnumerable<Assembly> _scriptAssemblies;
-        private static IEnumerable<string> _scriptNamespaces;
-        private static ScriptOptions _scriptOptions;
-        private readonly CommandService _cmdService;
-        private readonly LogService _logService;
-        private readonly MusicService _musicService;
-        private readonly InteractiveService _interactive;
-
-        public Owner(CommandService commands, LogService logService, MusicService musicService, InteractiveService interactive)
-        {
-            _cmdService ??= commands;
-            _logService ??= logService;
-            _musicService ??= musicService;
-            _interactive ??= interactive;
-        }
-
-        [LongRunning]
-        [Command("bash", RunMode = RunMode.Async)]
-        [Summary("bashSummary")]
-        [Alias("sh", "cmd")]
-        [Example("ping discord.com -c 4")]
-        public async Task Bash([Remainder] string command)
-        {
-            string result = CommandUtils.RunCommand(command);
-            if (string.IsNullOrWhiteSpace(result))
-            {
-                await SendEmbedAsync("No output.");
-            }
-            else
-            {
-                await ReplyAsync(Format.Code(result.Truncate(DiscordConfig.MaxMessageSize - 10), "md"));
-            }
-        }
-
-        [Command("blacklist", RunMode = RunMode.Async)]
-        [Summary("blacklistSummary")]
-        [Example("666963870385923507 bot abuse")]
-        public async Task Blacklist([Summary("blacklistParam1")] ulong userId,
-            [Remainder, Summary("blacklistParam2")] string reason = null)
-        {
-            var userConfig = GuildUtils.UserConfigCache.GetValueOrDefault(userId, new UserConfig(userId));
-            if (userConfig!.IsBlacklisted)
-            {
-                userConfig.IsBlacklisted = false;
-                userConfig.BlacklistReason = null;
-                FergunClient.Database.InsertOrUpdateDocument(Constants.UserConfigCollection, userConfig);
-                GuildUtils.UserConfigCache[userId] = userConfig;
-
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Blacklist", $"Removed user {userId} from the blacklist."));
-                await SendEmbedAsync(string.Format(Locate("UserBlacklistRemoved"), userId));
-            }
-            else
-            {
-                userConfig.IsBlacklisted = true;
-                userConfig.BlacklistReason = reason;
-                FergunClient.Database.InsertOrUpdateDocument(Constants.UserConfigCollection, userConfig);
-                GuildUtils.UserConfigCache[userId] = userConfig;
-
-                if (reason == null)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Blacklist", $"Added user {userId} to the blacklist."));
-                    await SendEmbedAsync(string.Format(Locate("UserBlacklisted"), userId));
-                }
-                else
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Blacklist", $"Added user {userId} to the blacklist with reason: {reason}"));
-                    await SendEmbedAsync(string.Format(Locate("UserBlacklistedWithReason"), userId, reason));
-                }
-            }
-        }
-
-        [Command("blacklistserver", RunMode = RunMode.Async)]
-        [Summary("blacklistserverSummary")]
-        [Example("685963870363423500 bot farm")]
-        public async Task BlacklistServer([Summary("blacklistserverParam1")] ulong serverId,
-            [Remainder, Summary("blacklistserverParam2")] string reason = null)
-        {
-            var serverConfig = FergunClient.Database.FindDocument<GuildConfig>(Constants.GuildConfigCollection, x => x.Id == serverId) ?? new GuildConfig(serverId);
-            if (serverConfig.IsBlacklisted)
-            {
-                serverConfig.IsBlacklisted = false;
-                serverConfig.BlacklistReason = null;
-                FergunClient.Database.InsertOrUpdateDocument(Constants.GuildConfigCollection, serverConfig);
-
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Blacklist", $"Removed server {serverId} from the blacklist."));
-                await SendEmbedAsync(string.Format(Locate("UserBlacklistRemoved"), serverId));
-            }
-            else
-            {
-                serverConfig.IsBlacklisted = true;
-                serverConfig.BlacklistReason = reason;
-                FergunClient.Database.InsertOrUpdateDocument(Constants.GuildConfigCollection, serverConfig);
-
-                if (reason == null)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Blacklist", $"Added server {serverId} to the blacklist."));
-                    await SendEmbedAsync(string.Format(Locate("ServerBlacklisted"), serverId));
-                }
-                else
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Blacklist", $"Added server {serverId} to the blacklist with reason: {reason}"));
-                    await SendEmbedAsync(string.Format(Locate("ServerBlacklistedWithReason"), serverId, reason));
-                }
-
-                var server = Context.Client.Guilds.FirstOrDefault(x => x.Id == serverId);
-                if (server != null)
-                {
-                    await server.LeaveAsync();
-                }
-            }
-        }
-
-        [Command("botgame")]
-        [Summary("botgameSummary")]
-        [Example("f!help | fergun.com")]
-        public async Task BotGame([Remainder, Summary("botgameParam1")] string text)
-        {
-            await Context.Client.SetGameAsync(text);
-            if (Context.Guild.CurrentUser.GuildPermissions.AddReactions)
-            {
-                await Context.Message.AddReactionAsync(new Emoji("\u2705"));
-            }
-        }
-
-        [Command("botstatus")]
-        [Summary("botstatusSummary")]
-        [Example("1")]
-        public async Task BotStatus([Summary("botstatusParam1")] uint status)
-        {
-            if (status <= 5)
-            {
-                await Context.Client.SetStatusAsync((UserStatus)status);
-                if (Context.Guild.CurrentUser.GuildPermissions.AddReactions)
-                {
-                    await Context.Message.AddReactionAsync(new Emoji("\u2705"));
-                }
-            }
-        }
-
-        [Command("eval", RunMode = RunMode.Async)]
-        [Summary("evalSummary")]
-        [Example("return Context.Client.Guilds.Count();")]
-        public async Task Eval([Remainder, Summary("evalParam1")] string code)
-        {
-            var sw = new Stopwatch();
-            code = code.Trim('`'); //Remove code block tags
-            bool silent = false;
-            if (code.EndsWith("-s", StringComparison.OrdinalIgnoreCase))
-            {
-                silent = true;
-                code = code.Substring(0, code.Length - 2);
-            }
-            if (!silent)
-            {
-                await Context.Channel.TriggerTypingAsync();
-                sw.Start();
-            }
-
-            _scriptAssemblies ??= AppDomain.CurrentDomain.GetAssemblies()
-                .Where(x => !x.IsDynamic && !string.IsNullOrWhiteSpace(x.Location));
-
-            _scriptNamespaces ??= Assembly.GetEntryAssembly()?.GetTypes()
-                .Where(x => !string.IsNullOrWhiteSpace(x.Namespace))
-                .Select(x => x.Namespace)
-                .Distinct()
-                .Append("System")
-                .Append("System.IO")
-                .Append("System.Math")
-                .Append("System.Diagnostics")
-                .Append("System.Linq")
-                .Append("System.Collections.Generic")
-                .Append("Discord")
-                .Append("Discord.WebSocket");
-
-            _scriptOptions ??= ScriptOptions.Default
-                .AddReferences(_scriptAssemblies)
-                .AddImports(_scriptNamespaces);
-
-            var script = CSharpScript.Create(code, _scriptOptions, typeof(EvaluationEnvironment));
-            object returnValue;
-            string returnType;
-            try
-            {
-                var globals = new EvaluationEnvironment(Context);
-                var scriptState = await script.RunAsync(globals);
-                returnValue = scriptState?.ReturnValue;
-                returnType = returnValue?.GetType().Name ?? "none";
-            }
-            catch (CompilationErrorException e)
-            {
-                returnValue = e.Message;
-                returnType = e.GetType().Name;
-            }
-            if (silent)
-            {
-                return;
-            }
-            sw.Stop();
-
-            string value = returnValue?.ToString();
-            if (value == null)
-            {
-                await SendEmbedAsync(Locate("EvalNoReturnValue"));
-                return;
-            }
-
-            var splitOutput = value
-                .SplitBySeparatorWithLimit('\n', EmbedBuilder.MaxDescriptionLength - 10)
-                .Select(x => Format.Code(x.Replace("`", string.Empty, StringComparison.OrdinalIgnoreCase), "md"))
-                .ToArray();
-
-            string evalResults = Locate("EvalResults");
-            string type = Locate("Type");
-            string paginatorFooter = $"{string.Format(Locate("EvalFooter"), sw.ElapsedMilliseconds)} - {Locate("PaginatorFooter")}";
-
-            Task<PageBuilder> GeneratePageAsync(int index)
-            {
-                var pageBuilder = new PageBuilder()
-                    .WithAuthor(Context.User)
-                    .WithColor(new Color(FergunClient.Config.EmbedColor))
-                    .WithTitle(evalResults)
-                    .WithDescription(splitOutput[index].Truncate(EmbedBuilder.MaxDescriptionLength))
-                    .AddField(type, Format.Code(returnType, "md"))
-                    .WithFooter(string.Format(paginatorFooter, index + 1, splitOutput.Length));
-
-                return Task.FromResult(pageBuilder);
-            }
-
-            var paginator = new LazyPaginatorBuilder()
-                .AddUser(Context.User)
-                .WithOptions(CommandUtils.GetFergunPaginatorEmotes(FergunClient.Config))
-                .WithMaxPageIndex(splitOutput.Length - 1)
-                .WithPageFactory(GeneratePageAsync)
-                .WithFooter(PaginatorFooter.None)
-                .WithActionOnCancellation(ActionOnStop.DisableInput)
-                .WithActionOnTimeout(ActionOnStop.DisableInput)
-                .WithDeletion(DeletionOptions.Valid)
-                .Build();
-
-            _ = SendPaginatorAsync(paginator, Constants.PaginatorTimeout);
-        }
-
-        [RequireContext(ContextType.Guild, ErrorMessage = "NotSupportedInDM")]
-        [Command("forceprefix")]
-        [Summary("forceprefixSummary")]
-        [Example("!")]
-        public async Task<RuntimeResult> ForcePrefix([Summary("prefixParam1")] string newPrefix)
-        {
-            if (newPrefix == GetPrefix())
-            {
-                return FergunResult.FromError(Locate("PrefixSameCurrentTarget"));
-            }
-
-            var guild = GetGuildConfig() ?? new GuildConfig(Context.Guild.Id);
-            guild.Prefix = newPrefix == DatabaseConfig.GlobalPrefix ? null : newPrefix;
-
-            FergunClient.Database.InsertOrUpdateDocument(Constants.GuildConfigCollection, guild);
-            GuildUtils.PrefixCache[Context.Guild.Id] = newPrefix;
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Forceprefix: Force updated prefix to: \"{newPrefix}\" in {Context.Guild.Name}"));
-
-            await SendEmbedAsync(string.Format(Locate("NewPrefix"), newPrefix));
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("globaldisable", RunMode = RunMode.Async)]
-        [Summary("globaldisableSummary")]
-        [Example("img")]
-        public async Task<RuntimeResult> GlobalDisable([Summary("globaldisableParam1")] string commandName,
-            [Remainder, Summary("globaldisableParam2")] string reason = null)
-        {
-            var command = _cmdService.Commands.FirstOrDefault(x => x.Aliases.Any(y => y == commandName.ToLowerInvariant()) && x.Module.Name != Constants.DevelopmentModuleName);
-            if (command != null)
-            {
-                if (command.Attributes.Concat(command.Module.Attributes).Any(x => x is AlwaysEnabledAttribute))
-                {
-                    return FergunResult.FromError(string.Format(Locate("NonDisableable"), Format.Code(command.Name)));
-                }
-            }
-            else
-            {
-                return FergunResult.FromError(string.Format(Locate("CommandNotFound"), GetPrefix()));
-            }
-
-            string complete = command.Module.Group == null ? command.Name : $"{command.Module.Group} {command.Name}";
-            var disabledCommands = DatabaseConfig.GloballyDisabledCommands;
-            if (disabledCommands.ContainsKey(complete))
-            {
-                return FergunResult.FromError(string.Format(Locate("AlreadyDisabledGlobally"), Format.Code(complete)));
-            }
-
-            disabledCommands.Add(complete, reason);
-            DatabaseConfig.Update(x => x.GloballyDisabledCommands = disabledCommands);
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Globaldisable: Disabled command \"{complete}\" in all servers."));
-
-            await SendEmbedAsync("\u2705 " + string.Format(Locate("CommandDisabledGlobally"), Format.Code(complete)));
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("globalenable", RunMode = RunMode.Async)]
-        [Summary("globalenableSummary")]
-        [Example("img")]
-        public async Task<RuntimeResult> GlobalEnable([Summary("globalenableParam1")] string commandName)
-        {
-            var command = _cmdService.Commands.FirstOrDefault(x => x.Aliases.Any(y => y == commandName.ToLowerInvariant()) && x.Module.Name != Constants.DevelopmentModuleName);
-            if (command != null)
-            {
-                if (command.Attributes.Concat(command.Module.Attributes).Any(x => x is AlwaysEnabledAttribute))
-                {
-                    return FergunResult.FromError(string.Format(Locate("AlreadyEnabledGlobally"), Format.Code(command.Name)));
-                }
-            }
-            else
-            {
-                return FergunResult.FromError(string.Format(Locate("CommandNotFound"), GetPrefix()));
-            }
-
-            string complete = command.Module.Group == null ? command.Name : $"{command.Module.Group} {command.Name}";
-            var disabledCommands = DatabaseConfig.GloballyDisabledCommands;
-            if (disabledCommands.ContainsKey(complete))
-            {
-                disabledCommands.Remove(complete);
-                DatabaseConfig.Update(x => x.GloballyDisabledCommands = disabledCommands);
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Globalenable: Enabled command \"{complete}\" in all servers."));
-
-                await SendEmbedAsync("\u2705 " + string.Format(Locate("CommandEnabledGlobally"), Format.Code(complete)));
-            }
-            else
-            {
-                return FergunResult.FromError(string.Format(Locate("AlreadyEnabledGlobally"), Format.Code(complete)));
-            }
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("globalprefix")]
-        [Summary("globalprefixSummary")]
-        [Example("f!")]
-        public async Task<RuntimeResult> GlobalPrefix([Summary("globalprefixParam1")] string newPrefix)
-        {
-            if (newPrefix == DatabaseConfig.GlobalPrefix)
-            {
-                return FergunResult.FromError(Locate("PrefixSameCurrentTarget"));
-            }
-            if (FergunClient.IsDebugMode)
-            {
-                DatabaseConfig.Update(x => x.DevGlobalPrefix = newPrefix);
-            }
-            else
-            {
-                DatabaseConfig.Update(x => x.GlobalPrefix = newPrefix);
-            }
-            GuildUtils.CachedGlobalPrefix = newPrefix;
-            await SendEmbedAsync(string.Format(Locate("NewGlobalPrefix"), newPrefix));
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("logout", RunMode = RunMode.Async)]
-        [Summary("logoutSummary")]
-        [Alias("die")]
-        public async Task<RuntimeResult> Logout([Summary("logoutParam1")]  bool simulate = false)
-        {
-            int count = await _musicService.ShutdownAllPlayersAsync(simulate);
-            if (simulate)
-            {
-                await SendEmbedAsync($"Disconnecting the bot would shut down {count} music player(s).");
-                return FergunResult.FromSuccess();
-            }
-
-            await ReplyAsync("Bye bye");
-            await Context.Client.SetStatusAsync(UserStatus.Invisible);
-            //await Context.Client.LogoutAsync();
-            await Context.Client.StopAsync();
-            Cache.Dispose();
-            Environment.Exit(0);
-
-            return FergunResult.FromError("Wait. This line was not supposed to be reached.");
-        }
-
-        [Command("restart", RunMode = RunMode.Async)]
-        [Summary("restartSummary")]
-        public async Task<RuntimeResult> Restart([Summary("restartParam1")] bool simulate = false)
-        {
-            int count = await _musicService.ShutdownAllPlayersAsync(simulate);
-            if (simulate)
-            {
-                await SendEmbedAsync($"Restarting the bot would shut down {count} music player(s).");
-                return FergunResult.FromSuccess();
-            }
-
-            if (Context.Guild.CurrentUser.GuildPermissions.AddReactions)
-            {
-                await Context.Message.AddReactionAsync(new Emoji("\u2705"));
-            }
-
-            Process.Start(AppDomain.CurrentDomain.FriendlyName);
-            await Context.Client.SetStatusAsync(UserStatus.Idle);
-            Cache.Dispose();
-            Environment.Exit(0);
-
-            return FergunResult.FromError("Wait. This line was not supposed to be reached.");
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Modules/Text.cs b/src/Modules/Text.cs
deleted file mode 100644
index 624b373..0000000
--- a/src/Modules/Text.cs
+++ /dev/null
@@ -1,105 +0,0 @@
-using System;
-using System.Globalization;
-using System.Text;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Fergun.Attributes;
-using Fergun.Attributes.Preconditions;
-using Fergun.Extensions;
-
-namespace Fergun.Modules
-{
-    [Order(0)]
-    [RequireBotPermission(Constants.MinimumRequiredPermissions)]
-    [Ratelimit(Constants.GlobalCommandUsesPerPeriod, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-    public class Text : FergunBase
-    {
-        [Command("normalize")]
-        [Summary("normalizeSummary")]
-        [Alias("decancer")]
-        [Example("aesthetic")]
-        public async Task Normalize([Remainder, Summary("normalizeParam1")] string text)
-        {
-            var normalized = new StringBuilder();
-
-            foreach (char c in text.Normalize(NormalizationForm.FormKD))
-            {
-                var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
-                if (unicodeCategory != UnicodeCategory.NonSpacingMark)
-                {
-                    normalized.Append(c);
-                }
-            }
-            await ReplyAsync(normalized.ToString().Normalize(NormalizationForm.FormKC).Truncate(DiscordConfig.MaxMessageSize), allowedMentions: AllowedMentions.None);
-        }
-
-        [Command("randomize")]
-        [Summary("randomizeSummary")]
-        [Example("hello")]
-        public async Task Randomize([Remainder, Summary("randomizeParam1")] string text)
-        {
-            await ReplyAsync(text.Randomize(), allowedMentions: AllowedMentions.None);
-        }
-
-        [Command("repeat")]
-        [Summary("repeatSummary")]
-        [Example("10 oof")]
-        public async Task Repeat([Summary("repeatParam1")] int count, [Remainder, Summary("repeatParam2")] string text)
-        {
-            count = Math.Max(1, count);
-
-            // Repeat the text to the max message size if the resulting text is too large
-            text = text.Length * count > DiscordConfig.MaxMessageSize
-                ? text.RepeatToLength(DiscordConfig.MaxMessageSize)
-                : text.Repeat(count);
-
-            await ReplyAsync(text, allowedMentions: AllowedMentions.None);
-        }
-
-        [Command("reverse")]
-        [Summary("reverseSummary")]
-        [Example("hello")]
-        public async Task Reverse([Remainder, Summary("reverseParam1")] string text)
-        {
-            await ReplyAsync(text.Reverse(), allowedMentions: AllowedMentions.None);
-        }
-
-        [Command("reverselines")]
-        [Summary("reverselinesSummary")]
-        [Alias("rlines", "rline", "rel")]
-        [Example("line 1\nline 2\nline 3")]
-        public async Task ReverseLines([Remainder, Summary("reverselinesParam1")] string text)
-        {
-            await ReplyAsync(text.ReverseEachLine().Truncate(DiscordConfig.MaxMessageSize), allowedMentions: AllowedMentions.None);
-        }
-
-        [Command("reversewords")]
-        [Summary("reversewordsSummary")]
-        [Alias("rwords")]
-        [Example("one two three")]
-        public async Task ReverseWords([Remainder, Summary("reversewordsParam1")] string text)
-        {
-            await ReplyAsync(text.ReverseWords().Truncate(DiscordConfig.MaxMessageSize), allowedMentions: AllowedMentions.None);
-        }
-
-        [Command("sarcasm")]
-        [Summary("sarcasmSummary")]
-        [Alias("randomcase", "sarcastic")]
-        [Example("you can't do that!")]
-        public async Task Sarcasm([Remainder, Summary("sarcasmParam1")] string text)
-        {
-            await ReplyAsync(text.ToRandomCase(), allowedMentions: AllowedMentions.None);
-        }
-
-        [Command("vaporwave")]
-        [Summary("vaporwaveSummary")]
-        [Alias("vapor", "aesthetic", "fullwidth")]
-        [Example("aesthetic")]
-        public async Task Vaporwave([Remainder, Summary("vaporwaveParam1")] string text)
-        {
-            await ReplyAsync(text.ToFullWidth().Truncate(DiscordConfig.MaxMessageSize));
-            //await ReplyAsync(new Regex(@"[\uFF61-\uFF9F]+", RegexOptions.Compiled).Replace(text, m => m.Value.Normalize(NormalizationForm.FormKC)));
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Modules/UserModule.cs b/src/Modules/UserModule.cs
new file mode 100644
index 0000000..5d1065e
--- /dev/null
+++ b/src/Modules/UserModule.cs
@@ -0,0 +1,77 @@
+using Discord;
+using Discord.Interactions;
+using Fergun.Extensions;
+
+namespace Fergun.Modules;
+
+public class UserModule : InteractionModuleBase<ShardedInteractionContext>
+{
+    [UserCommand("Avatar")]
+    public async Task GetAvatar(IUser user)
+    {
+        string url = (user as IGuildUser)?.GetGuildAvatarUrl(size: 2048) ?? user.GetAvatarUrl(size: 2048) ?? user.GetDefaultAvatarUrl();
+
+        var builder = new EmbedBuilder
+        {
+            Title = user.ToString(),
+            ImageUrl = url,
+            Color = Color.Orange
+        };
+
+        await RespondAsync(embed: builder.Build());
+    }
+
+    [UserCommand("User Info")]
+    public async Task UserInfo(IUser user)
+    {
+        string activities = "";
+        if (user.Activities.Count > 0)
+        {
+            activities = string.Join('\n', user.Activities.Select(x =>
+                x.Type == ActivityType.CustomStatus
+                    ? ((CustomStatusGame)x).ToString()
+                    : $"{x.Type} {x.Name}"));
+        }
+
+        if (string.IsNullOrWhiteSpace(activities))
+            activities = "None";
+
+        string clients = "?";
+        if (user.ActiveClients.Count > 0)
+        {
+            clients = string.Join(' ', user.ActiveClients.Select(x =>
+                x switch
+                {
+                    ClientType.Desktop => "🖥",
+                    ClientType.Mobile => "📱",
+                    ClientType.Web => "🌐",
+                    _ => ""
+                }));
+        }
+
+        if (string.IsNullOrWhiteSpace(clients))
+            clients = "?";
+
+        var guildUser = user as IGuildUser;
+        string avatarUrl = guildUser?.GetGuildAvatarUrl(size: 2048) ?? user.GetAvatarUrl(ImageFormat.Auto, 2048) ?? user.GetDefaultAvatarUrl();
+
+        var builder = new EmbedBuilder()
+            .WithTitle("User Info")
+            .AddField("Name", user.ToString())
+            .AddField("Nickname", guildUser?.Nickname ?? "None")
+            .AddField("ID", user.Id)
+            .AddField("Activity", activities, true)
+            .AddField("Active Clients", clients, true)
+            .AddField("IsBot", user.IsBot)
+            .AddField("Created At", GetTimestamp(user.CreatedAt))
+            .AddField("Guild Join Date", GetTimestamp(guildUser?.JoinedAt))
+            .AddField("Boosting Since", GetTimestamp(guildUser?.PremiumSince))
+            .WithThumbnailUrl(avatarUrl)
+            .WithColor(Color.Orange);
+
+        await RespondAsync(embed: builder.Build());
+
+        static string GetTimestamp(DateTimeOffset? dateTime)
+            => dateTime == null ? "N/A" : $"{dateTime.ToDiscordTimestamp()} ({dateTime.ToDiscordTimestamp('R')})";
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/Utility.cs b/src/Modules/Utility.cs
deleted file mode 100644
index a3df91a..0000000
--- a/src/Modules/Utility.cs
+++ /dev/null
@@ -1,2559 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.Globalization;
-using System.IO;
-using System.Linq;
-using System.Net;
-using System.Net.Http;
-using System.Text;
-using System.Text.Json;
-using System.Text.RegularExpressions;
-using System.Threading;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Discord.Rest;
-using Discord.WebSocket;
-using Fergun.APIs;
-using Fergun.APIs.Dictionary;
-using Fergun.APIs.UrbanDictionary;
-using Fergun.APIs.WaybackMachine;
-using Fergun.Attributes;
-using Fergun.Attributes.Preconditions;
-using Fergun.Extensions;
-using Fergun.Interactive;
-using Fergun.Interactive.Pagination;
-using Fergun.Interactive.Selection;
-using Fergun.Responses;
-using Fergun.Services;
-using Fergun.Utils;
-using GScraper;
-using GScraper.Brave;
-using GScraper.DuckDuckGo;
-using GScraper.Google;
-using GTranslate;
-using GTranslate.Results;
-using GTranslate.Translators;
-using NCalc;
-using Newtonsoft.Json;
-using SixLabors.ImageSharp;
-using SixLabors.ImageSharp.Drawing.Processing;
-using SixLabors.ImageSharp.PixelFormats;
-using SixLabors.ImageSharp.Processing;
-using YoutubeExplode;
-using YoutubeExplode.Common;
-using YoutubeExplode.Exceptions;
-using YoutubeExplode.Search;
-
-namespace Fergun.Modules
-{
-    [Order(1)]
-    [RequireBotPermission(Constants.MinimumRequiredPermissions)]
-    [Ratelimit(Constants.GlobalCommandUsesPerPeriod, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-    public class Utility : FergunBase
-    {
-        private static readonly Regex _bracketRegex = new Regex(@"\[(.+?)\]", RegexOptions.IgnoreCase | RegexOptions.Compiled); // \[(\[*.+?]*)\]
-        private static readonly HttpClient _httpClient = new HttpClient { Timeout = Constants.HttpClientTimeout };
-        private static readonly YoutubeClient _ytClient = new YoutubeClient();
-        private static readonly GoogleScraper _googleScraper = new GoogleScraper();
-        private static readonly DuckDuckGoScraper _ddgScraper = new DuckDuckGoScraper();
-        private static readonly BraveScraper _braveScraper = new BraveScraper();
-        private static Language[] _filteredLanguages;
-        private static Dictionary<string, string> _commandListCache;
-        private static int _cachedVisibleCmdCount = -1;
-        private static XkcdComic _lastComic;
-        private static DateTimeOffset _timeToCheckComic = DateTimeOffset.UtcNow;
-        private readonly CommandService _cmdService;
-        private readonly LogService _logService;
-        private readonly AggregateTranslator _translator;
-        private readonly GoogleTranslator _googleTranslator;
-        private readonly GoogleTranslator2 _googleTranslator2;
-        private readonly BingTranslator _bingTranslator;
-        private readonly MicrosoftTranslator _microsoftTranslator;
-        private readonly YandexTranslator _yandexTranslator;
-        private readonly MessageCacheService _messageCache;
-        private readonly InteractiveService _interactive;
-
-        public Utility(CommandService commands, LogService logService, MessageCacheService messageCache,
-            InteractiveService interactive, AggregateTranslator translator, GoogleTranslator googleTranslator,
-            GoogleTranslator2 googleTranslator2, BingTranslator bingTranslator, MicrosoftTranslator microsoftTranslator,
-            YandexTranslator yandexTranslator)
-        {
-            _cmdService ??= commands;
-            _logService ??= logService;
-            _messageCache ??= messageCache;
-            _interactive ??= interactive;
-            _translator = translator;
-            _googleTranslator = googleTranslator;
-            _googleTranslator2 = googleTranslator2;
-            _bingTranslator = bingTranslator;
-            _microsoftTranslator = microsoftTranslator;
-            _yandexTranslator = yandexTranslator;
-        }
-
-        [RequireNsfw(ErrorMessage = "NSFWOnly")]
-        [RequireBotPermission(ChannelPermission.AttachFiles, ErrorMessage = "BotRequireAttachFiles")]
-        [LongRunning]
-        [Command("archive", RunMode = RunMode.Async), Ratelimit(1, 1.5, Measure.Minutes)]
-        [Summary("archiveSummary")]
-        [Remarks("TimestampFormat")]
-        [Alias("waybackmachine", "wb")]
-        [Example("https://www.youtube.com 2008")]
-        public async Task<RuntimeResult> Archive([Summary("archiveParam1")] string url, [Summary("archiveParam2")] ulong timestamp)
-        {
-            double length = Math.Floor(Math.Log10(timestamp) + 1);
-            if (length < 4 || length > 14)
-            {
-                return FergunResult.FromError($"{Locate("InvalidTimestamp")} {Locate("TimestampFormat")}");
-            }
-
-            Uri uri;
-            try
-            {
-                uri = new UriBuilder(Uri.UnescapeDataString(url)).Uri;
-            }
-            catch (UriFormatException)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Archive: Invalid url: {Uri.UnescapeDataString(url)}"));
-                return FergunResult.FromError(Locate("InvalidUrl"));
-            }
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Archive: Url: {uri.AbsoluteUri}"));
-
-            WaybackResponse waybackResponse;
-            try
-            {
-                waybackResponse = await WaybackApi.GetSnapshotAsync(uri.AbsoluteUri, timestamp);
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error in Wayback Machine API, url: {url}", e));
-                return FergunResult.FromError(e.Message);
-            }
-            catch (TaskCanceledException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error in Wayback Machine API, url: {url}", e));
-                return FergunResult.FromError(Locate("RequestTimedOut"));
-            }
-
-            var snapshot = waybackResponse.ArchivedSnapshots?.Closest;
-
-            if (snapshot == null)
-            {
-                return FergunResult.FromError(Locate("NoSnapshots"));
-            }
-
-            bool success = DateTimeOffset.TryParseExact(snapshot.Timestamp, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var datetime);
-
-            var builder = new EmbedBuilder()
-                .WithTitle("Wayback Machine")
-                .AddField("Url", Format.Url(Locate("ClickHere"), snapshot.Url))
-                .AddField(Locate("Timestamp"), success ? $"{datetime.ToDiscordTimestamp()} ({datetime.ToDiscordTimestamp('R')})" : "?")
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            if (string.IsNullOrEmpty(FergunClient.Config.ApiFlashAccessKey))
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", "Archive: ApiFlash access key is null or empty, sending only the embed."));
-
-                await ReplyAsync(embed: builder.Build());
-
-                return FergunResult.FromSuccess();
-            }
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Archive: Requesting screenshot of url: {snapshot.Url}"));
-            ApiFlashResponse response;
-            try
-            {
-                response = await ApiFlash.UrlToImageAsync(FergunClient.Config.ApiFlashAccessKey, snapshot.Url, ApiFlash.FormatType.Png, "400,403,404,500-511");
-            }
-            catch (ArgumentException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Archive: Error in ApiFlash API", e));
-                return FergunResult.FromError(Locate("InvalidUrl"));
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Archive: Error in ApiFlash API", e));
-                return FergunResult.FromError(e.Message);
-            }
-            catch (TaskCanceledException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Archive: Error in ApiFlash API", e));
-                return FergunResult.FromError(Locate("RequestTimedOut"));
-            }
-
-            if (response.ErrorMessage != null)
-            {
-                return FergunResult.FromError(response.ErrorMessage);
-            }
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", "Archive: Sending the embed..."));
-            try
-            {
-                builder.ImageUrl = "attachment://screenshot.png";
-                await using var image = await _httpClient.GetStreamAsync(new Uri(response.Url));
-                await Context.Channel.SendCachedFileAsync(Cache, Context.Message.Id, image, "screenshot.png", embed: builder.Build());
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error getting the image from url: {url}", e));
-                return FergunResult.FromError(e.Message);
-            }
-            catch (TaskCanceledException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error getting the image from url: {url}", e));
-                return FergunResult.FromError(Locate("RequestTimedOut"));
-            }
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("avatar", RunMode = RunMode.Async)]
-        [Summary("avatarSummary")]
-        [Example("Fergun#6839")]
-        public async Task<RuntimeResult> Avatar([Remainder, Summary("avatarParam1")] IUser user = null)
-        {
-            user ??= Context.User;
-            Discord.Color avatarColor = default;
-            string avatarUrl = user.GetAvatarUrl(ImageFormat.Auto, 2048) ?? user.GetDefaultAvatarUrl();
-
-            if (user is RestUser restUser && restUser.AccentColor != null)
-            {
-                avatarColor = restUser.AccentColor.Value;
-            }
-
-            if (avatarColor == default)
-            {
-                if (!(user is RestUser))
-                {
-                    // Prevent getting error 404 while downloading the avatar getting the user from REST.
-                    user = await Context.Client.Rest.GetUserAsync(user.Id);
-                    avatarUrl = user.GetAvatarUrl(ImageFormat.Auto, 2048) ?? user.GetDefaultAvatarUrl();
-                }
-
-                string thumbnail = user.GetAvatarUrl(ImageFormat.Png) ?? user.GetDefaultAvatarUrl();
-
-                try
-                {
-                    await using var response = await _httpClient.GetStreamAsync(new Uri(thumbnail));
-                    using var img = SixLabors.ImageSharp.Image.Load<Rgba32>(response);
-                    var average = img.GetAverageColor().ToPixel<Rgba32>();
-                    avatarColor = new Discord.Color(average.R, average.G, average.B);
-                }
-                catch (HttpRequestException e)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error getting the avatar from user {user}", e));
-                    return FergunResult.FromError(e.Message);
-                }
-                catch (TaskCanceledException e)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error getting the avatar from user {user}", e));
-                    return FergunResult.FromError(Locate("RequestTimedOut"));
-                }
-            }
-
-            var builder = new EmbedBuilder
-            {
-                Title = user.ToString(),
-                ImageUrl = avatarUrl,
-                Color = avatarColor
-            };
-
-            await ReplyAsync(embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        [LongRunning]
-        [Command("badtranslator", RunMode = RunMode.Async), Ratelimit(1, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-        [Summary("badtranslatorSummary")]
-        [Alias("bt")]
-        [Example("i don't know what to say lol")]
-        public async Task<RuntimeResult> BadTranslator([Remainder, Summary("badtranslatorParam1")] string text)
-        {
-            // Get languages that all services support
-            _filteredLanguages ??= Language.LanguageDictionary
-                .Values
-                .Where(x => x.SupportedServices == (TranslationServices.Google | TranslationServices.Bing | TranslationServices.Yandex | TranslationServices.Microsoft))
-                .ToArray();
-
-            // Create an aggregated translator manually so we can randomize the initial order of the translators and shift them.
-            // Bing Translator is not included because it only allows max. 1000 chars per translation
-            var translators = new ITranslator[] { _googleTranslator, _googleTranslator2, _microsoftTranslator, _yandexTranslator };
-            translators.Shuffle();
-
-            var badTranslator = new AggregateTranslator(translators);
-            var languageChain = new List<ILanguage>();
-            const int chainCount = 8;
-            ILanguage sourceLanguage = null;
-            for (int i = 0; i < chainCount; i++)
-            {
-                ILanguage targetLanguage;
-                if (i == chainCount - 1)
-                {
-                    targetLanguage = sourceLanguage;
-                }
-                else
-                {
-                    // Get unique and random languages.
-                    do
-                    {
-                        targetLanguage = _filteredLanguages[Random.Shared.Next(_filteredLanguages.Length)];
-                    } while (languageChain.Contains(targetLanguage));
-                }
-
-                // Shift the translators to avoid spamming them and get more variety
-                var last = translators[^1];
-                Array.Copy(translators, 0, translators, 1, translators.Length - 1);
-                translators[0] = last;
-
-                ITranslationResult result;
-                try
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Info, "BadTranslator", $"Translating to: {targetLanguage!.ISO6391}"));
-                    result = await badTranslator.TranslateAsync(text, targetLanguage);
-                }
-                catch (Exception e) when (e is TranslatorException || e is HttpRequestException)
-                {
-                    return FergunResult.FromError(e.Message);
-                }
-                catch (AggregateException e)
-                {
-                    return FergunResult.FromError(e.InnerExceptions.FirstOrDefault()?.Message ?? e.Message);
-                }
-
-                if (i == 0)
-                {
-                    sourceLanguage = result.SourceLanguage;
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Badtranslator: Original language: {sourceLanguage.ISO6391}"));
-                    languageChain.Add(sourceLanguage);
-                }
-                text = result.Translation;
-                languageChain.Add(targetLanguage);
-            }
-
-            string description = $"**{Locate("LanguageChain")}**\n" +
-                                 $"{string.Join(" -> ", languageChain.Select(x => x.ISO6391))}\n\n" +
-                                 $"**{Locate("Result")}**\n";
-
-            if (description.Length + text.Length > EmbedBuilder.MaxDescriptionLength)
-            {
-                try
-                {
-                    var hastebinUrl = await Hastebin.UploadAsync(text);
-                    text = Format.Url(Locate("HastebinLink"), hastebinUrl);
-                }
-                catch (Exception e) when (e is HttpRequestException || e is TaskCanceledException)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Paste: Error while uploading text to Hastebin", e));
-                }
-            }
-
-            var builder = new EmbedBuilder()
-                .WithTitle("Bad translator")
-                .WithDescription($"{description}{text.Truncate(EmbedBuilder.MaxDescriptionLength - description.Length)}")
-                .WithThumbnailUrl(Constants.BadTranslatorLogoUrl)
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("base64decode")]
-        [Summary("base64decodeSummary")]
-        [Alias("b64decode", "b64d")]
-        [Example("aGVsbG8gd29ybGQ=")]
-        public async Task<RuntimeResult> Base64Decode([Remainder, Summary("base64decodeParam1")] string text)
-        {
-            if (!text.TryBase64Decode(out string decoded))
-            {
-                return FergunResult.FromError(Locate("base64decodeInvalid"));
-            }
-
-            await ReplyAsync(decoded, allowedMentions: AllowedMentions.None);
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("base64encode")]
-        [Summary("base64encodeSummary")]
-        [Alias("b64encode", "b64e")]
-        [Example("hello")]
-        public async Task Base64Encode([Remainder, Summary("base64encodeParam1")] string text)
-        {
-            text = Convert.ToBase64String(Encoding.UTF8.GetBytes(text));
-            if (text.Length > DiscordConfig.MaxMessageSize)
-            {
-                try
-                {
-                    text = await Hastebin.UploadAsync(text);
-                }
-                catch (Exception e) when (e is HttpRequestException || e is TaskCanceledException)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Badtranslator: Error while uploading text to Hastebin", e));
-                    text = text.Truncate(DiscordConfig.MaxMessageSize);
-                }
-            }
-            await ReplyAsync(text);
-        }
-
-        [Command("bigeditsnipe", RunMode = RunMode.Async)]
-        [Summary("bigeditsnipeSummary")]
-        [Alias("besnipe", "bes")]
-        public async Task<RuntimeResult> BigEditSnipe([Summary("bigeditsnipeParam1")] IMessageChannel channel = null)
-        {
-            channel ??= Context.Channel;
-            var messages = _messageCache
-                .GetCacheForChannel(channel, MessageSourceEvent.MessageUpdated)
-                .Values
-                .OrderByDescending(x => x.CachedAt)
-                .Where(x => !GuildUtils.UserConfigCache.TryGetValue(x.Author.Id, out var config) || !config.IsOptedOutSnipe)
-                .Take(20)
-                .ToArray();
-
-            var builder = new EmbedBuilder();
-            if (messages.Length == 0)
-            {
-                builder.WithDescription(string.Format(Locate("NothingToSnipe"), MentionUtils.MentionChannel(channel.Id)));
-            }
-            else
-            {
-                var text = messages.Select(x =>
-                    $"{Format.Bold(x.Author.ToString())} ({(x.OriginalMessage?.CreatedAt ?? x.CreatedAt).ToDiscordTimestamp('R')})" +
-                    $"\n{(x.OriginalMessage?.Content ?? x.Content).Truncate(200)}\n\n");
-
-                builder.WithTitle("Big edit snipe")
-                    .WithDescription(string.Concat(text).Truncate(EmbedBuilder.MaxDescriptionLength))
-                    .WithFooter($"{Locate("In")} #{channel.Name}");
-
-                if (Random.Shared.Next(5) == 4)
-                {
-                    builder.AddField(Locate("Privacy"), Locate("SnipePrivacy"));
-                }
-            }
-            builder.WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("bigsnipe", RunMode = RunMode.Async)]
-        [Summary("bigsnipeSummary")]
-        [Alias("bsnipe", "bs")]
-        public async Task<RuntimeResult> BigSnipe([Summary("bigsnipeParam1")] IMessageChannel channel = null)
-        {
-            channel ??= Context.Channel;
-            var messages = _messageCache
-                .GetCacheForChannel(channel, MessageSourceEvent.MessageDeleted)
-                .Values
-                .OrderByDescending(x => x.CachedAt)
-                .Where(x => !GuildUtils.UserConfigCache.TryGetValue(x.Author.Id, out var config) || !config.IsOptedOutSnipe)
-                .Take(20)
-                .ToArray();
-
-            var builder = new EmbedBuilder();
-            if (messages.Length == 0)
-            {
-                builder.WithDescription(string.Format(Locate("NothingToSnipe"), MentionUtils.MentionChannel(channel.Id)));
-            }
-            else
-            {
-                string text = "";
-                foreach (var msg in messages)
-                {
-                    text += $"{Format.Bold(msg.Author.ToString())} ({msg.CreatedAt.ToDiscordTimestamp('R')})\n";
-                    text += !string.IsNullOrEmpty(msg.Content) ? msg.Content.Truncate(200) : msg.Attachments.Count > 0 ? $"({Locate("Attachment")})" : "?";
-                    text += "\n\n";
-                }
-                builder.WithTitle("Big snipe")
-                    .WithDescription(text.Truncate(EmbedBuilder.MaxDescriptionLength))
-                    .WithFooter($"{Locate("In")} #{channel.Name}");
-
-                if (Random.Shared.Next(5) == 4)
-                {
-                    builder.AddField(Locate("Privacy"), Locate("SnipePrivacy"));
-                }
-            }
-            builder.WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("calc", RunMode = RunMode.Async)]
-        [Summary("calcSummary")]
-        [Alias("calculate")]
-        [Example("2 * 2 - 1")]
-        public async Task<RuntimeResult> Calc([Remainder, Summary("calcParam1")] string expression)
-        {
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Calc: expression: {expression}"));
-
-            var sw = Stopwatch.StartNew();
-            var ex = new Expression(expression);
-            if (ex.HasErrors())
-            {
-                return FergunResult.FromError(Locate("InvalidExpression"));
-            }
-            string result;
-            try
-            {
-                result = ex.Evaluate().ToString();
-            }
-            catch (Exception e) when (e is ArgumentException || e is EvaluationException || e is OverflowException)
-            {
-                return FergunResult.FromError(Locate("InvalidExpression"));
-            }
-
-            sw.Stop();
-            if (result == null)
-            {
-                return FergunResult.FromError(Locate("InvalidExpression"));
-            }
-
-            if (result.Length > EmbedFieldBuilder.MaxFieldValueLength)
-            {
-                result = result.Truncate(EmbedFieldBuilder.MaxFieldValueLength - 3) + "...";
-            }
-
-            var builder = new EmbedBuilder()
-                .WithTitle(Locate("CalcResults"))
-                .AddField(Locate("Input"), Format.Code(expression.Truncate(EmbedFieldBuilder.MaxFieldValueLength - 10), "md"))
-                .AddField(Locate("Output"), Format.Code(result.Truncate(EmbedFieldBuilder.MaxFieldValueLength - 10), "md"))
-                .WithFooter(string.Format(Locate("EvalFooter"), sw.ElapsedMilliseconds))
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("channelinfo")]
-        [Summary("channelinfoSummary")]
-        [Alias("channel")]
-        [Example("#general")]
-        public async Task<RuntimeResult> ChannelInfo([Remainder, Summary("channelinfoParam1")] IChannel channel = null)
-        {
-            channel ??= Context.Channel;
-
-            var builder = new EmbedBuilder()
-                .WithTitle(Locate("ChannelInfo"))
-                .AddField(Locate("Name"), channel.Name ?? "?", true)
-                .AddField("ID", channel.Id, true);
-
-            switch (channel)
-            {
-                case IThreadChannel threadChannel:
-                    builder.AddField(Locate("Type"), $"{Locate("Thread")} ({threadChannel.Type})", true)
-                        .AddField(Locate("Archived"), Locate(threadChannel.IsArchived), true)
-                        .AddField(Locate("IsNSFW"), Locate(threadChannel.IsNsfw), true)
-                        .AddField(Locate("SlowMode"), TimeSpan.FromSeconds(threadChannel.SlowModeInterval).ToShortForm2(), true)
-                        .AddField(Locate("AutoArchive"), TimeSpan.FromMinutes((int)threadChannel.AutoArchiveDuration).ToShortForm2(), true);
-                    break;
-
-                case IStageChannel stageChannel:
-                    builder.AddField(Locate("Type"), Locate("StageChannel"), true)
-                        .AddField(Locate("Topic"), string.IsNullOrEmpty(stageChannel.Topic) ? Locate("None") : stageChannel.Topic, true)
-                        .AddField(Locate("IsLive"), Locate(stageChannel.IsLive), true)
-                        .AddField(Locate("Bitrate"), $"{stageChannel.Bitrate / 1000}kbps", true)
-                        .AddField(Locate("UserLimit"), stageChannel.UserLimit?.ToString() ?? Locate("NoLimit"), true);
-                    break;
-
-                case ITextChannel textChannel:
-                    builder.AddField(Locate("Type"), Locate(channel is INewsChannel ? "AnnouncementChannel" : "TextChannel"), true)
-                        .AddField(Locate("Topic"), string.IsNullOrEmpty(textChannel.Topic) ? Locate("None") : textChannel.Topic, true)
-                        .AddField(Locate("IsNSFW"), Locate(textChannel.IsNsfw), true)
-                        .AddField(Locate("SlowMode"), TimeSpan.FromSeconds(channel is INewsChannel ? 0 : textChannel.SlowModeInterval).ToShortForm2(), true)
-                        .AddField(Locate("Category"), textChannel.CategoryId.HasValue ? Context.Guild.GetCategoryChannel(textChannel.CategoryId.Value).Name : Locate("None"), true);
-                    break;
-
-                case IVoiceChannel voiceChannel:
-                    builder.AddField(Locate("Type"), Locate("VoiceChannel"), true)
-                        .AddField(Locate("Bitrate"), $"{voiceChannel.Bitrate / 1000}kbps", true)
-                        .AddField(Locate("UserLimit"), voiceChannel.UserLimit?.ToString() ?? Locate("NoLimit"), true);
-                    break;
-
-                case SocketCategoryChannel categoryChannel:
-                    builder.AddField(Locate("Type"), Locate("Category"), true)
-                        .AddField(Locate("Channels"), categoryChannel.Channels.Count, true);
-                    break;
-
-                case IDMChannel _:
-                    builder.AddField(Locate("Type"), Locate("DMChannel"), true);
-                    break;
-
-                default:
-                    builder.AddField(Locate("Type"), "?", true);
-                    break;
-            }
-            if (channel is IGuildChannel guildChannel)
-            {
-                builder.AddField(Locate("Position"), guildChannel.Position, true);
-            }
-            builder.AddField(Locate("CreatedAt"), channel.CreatedAt.ToDiscordTimestamp(), true)
-                .AddField(Locate("Mention"), MentionUtils.MentionChannel(channel.Id), true)
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("choice")]
-        [Summary("choiceSummary")]
-        [Alias("choose")]
-        [Example("c c++ c#")]
-        public async Task<RuntimeResult> Choice([Summary("choiceParam1")] params string[] choices)
-        {
-            if (choices.Length == 0)
-            {
-                return FergunResult.FromError(Locate("NoChoices"));
-            }
-            await ReplyAsync($"{Locate("IChoose")} **{choices[Random.Shared.Next(0, choices.Length)]}**{(choices.Length == 1 ? Locate("OneChoice") : "")}", allowedMentions: AllowedMentions.None);
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("color")]
-        [Summary("colorSummary")]
-        [Example("#ff983e")]
-        public async Task<RuntimeResult> Color([Summary("colorParam1")] string color = null)
-        {
-            System.Drawing.Color argbColor;
-            if (string.IsNullOrWhiteSpace(color))
-            {
-                argbColor = System.Drawing.Color.FromArgb(Random.Shared.Next(0, 256), Random.Shared.Next(0, 256), Random.Shared.Next(0, 256));
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Color: Generated random color: {argbColor}"));
-            }
-            else
-            {
-                color = color.TrimStart('#');
-                if (!int.TryParse(color, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int rawColor)
-                    && !int.TryParse(color, NumberStyles.Integer, CultureInfo.InvariantCulture, out rawColor))
-                {
-                    rawColor = System.Drawing.Color.FromName(color).ToArgb();
-                    if (rawColor == 0)
-                    {
-                        rawColor = color.ToColor();
-                        await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Color: Converted string to color: {rawColor}"));
-                        //rawColor = uint.Parse(color.ToColor(), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
-                    }
-                    //return FergunResult.FromError(Locate("InvalidColor"));
-                }
-                argbColor = System.Drawing.Color.FromArgb(rawColor);
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Color: {color.Truncate(30)} -> {rawColor} -> {argbColor}"));
-            }
-
-            using var image = new Image<Rgba32>(500, 500);
-
-            var graphicsOptions = new GraphicsOptions
-            {
-                AlphaCompositionMode = PixelAlphaCompositionMode.Src,
-                ColorBlendingMode = PixelColorBlendingMode.Normal
-            };
-
-            image.Mutate(x => x.Fill(graphicsOptions, SixLabors.ImageSharp.Color.FromRgb(argbColor.R, argbColor.G, argbColor.B)));
-            await using var stream = new MemoryStream();
-            await image.SaveAsPngAsync(stream);
-            stream.Seek(0, SeekOrigin.Begin);
-
-            string hex = $"{argbColor.R:X2}{argbColor.G:X2}{argbColor.B:X2}";
-
-            var builder = new EmbedBuilder()
-                .WithTitle($"#{hex}")
-                .WithImageUrl($"attachment://{hex}.png")
-                .WithFooter($"R: {argbColor.R}, G: {argbColor.G}, B: {argbColor.B}")
-                .WithColor(new Discord.Color(argbColor.R, argbColor.G, argbColor.B));
-
-            await Context.Channel.SendCachedFileAsync(Cache, Context.Message.Id, stream, $"{hex}.png", embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        [AlwaysEnabled]
-        [RequireContext(ContextType.Guild, ErrorMessage = "NotSupportedInDM")]
-        [RequireUserPermission(GuildPermission.ManageGuild, ErrorMessage = "UserRequireManageServer")]
-        [LongRunning]
-        [Command("config", RunMode = RunMode.Async), Ratelimit(1, 2, Measure.Minutes)]
-        [Summary("configSummary")]
-        [Alias("configuration", "settings")]
-        public async Task<RuntimeResult> Config()
-        {
-            string language = GetLanguage();
-            string[] configList = Locate("ConfigList", language).Split(new[] { "\r\n", "\n" }, StringSplitOptions.None);
-            string menuOptions = "";
-            for (int i = 0; i < configList.Length; i++)
-            {
-                menuOptions += $"**{i + 1}.** {configList[i]}\n";
-            }
-
-            var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
-
-            var guildConfig = GetGuildConfig() ?? new GuildConfig(Context.Guild.Id);
-            var options = Enumerable.Range(1, 2).ToDictionary(x => new Emoji($"{x}\ufe0f\u20e3") as IEmote, y => y);
-            options.Add(new Emoji("❌"), -1);
-
-            InteractiveMessageResult<KeyValuePair<IEmote, int>> result = null;
-            IUserMessage message = null;
-
-            while (result == null || result.Status == InteractiveStatus.Success)
-            {
-                var selection = new EmoteSelectionBuilder<int>()
-                    .WithActionOnCancellation(ActionOnStop.DisableInput)
-                    .WithActionOnTimeout(ActionOnStop.DisableInput)
-                    .WithSelectionPage(CreateMenuPage())
-                    .AddUser(Context.User)
-                    .WithOptions(options)
-                    .WithAllowCancel(true)
-                    .Build();
-
-                result = await SendSelectionAsync(selection, TimeSpan.FromMinutes(5), message, cts.Token);
-                message = result.Message;
-
-                if (!result.IsSuccess) break;
-
-                guildConfig = GetGuildConfig() ?? new GuildConfig(Context.Guild.Id);
-
-                switch (result.Value.Value)
-                {
-                    case 1:
-                        guildConfig.AidAutoTranslate = !guildConfig.AidAutoTranslate;
-                        break;
-
-                    case 2:
-                        guildConfig.TrackSelection = !guildConfig.TrackSelection;
-                        break;
-                }
-
-                FergunClient.Database.InsertOrUpdateDocument(Constants.GuildConfigCollection, guildConfig);
-            }
-
-            return FergunResult.FromSuccess();
-
-            PageBuilder CreateMenuPage()
-            {
-                string valueList =
-                    $"{Locate(guildConfig.AidAutoTranslate ? "Yes" : "No", language)}\n" +
-                    $"{Locate(guildConfig.TrackSelection ? "Yes" : "No", language)}";
-
-                var builder = new EmbedBuilder()
-                    .WithAuthor(Context.User)
-                    .WithTitle(Locate("FergunConfig", language))
-                    .WithDescription(Locate("ConfigPrompt", language))
-                    .AddField(Locate("Option", language), menuOptions, true)
-                    .AddField(Locate("Value", language), valueList, true)
-                    .WithColor(FergunClient.Config.EmbedColor);
-
-                return PageBuilder.FromEmbedBuilder(builder);
-            }
-        }
-
-        [LongRunning]
-        [Command("define", RunMode = RunMode.Async), Ratelimit(2, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-        [Summary("defineSummary")]
-        [Alias("def", "definition", "dictionary")]
-        [Example("hi")]
-        public async Task<RuntimeResult> Define([Remainder, Summary("defineParam1")] string word)
-        {
-            IReadOnlyList<DefinitionCategory> results;
-            try
-            {
-                results = await DictionaryApi.GetDefinitionsAsync(word, GetLanguage(), true);
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Error calling Dictionary API", e));
-                return FergunResult.FromError(e.Message);
-            }
-            catch (TaskCanceledException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Error calling Dictionary API", e));
-                return FergunResult.FromError(Locate("RequestTimedOut"));
-            }
-            catch (Newtonsoft.Json.JsonException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Error deserializing Dictionary API response", e));
-                return FergunResult.FromError(Locate("AnErrorOccurred"));
-            }
-
-            var definitions = new List<SimpleDefinitionInfo>();
-            foreach (var result in results ?? Enumerable.Empty<DefinitionCategory>())
-            {
-                foreach (var meaning in result.Meanings)
-                {
-                    foreach (var definition in meaning.Definitions)
-                    {
-                        definitions.Add(new SimpleDefinitionInfo
-                        {
-                            Word = result.Word,
-                            PartOfSpeech = meaning.PartOfSpeech,
-                            Definition = definition.Definition,
-                            Example = definition.Example,
-                            Synonyms = definition.Synonyms,
-                            Antonyms = definition.Antonyms
-                        });
-                    }
-                }
-            }
-
-            if (definitions.Count == 0)
-            {
-                return FergunResult.FromError(Locate("NoResultsFound"));
-            }
-
-            string wordText = Locate("Word");
-            string definitionText = Locate("Definition");
-            string paginatorFooter = Locate("PaginatorFooter");
-            string exampleText = Locate("Example");
-            string synonymsText = Locate("Synonyms");
-            string antonymsText = Locate("Antonyms");
-
-            Task<PageBuilder> GeneratePageAsync(int index)
-            {
-                var info = definitions[index];
-
-                var pageBuilder = new PageBuilder()
-                    .WithColor(new Discord.Color(FergunClient.Config.EmbedColor))
-                    .WithTitle("Define")
-                    .AddField(wordText, info.PartOfSpeech == null || info.PartOfSpeech == "undefined" ? info.Word : $"{info.Word} ({info.PartOfSpeech})")
-                    .AddField(definitionText, info.Definition)
-                    .WithFooter(string.Format(paginatorFooter, index + 1, definitions.Count));
-
-                if (!string.IsNullOrEmpty(info.Example))
-                {
-                    pageBuilder.AddField(exampleText, info.Example);
-                }
-                if (info.Synonyms.Count > 0)
-                {
-                    pageBuilder.AddField(synonymsText, string.Join(", ", info.Synonyms));
-                }
-                if (info.Antonyms.Count > 0)
-                {
-                    pageBuilder.AddField(antonymsText, string.Join(", ", info.Antonyms));
-                }
-
-                return Task.FromResult(pageBuilder);
-            }
-
-            var paginator = new LazyPaginatorBuilder()
-                .AddUser(Context.User)
-                .WithOptions(CommandUtils.GetFergunPaginatorEmotes(FergunClient.Config))
-                .WithMaxPageIndex(definitions.Count - 1)
-                .WithPageFactory(GeneratePageAsync)
-                .WithFooter(PaginatorFooter.None)
-                .WithActionOnCancellation(ActionOnStop.DisableInput)
-                .WithActionOnTimeout(ActionOnStop.DisableInput)
-                .WithDeletion(DeletionOptions.Valid)
-                .Build();
-
-            _ = SendPaginatorAsync(paginator, Constants.PaginatorTimeout);
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("editsnipe", RunMode = RunMode.Async)]
-        [Summary("editsnipeSummary")]
-        [Alias("esnipe")]
-        [Example("#bots")]
-        public async Task EditSnipe([Summary("snipeParam1")] IMessageChannel channel = null)
-        {
-            channel ??= Context.Channel;
-            var message = _messageCache
-                .GetCacheForChannel(channel, MessageSourceEvent.MessageUpdated)
-                .Values
-                .OrderByDescending(x => x.CachedAt)
-                .FirstOrDefault(x => !GuildUtils.UserConfigCache.TryGetValue(x.Author.Id, out var config) || !config.IsOptedOutSnipe);
-
-            var builder = new EmbedBuilder();
-            if (message == null)
-            {
-                builder.WithDescription(string.Format(Locate("NothingToSnipe"), MentionUtils.MentionChannel(channel.Id)));
-            }
-            else
-            {
-                builder.WithAuthor(message.Author)
-                    .WithDescription((message.OriginalMessage?.Content ?? message.Content).Truncate(EmbedBuilder.MaxDescriptionLength))
-                    .WithFooter($"{Locate("In")} #{message.Channel.Name}")
-                    .WithTimestamp(message.CreatedAt);
-
-                if (Random.Shared.Next(5) == 4)
-                {
-                    builder.AddField(Locate("Privacy"), Locate("SnipePrivacy"));
-                }
-            }
-            builder.WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-        }
-
-        [AlwaysEnabled]
-        [LongRunning]
-        [Command("help", RunMode = RunMode.Async)]
-        [Summary("helpSummary")]
-        [Example("help")]
-        public async Task<RuntimeResult> Help([Remainder, Summary("helpParam1")] string commandName = null)
-        {
-            var builder = new EmbedBuilder();
-            if (commandName == null)
-            {
-                builder.Title = Locate("CommandList");
-
-                InitializeCmdListCache();
-
-                foreach (var module in _commandListCache)
-                {
-                    string name = module.Key == "AIDungeon"
-                        ? string.Format(Locate($"{module.Key}Commands"), GetPrefix())
-                        : Locate($"{module.Key}Commands");
-
-                    builder.AddField(name, module.Value);
-                }
-
-                MessageComponent component = null;
-
-                if (!FergunClient.IsDebugMode)
-                {
-                    component = CommandUtils.BuildLinks(Context.Channel);
-                }
-                builder.WithFooter(string.Format(Locate("HelpFooter"), $"v{Constants.Version}", _cachedVisibleCmdCount))
-                    .WithColor(FergunClient.Config.EmbedColor);
-
-                await ReplyAsync(embed: builder.Build(), component: component);
-            }
-            else
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Help: Getting help for command: {commandName}"));
-
-                var command = _cmdService.Commands.FirstOrDefault(x => // Match commands ignoring their groups.
-                    x.Aliases.Any(y => (y.Split(' ').ElementAtOrDefault(1) ?? y) == commandName.ToLowerInvariant()) &&
-                    x.Module.Name != Constants.DevelopmentModuleName);
-                if (command == null)
-                {
-                    return FergunResult.FromError(string.Format(Locate("CommandNotFound"), GetPrefix()));
-                }
-                var embed = command.ToHelpEmbed(GetLanguage(), GetPrefix());
-                await ReplyAsync(embed: embed);
-            }
-            return FergunResult.FromSuccess();
-        }
-
-        [LongRunning]
-        [Command("img", RunMode = RunMode.Async), Ratelimit(1, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-        [Summary("imgSummary")]
-        [Alias("im", "image")]
-        [Example("discord")]
-        public async Task<RuntimeResult> Img([Remainder, Summary("imgParam1")] string query)
-        {
-            query = query.Trim();
-            bool isNsfwChannel = Context.IsNsfw();
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Img: Query \"{query}\", NSFW channel: {isNsfwChannel}"));
-
-            IEnumerable<GoogleImageResult> images;
-            try
-            {
-                images = await _googleScraper.GetImagesAsync(query, isNsfwChannel ? SafeSearchLevel.Off : SafeSearchLevel.Strict, language: GetLanguage());
-            }
-            catch (Exception e) when (e is HttpRequestException || e is TaskCanceledException || e is GScraperException)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Error searching images. Using DuckDuckGo", e));
-                return await Img2(query);
-            }
-
-            var filteredImages = images
-                .Where(x =>
-                    Uri.IsWellFormedUriString(x.Url, UriKind.Absolute) &&
-                    x.Url.StartsWith("http", StringComparison.Ordinal) &&
-                    Uri.IsWellFormedUriString(x.SourceUrl, UriKind.Absolute) &&
-                    x.SourceUrl.StartsWith("http", StringComparison.Ordinal))
-                .ToArray();
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Google Images: Results count: {filteredImages.Length}"));
-
-            if (filteredImages.Length == 0)
-            {
-                return FergunResult.FromError(Locate("NoResultsFound"));
-            }
-
-            string imageSearch = Locate("ImageSearch");
-            string paginatorFooter = Locate("PaginatorFooter");
-
-            Task<PageBuilder> GeneratePageAsync(int index)
-            {
-                var pageBuilder = new PageBuilder()
-                    .WithAuthor(Context.User)
-                    .WithColor(new Discord.Color(FergunClient.Config.EmbedColor))
-                    .WithTitle(filteredImages[index].Title.Truncate(EmbedBuilder.MaxTitleLength))
-                    .WithUrl(filteredImages[index].SourceUrl)
-                    .WithDescription(imageSearch)
-                    .WithImageUrl(filteredImages[index].Url)
-                    .WithFooter(string.Format(paginatorFooter, index + 1, filteredImages.Length), Constants.GoogleLogoUrl);
-
-                return Task.FromResult(pageBuilder);
-            }
-
-            var paginator = new LazyPaginatorBuilder()
-                .AddUser(Context.User)
-                .WithOptions(CommandUtils.GetFergunPaginatorEmotes(FergunClient.Config))
-                .WithMaxPageIndex(filteredImages.Length - 1)
-                .WithPageFactory(GeneratePageAsync)
-                .WithFooter(PaginatorFooter.None)
-                .WithActionOnCancellation(ActionOnStop.DisableInput)
-                .WithActionOnTimeout(ActionOnStop.DisableInput)
-                .WithDeletion(DeletionOptions.Valid)
-                .Build();
-
-            _ = SendPaginatorAsync(paginator, Constants.PaginatorTimeout);
-
-            return FergunResult.FromSuccess();
-        }
-
-        [LongRunning]
-        [Command("img2", RunMode = RunMode.Async), Ratelimit(1, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-        [Summary("img2Summary")]
-        [Alias("im2", "image2", "ddgi")]
-        [Example("discord")]
-        public async Task<RuntimeResult> Img2([Remainder, Summary("img2Param1")] string query)
-        {
-            query = query.Replace("!", "", StringComparison.OrdinalIgnoreCase).Trim();
-            if (string.IsNullOrEmpty(query))
-            {
-                return FergunResult.FromError(Locate("NoResultsFound"));
-            }
-            if (query.Length > DuckDuckGoScraper.MaxQueryLength)
-            {
-                return FergunResult.FromError(string.Format(Locate("MustBeLowerThan"), nameof(query), DuckDuckGoScraper.MaxQueryLength));
-            }
-
-            bool isNsfwChannel = Context.IsNsfw();
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Img2: Query \"{query}\", NSFW channel: {isNsfwChannel}"));
-
-            IEnumerable<DuckDuckGoImageResult> images;
-            try
-            {
-                images = await _ddgScraper.GetImagesAsync(query, isNsfwChannel ? SafeSearchLevel.Off : SafeSearchLevel.Strict);
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Error searching images", e));
-                return FergunResult.FromError(e.Message);
-            }
-            catch (TaskCanceledException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Error searching images", e));
-                return FergunResult.FromError(Locate("RequestTimedOut"));
-            }
-            catch (GScraperException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Error searching images", e));
-                return FergunResult.FromError(Locate("NoResultsFound"));
-            }
-
-            var filteredImages = images
-                .Where(x =>
-                    Uri.IsWellFormedUriString(x.Url, UriKind.Absolute) &&
-                    x.Url.StartsWith("http", StringComparison.Ordinal) &&
-                    Uri.IsWellFormedUriString(x.SourceUrl, UriKind.Absolute) &&
-                    x.SourceUrl.StartsWith("http", StringComparison.Ordinal))
-                .ToArray();
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"DuckDuckGo Images: Results count: {filteredImages.Length}"));
-
-            if (filteredImages.Length == 0)
-            {
-                return FergunResult.FromError(Locate("NoResultsFound"));
-            }
-
-            string imageSearch = Locate("ImageSearch");
-            string paginatorFooter = Locate("PaginatorFooter");
-
-            Task<PageBuilder> GeneratePageAsync(int index)
-            {
-                var pageBuilder = new PageBuilder()
-                    .WithAuthor(Context.User)
-                    .WithColor(new Discord.Color(FergunClient.Config.EmbedColor))
-                    .WithTitle(filteredImages[index].Title.Truncate(EmbedBuilder.MaxTitleLength))
-                    .WithUrl(filteredImages[index].SourceUrl)
-                    .WithDescription(imageSearch)
-                    .WithImageUrl(filteredImages[index].Url)
-                    .WithFooter(string.Format(paginatorFooter, index + 1, filteredImages.Length), Constants.DuckDuckGoLogoUrl);
-
-                return Task.FromResult(pageBuilder);
-            }
-
-            var paginator = new LazyPaginatorBuilder()
-                .AddUser(Context.User)
-                .WithOptions(CommandUtils.GetFergunPaginatorEmotes(FergunClient.Config))
-                .WithMaxPageIndex(filteredImages.Length - 1)
-                .WithPageFactory(GeneratePageAsync)
-                .WithFooter(PaginatorFooter.None)
-                .WithActionOnCancellation(ActionOnStop.DisableInput)
-                .WithActionOnTimeout(ActionOnStop.DisableInput)
-                .WithDeletion(DeletionOptions.Valid)
-                .Build();
-
-            _ = SendPaginatorAsync(paginator, Constants.PaginatorTimeout);
-
-            return FergunResult.FromSuccess();
-        }
-
-        [LongRunning]
-        [Command("img3", RunMode = RunMode.Async), Ratelimit(1, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-        [Summary("img3Summary")]
-        [Alias("im3", "image3", "brave")]
-        [Example("discord")]
-        public async Task<RuntimeResult> Img3([Remainder, Summary("img3Param1")] string query)
-        {
-            query = query.Trim();
-            bool isNsfwChannel = Context.IsNsfw();
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Img3: Query \"{query}\", NSFW channel: {isNsfwChannel}"));
-
-            IEnumerable<BraveImageResult> images;
-            try
-            {
-                images = await _braveScraper.GetImagesAsync(query, isNsfwChannel ? SafeSearchLevel.Off : SafeSearchLevel.Strict);
-            }
-            catch (Exception e) when (e is HttpRequestException || e is TaskCanceledException || e is GScraperException)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Error searching images. Using Google", e));
-                return await Img(query);
-            }
-
-            var filteredImages = images
-                .Where(x =>
-                    Uri.IsWellFormedUriString(x.Url, UriKind.Absolute) &&
-                    x.Url.StartsWith("http", StringComparison.Ordinal) &&
-                    Uri.IsWellFormedUriString(x.SourceUrl, UriKind.Absolute) &&
-                    x.SourceUrl.StartsWith("http", StringComparison.Ordinal))
-                .ToArray();
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Brave Search: Results count: {filteredImages.Length}"));
-
-            if (filteredImages.Length == 0)
-            {
-                return FergunResult.FromError(Locate("NoResultsFound"));
-            }
-
-            string imageSearch = Locate("ImageSearch");
-            string paginatorFooter = Locate("PaginatorFooter");
-
-            Task<PageBuilder> GeneratePageAsync(int index)
-            {
-                var pageBuilder = new PageBuilder()
-                    .WithAuthor(Context.User)
-                    .WithColor(new Discord.Color(FergunClient.Config.EmbedColor))
-                    .WithTitle(filteredImages[index].Title.Truncate(EmbedBuilder.MaxTitleLength))
-                    .WithUrl(filteredImages[index].SourceUrl)
-                    .WithDescription(imageSearch)
-                    .WithImageUrl(filteredImages[index].Url)
-                    .WithFooter(string.Format(paginatorFooter, index + 1, filteredImages.Length), Constants.BraveLogoUrl);
-
-                return Task.FromResult(pageBuilder);
-            }
-
-            var paginator = new LazyPaginatorBuilder()
-                .AddUser(Context.User)
-                .WithOptions(CommandUtils.GetFergunPaginatorEmotes(FergunClient.Config))
-                .WithMaxPageIndex(filteredImages.Length - 1)
-                .WithPageFactory(GeneratePageAsync)
-                .WithFooter(PaginatorFooter.None)
-                .WithActionOnCancellation(ActionOnStop.DisableInput)
-                .WithActionOnTimeout(ActionOnStop.DisableInput)
-                .WithDeletion(DeletionOptions.Valid)
-                .Build();
-
-            _ = SendPaginatorAsync(paginator, Constants.PaginatorTimeout);
-
-            return FergunResult.FromSuccess();
-        }
-
-        [LongRunning]
-        [RequireBotPermission(ChannelPermission.AttachFiles, ErrorMessage = "BotRequireAttachFiles")]
-        [Command("invert", RunMode = RunMode.Async), Ratelimit(2, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-        [Summary("invertSummary")]
-        [Alias("negate", "negative")]
-        [Example("https://www.fergun.com/image.png")]
-        public async Task<RuntimeResult> Invert([Remainder, Summary("invertParam1")] string url = null)
-        {
-            UrlFindResult result;
-            (url, result) = await Context.GetLastUrlAsync(FergunClient.Config.MessagesToSearchLimit, _messageCache, true, url);
-            if (result != UrlFindResult.UrlFound)
-            {
-                return FergunResult.FromError(string.Format(Locate(result.ToString()), FergunClient.Config.MessagesToSearchLimit));
-            }
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Invert: url to use: {url}"));
-
-            Stream response;
-            try
-            {
-                response = await _httpClient.GetStreamAsync(new Uri(url));
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error getting the image from url: {url}", e));
-                return FergunResult.FromError(e.Message);
-            }
-            catch (TaskCanceledException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error getting the image from url: {url}", e));
-                return FergunResult.FromError(Locate("RequestTimedOut"));
-            }
-
-            var (img, format) = await SixLabors.ImageSharp.Image.LoadWithFormatAsync(response);
-            img.Mutate(x => x.Invert());
-
-            await using var stream = new MemoryStream();
-            await img.SaveAsync(stream, format);
-            stream.Seek(0, SeekOrigin.Begin);
-
-            if (stream.Length > Constants.AttachmentSizeLimit)
-            {
-                return FergunResult.FromError("The file is too large.");
-            }
-
-            string fileName = $"invert.{format.FileExtensions.FirstOrDefault() ?? "dat"}";
-
-            var builder = new EmbedBuilder()
-                .WithTitle("Invert")
-                .WithImageUrl($"attachment://{fileName}")
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await Context.Channel.SendCachedFileAsync(Cache, Context.Message.Id, stream, fileName, embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("lmgtfy", RunMode = RunMode.Async)]
-        [Summary("lmgtfySummary")]
-        public async Task Lmgtfy([Remainder, Summary("lmgtfyParam1")] string query)
-        {
-            await ReplyAsync($"https://lmgtfy.com/?q={Uri.EscapeDataString(query)}", allowedMentions: AllowedMentions.None);
-        }
-
-        [LongRunning]
-        [Command("ocr", RunMode = RunMode.Async), Ratelimit(2, 1, Measure.Minutes)]
-        [Summary("ocrSummary")]
-        [Remarks("NoUrlPassed")]
-        [Example("https://www.fergun.com/image.png")]
-        public async Task<RuntimeResult> Ocr([Summary("ocrParam1")] string url = null)
-        {
-            UrlFindResult result;
-            (url, result) = await Context.GetLastUrlAsync(FergunClient.Config.MessagesToSearchLimit, _messageCache, true, url, long.MaxValue);
-            if (result != UrlFindResult.UrlFound)
-            {
-                return FergunResult.FromError(string.Format(Locate(result.ToString()), FergunClient.Config.MessagesToSearchLimit));
-            }
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Ocr: url to use: {url}"));
-
-            (string error, string text) = await BingOcrAsync(url);
-            if (!int.TryParse(error, out int processTime))
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Ocr: Failed to get OCR from Bing, using Yandex..."));
-
-                (error, text) = await YandexOcrAsync(url);
-                if (!int.TryParse(error, out processTime))
-                {
-                    string message = Locate(error);
-                    if (!string.IsNullOrEmpty(text))
-                        message += $"\n{Locate("ErrorMessage")}: {text}";
-
-                    return FergunResult.FromError(message);
-                }
-            }
-
-            if (text.Length > EmbedFieldBuilder.MaxFieldValueLength - 10)
-            {
-                try
-                {
-                    var hastebinUrl = await Hastebin.UploadAsync(text);
-                    text = Format.Url(Locate("HastebinLink"), hastebinUrl);
-                }
-                catch (Exception e) when (e is HttpRequestException || e is TaskCanceledException)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Ocr: Error while uploading text to Hastebin", e));
-                    text = Format.Code(text.Truncate(EmbedFieldBuilder.MaxFieldValueLength - 10), "md");
-                }
-            }
-            else
-            {
-                text = Format.Code(text.Truncate(EmbedFieldBuilder.MaxFieldValueLength - 10), "md");
-            }
-
-            var builder = new EmbedBuilder()
-                .WithTitle(Locate("OcrResults"))
-                .AddField(Locate("Output"), text)
-                .WithFooter(string.Format(Locate("ProcessingTime"), processTime))
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        [LongRunning]
-        [Command("ocrtranslate", RunMode = RunMode.Async), Ratelimit(2, 1, Measure.Minutes)]
-        [Summary("ocrtranslateSummary")]
-        [Alias("ocrtr")]
-        [Remarks("NoUrlPassed")]
-        [Example("en https://www.fergun.com/image.png")]
-        public async Task<RuntimeResult> OcrTranslate([Summary("ocrtranslateParam1")] string target,
-            [Summary("ocrtranslateParam2")] string url = null)
-        {
-            if (!_translator.IsLanguageSupported(target))
-            {
-                return FergunResult.FromError(string.Format(Locate("InvalidLanguage"), GetPrefix()));
-            }
-
-            UrlFindResult result;
-            (url, result) = await Context.GetLastUrlAsync(FergunClient.Config.MessagesToSearchLimit, _messageCache, true, url, long.MaxValue);
-            if (result != UrlFindResult.UrlFound)
-            {
-                return FergunResult.FromError(string.Format(Locate(result.ToString()), FergunClient.Config.MessagesToSearchLimit));
-            }
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Orctranslate: url to use: {url}"));
-
-            (string error, string text) = await BingOcrAsync(url);
-            if (!int.TryParse(error, out int processTime))
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Ocrtranslate: Failed to get OCR from Bing, using Yandex..."));
-
-                (error, text) = await YandexOcrAsync(url);
-                if (!int.TryParse(error, out processTime))
-                {
-                    string message = Locate(error);
-                    if (!string.IsNullOrEmpty(text))
-                        message += $"\n{Locate("ErrorMessage")}: {text}";
-
-                    return FergunResult.FromError(message);
-                }
-            }
-
-            var sw = Stopwatch.StartNew();
-            ITranslationResult translationResult;
-            try
-            {
-                translationResult = await _translator.TranslateAsync(text, target);
-            }
-            catch (Exception e) when (e is TranslatorException || e is HttpRequestException)
-            {
-                return FergunResult.FromError(e.Message);
-            }
-            catch (AggregateException e)
-            {
-                return FergunResult.FromError(e.InnerExceptions.FirstOrDefault()?.Message ?? e.Message);
-            }
-            finally
-            {
-                sw.Stop();
-            }
-
-            string translation = translationResult.Translation;
-            if (text.Length > EmbedFieldBuilder.MaxFieldValueLength - 10)
-            {
-                try
-                {
-                    var hastebinUrl = await Hastebin.UploadAsync(translation);
-                    translation = Format.Url(Locate("HastebinLink"), hastebinUrl);
-                }
-                catch (Exception e) when (e is HttpRequestException || e is TaskCanceledException)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Ocrtranslate: Error while uploading text to Hastebin", e));
-                    translation = Format.Code(translation.Truncate(EmbedFieldBuilder.MaxFieldValueLength - 10), "md");
-                }
-            }
-            else
-            {
-                translation = Format.Code(translation.Truncate(EmbedFieldBuilder.MaxFieldValueLength - 10), "md");
-            }
-
-            var builder = new EmbedBuilder()
-                .WithTitle(Locate("OcrtrResults"))
-                .AddField(Locate("Input"), Format.Code(text.Truncate(EmbedFieldBuilder.MaxFieldValueLength - 10), "md"))
-                .AddField(Locate("SourceLanguage"), translationResult.SourceLanguage.Name)
-                .AddField(Locate("TargetLanguage"), translationResult.TargetLanguage.Name)
-                .AddField(Locate("Result"), translation)
-                .WithFooter(string.Format(Locate("ProcessingTime"), processTime + sw.ElapsedMilliseconds))
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        //[LongRunning]
-        [Command("paste", RunMode = RunMode.Async), Ratelimit(1, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-        [Summary("pasteSummary")]
-        [Alias("haste")]
-        public async Task<RuntimeResult> Paste([Remainder, Summary("pasteParam1")] string text)
-        {
-            var message = await SendEmbedAsync($"{FergunClient.Config.LoadingEmote} {Locate("Uploading")}");
-            try
-            {
-                string hastebinUrl = await Hastebin.UploadAsync(text);
-                var builder = new EmbedBuilder()
-                    .WithDescription(Format.Url(Locate("HastebinLink"), hastebinUrl))
-                    .WithColor(FergunClient.Config.EmbedColor);
-
-                await message.ModifyOrResendAsync(embed: builder.Build(), cache: _messageCache);
-            }
-            catch (Exception e) when (e is HttpRequestException || e is TaskCanceledException)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Paste: Error while uploading text to Hastebin", e));
-                return FergunResult.FromError(e.Message);
-            }
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("ping", RunMode = RunMode.Async)]
-        [Summary("pingSummary")]
-        public async Task Ping()
-        {
-            var sw = Stopwatch.StartNew();
-            var message = await SendEmbedAsync(Format.Bold("Pong!"));
-            sw.Stop();
-
-            var sw2 = Stopwatch.StartNew();
-            FergunClient.Database.FindDocument<GuildConfig>(Constants.GuildConfigCollection, _ => true);
-            sw2.Stop();
-
-            var builder = new EmbedBuilder()
-                .WithDescription($"⏱{Format.Bold("Message")}: {sw.ElapsedMilliseconds}ms\n\n" +
-                                 $"{FergunClient.Config.WebSocketEmote}{Format.Bold("WebSocket")}: {Context.Client.Latency}ms\n\n" +
-                                 $"{FergunClient.Config.MongoDbEmote}{Format.Bold("Database")}: {Math.Round(sw2.Elapsed.TotalMilliseconds, 2)}ms")
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await message.ModifyAsync(x => x.Embed = builder.Build());
-        }
-
-        [LongRunning]
-        [Command("resize", RunMode = RunMode.Async), Ratelimit(1, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-        [Summary("resizeSummary")]
-        [Alias("waifu2x", "w2x")]
-        [Remarks("NoUrlPassed")]
-        [Example("https://www.fergun.com/image.png")]
-        public async Task<RuntimeResult> Resize([Summary("resizeParam1")] string url = null)
-        {
-            if (string.IsNullOrEmpty(FergunClient.Config.DeepAiApiKey))
-            {
-                return FergunResult.FromError(string.Format(Locate("ValueNotSetInConfig"), nameof(FergunConfig.DeepAiApiKey)));
-            }
-
-            UrlFindResult result;
-            (url, result) = await Context.GetLastUrlAsync(FergunClient.Config.MessagesToSearchLimit, _messageCache, true, url);
-            if (result != UrlFindResult.UrlFound)
-            {
-                return FergunResult.FromError(string.Format(Locate(result.ToString()), FergunClient.Config.MessagesToSearchLimit));
-            }
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Resize: url to use: {url}"));
-
-            string json;
-            try
-            {
-                using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.deepai.org/api/waifu2x");
-                request.Headers.Add("Api-Key", FergunClient.Config.DeepAiApiKey);
-                request.Content = new FormUrlEncodedContent(new[] { new KeyValuePair<string, string>("image", url) });
-                var response = await _httpClient.SendAsync(request);
-                response.EnsureSuccessStatusCode();
-                json = await response.Content.ReadAsStringAsync();
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Error calling waifu2x API", e));
-                return FergunResult.FromError(e.Message);
-            }
-            catch (TaskCanceledException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Error calling waifu2x API", e));
-                return FergunResult.FromError(Locate("RequestTimedOut"));
-            }
-
-            using var document = JsonDocument.Parse(json);
-
-            string resultUrl = document
-                .RootElement
-                .GetPropertyOrDefault("output_url")
-                .GetStringOrDefault();
-
-            if (string.IsNullOrWhiteSpace(resultUrl))
-            {
-                return FergunResult.FromError(Locate("AnErrorOccurred"));
-            }
-
-            var builder = new EmbedBuilder()
-                .WithTitle(Locate("ResizeResults"))
-                .WithImageUrl(resultUrl)
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-            return FergunResult.FromSuccess();
-        }
-
-        [RequireContext(ContextType.Guild, ErrorMessage = "NotSupportedInDM")]
-        [Command("roleinfo")]
-        [Summary("roleinfoSummary")]
-        [Alias("role")]
-        [Example("Devs")]
-        public async Task<RuntimeResult> RoleInfo([Remainder, Summary("roleinfoParam1")] SocketRole role)
-        {
-            int memberCount = role.Members.Count();
-
-            var builder = new EmbedBuilder()
-                .WithTitle(Locate("RoleInfo"))
-                .AddField(Locate("Name"), role.Name, true)
-                .AddField(Locate("Color"), $"{role.Color} ({role.Color.R}, {role.Color.G}, {role.Color.B})", true)
-                .AddField(Locate("IsMentionable"), Locate(role.IsMentionable), true)
-                .AddField("ID", role.Id, true)
-                .AddField(Locate("IsHoisted"), Locate(role.IsHoisted), true)
-                .AddField(Locate("Position"), role.Position, true)
-                .AddField(Locate("Permissions"), role.Permissions.RawValue == 0 ? Locate("None") : Format.Code(string.Join("`, `", role.Permissions.ToList())))
-                .AddField(Locate("MemberCount"), Context.Guild.HasAllMembers ? memberCount.ToString() : memberCount == 0 ? "?" : "~" + memberCount, true)
-                .AddField(Locate("CreatedAt"), role.CreatedAt.ToDiscordTimestamp(), true)
-                .AddField(Locate("Mention"), role.Mention, true)
-                .WithColor(role.Color)
-                .WithThumbnailUrl(role.GetIconUrl());
-
-            await ReplyAsync(embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        // The attribute order matters
-        [RequireNsfw(ErrorMessage = "NSFWOnly")]
-        [RequireBotPermission(ChannelPermission.AttachFiles, ErrorMessage = "BotRequireAttachFiles")]
-        [LongRunning]
-        [Command("screenshot", RunMode = RunMode.Async), Ratelimit(2, 1, Measure.Minutes)]
-        [Summary("screenshotSummary")]
-        [Alias("ss")]
-        [Example("https://www.fergun.com")]
-        public async Task<RuntimeResult> Screenshot([Summary("screenshotParam1")] string url)
-        {
-            if (string.IsNullOrEmpty(FergunClient.Config.ApiFlashAccessKey))
-            {
-                return FergunResult.FromError(string.Format(Locate("ValueNotSetInConfig"), nameof(FergunConfig.ApiFlashAccessKey)));
-            }
-
-            Uri uri;
-            try
-            {
-                uri = new UriBuilder(Uri.UnescapeDataString(url)).Uri;
-            }
-            catch (UriFormatException)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Screenshot: Invalid url: {Uri.UnescapeDataString(url)}"));
-                return FergunResult.FromError(Locate("InvalidUrl"));
-            }
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Screenshot: Url: {uri.AbsoluteUri}"));
-
-            ApiFlashResponse response;
-            try
-            {
-                response = await ApiFlash.UrlToImageAsync(FergunClient.Config.ApiFlashAccessKey, uri.AbsoluteUri, ApiFlash.FormatType.Png, "400,403,404,500-511");
-            }
-            catch (ArgumentException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Screenshot: Error in API", e));
-                return FergunResult.FromError(Locate("InvalidUrl"));
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Screenshot: Error in API", e));
-                return FergunResult.FromError(e.Message);
-            }
-            catch (TaskCanceledException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Screenshot: Error in API", e));
-                return FergunResult.FromError(Locate("RequestTimedOut"));
-            }
-
-            if (response.ErrorMessage != null)
-            {
-                return FergunResult.FromError(response.ErrorMessage);
-            }
-
-            try
-            {
-                var builder = new EmbedBuilder()
-                    .WithTitle("Screenshot")
-                    .WithDescription($"Url: {uri}".Truncate(EmbedBuilder.MaxDescriptionLength))
-                    .WithImageUrl("attachment://screenshot.png")
-                    .WithColor(FergunClient.Config.EmbedColor);
-
-                await using var image = await _httpClient.GetStreamAsync(new Uri(response.Url));
-                await Context.Channel.SendCachedFileAsync(Cache, Context.Message.Id, image, "screenshot.png", embed: builder.Build());
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error getting the image from url: {url}", e));
-                return FergunResult.FromError(e.Message);
-            }
-            catch (TaskCanceledException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error getting the image from url: {url}", e));
-                return FergunResult.FromError(Locate("RequestTimedOut"));
-            }
-
-            return FergunResult.FromSuccess();
-        }
-
-        [RequireContext(ContextType.Guild, ErrorMessage = "NotSupportedInDM")]
-        [Command("serverinfo", RunMode = RunMode.Async)]
-        [Summary("serverinfoSummary")]
-        [Alias("server", "guild", "guildinfo")]
-        public async Task<RuntimeResult> ServerInfo(string serverId = null)
-        {
-            SocketGuild server;
-            if (Context.User.Id == (await Context.Client.GetApplicationInfoAsync()).Owner.Id)
-            {
-                server = serverId == null ? Context.Guild : Context.Client.GetGuild(ulong.Parse(serverId));
-                if (server == null)
-                {
-                    return FergunResult.FromError(Locate("GuildNotFound"));
-                }
-            }
-            else
-            {
-                server = Context.Guild;
-            }
-
-            string features = server.Features.Value == GuildFeature.None ? Locate("None") : string.Join(", ", server.Features.Value);
-
-            string channelCountInfo = $"{server.TextChannels.Count + server.VoiceChannels.Count} " +
-                                      $"({FergunClient.Config.TextEmote} {server.TextChannels.Count} **|** " +
-                                      $"{FergunClient.Config.VoiceEmote} {server.VoiceChannels.Count})";
-
-            var builder = new EmbedBuilder()
-                .WithTitle(Locate("ServerInfo"))
-
-                .AddField(Locate("Name"), server.Name, true)
-                .AddField(Locate("Owner"), MentionUtils.MentionUser(server.OwnerId), true)
-                .AddField("ID", server.Id, true)
-
-                .AddField(Locate("CategoryCount"), server.CategoryChannels.Count, true)
-                .AddField(Locate("ChannelCount"), channelCountInfo, true)
-                .AddField(Locate("RoleCount"), server.Roles.Count, true)
-
-                .AddField(Locate("DefaultChannel"), server.DefaultChannel?.Mention ?? Locate("None"), true)
-                .AddField(Locate("Region"), Format.Code(server.VoiceRegionId), true)
-                .AddField(Locate("VerificationLevel"), Locate(server.VerificationLevel.ToString()), true)
-
-                .AddField(Locate("BoostTier"), (int)server.PremiumTier, true)
-                .AddField(Locate("BoostCount"), server.PremiumSubscriptionCount, true)
-                .AddField(Locate("ServerFeatures"), features, true);
-
-            if (server.HasAllMembers && FergunClient.Config.PresenceIntent)
-            {
-                builder.AddField(Locate("Members"), $"{server.MemberCount} (Bots: {server.Users.Count(x => x.IsBot)}) **|** " +
-                $"{FergunClient.Config.OnlineEmote} {server.Users.Count(x => x.Status == UserStatus.Online)} **|** " +
-                $"{FergunClient.Config.IdleEmote} {server.Users.Count(x => x.Status == UserStatus.Idle)} **|** " +
-                $"{FergunClient.Config.DndEmote} {server.Users.Count(x => x.Status == UserStatus.DoNotDisturb)} **|** " +
-                $"{FergunClient.Config.StreamingEmote} {server.Users.Count(x => x.Activities.Any(y => y.Type == ActivityType.Streaming))} **|** " +
-                $"{FergunClient.Config.OfflineEmote} {server.Users.Count(x => x.Status == UserStatus.Offline)}");
-            }
-            else
-            {
-                builder.AddField(Locate("Members"), server.HasAllMembers ? "~" : "" + server.MemberCount, true);
-            }
-
-            builder.AddField(Locate("CreatedAt"), $"{server.CreatedAt.ToDiscordTimestamp()} ({server.CreatedAt.ToDiscordTimestamp('R')})", true)
-                .WithThumbnailUrl($"https://cdn.discordapp.com/icons/{server.Id}/{server.IconId}")
-                .WithImageUrl(server.BannerUrl)
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-            return FergunResult.FromSuccess();
-        }
-
-        [LongRunning]
-        [Command("shorten", RunMode = RunMode.Async), Ratelimit(1, 1, Measure.Minutes)]
-        [Summary("shortenSummary")]
-        [Alias("short")]
-        [Example("https://www.fergun.com")]
-        public async Task<RuntimeResult> Shorten([Summary("shortenParam1")] string url)
-        {
-            Uri uri;
-            try
-            {
-                uri = new UriBuilder(Uri.UnescapeDataString(url)).Uri;
-            }
-            catch (UriFormatException)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Shorten: Invalid url: {Uri.UnescapeDataString(url)}"));
-                return FergunResult.FromError(Locate("InvalidUrl"));
-            }
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Shorten: Url: {uri.AbsoluteUri}"));
-
-            HttpResponseMessage response;
-            try
-            {
-                // GetStringAsync() hides the content message in case of error.
-                response = await _httpClient.GetAsync(new Uri($"https://is.gd/create.php?format=simple&url={Uri.EscapeDataString(uri.AbsoluteUri)}"));
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error in is.gd API, url: {url}", e));
-                return FergunResult.FromError(e.Message);
-            }
-            catch (TaskCanceledException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error in is.gd API, url: {url}", e));
-                return FergunResult.FromError(Locate("RequestTimedOut"));
-            }
-
-            string shortenedUrl = await response.Content.ReadAsStringAsync();
-
-            if (shortenedUrl.StartsWith("Error:", StringComparison.OrdinalIgnoreCase))
-            {
-                return FergunResult.FromError(shortenedUrl);
-            }
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Shorten: Shortened Url: {shortenedUrl}"));
-            await ReplyAsync(shortenedUrl);
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("snipe", RunMode = RunMode.Async)]
-        [Summary("snipeSummary")]
-        [Example("#help")]
-        public async Task Snipe([Summary("snipeParam1")] IMessageChannel channel = null)
-        {
-            channel ??= Context.Channel;
-
-            var message = _messageCache
-                .GetCacheForChannel(channel, MessageSourceEvent.MessageDeleted)
-                .Values
-                .OrderByDescending(x => x.CachedAt)
-                .FirstOrDefault(x => !GuildUtils.UserConfigCache.TryGetValue(x.Author.Id, out var config) || !config.IsOptedOutSnipe);
-
-            var builder = new EmbedBuilder();
-            if (message == null)
-            {
-                builder.WithDescription(string.Format(Locate("NothingToSnipe"), MentionUtils.MentionChannel(channel.Id)));
-            }
-            else
-            {
-                string text = !string.IsNullOrEmpty(message.Content) ? message.Content : message.Attachments.Count > 0 ? $"({Locate("Attachment")})" : "?";
-
-                builder.WithAuthor(message.Author)
-                    .WithDescription(text.Truncate(EmbedBuilder.MaxDescriptionLength))
-                    .WithFooter($"{Locate("In")} #{message.Channel.Name}")
-                    .WithTimestamp(message.CreatedAt);
-
-                if (Random.Shared.Next(5) == 4)
-                {
-                    builder.AddField(Locate("Privacy"), Locate("SnipePrivacy"));
-                }
-            }
-            builder.WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-        }
-
-        [LongRunning]
-        [Command("translate", RunMode = RunMode.Async)]
-        [Summary("translateSummary")]
-        [Alias("tr")]
-        [Example("es hello world")]
-        public async Task<RuntimeResult> Translate([Summary("translateParam1")] string target,
-            [Remainder, Summary("translateParam2")] string text)
-        {
-            if (target == "language" && text == "codes")
-            {
-                var languagesBuilder = new EmbedBuilder()
-                    .WithTitle(Locate("LanguageList"))
-                    .WithColor(FergunClient.Config.EmbedColor);
-
-                var orderedLangs = Language.LanguageDictionary.Values.OrderBy(x => x.Name).ToArray();
-                string tempLangs = "";
-
-                for (int i = 1; i <= orderedLangs.Length; i++)
-                {
-                    tempLangs += $"{Format.Code(orderedLangs[i - 1].ISO6391)}: {orderedLangs[i - 1].Name}\n";
-                    if (i % 15 == 0)
-                    {
-                        languagesBuilder.AddField("\u200b", tempLangs, true);
-                        tempLangs = "";
-                    }
-                    else if (i == orderedLangs.Length)
-                    {
-                        languagesBuilder.AddField("\u200b", tempLangs, true);
-                    }
-                }
-
-                await ReplyAsync(embed: languagesBuilder.Build());
-                return FergunResult.FromSuccess();
-            }
-            if (!_translator.IsLanguageSupported(target))
-            {
-                return FergunResult.FromError(string.Format(Locate("InvalidLanguage"), GetPrefix()));
-            }
-
-            ITranslationResult result;
-            try
-            {
-                result = await _translator.TranslateAsync(text, target);
-            }
-            catch (Exception e) when (e is TranslatorException || e is HttpRequestException)
-            {
-                return FergunResult.FromError(e.Message);
-            }
-            catch (AggregateException e)
-            {
-                return FergunResult.FromError(e.InnerExceptions.FirstOrDefault()?.Message ?? e.Message);
-            }
-
-            string translation = result.Translation;
-            if (text.Length > EmbedFieldBuilder.MaxFieldValueLength - 10)
-            {
-                try
-                {
-                    var hastebinUrl = await Hastebin.UploadAsync(translation);
-                    translation = Format.Url(Locate("HastebinLink"), hastebinUrl);
-                }
-                catch (Exception e) when (e is HttpRequestException || e is TaskCanceledException)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Translate: Error while uploading text to Hastebin", e));
-                    translation = Format.Code(translation.Truncate(EmbedFieldBuilder.MaxFieldValueLength - 10), "md");
-                }
-            }
-            else
-            {
-                translation = Format.Code(translation.Truncate(EmbedFieldBuilder.MaxFieldValueLength - 10), "md");
-            }
-
-            string thumbnail = result.Service switch
-            {
-                "BingTranslator" => Constants.BingTranslatorLogoUrl,
-                "YandexTranslator" => Constants.YandexTranslateLogoUrl,
-                "MicrosoftTranslator" => Constants.MicrosoftAzureLogoUrl,
-                _ => Constants.GoogleTranslateLogoUrl
-            };
-
-            var builder = new EmbedBuilder()
-                .WithTitle(Locate("TranslationResults"))
-                .AddField(Locate("SourceLanguage"), result.SourceLanguage.Name)
-                .AddField(Locate("TargetLanguage"), result.TargetLanguage.Name)
-                .AddField(Locate("Result"), translation)
-                .WithThumbnailUrl(thumbnail)
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        [RequireBotPermission(ChannelPermission.AttachFiles, ErrorMessage = "BotRequireAttachFiles")]
-        [LongRunning]
-        [Command("tts", RunMode = RunMode.Async), Ratelimit(2, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-        [Summary("Text to speech.")]
-        [Alias("texttospeech", "t2s")]
-        [Example("en hello world")]
-        public async Task<RuntimeResult> Tts([Summary("ttsParam1")] string target,
-            [Remainder, Summary("ttsParam2")] string text)
-        {
-            target = target.ToLowerInvariant();
-            text = text.ToLowerInvariant();
-
-            if (!Language.TryGetLanguage(target, out var lang) || !GoogleTranslator2.TextToSpeechLanguages.Contains(lang))
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"TTS: Target language not supported ({target})"));
-                //return CustomResult.FromError(GetValue("InvalidLanguage"));
-                text = $"{target} {text}";
-                target = "en";
-            }
-
-            try
-            {
-                var stream = await _googleTranslator2.TextToSpeechAsync(text, target);
-                await Context.Channel.SendCachedFileAsync(Cache, Context.Message.Id, stream, "tts.mp3");
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "TTS: Error while getting TTS", e));
-                return FergunResult.FromError(Locate("AnErrorOccurred"));
-            }
-            catch (TaskCanceledException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "TTS: Error while getting TTS", e));
-                return FergunResult.FromError(Locate("RequestTimedOut"));
-            }
-
-            return FergunResult.FromSuccess();
-        }
-
-        // The attribute order matters
-        [LongRunning]
-        [Command("urban", RunMode = RunMode.Async)]
-        [Summary("urbanSummary")]
-        [Alias("ud")]
-        [Example("pog")]
-        public async Task<RuntimeResult> Urban([Remainder, Summary("urbanParam1")] string query = null)
-        {
-            UrbanResponse search;
-
-            query = query?.Trim();
-            if (string.IsNullOrWhiteSpace(query))
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", "Urban: Getting random words..."));
-                try
-                {
-                    search = await UrbanApi.GetRandomWordsAsync();
-                }
-                catch (HttpRequestException e)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Urban: Error in API", e));
-                    return FergunResult.FromError($"Error in Urban Dictionary API: {e.Message}");
-                }
-            }
-            else
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Urban: Query \"{query}\""));
-                try
-                {
-                    search = await UrbanApi.SearchWordAsync(query);
-                }
-                catch (HttpRequestException e)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Urban: Error in API", e));
-                    return FergunResult.FromError($"Error in Urban Dictionary API: {e.Message}");
-                }
-                if (search.Definitions.Count == 0)
-                {
-                    return FergunResult.FromError(Locate("NoResults"));
-                }
-            }
-
-            string by = Locate("By");
-            string noExampleText = Locate("NoExample");
-            string exampleText = Locate("Example");
-            string paginatorFooter = $"Urban Dictionary {(string.IsNullOrWhiteSpace(query) ? "(Random words)" : "")} - {Locate("PaginatorFooter")}";
-
-            Task<PageBuilder> GeneratePageAsync(int index)
-            {
-                var info = search.Definitions[index];
-
-                // Replace all occurrences of a term in brackets with a hyperlink directing to the definition of that term.
-                string definition = _bracketRegex.Replace(info.Definition,
-                        m => Format.Url(m.Groups[1].Value, $"https://urbandictionary.com/define.php?term={Uri.EscapeDataString(m.Groups[1].Value)}"))
-                    .Truncate(EmbedBuilder.MaxDescriptionLength);
-
-                string example;
-                if (string.IsNullOrEmpty(info.Example))
-                {
-                    example = noExampleText;
-                }
-                else
-                {
-                    example = _bracketRegex.Replace(info.Example,
-                            m => Format.Url(m.Groups[1].Value, $"https://urbandictionary.com/define.php?term={Uri.EscapeDataString(m.Groups[1].Value)}"))
-                        .Truncate(EmbedFieldBuilder.MaxFieldValueLength);
-                }
-
-                var pageBuilder = new PageBuilder()
-                    .WithAuthor($"{by} {info.Author}")
-                    .WithColor(new Discord.Color(FergunClient.Config.EmbedColor))
-                    .WithTitle(info.Word.Truncate(EmbedBuilder.MaxTitleLength))
-                    .WithUrl(info.Permalink)
-                    .WithDescription(definition)
-                    .AddField(exampleText, example)
-                    .AddField("👍", info.ThumbsUp, true)
-                    .AddField("👎", info.ThumbsDown, true)
-                    .WithFooter(string.Format(paginatorFooter, index + 1, search.Definitions.Count))
-                    .WithTimestamp(info.WrittenOn);
-
-                return Task.FromResult(pageBuilder);
-            }
-
-            var paginator = new LazyPaginatorBuilder()
-                .AddUser(Context.User)
-                .WithOptions(CommandUtils.GetFergunPaginatorEmotes(FergunClient.Config))
-                .WithMaxPageIndex(search.Definitions.Count - 1)
-                .WithPageFactory(GeneratePageAsync)
-                .WithFooter(PaginatorFooter.None)
-                .WithActionOnCancellation(ActionOnStop.DisableInput)
-                .WithActionOnTimeout(ActionOnStop.DisableInput)
-                .WithDeletion(DeletionOptions.Valid)
-                .Build();
-
-            _ = SendPaginatorAsync(paginator, Constants.PaginatorTimeout);
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("userinfo", RunMode = RunMode.Async)]
-        [Summary("userinfoSummary")]
-        [Alias("ui", "user", "whois")]
-        [Example("Fergun#6839")]
-        public async Task<RuntimeResult> UserInfo([Remainder, Summary("userinfoParam1")] IUser user = null)
-        {
-            user ??= Context.User;
-
-            string activities = "";
-            if (user.Activities.Count > 0)
-            {
-                activities = string.Join('\n', user.Activities.Select(x =>
-                    x.Type == ActivityType.CustomStatus
-                        ? ((CustomStatusGame)x).ToString()
-                        : $"{x.Type} {x.Name}"));
-            }
-
-            if (string.IsNullOrWhiteSpace(activities))
-                activities = Locate("None");
-
-            string clients = "?";
-            if (user.ActiveClients.Count > 0)
-            {
-                clients = string.Join(' ', user.ActiveClients.Select(x =>
-                    x switch
-                    {
-                        ClientType.Desktop => "🖥",
-                        ClientType.Mobile => "📱",
-                        ClientType.Web => "🌐",
-                        _ => ""
-                    }));
-            }
-
-            if (string.IsNullOrWhiteSpace(clients))
-                clients = "?";
-
-            var flags = user.PublicFlags ?? UserProperties.None;
-
-            string badges = flags == UserProperties.None ? null
-                : string.Join(' ',
-                Enum.GetValues(typeof(UserProperties))
-                    .Cast<Enum>()
-                    .Where(flags.HasFlag)
-                    .Select(x => FergunClient.Config.UserFlagsEmotes.TryGetValue(x.ToString(), out string emote) ? emote : null)
-                    .Distinct());
-
-            var guildUser = user as IGuildUser;
-
-            if (guildUser?.PremiumSince != null)
-            {
-                badges += " " + FergunClient.Config.BoosterEmote;
-            }
-            if (string.IsNullOrWhiteSpace(badges))
-            {
-                badges = Locate("None");
-            }
-
-            Discord.Color avatarColor = default;
-            string avatarUrl = user.GetAvatarUrl(ImageFormat.Auto, 2048) ?? user.GetDefaultAvatarUrl();
-
-            if (user is RestUser restUser && restUser.AccentColor != null)
-            {
-                avatarColor = restUser.AccentColor.Value;
-            }
-
-            if (avatarColor == default)
-            {
-                if (!(user is RestUser))
-                {
-                    // Prevent getting error 404 while downloading the avatar getting the user from REST.
-                    user = await Context.Client.Rest.GetUserAsync(user.Id);
-                    avatarUrl = user.GetAvatarUrl(ImageFormat.Auto, 2048) ?? user.GetDefaultAvatarUrl();
-                }
-
-                string thumbnail = user.GetAvatarUrl(ImageFormat.Png) ?? user.GetDefaultAvatarUrl();
-
-                try
-                {
-                    await using var response = await _httpClient.GetStreamAsync(new Uri(thumbnail));
-                    using var img = SixLabors.ImageSharp.Image.Load<Rgba32>(response);
-                    var average = img.GetAverageColor().ToPixel<Rgba32>();
-                    avatarColor = new Discord.Color(average.R, average.G, average.B);
-                }
-                catch (HttpRequestException e)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error getting the avatar from user {user}", e));
-                    return FergunResult.FromError(e.Message);
-                }
-                catch (TaskCanceledException e)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Error getting the avatar from user {user}", e));
-                    return FergunResult.FromError(Locate("RequestTimedOut"));
-                }
-            }
-
-            var builder = new EmbedBuilder()
-                .WithTitle(Locate("UserInfo"))
-                .AddField(Locate("Name"), user.ToString())
-                .AddField("Nickname", guildUser?.Nickname ?? Locate("None"))
-                .AddField("ID", user.Id)
-                .AddField(Locate("Activity"), activities, true)
-                .AddField(Locate("ActiveClients"), clients, true)
-                .AddField(Locate("Badges"), badges)
-                .AddField(Locate("IsBot"), Locate(user.IsBot))
-                .AddField(Locate("CreatedAt"), GetTimestamp(user.CreatedAt))
-                .AddField(Locate("GuildJoinDate"), GetTimestamp(guildUser?.JoinedAt))
-                .AddField(Locate("BoostingSince"), GetTimestamp(guildUser?.PremiumSince))
-                .WithThumbnailUrl(avatarUrl)
-                .WithColor(avatarColor);
-
-            await ReplyAsync(embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-
-            static string GetTimestamp(DateTimeOffset? dateTime)
-                => dateTime == null ? "N/A" : $"{dateTime.ToDiscordTimestamp()} ({dateTime.ToDiscordTimestamp('R')})";
-        }
-
-        [LongRunning]
-        [Command("wikipedia", RunMode = RunMode.Async)]
-        [Summary("wikipediaSummary")]
-        [Alias("wiki")]
-        [Example("Discord")]
-        public async Task<RuntimeResult> Wikipedia([Remainder, Summary("wikipediaParam1")] string query)
-        {
-            string language = GetLanguage();
-            (string Title, string Extract, string ImageUrl, int Id)[] articles;
-            try
-            {
-                articles = await GetArticlesAsync(query, language);
-            }
-            catch (HttpRequestException e)
-            {
-                return FergunResult.FromError(e.Message);
-            }
-
-            if (articles.Length == 0)
-            {
-                return FergunResult.FromError(Locate("NoResults"));
-            }
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Command", $"Wikipedia: Results count: {articles.Length}"));
-
-            // Cache localized strings
-            string wikipediaSearch = Locate("WikipediaSearch");
-            string paginatorFooter = Locate("PaginatorFooter");
-
-            var paginator = new LazyPaginatorBuilder()
-                .AddUser(Context.User)
-                .WithOptions(CommandUtils.GetFergunPaginatorEmotes(FergunClient.Config))
-                .WithMaxPageIndex(articles.Length - 1)
-                .WithPageFactory(GeneratePageAsync)
-                .WithFooter(PaginatorFooter.None)
-                .WithActionOnCancellation(ActionOnStop.DisableInput)
-                .WithActionOnTimeout(ActionOnStop.DisableInput)
-                .WithDeletion(DeletionOptions.Valid)
-                .Build();
-
-            _ = SendPaginatorAsync(paginator, Constants.PaginatorTimeout);
-
-            return FergunResult.FromSuccess();
-
-            static async Task<(string Title, string Extract, string ImageUrl, int Id)[]> GetArticlesAsync(string query, string language)
-            {
-                string url = $"https://{language}.wikipedia.org/w/api.php?" +
-                    "action=query" +
-                    "&generator=prefixsearch" + // https://www.mediawiki.org/wiki/API:Prefixsearch
-                    "&format=json" +
-                    "&formatversion=2" +
-                    "&prop=extracts|pageimages|description" + // Get article extract, page images and short description
-                    //"&exchars=1200" + // Return max 1200 characters
-                    "&exintro" + // Return only content before the first section
-                    "&explaintext" + // Return extracts as plain text
-                    "&redirects" + // Automatically resolve redirects
-                    $"&gpssearch={Uri.EscapeDataString(query)}" + // Search string
-                    "&pilicense=any" + // Get images with any license
-                    "&piprop=original"; // Get original images
-
-                byte[] bytes = await _httpClient.GetByteArrayAsync(url);
-
-                using var document = JsonDocument.Parse(bytes);
-
-                var pages = document
-                    .RootElement
-                    .GetPropertyOrDefault("query")
-                    .GetPropertyOrDefault("pages");
-
-                // When there are no results, the API returns {"batchcomplete":true} instead of an empty array
-                var articles = pages.ValueKind != JsonValueKind.Array
-                    ? Array.Empty<(string, string, string, int)>()
-                    : pages.EnumerateArray()
-                    .Select(x => (
-                    Title: x.GetProperty("title").GetString(),
-                    Extract: x.GetPropertyOrDefault("extract").GetStringOrDefault(),
-                    ImageUrl: x.GetPropertyOrDefault("original").GetPropertyOrDefault("source").GetStringOrDefault(),
-                    Id: x.GetPropertyOrDefault("pageid").GetInt32OrDefault()))
-                    .ToArray();
-
-                return articles.Length == 0 && language != "en"
-                    ? await GetArticlesAsync(query, "en")
-                    : articles;
-            }
-
-            Task<PageBuilder> GeneratePageAsync(int index)
-            {
-                bool isMobile = Context.User.ActiveClients.Contains(ClientType.Mobile);
-
-                var builder = new PageBuilder()
-                        .WithAuthor(Context.User)
-                        .WithColor(new Discord.Color(FergunClient.Config.EmbedColor))
-                        .WithTitle(articles[index].Title.Truncate(EmbedBuilder.MaxTitleLength))
-                        .WithUrl($"https://{language}.{(isMobile ? "m." : "")}wikipedia.org?curid={articles[index].Id}")
-                        .WithDescription(articles[index].Extract?.Truncate(EmbedBuilder.MaxDescriptionLength) ?? "?")
-                        .WithThumbnailUrl($"https://commons.wikimedia.org/w/index.php?title=Special:Redirect/file/Wikipedia-logo-v2-{language}.png")
-                        .WithFooter($"{wikipediaSearch} - {string.Format(paginatorFooter, index + 1, articles.Length)}");
-
-                if (Context.IsNsfw() && !string.IsNullOrEmpty(articles[index].ImageUrl))
-                {
-                    string decodedUrl = Uri.UnescapeDataString(articles[index].ImageUrl);
-                    if (Uri.IsWellFormedUriString(decodedUrl, UriKind.Absolute))
-                    {
-                        builder.ThumbnailUrl = decodedUrl;
-                    }
-                }
-
-                return Task.FromResult(builder);
-            }
-        }
-
-        [LongRunning]
-        [Command("wolframalpha", RunMode = RunMode.Async), Ratelimit(1, Constants.GlobalRatelimitPeriod, Measure.Minutes)]
-        [Summary("wolframalphaSummary")]
-        [Alias("wolfram", "wa")]
-        [Example("2 + 2")]
-        public async Task<RuntimeResult> WolframAlpha([Remainder, Summary("wolframalphaParam1")] string query)
-        {
-            if (string.IsNullOrEmpty(FergunClient.Config.WolframAlphaAppId))
-            {
-                return FergunResult.FromError(string.Format(Locate("ValueNotSetInConfig"), nameof(FergunConfig.WolframAlphaAppId)));
-            }
-
-            string output;
-            try
-            {
-                var response = await _httpClient.GetAsync(new Uri($"https://api.wolframalpha.com/v1/result?i={Uri.EscapeDataString(query)}&appid={FergunClient.Config.WolframAlphaAppId}"));
-                if (response.StatusCode == HttpStatusCode.NotImplemented)
-                {
-                    return FergunResult.FromError("The input could not be interpreted by the API.");
-                }
-
-                response.EnsureSuccessStatusCode();
-                output = await response.Content.ReadAsStringAsync();
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Error calling WolframAlpha API", e));
-                return FergunResult.FromError(e.Message);
-            }
-
-            var builder = new EmbedBuilder()
-                .WithAuthor("Wolfram Alpha", Constants.WolframAlphaLogoUrl)
-                .AddField(Locate("Input"), Format.Code(query.Truncate(EmbedFieldBuilder.MaxFieldValueLength - 10), "md"))
-                .AddField(Locate("Output"), Format.Code(output.Truncate(EmbedFieldBuilder.MaxFieldValueLength - 10), "md"))
-                .WithFooter("wolframalpha.com")
-                .WithColor(FergunClient.Config.EmbedColor);
-
-            await ReplyAsync(embed: builder.Build());
-
-            return FergunResult.FromSuccess();
-        }
-
-        [Command("xkcd")]
-        [Summary("xkcdSummary")]
-        [Example("1000")]
-        public async Task<RuntimeResult> Xkcd([Summary("xkcdParam1")] int? number = null)
-        {
-            await UpdateLastComicAsync();
-            if (_lastComic == null)
-            {
-                return FergunResult.FromError(Locate("AnErrorOccurred"));
-            }
-            if (number != null && (number < 1 || number > _lastComic.Num))
-            {
-                return FergunResult.FromError(string.Format(Locate("InvalidxkcdNumber"), _lastComic.Num));
-            }
-            if (number == 404)
-            {
-                return FergunResult.FromError("404 Not Found");
-            }
-            string response = await _httpClient.GetStringAsync($"https://xkcd.com/{number ?? Random.Shared.Next(1, _lastComic.Num)}/info.0.json");
-
-            var comic = JsonConvert.DeserializeObject<XkcdComic>(response);
-
-            var builder = new EmbedBuilder()
-                .WithTitle(comic.Title.Truncate(EmbedBuilder.MaxTitleLength))
-                .WithUrl($"https://xkcd.com/{comic.Num}/")
-                .WithImageUrl(comic.Img)
-                .WithFooter(comic.Alt.Truncate(EmbedFooterBuilder.MaxFooterTextLength))
-                .WithTimestamp(new DateTime(int.Parse(comic.Year), int.Parse(comic.Month), int.Parse(comic.Day)));
-
-            await ReplyAsync(embed: builder.Build());
-            return FergunResult.FromSuccess();
-        }
-
-        [LongRunning]
-        [Command("youtube", RunMode = RunMode.Async)]
-        [Summary("youtubeSummary")]
-        [Alias("yt")]
-        [Example("discord")]
-        public async Task<RuntimeResult> YouTube([Remainder, Summary("youtubeParam1")] string query)
-        {
-            string[] urls;
-            try
-            {
-                urls = await _ytClient.Search.GetVideosAsync(query).Take(10).Select(x => x.Url).ToArrayAsync();
-            }
-            catch (HttpRequestException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Youtube: Error obtaining videos (query: {query})", e));
-                return FergunResult.FromError(e.Message);
-            }
-            catch (YoutubeExplodeException e)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Youtube: Error obtaining videos (query: {query})", e));
-                return FergunResult.FromError(Locate("ErrorInAPI"));
-            }
-
-            switch (urls.Length)
-            {
-                case 0:
-                    return FergunResult.FromError(Locate("NoResultsFound"));
-
-                case 1:
-                    await ReplyAsync(urls[0]);
-                    break;
-
-                default:
-                {
-                    string paginatorFooter = Locate("PaginatorFooter");
-
-                    Task<PageBuilder> GeneratePageAsync(int index)
-                    {
-                        var pageBuilder = new PageBuilder()
-                            .WithText($"{urls[index]}\n{string.Format(paginatorFooter, index + 1, urls.Length)}");
-
-                        return Task.FromResult(pageBuilder);
-                    }
-
-                    var paginator = new LazyPaginatorBuilder()
-                        .AddUser(Context.User)
-                        .WithOptions(CommandUtils.GetFergunPaginatorEmotes(FergunClient.Config))
-                        .WithMaxPageIndex(urls.Length - 1)
-                        .WithPageFactory(GeneratePageAsync)
-                        .WithFooter(PaginatorFooter.None)
-                        .WithActionOnCancellation(ActionOnStop.DisableInput)
-                        .WithActionOnTimeout(ActionOnStop.DisableInput)
-                        .WithDeletion(DeletionOptions.Valid)
-                        .Build();
-
-                    _ = SendPaginatorAsync(paginator, Constants.PaginatorTimeout);
-                        break;
-                }
-            }
-
-            return FergunResult.FromSuccess();
-        }
-
-        [LongRunning]
-        [Command("ytrandom", RunMode = RunMode.Async)]
-        [Summary("ytrandomSummary")]
-        [Alias("ytrand")]
-        public async Task<RuntimeResult> YtRandom()
-        {
-            for (int i = 0; i < 10; i++)
-            {
-                string randStr = StringUtils.RandomString(Random.Shared.Next(5, 7));
-                IReadOnlyList<VideoSearchResult> videos;
-                try
-                {
-                    videos = await _ytClient.Search.GetVideosAsync(randStr).Take(5);
-                }
-                catch (HttpRequestException e)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Ytrandom: Error obtaining videos (query: {randStr})", e));
-                    return FergunResult.FromError(e.Message);
-                }
-                catch (YoutubeExplodeException e)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Ytrandom: Error obtaining videos (query: {randStr})", e));
-                    return FergunResult.FromError(Locate("ErrorInAPI"));
-                }
-
-                if (videos.Count != 0)
-                {
-                    string id = videos[Random.Shared.Next(videos.Count)].Id;
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Ytrandom", $"Using id: {id} (random string: {randStr}, search count: {videos.Count})"));
-
-                    await ReplyAsync($"https://www.youtube.com/watch?v={id}");
-                    return FergunResult.FromSuccess();
-                }
-
-                await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "Ytrandom", $"No videos found on random string ({randStr})"));
-            }
-
-            return FergunResult.FromError(Locate("AnErrorOccurred"));
-        }
-
-        private async Task<(string, string)> BingOcrAsync(string url)
-        {
-            string jsonRequest = $"{{\"imageInfo\":{{\"url\":\"{url}\",\"source\":\"Url\"}},\"knowledgeRequest\":{{\"invokedSkills\":[\"OCR\"]}}}}";
-            using var content = new MultipartFormDataContent
-            {
-                { new StringContent(jsonRequest), "knowledgeRequest" }
-            };
-
-            using var request = new HttpRequestMessage();
-            request.Method = HttpMethod.Post;
-            request.RequestUri = new Uri("https://www.bing.com/images/api/custom/knowledge?skey=ZbQI4MYyHrlk2E7L-vIV2VLrieGlbMfV8FcK-WCY3ug");
-            request.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36");
-            request.Headers.Referrer = new Uri($"https://www.bing.com/images/search?view=detailv2&iss=sbi&q=imgurl:{url}");
-            request.Content = content;
-
-            var sw = Stopwatch.StartNew();
-            var response = await _httpClient.SendAsync(request);
-            sw.Stop();
-            byte[] bytes = await response.Content.ReadAsByteArrayAsync();
-            using var document = JsonDocument.Parse(bytes);
-
-            string imageCategory = document
-                .RootElement
-                .GetPropertyOrDefault("imageQualityHints")
-                .FirstOrDefault()
-                .GetPropertyOrDefault("category")
-                .GetStringOrDefault();
-
-            // UnknownFormat (Only JPEG, PNG o BMP allowed)
-            // ImageDimensionsExceedLimit (Max. 4000px)
-            // ImageByteSizeExceedsLimit (Max. 20MB)
-            // ImageDownloadFailed
-            // JunkImage
-            if (!string.IsNullOrEmpty(imageCategory))
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "SimpleOcr", $"Bing Visual Search returned image category \"{imageCategory}\" for url: {url}."));
-            }
-
-            var textRegions = document
-                .RootElement
-                .GetPropertyOrDefault("tags")
-                .FirstOrDefault(x => x.GetPropertyOrDefault("displayName").GetStringOrDefault() == "##TextRecognition")
-                .GetPropertyOrDefault("actions")
-                .FirstOrDefault()
-                .GetPropertyOrDefault("data")
-                .GetPropertyOrDefault("regions")
-                .EnumerateArrayOrEmpty()
-                .Select(x => string.Join('\n',
-                    x.GetPropertyOrDefault("lines")
-                    .EnumerateArrayOrEmpty()
-                    .Select(y => y.GetPropertyOrDefault("text").GetStringOrDefault())));
-
-            string joinedText = string.Join("\n\n", textRegions);
-            if (!string.IsNullOrEmpty(joinedText))
-            {
-                return (sw.ElapsedMilliseconds.ToString(), joinedText);
-            }
-
-            await _logService.LogAsync(new LogMessage(LogSeverity.Verbose, "SimpleOcr", $"Bing Visual Search didn't return text for url: {url}."));
-
-            return (string.IsNullOrEmpty(imageCategory) ? "OcrEmpty" : "OcrApiError", imageCategory);
-        }
-
-        private static async Task<(string, string)> YandexOcrAsync(string url)
-        {
-            // Get CBIR ID
-            const string cbirJsonRequest = @"{""blocks"":[{""block"":""content_type_search-by-image"",""params"":{},""version"":2}]}";
-            string requestUrl = $"https://yandex.com/images/search?rpt=imageview&url={Uri.EscapeDataString(url)}&format=json&request={cbirJsonRequest}&yu=0";
-            using var searchRequest = new HttpRequestMessage();
-            searchRequest.Method = HttpMethod.Get;
-            searchRequest.RequestUri = new Uri(requestUrl);
-            searchRequest.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36");
-
-            var sw = Stopwatch.StartNew();
-            var response = await _httpClient.SendAsync(searchRequest);
-            sw.Stop();
-            byte[] bytes = await response.Content.ReadAsByteArrayAsync();
-            using var document = JsonDocument.Parse(bytes);
-
-            var html = document
-                .RootElement
-                .GetProperty("blocks")[0]
-                .GetProperty("html")
-                .GetString() ?? "";
-
-            int startIndex = html.IndexOf("\"cbirId\":\"", StringComparison.Ordinal);
-
-            if (startIndex == -1)
-            {
-                return ("OcrApiError", null);
-            }
-
-            startIndex += 10;
-            int endIndex = html.IndexOf("\"", startIndex + 10, StringComparison.Ordinal);
-
-            if (startIndex == -1)
-            {
-                return ("OcrApiError", null);
-            }
-
-            string cbirId = html[startIndex..endIndex];
-
-            // Get OCR text
-            const string ocrJsonRequest = @"{""blocks"":[{""block"":{""block"":""i-react-ajax-adapter:ajax""},""params"":{""type"":""CbirOcr""},""version"":2}]}";
-            requestUrl = $"https://yandex.com/images/search?format=json&request={ocrJsonRequest}&rpt=ocr&cbir_id={cbirId}";
-            using var ocrRequest = new HttpRequestMessage();
-            ocrRequest.Method = HttpMethod.Get;
-            ocrRequest.RequestUri = new Uri(requestUrl);
-            ocrRequest.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36");
-
-            sw.Start();
-            response = await _httpClient.SendAsync(ocrRequest);
-            sw.Stop();
-            bytes = await response.Content.ReadAsByteArrayAsync();
-            using var ocrDocument = JsonDocument.Parse(bytes);
-
-            string ocrText = ocrDocument
-                .RootElement
-                .GetProperty("blocks")[0]
-                .GetProperty("params")
-                .GetPropertyOrDefault("adapterData")
-                .GetPropertyOrDefault("plainText")
-                .GetStringOrDefault();
-
-            if (!string.IsNullOrEmpty(ocrText))
-            {
-                return (sw.ElapsedMilliseconds.ToString(), ocrText);
-            }
-
-            return ("OcrEmpty", ocrText);
-        }
-
-        private static async Task UpdateLastComicAsync()
-        {
-            if (_timeToCheckComic >= DateTimeOffset.UtcNow) return;
-
-            string response;
-            try
-            {
-                response = await _httpClient.GetStringAsync("https://xkcd.com/info.0.json");
-            }
-            catch (HttpRequestException) { return; }
-
-            _lastComic = JsonConvert.DeserializeObject<XkcdComic>(response);
-            _timeToCheckComic = DateTimeOffset.UtcNow.AddDays(1);
-        }
-
-        private void InitializeCmdListCache()
-        {
-            if (_commandListCache == null)
-            {
-                _commandListCache = new Dictionary<string, string>();
-
-                var modules = _cmdService.Modules
-                    .Where(x => x.Name != Constants.DevelopmentModuleName && x.Commands.Count > 0)
-                    .OrderBy(x => x.Attributes.OfType<OrderAttribute>().FirstOrDefault()?.Order ?? int.MaxValue);
-
-                foreach (var module in modules)
-                {
-                    _commandListCache.Add(module.Name, string.Join(", ", module.Commands.Select(x => x.Name)));
-                }
-            }
-            if (_cachedVisibleCmdCount == -1)
-            {
-                _cachedVisibleCmdCount = _cmdService.Commands.Count(x => x.Module.Name != Constants.DevelopmentModuleName);
-            }
-        }
-    }
-}
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
new file mode 100644
index 0000000..75cf774
--- /dev/null
+++ b/src/Modules/UtilityModule.cs
@@ -0,0 +1,514 @@
+using System.Diagnostics;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using Discord;
+using Discord.Interactions;
+using Fergun.Extensions;
+using Fergun.Interactive;
+using Fergun.Interactive.Pagination;
+using Fergun.Modules.Handlers;
+using Fergun.Utils;
+using GScraper;
+using GScraper.Brave;
+using GScraper.DuckDuckGo;
+using GScraper.Google;
+using GTranslate;
+using GTranslate.Results;
+using GTranslate.Translators;
+using Humanizer;
+using Microsoft.Extensions.Logging;
+using YoutubeExplode.Common;
+using YoutubeExplode.Search;
+
+namespace Fergun.Modules;
+
+public class UtilityModule : InteractionModuleBase<ShardedInteractionContext>
+{
+    private readonly ILogger<UtilityModule> _logger;
+    private readonly InteractiveService _interactive;
+    private readonly AggregateTranslator _translator;
+    private readonly GoogleScraper _googleScraper;
+    private readonly DuckDuckGoScraper _duckDuckGoScraper;
+    private readonly BraveScraper _braveScraper;
+    private readonly SearchClient _searchClient;
+
+    public UtilityModule(ILogger<UtilityModule> logger, InteractiveService interactive, AggregateTranslator translator, GoogleScraper googleScraper,
+        DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper, SearchClient searchClient)
+    {
+        _logger = logger;
+        _interactive = interactive;
+        _translator = translator;
+        _googleScraper = googleScraper;
+        _duckDuckGoScraper = duckDuckGoScraper;
+        _braveScraper = braveScraper;
+        _searchClient = searchClient;
+    }
+
+    [RequireOwner]
+    [SlashCommand("cmd", "(Owner only) Executes a command.")]
+    public async Task Cmd([Summary(description: "The command to execute")] string command, [Summary("noembed", "No embed.")] bool noEmbed = false)
+    {
+        await DeferAsync();
+
+        var result = CommandUtils.RunCommand(command);
+
+        if (string.IsNullOrWhiteSpace(result))
+        {
+            await FollowupAsync("No output.");
+        }
+        else
+        {
+            int limit = noEmbed ? DiscordConfig.MaxMessageSize : EmbedBuilder.MaxDescriptionLength;
+            string sanitized = Format.Code(result.Replace('`', '´').Truncate(limit - 12), "ansi");
+            string? text = null;
+            Embed? embed = null;
+
+            if (noEmbed)
+            {
+                text = sanitized;
+            }
+            else
+            {
+                embed = new EmbedBuilder()
+                    .WithTitle("Command output")
+                    .WithDescription(sanitized)
+                    .WithColor(Color.Orange)
+                    .Build();
+            }
+
+            await FollowupAsync(text, embed: embed);
+        }
+    }
+
+    [SlashCommand("help", "Information about Fergun 2")]
+    public async Task Help()
+    {
+        var embed = new EmbedBuilder()
+            .WithTitle("Fergun 2")
+            .WithDescription("Hey, it seems that you found some slash commands in Fergun.\n\n" +
+                             "This is Fergun 2, a complete rewrite of Fergun 1.x, using only slash commands.\n" +
+                             "Fergun 2 is still in very alpha stages and only some commands are present, but more commands will be added soon.\n" +
+                             "Fergun 2 will be finished in early 2022 and it will include new features and commands.\n\n" +
+                             "Some modules and commands are currently in maintenance mode in Fergun 1.x and they won't be migrated to Fergun 2. These modules are:\n" +
+                             "- **AI Dungeon** module\n" +
+                             "- **Music** module\n" +
+                             "- **Snipe** commands\n\n" +
+                             $"You can find more info about the removals of these modules/commands {Format.Url("here", "https://github.com/d4n3436/Fergun/wiki/Command-removal-notice")}.")
+            .WithColor(Color.Orange)
+            .Build();
+
+        await RespondAsync(embed: embed);
+    }
+
+    [SlashCommand("ping", "Sends the response time of the bot.")]
+    public async Task Ping()
+    {
+        var embed = new EmbedBuilder()
+            .WithDescription("Pong!")
+            .WithColor(Color.Orange)
+            .Build();
+
+        var sw = Stopwatch.StartNew();
+        await RespondAsync(embed: embed);
+        sw.Stop();
+
+        embed = new EmbedBuilder()
+            .WithDescription($"Pong! {sw.ElapsedMilliseconds}ms")
+            .WithColor(Color.Orange)
+            .Build();
+
+        await Context.Interaction.ModifyOriginalResponseAsync(x => x.Embed = embed);
+    }
+
+    [SlashCommand("img", "Searches for images from Google Images and displays them in a paginator.")]
+    public async Task Img([Autocomplete(typeof(GoogleAutocompleteHandler))] [Summary(description: "The query to search.")] string query,
+        [Summary(description: "Whether to display multiple images in a single page.")] bool multiImages = false)
+    {
+        await DeferAsync();
+
+        bool isNsfw = Context.Channel.IsNsfw();
+        _logger.LogInformation(new EventId(0, "img"), "Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
+
+        var images = await _googleScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict, language: Context.Interaction.GetTwoLetterLanguageCode());
+
+        var filteredImages = images
+            .Where(x => x.Url.StartsWith("http") && x.SourceUrl.StartsWith("http"))
+            .Chunk(multiImages ? 4 : 1)
+            .ToArray();
+
+        _logger.LogInformation(new EventId(0, "img"), "Image results: {count}", filteredImages.Length);
+
+        if (filteredImages.Length == 0)
+        {
+            await Context.Interaction.FollowupWarning("No results.");
+            return;
+        }
+
+        var paginator = new LazyPaginatorBuilder()
+            .WithPageFactory(GeneratePage)
+            .WithFergunEmotes()
+            .WithActionOnCancellation(ActionOnStop.DisableInput)
+            .WithActionOnTimeout(ActionOnStop.DisableInput)
+            .WithMaxPageIndex(filteredImages.Length - 1)
+            .WithFooter(PaginatorFooter.None)
+            .AddUser(Context.User)
+            .Build();
+
+        _ = _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+
+        MultiEmbedPageBuilder GeneratePage(int index)
+        {
+            var builders = filteredImages[index].Select(result => new EmbedBuilder()
+                .WithTitle(result.Title)
+                .WithDescription("Google Images results")
+                .WithUrl(multiImages ? "https://google.com" : result.SourceUrl)
+                .WithImageUrl(result.Url)
+                .WithFooter($"Page {index + 1}/{filteredImages.Length}", Constants.GoogleLogoUrl)
+                .WithColor(Color.Orange));
+
+            return new MultiEmbedPageBuilder().WithBuilders(builders);
+        }
+    }
+
+    [SlashCommand("img2", "Searches for images from DuckDuckGo and displays them in a paginator.")]
+    public async Task Img2([Autocomplete(typeof(DuckDuckGoAutocompleteHandler))] [Summary(description: "The query to search.")] string query)
+    {
+        await DeferAsync();
+
+        bool isNsfw = Context.Channel.IsNsfw();
+        _logger.LogInformation(new EventId(0, "img2"), "Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
+
+        var images = await _duckDuckGoScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict);
+
+        var filteredImages = images
+            .Where(x => x.Url.StartsWith("http") && x.SourceUrl.StartsWith("http"))
+            .ToArray();
+
+        _logger.LogInformation(new EventId(0, "img2"), "Image results: {count}", filteredImages.Length);
+
+        if (filteredImages.Length == 0)
+        {
+            await Context.Interaction.FollowupWarning("No results.");
+            return;
+        }
+
+        var paginator = new LazyPaginatorBuilder()
+            .WithPageFactory(GeneratePageAsync)
+            .WithFergunEmotes()
+            .WithActionOnCancellation(ActionOnStop.DisableInput)
+            .WithActionOnTimeout(ActionOnStop.DisableInput)
+            .WithMaxPageIndex(filteredImages.Length - 1)
+            .WithFooter(PaginatorFooter.None)
+            .AddUser(Context.User)
+            .Build();
+
+        _ = _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+
+        Task<PageBuilder> GeneratePageAsync(int index)
+        {
+            var pageBuilder = new PageBuilder()
+                .WithTitle(filteredImages[index].Title)
+                .WithDescription("DuckDuckGo image search")
+                .WithUrl(filteredImages[index].SourceUrl)
+                .WithImageUrl(filteredImages[index].Url)
+                .WithFooter($"Page {index + 1}/{filteredImages.Length}", Constants.DuckDuckGoLogoUrl)
+                .WithColor(Color.Orange);
+
+            return Task.FromResult(pageBuilder);
+        }
+    }
+
+    [SlashCommand("img3", "Searches for images from Brave and displays them in a paginator.")]
+    public async Task Img3([Autocomplete(typeof(BraveAutocompleteHandler))] [Summary(description: "The query to search.")] string query)
+    {
+        await DeferAsync();
+
+        bool isNsfw = Context.Channel.IsNsfw();
+        _logger.LogInformation(new EventId(0, "img3"), "Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
+
+        var images = await _braveScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict);
+
+        var filteredImages = images
+            .Where(x => x.Url.StartsWith("http") && x.SourceUrl.StartsWith("http"))
+            .ToArray();
+
+        _logger.LogInformation(new EventId(0, "img3"), "Image results: {count}", filteredImages.Length);
+
+        if (filteredImages.Length == 0)
+        {
+            await Context.Interaction.FollowupWarning("No results.");
+            return;
+        }
+
+        var paginator = new LazyPaginatorBuilder()
+            .WithPageFactory(GeneratePageAsync)
+            .WithFergunEmotes()
+            .WithActionOnCancellation(ActionOnStop.DisableInput)
+            .WithActionOnTimeout(ActionOnStop.DisableInput)
+            .WithMaxPageIndex(filteredImages.Length - 1)
+            .WithFooter(PaginatorFooter.None)
+            .AddUser(Context.User)
+            .Build();
+
+        _ = _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+
+        Task<PageBuilder> GeneratePageAsync(int index)
+        {
+            var pageBuilder = new PageBuilder()
+                .WithTitle(filteredImages[index].Title)
+                .WithDescription("Brave image search")
+                .WithUrl(filteredImages[index].SourceUrl)
+                .WithImageUrl(filteredImages[index].Url)
+                .WithFooter($"Page {index + 1}/{filteredImages.Length}", Constants.BraveLogoUrl)
+                .WithColor(Color.Orange);
+
+            return Task.FromResult(pageBuilder);
+        }
+    }
+
+    [SlashCommand("say", "Says something.")]
+    public async Task Say([Summary(description: "The text to send.")] string text)
+    {
+        await RespondAsync(text.Truncate(DiscordConfig.MaxMessageSize), allowedMentions: AllowedMentions.None);
+    }
+
+    [SlashCommand("stats", "Sends the stats of the bot.")]
+    public async Task Stats()
+    {
+        await DeferAsync();
+
+        long temp;
+        var owner = (await Context.Client.GetApplicationInfoAsync()).Owner;
+        var cpuUsage = (int)await CommandUtils.GetCpuUsageForProcessAsync();
+        string? cpu = null;
+        long? totalRamUsage = null;
+        long processRamUsage = 0;
+        long? totalRam = null;
+        string? os = RuntimeInformation.OSDescription;
+
+        if (OperatingSystem.IsLinux())
+        {
+            // CPU Name
+            if (File.Exists("/proc/cpuinfo"))
+            {
+                cpu = File.ReadAllLines("/proc/cpuinfo")
+                    .FirstOrDefault(x => x.StartsWith("model name", StringComparison.OrdinalIgnoreCase))?
+                    .Split(':')
+                    .ElementAtOrDefault(1)?
+                    .Trim();
+            }
+
+            if (string.IsNullOrWhiteSpace(cpu))
+            {
+                cpu = CommandUtils.RunCommand("lscpu")?
+                    .Split('\n')
+                    .FirstOrDefault(x => x.StartsWith("model name", StringComparison.OrdinalIgnoreCase))?
+                    .Split(':')
+                    .ElementAtOrDefault(1)?
+                    .Trim();
+
+                if (string.IsNullOrWhiteSpace(cpu))
+                {
+                    cpu = "?";
+                }
+            }
+
+            // OS Name
+            if (File.Exists("/etc/lsb-release"))
+            {
+                var distroInfo = File.ReadAllLines("/etc/lsb-release");
+                os = distroInfo.ElementAtOrDefault(3)?.Split('=').ElementAtOrDefault(1)?.Trim('\"');
+            }
+
+            // Total RAM & total RAM usage
+            var output = CommandUtils.RunCommand("free -m")?.Split(Environment.NewLine);
+            var memory = output?.ElementAtOrDefault(1)?.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+
+            if (long.TryParse(memory?.ElementAtOrDefault(1), out temp)) totalRam = temp;
+            if (long.TryParse(memory?.ElementAtOrDefault(2), out temp)) totalRamUsage = temp;
+
+            // Process RAM usage
+            processRamUsage = Process.GetCurrentProcess().WorkingSet64 / 1024 / 1024;
+        }
+        else if (OperatingSystem.IsWindows())
+        {
+            // CPU Name
+            cpu = CommandUtils.RunCommand("wmic cpu get name")
+                ?.Trim()
+                .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
+                .ElementAtOrDefault(1);
+
+            // Total RAM & total RAM usage
+            var output = CommandUtils.RunCommand("wmic OS get FreePhysicalMemory,TotalVisibleMemorySize /Value")
+                ?.Trim()
+                .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
+
+            if (output?.Length > 1)
+            {
+                long freeRam = 0;
+                var split = output[0].Split('=', StringSplitOptions.RemoveEmptyEntries);
+                if (split.Length > 1 && long.TryParse(split[1], out temp))
+                {
+                    freeRam = temp / 1024;
+                }
+
+                split = output[1].Split('=', StringSplitOptions.RemoveEmptyEntries);
+                if (split.Length > 1 && long.TryParse(split[1], out temp))
+                {
+                    totalRam = temp / 1024;
+                }
+
+                if (totalRam != null && freeRam != 0)
+                {
+                    totalRamUsage = totalRam - freeRam;
+                }
+            }
+
+            // Process RAM usage
+            processRamUsage = Process.GetCurrentProcess().PrivateMemorySize64 / 1024 / 1024;
+        }
+
+        int totalUsers = 0;
+        foreach (var guild in Context.Client.Guilds)
+        {
+            totalUsers += guild.MemberCount;
+        }
+
+        int totalUsersInShard = 0;
+        int shardId = Context.Channel.IsPrivate() ? 0 : Context.Client.GetShardIdFor(Context.Guild);
+        foreach (var guild in Context.Client.GetShard(shardId).Guilds)
+        {
+            totalUsersInShard += guild.MemberCount;
+        }
+
+        string version = $"v{Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion}";
+
+        var elapsed = DateTimeOffset.UtcNow - Process.GetCurrentProcess().StartTime;
+
+        var builder = new EmbedBuilder()
+            .WithTitle("Fergun Stats")
+            .AddField("Operating System", os, true)
+            .AddField("\u200b", "\u200b", true)
+            .AddField("CPU", cpu, true)
+            .AddField("CPU Usage", cpuUsage + "%", true)
+            .AddField("\u200b", "\u200b", true)
+            .AddField("RAM Usage",
+                $"{processRamUsage}MB ({(totalRam == null ? 0 : Math.Round((double)processRamUsage / totalRam.Value * 100, 2))}%) " +
+                $"/ {(totalRamUsage == null || totalRam == null ? "?MB" : $"{totalRamUsage}MB ({Math.Round((double)totalRamUsage.Value / totalRam.Value * 100, 2)}%)")} " +
+                $"/ {totalRam?.ToString() ?? "?"}MB", true)
+            .AddField("Library", $"Discord.Net v{DiscordConfig.Version}", true)
+            .AddField("\u200b", "\u200b", true)
+            .AddField("BotVersion", version, true)
+            .AddField("Total Servers", $"{Context.Client.Guilds.Count} (Shard: {Context.Client.GetShard(shardId).Guilds.Count})", true)
+            .AddField("\u200b", "\u200b", true)
+            .AddField("Total Users", $"{totalUsers} (Shard: {totalUsersInShard})", true)
+            .AddField("Shard ID", shardId, true)
+            .AddField("\u200b", "\u200b", true)
+            .AddField("Shards", Context.Client.Shards.Count, true)
+            .AddField("Uptime", elapsed.Humanize(), true)
+            .AddField("\u200b", "\u200b", true)
+            .AddField("BotOwner", owner, true);
+
+        builder.WithColor(Color.Orange);
+
+        await FollowupAsync(embed: builder.Build());
+    }
+
+    [SlashCommand("translate", "Translates a text.")]
+    public async Task Translate([Summary(description: "The text to translate")] string text,
+        [Autocomplete(typeof(TranslateAutocompleteHandler))] [Summary(description: "Target language (name, code or alias)")] string target,
+        [Autocomplete(typeof(TranslateAutocompleteHandler))] [Summary(description: "Source language (name, code or alias)")] string? source = null)
+    {
+        if (string.IsNullOrWhiteSpace(text))
+        {
+            await Context.Interaction.RespondWarningAsync("The message must contain text.", true);
+            return;
+        }
+
+        if (!Language.TryGetLanguage(target, out _))
+        {
+            await Context.Interaction.RespondWarningAsync($"Invalid target language \"{target}\".", true);
+            return;
+        }
+
+        if (source != null && !Language.TryGetLanguage(source, out _))
+        {
+            await Context.Interaction.RespondWarningAsync($"Invalid source language \"{source}\".", true);
+            return;
+        }
+
+        await DeferAsync();
+        ITranslationResult result;
+
+        try
+        {
+            result = await _translator.TranslateAsync(text, target, source);
+        }
+        catch (Exception e)
+        {
+            _logger.LogWarning(new(0, "Translate"), e, "Error translating text {text} ({source} -> {target})", text, source ?? "auto", target);
+            await Context.Interaction.FollowupWarning(e.Message);
+            return;
+        }
+
+        string thumbnailUrl = result.Service switch
+        {
+            "BingTranslator" => Constants.BingTranslatorLogoUrl,
+            "MicrosoftTranslator" => Constants.MicrosoftAzureLogoUrl,
+            "YandexTranslator" => Constants.YandexTranslateLogoUrl,
+            _ => Constants.GoogleTranslateLogoUrl
+        };
+
+        string embedText = $"**Source language** {(source == null ? "**(Detected)**" : "")}\n" +
+                           $"{result.SourceLanguage.Name}\n\n" +
+                           "**Target language**\n" +
+                           $"{result.TargetLanguage.Name}" +
+                           "\n\n**Result**\n";
+
+        string translation = result.Translation.Replace('`', '´').Truncate(EmbedBuilder.MaxDescriptionLength - embedText.Length - 6);
+
+        var builder = new EmbedBuilder()
+            .WithTitle("Translation result")
+            .WithDescription($"{embedText}```{translation}```")
+            .WithThumbnailUrl(thumbnailUrl)
+            .WithColor(Color.Orange);
+
+        await FollowupAsync(embed: builder.Build());
+    }
+
+    [MessageCommand("Translate")]
+    public async Task Translate(IUserMessage message)
+        => await Translate(message.GetText(), Context.Interaction.GetTwoLetterLanguageCode());
+
+    [SlashCommand("youtube", "Sends a paginator containing YouTube videos.")]
+    public async Task YouTube([Autocomplete(typeof(YouTubeAutocompleteHandler))] [Summary(description: "The query.")] string query)
+    {
+        await DeferAsync();
+
+        var videos = await _searchClient.GetVideosAsync(query).Take(10);
+
+        switch (videos.Count)
+        {
+            case 0:
+                await Context.Interaction.FollowupWarning("No results.");
+                break;
+
+            case 1:
+                await Context.Interaction.FollowupAsync(videos[0].Url);
+                break;
+
+            default:
+                var paginator = new StaticPaginatorBuilder()
+                    .AddUser(Context.User)
+                    .WithPages(videos.Select((x, i) => new PageBuilder { Text = $"{x.Url}\nPage {i + 1} of {videos.Count}" }).ToArray())
+                    .WithActionOnCancellation(ActionOnStop.DisableInput)
+                    .WithActionOnTimeout(ActionOnStop.DisableInput)
+                    .WithFooter(PaginatorFooter.None)
+                    .WithFergunEmotes()
+                    .Build();
+
+                _ = _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+                break;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Program.cs b/src/Program.cs
index 39c3e60..70db21f 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -1,19 +1,136 @@
-using System;
-using System.Globalization;
-using System.Threading.Tasks;
+using Discord;
+using Discord.Addons.Hosting;
+using Discord.Interactions;
+using Discord.WebSocket;
+using Fergun;
+using Fergun.Extensions;
+using Fergun.Interactive;
+using Fergun.Modules;
+using Fergun.Services;
+using GScraper.Brave;
+using GScraper.DuckDuckGo;
+using GScraper.Google;
+using GTranslate.Translators;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Polly;
+using Polly.Caching;
+using Polly.Caching.Memory;
+using Polly.Extensions.Http;
+using Polly.Registry;
+using Serilog;
+using Serilog.Events;
+using Serilog.Filters;
+using Serilog.Sinks.SystemConsole.Themes;
+using YoutubeExplode.Search;
 
-namespace Fergun
-{
-    internal static class Program
+await Host.CreateDefaultBuilder()
+    .ConfigureDiscordShardedHost((context, config) =>
     {
-        public static async Task Main()
+        config.SocketConfig = new DiscordSocketConfig
         {
-            // Exceptions in english
-            CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US");
+            LogLevel = LogSeverity.Verbose,
+            GatewayIntents = GatewayIntents.Guilds,
+            UseInteractionSnowflakeDate = false,
+            LogGatewayIntentWarnings = false
+        };
 
-            Console.OutputEncoding = System.Text.Encoding.UTF8;
+        config.Token = context.Configuration["Token"];
+    })
+    .UseInteractionService((_, config) =>
+    {
+        config.LogLevel = LogSeverity.Verbose;
+        config.DefaultRunMode = RunMode.Async;
+        config.UseCompiledLambda = true;
+    })
+    .ConfigureLogging(logging => logging.ClearProviders())
+    .UseSerilog((_, config) =>
+    {
+        config.MinimumLevel.Debug()
+            .Filter.ByExcluding(e => e.Level == LogEventLevel.Debug && Matching.FromSource("Discord.WebSocket.DiscordShardedClient").Invoke(e) && e.MessageTemplate.Render(e.Properties).ContainsAny("Connected to", "Disconnected from"))
+            .Filter.ByExcluding(e => e.Level == LogEventLevel.Warning && Matching.FromSource("Discord.WebSocket.DiscordShardedClient").Invoke(e) && e.MessageTemplate.Render(e.Properties).Contains("Unknown Dispatch"))
+            .Filter.ByExcluding(e => e.Level <= LogEventLevel.Debug && Matching.FromSource("Microsoft.Extensions.Http").Invoke(e))
+            .WriteTo.Console(LogEventLevel.Debug, theme: AnsiConsoleTheme.Literate)
+            .WriteTo.Async(logger => logger.File("logs/log-.txt", LogEventLevel.Debug, rollingInterval: RollingInterval.Day));
+    })
+    .ConfigureServices(services =>
+    {
+        services.AddHostedService<InteractionHandlingService>();
+        services.AddSingleton<InteractiveService>();
+
+        services.AddMemoryCache();
+        services.AddSingleton<IAsyncCacheProvider, MemoryCacheProvider>();
+        services.AddSingleton<IReadOnlyPolicyRegistry<string>, PolicyRegistry>(provider =>
+        {
+            var cacheProvider = provider.GetRequiredService<IAsyncCacheProvider>().AsyncFor<HttpResponseMessage>();
+            var cachePolicy = Policy.CacheAsync(cacheProvider, new SlidingTtl(TimeSpan.FromHours(2)));
+
+            var retryPolicy = HttpPolicyExtensions.HandleTransientHttpError()
+                .OrTransientHttpStatusCode()
+                .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
+
+            var timeoutPolicy = Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(3));
+
+            return new PolicyRegistry
+            {
+                { "GeneralPolicy", Policy.WrapAsync(cachePolicy, retryPolicy) },
+                { "AutocompletePolicy", Policy.WrapAsync(cachePolicy, retryPolicy, timeoutPolicy) }
+            };
+        });
+
+        services.AddHttpClient<GoogleTranslator>()
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
+        services.AddHttpClient<GoogleTranslator2>()
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
+        services.AddHttpClient<YandexTranslator>()
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
+        // We have to register the named client and service separately because Bing Translator and Microsoft Translator aren't stateless,
+        // They store a token required to make API calls that is obtained once and updated occasionally, since AddHttpClient<TClient>
+        // adds the services with a transient scope, this means that the token would be obtained every time the services are used.
+        services.AddHttpClient(nameof(MicrosoftTranslator))
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
+        services.AddSingleton(s => new MicrosoftTranslator(s.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(MicrosoftTranslator))));
+
+        services.AddHttpClient(nameof(BingTranslator))
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
+        services.AddSingleton(s => new BingTranslator(s.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(BingTranslator))));
+
+        services.AddHttpClient<SearchClient>()
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
+        services.AddHttpClient<UtilityModule>()
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
+        services.AddHttpClient(nameof(GoogleScraper))
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
+        services.AddHttpClient(nameof(DuckDuckGoScraper))
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
+        services.AddHttpClient(nameof(BraveScraper))
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
+        services.AddHttpClient("autocomplete", client => client.DefaultRequestHeaders.UserAgent.ParseAdd(Constants.ChromeUserAgent))
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30));
 
-            await new FergunClient().InitializeAsync();
-        }
-    }
-}
\ No newline at end of file
+        services.AddSingleton<AggregateTranslator>();
+        services.AddSingleton(x => new GoogleScraper(x.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(GoogleScraper))));
+        services.AddSingleton(x => new DuckDuckGoScraper(x.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(DuckDuckGoScraper))));
+        services.AddSingleton(x => new BraveScraper(x.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(BraveScraper))));
+    }).RunConsoleAsync();
\ No newline at end of file
diff --git a/src/Readers/UserTypeReader.cs b/src/Readers/UserTypeReader.cs
deleted file mode 100644
index 472b1e1..0000000
--- a/src/Readers/UserTypeReader.cs
+++ /dev/null
@@ -1,116 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Discord.WebSocket;
-
-namespace Fergun.Readers
-{
-    /// <summary>
-    ///     A <see cref="TypeReader"/> for parsing objects implementing <see cref="IUser"/>.
-    ///     Modified from the original to get user by mention/id from REST and use the "Search Guild Members" endpoint.
-    /// </summary>
-    /// <typeparam name="T">The type to be checked; must implement <see cref="IUser"/>.</typeparam>
-    public class UserTypeReader<T> : TypeReader
-        where T : class, IUser
-    {
-        /// <inheritdoc />
-        public override async Task<TypeReaderResult> ReadAsync(ICommandContext context, string input, IServiceProvider services)
-        {
-            //By Mention or Id (1.0)
-            if (MentionUtils.TryParseUser(input, out ulong id) ||
-                ulong.TryParse(input, NumberStyles.None, CultureInfo.InvariantCulture, out id))
-            {
-                var user = await GetUserFromIdAsync(context, id).ConfigureAwait(false);
-                if (user != null)
-                {
-                    return TypeReaderResult.FromSuccess(user as T);
-                }
-            }
-
-            var results = new Dictionary<ulong, TypeReaderValue>();
-
-            var usersFromGuildSearch = context.Guild != null
-                ? await context.Guild.SearchUsersAsync(input.Substring(0, Math.Min(input.Length, 100)), 10).ConfigureAwait(false)
-                : Array.Empty<IGuildUser>();
-
-            var guildUsers = context.Guild != null
-                ? await context.Guild.GetUsersAsync(CacheMode.CacheOnly).ConfigureAwait(false)
-                : Array.Empty<IGuildUser>();
-
-            var channelUsers = await context.Channel.GetUsersAsync(CacheMode.CacheOnly).FlattenAsync().ConfigureAwait(false);
-
-            var users = usersFromGuildSearch
-                .Union(guildUsers, UserEqualityComparer.Instance)
-                .Union(channelUsers, UserEqualityComparer.Instance)
-                .ToArray();
-
-            //By Username + Discriminator (0.7-0.8)
-            int index = input.LastIndexOf('#');
-            if (index >= 0 && ushort.TryParse(input.AsSpan().Slice(index + 1), out ushort discriminator))
-            {
-                var username = input.Substring(0, index);
-                var user = users.FirstOrDefault(x => x.DiscriminatorValue == discriminator && string.Equals(username, x.Username, StringComparison.OrdinalIgnoreCase));
-                if (user != null)
-                {
-                    AddResult(results, user as T, user.Username == username ? 0.80f : 0.70f);
-                }
-            }
-
-            //By Username (0.5-0.6)
-            foreach (var user in users)
-            {
-                if (string.Equals(input, user.Username, StringComparison.OrdinalIgnoreCase))
-                {
-                    AddResult(results, user as T, user.Username == input ? 0.60f : 0.50f);
-                }
-            }
-
-            //By Nickname (0.5-0.6)
-            foreach (var user in users)
-            {
-                if (user is IGuildUser guildUser && string.Equals(input, guildUser.Nickname, StringComparison.OrdinalIgnoreCase))
-                {
-                    AddResult(results, guildUser as T, guildUser.Nickname == input ? 0.60f : 0.50f);
-                }
-            }
-
-            return results.Count > 0
-                ? TypeReaderResult.FromSuccess(results.Values.ToArray())
-                : TypeReaderResult.FromError(CommandError.ObjectNotFound, "User not found.");
-        }
-
-        private static async Task<IUser> GetUserFromIdAsync(ICommandContext context, ulong id)
-        {
-            IUser user;
-            if (context.Guild != null)
-            {
-                user = await context.Guild.GetUserAsync(id).ConfigureAwait(false)
-                    ?? await ((BaseSocketClient)context.Client).Rest.GetGuildUserAsync(context.Guild.Id, id).ConfigureAwait(false);
-            }
-            else
-            {
-                user = await context.Channel.GetUserAsync(id, CacheMode.CacheOnly).ConfigureAwait(false);
-            }
-
-            return user ?? await ((BaseSocketClient)context.Client).Rest.GetUserAsync(id).ConfigureAwait(false);
-        }
-
-        private static void AddResult(IDictionary<ulong, TypeReaderValue> results, T user, float score)
-        {
-            results.TryAdd(user.Id, new TypeReaderValue(user, score));
-        }
-
-        private class UserEqualityComparer : IEqualityComparer<IUser>
-        {
-            public static readonly UserEqualityComparer Instance = new UserEqualityComparer();
-
-            public bool Equals(IUser x, IUser y) => x.Id == y.Id;
-
-            public int GetHashCode(IUser obj) => obj.Id.GetHashCode();
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Responses/XkcdComic.cs b/src/Responses/XkcdComic.cs
deleted file mode 100644
index 8c452e7..0000000
--- a/src/Responses/XkcdComic.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using Newtonsoft.Json;
-
-namespace Fergun.Responses
-{
-    public class XkcdComic
-    {
-        [JsonProperty("month")]
-        public string Month { get; set; }
-
-        [JsonProperty("num")]
-        public int Num { get; set; }
-
-        [JsonProperty("link")]
-        public string Link { get; set; }
-
-        [JsonProperty("year")]
-        public string Year { get; set; }
-
-        [JsonProperty("news")]
-        public string News { get; set; }
-
-        [JsonProperty("safe_title")]
-        public string SafeTitle { get; set; }
-
-        [JsonProperty("transcript")]
-        public string Transcript { get; set; }
-
-        [JsonProperty("alt")]
-        public string Alt { get; set; }
-
-        [JsonProperty("img")]
-        public string Img { get; set; }
-
-        [JsonProperty("title")]
-        public string Title { get; set; }
-
-        [JsonProperty("day")]
-        public string Day { get; set; }
-    }
-}
\ No newline at end of file
diff --git a/src/Services/BotListService.cs b/src/Services/BotListService.cs
deleted file mode 100644
index 73e948c..0000000
--- a/src/Services/BotListService.cs
+++ /dev/null
@@ -1,190 +0,0 @@
-using System;
-using System.Net.Http;
-using System.Net.Http.Headers;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using Discord;
-using Discord.WebSocket;
-
-namespace Fergun.Services
-{
-    /// <summary>
-    /// Represents a service that updates the bot server count periodically (currently Top.gg and DiscordBots)
-    /// </summary>
-    public class BotListService : IDisposable
-    {
-        private readonly BaseSocketClient _client;
-        private readonly Timer _updateTimer;
-        private readonly Func<LogMessage, Task> _logger;
-        private readonly HttpClient _topGgClient;
-        private readonly HttpClient _discordBotsClient;
-        private bool _topGgClientDisposed;
-        private bool _discordBotsClientDisposed;
-        private bool _disposed;
-        private int _lastServerCount = -1;
-
-        public BotListService(DiscordSocketClient client, string topGgToken = null, string discordBotsToken = null,
-            TimeSpan? updatePeriod = null, Func<LogMessage, Task> logger = null)
-            : this((BaseSocketClient)client, topGgToken, discordBotsToken, updatePeriod, logger)
-        {
-        }
-
-        public BotListService(DiscordShardedClient client, string topGgToken = null, string discordBotsToken = null,
-            TimeSpan? updatePeriod = null, Func<LogMessage, Task> logger = null)
-            : this((BaseSocketClient)client, topGgToken, discordBotsToken, updatePeriod, logger)
-        {
-        }
-
-        public BotListService(BaseSocketClient client, string topGgToken = null, string discordBotsToken = null,
-            TimeSpan? updatePeriod = null, Func<LogMessage, Task> logger = null)
-        {
-            _client = client ?? throw new ArgumentNullException(nameof(client));
-            _logger = logger ?? Task.FromResult;
-
-            if (string.IsNullOrEmpty(topGgToken))
-            {
-                _topGgClientDisposed = true;
-                _ = _logger(new LogMessage(LogSeverity.Info, "BotList", "Top.gg API token is empty or not set. Bot server count will not be sent to the API."));
-            }
-            else
-            {
-                _topGgClient = new HttpClient
-                {
-                    BaseAddress = new Uri("https://top.gg/api/")
-                };
-                _topGgClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(topGgToken);
-            }
-
-            if (string.IsNullOrEmpty(discordBotsToken))
-            {
-                _discordBotsClientDisposed = true;
-                _ = _logger(new LogMessage(LogSeverity.Info, "BotList", "DiscordBots API token is empty or not set. Bot server count will not be sent to the API."));
-            }
-            else
-            {
-                _discordBotsClient = new HttpClient
-                {
-                    BaseAddress = new Uri("https://discord.bots.gg/api/v1/")
-                };
-                _discordBotsClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(discordBotsToken);
-            }
-            if (_topGgClientDisposed && _discordBotsClientDisposed)
-            {
-                return;
-            }
-
-            updatePeriod ??= TimeSpan.FromMinutes(30);
-            _updateTimer = new Timer(OnTimerFired, null, updatePeriod.Value, updatePeriod.Value);
-        }
-
-        private void OnTimerFired(object state)
-        {
-            _ = UpdateStatsAsync();
-        }
-
-        /// <summary>
-        /// Manually updates the bot list server count using the client's guild count.
-        /// </summary>
-        public async Task UpdateStatsAsync() => await UpdateStatsAsync(_client.Guilds.Count);
-
-        /// <summary>
-        /// Manually updates the bot list server count using the specified server count.
-        /// </summary>
-        /// <param name="serverCount">The server count.</param>
-        public async Task UpdateStatsAsync(int serverCount)
-        {
-            if (_lastServerCount == -1) _lastServerCount = serverCount;
-            else if (_lastServerCount == serverCount) return;
-
-            await UpdateStatsAsync(serverCount, BotList.TopGg);
-            await UpdateStatsAsync(serverCount, BotList.DiscordBots);
-            _lastServerCount = serverCount;
-
-            if (_topGgClientDisposed && _discordBotsClientDisposed)
-            {
-                Dispose();
-            }
-        }
-
-        /// <summary>
-        /// Manually updates a specific bot list server count using the specified server count.
-        /// </summary>
-        /// <param name="serverCount">The server count.</param>
-        /// <param name="botList">The bot list.</param>
-        public async Task UpdateStatsAsync(int serverCount, BotList botList)
-        {
-            switch (botList)
-            {
-                case BotList.TopGg when _topGgClientDisposed:
-                case BotList.DiscordBots when _discordBotsClientDisposed:
-                    return;
-            }
-
-            var httpClient = botList == BotList.TopGg ? _topGgClient : _discordBotsClient;
-            string botListString = botList == BotList.TopGg ? "Top.gg" : "DiscordBots";
-            await _logger(new LogMessage(LogSeverity.Verbose, "BotList", $"Updating {botListString} bot server count..."));
-            HttpResponseMessage response = null;
-            try
-            {
-                string serverCountString = botList == BotList.TopGg ? "server_count" : "guildCount";
-                var content = new StringContent($"{{\"{serverCountString}\": {serverCount}}}", Encoding.UTF8, "application/json");
-
-                response = await httpClient.PostAsync(new Uri($"bots/{_client.CurrentUser.Id}/stats", UriKind.Relative), content);
-                response.EnsureSuccessStatusCode();
-                await _logger(new LogMessage(LogSeverity.Verbose, "BotList", $"Successfully updated {botListString} bot server count to {serverCount}."));
-            }
-            catch (Exception e)
-            {
-                await _logger(new LogMessage(LogSeverity.Warning, "BotList", $"Failed to update {botListString} bot server count to {serverCount}.", e));
-                if (response != null)
-                {
-                    int code = (int)response.StatusCode;
-                    if (code == 401 || code == 403 || code == 404)
-                    {
-                        string message = code == 404 ? $"Got error {code}, make sure the bot is listed on {botListString}." : $"Got error {code}, make sure the token is valid.";
-                        await _logger(new LogMessage(LogSeverity.Info, "BotList", message));
-                        await _logger(new LogMessage(LogSeverity.Info, "BotList", $"Bot server count will not be sent to {botListString} API."));
-                        httpClient.Dispose();
-                        if (botList == BotList.TopGg)
-                        {
-                            _topGgClientDisposed = true;
-                        }
-                        else
-                        {
-                            _discordBotsClientDisposed = true;
-                        }
-                    }
-                }
-            }
-        }
-
-        public enum BotList
-        {
-            TopGg,
-            DiscordBots
-        }
-
-        protected virtual void Dispose(bool disposing)
-        {
-            if (_disposed) return;
-            if (disposing)
-            {
-                _updateTimer?.Dispose();
-                _topGgClient?.Dispose();
-                _discordBotsClient?.Dispose();
-                _topGgClientDisposed = true;
-                _discordBotsClientDisposed = true;
-            }
-
-            _disposed = true;
-        }
-
-        /// <inheritdoc/>
-        public void Dispose()
-        {
-            GC.SuppressFinalize(this);
-            Dispose(true);
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Services/CommandCacheService.cs b/src/Services/CommandCacheService.cs
deleted file mode 100644
index 9082f0e..0000000
--- a/src/Services/CommandCacheService.cs
+++ /dev/null
@@ -1,416 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Discord.WebSocket;
-
-namespace Fergun.Services
-{
-    /// <summary>
-    /// A thread-safe class used to automatically modify or delete response messages when the command message is modified or deleted.
-    /// </summary>
-    public class CommandCacheService : IDisposable
-    {
-        private readonly ConcurrentDictionary<ulong, ulong> _cache = new ConcurrentDictionary<ulong, ulong>();
-        private readonly int _max;
-        private Timer _autoClear;
-        private readonly Func<LogMessage, Task> _logger;
-        private int _count;
-        private bool _disposed;
-        private readonly BaseSocketClient _client;
-        private readonly Func<SocketMessage, Task> _cmdHandler;
-        private readonly double _maxMessageTime;
-        private readonly MessageCacheService _messageCache;
-
-        private CommandCacheService()
-        {
-            IsDisabled = true;
-            _disposed = true;
-        }
-
-        /// <inheritdoc cref="CommandCacheService(BaseSocketClient, int, Func{SocketMessage, Task}, Func{LogMessage, Task}, int, double, MessageCacheService)"/>
-        public CommandCacheService(DiscordSocketClient client, int capacity = 200, Func<SocketMessage, Task> cmdHandler = null,
-            Func<LogMessage, Task> logger = null, int period = 1800000, double maxMessageTime = 2.0, MessageCacheService messageCache = null)
-            : this((BaseSocketClient)client, capacity, cmdHandler, logger, period, maxMessageTime, messageCache)
-        {
-        }
-
-        /// <inheritdoc cref="CommandCacheService(BaseSocketClient, int, Func{SocketMessage, Task}, Func{LogMessage, Task}, int, double, MessageCacheService)"/>
-        public CommandCacheService(DiscordShardedClient client, int capacity = 200, Func<SocketMessage, Task> cmdHandler = null,
-            Func<LogMessage, Task> logger = null, int period = 1800000, double maxMessageTime = 2.0, MessageCacheService messageCache = null)
-            : this((BaseSocketClient)client, capacity, cmdHandler, logger, period, maxMessageTime, messageCache)
-        {
-        }
-
-        /// <summary>
-        /// Initializes the cache with a maximum capacity, tracking the client's message deleted event, and optionally the client's message modified event.
-        /// </summary>
-        /// <param name="client">The client that the MessageDeleted handler should be hooked up to.</param>
-        /// <param name="capacity">The maximum capacity of the cache.</param>
-        /// <param name="cmdHandler">An optional method that gets called when the modified message event is fired.</param>
-        /// <param name="logger">An optional method to use for logging.</param>
-        /// <param name="period">The interval between invocations of the cache clearing, in milliseconds.</param>
-        /// <param name="maxMessageTime">The max. message longevity, in hours.</param>
-        /// <param name="messageCache">The message cache.</param>
-        /// <exception cref="ArgumentOutOfRangeException">Thrown if capacity is less than 1.</exception>
-        public CommandCacheService(BaseSocketClient client, int capacity = 200, Func<SocketMessage, Task> cmdHandler = null,
-            Func<LogMessage, Task> logger = null, int period = 1800000, double maxMessageTime = 2.0, MessageCacheService messageCache = null)
-        {
-            _client = client;
-
-            _client.MessageDeleted += OnMessageDeleted;
-            _client.MessageUpdated += OnMessageModified;
-
-            // If a method is supplied, use it, otherwise use a method that does nothing.
-            _cmdHandler = cmdHandler ?? (_ => Task.CompletedTask);
-            _logger = logger ?? (_ => Task.CompletedTask);
-
-            // Make sure the max capacity is within an acceptable range.
-            if (capacity < 1)
-            {
-                throw new ArgumentOutOfRangeException(nameof(capacity), capacity, "Capacity can not be lower than 1.");
-            }
-
-            _max = capacity;
-            _maxMessageTime = maxMessageTime;
-            _messageCache = messageCache;
-
-            // Create a timer that will clear out cached messages.
-            _autoClear = new Timer(OnTimerFired, null, period, period);
-
-            _logger(new LogMessage(LogSeverity.Info, "CmdCache", "Service initialized, MessageDeleted and OnMessageModified event handlers registered."));
-        }
-
-        /// <summary>
-        /// Returns a disabled instance of <see cref="CommandCacheService"/>.
-        /// </summary>
-        public static CommandCacheService Disabled => new CommandCacheService();
-
-        /// <summary>
-        /// Gets all the keys in the cache. Will claim all locks until the operation is complete.
-        /// </summary>
-        public ICollection<ulong> Keys => _cache.Keys;
-
-        /// <summary>
-        /// Gets all the values in the cache. Will claim all locks until the operation is complete.
-        /// </summary>
-        public ICollection<ulong> Values => _cache.Values;
-
-        /// <summary>
-        /// Gets the number of command/response pairs in the cache.
-        /// </summary>
-        public int Count => _count;
-
-        public bool IsDisabled { get; }
-
-        /// <summary>
-        /// Adds a key and a value to the cache, or update the value if the key already exists.
-        /// </summary>
-        /// <param name="key">The id of the command message.</param>
-        /// <param name="value">The ids of the response messages.</param>
-        public void Add(ulong key, ulong value)
-        {
-            if (_count >= _max)
-            {
-                int removeCount = _count - _max + 1;
-                // The left 42 bits represent the timestamp.
-                var orderedKeys = _cache.Keys.OrderBy(k => k >> 22).ToList();
-                // Remove items until we're under the maximum.
-                int successfulRemovals = 0;
-                foreach (var orderedKey in orderedKeys)
-                {
-                    if (successfulRemovals >= removeCount) break;
-
-                    var success = TryRemove(orderedKey);
-                    if (success) successfulRemovals++;
-                }
-
-                // Reset _count to _cache.Count.
-                UpdateCount();
-            }
-
-            // TryAdd will return false if the key already exists, in which case we don't want to increment the count.
-            if (!_cache.ContainsKey(value))
-            {
-                Interlocked.Increment(ref _count);
-            }
-            _cache[key] = value;
-        }
-
-        /// <summary>
-        /// Adds a new command/response pair to the cache, or updates the value if the key already exists.
-        /// </summary>
-        /// <param name="pair">The command/response pair.</param>
-        public void Add(KeyValuePair<ulong, ulong> pair) => Add(pair.Key, pair.Value);
-
-        /// <summary>
-        /// Adds a command message and response to the cache.
-        /// </summary>
-        /// <param name="command">The command message.</param>
-        /// <param name="response">The response message.</param>
-        public void Add(IUserMessage command, IUserMessage response) => Add(command.Id, response.Id);
-
-        /// <summary>
-        /// Clears all items from the cache. Will claim all locks until the operation is complete.
-        /// </summary>
-        public void Clear()
-        {
-            _cache.Clear();
-            Interlocked.Exchange(ref _count, 0);
-        }
-
-        /// <summary>
-        /// Checks whether the cache contains a set with a certain key.
-        /// </summary>
-        /// <param name="key">The key to search for.</param>
-        /// <returns>Whether or not the key was found.</returns>
-        public bool ContainsKey(ulong key) => _cache.ContainsKey(key);
-
-        /// <summary>
-        /// Returns an enumerator that iterates through the cache.
-        /// </summary>
-        /// <returns>An enumerator for the cache.</returns>
-        public IEnumerator<KeyValuePair<ulong, ulong>> GetEnumerator() => _cache.GetEnumerator();
-
-        /// <summary>
-        /// Tries to remove a value from the cache by key.
-        /// </summary>
-        /// <param name="key">The key to search for.</param>
-        /// <returns>Whether or not the removal operation was successful.</returns>
-        public bool TryRemove(ulong key)
-        {
-            var success = _cache.TryRemove(key, out _);
-            if (success) Interlocked.Decrement(ref _count);
-            return success;
-        }
-
-        /// <summary>
-        /// Tries to get a value from the cache by key.
-        /// </summary>
-        /// <param name="key">The key to search for.</param>
-        /// <param name="value">The value if found.</param>
-        /// <returns>Whether or not key was found in the cache.</returns>
-        public bool TryGetValue(ulong key, out ulong value) => _cache.TryGetValue(key, out value);
-
-        /// <summary>
-        /// Safely disposes of the auto-clear timer
-        /// and unsubscribes from the <see cref="BaseSocketClient.MessageDeleted"/> and <see cref="BaseSocketClient.MessageUpdated"/> events.
-        /// </summary>
-        public void Dispose()
-        {
-            Dispose(true);
-            GC.SuppressFinalize(this);
-        }
-
-        protected virtual void Dispose(bool disposing)
-        {
-            if (_disposed)
-            {
-                throw new ObjectDisposedException(nameof(CommandCacheService), "Service has been disposed.");
-            }
-
-            if (!disposing) return;
-            _autoClear.Dispose();
-            _autoClear = null;
-
-            _client.MessageDeleted -= OnMessageDeleted;
-            _client.MessageUpdated -= OnMessageModified;
-            _disposed = true;
-
-            _logger(new LogMessage(LogSeverity.Info, "CmdCache", "Cache disposed successfully."));
-        }
-
-        private void UpdateCount() => Interlocked.Exchange(ref _count, _cache.Count);
-
-        private void OnTimerFired(object state)
-        {
-            // Get all messages where the timestamp is older than the specified max message longevity, then convert it to a list. The result of where merely contains references to the original
-            // collection, so iterating and removing will throw an exception. Converting it to a list first avoids this.
-            var toPurge = _cache.Where(p =>
-            {
-                var difference = DateTimeOffset.UtcNow - SnowflakeUtils.FromSnowflake(p.Key);
-                return difference.TotalHours >= _maxMessageTime;
-            }).ToList();
-
-            int removed = toPurge.Count(p => TryRemove(p.Key));
-
-            UpdateCount();
-
-            _logger(new LogMessage(LogSeverity.Verbose, "CmdCache", $"Cleaned {removed} item(s) from the cache."));
-        }
-
-        private Task OnMessageDeleted(Cacheable<IMessage, ulong> cacheable, Cacheable<IMessageChannel, ulong> cachedChannel)
-        {
-            _ = Task.Run(async () =>
-            {
-                if (TryGetValue(cacheable.Id, out ulong responseId))
-                {
-                    var channel = await cachedChannel.GetOrDownloadAsync();
-                    var message = await channel.GetMessageAsync(_messageCache, responseId);
-                    if (message != null)
-                    {
-                        await _logger(new LogMessage(LogSeverity.Verbose, "CmdCache", $"Command message ({cacheable.Id}) deleted. Deleting the response..."));
-                        await message.DeleteAsync();
-                    }
-                    else
-                    {
-                        await _logger(new LogMessage(LogSeverity.Info, "CmdCache", $"Command message ({cacheable.Id}) deleted but the response ({responseId}) was already deleted."));
-                    }
-                    TryRemove(cacheable.Id);
-                }
-            });
-
-            return Task.CompletedTask;
-        }
-
-        private Task OnMessageModified(Cacheable<IMessage, ulong> cacheable, SocketMessage after, ISocketMessageChannel channel)
-        {
-            _ = Task.Run(async () =>
-            {
-                // Prevent the double reply that happens when the message is "updated" with an embed or image/video preview.
-                if (after.Source != MessageSource.User ||
-                    after.Author is SocketUnknownUser ||
-                    string.IsNullOrEmpty(after.Content) ||
-                    cacheable.HasValue && cacheable.Value.Content == after.Content)
-                    return;
-
-                if (TryGetValue(cacheable.Id, out ulong responseId))
-                {
-                    var response = await channel.GetMessageAsync(_messageCache, responseId);
-                    if (response == null)
-                    {
-                        await _logger(new LogMessage(LogSeverity.Info, "CmdCache", $"A command message ({cacheable.Id}) associated to a response was found but the response ({responseId}) was already deleted."));
-                        TryRemove(cacheable.Id);
-                    }
-                    else
-                    {
-                        await _logger(new LogMessage(LogSeverity.Verbose, "CmdCache", $"Found a response associated to command message ({cacheable.Id}) in cache."));
-                    }
-                }
-
-                if ((DateTimeOffset.UtcNow - after.CreatedAt).TotalHours <= _maxMessageTime)
-                {
-                    _ = _cmdHandler(after);
-                }
-            });
-
-            return Task.CompletedTask;
-        }
-    }
-
-    /// <summary>
-    /// The command cache module base.
-    /// </summary>
-    /// <typeparam name="TCommandContext">The <see cref="ICommandContext"/> implementation.</typeparam>
-    public abstract class CommandCacheModuleBase<TCommandContext> : ModuleBase<TCommandContext>
-        where TCommandContext : class, ICommandContext
-    {
-        /// <summary>
-        /// Gets or sets the command cache service.
-        /// </summary>
-        public CommandCacheService Cache { get; set; }
-
-        /// <summary>
-        /// Sends or edits a message to the source channel, and adds the response to the cache if the message is new.
-        /// </summary>
-        /// <param name="message">The message to be sent or edited.</param>
-        /// <param name="isTTS">Whether the message should be read aloud by Discord or not.</param>
-        /// <param name="embed">The <see cref="EmbedType.Rich"/> <see cref="Embed"/> to be sent or edited.</param>
-        /// <param name="options">The options to be used when sending the request.</param>
-        /// <param name="allowedMentions">
-        /// Specifies if notifications are sent for mentioned users and roles in the message <paramref name="message"/>. If <c>null</c>, all mentioned roles and users will be notified.
-        /// </param>
-        /// <param name="messageReference">The message references to be included. Used to reply to specific messages.</param>
-        /// <param name="component">The message components to be included with this message. Used for interactions</param>
-        /// <param name="stickers">A collection of stickers to send.</param>
-        /// <param name="embeds">A array of <see cref="Embed"/>s to send with this response. Max 10.</param>
-        /// <returns>A task that represents an asynchronous operation for sending or editing the message. The task contains the sent or edited message.</returns>
-        protected override async Task<IUserMessage> ReplyAsync(string message = null, bool isTTS = false, Embed embed = null,
-            RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent component = null, ISticker[] stickers = null, Embed[] embeds = null)
-        {
-            if (Cache.IsDisabled)
-            {
-                return await base.ReplyAsync(message, isTTS, embed, options, allowedMentions, messageReference, component, stickers, embeds);
-            }
-
-            IUserMessage response;
-            bool found = Cache.TryGetValue(Context.Message.Id, out ulong messageId);
-            if (found && (response = (IUserMessage)await Context.Channel.GetMessageAsync(messageId)) != null)
-            {
-                await response.ModifyAsync(x =>
-                {
-                    x.Content = message;
-                    x.Embed = embed;
-                    x.Attachments = Array.Empty<FileAttachment>();
-                    x.AllowedMentions = allowedMentions ?? Optional.Create<AllowedMentions>();
-                    x.Components = component;
-                }).ConfigureAwait(false);
-
-                response = (IUserMessage)await Context.Channel.GetMessageAsync(messageId).ConfigureAwait(false);
-            }
-            else
-            {
-                response = await Context.Channel.SendMessageAsync(message, isTTS, embed, options, allowedMentions, messageReference, component).ConfigureAwait(false);
-                Cache.Add(Context.Message, response);
-            }
-            return response;
-        }
-    }
-
-    public static class CommandCacheExtensions
-    {
-        /// <summary>
-        /// Sends a file to this message channel with an optional caption, then adds it to the command cache.
-        /// </summary>
-        /// <param name="channel">The source channel.</param>
-        /// <param name="cache">The command cache that the messages should be added to.</param>
-        /// <param name="commandId">The ID of the command message.</param>
-        /// <param name="stream">The <see cref="Stream" /> of the file to be sent.</param>
-        /// <param name="filename">The name of the attachment.</param>
-        /// <param name="text">The message to be sent.</param>
-        /// <param name="isTTS">Whether the message should be read aloud by Discord or not.</param>
-        /// <param name="embed">The <see cref="EmbedType.Rich"/> <see cref="Embed"/> to be sent.</param>
-        /// <param name="options">The options to be used when sending the request.</param>
-        /// <param name="isSpoiler">Whether the message attachment should be hidden as a spoiler.</param>
-        /// <param name="allowedMentions">
-        /// Specifies if notifications are sent for mentioned users and roles in the message <paramref name="text"/>. If <c>null</c>, all mentioned roles and users will be notified.
-        /// </param>
-        /// <param name="messageReference">The message references to be included. Used to reply to specific messages.</param>
-        /// <returns>A task that represents an asynchronous send operation for delivering the message. The task result contains the sent message.</returns>
-        public static async Task<IUserMessage> SendCachedFileAsync(this IMessageChannel channel, CommandCacheService cache, ulong commandId,
-            Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false,
-            AllowedMentions allowedMentions = null, MessageReference messageReference = null)
-        {
-            IUserMessage response;
-            bool found = cache.TryGetValue(commandId, out ulong responseId);
-            if (found && (response = (IUserMessage)await channel.GetMessageAsync(responseId)) != null)
-            {
-                await response.ModifyAsync(x =>
-                {
-                    x.Content = text;
-                    x.Embed = embed;
-                    x.Attachments = new[] { new FileAttachment(stream, filename) };
-                    x.AllowedMentions = allowedMentions ?? Optional.Create<AllowedMentions>();
-                }).ConfigureAwait(false);
-
-                response = (IUserMessage)await channel.GetMessageAsync(responseId).ConfigureAwait(false);
-            }
-            else
-            {
-                response = await channel.SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference);
-            }
-
-            if (!cache.IsDisabled)
-            {
-                cache.Add(commandId, response.Id);
-            }
-
-            return response;
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Services/CommandHandlingService.cs b/src/Services/CommandHandlingService.cs
deleted file mode 100644
index eeb36af..0000000
--- a/src/Services/CommandHandlingService.cs
+++ /dev/null
@@ -1,407 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Net;
-using System.Reflection;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Discord.Net;
-using Discord.WebSocket;
-using Fergun.Extensions;
-using Fergun.Interactive;
-using Fergun.Utils;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Fergun.Services
-{
-    public class CommandHandlingService
-    {
-        private readonly DiscordShardedClient _client;
-        private readonly LogService _logService;
-        private readonly CommandService _cmdService;
-        private readonly IServiceProvider _services;
-        private bool _isValidLogChannel = true;
-
-        private static readonly HashSet<ulong> _ignoredUsers = new HashSet<ulong>();
-        private static readonly object _userLock = new object();
-        private static readonly object _cmdStatsLock = new object();
-
-        public CommandHandlingService(DiscordShardedClient client, CommandService commands, LogService logService, IServiceProvider services)
-        {
-            _client = client;
-            _cmdService = commands;
-            _logService = logService;
-            _services = services;
-
-            _client.MessageReceived += HandleCommandAsync;
-            _cmdService.CommandExecuted += OnCommandExecutedAsync;
-        }
-
-        public async Task InitializeAsync()
-        {
-            _cmdService.AddTypeReader(typeof(IUser), new Readers.UserTypeReader<IUser>());
-
-            await _cmdService.AddModulesAsync(Assembly.GetEntryAssembly(), _services);
-        }
-
-        public async Task HandleCommandAsync(SocketMessage messageParam)
-        {
-            // Don't process the command if it was a system message
-            if (!(messageParam is SocketUserMessage message)) return;
-
-            // Ignore empty messages
-            if (string.IsNullOrEmpty(message.Content)) return;
-
-            // Ignore messages from bots
-            if (message.Author.IsBot) return;
-
-            string prefix = GuildUtils.GetCachedPrefix(message.Channel);
-            if (message.Content == prefix) return;
-
-            if (message.Content == _client.CurrentUser.Mention)
-            {
-                lock (_userLock)
-                {
-                    if (_ignoredUsers.Contains(message.Author.Id)) return;
-                }
-
-                _ = IgnoreUserAsync(message.Author.Id, TimeSpan.FromSeconds(Constants.MentionIgnoreTime));
-                await SendEmbedAsync(message, string.Format(GuildUtils.Locate("BotMention", message.Channel), prefix));
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Command", $"{message.Author} ({message.Author.Id}) mentioned me."));
-                return;
-            }
-
-            // Create a number to track where the prefix ends and the command begins
-            int argPos = 0;
-
-            // Determine if the message is a command based on the prefix or mention
-            if (!(message.HasStringPrefix(prefix, ref argPos) ||
-                  message.HasMentionPrefix(_client.CurrentUser, ref argPos)))
-                return;
-
-            // Ignore ignored users
-            lock (_userLock)
-            {
-                if (_ignoredUsers.Contains(message.Author.Id)) return;
-            }
-
-            var result = _cmdService.Search(message.Content.Substring(argPos));
-            if (!result.IsSuccess) return;
-
-            if (GuildUtils.UserConfigCache.TryGetValue(message.Author.Id, out var userConfig) && userConfig.IsBlacklisted)
-            {
-                _ = IgnoreUserAsync(message.Author.Id, TimeSpan.FromMinutes(Constants.BlacklistIgnoreTime));
-                if (userConfig.BlacklistReason == null)
-                {
-                    await SendEmbedAsync(message, "\u274c " + GuildUtils.Locate("Blacklisted", message.Channel), message.Author.Mention);
-                }
-                else
-                {
-                    await SendEmbedAsync(message, "\u274c " + string.Format(GuildUtils.Locate("BlacklistedWithReason", message.Channel), userConfig.BlacklistReason), message.Author.Mention);
-                }
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Blacklist", $"{message.Author} ({message.Author.Id}) wanted to use the command \"{result.Commands[0].Alias}\" but they are blacklisted."));
-                return;
-            }
-
-            var disabledCommands = GuildUtils.GetGuildConfig(message.Channel)?.DisabledCommands;
-            var disabled = disabledCommands?.FirstOrDefault(x => result.Commands.Any(y =>
-                x == (y.Command.Module.Group == null ? y.Command.Name : $"{y.Command.Module.Group} {y.Command.Name}")));
-
-            if (disabled != null)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Command", $"User {message.Author} ({message.Author.Id}) tried to use the locally disabled command \"{disabled}\"."));
-                await SendEmbedAsync(message, "\u26a0 " + string.Format(GuildUtils.Locate("CommandDisabled", message.Channel), Format.Code(disabled)));
-                _ = IgnoreUserAsync(message.Author.Id, TimeSpan.FromSeconds(Constants.DefaultIgnoreTime));
-            }
-            else
-            {
-                var globalDisabled = DatabaseConfig.GloballyDisabledCommands.FirstOrDefault(x =>
-                    result.Commands.Any(y =>
-                        x.Key == (y.Command.Module.Group == null ? y.Command.Name : $"{y.Command.Module.Group} {y.Command.Name}")));
-
-                if (globalDisabled.Key != null)
-                {
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Command", $"User {message.Author} ({message.Author.Id}) tried to use the globally disabled command \"{globalDisabled.Key}\"."));
-                    await SendEmbedAsync(message, $"\u26a0 {string.Format(GuildUtils.Locate("CommandDisabledGlobally", message.Channel), Format.Code(globalDisabled.Key))}" +
-                        $"{(!string.IsNullOrEmpty(globalDisabled.Value) ? $"\n{GuildUtils.Locate("Reason", message.Channel)}: {globalDisabled.Value}" : "")}");
-                    _ = IgnoreUserAsync(message.Author.Id, TimeSpan.FromSeconds(Constants.DefaultIgnoreTime));
-                }
-                else
-                {
-                    // Create a WebSocket-based command context based on the message
-                    var context = new ShardedCommandContext(_client, message);
-
-                    // Execute the command with the command context we just
-                    // created, along with the service provider for precondition checks.
-                    await _cmdService.ExecuteAsync(context, argPos, _services);
-                }
-            }
-        }
-
-        private async Task OnCommandExecutedAsync(Optional<CommandInfo> optionalCommand, ICommandContext commandContext, IResult result)
-        {
-            var context = (ShardedCommandContext)commandContext;
-
-            // command is unspecified when there was a search failure (command not found)
-            if (!optionalCommand.IsSpecified)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Command", $"Unknown command: \"{context.Message.Content}\", sent by {context.User} in {context.Display()}"));
-                return;
-            }
-
-            if (context.Guild != null)
-            {
-                // Update the last time a command was used in this guild.
-                _services.GetService<MessageCacheService>()?.UpdateLastCommandUsageTime(context.Guild.Id);
-            }
-
-            var command = optionalCommand.Value;
-
-            if (command.Module.Name != Constants.DevelopmentModuleName)
-            {
-                // Update the command stats
-                lock (_cmdStatsLock)
-                {
-                    var stats = DatabaseConfig.CommandStats;
-                    if (stats.ContainsKey(command.Name))
-                    {
-                        stats[command.Name]++;
-                    }
-                    else
-                    {
-                        stats.Add(command.Name, 1);
-                    }
-                    DatabaseConfig.Update(x => x.CommandStats = stats);
-                }
-            }
-
-            // the command was successful, we don't care about this result, unless we want to log that a command succeeded.
-            if (result.IsSuccess) return;
-
-            var responseMessage = (result as FergunResult)?.ResponseMessage;
-            double ignoreTime = Constants.DefaultIgnoreTime;
-            switch (result.Error)
-            {
-                //case CommandError.UnknownCommand:
-                //    await SendEmbedAsync(context.Message, string.Format(LocalizationService.Locate("CommandNotFound", context.Message), GetPrefix(context.Channel)));
-                //    break;
-                case CommandError.BadArgCount:
-                case CommandError.ParseFailed:
-                    string language = GuildUtils.GetLanguage(context.Channel);
-                    string prefix = GuildUtils.GetPrefix(context.Channel);
-                    await SendEmbedAsync(context.Message, command.ToHelpEmbed(language, prefix), null, responseMessage);
-                    break;
-
-                case CommandError.UnmetPrecondition when command.Module.Name != Constants.DevelopmentModuleName:
-                    ChannelPermissions permissions;
-                    if (context.Guild == null)
-                    {
-                        permissions = ChannelPermissions.All(context.Channel);
-                    }
-                    else
-                    {
-                        var currentUser = await commandContext.Guild.GetCurrentUserAsync();
-                        permissions = currentUser.GetPermissions((IGuildChannel)context.Channel);
-                    }
-
-                    if (!permissions.Has(Constants.MinimumRequiredPermissions))
-                    {
-                        var builder = new EmbedBuilder()
-                            .WithDescription($"\u26a0 {result.ErrorReason}")
-                            .WithColor(FergunClient.Config.EmbedColor);
-                        try
-                        {
-                            await context.User.SendMessageAsync(embed: builder.Build());
-                        }
-                        catch (HttpException e) when ((int?)e.DiscordCode == 50007)
-                        {
-                            await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Unable to send a DM about the minimum required permissions to the user."));
-                        }
-                    }
-                    else
-                    {
-                        if (result.ErrorReason.StartsWith("(Cooldown)", StringComparison.OrdinalIgnoreCase))
-                        {
-                            ignoreTime = Constants.CooldownIgnoreTime;
-                        }
-                        await SendEmbedAsync(context.Message, $"\u26a0 {GuildUtils.Locate(result.ErrorReason, context.Channel)}", null, responseMessage);
-                    }
-                    break;
-
-                case CommandError.ObjectNotFound:
-                    // reason: The error reason (User not found., Role not found., etc)
-                    string reason = result.ErrorReason;
-                    // Delete the last char (.)
-                    reason = reason.Substring(0, result.ErrorReason.Length - 1);
-                    // Convert to title case (User Not Found)
-                    reason = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(reason);
-                    // Remove spaces (UserNotFound)
-                    reason = reason.Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase);
-                    // Locate the string to the current language of the guild
-                    reason = string.Format(GuildUtils.Locate(reason, context.Channel),
-                        Format.Code(context.Client.CurrentUser.ToString()),
-                        context.Client.CurrentUser.Mention,
-                        Format.Code(context.Client.CurrentUser.Id.ToString()));
-
-                    await SendEmbedAsync(context.Message, $"\u26a0 {reason}", null, responseMessage);
-                    break;
-
-                case CommandError.MultipleMatches:
-                    string message = string.Format(GuildUtils.Locate("MultipleMatches", context.Channel),
-                        Format.Code(context.Client.CurrentUser.ToString()),
-                        context.Client.CurrentUser.Mention,
-                        Format.Code(context.Client.CurrentUser.Id.ToString()));
-
-                    await SendEmbedAsync(context.Message, $"\u26a0 {message}", null, responseMessage);
-                    break;
-
-                case CommandError.Unsuccessful:
-                    if (!(result is FergunResult fergunResult) || !fergunResult.IsSilent)
-                    {
-                        await SendEmbedAsync(context.Message, $"\u26a0 {result.ErrorReason}".Truncate(EmbedBuilder.MaxDescriptionLength), null, responseMessage);
-                    }
-                    break;
-
-                case CommandError.Exception when result is ExecuteResult execResult:
-                    var exception = execResult.Exception;
-
-                    if (exception is HttpException httpException && httpException.HttpCode >= HttpStatusCode.InternalServerError)
-                    {
-                        await Task.Delay(2000);
-                        var builder = new EmbedBuilder()
-                            .WithTitle(GuildUtils.Locate("DiscordServerError", context.Channel))
-                            .WithDescription($"\u26a0 {GuildUtils.Locate("DiscordServerErrorInfo", context.Channel)}")
-                            .AddField(GuildUtils.Locate("ErrorDetails", context.Channel),
-                                Format.Code($"Code: {(int)httpException.HttpCode}, Reason: {httpException.Reason}", "md"))
-                            .WithColor(FergunClient.Config.EmbedColor);
-
-                        try
-                        {
-                            await SendEmbedAsync(context.Message, builder.Build());
-                        }
-                        catch (HttpException) { }
-                        break;
-                    }
-
-                    var owner = (await context.Client.GetApplicationInfoAsync()).Owner;
-
-                    string errorMessage = Format.Code(exception.Message, "cs");
-
-                    if (context.User.Id != owner.Id)
-                    {
-                        errorMessage += "\n" + string.Format(GuildUtils.Locate("ErrorHelp", context.Channel), FergunClient.Config.SupportServer, Constants.GitHubRepository);
-                    }
-
-                    var builder2 = new EmbedBuilder()
-                        .WithTitle($"\u274c {GuildUtils.Locate("FailedExecution", context.Channel)} {Format.Code(command.Name)}")
-                        .AddField(GuildUtils.Locate("ErrorType", context.Channel), Format.Code(exception.GetType().Name, "cs"))
-                        .AddField(GuildUtils.Locate("ErrorMessage", context.Channel), errorMessage)
-                        .WithColor(FergunClient.Config.EmbedColor);
-
-                    await SendEmbedAsync(context.Message, builder2.Build());
-
-                    if (context.User.Id == owner.Id || !_isValidLogChannel || FergunClient.Config.LogChannel == 0) break;
-                    // if the user that executed the command isn't the bot owner, send the full stack trace to the errors channel
-
-                    var channel = await commandContext.Client.GetChannelAsync(FergunClient.Config.LogChannel);
-                    if (!(channel is IMessageChannel messageChannel))
-                    {
-                        _isValidLogChannel = false;
-                        await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", $"Invalid log channel Id ({FergunClient.Config.LogChannel}). Not possible to send the embed with the error info."));
-                        break;
-                    }
-
-                    var builder3 = new EmbedBuilder()
-                        .WithTitle($"\u274c Failed to execute {Format.Code(command.Name)} for {context.User} in {context.Display()}".Truncate(EmbedBuilder.MaxTitleLength))
-                        .AddField(GuildUtils.Locate("ErrorType", messageChannel), Format.Code(exception.GetType().Name, "cs"))
-                        .AddField(GuildUtils.Locate("ErrorMessage", messageChannel), Format.Code(exception.ToString().Truncate(EmbedFieldBuilder.MaxFieldValueLength - 10), "cs"))
-                        .AddField("Jump url", context.Message.GetJumpUrl())
-                        .AddField("Command", context.Message.Content.Truncate(EmbedFieldBuilder.MaxFieldValueLength))
-                        .WithColor(FergunClient.Config.EmbedColor);
-
-                    try
-                    {
-                        await messageChannel.SendMessageAsync(embed: builder3.Build());
-                    }
-                    catch (HttpException e)
-                    {
-                        await _logService.LogAsync(new LogMessage(LogSeverity.Warning, "Command", "Error while sending the embed in the log channel", e));
-                    }
-                    break;
-            }
-
-            _ = IgnoreUserAsync(context.User.Id, TimeSpan.FromSeconds(ignoreTime));
-            await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Command", $"Failed to execute \"{command.Name}\" for {context.User} ({context.User.Id}) in {context.Display()}, with error type: {result.Error} and reason: {result.ErrorReason}"));
-        }
-
-        private static async Task IgnoreUserAsync(ulong id, TimeSpan time)
-        {
-            lock (_userLock)
-            {
-                _ignoredUsers.Add(id);
-            }
-            await Task.Delay(time);
-            lock (_userLock)
-            {
-                _ignoredUsers.Remove(id);
-            }
-        }
-
-        private Task SendEmbedAsync(IUserMessage userMessage, string embedText, string text = null, IUserMessage responseMessage = null)
-        {
-            var embed = new EmbedBuilder()
-                .WithColor(FergunClient.Config.EmbedColor)
-                .WithDescription(embedText)
-                .Build();
-
-            return SendEmbedAsync(userMessage, embed, text, responseMessage);
-        }
-
-        private async Task SendEmbedAsync(IUserMessage userMessage, Embed embed, string text = null, IUserMessage responseMessage = null)
-        {
-            var messageCache = _services.GetService<MessageCacheService>();
-            if (responseMessage == null)
-            {
-                var component = new ComponentBuilder().Build(); // remove message components
-                var cache = _services.GetService<CommandCacheService>();
-
-                ulong messageId = 0;
-                bool found = cache?.TryGetValue(userMessage.Id, out messageId) ?? false;
-
-                var response = found ? (IUserMessage)await userMessage.Channel.GetMessageAsync(messageCache, messageId) : null;
-
-                if (response == null)
-                {
-                    response = await userMessage.Channel.SendMessageAsync(text, embed: embed, components: component).ConfigureAwait(false);
-                }
-                else
-                {
-                    var interactive = _services.GetService<InteractiveService>();
-                    if (interactive != null && interactive.TryRemoveCallback(messageId, out var callback))
-                    {
-                        callback.Dispose();
-                    }
-
-                    await response.ModifyAsync(x =>
-                    {
-                        x.Content = text;
-                        x.Embed = embed;
-                        x.Components = component;
-                        x.Attachments = Array.Empty<FileAttachment>();
-                    });
-                }
-
-                if (cache != null && !cache.IsDisabled)
-                {
-                    cache.Add(userMessage, response);
-                }
-            }
-            else
-            {
-                await responseMessage.ModifyOrResendAsync(text, embed, cache: messageCache).ConfigureAwait(false);
-            }
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Services/InteractionHandlingService.cs b/src/Services/InteractionHandlingService.cs
new file mode 100644
index 0000000..3a19a10
--- /dev/null
+++ b/src/Services/InteractionHandlingService.cs
@@ -0,0 +1,123 @@
+using System.Reflection;
+using Discord;
+using Discord.Interactions;
+using Discord.WebSocket;
+using Fergun.Extensions;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Fergun.Services;
+
+public class InteractionHandlingService : IHostedService
+{
+    private readonly DiscordShardedClient _shardedClient;
+    private readonly InteractionService _interactionService;
+    private readonly ILogger<InteractionHandlingService> _logger;
+    private readonly IServiceProvider _services;
+    private readonly ulong _targetGuildId;
+
+    public InteractionHandlingService(DiscordShardedClient client, InteractionService interactionService,
+        ILogger<InteractionHandlingService> logger, IServiceProvider services, IConfiguration configuration)
+    {
+        _shardedClient = client;
+        _interactionService = interactionService;
+        _logger = logger;
+        _services = services;
+        _ = ulong.TryParse(configuration["TargetGuildId"], out _targetGuildId);
+    }
+
+    /// <inheritdoc />
+    public async Task StartAsync(CancellationToken cancellationToken)
+    {
+        _interactionService.Log += LogInteraction;
+        _interactionService.SlashCommandExecuted += SlashCommandExecuted;
+        _interactionService.ContextCommandExecuted += ContextMenuCommandExecuted;
+        _shardedClient.InteractionCreated += HandleInteractionAsync;
+
+        var modules = await _interactionService.AddModulesAsync(Assembly.GetEntryAssembly(), _services);
+        _logger.LogDebug("Added {moduleCount} command modules", modules.Count());
+
+        _shardedClient.ShardReady += ReadyAsync;
+    }
+
+    /// <inheritdoc />
+    public Task StopAsync(CancellationToken cancellationToken)
+    {
+        _interactionService.Log -= LogInteraction;
+        _shardedClient.InteractionCreated -= HandleInteractionAsync;
+        _shardedClient.ShardReady -= ReadyAsync;
+
+        return Task.CompletedTask;
+    }
+
+    public async Task ReadyAsync(DiscordSocketClient client)
+    {
+        if (_shardedClient.Shards.All(x => x.ConnectionState == ConnectionState.Connected))
+        {
+            _shardedClient.ShardReady -= ReadyAsync;
+            await ReadyAsync();
+        }
+    }
+
+    public async Task ReadyAsync()
+    {
+        if (_targetGuildId == 0)
+        {
+            _logger.LogInformation("Registering commands globally");
+            await _interactionService.RegisterCommandsGloballyAsync();
+        }
+        else
+        {
+            _logger.LogInformation("Registering commands to guild {guildId}", _targetGuildId);
+            await _interactionService.RegisterCommandsToGuildAsync(_targetGuildId);
+        }
+    }
+
+    public async Task HandleInteractionAsync(SocketInteraction interaction)
+    {
+        var context = new ShardedInteractionContext(_shardedClient, interaction);
+        await _interactionService.ExecuteCommandAsync(context, _services);
+    }
+
+    private async Task SlashCommandExecuted(SlashCommandInfo slashCommand, IInteractionContext context, IResult result)
+    {
+        _logger.LogInformation("Executed slash command \"{name}\" for {username}#{discriminator} ({id}) in {context}",
+            slashCommand.Name, context.User.Username, context.User.Discriminator, context.User.Id, context.Display());
+
+        await HandleInteractionErrorAsync(context, result);
+    }
+
+    private async Task ContextMenuCommandExecuted(ContextCommandInfo contextCommand, IInteractionContext context, IResult result)
+    {
+        _logger.LogInformation("Executed context menu command \"{name}\" for {username}#{discriminator} ({id}) in {context}",
+            contextCommand.Name, context.User.Username, context.User.Discriminator, context.User.Id, context.Display());
+
+        await HandleInteractionErrorAsync(context, result);
+    }
+
+    private static async ValueTask HandleInteractionErrorAsync(IInteractionContext context, IResult result)
+    {
+        if (result.IsSuccess)
+            return;
+
+        string message = result.Error == InteractionCommandError.Exception
+            ? $"An error occurred.\n\nError message: ```{((ExecuteResult)result).Exception.Message}```"
+            : result.ErrorReason;
+
+        if (context.Interaction.HasResponded)
+        {
+            await context.Interaction.FollowupWarning(message, ephemeral: true);
+        }
+        else
+        {
+            await context.Interaction.RespondWarningAsync(message, ephemeral: true);
+        }
+    }
+
+    private Task LogInteraction(LogMessage log)
+    {
+        _logger.Log(log.Severity.ToLogLevel(), new EventId(0, log.Source), log.Exception, log.Message);
+        return Task.CompletedTask;
+    }
+}
\ No newline at end of file
diff --git a/src/Services/LogService.cs b/src/Services/LogService.cs
deleted file mode 100644
index cba5f77..0000000
--- a/src/Services/LogService.cs
+++ /dev/null
@@ -1,167 +0,0 @@
-using System;
-using System.IO;
-using System.IO.Compression;
-using System.Text;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Commands;
-using Discord.WebSocket;
-
-namespace Fergun.Services
-{
-    public class LogService : IDisposable
-    {
-        private static readonly string _appDirectory = AppContext.BaseDirectory;
-        private readonly string _logDirectoryPath;
-        private static TextWriter _writer;
-        private readonly object _writerLock = new object();
-        private readonly LogSeverity _logSeverity;
-        private int _currentDay;
-        private bool _disposed;
-
-        public LogService()
-        {
-            string logDirectoryName = FergunClient.IsDebugMode ? "logs_debug" : "logs";
-            _logDirectoryPath = Path.Combine(_appDirectory, logDirectoryName);
-
-            // Create the log directory if it doesn't exist
-            // What would happen if the folder is deleted while logging..?
-            if (!Directory.Exists(_logDirectoryPath))
-                Directory.CreateDirectory(_logDirectoryPath);
-
-            _currentDay = DateTimeOffset.UtcNow.Day;
-            _writer = TextWriter.Synchronized(File.AppendText(GetLogFile()));
-            CompressYesterdayLogs();
-        }
-
-        public LogService(DiscordShardedClient client, CommandService cmdService, LogSeverity logSeverity) : this()
-        {
-            client.Log += LogAsync;
-            cmdService.Log += LogAsync;
-            _logSeverity = logSeverity;
-        }
-
-        public async Task LogAsync(LogMessage message)
-        {
-            if (_logSeverity < message.Severity)
-                return;
-
-            string logText = GetText(message);
-
-            lock (_writerLock)
-            {
-                if (_currentDay != DateTimeOffset.UtcNow.Day)
-                {
-                    _currentDay = DateTimeOffset.UtcNow.Day;
-                    _writer = TextWriter.Synchronized(File.AppendText(GetLogFile()));
-                    CompressYesterdayLogs();
-                }
-
-                switch (message.Severity)
-                {
-                    case LogSeverity.Critical:
-                        Console.ForegroundColor = ConsoleColor.DarkRed;
-                        break;
-
-                    case LogSeverity.Error:
-                        Console.ForegroundColor = ConsoleColor.Red;
-                        break;
-
-                    case LogSeverity.Warning:
-                        Console.ForegroundColor = ConsoleColor.Yellow;
-                        break;
-
-                    case LogSeverity.Info:
-                        Console.ForegroundColor = ConsoleColor.White;
-                        break;
-
-                    case LogSeverity.Verbose:
-                    case LogSeverity.Debug:
-                        Console.ForegroundColor = ConsoleColor.Gray;
-                        break;
-                }
-
-                Console.WriteLine(logText);
-                Console.ResetColor();
-                _writer.WriteLine(logText);
-                _writer.Flush();
-            }
-            await Task.CompletedTask;
-        }
-
-        private string GetLogFile() => Path.Combine(_logDirectoryPath, $"{DateTimeOffset.UtcNow:yyyy-MM-dd}.txt");
-
-        private void CompressYesterdayLogs()
-        {
-            string yesterday = Path.Combine(_logDirectoryPath, $"{DateTimeOffset.UtcNow.AddDays(-1):yyyy-MM-dd}.txt");
-            // If the yesterday log file exists, compress it.
-            if (File.Exists(yesterday))
-            {
-                _ = CompressAsync(yesterday);
-            }
-        }
-
-        private static async Task CompressAsync(string filePath)
-        {
-            await using (var msi = new MemoryStream(await File.ReadAllBytesAsync(filePath)))
-            await using (var mso = new MemoryStream())
-            {
-                await using (var gs = new GZipStream(mso, CompressionMode.Compress))
-                {
-                    await msi.CopyToAsync(gs);
-                }
-                await File.WriteAllBytesAsync(Path.ChangeExtension(filePath, "gz"), mso.ToArray());
-            }
-            File.Delete(filePath);
-        }
-
-        private static string GetText(LogMessage message)
-        {
-            string msg = message.Message;
-            string exMessage = message.Exception?.ToString();
-            const int padWidth = 32;
-            int capacity = 30 + padWidth + (msg?.Length ?? 0) + (exMessage?.Length ?? 0);
-
-            var builder = new StringBuilder($"[{DateTimeOffset.UtcNow:HH:mm:ss}] [{message.Source}/{message.Severity}]".PadRight(padWidth), capacity);
-
-            if (!string.IsNullOrEmpty(msg))
-            {
-                foreach (var ch in msg)
-                {
-                    //Strip control chars
-                    if (!char.IsControl(ch))
-                        builder.Append(ch);
-                }
-            }
-            if (exMessage != null)
-            {
-                if (!string.IsNullOrEmpty(msg))
-                {
-                    builder.Append(':');
-                    builder.AppendLine();
-                }
-                builder.Append(exMessage);
-            }
-
-            return builder.ToString();
-        }
-
-        public void Dispose()
-        {
-            Dispose(true);
-            GC.SuppressFinalize(this);
-        }
-
-        protected virtual void Dispose(bool disposing)
-        {
-            if (_disposed)
-            {
-                throw new ObjectDisposedException(nameof(LogService), "Service has been disposed.");
-            }
-
-            if (!disposing) return;
-            _writer.Dispose();
-            _disposed = true;
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Services/MessageCacheService.cs b/src/Services/MessageCacheService.cs
deleted file mode 100644
index 49f7687..0000000
--- a/src/Services/MessageCacheService.cs
+++ /dev/null
@@ -1,1198 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Collections.Immutable;
-using System.Diagnostics;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Discord;
-using Discord.WebSocket;
-
-namespace Fergun.Services
-{
-    /// <summary>
-    /// Represents an optimized cache of sent, deleted and edited messages.
-    /// </summary>
-    public class MessageCacheService : IDisposable
-    {
-        // short term cache
-        private readonly ConcurrentDictionary<ulong, ConcurrentDictionary<ulong, ICachedMessage>> _cache
-            = new ConcurrentDictionary<ulong, ConcurrentDictionary<ulong, ICachedMessage>>();
-
-        private readonly ConcurrentDictionary<ulong, ConcurrentQueue<ulong>> _orderedCache
-            = new ConcurrentDictionary<ulong, ConcurrentQueue<ulong>>();
-
-        // long term cache
-        private readonly ConcurrentDictionary<ulong, ConcurrentDictionary<ulong, ICachedMessage>> _editedCache
-            = new ConcurrentDictionary<ulong, ConcurrentDictionary<ulong, ICachedMessage>>();
-
-        private readonly ConcurrentDictionary<ulong, ConcurrentDictionary<ulong, ICachedMessage>> _deletedCache
-            = new ConcurrentDictionary<ulong, ConcurrentDictionary<ulong, ICachedMessage>>();
-
-        private readonly ConcurrentDictionary<ulong, DateTimeOffset> _lastCommandUsageTimes = new ConcurrentDictionary<ulong, DateTimeOffset>();
-        private readonly bool _onlyCacheUserDeletedEditedMessages;
-        private readonly Func<LogMessage, Task> _logger;
-        private readonly BaseSocketClient _client;
-        private readonly double _maxMessageTime;
-        private readonly int _messageCacheSize;
-        private readonly int _minCommandTime;
-        private Timer _autoClear;
-        private bool _disposed;
-
-        private MessageCacheService()
-        {
-            IsDisabled = true;
-            _disposed = true;
-        }
-
-        /// <inheritdoc cref="MessageCacheService(BaseSocketClient, int, Func{LogMessage, Task}, int, double, int, bool)"/>
-        public MessageCacheService(DiscordSocketClient client, int messageCacheSize, Func<LogMessage, Task> logger = null,
-            int period = 3600000, double maxMessageTime = 6, int minCommandTime = 12, bool onlyCacheUserDeletedEditedMessages = true)
-            : this((BaseSocketClient)client, messageCacheSize, logger, period, maxMessageTime, minCommandTime, onlyCacheUserDeletedEditedMessages)
-        {
-        }
-
-        /// <inheritdoc cref="MessageCacheService(BaseSocketClient, int, Func{LogMessage, Task}, int, double, int, bool)"/>
-        public MessageCacheService(DiscordShardedClient client, int messageCacheSize, Func<LogMessage, Task> logger = null,
-            int period = 3600000, double maxMessageTime = 6, int minCommandTime = 12, bool onlyCacheUserDeletedEditedMessages = true)
-            : this((BaseSocketClient)client, messageCacheSize, logger, period, maxMessageTime, minCommandTime, onlyCacheUserDeletedEditedMessages)
-        {
-        }
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="MessageCacheService"/> class.
-        /// </summary>
-        /// <param name="client">The client.</param>
-        /// <param name="messageCacheSize">The message cache size. This only applies to sent messages and not edited/deleted messages.</param>
-        /// <param name="logger">The logger.</param>
-        /// <param name="period">The period between cleanings. This only applies to edited/deleted messages.</param>
-        /// <param name="maxMessageTime">The max. time the messages can be kept in the cache. This only applies to edited/deleted messages.</param>
-        /// <param name="minCommandTime">The min. hours since a command has to be used in a guild for the messages to be cached there. Setting this to 0 disables this requirement.<br/>
-        /// Use <see cref="UpdateLastCommandUsageTime(ulong)"/> in your command handler to update the last time a command was used.</param>
-        /// <param name="onlyCacheUserDeletedEditedMessages">Whether to only save messages from users in the edited/deleted messages cache.</param>
-        public MessageCacheService(BaseSocketClient client, int messageCacheSize, Func<LogMessage, Task> logger = null,
-            int period = 3600000, double maxMessageTime = 6, int minCommandTime = 12, bool onlyCacheUserDeletedEditedMessages = true)
-        {
-            _client = client;
-
-            _client.MessageReceived += MessageReceived;
-            _client.MessageDeleted += MessageDeleted;
-            _client.MessageUpdated += MessageUpdated;
-            _client.MessagesBulkDeleted += MessagesBulkDeleted;
-
-            _client.ReactionAdded += ReactionAdded;
-            _client.ReactionRemoved += ReactionRemoved;
-            _client.ReactionsCleared += ReactionsCleared;
-            _client.ReactionsRemovedForEmote += ReactionsRemovedForEmote;
-
-            _client.ChannelDestroyed += ChannelDestroyed;
-
-            _client.LeftGuild += LeftGuild;
-
-            _logger = logger ?? (_ => Task.CompletedTask);
-            _autoClear = new Timer(OnTimerFired, null, period, period);
-            _messageCacheSize = messageCacheSize;
-            _maxMessageTime = maxMessageTime;
-            _minCommandTime = minCommandTime;
-            _onlyCacheUserDeletedEditedMessages = onlyCacheUserDeletedEditedMessages;
-        }
-
-        /// <summary>
-        /// Returns a disabled instance of <see cref="MessageCacheService"/>.
-        /// </summary>
-        public static MessageCacheService Disabled => new MessageCacheService();
-
-        /// <summary>
-        /// Gets whether the cache is disabled.
-        /// </summary>
-        public bool IsDisabled { get; }
-
-        /// <summary>
-        /// Gets a cache of messages for a channel.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <param name="sourceEvent">The source event.</param>
-        /// <returns>A cache of messages.</returns>
-        public IReadOnlyDictionary<ulong, ICachedMessage> GetCacheForChannel(IMessageChannel channel,
-            MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived)
-            => GetCacheForChannel(channel.Id, sourceEvent);
-
-        /// <summary>
-        /// Gets a cache of messages for a channel.
-        /// </summary>
-        /// <param name="channelId">The channel id.</param>
-        /// <param name="sourceEvent">The source event.</param>
-        /// <returns>A cache of messages.</returns>
-        public IReadOnlyDictionary<ulong, ICachedMessage> GetCacheForChannel(ulong channelId,
-            MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived)
-        {
-            GetCacheForEvent(sourceEvent).TryGetValue(channelId, out var channel);
-
-            return channel as IReadOnlyDictionary<ulong, ICachedMessage> ?? ImmutableDictionary<ulong, ICachedMessage>.Empty;
-        }
-
-        /// <summary>
-        /// Clears all channels and messages from the cache.
-        /// </summary>
-        public void Clear()
-        {
-            _cache.Clear();
-            _orderedCache.Clear();
-            _editedCache.Clear();
-            _deletedCache.Clear();
-            _lastCommandUsageTimes.Clear();
-        }
-
-        /// <summary>
-        /// Attempts to clear the specified channel and all its messages from the cache.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <returns>Whether the channel has been removed from at least one cache.</returns>
-        public bool TryClear(IMessageChannel channel) => TryClear(channel.Id);
-
-        /// <summary>
-        /// Attempts to clear the specified channel and all its messages from the cache.
-        /// </summary>
-        /// <param name="channelId">The channel id.</param>
-        /// <returns>Whether the channel has been removed from at least one cache.</returns>
-        public bool TryClear(ulong channelId)
-            => _cache.TryRemove(channelId, out _)
-               | _orderedCache.TryRemove(channelId, out _)
-               | _editedCache.TryRemove(channelId, out _)
-               | _deletedCache.TryRemove(channelId, out _);
-
-        /// <summary>
-        /// Attempts to get a cached message from the provided channel and message id.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <param name="messageId">The id of the cached message.</param>
-        /// <param name="message">The cached message, or <c>null</c> if the message could not be found.</param>
-        /// <param name="sourceEvent">The source event.</param>
-        /// <returns>Whether the message was found.</returns>
-        public bool TryGetCachedMessage(IMessageChannel channel, ulong messageId, out ICachedMessage message,
-            MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived)
-            => TryGetCachedMessage(channel.Id, messageId, out message, sourceEvent);
-
-        /// <summary>
-        /// Attempts to get a cached message from the provided channel id and message id.
-        /// </summary>
-        /// <param name="channelId">The id of the channel.</param>
-        /// <param name="messageId">The id of the cached message.</param>
-        /// <param name="message">The cached message, or <c>null</c> if the message could not be found.</param>
-        /// <param name="sourceEvent">The source event.</param>
-        /// <returns>Whether the message was found.</returns>
-        public bool TryGetCachedMessage(ulong channelId, ulong messageId, out ICachedMessage message,
-            MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived)
-        {
-            message = null;
-            var cache = GetCacheForEvent(sourceEvent);
-
-            return cache.TryGetValue(channelId, out var cachedChannel) && cachedChannel.TryGetValue(messageId, out message);
-        }
-
-        /// <summary>
-        /// Attempts to get a cached message from the provided message id, searching in every channel cache.
-        /// </summary>
-        /// <param name="messageId">The id of the cached message.</param>
-        /// <param name="message">The cached message, or <c>null</c> if the message could not be found.</param>
-        /// <param name="sourceEvent">The source event.</param>
-        /// <returns>Whether the message was found.</returns>
-        public bool TryGetCachedMessage(ulong messageId, out ICachedMessage message,
-            MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived)
-        {
-            message = null;
-            var cache = GetCacheForEvent(sourceEvent);
-
-            foreach (var cachedChannel in cache)
-            {
-                if (cachedChannel.Value.TryGetValue(messageId, out message))
-                    return true;
-            }
-
-            return false;
-        }
-
-        /// <summary>
-        /// Attempts to remove and return a cached message from the provided channel and message id.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <param name="messageId">The id of the cached message.</param>
-        /// <param name="message">The cached message, or <c>null</c> if the message could not be found.</param>
-        /// <param name="sourceEvent">The source event.</param>
-        /// <returns>Whether the message was found.</returns>
-        public bool TryRemoveCachedMessage(IMessageChannel channel, ulong messageId, out ICachedMessage message,
-            MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived)
-            => TryRemoveCachedMessage(channel.Id, messageId, out message, sourceEvent);
-
-        /// <summary>
-        /// Attempts to remove and return a cached message from the provided channel id and message id.
-        /// </summary>
-        /// <param name="channelId">The id of the channel.</param>
-        /// <param name="messageId">The id of the cached message.</param>
-        /// <param name="message">The cached message, or <c>null</c> if the message could not be found.</param>
-        /// <param name="sourceEvent">The source event.</param>
-        /// <returns>Whether the message was found.</returns>
-        public bool TryRemoveCachedMessage(ulong channelId, ulong messageId, out ICachedMessage message,
-            MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived)
-        {
-            message = null;
-            var cache = GetCacheForEvent(sourceEvent);
-
-            return cache.TryGetValue(channelId, out var cachedChannel) && cachedChannel.TryRemove(messageId, out message);
-        }
-
-        /// <summary>
-        /// Updates the last time a command was executed in the specified guild to <see cref="DateTimeOffset.UtcNow"/>.
-        /// </summary>
-        /// <param name="guildId">The id of the guild.</param>
-        public void UpdateLastCommandUsageTime(ulong guildId)
-            => UpdateLastCommandUsageTime(guildId, DateTimeOffset.UtcNow);
-
-        /// <summary>
-        /// Updates the last time a command was executed in the specified guild to <paramref name="dateTime"/>.
-        /// </summary>
-        /// <param name="guildId">The id of the guild.</param>
-        /// <param name="dateTime">The <see cref="DateTimeOffset"/> to set.</param>
-        public void UpdateLastCommandUsageTime(ulong guildId, DateTimeOffset dateTime)
-        {
-            _lastCommandUsageTimes[guildId] = dateTime;
-        }
-
-        /// <inheritdoc/>
-        public void Dispose()
-        {
-            Dispose(true);
-            GC.SuppressFinalize(this);
-        }
-
-        internal IEnumerable<ulong> GetMessageQueue(ulong channelId) => _orderedCache.GetValueOrDefault(channelId) ?? Enumerable.Empty<ulong>();
-
-        private ConcurrentDictionary<ulong, ConcurrentDictionary<ulong, ICachedMessage>> GetCacheForEvent(MessageSourceEvent sourceEvent) =>
-            sourceEvent switch
-            {
-                MessageSourceEvent.MessageReceived => _cache,
-                MessageSourceEvent.MessageUpdated => _editedCache,
-                _ => _deletedCache
-            };
-
-        private bool IsCachedChannelNotPresentOrEmpty(ulong channelId, MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived)
-            => !GetCacheForEvent(sourceEvent).TryGetValue(channelId, out var cachedChannel) || cachedChannel.IsEmpty;
-
-        private static ICachedMessage CreateCachedMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent)
-            => CreateCachedMessage(message, cachedAt, sourceEvent, null);
-
-        private static ICachedMessage CreateCachedMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage)
-            => CreateCachedMessage(message, cachedAt, sourceEvent, originalMessage, null);
-
-        private static ICachedMessage CreateCachedMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage,
-            IReadOnlyCollection<IEmbed> embeds)
-            => CreateCachedMessage(message, cachedAt, sourceEvent, originalMessage, embeds, null);
-
-        private static ICachedMessage CreateCachedMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage,
-            IReadOnlyCollection<IEmbed> embeds, List<SocketReaction> reactions)
-            => message is IUserMessage userMessage
-                ? new CachedUserMessage(userMessage, cachedAt, sourceEvent, originalMessage, embeds, reactions)
-                : new CachedMessage(message, cachedAt, sourceEvent, originalMessage, embeds, reactions);
-
-        // This only applies to the long term cache.
-        private int ClearOldMessages(MessageSourceEvent sourceEvent)
-        {
-            int removed = 0;
-            var now = DateTimeOffset.UtcNow;
-
-            foreach (var cachedChannel in GetCacheForEvent(sourceEvent))
-            {
-                foreach (var cachedMessage in cachedChannel.Value)
-                {
-                    if ((now - SnowflakeUtils.FromSnowflake(cachedMessage.Key)).TotalHours >= _maxMessageTime)
-                    {
-                        cachedChannel.Value.TryRemove(cachedMessage.Key, out _);
-                        removed++;
-                    }
-                }
-            }
-
-            return removed;
-        }
-
-        private void OnTimerFired(object state)
-        {
-            _ = Task.Run(() =>
-            {
-                int removed = ClearOldMessages(MessageSourceEvent.MessageUpdated) +
-                              ClearOldMessages(MessageSourceEvent.MessageDeleted);
-
-                _ = _logger(new LogMessage(LogSeverity.Verbose, "MsgCache", $"Cleaned {removed} deleted / edited messages from the cache."));
-            });
-        }
-
-        private Task MessageReceived(SocketMessage message)
-        {
-            HandleReceivedMessage(message);
-            return Task.CompletedTask;
-        }
-
-        private void HandleReceivedMessage(IMessage message)
-        {
-            if (!(message.Channel is IGuildChannel guildChannel))
-                return;
-
-            if (_minCommandTime != 0)
-            {
-                if (!_lastCommandUsageTimes.TryGetValue(guildChannel.GuildId, out var lastUsageTime))
-                    return;
-
-                var now = DateTimeOffset.UtcNow;
-
-                if (lastUsageTime <= now.AddHours(-_minCommandTime) || lastUsageTime > now)
-                    return;
-            }
-
-            var cachedChannel = _cache.GetOrAdd(message.Channel.Id,
-                new ConcurrentDictionary<ulong, ICachedMessage>(Environment.ProcessorCount, (int)(_messageCacheSize * 1.05)));
-
-            var channelQueue = _orderedCache.GetOrAdd(message.Channel.Id, new ConcurrentQueue<ulong>());
-
-            cachedChannel[message.Id] = CreateCachedMessage(message, message.CreatedAt, MessageSourceEvent.MessageReceived);
-            channelQueue.Enqueue(message.Id);
-
-            while (cachedChannel.Count > _messageCacheSize && channelQueue.TryDequeue(out ulong msgId))
-            {
-                cachedChannel.TryRemove(msgId, out _);
-            }
-        }
-
-        private Task MessageDeleted(Cacheable<IMessage, ulong> cachedMessage, Cacheable<IMessageChannel, ulong> cachedChannel)
-        {
-            HandleDeletedMessage(cachedMessage.Id, cachedChannel.Id);
-            return Task.CompletedTask;
-        }
-
-        private void HandleDeletedMessage(ulong messageId, ulong channelId)
-        {
-            // The default message cache removes deleted message from its cache, here we just move the message to the deleted messages
-            if (TryRemoveCachedMessage(channelId, messageId, out var message)) // A message gets deleted
-            {
-                if (_onlyCacheUserDeletedEditedMessages && message.Source != MessageSource.User) return;
-
-                message.Update(DateTimeOffset.UtcNow, MessageSourceEvent.MessageDeleted);
-
-                // move cached message to the deleted messages cache
-                var cachedChannel = _deletedCache.GetOrAdd(channelId, new ConcurrentDictionary<ulong, ICachedMessage>());
-                cachedChannel[messageId] = message;
-            }
-            if (TryRemoveCachedMessage(channelId, messageId, out message, MessageSourceEvent.MessageUpdated)) // An updated message gets deleted
-            {
-                var cachedChannel = _deletedCache.GetOrAdd(channelId, new ConcurrentDictionary<ulong, ICachedMessage>());
-
-                // Avoid adding again the deleted message
-                if (cachedChannel.ContainsKey(messageId)) return;
-
-                message.Update(DateTimeOffset.UtcNow, MessageSourceEvent.MessageDeleted);
-
-                // move cached message to the deleted messages cache
-                cachedChannel[messageId] = message;
-            }
-        }
-
-        private Task MessageUpdated(Cacheable<IMessage, ulong> cachedBefore, SocketMessage updatedMessage, ISocketMessageChannel channel)
-        {
-            HandleUpdatedMessage(updatedMessage, channel);
-            return Task.CompletedTask;
-        }
-
-        private void HandleUpdatedMessage(IMessage updatedMessage, IMessageChannel channel)
-        {
-            // From Discord API docs:
-            // "Unlike creates, message updates may contain only a subset of the full message object payload"
-            // It isn't possible to update a message property since the Update() method is internal,
-            // but I only want the updated embeds/link previews, so I just added a new parameter for the updated embed
-            // in CachedMessage/CachedUserMessage's constructor and use that overload if the updated message's author is a SocketUnknownUser
-            // and both messages contains a different number of embeds.
-
-            if (TryGetCachedMessage(channel, updatedMessage.Id, out var message, MessageSourceEvent.MessageUpdated)) // An updated message gets updated again
-            {
-                var cachedChannel = _editedCache.GetOrAdd(channel.Id, new ConcurrentDictionary<ulong, ICachedMessage>());
-
-                message.Update(null);
-                var reactions = (message as CachedMessage)?._reactions;
-
-                bool useUpdatedEmbeds = updatedMessage.Author is SocketUnknownUser && updatedMessage.Embeds.Count != message.Embeds.Count;
-
-                if (useUpdatedEmbeds)
-                {
-                    // "Add" the updated embeds to the existing message
-                    cachedChannel[updatedMessage.Id] = CreateCachedMessage(message, message.CachedAt, MessageSourceEvent.MessageUpdated, null, updatedMessage.Embeds, reactions);
-                }
-                else if (!(updatedMessage.Author is SocketUnknownUser))
-                {
-                    // Create a new cached message containing the previous and current messages
-                    cachedChannel[updatedMessage.Id] = CreateCachedMessage(updatedMessage, DateTimeOffset.UtcNow, MessageSourceEvent.MessageUpdated, message, null, reactions);
-                }
-            }
-            else if (TryGetCachedMessage(channel, updatedMessage.Id, out message)) // A message gets updated
-            {
-                var reactions = (message as CachedMessage)?._reactions;
-
-                bool useUpdatedEmbeds = updatedMessage.Author is SocketUnknownUser && updatedMessage.Embeds.Count != message.Embeds.Count;
-
-                // We need to simulate the functionality of the default message cache,
-                // so we update the message in the short term cache instead of removing it
-
-                if (useUpdatedEmbeds)
-                {
-                    // "Add" the updated embeds to the existing message
-                    _cache[channel.Id][updatedMessage.Id] = CreateCachedMessage(message, DateTimeOffset.UtcNow, MessageSourceEvent.MessageUpdated, null, updatedMessage.Embeds, reactions);
-                }
-                else if (!(updatedMessage.Author is SocketUnknownUser))
-                {
-                    _cache[channel.Id][updatedMessage.Id] = CreateCachedMessage(updatedMessage, DateTimeOffset.UtcNow, MessageSourceEvent.MessageUpdated, null, null, reactions);
-                }
-
-                if (_onlyCacheUserDeletedEditedMessages && message.Source != MessageSource.User) return;
-
-                var cachedChannel = _editedCache.GetOrAdd(channel.Id, new ConcurrentDictionary<ulong, ICachedMessage>());
-
-                if (useUpdatedEmbeds)
-                {
-                    // "Add" the updated embeds to the existing message
-                    cachedChannel[updatedMessage.Id] = CreateCachedMessage(message, message.CachedAt, MessageSourceEvent.MessageUpdated, null, updatedMessage.Embeds, reactions);
-                }
-                else if (!(updatedMessage.Author is SocketUnknownUser))
-                {
-                    // "Copy" the cached message to the edited messages cache
-                    cachedChannel[updatedMessage.Id] = CreateCachedMessage(updatedMessage, DateTimeOffset.UtcNow, MessageSourceEvent.MessageUpdated, message, null, reactions);
-                }
-            }
-        }
-
-        private Task MessagesBulkDeleted(IReadOnlyCollection<Cacheable<IMessage, ulong>> cachedMessages, Cacheable<IMessageChannel, ulong> cachedChannel)
-        {
-            if (IsCachedChannelNotPresentOrEmpty(cachedChannel.Id) && IsCachedChannelNotPresentOrEmpty(cachedChannel.Id, MessageSourceEvent.MessageUpdated))
-            {
-                return Task.CompletedTask;
-            }
-
-            _ = Task.Run(() =>
-            {
-                foreach (var cached in cachedMessages)
-                {
-                    HandleDeletedMessage(cached.Id, cachedChannel.Id);
-                }
-            });
-
-            return Task.CompletedTask;
-        }
-
-        private Task ReactionAdded(Cacheable<IUserMessage, ulong> cachedMessage, Cacheable<IMessageChannel, ulong> cachedChannel, SocketReaction reaction)
-        {
-            if (TryGetCachedMessage(cachedChannel.Id, cachedMessage.Id, out var message))
-            {
-                message.AddReaction(reaction);
-            }
-
-            if (TryGetCachedMessage(cachedChannel.Id, cachedMessage.Id, out message, MessageSourceEvent.MessageUpdated))
-            {
-                message.AddReaction(reaction);
-            }
-
-            return Task.CompletedTask;
-        }
-
-        private Task ReactionRemoved(Cacheable<IUserMessage, ulong> cachedMessage, Cacheable<IMessageChannel, ulong> cachedChannel, SocketReaction reaction)
-        {
-            if (TryGetCachedMessage(cachedChannel.Id, cachedMessage.Id, out var message))
-            {
-                message.RemoveReaction(reaction);
-            }
-
-            if (TryGetCachedMessage(cachedChannel.Id, cachedMessage.Id, out message, MessageSourceEvent.MessageUpdated))
-            {
-                message.RemoveReaction(reaction);
-            }
-
-            return Task.CompletedTask;
-        }
-
-        private Task ReactionsCleared(Cacheable<IUserMessage, ulong> cachedMessage, Cacheable<IMessageChannel, ulong> cachedChannel)
-        {
-            if (TryGetCachedMessage(cachedChannel.Id, cachedMessage.Id, out var message))
-            {
-                message.RemoveAllReactions();
-            }
-
-            if (TryGetCachedMessage(cachedChannel.Id, cachedMessage.Id, out message, MessageSourceEvent.MessageUpdated))
-            {
-                message.RemoveAllReactions();
-            }
-
-            return Task.CompletedTask;
-        }
-
-        private Task ReactionsRemovedForEmote(Cacheable<IUserMessage, ulong> cachedMessage, Cacheable<IMessageChannel, ulong> cachedChannel, IEmote emote)
-        {
-            if (TryGetCachedMessage(cachedChannel.Id, cachedMessage.Id, out var message))
-            {
-                message.RemoveAllReactionsForEmote(emote);
-            }
-
-            if (TryGetCachedMessage(cachedChannel.Id, cachedMessage.Id, out message, MessageSourceEvent.MessageUpdated))
-            {
-                message.RemoveAllReactionsForEmote(emote);
-            }
-
-            return Task.CompletedTask;
-        }
-
-        private Task ChannelDestroyed(SocketChannel channel)
-        {
-            _ = Task.Run(async () =>
-            {
-                if (channel is IMessageChannel messageChanel && TryClear(messageChanel))
-                {
-                    await _logger(new LogMessage(LogSeverity.Verbose, "MsgCache", $"Removed cached channel {messageChanel.Id}"));
-                }
-            });
-
-            return Task.CompletedTask;
-        }
-
-        private Task LeftGuild(SocketGuild guild)
-        {
-            _ = Task.Run(async () =>
-            {
-                int count = 0;
-                foreach (var channel in guild.TextChannels)
-                {
-                    if (TryClear(channel))
-                        count++;
-                }
-
-                _lastCommandUsageTimes.TryRemove(guild.Id, out _);
-                if (count > 0)
-                {
-                    await _logger(new LogMessage(LogSeverity.Verbose, "MsgCache", $"Removed {count} cached channels from guild {guild.Id}"));
-                }
-            });
-
-            return Task.CompletedTask;
-        }
-
-        protected virtual void Dispose(bool disposing)
-        {
-            if (_disposed)
-            {
-                throw new ObjectDisposedException(nameof(MessageCacheService), "Service has been disposed.");
-            }
-
-            if (!disposing) return;
-            _autoClear.Dispose();
-            _autoClear = null;
-
-            Clear();
-
-            _client.MessageReceived -= MessageReceived;
-            _client.MessageDeleted -= MessageDeleted;
-            _client.MessageUpdated -= MessageUpdated;
-            _client.MessagesBulkDeleted -= MessagesBulkDeleted;
-
-            _client.ReactionAdded -= ReactionAdded;
-            _client.ReactionRemoved -= ReactionRemoved;
-            _client.ReactionsCleared -= ReactionsCleared;
-            _client.ReactionsRemovedForEmote -= ReactionsRemovedForEmote;
-
-            _client.ChannelDestroyed -= ChannelDestroyed;
-
-            _client.LeftGuild -= LeftGuild;
-
-            _disposed = true;
-        }
-    }
-
-    /// <summary>
-    /// Represents an edited message.
-    /// </summary>
-    public interface IEditedMessage : IMessage
-    {
-        /// <summary>
-        /// Gets the original message prior to being edited. This property is only present if the message has been edited once.
-        /// </summary>
-        public IMessage OriginalMessage { get; }
-
-        internal void Update(IMessage originalMessage);
-    }
-
-    /// <summary>
-    /// Represents a generic cached message.
-    /// </summary>
-    public interface ICachedMessage : IEditedMessage
-    {
-        /// <summary>
-        /// Gets when this message was cached.
-        /// </summary>
-        public DateTimeOffset CachedAt { get; }
-
-        /// <summary>
-        /// Gets the source event of this message.
-        /// </summary>
-        public MessageSourceEvent SourceEvent { get; }
-
-        internal void Update(DateTimeOffset cachedAt, MessageSourceEvent sourceEvent);
-
-        internal void AddReaction(SocketReaction reaction);
-
-        internal void RemoveReaction(SocketReaction reaction);
-
-        internal void RemoveAllReactions();
-
-        internal void RemoveAllReactionsForEmote(IEmote emote);
-    }
-
-    /// <summary>
-    /// Represents a cached user message.
-    /// </summary>
-    [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")]
-    public class CachedUserMessage : CachedMessage, IUserMessage
-    {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CachedUserMessage"/> class.
-        /// </summary>
-        /// <param name="message">The message.</param>
-        /// <param name="cachedAt">When the message was cached.</param>
-        /// <param name="sourceEvent">The source event of the message.</param>
-        internal CachedUserMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent)
-            : base(message, cachedAt, sourceEvent)
-        {
-        }
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CachedMessage"/> class.
-        /// </summary>
-        /// <param name="message">The message.</param>
-        /// <param name="cachedAt">When the message was cached.</param>
-        /// <param name="sourceEvent">The source event of the message.</param>
-        /// <param name="originalMessage">The original message (prior to being edited).</param>
-        internal CachedUserMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage)
-            : base(message, cachedAt, sourceEvent, originalMessage)
-        {
-        }
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CachedMessage"/> class.
-        /// </summary>
-        /// <param name="message">The message.</param>
-        /// <param name="cachedAt">When the message was cached.</param>
-        /// <param name="sourceEvent">The source event of the message.</param>
-        /// <param name="originalMessage">The original message (prior to being edited).</param>
-        /// <param name="embeds">A collection of embeds.</param>
-        internal CachedUserMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage,
-            IReadOnlyCollection<IEmbed> embeds)
-            : base(message, cachedAt, sourceEvent, originalMessage, embeds)
-        {
-        }
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CachedMessage"/> class.
-        /// </summary>
-        /// <param name="message">The message.</param>
-        /// <param name="cachedAt">When the message was cached.</param>
-        /// <param name="sourceEvent">The source event of the message.</param>
-        /// <param name="originalMessage">The original message (prior to being edited).</param>
-        /// <param name="embeds">A collection of embeds.</param>
-        /// <param name="reactions">A collection of reactions.</param>
-        internal CachedUserMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage,
-            IReadOnlyCollection<IEmbed> embeds, List<SocketReaction> reactions)
-            : base(message, cachedAt, sourceEvent, originalMessage, embeds, reactions)
-        {
-        }
-
-        /// <inheritdoc/>
-        public Task ModifyAsync(Action<MessageProperties> func, RequestOptions options = null)
-            => ((IUserMessage)_message).ModifyAsync(func, options);
-
-        /// <inheritdoc/>
-        public Task PinAsync(RequestOptions options = null) => ((IUserMessage)_message).PinAsync(options);
-
-        /// <inheritdoc/>
-        public Task UnpinAsync(RequestOptions options = null) => ((IUserMessage)_message).UnpinAsync(options);
-
-        /// <inheritdoc/>
-        public Task CrosspostAsync(RequestOptions options = null) => ((IUserMessage)_message).CrosspostAsync(options);
-
-        /// <inheritdoc/>
-        public string Resolve(TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name,
-            TagHandling roleHandling = TagHandling.Name, TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name)
-            => ((IUserMessage)_message).Resolve(userHandling, channelHandling, roleHandling, everyoneHandling, emojiHandling);
-
-        /// <inheritdoc/>
-        public IUserMessage ReferencedMessage => ((IUserMessage)_message).ReferencedMessage;
-    }
-
-    /// <summary>
-    /// Represents a cached message.
-    /// </summary>
-    [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")]
-    public class CachedMessage : ICachedMessage
-    {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CachedMessage"/> class.
-        /// </summary>
-        /// <param name="message">The message.</param>
-        /// <param name="cachedAt">When the message was cached.</param>
-        /// <param name="sourceEvent">The source event of the message.</param>
-        internal CachedMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent)
-        {
-            _message = message;
-            CachedAt = cachedAt;
-            SourceEvent = sourceEvent;
-        }
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CachedMessage"/> class.
-        /// </summary>
-        /// <param name="message">The message.</param>
-        /// <param name="cachedAt">When the message was cached.</param>
-        /// <param name="sourceEvent">The source event of the message.</param>
-        /// <param name="originalMessage">The original message (prior to being edited).</param>
-        internal CachedMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage)
-            : this(message, cachedAt, sourceEvent)
-        {
-            OriginalMessage = originalMessage;
-        }
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CachedMessage"/> class.
-        /// </summary>
-        /// <param name="message">The message.</param>
-        /// <param name="cachedAt">When the message was cached.</param>
-        /// <param name="sourceEvent">The source event of the message.</param>
-        /// <param name="originalMessage">The original message (prior to being edited).</param>
-        /// <param name="embeds">A collection of embeds.</param>
-        internal CachedMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage,
-            IReadOnlyCollection<IEmbed> embeds)
-            : this(message, cachedAt, sourceEvent, originalMessage)
-        {
-            _embeds = embeds;
-        }
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CachedMessage"/> class.
-        /// </summary>
-        /// <param name="message">The message.</param>
-        /// <param name="cachedAt">When the message was cached.</param>
-        /// <param name="sourceEvent">The source event of the message.</param>
-        /// <param name="originalMessage">The original message (prior to being edited).</param>
-        /// <param name="embeds">A collection of embeds.</param>
-        /// <param name="reactions">A collection of reactions.</param>
-        internal CachedMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage,
-            IReadOnlyCollection<IEmbed> embeds, List<SocketReaction> reactions)
-            : this(message, cachedAt, sourceEvent, originalMessage, embeds)
-        {
-            _reactions = reactions;
-        }
-
-        private protected readonly IMessage _message;
-        private protected readonly IReadOnlyCollection<IEmbed> _embeds;
-        internal List<SocketReaction> _reactions;
-
-        /// <inheritdoc/>
-        public IMessage OriginalMessage { get; private set; }
-
-        /// <inheritdoc/>
-        public DateTimeOffset CachedAt { get; private set; }
-
-        /// <inheritdoc/>
-        public MessageSourceEvent SourceEvent { get; private set; }
-
-        /// <inheritdoc/>
-        public ulong Id => _message.Id;
-
-        /// <inheritdoc/>
-        public DateTimeOffset CreatedAt => _message.CreatedAt;
-
-        /// <inheritdoc/>
-        public MessageType Type => _message.Type;
-
-        /// <inheritdoc/>
-        public MessageSource Source => _message.Source;
-
-        /// <inheritdoc/>
-        public bool IsTTS => _message.IsTTS;
-
-        /// <inheritdoc/>
-        public bool IsPinned => _message.IsPinned;
-
-        /// <inheritdoc/>
-        public bool IsSuppressed => _message.IsSuppressed;
-
-        /// <inheritdoc/>
-        public bool MentionedEveryone => _message.MentionedEveryone;
-
-        /// <inheritdoc/>
-        public string Content => _message.Content;
-
-        /// <inheritdoc/>
-        public DateTimeOffset Timestamp => _message.Timestamp;
-
-        /// <inheritdoc/>
-        public DateTimeOffset? EditedTimestamp => _message.EditedTimestamp;
-
-        /// <inheritdoc/>
-        public IMessageChannel Channel => _message.Channel;
-
-        /// <inheritdoc/>
-        public IUser Author => _message.Author;
-
-        /// <inheritdoc/>
-        public IReadOnlyCollection<IAttachment> Attachments => _message.Attachments;
-
-        /// <inheritdoc/>
-        public IReadOnlyCollection<IEmbed> Embeds => _embeds ?? _message.Embeds;
-
-        /// <inheritdoc/>
-        public IReadOnlyCollection<ITag> Tags => _message.Tags;
-
-        /// <inheritdoc/>
-        public IReadOnlyCollection<ulong> MentionedChannelIds => _message.MentionedChannelIds;
-
-        /// <inheritdoc/>
-        public IReadOnlyCollection<ulong> MentionedRoleIds => _message.MentionedRoleIds;
-
-        /// <inheritdoc/>
-        public IReadOnlyCollection<ulong> MentionedUserIds => _message.MentionedUserIds;
-
-        /// <inheritdoc/>
-        public MessageActivity Activity => _message.Activity;
-
-        /// <inheritdoc/>
-        public MessageApplication Application => _message.Application;
-
-        /// <inheritdoc/>
-        public MessageReference Reference => _message.Reference;
-
-        /// <inheritdoc/>
-        public IReadOnlyDictionary<IEmote, ReactionMetadata> Reactions =>
-            _reactions?.GroupBy(r => r.Emote).ToDictionary(x => x.Key, x => default(ReactionMetadata)) // It's not possible to create a ReactionMetadata instance, so we use the default value.
-            ?? _message.Reactions;
-
-        /// <inheritdoc/>
-        public MessageFlags? Flags => _message.Flags;
-
-        /// <inheritdoc/>
-        public IReadOnlyCollection<IMessageComponent> Components => _message.Components;
-
-        /// <inheritdoc/>
-        public IReadOnlyCollection<IStickerItem> Stickers => _message.Stickers;
-
-        public string CleanContent => _message.CleanContent;
-
-        public IMessageInteraction Interaction => _message.Interaction;
-
-        /// <inheritdoc/>
-        public Task DeleteAsync(RequestOptions options = null) => _message.DeleteAsync(options);
-
-        /// <inheritdoc/>
-        public Task AddReactionAsync(IEmote emote, RequestOptions options = null) => _message.AddReactionAsync(emote, options);
-
-        /// <inheritdoc/>
-        public Task RemoveReactionAsync(IEmote emote, IUser user, RequestOptions options = null) => _message.RemoveReactionAsync(emote, user, options);
-
-        /// <inheritdoc/>
-        public Task RemoveReactionAsync(IEmote emote, ulong userId, RequestOptions options = null) => _message.RemoveReactionAsync(emote, userId, options);
-
-        /// <inheritdoc/>
-        public Task RemoveAllReactionsAsync(RequestOptions options = null) => _message.RemoveAllReactionsAsync(options);
-
-        /// <inheritdoc/>
-        public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions options = null) => _message.RemoveAllReactionsForEmoteAsync(emote, options);
-
-        /// <inheritdoc/>
-        public IAsyncEnumerable<IReadOnlyCollection<IUser>> GetReactionUsersAsync(IEmote emoji, int limit, RequestOptions options = null)
-            => _message.GetReactionUsersAsync(emoji, limit, options);
-
-        void IEditedMessage.Update(IMessage originalMessage) => OriginalMessage = originalMessage;
-
-        void ICachedMessage.Update(DateTimeOffset cachedAt, MessageSourceEvent sourceEvent)
-        {
-            CachedAt = cachedAt;
-            SourceEvent = sourceEvent;
-        }
-
-        void ICachedMessage.AddReaction(SocketReaction reaction)
-        {
-            _reactions ??= new List<SocketReaction>();
-            _reactions.Add(reaction);
-        }
-
-        void ICachedMessage.RemoveReaction(SocketReaction reaction) => _reactions?.Remove(reaction);
-
-        void ICachedMessage.RemoveAllReactions() => _reactions?.Clear();
-
-        void ICachedMessage.RemoveAllReactionsForEmote(IEmote emote) => _reactions?.RemoveAll(x => x.Emote.Equals(emote));
-
-        protected string DebuggerDisplay => $"{Author}: {Content} ({Id}{(Attachments.Count > 0 ? $", {Attachments.Count} Attachments" : "")})";
-    }
-
-    /// <summary>
-    /// Represents the event where a message gets cached.
-    /// </summary>
-    public enum MessageSourceEvent
-    {
-        /// <summary>
-        /// The message has been received.
-        /// </summary>
-        MessageReceived,
-
-        /// <summary>
-        /// The message has been deleted.
-        /// </summary>
-        MessageDeleted,
-
-        /// <summary>
-        /// The message has been updated (edited).
-        /// </summary>
-        MessageUpdated
-    }
-
-    public static class MessageCacheExtensions
-    {
-        /// <summary>
-        /// Gets a message from this message channel.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <param name="cache">The message cache service.</param>
-        /// <param name="messageId">The snowflake identifier of the message.</param>
-        /// <param name="sourceEvent">The source event.</param>
-        /// <param name="mode"></param>
-        /// <param name="options"></param>
-        /// <returns>A task that represents an asynchronous get operation for retrieving the message. The task result contains
-        /// the retrieved message; <c>null</c> if no message is found with the specified identifier.</returns>
-        public static async Task<IMessage> GetMessageAsync(this IMessageChannel channel, MessageCacheService cache, ulong messageId,
-            MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null)
-        {
-            if (cache == null || cache.IsDisabled || !cache.TryGetCachedMessage(channel, messageId, out var message, sourceEvent))
-            {
-                return sourceEvent == MessageSourceEvent.MessageReceived && mode == CacheMode.AllowDownload
-                    ? await channel.GetMessageAsync(messageId, mode, options)
-                    : null;
-            }
-
-            return message;
-        }
-
-        /// <summary>
-        /// Gets a cached message from this message channel from the optimized cache.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <param name="cache">The message cache service.</param>
-        /// <param name="messageId">The snowflake identifier of the message.</param>
-        /// <param name="sourceEvent">The source event.</param>
-        /// <returns>A task that represents an asynchronous get operation for retrieving the message. The task result contains
-        /// the retrieved message; <c>null</c> if no message is found with the specified identifier.</returns>
-        public static ICachedMessage GetCachedMessage(this IMessageChannel channel, MessageCacheService cache, ulong messageId,
-            MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived)
-        {
-            cache.TryGetCachedMessage(channel, messageId, out var message, sourceEvent);
-            return message;
-        }
-
-        /// <summary>
-        /// Gets the last N messages from this message channel.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <param name="cache">The message cache service.</param>
-        /// <param name="limit">The numbers of message to be gotten from.</param>
-        /// <param name="options">The options to be used when sending the request.</param>
-        /// <returns>A paged collection of messages.</returns>
-        public static IAsyncEnumerable<IReadOnlyCollection<IMessage>> GetMessagesAsync(this IMessageChannel channel, MessageCacheService cache,
-            int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null)
-            => GetMessagesInternalAsync(channel, cache, null, Direction.Before, limit, CacheMode.AllowDownload, options);
-
-        /// <summary>
-        /// Gets the last N messages from this message channel.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <param name="cache">The message cache service.</param>
-        /// <param name="fromMessageId">The ID of the starting message to get the messages from.</param>
-        /// <param name="dir">The direction of the messages to be gotten from.</param>
-        /// <param name="limit">The numbers of message to be gotten from.</param>
-        /// <param name="options">The options to be used when sending the request.</param>
-        /// <returns>A paged collection of messages.</returns>
-        public static IAsyncEnumerable<IReadOnlyCollection<IMessage>> GetMessagesAsync(this IMessageChannel channel, MessageCacheService cache,
-            ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null)
-            => GetMessagesInternalAsync(channel, cache, fromMessageId, dir, limit, CacheMode.AllowDownload, options);
-
-        /// <summary>
-        /// Gets the last N messages from this message channel.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <param name="cache">The message cache service.</param>
-        /// <param name="fromMessage">The starting message to get the messages from.</param>
-        /// <param name="dir">The direction of the messages to be gotten from.</param>
-        /// <param name="limit">The numbers of message to be gotten from.</param>
-        /// <param name="options">The options to be used when sending the request.</param>
-        /// <returns>A paged collection of messages.</returns>
-        public static IAsyncEnumerable<IReadOnlyCollection<IMessage>> GetMessagesAsync(this IMessageChannel channel, MessageCacheService cache,
-            IMessage fromMessage, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null)
-            => GetMessagesInternalAsync(channel, cache, fromMessage.Id, dir, limit, CacheMode.AllowDownload, options);
-
-        /// <summary>
-        /// Gets a collection of cached messages from this message channel.
-        /// </summary>
-        /// <remarks>Unlike the other overloads, this method allows you to get all the cached messages from a channel, and also allows you to specify a source event.</remarks>
-        /// <param name="channel">The channel.</param>
-        /// <param name="cache">The message cache service.</param>
-        /// <param name="sourceEvent">The source event.</param>
-        /// <returns>A read-only collection of cached messages.</returns>
-        public static IReadOnlyCollection<ICachedMessage> GetCachedMessages(this IMessageChannel channel, MessageCacheService cache,
-            MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived)
-            => cache
-                .GetCacheForChannel(channel, sourceEvent)
-                .Values
-                .ToArray();
-
-        /// <summary>
-        /// Gets the last N cached messages from this message channel.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <param name="cache">The message cache service.</param>
-        /// <param name="limit">The number of messages to get.</param>
-        /// <returns>A read-only collection of cached messages.</returns>
-        public static IReadOnlyCollection<ICachedMessage> GetCachedMessages(this IMessageChannel channel, MessageCacheService cache,
-            int limit = DiscordConfig.MaxMessagesPerBatch)
-            => GetCachedMessagesInternal(channel, cache, null, Direction.Before, limit);
-
-        /// <summary>
-        /// Gets the last N cached messages from this message channel.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <param name="cache">The message cache service.</param>
-        /// <param name="fromMessageId">The message ID to start the fetching from.</param>
-        /// <param name="dir">The direction of which the message should be gotten from.</param>
-        /// <param name="limit">The number of messages to get.</param>
-        /// <returns>A read-only collection of cached messages.</returns>
-        public static IReadOnlyCollection<ICachedMessage> GetCachedMessages(this IMessageChannel channel, MessageCacheService cache, ulong fromMessageId,
-            Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch)
-            => GetCachedMessagesInternal(channel, cache, fromMessageId, dir, limit);
-
-        /// <summary>
-        /// Gets the last N cached messages from this message channel.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <param name="cache">The message cache service.</param>
-        /// <param name="fromMessage">The message to start the fetching from.</param>
-        /// <param name="dir">The direction of which the message should be gotten from.</param>
-        /// <param name="limit">The number of messages to get.</param>
-        /// <returns>A read-only collection of cached messages.</returns>
-        public static IReadOnlyCollection<ICachedMessage> GetCachedMessages(this IMessageChannel channel, MessageCacheService cache, IMessage fromMessage,
-            Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch)
-            => GetCachedMessagesInternal(channel, cache, fromMessage.Id, dir, limit);
-
-        private static IReadOnlyCollection<ICachedMessage> GetCachedMessagesInternal(IMessageChannel channel, MessageCacheService cache,
-            ulong? fromMessageId, Direction dir, int limit)
-            => cache == null || cache.IsDisabled ? Array.Empty<ICachedMessage>() : GetMany(cache, channel, fromMessageId, dir, limit);
-
-        private static IAsyncEnumerable<IReadOnlyCollection<IMessage>> GetMessagesInternalAsync(IMessageChannel channel, MessageCacheService cache,
-            ulong? fromMessageId, Direction dir, int limit, CacheMode mode, RequestOptions options)
-        {
-            if (dir == Direction.After && fromMessageId == null)
-                return AsyncEnumerable.Empty<IReadOnlyCollection<IMessage>>();
-
-            var cachedMessages = GetMany(cache, channel, fromMessageId, dir, limit);
-            var result = ImmutableArray.Create(cachedMessages).ToAsyncEnumerable<IReadOnlyCollection<IMessage>>();
-
-            switch (dir)
-            {
-                case Direction.Before:
-                {
-                    limit -= cachedMessages.Count;
-                    if (mode == CacheMode.CacheOnly || limit <= 0)
-                        return result;
-
-                    //Download remaining messages
-                    ulong? minId = cachedMessages.Count > 0 ? cachedMessages.Min(x => x.Id) : fromMessageId;
-                    var downloadedMessages = channel.GetMessagesAsync(minId ?? 0, dir, limit, CacheMode.AllowDownload, options);
-                    return cachedMessages.Count != 0 ? result.Concat(downloadedMessages) : downloadedMessages;
-                }
-                case Direction.After:
-                {
-                    limit -= cachedMessages.Count;
-                    if (mode == CacheMode.CacheOnly || limit <= 0)
-                        return result;
-
-                    //Download remaining messages
-                    ulong maxId = cachedMessages.Count > 0 ? cachedMessages.Max(x => x.Id) : fromMessageId ?? 0;
-                    var downloadedMessages = channel.GetMessagesAsync(maxId, dir, limit, CacheMode.AllowDownload, options);
-                    return cachedMessages.Count != 0 ? result.Concat(downloadedMessages) : downloadedMessages;
-                }
-                //Direction.Around
-                default:
-                {
-                    if (mode == CacheMode.CacheOnly || limit <= cachedMessages.Count)
-                        return result;
-
-                    //Cache isn't useful here since Discord will send them anyways
-                    return channel.GetMessagesAsync(fromMessageId ?? 0, dir, limit, CacheMode.AllowDownload, options);
-                }
-            }
-        }
-
-        private static IReadOnlyCollection<ICachedMessage> GetMany(MessageCacheService cache, IMessageChannel channel,
-            ulong? fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch)
-            => GetMany(cache, channel.Id, fromMessageId, dir, limit);
-
-        private static IReadOnlyCollection<ICachedMessage> GetMany(MessageCacheService cache, ulong channelId, ulong? fromMessageId,
-            Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch)
-        {
-
-            if (limit < 0) throw new ArgumentOutOfRangeException(nameof(limit));
-            if (cache == null || cache.IsDisabled || limit == 0) return Array.Empty<ICachedMessage>();
-
-            var cachedChannel = cache.GetCacheForChannel(channelId);
-            if (cachedChannel.Count == 0)
-                return Array.Empty<ICachedMessage>();
-
-            var orderedChannel = cache.GetMessageQueue(channelId);
-
-            IEnumerable<ulong> cachedMessageIds;
-            if (fromMessageId == null)
-                cachedMessageIds = orderedChannel;
-            else switch (dir)
-            {
-                case Direction.Before:
-                    cachedMessageIds = orderedChannel.Where(x => x < fromMessageId.Value);
-                    break;
-                case Direction.After:
-                    cachedMessageIds = orderedChannel.Where(x => x > fromMessageId.Value);
-                    break;
-                //Direction.Around
-                default:
-                {
-                    if (!cachedChannel.TryGetValue(fromMessageId.Value, out var msg))
-                        return Array.Empty<ICachedMessage>();
-
-                    int around = limit / 2;
-                    var before = GetMany(cache, channelId, fromMessageId, Direction.Before, around);
-                    var after = GetMany(cache, channelId, fromMessageId, Direction.After, around).Reverse();
-
-                    return after
-                        .Append(msg)
-                        .Concat(before)
-                        .ToArray();
-                }
-            }
-
-            if (dir == Direction.Before)
-                cachedMessageIds = cachedMessageIds.Reverse();
-            if (dir == Direction.Around) // Only happens if fromMessageId is null, should only get "around" and itself (+1)
-                limit = limit / 2 + 1;
-
-            return cachedMessageIds
-                .Select(x => cachedChannel.TryGetValue(x, out var msg) ? msg : null)
-                .Where(x => x != null)
-                .Take(limit)
-                .ToArray();
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Services/MusicService.cs b/src/Services/MusicService.cs
deleted file mode 100644
index 9329d01..0000000
--- a/src/Services/MusicService.cs
+++ /dev/null
@@ -1,586 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Threading.Tasks;
-using Discord;
-using Discord.Net;
-using Discord.WebSocket;
-using Fergun.Extensions;
-using Fergun.Utils;
-using Victoria;
-using Victoria.Enums;
-using Victoria.EventArgs;
-using Victoria.Responses.Search;
-
-namespace Fergun.Services
-{
-    public class MusicService
-    {
-        private readonly LavaNode _lavaNode;
-        private readonly DiscordShardedClient _client;
-        private readonly LogService _logService;
-        private static readonly ConcurrentDictionary<ulong, uint> _loopDict = new ConcurrentDictionary<ulong, uint>();
-        private static readonly string[] _timeFormats = { @"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss" };
-
-        public MusicService(DiscordShardedClient client, LogService logService, LavaNode lavaNode)
-        {
-            _client = client;
-            _logService = logService;
-            _lavaNode = lavaNode;
-            _client.ShardReady += ShardReadyAsync;
-            _client.UserVoiceStateUpdated += UserVoiceStateUpdatedAsync;
-            _lavaNode.OnLog += LogAsync;
-            _lavaNode.OnTrackEnded += OnTrackEndedAsync;
-            _lavaNode.OnTrackStuck += OnTrackStuckAsync;
-            _lavaNode.OnTrackException += OnTrackExceptionAsync;
-            _lavaNode.OnWebSocketClosed += OnWebSocketClosedAsync;
-        }
-
-        private Task ShardReadyAsync(DiscordSocketClient client)
-        {
-            if (!_lavaNode.IsConnected)
-            {
-                _ = _lavaNode.ConnectAsync();
-            }
-
-            return Task.CompletedTask;
-        }
-
-        private async Task UserVoiceStateUpdatedAsync(SocketUser user, SocketVoiceState beforeState, SocketVoiceState afterState)
-        {
-            // Someone left a voice channel
-            if (user is SocketGuildUser guildUser
-                && _lavaNode.TryGetPlayer(guildUser.Guild, out var player)
-                && player.VoiceChannel != null
-                && afterState.VoiceChannel == null)
-            {
-                // Fergun left a voice channel that has a player
-                if (user.Id == _client.CurrentUser.Id)
-                {
-                    if (_loopDict.ContainsKey(player.VoiceChannel.GuildId))
-                    {
-                        _loopDict.TryRemove(player.VoiceChannel.GuildId, out _);
-                    }
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Victoria", $"Left the voice channel \"{player.VoiceChannel.Name}\" in guild \"{player.VoiceChannel.Guild.Name}\" because I got kicked out."));
-
-                    await _lavaNode.LeaveAsync(player.VoiceChannel);
-                }
-                // There are no users (or only bots) in the voice channel
-                else if (((SocketVoiceChannel)player.VoiceChannel).Users.All(x => x.IsBot))
-                {
-                    if (_loopDict.ContainsKey(player.VoiceChannel.GuildId))
-                    {
-                        _loopDict.TryRemove(player.VoiceChannel.GuildId, out _);
-                    }
-
-                    var builder = new EmbedBuilder()
-                        .WithDescription("\u26A0 " + GuildUtils.Locate("LeftVCInactivity", player.TextChannel))
-                        .WithColor(FergunClient.Config.EmbedColor);
-
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Victoria", $"Left the voice channel \"{player.VoiceChannel.Name}\" in guild \"{player.VoiceChannel.Guild.Name}\" because there are no users."));
-                    await player.TextChannel.SendMessageAsync(embed: builder.Build());
-
-                    await _lavaNode.LeaveAsync(player.VoiceChannel);
-                }
-            }
-        }
-
-        private async Task OnWebSocketClosedAsync(WebSocketClosedEventArgs args)
-        {
-            if (_loopDict.ContainsKey(args.GuildId))
-            {
-                _loopDict.TryRemove(args.GuildId, out _);
-            }
-            await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Victoria", $"Websocket closed the connection on guild ID {args.GuildId} with code {args.Code} and reason: {args.Reason}"));
-        }
-
-        private async Task LogAsync(LogMessage logMessage)
-        {
-            await _logService.LogAsync(logMessage);
-        }
-
-        private async Task OnTrackEndedAsync(TrackEndedEventArgs args)
-        {
-            if (!(args.Reason == TrackEndReason.Finished || args.Reason == TrackEndReason.LoadFailed))
-                return;
-
-            var builder = new EmbedBuilder();
-            ulong guildId = args.Player.TextChannel.GuildId;
-            if (_loopDict.ContainsKey(guildId))
-            {
-                if (_loopDict[guildId] == 0)
-                {
-                    _loopDict.TryRemove(guildId, out _);
-                    var builder2 = new EmbedBuilder()
-                        .WithDescription(string.Format(GuildUtils.Locate("LoopEnded", args.Player.TextChannel), args.Track.ToTrackLink(false)))
-                        .WithColor(FergunClient.Config.EmbedColor);
-                    await args.Player.TextChannel.SendMessageAsync(null, false, builder2.Build());
-                    await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Victoria", $"Loop for track {args.Track.Title} ({args.Track.Url}) ended in {args.Player.TextChannel.Guild.Name}/{args.Player.TextChannel.Name}"));
-                }
-                else
-                {
-                    await args.Player.PlayAsync(args.Track);
-                    _loopDict[guildId]--;
-                    return;
-                }
-            }
-            if (!args.Player.Queue.TryDequeue(out var nextTrack))
-            {
-                builder.WithDescription(GuildUtils.Locate("NoTracks", args.Player.TextChannel))
-                    .WithColor(FergunClient.Config.EmbedColor);
-
-                await args.Player.TextChannel.SendMessageAsync(embed: builder.Build());
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Victoria", $"Queue now empty in {args.Player.TextChannel.Guild.Name}/{args.Player.TextChannel.Name}"));
-                return;
-            }
-
-            await args.Player.PlayAsync(nextTrack);
-            builder.WithTitle(GuildUtils.Locate("NowPlaying", args.Player.TextChannel))
-                .WithDescription(nextTrack.ToTrackLink())
-                .WithColor(FergunClient.Config.EmbedColor);
-            await args.Player.TextChannel.SendMessageAsync(embed: builder.Build());
-            await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Victoria", $"Now playing: {nextTrack.Title} ({nextTrack.Url}) in {args.Player.TextChannel.Guild.Name}/{args.Player.TextChannel.Name}"));
-        }
-
-        private async Task OnTrackExceptionAsync(TrackExceptionEventArgs args)
-        {
-            var builder = new EmbedBuilder()
-                .WithDescription($"\u26a0 {GuildUtils.Locate("PlayerError", args.Player.TextChannel)}:```{args.Exception.Message}```")
-                .WithColor(FergunClient.Config.EmbedColor);
-            await args.Player.TextChannel.SendMessageAsync(embed: builder.Build());
-            // The current track is auto-skipped
-        }
-
-        private async Task OnTrackStuckAsync(TrackStuckEventArgs args)
-        {
-            var builder = new EmbedBuilder()
-                .WithDescription($"\u26a0 {string.Format(GuildUtils.Locate("PlayerStuck", args.Player.TextChannel), args.Player.Track.Title, args.Threshold.TotalSeconds)}")
-                .WithColor(FergunClient.Config.EmbedColor);
-            await args.Player.TextChannel.SendMessageAsync(embed: builder.Build());
-            // The current track is auto-skipped
-        }
-
-        public async Task<string> JoinAsync(IGuild guild, SocketVoiceChannel voiceChannel, ITextChannel textChannel)
-        {
-            if (_lavaNode.HasPlayer(guild))
-                return GuildUtils.Locate("AlreadyConnected", textChannel);
-            await _lavaNode.JoinAsync(voiceChannel, textChannel);
-            return string.Format(GuildUtils.Locate("NowConnected", textChannel), Format.Bold(voiceChannel.Name));
-        }
-
-        public async Task<bool> LeaveAsync(IGuild guild, SocketVoiceChannel voiceChannel)
-        {
-            bool hasPlayer = _lavaNode.HasPlayer(guild);
-            if (!hasPlayer) return false;
-
-            if (_loopDict.ContainsKey(guild.Id))
-            {
-                _loopDict.TryRemove(guild.Id, out _);
-            }
-            await _lavaNode.LeaveAsync(voiceChannel);
-
-            return true;
-        }
-
-        public async Task<string> MoveAsync(IGuild guild, SocketVoiceChannel voiceChannel, ITextChannel textChannel)
-        {
-            bool hasPlayer = _lavaNode.TryGetPlayer(guild, out var player);
-            if (!hasPlayer)
-                return GuildUtils.Locate("PlayerNotPlaying", textChannel);
-            var oldChannel = player.VoiceChannel;
-            if (voiceChannel.Id == oldChannel.Id)
-                return GuildUtils.Locate("MoveSameChannel", textChannel);
-            await _lavaNode.MoveChannelAsync(voiceChannel);
-            return string.Format(GuildUtils.Locate("PlayerMoved", textChannel), oldChannel, voiceChannel);
-        }
-
-        public async Task<(string, IReadOnlyList<LavaTrack>)> PlayAsync(string query, IGuild guild, SocketVoiceChannel voiceChannel, ITextChannel textChannel)
-        {
-            if (voiceChannel == null)
-                return (GuildUtils.Locate("PlayerError", textChannel), null);
-
-            var search = await _lavaNode.SearchAsync(query);
-
-            if (search.Status == SearchStatus.NoMatches || search.Status == SearchStatus.LoadFailed)
-            {
-                return (GuildUtils.Locate("PlayerNoMatches", textChannel), null);
-            }
-
-            LavaPlayer player;
-
-            if (search.Playlist.Name != null)
-            {
-                if (!_lavaNode.TryGetPlayer(guild, out player))
-                {
-                    await _lavaNode.JoinAsync(voiceChannel, textChannel);
-                    player = _lavaNode.GetPlayer(guild);
-                }
-
-                var time = TimeSpan.Zero;
-                if (player.PlayerState == PlayerState.Playing)
-                {
-                    int trackCount = Math.Min(10, search.Tracks.Count);
-                    foreach (var track in search.Tracks.Take(10))
-                    {
-                        player.Queue.Enqueue(track);
-                        time += track.Duration;
-                    }
-                    return (string.Format(GuildUtils.Locate("PlayerPlaylistAdded", textChannel), search.Playlist.Name, trackCount, time.ToShortForm()), null);
-                }
-                else
-                {
-                    int trackCount = Math.Min(9, search.Tracks.Count);
-                    foreach (var track in search.Tracks.Take(10).Skip(1))
-                    {
-                        player.Queue.Enqueue(track);
-                        time += track.Duration;
-                    }
-                    // if player wasn't playing anything
-                    await player.PlayAsync(search.Tracks.First());
-                    return (string.Format(GuildUtils.Locate("PlayerEmptyPlaylistAdded", textChannel), trackCount, time.ToShortForm(), search.Tracks.First().ToTrackLink()), null);
-                }
-            }
-
-            LavaTrack firstTrack;
-            switch (search.Tracks.Count)
-            {
-                case 0:
-                    return (GuildUtils.Locate("PlayerNoMatches", textChannel), null);
-
-                case 1:
-                    firstTrack = search.Tracks.First();
-                    break;
-
-                default:
-                    return (null, search.Tracks.ToArray());
-            }
-
-            if (!_lavaNode.TryGetPlayer(guild, out player))
-            {
-                await _lavaNode.JoinAsync(voiceChannel, textChannel);
-                player = _lavaNode.GetPlayer(guild);
-            }
-            if (player.PlayerState == PlayerState.Playing)
-            {
-                player.Queue.Enqueue(firstTrack);
-                return (string.Format(GuildUtils.Locate("PlayerTrackAdded", textChannel), firstTrack.ToTrackLink()), null);
-            }
-
-            await player.PlayAsync(firstTrack);
-            return (string.Format(GuildUtils.Locate("PlayerNowPlaying", textChannel), firstTrack.ToTrackLink()), null);
-        }
-
-        public async Task<string> PlayTrack(IGuild guild, SocketVoiceChannel voiceChannel, ITextChannel textChannel, LavaTrack track)
-        {
-            if (voiceChannel == null)
-                return GuildUtils.Locate("PlayerError", textChannel);
-
-            if (!_lavaNode.TryGetPlayer(guild, out var player) || player == null)
-            {
-                await _lavaNode.JoinAsync(voiceChannel, textChannel);
-                player = _lavaNode.GetPlayer(guild);
-            }
-            if (track == null)
-                return GuildUtils.Locate("InvalidTrack", textChannel);
-            if (player.PlayerState == PlayerState.Playing)
-            {
-                player.Queue.Enqueue(track);
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Victoria", $"Added {track.Title} ({track.Url}) to the queue in {textChannel.Guild.Name}/{textChannel.Name}"));
-                return string.Format(GuildUtils.Locate("PlayerTrackAdded", textChannel), track.ToTrackLink());
-            }
-
-            await player.PlayAsync(track);
-            await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Victoria", $"Now playing: {track.Title} ({track.Url}) in {textChannel.Guild.Name}/{textChannel.Name}"));
-            return string.Format(GuildUtils.Locate("PlayerNowPlaying", textChannel), track.ToTrackLink());
-        }
-
-        public async Task<string> ReplayAsync(IGuild guild, ITextChannel textChannel)
-        {
-            bool hasPlayer = _lavaNode.TryGetPlayer(guild, out var player);
-            if (player == null)
-                return GuildUtils.Locate("EmptyQueue", textChannel);
-            if (!hasPlayer || player.PlayerState != PlayerState.Playing)
-                return GuildUtils.Locate("PlayerNotPlaying", textChannel);
-            await player.SeekAsync(TimeSpan.Zero);
-            return string.Format(GuildUtils.Locate("Replaying", textChannel), player.Track.ToTrackLink());
-        }
-
-        public async Task<string> SeekAsync(IGuild guild, ITextChannel textChannel, string time)
-        {
-            bool hasPlayer = _lavaNode.TryGetPlayer(guild, out var player);
-            if (!hasPlayer)
-                return GuildUtils.Locate("PlayerNotPlaying", textChannel);
-            if (player?.Track?.Duration == null || !player.Track.CanSeek)
-                return GuildUtils.Locate("CannotSeek", textChannel);
-
-            if (uint.TryParse(time, out uint second))
-            {
-                if (second >= player.Track.Duration.TotalSeconds)
-                {
-                    return string.Format(GuildUtils.Locate("SeekHigherOrEqual", textChannel), second, player.Track.Duration.TotalSeconds);
-                }
-                await player.SeekAsync(TimeSpan.FromSeconds(second));
-
-                return string.Format(GuildUtils.Locate("SeekComplete", textChannel), second, TimeSpan.FromSeconds(second).ToShortForm(), player.Track.Duration.ToShortForm());
-            }
-
-            if (!TimeSpan.TryParseExact(time, _timeFormats, CultureInfo.InvariantCulture, out var span))
-            {
-                return GuildUtils.Locate("SeekInvalidFormat", textChannel);
-            }
-            if (span < TimeSpan.Zero)
-            {
-                span = TimeSpan.Zero;
-            }
-            if (span >= player.Track.Duration)
-            {
-                return string.Format(GuildUtils.Locate("SeekTimeHigherOrEqual", textChannel), span.ToShortForm(), player.Track.Duration.ToShortForm());
-            }
-            await player.SeekAsync(span);
-
-            return string.Format(GuildUtils.Locate("SeekTimeComplete", textChannel), span.ToShortForm(), player.Track.Duration.ToShortForm());
-        }
-
-        public async Task<string> StopAsync(IGuild guild, ITextChannel textChannel)
-        {
-            bool hasPlayer = _lavaNode.TryGetPlayer(guild, out var player);
-            if (!hasPlayer)
-                return GuildUtils.Locate("PlayerNotPlaying", textChannel);
-            if (player == null)
-                return GuildUtils.Locate("PlayerError", textChannel);
-            await player.StopAsync();
-            if (_loopDict.ContainsKey(guild.Id))
-            {
-                _loopDict.TryRemove(guild.Id, out _);
-            }
-            return GuildUtils.Locate("PlayerStopped", textChannel);
-        }
-
-        public async Task<string> SkipAsync(IGuild guild, ITextChannel textChannel)
-        {
-            bool hasPlayer = _lavaNode.TryGetPlayer(guild, out var player);
-            if (!hasPlayer)
-                return GuildUtils.Locate("PlayerNotPlaying", textChannel);
-            if (player == null)
-                return GuildUtils.Locate("PlayerError", textChannel);
-            if (player.PlayerState == PlayerState.Stopped)
-                return GuildUtils.Locate("PlayerNotPlaying", textChannel);
-            if (player.Queue.Count == 0)
-                return GuildUtils.Locate("EmptyQueue", textChannel);
-
-            var oldTrack = player.Track;
-            await player.SkipAsync();
-            return string.Format(GuildUtils.Locate("PlayerTrackSkipped", textChannel), oldTrack.ToTrackLink(false), player.Track.ToTrackLink());
-        }
-
-        public async Task<string> SetVolumeAsync(int volume, IGuild guild, ITextChannel textChannel)
-        {
-            bool hasPlayer = _lavaNode.TryGetPlayer(guild, out var player);
-            if (!hasPlayer || player.PlayerState != PlayerState.Playing)
-                return GuildUtils.Locate("PlayerNotPlaying", textChannel);
-
-            volume = Math.Min(volume, 150);
-            if (volume <= 2)
-            {
-                return GuildUtils.Locate("VolumeOutOfIndex", textChannel);
-            }
-
-            await player.UpdateVolumeAsync((ushort)volume);
-            return string.Format(GuildUtils.Locate("VolumeSet", textChannel), volume);
-        }
-
-        public async Task<string> PauseOrResumeAsync(IGuild guild, ITextChannel textChannel)
-        {
-            bool hasPlayer = _lavaNode.TryGetPlayer(guild, out var player);
-            if (!hasPlayer)
-                return GuildUtils.Locate("PlayerNotPlaying", textChannel);
-
-            switch (player.PlayerState)
-            {
-                case PlayerState.Playing:
-                    await player.PauseAsync();
-                    return GuildUtils.Locate("PlayerPaused", textChannel);
-                case PlayerState.Stopped:
-                    return GuildUtils.Locate("PlayerNotPlaying", textChannel);
-                default:
-                    await player.ResumeAsync();
-                    return GuildUtils.Locate("PlaybackResumed", textChannel);
-            }
-        }
-
-        public async Task<string> ResumeAsync(IGuild guild, ITextChannel textChannel)
-        {
-            bool hasPlayer = _lavaNode.TryGetPlayer(guild, out var player);
-            if (!hasPlayer)
-                return GuildUtils.Locate("PlayerNotPlaying", textChannel);
-
-            if (player.PlayerState != PlayerState.Paused)
-                return GuildUtils.Locate("PlayerNotPaused", textChannel);
-
-            await player.ResumeAsync();
-            return GuildUtils.Locate("PlaybackResumed", textChannel);
-        }
-
-        public string GetCurrentTrack(IGuild guild, ITextChannel textChannel)
-        {
-            bool hasPlayer = _lavaNode.TryGetPlayer(guild, out var player);
-            if (!hasPlayer || player.PlayerState != PlayerState.Playing)
-                return GuildUtils.Locate("PlayerNotPlaying", textChannel);
-
-            return string.Format(GuildUtils.Locate("CurrentlyPlaying", textChannel), player.Track.ToTrackLink(false), player.Track.Position.ToShortForm(), player.Track.Duration.ToShortForm());
-        }
-
-        public string GetQueue(IGuild guild, ITextChannel textChannel)
-        {
-            bool hasPlayer = _lavaNode.TryGetPlayer(guild, out var player);
-            if (!hasPlayer || player.PlayerState != PlayerState.Playing)
-                return GuildUtils.Locate("PlayerNotPlaying", textChannel);
-
-            string queue = string.Format(GuildUtils.Locate("CurrentlyPlaying", textChannel), player.Track.ToTrackLink(false), player.Track.Position.ToShortForm(), player.Track.Duration.ToShortForm()) + "\n\n";
-            if (player.Queue.Count == 0)
-            {
-                return queue + GuildUtils.Locate("EmptyQueue", textChannel);
-            }
-
-            queue += $"{GuildUtils.Locate("MusicInQueue", textChannel)}\n";
-            //return "Music in queue:\n" + string.Join("\n", player.Queue.Select(x => (x as LavaTrack).Title));
-            int tracksToShow = Math.Min(10, player.Queue.Count);
-            int excess = player.Queue.Count - 10;
-
-            for (int i = 0; i < tracksToShow; i++)
-            {
-                var current = player.Queue.ElementAt(i);
-                queue += $"{i + 1}. {current.ToTrackLink()}\n";
-            }
-            if (excess > 0)
-            {
-                queue += "\n" + string.Format(GuildUtils.Locate("QueueExcess", textChannel), excess);
-            }
-            return queue;
-        }
-
-        public string Shuffle(IGuild guild, ITextChannel textChannel)
-        {
-            bool hasPlayer = _lavaNode.TryGetPlayer(guild, out var player);
-            if (!hasPlayer || player.PlayerState != PlayerState.Playing)
-                return GuildUtils.Locate("PlayerNotPlaying", textChannel);
-
-            switch (player.Queue.Count)
-            {
-                case 0:
-                    return GuildUtils.Locate("EmptyQueue", textChannel);
-
-                case 1:
-                    return GuildUtils.Locate("Queue1Item", textChannel);
-
-                default:
-                    player.Queue.Shuffle();
-                    return GuildUtils.Locate("QueueShuffled", textChannel);
-            }
-        }
-
-        public string RemoveAt(IGuild guild, ITextChannel textChannel, int index)
-        {
-            bool hasPlayer = _lavaNode.TryGetPlayer(guild, out var player);
-            if (!hasPlayer || player.PlayerState != PlayerState.Playing)
-                return GuildUtils.Locate("PlayerNotPlaying", textChannel);
-            if (player.Queue.Count == 0)
-            {
-                return GuildUtils.Locate("EmptyQueue", textChannel);
-            }
-            if (index < 1 || index > player.Queue.Count)
-            {
-                return GuildUtils.Locate("IndexOutOfRange", textChannel);
-            }
-            var track = player.Queue.ElementAt(index - 1);
-
-            player.Queue.RemoveAt(index - 1);
-            return string.Format(GuildUtils.Locate("TrackRemoved", textChannel), track.ToTrackLink(false), index);
-        }
-
-        public async Task<(bool, string)> GetArtworkAsync(IGuild guild, ITextChannel textChannel)
-        {
-            bool hasPlayer = _lavaNode.TryGetPlayer(guild, out var player);
-            if (!hasPlayer || player.PlayerState != PlayerState.Playing)
-                return (false, GuildUtils.Locate("PlayerNotPlaying", textChannel));
-
-            var artworkLink = await player.Track.FetchArtworkAsync();
-
-            return string.IsNullOrEmpty(artworkLink)
-                ? (false, GuildUtils.Locate("AnErrorOccurred", textChannel))
-                : (true, artworkLink);
-        }
-
-        public string Loop(uint? count, IGuild guild, ITextChannel textChannel)
-        {
-            bool hasPlayer = _lavaNode.TryGetPlayer(guild, out var player);
-            if (!hasPlayer || player.PlayerState != PlayerState.Playing)
-                return GuildUtils.Locate("PlayerNotPlaying", textChannel);
-
-            if (!count.HasValue)
-            {
-                if (!_loopDict.ContainsKey(guild.Id))
-                    return string.Format(GuildUtils.Locate("LoopNoValuePassed", textChannel), GuildUtils.GetPrefix(textChannel));
-
-                _loopDict.TryRemove(guild.Id, out _);
-                return GuildUtils.Locate("LoopDisabled", textChannel);
-            }
-
-            uint countValue = count.Value;
-            if (countValue < 1)
-            {
-                return string.Format(GuildUtils.Locate("NumberOutOfIndex", textChannel), 1, Constants.MaxTrackLoops);
-            }
-            countValue = Math.Min(Constants.MaxTrackLoops, countValue);
-
-            if (_loopDict.ContainsKey(guild.Id))
-            {
-                _loopDict[guild.Id] = countValue;
-                return string.Format(GuildUtils.Locate("LoopUpdated", textChannel), countValue);
-            }
-            _loopDict.TryAdd(guild.Id, countValue);
-            return string.Format(GuildUtils.Locate("NowLooping", textChannel), countValue);
-        }
-
-        public async Task<int> ShutdownAllPlayersAsync(bool simulate)
-        {
-            var players = _lavaNode.Players.Where(x => x != null).ToArray();
-
-            if (!simulate && players.Length > 0)
-            {
-                await _logService.LogAsync(new LogMessage(LogSeverity.Info, "Logout", $"Shutting down {players.Length} music players..."));
-
-                foreach (var player in players)
-                {
-                    var embed = new EmbedBuilder()
-                        .WithTitle($"\u26a0 {GuildUtils.Locate("Warning", player.TextChannel)} \u26a0")
-                        .WithDescription(GuildUtils.Locate("MusicPlayerShutdownWarning", player.TextChannel))
-                        .WithColor(FergunClient.Config.EmbedColor);
-
-                    try
-                    {
-                        await player.TextChannel.SendMessageAsync(embed: embed.Build());
-                    }
-                    catch (HttpException)
-                    {
-                    }
-                }
-
-                await Task.Delay(5000);
-
-                foreach (var player in players)
-                {
-                    try
-                    {
-                        await _lavaNode.LeaveAsync(player.VoiceChannel);
-                    }
-                    catch (NullReferenceException) { }
-                }
-            }
-
-            return players.Length;
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Services/ReliabilityService.cs b/src/Services/ReliabilityService.cs
deleted file mode 100644
index c237c19..0000000
--- a/src/Services/ReliabilityService.cs
+++ /dev/null
@@ -1,174 +0,0 @@
-using System;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Discord;
-using Discord.WebSocket;
-
-namespace Fergun.Services
-{
-    // This service requires that your bot is being run by a daemon that handles
-    // Exit Code 1 (or any exit code) as a restart.
-    //
-    // If you do not have your bot setup to run in a daemon, this service will just
-    // terminate the process and the bot will not restart.
-    //
-    // Links to daemons:
-    // [Powershell (Windows+Unix)] https://gitlab.com/snippets/21444
-    // [Bash (Unix)] https://stackoverflow.com/a/697064
-    public class ReliabilityService : IDisposable
-    {
-        private readonly IDiscordClient _client;
-        private readonly Func<LogMessage, Task> _logger;
-        private CancellationTokenSource _cts;
-        private bool _isReconnecting;
-        private bool _disposed;
-
-        // How long should we wait on the client to reconnect before resetting?
-        private readonly TimeSpan _timeout;
-
-        // Should we attempt to reset the client? Set this to false if your client is still locking up.
-        private readonly bool _attemptReset;
-        private const string _logSource = "Reliability";
-
-        public ReliabilityService(DiscordSocketClient client, Func<LogMessage, Task> logger = null, TimeSpan? timeout = null, bool attemptReset = true)
-        {
-            _cts = new CancellationTokenSource();
-            _client = client;
-            _logger = logger ?? (_ => Task.CompletedTask);
-            _timeout = timeout ?? TimeSpan.FromSeconds(30);
-            _attemptReset = attemptReset;
-
-            client.Connected += ConnectedAsync;
-            client.Disconnected += DisconnectedAsync;
-        }
-
-        public ReliabilityService(DiscordShardedClient client, Func<LogMessage, Task> logger = null, TimeSpan? timeout = null, bool attemptReset = true)
-        {
-            _cts = new CancellationTokenSource();
-            _client = client;
-            _logger = logger ?? (_ => Task.CompletedTask);
-            _timeout = timeout ?? TimeSpan.FromSeconds(30);
-            _attemptReset = attemptReset;
-
-            client.ShardConnected += ShardConnectedAsync;
-            client.ShardDisconnected += ShardDisconnectedAsync;
-        }
-
-        /// <summary>
-        /// Disposes the cancellation token
-        /// and unsubscribes from the <see cref="DiscordSocketClient.Connected"/> and <see cref="DiscordSocketClient.Disconnected"/> events.
-        /// </summary>
-        /// <remarks>
-        /// If a sharded client is used, unsubscribes from the <see cref="DiscordShardedClient.ShardConnected"/> and <see cref="DiscordShardedClient.ShardDisconnected"/> events.
-        /// </remarks>
-        public void Dispose()
-        {
-            Dispose(true);
-            GC.SuppressFinalize(this);
-        }
-
-        protected virtual void Dispose(bool disposing)
-        {
-            if (_disposed)
-            {
-                throw new ObjectDisposedException(nameof(ReliabilityService), "Service has been disposed.");
-            }
-
-            if (!disposing) return;
-            _cts.Dispose();
-            _cts = null;
-
-            switch (_client)
-            {
-                case DiscordSocketClient socketClient:
-                    socketClient.Connected -= ConnectedAsync;
-                    socketClient.Disconnected -= DisconnectedAsync;
-                    break;
-
-                case DiscordShardedClient shardedClient:
-                    shardedClient.ShardConnected -= ShardConnectedAsync;
-                    shardedClient.ShardDisconnected -= ShardDisconnectedAsync;
-                    break;
-            }
-
-            _disposed = true;
-        }
-
-        private Task ConnectedAsync()
-        {
-            if (_isReconnecting) return Task.CompletedTask;
-            // Cancel all previous state checks and reset the CancelToken - client is back online
-            _ = _logger(new LogMessage(LogSeverity.Debug, _logSource, "Client reconnected, resetting cancel tokens..."));
-            _cts.Cancel();
-            _cts = new CancellationTokenSource();
-            _ = _logger(new LogMessage(LogSeverity.Debug, _logSource, "Client reconnected, cancel tokens reset."));
-
-            return Task.CompletedTask;
-        }
-
-        private Task ShardConnectedAsync(DiscordSocketClient client) => ConnectedAsync();
-
-        public Task DisconnectedAsync(Exception exception)
-        {
-            if (exception is GatewayReconnectException)
-            {
-                _isReconnecting = true;
-            }
-            else
-            {
-                _isReconnecting = false;
-                // Check the state after <timeout> to see if we reconnected
-                _ = Task.Run(async () =>
-                {
-                    await _logger(new LogMessage(LogSeverity.Info, _logSource, "Client disconnected, starting timeout task..."));
-                    await Task.Delay(_timeout, _cts.Token);
-                    await _logger(new LogMessage(LogSeverity.Debug, _logSource, "Timeout expired, continuing to check client state..."));
-                    await CheckStateAsync();
-                    await _logger(new LogMessage(LogSeverity.Debug, _logSource, "State came back."));
-                });
-            }
-            return Task.CompletedTask;
-        }
-
-        private Task ShardDisconnectedAsync(Exception exception, DiscordSocketClient client)
-        {
-            return ((DiscordShardedClient)_client).Shards.All(x => x.ConnectionState == ConnectionState.Disconnected)
-                ? DisconnectedAsync(exception)
-                : Task.CompletedTask;
-        }
-
-        private async Task CheckStateAsync()
-        {
-            // Client reconnected, no need to reset
-            if (_client.ConnectionState == ConnectionState.Connected) return;
-            if (_attemptReset)
-            {
-                await _logger(new LogMessage(LogSeverity.Info, _logSource, "Attempting to reset the client..."));
-
-                var timeout = Task.Delay(_timeout);
-                var connect = _client.StartAsync();
-                var task = await Task.WhenAny(timeout, connect);
-
-                if (task == timeout)
-                {
-                    await _logger(new LogMessage(LogSeverity.Critical, _logSource, "Client reset timed out (task deadlocked?), killing process."));
-                    FailFast();
-                }
-                else if (connect.IsFaulted)
-                {
-                    await _logger(new LogMessage(LogSeverity.Critical, _logSource, "Client reset faulted, killing process.", connect.Exception));
-                    FailFast();
-                }
-                else if (connect.IsCompletedSuccessfully)
-                    await _logger(new LogMessage(LogSeverity.Info, _logSource, "Client reset successfully!"));
-                return;
-            }
-
-            await _logger(new LogMessage(LogSeverity.Critical, _logSource, "Client did not reconnect in time, killing process."));
-            FailFast();
-        }
-
-        private static void FailFast() => Environment.Exit(1);
-    }
-}
\ No newline at end of file
diff --git a/src/Utils/CommandUtils.cs b/src/Utils/CommandUtils.cs
index afa4499..462f943 100644
--- a/src/Utils/CommandUtils.cs
+++ b/src/Utils/CommandUtils.cs
@@ -1,195 +1,49 @@
-using System;
-using System.Collections.Generic;
 using System.Diagnostics;
-using System.Linq;
-using System.Net;
 using System.Runtime.InteropServices;
-using System.Text.RegularExpressions;
-using System.Threading.Tasks;
-using AngleSharp;
-using AngleSharp.Dom;
-using Discord;
-using Fergun.Interactive.Pagination;
 
-namespace Fergun.Utils
+namespace Fergun.Utils;
+
+public static class CommandUtils
 {
-    public static class CommandUtils
+    public static async Task<double> GetCpuUsageForProcessAsync()
     {
-        private static Dictionary<IEmote, PaginatorAction> _fergunPaginatorEmotes;
-
-        public static async Task<double> GetCpuUsageForProcessAsync()
-        {
-            var startTime = DateTimeOffset.UtcNow;
-            var startCpuUsage = Process.GetCurrentProcess().TotalProcessorTime;
-            await Task.Delay(500);
-
-            var endTime = DateTimeOffset.UtcNow;
-            var endCpuUsage = Process.GetCurrentProcess().TotalProcessorTime;
-            var cpuUsedMs = (endCpuUsage - startCpuUsage).TotalMilliseconds;
-            var totalMsPassed = (endTime - startTime).TotalMilliseconds;
-            var cpuUsageTotal = cpuUsedMs / (Environment.ProcessorCount * totalMsPassed);
-            return cpuUsageTotal * 100;
-        }
-
-        public static async Task<string> ParseGeniusLyricsAsync(string url, bool keepHeaders = false)
-        {
-            var context = BrowsingContext.New(Configuration.Default.WithDefaultLoader());
-            var document = await context.OpenAsync(url);
-
-            var element = document
-                .All
-                .FirstOrDefault(x =>
-                x?.ClassName != null
-                && x.NodeType == NodeType.Element
-                && x.NodeName == "DIV"
-                && (x.ClassName.Contains("Lyrics__Root", StringComparison.Ordinal) || x.ClassName.Contains("lyrics", StringComparison.Ordinal)));
-
-            if (element == null)
-            {
-                return null;
-            }
-
-            string innerHtml = element.GetElementsByClassName("lyrics")?.FirstOrDefault()?.InnerHtml
-                ?? string.Concat(element
-                .Children
-                .Where(x =>
-                x?.ClassName != null
-                && x.NodeType == NodeType.Element
-                && x.NodeName == "DIV"
-                && x.ClassName.Contains("Lyrics__Container", StringComparison.Ordinal))
-                .Select(x => x.InnerHtml));
-
-            if (string.IsNullOrEmpty(innerHtml))
-            {
-                return null;
-            }
-
-            // Remove newlines, tabs and empty HTML tags.
-            string lyrics = Regex.Replace(innerHtml, @"\t|\n|\r|<[^/>][^>]*>\s*<\/[^>]+>", string.Empty);
-
-            lyrics = WebUtility.HtmlDecode(lyrics)
-                .Replace("<b>", "**", StringComparison.Ordinal)
-                .Replace("</b>", "**", StringComparison.Ordinal)
-                .Replace("<i>", "*", StringComparison.Ordinal)
-                .Replace("</i>", "*", StringComparison.Ordinal)
-                .Replace("<br>", "\n", StringComparison.Ordinal)
-                .Replace("</div>", "\n", StringComparison.Ordinal);
-
-            // Remove remaining HTML tags.
-            lyrics = Regex.Replace(lyrics, @"(\<.*?\>)", string.Empty);
-
-            // Prevent bold text overlapping.
-            lyrics = lyrics.Replace("****", "** **", StringComparison.Ordinal)
-                .Replace("******", "*** ****", StringComparison.Ordinal);
-
-            if (!keepHeaders)
-            {
-                lyrics = Regex.Replace(lyrics, @"(\[.*?\])*", string.Empty);
-            }
-            return Regex.Replace(lyrics, @"\n{3,}", "\n\n").Trim();
-        }
-
-         public static MessageComponent BuildLinks(IMessageChannel channel)
-        {
-            var builder = new ComponentBuilder();
-            if (FergunClient.InviteLink != null && Uri.IsWellFormedUriString(FergunClient.InviteLink, UriKind.Absolute))
-            {
-                var button = new ButtonBuilder()
-                    .WithLabel(GuildUtils.Locate("Invite", channel))
-                    .WithUrl(FergunClient.InviteLink)
-                    .WithStyle(ButtonStyle.Link);
-
-                builder.WithButton(button);
-            }
-
-            if (FergunClient.TopGgBotPage != null && Uri.IsWellFormedUriString(FergunClient.TopGgBotPage, UriKind.Absolute))
-            {
-                var button = new ButtonBuilder()
-                    .WithLabel(GuildUtils.Locate("TopGGBotPage", channel))
-                    .WithUrl(FergunClient.TopGgBotPage)
-                    .WithStyle(ButtonStyle.Link);
-
-                builder.WithButton(button);
-                //.WithButton(ButtonBuilder.CreateLinkButton(GuildUtils.Locate("VoteLink", channel), $"{FergunClient.TopGgBotPage}/vote"));
-            }
-
-            if (FergunClient.Config.SupportServer != null && Uri.IsWellFormedUriString(FergunClient.Config.SupportServer, UriKind.Absolute))
-            {
-                var button = new ButtonBuilder()
-                    .WithLabel(GuildUtils.Locate("SupportServer", channel))
-                    .WithUrl(FergunClient.Config.SupportServer)
-                    .WithStyle(ButtonStyle.Link);
-
-                builder.WithButton(button);
-            }
-
-            if (FergunClient.Config.DonationUrl != null && Uri.IsWellFormedUriString(FergunClient.Config.DonationUrl, UriKind.Absolute))
-            {
-                var button = new ButtonBuilder()
-                    .WithLabel(GuildUtils.Locate("Donate", channel))
-                    .WithUrl(FergunClient.Config.DonationUrl)
-                    .WithStyle(ButtonStyle.Link);
-
-                builder.WithButton(button);
-            }
-
-            return builder.Build();
-        }
-
-         public static Dictionary<IEmote, PaginatorAction> GetFergunPaginatorEmotes(FergunConfig config)
-        {
-            if (_fergunPaginatorEmotes != null)
-            {
-                return _fergunPaginatorEmotes;
-            }
-
-            _fergunPaginatorEmotes = new Dictionary<IEmote, PaginatorAction>();
-
-            AddEmote(config.FirstPageEmote, PaginatorAction.SkipToStart, "⏮");
-            AddEmote(config.PreviousPageEmote, PaginatorAction.Backward, "â—€");
-            AddEmote(config.NextPageEmote, PaginatorAction.Forward, "â–¶");
-            AddEmote(config.LastPageEmote, PaginatorAction.SkipToEnd, "⏭");
-            AddEmote(config.StopPaginatorEmote, PaginatorAction.Exit, "🛑");
-
-            return _fergunPaginatorEmotes;
-
-            static void AddEmote(string emoteString, PaginatorAction action, string defaultEmoji)
-            {
-                var emote = string.IsNullOrEmpty(emoteString) || !Emote.TryParse(emoteString, out var parsedEmote)
-                    ? new Emoji(defaultEmoji) as IEmote
-                    : parsedEmote;
+        var startTime = DateTimeOffset.UtcNow;
+        var startCpuUsage = Process.GetCurrentProcess().TotalProcessorTime;
+        await Task.Delay(500);
+
+        var endTime = DateTimeOffset.UtcNow;
+        var endCpuUsage = Process.GetCurrentProcess().TotalProcessorTime;
+        var cpuUsedMs = (endCpuUsage - startCpuUsage).TotalMilliseconds;
+        var totalMsPassed = (endTime - startTime).TotalMilliseconds;
+        var cpuUsageTotal = cpuUsedMs / (Environment.ProcessorCount * totalMsPassed);
+        return cpuUsageTotal * 100;
+    }
 
-                _fergunPaginatorEmotes.Add(emote, action);
-            }
-        }
+    public static string? RunCommand(string command)
+    {
+        bool isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
+        bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+        if (!isLinux && !isWindows)
+            return null;
 
-        public static string RunCommand(string command)
+        var escapedArgs = command.Replace("\"", "\\\"", StringComparison.Ordinal);
+        var startInfo = new ProcessStartInfo
         {
-            // TODO: Add support to the remaining platforms
-            bool isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
-            bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
-            if (!isLinux && !isWindows)
-                return null;
-
-            var escapedArgs = command.Replace("\"", "\\\"", StringComparison.OrdinalIgnoreCase);
-            var startInfo = new ProcessStartInfo
-            {
-                FileName = isLinux ? "/bin/bash" : "cmd.exe",
-                Arguments = isLinux ? $"-c \"{escapedArgs}\"" : $"/c {escapedArgs}",
-                RedirectStandardOutput = true,
-                RedirectStandardError = true,
-                UseShellExecute = false,
-                CreateNoWindow = true,
-                WorkingDirectory = isLinux ? "/home" : ""
-            };
-
-            using var process = new Process { StartInfo = startInfo };
-            process.Start();
-            process.WaitForExit(10000);
-
-            return process.ExitCode == 0
-                ? process.StandardOutput.ReadToEnd()
-                : process.StandardError.ReadToEnd();
-        }
+            FileName = isLinux ? "/bin/bash" : "cmd.exe",
+            Arguments = isLinux ? $"-c \"{escapedArgs}\"" : $"/c {escapedArgs}",
+            RedirectStandardOutput = true,
+            RedirectStandardError = true,
+            UseShellExecute = false,
+            CreateNoWindow = true,
+            WorkingDirectory = isLinux ? "/home" : ""
+        };
+
+        using var process = new Process { StartInfo = startInfo };
+        process.Start();
+        process.WaitForExit(10000);
+
+        return process.ExitCode == 0
+            ? process.StandardOutput.ReadToEnd()
+            : process.StandardError.ReadToEnd();
     }
 }
\ No newline at end of file
diff --git a/src/Utils/GuildUtils.cs b/src/Utils/GuildUtils.cs
deleted file mode 100644
index 1743d1b..0000000
--- a/src/Utils/GuildUtils.cs
+++ /dev/null
@@ -1,108 +0,0 @@
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Linq;
-using Discord;
-using Fergun.Extensions;
-
-namespace Fergun.Utils
-{
-    public static class GuildUtils
-    {
-        /// <summary>
-        /// Gets or sets the cached global prefix.
-        /// </summary>
-        /// <remarks>This prefix may not be up-to-date if its value is modified externally.</remarks>
-        public static string CachedGlobalPrefix { get; set; }
-
-        /// <summary>
-        /// Gets or sets the guild prefix cache.
-        /// </summary>
-        /// <remarks>These prefixes may not be up-to-date if their values are modified externally.</remarks>
-        public static ConcurrentDictionary<ulong, string> PrefixCache { get; private set; }
-
-        /// <summary>
-        /// Gets or sets the user config cache.
-        /// </summary>
-        /// <remarks>These configs may not be up-to-date if their values are modified externally.</remarks>
-        public static ConcurrentDictionary<ulong, UserConfig> UserConfigCache { get; private set; }
-
-        /// <summary>
-        /// Initializes the prefix cache.
-        /// </summary>
-        public static void Initialize()
-        {
-            CachedGlobalPrefix = DatabaseConfig.GlobalPrefix;
-            var guilds = FergunClient.Database.GetAllDocuments<GuildConfig>(Constants.GuildConfigCollection);
-            PrefixCache = new ConcurrentDictionary<ulong, string>(guilds?.ToDictionary(x => x.Id, x => x.Prefix) ?? new Dictionary<ulong, string>());
-            var users = FergunClient.Database.GetAllDocuments<UserConfig>(Constants.UserConfigCollection);
-            UserConfigCache = new ConcurrentDictionary<ulong, UserConfig>(
-                users?.Where(x => x != null).ToDictionary(x => x.Id, x => x) ?? new Dictionary<ulong, UserConfig>());
-        }
-
-        /// <summary>
-        /// Returns a cached prefix corresponding to the specified channel.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <returns>The cached prefix of the channel.</returns>
-        public static string GetCachedPrefix(IMessageChannel channel)
-            => channel.IsPrivate() ? CachedGlobalPrefix : PrefixCache.GetValueOrDefault(((IGuildChannel)channel).GuildId, CachedGlobalPrefix) ?? CachedGlobalPrefix;
-
-        /// <summary>
-        /// Returns the configuration of a guild using the specified channel.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <returns>The configuration of the guild, or <c>null</c> if the guild cannot be found in the database.</returns>
-        public static GuildConfig GetGuildConfig(IMessageChannel channel)
-            => channel.IsPrivate() ? null : GetGuildConfig(((IGuildChannel)channel).GuildId);
-
-        /// <summary>
-        /// Returns the configuration of the specified guild Id.
-        /// </summary>
-        /// <param name="guildId">The Id of the guild.</param>
-        /// <returns>The configuration of the guild, or <c>null</c> if the guild cannot be found in the database.</returns>
-        public static GuildConfig GetGuildConfig(ulong guildId)
-            => FergunClient.Database.FindDocument<GuildConfig>(Constants.GuildConfigCollection, x => x.Id == guildId);
-
-        /// <summary>
-        /// Returns the configuration of the specified guild.
-        /// </summary>
-        /// <param name="guild">The guild.</param>
-        /// <returns>The configuration of the guild, or <c>null</c> if the guild cannot be found in the database.</returns>
-        public static GuildConfig GetGuildConfig(IGuild guild)
-            => GetGuildConfig(guild.Id);
-
-        /// <summary>
-        /// Returns the prefix of the specified channel.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <returns>The prefix of the channel.</returns>
-        public static string GetPrefix(IMessageChannel channel)
-            => GetGuildConfig(channel)?.Prefix ?? DatabaseConfig.GlobalPrefix;
-
-        /// <summary>
-        /// Returns the language of the specified channel.
-        /// </summary>
-        /// <param name="channel">The channel.</param>
-        /// <returns>The language of the channel.</returns>
-        public static string GetLanguage(IMessageChannel channel)
-            => GetGuildConfig(channel)?.Language ?? Constants.DefaultLanguage;
-
-        /// <summary>
-        /// Returns the localized value of a resource key in a channel.
-        /// </summary>
-        /// <param name="key">The resource key to localize.</param>
-        /// <param name="channel">The channel.</param>
-        /// <returns>The localized text, or <paramref name="key"/> if the value cannot be found.</returns>
-        public static string Locate(string key, IMessageChannel channel)
-            => Locate(key, GetLanguage(channel));
-
-        /// <summary>
-        /// Returns the localized value of a resource key in the specified language.
-        /// </summary>
-        /// <param name="key">The resource key to localize.</param>
-        /// <param name="language">The language to localize the resource key.</param>
-        /// <returns>The localized text, or <paramref name="key"/> if the value cannot be found.</returns>
-        public static string Locate(string key, string language)
-            => strings.ResourceManager.GetString(key, FergunClient.Languages.GetValueOrDefault(language, FergunClient.Languages[Constants.DefaultLanguage])) ?? key;
-    }
-}
\ No newline at end of file
diff --git a/src/Utils/StringUtils.cs b/src/Utils/StringUtils.cs
deleted file mode 100644
index 8e14a92..0000000
--- a/src/Utils/StringUtils.cs
+++ /dev/null
@@ -1,65 +0,0 @@
-using System;
-using System.Linq;
-using System.Net.Http;
-using System.Threading.Tasks;
-
-namespace Fergun.Utils
-{
-    public static class StringUtils
-    {
-        private static readonly HttpClient _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
-
-        public static async Task<HttpResponseMessage> GetUrlResponseHeadersAsync(string url)
-        {
-            try
-            {
-                return await _httpClient.GetAsync(new UriBuilder(url).Uri, HttpCompletionOption.ResponseHeadersRead);
-            }
-            catch (Exception e) when (e is HttpRequestException || e is TaskCanceledException || e is UriFormatException || e is ArgumentException)
-            {
-                return null;
-            }
-        }
-
-        public static async Task<long?> GetUrlContentLengthAsync(string url)
-        {
-            var response = await GetUrlResponseHeadersAsync(url);
-            return response?.Content?.Headers?.ContentLength;
-        }
-
-        public static async Task<string> GetUrlMediaTypeAsync(string url)
-        {
-            var response = await GetUrlResponseHeadersAsync(url);
-            return response?.Content?.Headers?.ContentType?.MediaType;
-        }
-
-        public static async Task<bool> IsImageUrlAsync(string url)
-        {
-            string mediaType = await GetUrlMediaTypeAsync(url);
-            return mediaType != null && mediaType.ToLowerInvariant().StartsWith("image/", StringComparison.OrdinalIgnoreCase);
-        }
-
-        public static string RandomString(int length, Random rng = null)
-        {
-            const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
-            rng ??= Random.Shared;
-            return new string(Enumerable.Repeat(chars, length)
-              .Select(s => s[rng.Next(s.Length)]).ToArray());
-        }
-
-        public static string ReadPassword()
-        {
-            string password = string.Empty;
-            while (true)
-            {
-                var keyInfo = Console.ReadKey(true);
-                if (keyInfo.Key == ConsoleKey.Enter)
-                {
-                    Console.WriteLine();
-                    return password;
-                }
-                password += keyInfo.KeyChar;
-            }
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/appsettings.json b/src/appsettings.json
new file mode 100644
index 0000000..65ddbd5
--- /dev/null
+++ b/src/appsettings.json
@@ -0,0 +1,4 @@
+{
+  "TargetGuildId": 0,
+  "Token": ""
+}
\ No newline at end of file
diff --git a/src/strings.Designer.cs b/src/strings.Designer.cs
deleted file mode 100644
index 0a26592..0000000
--- a/src/strings.Designer.cs
+++ /dev/null
@@ -1,72 +0,0 @@
-//------------------------------------------------------------------------------
-// <auto-generated>
-//     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.
-// </auto-generated>
-//------------------------------------------------------------------------------
-
-namespace Fergun
-{
-    using System;
-
-
-    /// <summary>
-    ///   A strongly-typed resource class, for looking up localized strings, etc.
-    /// </summary>
-    // 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", "16.0.0.0")]
-    [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
-    [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
-    public class strings
-    {
-
-        private static global::System.Resources.ResourceManager resourceMan;
-
-        private static global::System.Globalization.CultureInfo resourceCulture;
-
-        [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
-        internal strings()
-        {
-        }
-
-        /// <summary>
-        ///   Returns the cached ResourceManager instance used by this class.
-        /// </summary>
-        [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
-        public static global::System.Resources.ResourceManager ResourceManager
-        {
-            get
-            {
-                if (object.ReferenceEquals(resourceMan, null))
-                {
-                    global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Fergun.strings", typeof(strings).Assembly);
-                    resourceMan = temp;
-                }
-                return resourceMan;
-            }
-        }
-
-        /// <summary>
-        ///   Overrides the current thread's CurrentUICulture property for all
-        ///   resource lookups using this strongly typed resource class.
-        /// </summary>
-        [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
-        public static global::System.Globalization.CultureInfo Culture
-        {
-            get
-            {
-                return resourceCulture;
-            }
-            set
-            {
-                resourceCulture = value;
-            }
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/strings.ar.resx b/src/strings.ar.resx
deleted file mode 100644
index 3ff889b..0000000
--- a/src/strings.ar.resx
+++ /dev/null
@@ -1,2189 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<root>
-	<!-- 
-    Microsoft ResX Schema 
-    
-    Version 2.0
-    
-    The primary goals of this format is to allow a simple XML format 
-    that is mostly human readable. The generation and parsing of the 
-    various data types are done through the TypeConverter classes 
-    associated with the data types.
-    
-    Example:
-    
-    ... ado.net/XML headers & schema ...
-    <resheader name="resmimetype">text/microsoft-resx</resheader>
-    <resheader name="version">2.0</resheader>
-    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
-    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
-    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
-    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
-    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
-        <value>[base64 mime encoded serialized .NET Framework object]</value>
-    </data>
-    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
-        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
-        <comment>This is a comment</comment>
-    </data>
-                
-    There are any number of "resheader" rows that contain simple 
-    name/value pairs.
-    
-    Each data row contains a name, and value. The row also contains a 
-    type or mimetype. Type corresponds to a .NET class that support 
-    text/value conversion through the TypeConverter architecture. 
-    Classes that don't support this are serialized and stored with the 
-    mimetype set.
-    
-    The mimetype is used for serialized objects, and tells the 
-    ResXResourceReader how to depersist the object. This is currently not 
-    extensible. For a given mimetype the value must be set accordingly:
-    
-    Note - application/x-microsoft.net.object.binary.base64 is the format 
-    that the ResXResourceWriter will generate, however the reader can 
-    read any of the formats listed below.
-    
-    mimetype: application/x-microsoft.net.object.binary.base64
-    value   : The object must be serialized with 
-            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
-            : and then encoded with base64 encoding.
-    
-    mimetype: application/x-microsoft.net.object.soap.base64
-    value   : The object must be serialized with 
-            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
-            : and then encoded with base64 encoding.
-
-    mimetype: application/x-microsoft.net.object.bytearray.base64
-    value   : The object must be serialized into a byte array 
-            : using a System.ComponentModel.TypeConverter
-            : and then encoded with base64 encoding.
-    -->
-	<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
-		<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
-		<xsd:element name="root" msdata:IsDataSet="true">
-			<xsd:complexType>
-				<xsd:choice maxOccurs="unbounded">
-					<xsd:element name="metadata">
-						<xsd:complexType>
-							<xsd:sequence>
-								<xsd:element name="value" type="xsd:string" minOccurs="0" />
-							</xsd:sequence>
-							<xsd:attribute name="name" use="required" type="xsd:string" />
-							<xsd:attribute name="type" type="xsd:string" />
-							<xsd:attribute name="mimetype" type="xsd:string" />
-							<xsd:attribute ref="xml:space" />
-						</xsd:complexType>
-					</xsd:element>
-					<xsd:element name="assembly">
-						<xsd:complexType>
-							<xsd:attribute name="alias" type="xsd:string" />
-							<xsd:attribute name="name" type="xsd:string" />
-						</xsd:complexType>
-					</xsd:element>
-					<xsd:element name="data">
-						<xsd:complexType>
-							<xsd:sequence>
-								<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
-								<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
-							</xsd:sequence>
-							<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
-							<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
-							<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
-							<xsd:attribute ref="xml:space" />
-						</xsd:complexType>
-					</xsd:element>
-					<xsd:element name="resheader">
-						<xsd:complexType>
-							<xsd:sequence>
-								<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
-							</xsd:sequence>
-							<xsd:attribute name="name" type="xsd:string" use="required" />
-						</xsd:complexType>
-					</xsd:element>
-				</xsd:choice>
-			</xsd:complexType>
-		</xsd:element>
-	</xsd:schema>
-	<resheader name="resmimetype">
-		<value>text/microsoft-resx</value>
-	</resheader>
-	<resheader name="version">
-		<value>2.0</value>
-	</resheader>
-	<resheader name="reader">
-		<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
-	</resheader>
-	<resheader name="writer">
-		<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
-	</resheader>
-	<data name="CommandList" xml:space="preserve">
-    <value>قائمة الأوامر</value>
-  </data>
-	<data name="EntertainmentCommands" xml:space="preserve">
-    <value>أوامر الترفيه</value>
-  </data>
-	<data name="HelpFooter" xml:space="preserve">
-    <value>Fergun  {0} - إجمالي عدد الأوامر: {1}</value>
-  </data>
-	<data name="ModerationCommands" xml:space="preserve">
-    <value>أوامر الإشراف</value>
-  </data>
-	<data name="MusicCommands" xml:space="preserve">
-    <value>أوامر الموسيقى</value>
-  </data>
-	<data name="Notes" xml:space="preserve">
-    <value>ملاحظات</value>
-  </data>
-	<data name="NotesInfo" xml:space="preserve">
-    <value>استخدم `help [command]{0}` لمعرفة المزيد من المعلومات حول الأمر.</value>
-  </data>
-	<data name="OtherCommands" xml:space="preserve">
-    <value>أوامر أخرى</value>
-  </data>
-	<data name="TextCommands" xml:space="preserve">
-    <value>أوامر نصية</value>
-  </data>
-	<data name="UtilityCommands" xml:space="preserve">
-    <value>أوامر المنفعة</value>
-  </data>
-	<data name="UpcomingCommands" xml:space="preserve">
-    <value>الأوامر القادمة</value>
-  </data>
-	<data name="CommandNotFound" xml:space="preserve">
-    <value>القيادة لم يتم العثور. استخدم `{0} help` لعرض قائمة الأوامر.</value>
-  </data>
-	<data name="NoDescription" xml:space="preserve">
-    <value>(لا يوجد وصف متاح)</value>
-  </data>
-	<data name="Optional" xml:space="preserve">
-    <value>(اختياري)</value>
-  </data>
-	<data name="Usage" xml:space="preserve">
-    <value>إستعمال</value>
-  </data>
-	<data name="Alias" xml:space="preserve">
-    <value>اسماء مستعارة)</value>
-  </data>
-	<data name="Parameters" xml:space="preserve">
-    <value>المعلمات</value>
-  </data>
-	<data name="mojibakeSummary" xml:space="preserve">
-    <value>يظهر أحرف يونيكود عشوائية.</value>
-  </data>
-	<data name="mojibakeParam1" xml:space="preserve">
-    <value>طول النتيجة.</value>
-  </data>
-	<data name="3orMoreChars" xml:space="preserve">
-    <value>يجب أن يحتوي النص على 3 أحرف أو أكثر.</value>
-  </data>
-	<data name="TooLongToDisplay" xml:space="preserve">
-    <value>النص الناتج أطول من أن يمكن عرضه ({0})</value>
-  </data>
-	<data name="normalizeSummary" xml:space="preserve">
-    <value>تطبيع النص.</value>
-  </data>
-	<data name="normalizeParam1" xml:space="preserve">
-    <value>النص المطلوب تطبيعه.</value>
-  </data>
-	<data name="randomizeSummary" xml:space="preserve">
-    <value>عشوائية النص.</value>
-  </data>
-	<data name="randomizeParam1" xml:space="preserve">
-    <value>النص العشوائي.</value>
-  </data>
-	<data name="repeatSummary" xml:space="preserve">
-    <value>يكرر نصًا عدة مرات.</value>
-  </data>
-	<data name="repeatParam1" xml:space="preserve">
-    <value>مرات التكرار.</value>
-  </data>
-	<data name="repeatParam2" xml:space="preserve">
-    <value>النص المكرر.</value>
-  </data>
-	<data name="reverseSummary" xml:space="preserve">
-    <value>عكس النص.</value>
-  </data>
-	<data name="reverseParam1" xml:space="preserve">
-    <value>النص المراد عكسه.</value>
-  </data>
-	<data name="reverselinesSummary" xml:space="preserve">
-    <value>لعكس ترتيب سطر النص.</value>
-  </data>
-	<data name="reverselinesParam1" xml:space="preserve">
-    <value>النص لعكس خطوطه.</value>
-  </data>
-	<data name="reversewordsSummary" xml:space="preserve">
-    <value>لعكس ترتيب الكلمات في النص.</value>
-  </data>
-	<data name="reversewordsParam1" xml:space="preserve">
-    <value>النص لعكس كلماته.</value>
-  </data>
-	<data name="sarcasmSummary" xml:space="preserve">
-    <value>sARCAstIC TEXt.</value>
-  </data>
-	<data name="sarcasmParam1" xml:space="preserve">
-    <value>النص المطلوب تحويله.</value>
-  </data>
-	<data name="vaporwaveSummary" xml:space="preserve">
-    <value>يحول النص إلى vaporwave.</value>
-  </data>
-	<data name="vaporwaveParam1" xml:space="preserve">
-    <value>النص المطلوب تحويله.</value>
-  </data>
-	<data name="avatarSummary" xml:space="preserve">
-    <value>إرجاع الصورة الرمزية للمتصل بالأمر ، أو مستخدم معين ، إذا تم تمريره.</value>
-  </data>
-	<data name="avatarParam1" xml:space="preserve">
-    <value>المستخدم للحصول على الصورة الرمزية الخاصة به.</value>
-  </data>
-	<data name="base64encodeSummary" xml:space="preserve">
-    <value>يشفر النص إلى Base64.</value>
-  </data>
-	<data name="base64encodeParam1" xml:space="preserve">
-    <value>النص المطلوب ترميزه.</value>
-  </data>
-	<data name="base64decodeSummary" xml:space="preserve">
-    <value>يفك تشفير نص من Base64.</value>
-  </data>
-	<data name="base64decodeParam1" xml:space="preserve">
-    <value>النص لفك.</value>
-  </data>
-	<data name="base64decodeInvalid" xml:space="preserve">
-    <value>نص غير صالح.</value>
-  </data>
-	<data name="choiceSummary" xml:space="preserve">
-    <value>يختار خيار من القائمة.</value>
-  </data>
-	<data name="choiceParam1" xml:space="preserve">
-    <value>قائمة خيارات مفصولة بمسافات.</value>
-  </data>
-	<data name="IChoose" xml:space="preserve">
-    <value>انا اخترت...</value>
-  </data>
-	<data name="OneChoice" xml:space="preserve">
-    <value>... لأنك منحتني خيارًا واحدًا فقط</value>
-  </data>
-	<data name="colorSummary" xml:space="preserve">
-	<value>يظهر لونًا عشوائيًا أو محددًا.</value>
-  </data>
-	<data name="colorParam1" xml:space="preserve">
-	<value>اللون المحدد للاستخدام. يجب أن تكون قيمة سداسية عشرية أو قيمة خام أو اسم لون معروف.</value>
-  </data>
-	<data name="helpSummary" xml:space="preserve">
-    <value>إظهار قائمة التعليمات أو معلومات الأمر ، إذا تم تمريرها.</value>
-  </data>
-	<data name="helpParam1" xml:space="preserve">
-    <value>الأمر للحصول على معلومات.</value>
-  </data>
-	<data name="identifySummary" xml:space="preserve">
-    <value>يحدد صورة باستخدام Microsoft CaptionBot.</value>
-  </data>
-	<data name="identifyParam1" xml:space="preserve">
-    <value>عنوان URL للصورة المراد استخدامها.</value>
-  </data>
-	<data name="NoUrlPassed" xml:space="preserve">
-    <value>لا تحديد عنوان URL سيجعل الأمر للبحث عن رسائل س مشاركة في ذاكرة التخزين المؤقت لرابط أو مرفق.</value>
-  </data>
-	<data name="AttachmentNotImage" xml:space="preserve">
-    <value>المرفق ليس صورة.</value>
-  </data>
-	<data name="UrlNotFound" xml:space="preserve">
-    <value>تعذر العثور على أي عنوان url أو مرفق في آخر {0} رسائل.</value>
-  </data>
-	<data name="UrlNotImage" xml:space="preserve">
-    <value>عنوان url ليس صورة صالحة.</value>
-  </data>
-	<data name="imgSummary" xml:space="preserve">
-    <value>يبحث عن الصور باستخدام صور Google.</value>
-  </data>
-	<data name="imgParam1" xml:space="preserve">
-    <value>الكلمة (الكلمات) الرئيسية للبحث.</value>
-  </data>
-	<data name="NoResultsFound" xml:space="preserve">
-    <value>تعذر العثور على أي نتائج.</value>
-  </data>
-	<data name="IfNSFW" xml:space="preserve">
-    <value>إذا كان البحث NSFW ، استخدم الأمر في قناة NSFW.</value>
-  </data>
-	<data name="ImageSearch" xml:space="preserve">
-    <value>بحث في الصور</value>
-  </data>
-	<data name="UrbanFooter" xml:space="preserve">
-    <value>Urban Dictionary - صفحة {0} من {1}</value>
-  </data>
-	<data name="ImageSearchCap" xml:space="preserve">
-    <value>تم الوصول إلى الحد الأقصى لبحث الصور :(</value>
-  </data>
-	<data name="ocrSummary" xml:space="preserve">
-    <value>ينفذ OCR على الصورة.</value>
-  </data>
-	<data name="ocrParam1" xml:space="preserve">
-    <value>عنوان URL للصورة المراد استخدامها.</value>
-  </data>
-	<data name="UrlFileTooLarge" xml:space="preserve">
-    <value>الملف كبير جدًا.</value>
-  </data>
-	<data name="OcrEmpty" xml:space="preserve">
-    <value>لم يعط OCR نتائج.</value>
-  </data>
-	<data name="ocr2Summary" xml:space="preserve">
-    <value>ينفذ OCR إلى صورة.</value>
-  </data>
-	<data name="ocr2Param1" xml:space="preserve">
-    <value>عنوان URL للصورة المراد استخدامها.</value>
-  </data>
-	<data name="StatusCode" xml:space="preserve">
-    <value>رمز الحالة:</value>
-  </data>
-	<data name="pingSummary" xml:space="preserve">
-    <value>يحصل الكمون في إرسال رسالة والكمون من قاعدة البيانات.</value>
-  </data>
-	<data name="resizeSummary" xml:space="preserve">
-    <value>يغير حجم الصورة مع waifu2x.</value>
-  </data>
-	<data name="resizeParam1" xml:space="preserve">
-    <value>عنوان URL للصورة المراد استخدامها.</value>
-  </data>
-	<data name="AnErrorOccurred" xml:space="preserve">
-    <value>حدث خطأ.</value>
-  </data>
-	<data name="ResizeResults" xml:space="preserve">
-    <value>تغيير حجم النتائج</value>
-  </data>
-	<data name="screenshotSummary" xml:space="preserve">
-    <value>يأخذ لقطة شاشة لموقع ويب.</value>
-  </data>
-	<data name="screenshotParam1" xml:space="preserve">
-    <value>الموقع لأخذ لقطة شاشة.</value>
-  </data>
-	<data name="serverinfoSummary" xml:space="preserve">
-    <value>يعود من المعلومات حول الملقم الحالي.</value>
-  </data>
-	<data name="ServerInfo" xml:space="preserve">
-    <value>معلومات الخادم</value>
-  </data>
-	<data name="Name" xml:space="preserve">
-    <value>اسم</value>
-  </data>
-	<data name="Owner" xml:space="preserve">
-    <value>صاحب</value>
-  </data>
-	<data name="RoleCount" xml:space="preserve">
-    <value>عدد الأدوار</value>
-  </data>
-	<data name="UserCount" xml:space="preserve">
-    <value>عدد المستخدمين</value>
-  </data>
-	<data name="VerificationLevel" xml:space="preserve">
-    <value>مستوى التحقق</value>
-  </data>
-	<data name="None" xml:space="preserve">
-    <value>(بلا)</value>
-  </data>
-	<data name="CreatedAt" xml:space="preserve">
-    <value>أنشئت في</value>
-  </data>
-	<data name="snipeSummary" xml:space="preserve">
-    <value>يعرض آخر رسالة محذوفة في القناة الحالية.</value>
-  </data>
-	<data name="NothingToSnipe" xml:space="preserve">
-    <value>لا يوجد شيء للقنص في {0}</value>
-  </data>
-	<data name="translateSummary" xml:space="preserve">
-    <value>يترجم النص.</value>
-  </data>
-	<data name="translateParam1" xml:space="preserve">
-    <value>اللغة الهدف في كود ISO (`en` ،` es` ، `br` ، إلخ.)</value>
-  </data>
-	<data name="translateParam2" xml:space="preserve">
-    <value>النص المراد ترجمته.</value>
-  </data>
-	<data name="InvalidLanguage" xml:space="preserve">
-    <value>لغة الهدف غير صالحة. استخدم `{0}translate language codes` لمشاهدة قائمة اللغات.</value>
-  </data>
-	<data name="GoogleIPBanned" xml:space="preserve">
-    <value>لقد تم حظر IP من قبل Google :( لول</value>
-  </data>
-	<data name="ttsSummary" xml:space="preserve">
-    <value>النص إلى الكلام.</value>
-  </data>
-	<data name="ttsParam1" xml:space="preserve">
-    <value>اللغة الهدف في رمز ISO (`en` ،` es` ، `br` ، إلخ) ، تتراجع إلى اللغة الإنجليزية إذا كان الهدف غير صالح.</value>
-  </data>
-	<data name="ttsParam2" xml:space="preserve">
-    <value>النص المطلوب تحويله إلى كلام.</value>
-  </data>
-	<data name="urbanSummary" xml:space="preserve">
-    <value>البحث في القاموس الحضري.</value>
-  </data>
-	<data name="urbanParam1" xml:space="preserve">
-    <value>الكلمة (الكلمات) الرئيسية للبحث.</value>
-  </data>
-	<data name="NoResults" xml:space="preserve">
-    <value>لا نتائج.</value>
-  </data>
-	<data name="By" xml:space="preserve">
-    <value>بواسطة</value>
-  </data>
-	<data name="Example" xml:space="preserve">
-    <value>مثال</value>
-  </data>
-	<data name="NoExample" xml:space="preserve">
-    <value>(لم يتم تقديم مثال)</value>
-  </data>
-	<data name="userinfoSummary" xml:space="preserve">
-    <value>إرجاع معلومات حول المستخدم الحالي ، أو مستخدم معين ، في حالة مرور المستخدم.</value>
-  </data>
-	<data name="userinfoParam1" xml:space="preserve">
-    <value>المستخدم للحصول على معلومات من.</value>
-  </data>
-	<data name="UserInfo" xml:space="preserve">
-    <value>معلومات المستخدم</value>
-  </data>
-	<data name="Activity" xml:space="preserve">
-    <value>نشاط</value>
-  </data>
-	<data name="IsBot" xml:space="preserve">
-    <value>بوت</value>
-  </data>
-	<data name="GuildJoinDate" xml:space="preserve">
-    <value>تاريخ انضمام النقابة</value>
-  </data>
-	<data name="UserNotFound" xml:space="preserve">
-    <value>المستخدم ليس موجود.
-حاول استخدام علامة ({0}) أو إشارة ({1}) أو معرّف ({2}).</value>
-  </data>
-	<data name="wikipediaSummary" xml:space="preserve">
-    <value>يبحث عن مقال في ويكيبيديا.</value>
-  </data>
-	<data name="wikipediaParam1" xml:space="preserve">
-    <value>الكلمة (الكلمات) الرئيسية للبحث.</value>
-  </data>
-	<data name="WikipediaSearch" xml:space="preserve">
-    <value>بحث ويكيبيديا</value>
-  </data>
-	<data name="xkcdSummary" xml:space="preserve">
-    <value>إرجاع كوميدي عشوائي xkcd أو كوميدي محدد إذا تم تمرير رقم.</value>
-  </data>
-	<data name="xkcdParam1" xml:space="preserve">
-    <value>الرقم الهزلي.</value>
-  </data>
-	<data name="InvalidxkcdNumber" xml:space="preserve">
-    <value>يجب أن يكون الرقم بين 1 و {0}.</value>
-  </data>
-	<data name="youtubeSummary" xml:space="preserve">
-    <value>يبحث عن فيديو على موقع يوتيوب.</value>
-  </data>
-	<data name="youtubeParam1" xml:space="preserve">
-    <value>الكلمة (الكلمات) الرئيسية للبحث.</value>
-  </data>
-	<data name="YouTubeNoResults" xml:space="preserve">
-    <value>تعذر العثور على أي نتائج.</value>
-  </data>
-	<data name="ytrandomSummary" xml:space="preserve">
-    <value>إرجاع فيديو YouTube "عشوائي".</value>
-  </data>
-	<data name="EmptyCache" xml:space="preserve">
-    <value>من مقاطع الفيديو المخزنة مؤقتًا
-جارٍ إنشاء ذاكرة التخزين المؤقت ...</value>
-  </data>
-	<data name="banSummary" xml:space="preserve">
-    <value>يحظر مستخدم.</value>
-  </data>
-	<data name="banParam1" xml:space="preserve">
-    <value>المستخدم لحظر.</value>
-  </data>
-	<data name="banParam2" xml:space="preserve">
-    <value>سبب الحظر.</value>
-  </data>
-	<data name="BanSameUser" xml:space="preserve">
-    <value>ماذا؟ تريد حظر نفسك؟</value>
-  </data>
-	<data name="BanMyself" xml:space="preserve">
-    <value>لن أحظر نفسي ههههههههه</value>
-  </data>
-	<data name="AlreadyBanned" xml:space="preserve">
-    <value>المستخدم محظور بالفعل.</value>
-  </data>
-	<data name="Banned" xml:space="preserve">
-    <value>تم حظر المستخدم {0}.</value>
-  </data>
-	<data name="clearSummary" xml:space="preserve">
-    <value>يمسح آخر x رسائل للقناة الحالية.</value>
-  </data>
-	<data name="clearParam1" xml:space="preserve">
-    <value>عدد الرسائل المراد مسحها.</value>
-  </data>
-	<data name="clearParam2" xml:space="preserve">
-    <value>مستخدم لحذف رسائله.</value>
-  </data>
-	<data name="NumberOutOfIndex" xml:space="preserve">
-    <value>يجب أن يكون الرقم بين {0} و {1}.</value>
-  </data>
-	<data name="ClearNotFound" xml:space="preserve">
-    <value>تعذر العثور على أي رسائل بواسطة {0} في رسائل {1} الأخيرة.</value>
-  </data>
-	<data name="By2" xml:space="preserve">
-    <value>بواسطة</value>
-  </data>
-	<data name="hackbanSummary" xml:space="preserve">
-    <value>Hackbans مستخدم.</value>
-  </data>
-	<data name="hackbanParam1" xml:space="preserve">
-    <value>معرف المستخدم لقرصنة.</value>
-  </data>
-	<data name="hackbanParam2" xml:space="preserve">
-    <value>سبب الإختراق.</value>
-  </data>
-	<data name="Hackbanned" xml:space="preserve">
-    <value>تم اختراق المستخدم {0}.</value>
-  </data>
-	<data name="kickSummary" xml:space="preserve">
-    <value>يركل مستخدمًا.</value>
-  </data>
-	<data name="kickParam1" xml:space="preserve">
-    <value>المستخدم لركل.</value>
-  </data>
-	<data name="kickParam2" xml:space="preserve">
-    <value>سبب الركلة.</value>
-  </data>
-	<data name="Kicked" xml:space="preserve">
-    <value>تم طرد المستخدم {0}.</value>
-  </data>
-	<data name="nickSummary" xml:space="preserve">
-    <value>يغير لقب المستخدم.</value>
-  </data>
-	<data name="nickParam1" xml:space="preserve">
-    <value>المستخدم لتغيير لقبه.</value>
-  </data>
-	<data name="nickParam2" xml:space="preserve">
-    <value>اللقب الجديد. اترك هذا فارغًا لإزالة اللقب.</value>
-  </data>
-	<data name="unbanSummary" xml:space="preserve">
-    <value>إلغاء حظر مستخدم.</value>
-  </data>
-	<data name="unbanParam1" xml:space="preserve">
-    <value>معرف المستخدم لإلغاء الحظر.</value>
-  </data>
-	<data name="Unbanned" xml:space="preserve">
-    <value>تم حظر المستخدم {0}.</value>
-  </data>
-	<data name="triviaSummary" xml:space="preserve">
-    <value>الوقت التوافه!</value>
-  </data>
-	<data name="triviaParam1" xml:space="preserve">
-    <value>الفئة المطلوب تحديدها. حدد "الفئات" لرؤية قائمة الفئات أو "ليدربورد" / "الرتب" لرؤية ليدربورد.</value>
-  </data>
-	<data name="CategoryList" xml:space="preserve">
-    <value>قائمة الفئات</value>
-  </data>
-	<data name="TriviaLeaderboard" xml:space="preserve">
-    <value>المتصدرين التوافه</value>
-  </data>
-	<data name="AllQuestionsAnswered" xml:space="preserve">
-    <value>يبدو أنك أجبت على جميع الأسئلة في الفئة المحددة ، حدد فئة أخرى ..</value>
-  </data>
-	<data name="TriviaError" xml:space="preserve">
-    <value>حدث خطأ. خطا بالكود</value>
-  </data>
-	<data name="Category" xml:space="preserve">
-    <value>الفئة</value>
-  </data>
-	<data name="Type" xml:space="preserve">
-    <value>اكتب</value>
-  </data>
-	<data name="Difficulty" xml:space="preserve">
-    <value>صعوبة</value>
-  </data>
-	<data name="Question" xml:space="preserve">
-    <value>سؤال</value>
-  </data>
-	<data name="Options" xml:space="preserve">
-    <value>خيارات</value>
-  </data>
-	<data name="TimeLeft" xml:space="preserve">
-    <value>لديك {0} ثانية للإجابة.</value>
-  </data>
-	<data name="InvalidOption" xml:space="preserve">
-    <value>خيار غير صالح!</value>
-  </data>
-	<data name="Lost1Point" xml:space="preserve">
-    <value>لقد فقدت نقطة واحدة.</value>
-  </data>
-	<data name="CorrectAnswer" xml:space="preserve">
-    <value>اجابة صحيحة!</value>
-  </data>
-	<data name="Won1Point" xml:space="preserve">
-    <value>لقد ربحت نقطة واحدة.</value>
-  </data>
-	<data name="Incorrect" xml:space="preserve">
-    <value>غير صحيح!</value>
-  </data>
-	<data name="TheAnswerIs" xml:space="preserve">
-    <value>الجواب هو</value>
-  </data>
-	<data name="TimesUp" xml:space="preserve">
-    <value>انتهى الوقت!</value>
-  </data>
-	<data name="Points" xml:space="preserve">
-    <value>نقاط</value>
-  </data>
-	<data name="joinSummary" xml:space="preserve">
-    <value>ينضم إلى قناة صوتية.</value>
-  </data>
-	<data name="UserNotInVC" xml:space="preserve">
-    <value>تحتاج إلى الاتصال بقناة صوتية.</value>
-  </data>
-	<data name="NowConnected" xml:space="preserve">
-    <value>متصل الآن بـ {0}.</value>
-  </data>
-	<data name="leaveSummary" xml:space="preserve">
-    <value>يترك قناة صوتية.</value>
-  </data>
-	<data name="LeaveNotInVC" xml:space="preserve">
-    <value>انضم إلى القناة التي يتواجد فيها البوت لجعله يغادر.</value>
-  </data>
-	<data name="LeftVC" xml:space="preserve">
-    <value>وقد غادر بوت الآن {0}.</value>
-  </data>
-	<data name="moveSummary" xml:space="preserve">
-    <value>ينقل البوت إلى القناة الصوتية للمستخدم الحالي.</value>
-  </data>
-	<data name="MoveNotInVC" xml:space="preserve">
-    <value>انضم إلى قناة صوتية حيث تريد أن يكون البوت.</value>
-  </data>
-	<data name="playSummary" xml:space="preserve">
-    <value>يبحث ويلعب مسار من يوتيوب.</value>
-  </data>
-	<data name="playParam1" xml:space="preserve">
-    <value>الكلمة (الكلمات) الرئيسية للبحث.</value>
-  </data>
-	<data name="SelectTrack" xml:space="preserve">
-    <value>إرسال رقم من قائمة</value>
-  </data>
-	<data name="SearchCanceled" xml:space="preserve">
-    <value>تم إلغاء البحث.</value>
-  </data>
-	<data name="OutOfIndex" xml:space="preserve">
-    <value>الخيار خارج الفهرس.</value>
-  </data>
-	<data name="ReplyTimeout" xml:space="preserve">
-    <value>لم ترد قبل انتهاء المهلة!</value>
-  </data>
-	<data name="replaySummary" xml:space="preserve">
-    <value>يعيد المسار الحالي الذي يتم تشغيله ، إن وجد.</value>
-  </data>
-	<data name="pauseSummary" xml:space="preserve">
-    <value>يوقف اللاعب مؤقتًا.</value>
-  </data>
-	<data name="resumeSummary" xml:space="preserve">
-    <value>استئناف التشغيل.</value>
-  </data>
-	<data name="stopSummary" xml:space="preserve">
-    <value>توقف اللاعب.</value>
-  </data>
-	<data name="skipSummary" xml:space="preserve">
-    <value>تخطي المسار الحالي ، إن وجد.</value>
-  </data>
-	<data name="volumeSummary" xml:space="preserve">
-    <value>يضبط مستوى صوت المشغل.</value>
-  </data>
-	<data name="volumeParam1" xml:space="preserve">
-    <value>الحجم المطلوب ضبطه (2 - 150)</value>
-  </data>
-	<data name="queueSummary" xml:space="preserve">
-    <value>يظهر قائمة الانتظار.</value>
-  </data>
-	<data name="shuffleSummary" xml:space="preserve">
-    <value>خلط الترتيب.</value>
-  </data>
-	<data name="removeSummary" xml:space="preserve">
-    <value>إزالة مسار في قائمة الانتظار من فهرس محدد.</value>
-  </data>
-	<data name="removeParam1" xml:space="preserve">
-    <value>فهرس المسار المطلوب إزالته.</value>
-  </data>
-	<data name="lyricsSummary" xml:space="preserve">
-    <value>يعرض كلمات الأغنية المحددة ، أو المسار الحالي في المشغل إذا لم يمر أي منها.</value>
-  </data>
-	<data name="lyricsParam1" xml:space="preserve">
-    <value>الأغنية للبحث في كلماتها.</value>
-  </data>
-	<data name="artworkSummary" xml:space="preserve">
-    <value>يعرض العمل الفني للمسار الحالي في المشغل.</value>
-  </data>
-	<data name="NoTracks" xml:space="preserve">
-    <value>لا مزيد من المسارات للعب.</value>
-  </data>
-	<data name="NowPlaying" xml:space="preserve">
-    <value>الان العب</value>
-  </data>
-	<data name="PlayerError" xml:space="preserve">
-    <value>حدث خطأ</value>
-  </data>
-	<data name="PlayerStuck" xml:space="preserve">
-    <value>علق اللاعب بالمسار ** {0} ** لمدة {1} ثانية.</value>
-  </data>
-	<data name="PlayerMoved" xml:space="preserve">
-    <value>تم النقل من ** {0} ** إلى ** {1} **</value>
-  </data>
-	<data name="PlayerNoMatches" xml:space="preserve">
-    <value>لم يتم العثور على تطابق. حاول استخدام وصلة يوتيوب أو ارتباط ملف MP3.</value>
-  </data>
-	<data name="PlayerPlaylistAdded" xml:space="preserve">
-    <value>تمت إضافة قائمة التشغيل ** {0} ** ({1} المسارات) ({2}) إلى قائمة الانتظار.</value>
-  </data>
-	<data name="PlayerEmptyPlaylistAdded" xml:space="preserve">
-    <value>مسارات قائمة الانتظار {0} ({1})
-
-يلعب الآن: {2}</value>
-  </data>
-	<data name="PlayerTrackAdded" xml:space="preserve">
-    <value>تمت إضافة {0} إلى قائمة الانتظار.</value>
-  </data>
-	<data name="PlayerNowPlaying" xml:space="preserve">
-    <value>قيد التشغيل الآن: {0}</value>
-  </data>
-	<data name="InvalidTrack" xml:space="preserve">
-    <value>مسار غير صالح.</value>
-  </data>
-	<data name="Replaying" xml:space="preserve">
-    <value>إعادة التشغيل {0}</value>
-  </data>
-	<data name="EmptyQueue" xml:space="preserve">
-    <value>قائمة الانتظار فارغة.</value>
-  </data>
-	<data name="PlayerNotPlaying" xml:space="preserve">
-    <value>اللاعب لا يلعب.</value>
-  </data>
-	<data name="PlayerStopped" xml:space="preserve">
-    <value>توقف اللاعب الآن.</value>
-  </data>
-	<data name="PlayerTrackSkipped" xml:space="preserve">
-    <value>تم التخطي: {0}
-
-يلعب الآن: {1}</value>
-  </data>
-	<data name="VolumeOutOfIndex" xml:space="preserve">
-    <value>استخدم رقمًا بين 2 و 150.</value>
-  </data>
-	<data name="VolumeSet" xml:space="preserve">
-    <value>تم تعيين مستوى الصوت على: {0}.</value>
-  </data>
-	<data name="PlayerPaused" xml:space="preserve">
-    <value>تم إيقاف المشغل مؤقتًا الآن.</value>
-  </data>
-	<data name="PlaybackResumed" xml:space="preserve">
-    <value>تم استئناف التشغيل.</value>
-  </data>
-	<data name="PlayerNotPaused" xml:space="preserve">
-    <value>المشغل غير متوقف مؤقتًا.</value>
-  </data>
-	<data name="CurrentlyPlaying" xml:space="preserve">
-    <value>يلعب حاليًا: {0} ({1} من {2})</value>
-  </data>
-	<data name="MusicInQueue" xml:space="preserve">
-    <value>المسارات في قائمة الانتظار:</value>
-  </data>
-	<data name="Queue1Item" xml:space="preserve">
-    <value>يوجد عنصر واحد فقط في قائمة الانتظار.</value>
-  </data>
-	<data name="QueueShuffled" xml:space="preserve">
-    <value>تم ترتيب قائمة الانتظار بشكل عشوائي.</value>
-  </data>
-	<data name="IndexOutOfRange" xml:space="preserve">
-    <value>الفهرس خارج النطاق.</value>
-  </data>
-	<data name="TrackRemoved" xml:space="preserve">
-    <value>تمت إزالة المسار {0} في الموضع {1}.</value>
-  </data>
-	<data name="LyricsNotFound" xml:space="preserve">
-    <value>لا توجد كلمات ل{0}.</value>
-  </data>
-	<data name="botgameSummary" xml:space="preserve">
-    <value>يحدد حالة لعبة البوت.</value>
-  </data>
-	<data name="botgameParam1" xml:space="preserve">
-    <value>النص المطلوب تعيينه.</value>
-  </data>
-	<data name="BotOwnerOnly" xml:space="preserve">
-    <value>مالك بوت فقط.</value>
-  </data>
-	<data name="botstatusSummary" xml:space="preserve">
-    <value>يحدد حالة البوت.</value>
-  </data>
-	<data name="botstatusParam1" xml:space="preserve">
-    <value>الحالة المطلوب ضبطها (0 - 5).</value>
-  </data>
-	<data name="codeSummary" xml:space="preserve">
-    <value>يعرض التعليمات البرمجية المصدر للر.</value>
-  </data>
-	<data name="codeParam1" xml:space="preserve">
-    <value>الأمر للحصول على رمزها.</value>
-  </data>
-	<data name="botcolorSummary" xml:space="preserve">
-    <value>لتعيين لون التضمين.</value>
-  </data>
-	<data name="botcolorParam1" xml:space="preserve">
-    <value>اللون الجديد بالنظام الست عشري أو العشري.</value>
-  </data>
-	<data name="InvalidColor" xml:space="preserve">
-    <value>اللون غير صالح.</value>
-  </data>
-	<data name="cringeSummary" xml:space="preserve">
-    <value>إخوانه قمت بنشره للتو تذلل!</value>
-  </data>
-	<data name="globalprefixSummary" xml:space="preserve">
-    <value>لتعيين البادئة العامة للبوت.</value>
-  </data>
-	<data name="globalprefixParam1" xml:space="preserve">
-    <value>البادئة العالمية الجديدة.</value>
-  </data>
-	<data name="CurrentGlobalPrefix" xml:space="preserve">
-    <value>البادئة العالمية الحالية هي:</value>
-  </data>
-	<data name="PrefixSameCurrentTarget" xml:space="preserve">
-    <value>البادئة الهدف والبادئة الحالية هي نفسها.</value>
-  </data>
-	<data name="NewGlobalPrefix" xml:space="preserve">
-    <value>البادئة عالمية جديدة هي: "{0}"</value>
-  </data>
-	<data name="inviteSummary" xml:space="preserve">
-    <value>يرسل رابط دعوة الروبوت.</value>
-  </data>
-	<data name="prefixSummary" xml:space="preserve">
-    <value>يضبط بادئة البوت.</value>
-  </data>
-	<data name="prefixParam1" xml:space="preserve">
-    <value>البادئة الجديدة.</value>
-  </data>
-	<data name="CurrentPrefix" xml:space="preserve">
-    <value>البادئة الحالية هي:</value>
-  </data>
-	<data name="NewPrefix" xml:space="preserve">
-    <value>البادئة النقابة الجديدة هي: "{0}"</value>
-  </data>
-	<data name="restartSummary" xml:space="preserve">
-    <value>إعادة تشغيل البوت.</value>
-  </data>
-	<data name="saySummary" xml:space="preserve">
-    <value>يقول شيئا.</value>
-  </data>
-	<data name="sayParam1" xml:space="preserve">
-    <value>النص ليقول.</value>
-  </data>
-	<data name="uptimeSummary" xml:space="preserve">
-    <value>يظهر وقت تشغيل البوت.</value>
-  </data>
-	<data name="languageSummary" xml:space="preserve">
-    <value>يضبط لغة البوت.</value>
-  </data>
-	<data name="NewLanguage" xml:space="preserve">
-    <value>اللغة بوت الآن: الإنجليزية
-⚠ وجاء هذا ترجمة مع جوجل ترجمة ذلك قد يكون هناك بعض الأخطاء مع النص.</value>
-  </data>
-	<data name="PaginatorFooter" xml:space="preserve">
-    <value>الصفحة {0} من {1}</value>
-  </data>
-	<data name="FailedExecution" xml:space="preserve">
-    <value>فشل في التنفيذ</value>
-  </data>
-	<data name="nowplayingSummary" xml:space="preserve">
-    <value>يحصل على المسار الحالي في المشغل.</value>
-  </data>
-	<data name="changelogSummary" xml:space="preserve">
-    <value>يظهر سجل التغيير في برنامج التتبُّع.</value>
-  </data>
-	<data name="TempDisabled" xml:space="preserve">
-    <value>معطل مؤقتا.</value>
-  </data>
-	<data name="inspirobotSummary" xml:space="preserve">
-    <value>احصل على بعض الاقتباسات الملهمة.</value>
-  </data>
-	<data name="PermissionRequired" xml:space="preserve">
-    <value>أحتاج إلى إذن `{0}` لتنفيذ هذا الأمر.</value>
-  </data>
-	<data name="ManageMessages" xml:space="preserve">
-    <value>إدارة الرسائل</value>
-  </data>
-	<data name="Connect" xml:space="preserve">
-    <value>الاتصال</value>
-  </data>
-	<data name="Speak" xml:space="preserve">
-    <value>تحدث</value>
-  </data>
-	<data name="evalSummary" xml:space="preserve">
-    <value>يقيم رمز .</value>
-  </data>
-	<data name="evalParam1" xml:space="preserve">
-    <value>رمز التقييم.</value>
-  </data>
-	<data name="ScreenshotNSFW" xml:space="preserve">
-    <value>قد تحتوي لقطة الشاشة على محتوى NSFW ولن يتم عرضها. حاول استخدام الأمر على قناة NSFW أو استخدم عنوان url آخر.</value>
-  </data>
-	<data name="BotNotConnected" xml:space="preserve">
-    <value>البوت غير متصل بقناة صوتية.</value>
-  </data>
-	<data name="CreationCanceled" xml:space="preserve">
-    <value>تم إلغاء الإنشاء.</value>
-  </data>
-	<data name="AIDungeonWelcome" xml:space="preserve">
-    <value>مرحبًا بك في AI Dungeon</value>
-  </data>
-	<data name="ModeSelect" xml:space="preserve">
-    <value>تأكد من قراءة المعلومات والأوامر باستخدام `aid info{0}` قبل المتابعة.
-
-حدد الوضع:</value>
-  </data>
-	<data name="CharacterSelect" xml:space="preserve">
-    <value>حدد شخصية</value>
-  </data>
-	<data name="AttachFiles" xml:space="preserve">
-    <value>إرفاق ملفات</value>
-  </data>
-	<data name="EvalResults" xml:space="preserve">
-    <value>نتائج التقييم</value>
-  </data>
-	<data name="Output" xml:space="preserve">
-    <value>انتاج |</value>
-  </data>
-	<data name="EvalFooter" xml:space="preserve">
-    <value>تم التنفيذ في غضون {0} مللي ثانية</value>
-  </data>
-	<data name="EvalNoReturnValue" xml:space="preserve">
-    <value>تم تنفيذ الرمز دون أي قيمة إرجاع.</value>
-  </data>
-	<data name="NSFWOnly" xml:space="preserve">
-    <value>لا يمكن استخدام هذا الأمر إلا على قناة NSFW.</value>
-  </data>
-	<data name="UserOnWait" xml:space="preserve">
-    <value>انتظر بضع ثوان قبل استخدام هذا الأمر!</value>
-  </data>
-	<data name="snipeParam1" xml:space="preserve">
-    <value>قناة محددة للقنص.</value>
-  </data>
-	<data name="IDNotFound" xml:space="preserve">
-    <value>المعرف غير موجود.</value>
-  </data>
-	<data name="IDNotPublic" xml:space="preserve">
-    <value>معرف القصة هذا ليس عامًا ولا يمكنك استخدامه. اطلب من مالك المعرّف ({0}) نشره باستخدام "makepublic &lt;ID&gt;`</value>
-  </data>
-	<data name="IDOnWait" xml:space="preserve">
-    <value>يجب الانتظار حتى يتم إنشاء القصة قبل استخدام هذا المعرّف!</value>
-  </data>
-	<data name="GeneratingNewAdventure" xml:space="preserve">
-    <value>توليد مغامرة جديدة
-مع الوضع: ** {0} **
-والشخصية: ** {1} ** ...</value>
-  </data>
-	<data name="CustomCharacterCreation" xml:space="preserve">
-    <value>إنشاء شخصية مخصصة</value>
-  </data>
-	<data name="CustomCharacterPrompt" xml:space="preserve">
-    <value>أدخل نصًا يصف هويتك وأول جملتين من حيث تبدأ.
-ستنتهي مهلة هذه المطالبة في غضون 5 دقائق.</value>
-  </data>
-	<data name="GeneratingNewCustomAdventure" xml:space="preserve">
-    <value>توليد مغامرة جديدة مع موجه مخصص ...</value>
-  </data>
-	<data name="GeneratingStory" xml:space="preserve">
-    <value>جارٍ إنشاء قصة ...</value>
-  </data>
-	<data name="EditingStoryContext" xml:space="preserve">
-    <value>تحرير سياق القصة ...</value>
-  </data>
-	<data name="TheAIWillNowRemember" xml:space="preserve">
-    <value>سيتذكر الذكاء الاصطناعي الآن:</value>
-  </data>
-	<data name="AlteringLastOutput" xml:space="preserve">
-    <value>تعديل الناتج الأخير ...</value>
-  </data>
-	<data name="ChangedLastOutput" xml:space="preserve">
-    <value>تم تغيير الإخراج الأخير إلى:</value>
-  </data>
-	<data name="NotIDOwner" xml:space="preserve">
-    <value>أنت لست مالك هذا المعرّف.</value>
-  </data>
-	<data name="IDAlreadyPublic" xml:space="preserve">
-    <value>المعرّف عام بالفعل.</value>
-  </data>
-	<data name="IDNowPublic" xml:space="preserve">
-    <value>المعرّف الآن عام ويمكن للجميع استخدامه. يمكنك إعادة تعيينه إلى خاص باستخدام `makeprivate &lt;ID&gt;`</value>
-  </data>
-	<data name="IDAlreadyPrivate" xml:space="preserve">
-    <value>المعرّف خاص بالفعل.</value>
-  </data>
-	<data name="IDNowPrivate" xml:space="preserve">
-    <value>المعرّف الآن خاص ويمكنك أنت فقط استخدامه. يمكنك إعادة تعيينه إلى "عام" باستخدام "makepublic &lt;ID&gt;`</value>
-  </data>
-	<data name="NoIDs" xml:space="preserve">
-    <value>{0} ليس لديها أي معرفات المغامرة.</value>
-  </data>
-	<data name="IDList" xml:space="preserve">
-    <value>قائمة معرفات {0}</value>
-  </data>
-	<data name="IsPublic" xml:space="preserve">
-    <value>عامة</value>
-  </data>
-	<data name="IDListFooter" xml:space="preserve">
-    <value>استخدام "idinfo &lt;ID&gt; 'للحصول على معلومات حول هوية المغامرة.</value>
-  </data>
-	<data name="IDInfo" xml:space="preserve">
-    <value>معلومات مغامرة ID</value>
-  </data>
-	<data name="AboutAIDText" xml:space="preserve">
-    <value>AI Dungeon هي أول مغامرة نصية من نوع الذكاء الاصطناعي. باستخدام نموذج تعلُّم الآلة ذي المعلمة 1.5B يسمى GPT-2 ، تُنشئ AI Dungeon قصة ونتائج أفعالك أثناء اللعب في هذا العالم الافتراضي. على عكس كل لعبة أخرى موجودة فعليًا ، فأنت لا تقتصر على خيال المطور في ما يمكنك القيام به. يمكن أن يكون أي شيء يمكنك التعبير عنه باللغة هو عملك وسوف يقرر سيد البرج المحصن AI كيف يستجيب العالم لأفعالك.</value>
-  </data>
-	<data name="AIDHowToPlayText" xml:space="preserve">
-    <value>قواعد AI Dungeon بسيطة:
-1. تضع اللعبة "أنت" في مقدمة كل عمل ، لذا يجب أن يبدأ كل فعل بفعل. على سبيل المثال "مهاجمة التنين" ، "استدعي شطيرة" إلخ ...
-2. كاستثناء لما سبق: إذا وضعت الإجراء الخاص بك بين علامتي اقتباس. "هل ستنضم إلي في سعيي؟" ثم ستضيف اللعبة "أنت تقول" إلى المقدمة.</value>
-  </data>
-	<data name="AboutAIDTitle" xml:space="preserve">
-    <value>حول AI Dungeon</value>
-  </data>
-	<data name="AIDHelp" xml:space="preserve">
-    <value>مساعدة AI Dungeon</value>
-  </data>
-	<data name="AIDHowToPlayTitle" xml:space="preserve">
-    <value>كيف ألعب</value>
-  </data>
-	<data name="Commands" xml:space="preserve">
-    <value>أوامر</value>
-  </data>
-	<data name="AIDTips" xml:space="preserve">
-    <value>حاول استخدام كلمات جديدة كثيرًا ، فالذكاء الاصطناعي يصبح أكثر إبداعًا مع التنوع.
-هل تريد المزيد من القصة لتوليدها؟ استخدم الأمر متابعة بدون أي نص.
-ابدأ العمل مع! لإيقاف اللعبة من وضع "أنت" في المقدمة. على سبيل المثال "! فجأة ظهر غول"
-حاول استخدام كلمات جديدة كثيرًا ، فالذكاء الاصطناعي يصبح أكثر إبداعًا مع التنوع.
-يمكنك استخدام الاقتباسات للتحدث ، على سبيل المثال. "دعهم و شأنهم!"
-تذكر أن تبدأ العمل الخاص بك بفعل ، على سبيل المثال: Attack the orc
-استخدم الأمر الارتداد لإرجاع الإدخال الأخير وحاول إجراء شيء آخر.
-الجمل الطويلة للأعمال ليست مشكلة! الحصول على الإبداع!
-استخدم الأمر تذكر لتحرير سياق القصة الذي يتذكره الذكاء الاصطناعي دائمًا.</value>
-  </data>
-	<data name="In" xml:space="preserve">
-    <value>في</value>
-  </data>
-	<data name="configSummary" xml:space="preserve">
-    <value>يعرض التضمين مع خيارات تكوين البوت على مستوى النقابة.</value>
-  </data>
-	<data name="AdminRequired" xml:space="preserve">
-    <value>يجب أن تكون مسؤولاً لاستخدام هذا الأمر.</value>
-  </data>
-	<data name="Loading" xml:space="preserve">
-    <value>جار التحميل...</value>
-  </data>
-	<data name="FergunConfiguration" xml:space="preserve">
-    <value>تكوين Fergun</value>
-  </data>
-	<data name="ConfigCanceled" xml:space="preserve">
-    <value>تم إلغاء التكوين.</value>
-  </data>
-	<data name="CantDoThat" xml:space="preserve">
-    <value>لا يمكنني فعل ذلك.</value>
-  </data>
-	<data name="softbanSummary" xml:space="preserve">
-    <value>Softbans مستخدم (ركلة + حذف رسائل المستخدم).</value>
-  </data>
-	<data name="softbanParam1" xml:space="preserve">
-    <value>المستخدم ل softban.</value>
-  </data>
-	<data name="softbanParam2" xml:space="preserve">
-    <value>عدد أيام حذف رسائله الأخيرة. 7 بشكل افتراضي.</value>
-  </data>
-	<data name="softbanParam3" xml:space="preserve">
-    <value>سبب softban.</value>
-  </data>
-	<data name="SoftbanSameUser" xml:space="preserve">
-    <value>ماذا؟ تريد softban نفسك؟</value>
-  </data>
-	<data name="SoftbanMyself" xml:space="preserve">
-    <value>أنا لن softban نفسي ههههههههه</value>
-  </data>
-	<data name="Softbanned" xml:space="preserve">
-    <value>تم حظر المستخدم {0}.</value>
-  </data>
-	<data name="AIDungeonCommands" xml:space="preserve">
-    <value>زنزانة AI (استخدم {0}aid &lt;command&gt;)</value>
-  </data>
-	<data name="RevertingLastAction" xml:space="preserve">
-    <value>جارٍ الرجوع إلى الإجراء الأخير ...</value>
-  </data>
-	<data name="140CharsMax" xml:space="preserve">
-    <value>140 حرفًا كحد أقصى.</value>
-  </data>
-	<data name="InviteLink" xml:space="preserve">
-    <value>رابط الدعوة</value>
-  </data>
-	<data name="NoManageMessages" xml:space="preserve">
-    <value>ستعمل بعض الأوامر بشكل أفضل مع إذن `إدارة الرسائل` (` img`، `urban`،` config`)
-</value>
-  </data>
-	<data name="InvalidText" xml:space="preserve">
-    <value>نص غير صالح.</value>
-  </data>
-	<data name="clearRemarks" xml:space="preserve">
-    <value>إذا تم تمرير مستخدم ، فسيحاول الأمر حذف جميع رسائل المستخدمين في رسائل `العدد 'الأخيرة.</value>
-  </data>
-	<data name="BanMembers" xml:space="preserve">
-    <value>حظر الأعضاء</value>
-  </data>
-	<data name="KickMembers" xml:space="preserve">
-    <value>ركلة الأعضاء</value>
-  </data>
-	<data name="UserNotBanned" xml:space="preserve">
-    <value>المستخدم غير محظور.</value>
-  </data>
-	<data name="BotMention" xml:space="preserve">
-    <value>البادئة هنا هي "{0}`. استخدم `{0} help` لرؤية قائمة الأوامر الخاصة بي و` {0} البادئة` لتغيير البادئة.</value>
-  </data>
-	<data name="NotSupportedInDM" xml:space="preserve">
-    <value>لا أعتقد أنه يمكنك استخدام ذلك هنا.</value>
-  </data>
-	<data name="tcdneSummary" xml:space="preserve">
-    <value>هذا القط غير موجود</value>
-  </data>
-	<data name="tpdneSummary" xml:space="preserve">
-    <value>هذا الشخص غير موجود</value>
-  </data>
-	<data name="YTSearchCap" xml:space="preserve">
-    <value>بلغ الحد الأقصى للبحث يوتيوب :(</value>
-  </data>
-	<data name="CreatingVideoCache" xml:space="preserve">
-    <value>شخص ما يقوم بإنشاء ذاكرة التخزين المؤقت للفيديو ، يرجى الانتظار ..</value>
-  </data>
-	<data name="User" xml:space="preserve">
-    <value>المستعمل</value>
-  </data>
-	<data name="editsnipeSummary" xml:space="preserve">
-    <value>يعرض آخر رسالة تم تحريرها في القناة الحالية.</value>
-  </data>
-	<data name="reactionSummary" xml:space="preserve">
-    <value>يضيف رد فعل على رسالة.</value>
-  </data>
-	<data name="reactionParam1" xml:space="preserve">
-    <value>رد الفعل لإضافة (واحد فقط)</value>
-  </data>
-	<data name="reactionParam2" xml:space="preserve">
-    <value>معرف الرسالة لإضافة رد فعل (يجب أن يكون من نفس القناة التي يتم تنفيذ الأمر).
-</value>
-  </data>
-	<data name="InvalidMessageID" xml:space="preserve">
-    <value>معرف الرسالة غير صالح. تأكد من أن الرسالة من هذه القناة.</value>
-  </data>
-	<data name="InvalidReaction" xml:space="preserve">
-    <value>رمز تعبيري / رمز تعبيري غير صالح.
-</value>
-  </data>
-	<data name="BadArgumentCount" xml:space="preserve">
-    <value>عدد الوسيطة غير صالح. استخدم `{0} help{1}` للحصول على مزيد من المعلومات حول الأمر.</value>
-  </data>
-	<data name="CommandParseFailed" xml:space="preserve">
-    <value>خطأ تحليل الحجج الأوامر. حجة غير صالحة أو ترتيب الحجج غير صحيح.
-استخدام {0} {1} مساعدة للحصول على مزيد من المعلومات حول الأمر.</value>
-  </data>
-	<data name="TranslationResults" xml:space="preserve">
-    <value>نتائج الترجمة
-</value>
-  </data>
-	<data name="SourceLanguage" xml:space="preserve">
-    <value>لغة المصدر (تم الكشف عنها)
-</value>
-  </data>
-	<data name="TargetLanguage" xml:space="preserve">
-    <value>اللغة الهدف
-</value>
-  </data>
-	<data name="Result" xml:space="preserve">
-    <value>نتيجة</value>
-  </data>
-	<data name="ErrorType" xml:space="preserve">
-    <value>نوع الخطأ
-</value>
-  </data>
-	<data name="ErrorMessage" xml:space="preserve">
-    <value>رسالة خطأ</value>
-  </data>
-	<data name="seekSummary" xml:space="preserve">
-    <value>يبحث عن المسار الحالي إلى الموضع المحدد.</value>
-  </data>
-	<data name="seekParam1" xml:space="preserve">
-    <value>الثانية الثانية للبحث أو وقت صالح بالتنسيق `m: ss` أو` mm: ss` أو `h: mm: ss` أو` hh: mm: ss`.</value>
-  </data>
-	<data name="CannotSeek" xml:space="preserve">
-    <value>لا يمكن البحث عن هذا المسار.</value>
-  </data>
-	<data name="SeekHigherOrEqual" xml:space="preserve">
-    <value>الثانية التي يجب الذهاب إليها ({0}) لا يمكن أن تكون أعلى أو مساوية لطول المسار ({1})</value>
-  </data>
-	<data name="SeekComplete" xml:space="preserve">
-    <value>تم التخطي إلى الثانية {0} ({1} من {2})</value>
-  </data>
-	<data name="ErrorInTranslation" xml:space="preserve">
-    <value>حدث خطأ في واجهة برمجة تطبيقات الترجمة.</value>
-  </data>
-	<data name="statsSummary" xml:space="preserve">
-    <value>يظهر احصائيات البوت.</value>
-  </data>
-	<data name="AdventureDeleted" xml:space="preserve">
-    <value>تم حذف المغامرة.</value>
-  </data>
-	<data name="CPUUsage" xml:space="preserve">
-    <value>استخدام المعالج</value>
-  </data>
-	<data name="RAMUsage" xml:space="preserve">
-    <value>استخدام ذاكرة الوصول العشوائي</value>
-  </data>
-	<data name="Library" xml:space="preserve">
-    <value>مكتبة</value>
-  </data>
-	<data name="BotVersion" xml:space="preserve">
-    <value>نسخة بوت</value>
-  </data>
-	<data name="OperatingSystem" xml:space="preserve">
-    <value>نظام التشغيل</value>
-  </data>
-	<data name="BotOwner" xml:space="preserve">
-    <value>مالك بوت</value>
-  </data>
-	<data name="CurrentNewNickEqual" xml:space="preserve">
-    <value>اللقب الحالي والنك الجديد متطابقان.</value>
-  </data>
-	<data name="Yes" xml:space="preserve">
-    <value>نعم</value>
-  </data>
-	<data name="No" xml:space="preserve">
-    <value>لا</value>
-  </data>
-	<data name="True" xml:space="preserve">
-    <value>صحيح</value>
-  </data>
-	<data name="False" xml:space="preserve">
-    <value>خاطئة</value>
-  </data>
-	<data name="ConfigList" xml:space="preserve">
-    <value>السيارات ترجمة AI الأبراج المدخلات والمخرجات
-تتبع اختيار `play`</value>
-  </data>
-	<data name="ConfigPrompt" xml:space="preserve">
-    <value>استخدام ردود الفعل لتمكين أو تعطيل خيار.</value>
-  </data>
-	<data name="FergunConfig" xml:space="preserve">
-    <value>تكوين Fergun</value>
-  </data>
-	<data name="Option" xml:space="preserve">
-    <value>اختيار</value>
-  </data>
-	<data name="Value" xml:space="preserve">
-    <value>القيمة</value>
-  </data>
-	<data name="UserBlacklisted" xml:space="preserve">
-    <value>تم إدراج المستخدم {0} في القائمة السوداء.</value>
-  </data>
-	<data name="UserBlacklistedWithReason" xml:space="preserve">
-    <value>تم إدراج المستخدم {0} في القائمة السوداء للسبب: {1}</value>
-  </data>
-	<data name="UserBlacklistRemoved" xml:space="preserve">
-    <value>تمت إزالة المستخدم {0} من القائمة السوداء.</value>
-  </data>
-	<data name="blacklistSummary" xml:space="preserve">
-    <value>يضيف مستخدمًا إلى القائمة السوداء ، أو يزيله.</value>
-  </data>
-	<data name="blacklistParam1" xml:space="preserve">
-    <value>معرف المستخدم إلى القائمة السوداء.</value>
-  </data>
-	<data name="blacklistParam2" xml:space="preserve">
-    <value>سبب يجري على القائمة السوداء.</value>
-  </data>
-	<data name="Blacklisted" xml:space="preserve">
-    <value>انت في القائمة السوداء</value>
-  </data>
-	<data name="BlacklistedWithReason" xml:space="preserve">
-    <value>أنت في القائمة السوداء للسبب: {0}</value>
-  </data>
-	<data name="MissingPermissions" xml:space="preserve">
-    <value>ليس لديك أذونات لتشغيل هذا الأمر.</value>
-  </data>
-	<data name="UserRequireBanMembers" xml:space="preserve">
-    <value>تحتاج إلى إذن `حظر الأعضاء` لاستخدام هذا الأمر.</value>
-  </data>
-	<data name="BotRequireBanMembers" xml:space="preserve">
-    <value>أحتاج إلى إذن "حظر الأعضاء" لتنفيذ هذا الأمر.</value>
-  </data>
-	<data name="UserRequireManageMessages" xml:space="preserve">
-    <value>تحتاج إلى إذن `إدارة الرسائل` لاستخدام هذا الأمر.</value>
-  </data>
-	<data name="BotRequireManageMessages" xml:space="preserve">
-    <value>أحتاج إلى إذن "إدارة الرسائل" لتنفيذ هذا الأمر.</value>
-  </data>
-	<data name="UserRequireKickMembers" xml:space="preserve">
-    <value>تحتاج إلى إذن "Kick Members" لاستخدام هذا الأمر.</value>
-  </data>
-	<data name="BotRequireKickMembers" xml:space="preserve">
-    <value>أحتاج إلى إذن "Kick Members" لتنفيذ هذا الأمر.</value>
-  </data>
-	<data name="UserRequireManageNicknames" xml:space="preserve">
-    <value>تحتاج إلى إذن "إدارة الألقاب" لاستخدام هذا الأمر.</value>
-  </data>
-	<data name="BotRequireManageNicknames" xml:space="preserve">
-    <value>معرف الرسالة غير صالح. تأكد من أن الرسالة من هذه القناة.</value>
-  </data>
-	<data name="UserNotLowerHierarchy" xml:space="preserve">
-    <value>يجب أن يكون المستخدم المحدد أقل في التسلسل الهرمي.</value>
-  </data>
-	<data name="BotRequireConnect" xml:space="preserve">
-    <value>أحتاج إلى إذن `Connect` للانضمام إلى القناة الصوتية.</value>
-  </data>
-	<data name="BotRequireSpeak" xml:space="preserve">
-    <value>أحتاج إلى إذن `Speak` للمتابعة.</value>
-  </data>
-	<data name="MoveSameChannel" xml:space="preserve">
-    <value>انتقل إلى القناة الصوتية التي تريد مني نقلها.</value>
-  </data>
-	<data name="ProcessingTime" xml:space="preserve">
-    <value>وقت المعالجة: {0} مللي ثانية</value>
-  </data>
-	<data name="OcrResults" xml:space="preserve">
-    <value>نتائج التعرف الضوئي على الحروف</value>
-  </data>
-	<data name="BotRequireAttachFiles" xml:space="preserve">
-    <value>أحتاج إلى إذن "إرفاق ملفات" لتنفيذ هذا الأمر.</value>
-  </data>
-	<data name="OcrApiError" xml:space="preserve">
-    <value>خطأ في OCR API.</value>
-  </data>
-	<data name="forceprefixSummary" xml:space="preserve">
-    <value>يفرض البادئة على هذه النقابة.</value>
-  </data>
-	<data name="FirstTip" xml:space="preserve">
-    <value>استخدم {0} help Continue &lt;ID&gt; [text] لمتابعة قصتك.</value>
-  </data>
-	<data name="PrefixTooLarge" xml:space="preserve">
-    <value>{البادئة كبيرة جدا. الحد الأقصى. طول البادئة هو: {0.</value>
-  </data>
-	<data name="InvalidUrl" xml:space="preserve">
-    <value>URL غير صالح.</value>
-  </data>
-	<data name="ocrtranslateSummary" xml:space="preserve">
-    <value>OCR وترجمة.</value>
-  </data>
-	<data name="ocrtranslateParam1" xml:space="preserve">
-    <value>اللغة الهدف في كود ISO (en ، es ، br ، إلخ.)</value>
-  </data>
-	<data name="ocrtranslateParam2" xml:space="preserve">
-    <value>عنوان URL للصورة المراد استخدامها.</value>
-  </data>
-	<data name="Input" xml:space="preserve">
-    <value>إدخال</value>
-  </data>
-	<data name="RoleNotFound" xml:space="preserve">
-    <value>الدور غير موجود.</value>
-  </data>
-	<data name="RoleInfo" xml:space="preserve">
-    <value>معلومات الدور</value>
-  </data>
-	<data name="Color" xml:space="preserve">
-    <value>اللون</value>
-  </data>
-	<data name="IsMentionable" xml:space="preserve">
-    <value>هل يذكر</value>
-  </data>
-	<data name="Permissions" xml:space="preserve">
-    <value>أذونات</value>
-  </data>
-	<data name="MemberCount" xml:space="preserve">
-    <value>عدد الأعضاء</value>
-  </data>
-	<data name="Mention" xml:space="preserve">
-    <value>أشير</value>
-  </data>
-	<data name="roleinfoSummary" xml:space="preserve">
-    <value>يحصل على معلومات حول دور.</value>
-  </data>
-	<data name="roleinfoParam1" xml:space="preserve">
-    <value>دور الحصول على معلومات من (ليس من الضروري ذكرها).</value>
-  </data>
-	<data name="logoutSummary" xml:space="preserve">
-    <value>يفصل البوت.</value>
-  </data>
-	<data name="nothingSummary" xml:space="preserve">
-    <value>أفضل أمر.</value>
-  </data>
-	<data name="someoneSummary" xml:space="preserve">
-    <value>إرجاع مستخدم عشوائي.</value>
-  </data>
-	<data name="ActiveClients" xml:space="preserve">
-    <value>عملاء نشيطون</value>
-  </data>
-	<data name="Low" xml:space="preserve">
-    <value>منخفض</value>
-  </data>
-	<data name="Medium" xml:space="preserve">
-    <value>متوسط</value>
-  </data>
-	<data name="High" xml:space="preserve">
-    <value>عالي</value>
-  </data>
-	<data name="Extreme" xml:space="preserve">
-    <value>أقصى</value>
-  </data>
-	<data name="IsHoisted" xml:space="preserve">
-    <value>يرفع</value>
-  </data>
-	<data name="Position" xml:space="preserve">
-    <value>موضع</value>
-  </data>
-	<data name="ServerFeatures" xml:space="preserve">
-    <value>ميزات</value>
-  </data>
-	<data name="BoostTier" xml:space="preserve">
-    <value>طبقة Nitro Boost</value>
-  </data>
-	<data name="BoostCount" xml:space="preserve">
-    <value>عد دفعة نيترو</value>
-  </data>
-	<data name="SeekTimeHigherOrEqual" xml:space="preserve">
-    <value>لا يمكن أن يكون الوقت المستغرق ({0}) أعلى أو مساويًا لطول المسار ({1})</value>
-  </data>
-	<data name="SeekTimeComplete" xml:space="preserve">
-    <value>تم التخطي إلى {0} من {1}</value>
-  </data>
-	<data name="SeekInvalidFormat" xml:space="preserve">
-    <value>يجب عليك تمرير عدد صحيح أو سلسلة بالتنسيق `m: ss` أو` mm: ss` أو `h: mm: ss` أو` hh: mm: ss`.</value>
-  </data>
-	<data name="Ratelimited" xml:space="preserve">
-    <value>انتظر {0} ثانية قبل استخدام هذا الأمر مرة أخرى!</value>
-  </data>
-	<data name="ObjectNotFound" xml:space="preserve">
-    <value>المفعول به غير موجود.</value>
-  </data>
-	<data name="MultipleMatches" xml:space="preserve">
-    <value>تم العثور على تطابقات متعددة.
-حاول استخدام علامة ({0}) أو إشارة ({1}) أو معرّف ({2}).</value>
-  </data>
-	<data name="Roles" xml:space="preserve">
-    <value>الأدوار</value>
-  </data>
-	<data name="BoostingSince" xml:space="preserve">
-    <value>تعزيز منذ ذلك الحين</value>
-  </data>
-	<data name="AlreadyConnected" xml:space="preserve">
-    <value>البوت متصل بالفعل بقناة صوتية.</value>
-  </data>
-	<data name="InvalidFileType" xml:space="preserve">
-    <value>نوع الملف غير صالح.</value>
-  </data>
-	<data name="OwnerCommands" xml:space="preserve">
-    <value>أوامر المالك</value>
-  </data>
-	<data name="Vote" xml:space="preserve">
-    <value>يمكنك التصويت بالنسبة لي [هنا] ({0}). شكرا لك.</value>
-  </data>
-	<data name="voteSummary" xml:space="preserve">
-    <value>يظهر على تضمين مع رابط التصويت.</value>
-  </data>
-	<data name="TotalServers" xml:space="preserve">
-    <value>مجموع خوادم</value>
-  </data>
-	<data name="TotalUsers" xml:space="preserve">
-    <value>عدد الأعضاء</value>
-  </data>
-	<data name="ContactInfo" xml:space="preserve">
-    <value>إذا كان لديك أي تقرير خطأ أو سؤال أو اقتراح ، يمكنك الانضمام إلى [خادم الدعم] ({0}) أو الاتصال بي عبر رسالة مباشرة ({1}).</value>
-  </data>
-	<data name="RedoingLastAction" xml:space="preserve">
-    <value>الإعادة الإجراء الأخير ...</value>
-  </data>
-	<data name="SelfNoIDs" xml:space="preserve">
-    <value>لم يكن لديك أي معرفات المغامرة.
-يمكنك استخدام قيادة new لخلق مغامرة جديدة.</value>
-  </data>
-	<data name="QueueExcess" xml:space="preserve">
-    <value>.. و {0} أكثر المسارات!</value>
-  </data>
-	<data name="ErrorHelp" xml:space="preserve">
-    <value>في حالة استمرار حدوث ذلك ، يرجى الإبلاغ عن هذا الخطأ في [خادم الدعم]({0}) أو فتح مشكلة في [مستودع GitHub]({1}) تصف الخطأ وخطوات إعادة إنتاجه.</value>
-  </data>
-	<data name="bashSummary" xml:space="preserve">
-    <value>يدير أمر باش.</value>
-  </data>
-	<data name="supportSummary" xml:space="preserve">
-    <value>يظهر معلومات الدعم.</value>
-  </data>
-	<data name="ErrorInAPI" xml:space="preserve">
-    <value>حدث خطأ أثناء استدعاء API.</value>
-  </data>
-	<data name="Topic" xml:space="preserve">
-    <value>موضوع</value>
-  </data>
-	<data name="IsNSFW" xml:space="preserve">
-    <value>غير NSFW</value>
-  </data>
-	<data name="SlowMode" xml:space="preserve">
-    <value>وضع بطيء</value>
-  </data>
-	<data name="ChannelInfo" xml:space="preserve">
-    <value>معلومات القناة</value>
-  </data>
-	<data name="channelinfoSummary" xml:space="preserve">
-    <value>يظهر من المعلومات حول القناة.</value>
-  </data>
-	<data name="OcrtrResults" xml:space="preserve">
-    <value>OCR ونتائج الترجمة</value>
-  </data>
-	<data name="NoChoices" xml:space="preserve">
-    <value>تحتاج إلى تمرير لا يقل عن 1 الاختيار.</value>
-  </data>
-	<data name="ChannelNotFound" xml:space="preserve">
-    <value>قناة غير موجود.</value>
-  </data>
-	<data name="channelinfoParam1" xml:space="preserve">
-    <value>القناة للحصول على معلومات من.</value>
-  </data>
-	<data name="UserRequireManageServer" xml:space="preserve">
-    <value>كنت في حاجة إلى إدارة Server إذن لاستخدام هذا الأمر.
-
-</value>
-  </data>
-	<data name="badtranslatorSummary" xml:space="preserve">
-    <value>يمر النص من خلال مترجم سيئة.</value>
-  </data>
-	<data name="badtranslatorParam1" xml:space="preserve">
-    <value>النص للاستخدام.</value>
-  </data>
-	<data name="LanguageChain" xml:space="preserve">
-    <value>سلسلة لغة</value>
-  </data>
-	<data name="VersionNotFound" xml:space="preserve">
-    <value> نسخة غير موجود. الإصدارات هي: {0}</value>
-  </data>
-	<data name="OtherVersions" xml:space="preserve">
-    <value>إصدارات أخرى: {0}</value>
-  </data>
-	<data name="DeletedMessages" xml:space="preserve">
-    <value>حذف {0} رسائل.</value>
-  </data>
-	<data name="DeletedMessagesByUser" xml:space="preserve">
-    <value>حذف {0} رسائل كتبها {1}.</value>
-  </data>
-	<data name="calcSummary" xml:space="preserve">
-    <value>يقيم التعبير الرياضيات.</value>
-  </data>
-	<data name="calcParam1" xml:space="preserve">
-    <value>التعبير لتقييم.</value>
-  </data>
-	<data name="InvalidExpression" xml:space="preserve">
-    <value>التعبير صالح.</value>
-  </data>
-	<data name="CalcResults" xml:space="preserve">
-    <value>النتائج احسب</value>
-  </data>
-	<data name="LoopUpdated" xml:space="preserve">
-    <value>تم تحديث لاعب لتكرار المسار {0} مرات. استخدام هذا الأمر بدون معلمات لتعطيل حلقات المسار.</value>
-  </data>
-	<data name="LoopNoValuePassed" xml:space="preserve">
-    <value>تمرير عدد من مرة تريد تكرار المسار، مثلا:
-`{0}loop 5`</value>
-  </data>
-	<data name="NowLooping" xml:space="preserve">
-    <value>سيتم تكرار المسار الحالي الآن {0} مرة.
-لتعطيل الحلقة ، استخدم هذا الأمر بدون معلمات.</value>
-  </data>
-	<data name="LoopEnded" xml:space="preserve">
-    <value>انتهت {0}: في حلقات لمسار.</value>
-  </data>
-	<data name="loopSummary" xml:space="preserve">
-    <value>يكرر الحالية تتبع عددا من المرات.</value>
-  </data>
-	<data name="loopParam1" xml:space="preserve">
-    <value>عدد مرات تكرار المسار.</value>
-  </data>
-	<data name="LoopDisabled" xml:space="preserve">
-    <value>تم تعطيل حلقات المسار.</value>
-  </data>
-	<data name="LyricsQueryNotPassed" xml:space="preserve">
-    <value>تحتاج إلى تشغيل مسار أو تمرير نام المسار</value>
-  </data>
-	<data name="LyricsByGenius" xml:space="preserve">
-    <value>كلمات عبقرية</value>
-  </data>
-	<data name="PaginatorHelp" xml:space="preserve">
-    <value>هذا هو paginator. يتفاعل مع الرموز المعنية لصفحة تغيير</value>
-  </data>
-	<data name="ArtistPage" xml:space="preserve">
-    <value>صفحة الفنان</value>
-  </data>
-	<data name="AdventureDeletionPrompt" xml:space="preserve">
-    <value>اضغط على الزر / رمز تعبيري أدناه لحذف المغامرة.</value>
-  </data>
-	<data name="ReactTimeout" xml:space="preserve">
-    <value>أنت لم تتفاعل قبل المهلة!</value>
-  </data>
-	<data name="LanguageSelection" xml:space="preserve">
-    <value>اختيار اللغة</value>
-  </data>
-	<data name="LanguagePrompt" xml:space="preserve">
-    <value>حدد اللغة التي تريد تعيينها.</value>
-  </data>
-	<data name="lyricsRemarks" xml:space="preserve">
-    <value>استخدام -headers في نهاية الأمر للحفاظ على العلامات ( [المعلومات] ).</value>
-  </data>
-	<data name="HelpFooter2" xml:space="preserve">
-    <value>&lt;&gt; = إلزامية | [] = اختياري | لم تكتب هذه الرموز عند كتابة الأمر.</value>
-  </data>
-	<data name="invertSummary" xml:space="preserve">
-    <value>يحول (يلغي) صورة.</value>
-  </data>
-	<data name="invertParam1" xml:space="preserve">
-    <value>عنوان URL للصورة المراد استخدامها.</value>
-  </data>
-	<data name="MessagesOlderThan2Weeks" xml:space="preserve">
-    <value>يجب أن تكون رسائل الأصغر من أسبوعين.</value>
-  </data>
-	<data name="SomeMessagesNotDeleted" xml:space="preserve">
-    <value>لا يمكن حذف {0} رسائل لأنهم هم من كبار السن من أسبوعين.</value>
-  </data>
-	<data name="GuildNotFound" xml:space="preserve">
-    <value>لم يتم العثور على الخادم.</value>
-  </data>
-	<data name="ErrorParsingLyrics" xml:space="preserve">
-    <value>حدث خطأ أثناء تحليل كلمات ل{0}.
-ربما انها مفيدة؟</value>
-  </data>
-	<data name="MustBeLowerThan" xml:space="preserve">
-    <value>المعلمة  `{0}` يجب أن يكون أقل من {1}.</value>
-  </data>
-	<data name="InvalidID" xml:space="preserve">
-    <value>بطاقة التعريف غير صالحة.</value>
-  </data>
-	<data name="HastebinLink" xml:space="preserve">
-    <value>رابط Hastebin</value>
-  </data>
-	<data name="NotAvailable" xml:space="preserve">
-    <value>غير متوفر.</value>
-  </data>
-	<data name="WaitingQueue" xml:space="preserve">
-    <value>في انتظار تفريغ قائمة الانتظار ...</value>
-  </data>
-	<data name="NewOutputPrompt" xml:space="preserve">
-    <value>أدخل النص الجديد</value>
-  </data>
-	<data name="GeneratingNewResponse" xml:space="preserve">
-    <value>جارٍ إنشاء رد جديد ...</value>
-  </data>
-	<data name="InputTypes" xml:space="preserve">
-    <value>خيارات الإدخال</value>
-  </data>
-	<data name="InputTypesList" xml:space="preserve">
-    <value>افعل: الإجراء الذي تريد القيام به في القصة. ابدأ دائمًا بإجراء ، على سبيل المثال. "البحث عن الكنز المخفي". هذا هو الخيار الافتراضي إذا لم يتم الكشف عن أي شخص.
-
-قل: يمكنك استخدام هذا للتحدث ، على سبيل المثال. "دعهم و شأنهم!"
-
-القصة: يمنع هذا الإدخال AI من وضع "أنت" أمام إدخالك.</value>
-  </data>
-	<data name="ChannelCount" xml:space="preserve">
-    <value>عدد قناة</value>
-  </data>
-	<data name="Region" xml:space="preserve">
-    <value>منطقة</value>
-  </data>
-	<data name="DefaultChannel" xml:space="preserve">
-    <value>القناة الإفتراضية</value>
-  </data>
-	<data name="LanguageNotFound" xml:space="preserve">
-    <value>لم يتم العثور على اللغة.</value>
-  </data>
-	<data name="CategoryCount" xml:space="preserve">
-    <value>الفئة العد</value>
-  </data>
-	<data name="DumpingAdventure" xml:space="preserve">
-    <value>إلقاء المغامرة...</value>
-  </data>
-	<data name="EditCanceled" xml:space="preserve">
-    <value>تحرير إلغاؤها.</value>
-  </data>
-	<data name="NothingToDelete" xml:space="preserve">
-    <value>ليس هناك شيء لحذفه.</value>
-  </data>
-	<data name="Members" xml:space="preserve">
-    <value>أفراد</value>
-  </data>
-	<data name="Online" xml:space="preserve">
-    <value>عبر الانترنت</value>
-  </data>
-	<data name="Idle" xml:space="preserve">
-    <value>خامل</value>
-  </data>
-	<data name="DnD" xml:space="preserve">
-    <value>السحب والإفلات</value>
-  </data>
-	<data name="Offline" xml:space="preserve">
-    <value>غير متصل على الانترنت</value>
-  </data>
-	<data name="aidinfoSummary" xml:space="preserve">
-    <value>يظهر من المعلومات AI الأبراج.</value>
-  </data>
-	<data name="aidnewSummary" xml:space="preserve">
-    <value>يخلق مغامرة جديدة.</value>
-  </data>
-	<data name="continueSummary" xml:space="preserve">
-    <value>يواصل المغامرة مع نص المقدمة. إذا كان يتم تمرير أي نص، فإن منظمة العفو الدولية توليد القصة.</value>
-  </data>
-	<data name="continueParam1" xml:space="preserve">
-    <value>معرف المغامرة.</value>
-  </data>
-	<data name="continueParam2" xml:space="preserve">
-    <value>النص للاستخدام.</value>
-  </data>
-	<data name="undoSummary" xml:space="preserve">
-    <value>التراجع عن الإجراء الأخير.</value>
-  </data>
-	<data name="undoParam1" xml:space="preserve">
-    <value>معرف المغامرة.</value>
-  </data>
-	<data name="redoSummary" xml:space="preserve">
-    <value>Redoes العمل التراجع الماضي.</value>
-  </data>
-	<data name="redoParam1" xml:space="preserve">
-    <value>معرف المغامرة.</value>
-  </data>
-	<data name="rememberSummary" xml:space="preserve">
-    <value>ويضيف النص إلى إطار الذاكرة.</value>
-  </data>
-	<data name="rememberParam1" xml:space="preserve">
-    <value>معرف المغامرة.</value>
-  </data>
-	<data name="rememberParam2" xml:space="preserve">
-    <value>النص لإضافة.</value>
-  </data>
-	<data name="alterSummary" xml:space="preserve">
-    <value>التعديلات استجابة الماضية.</value>
-  </data>
-	<data name="alterParam1" xml:space="preserve">
-    <value>معرف المغامرة.</value>
-  </data>
-	<data name="retrySummary" xml:space="preserve">
-    <value>عمليات إعادة الإجراء الأخير ويولد استجابة جديدة.</value>
-  </data>
-	<data name="retryParam1" xml:space="preserve">
-    <value>معرف المغامرة.</value>
-  </data>
-	<data name="makepublicSummary" xml:space="preserve">
-    <value>يجعل الجمهور ID.</value>
-  </data>
-	<data name="makepublicParam1" xml:space="preserve">
-    <value>مغامرة IF. عليك أن تملك هذا الرقم.</value>
-  </data>
-	<data name="makeprivateSummary" xml:space="preserve">
-    <value>يجعل الخاص ID.</value>
-  </data>
-	<data name="makeprivateParam1" xml:space="preserve">
-    <value>مغامرة IF. عليك أن تملك هذا الرقم.</value>
-  </data>
-	<data name="idlistSummary" xml:space="preserve">
-    <value>يحصل على قائمة معرف مستخدم.</value>
-  </data>
-	<data name="idlistParam1" xml:space="preserve">
-    <value>للمستخدم الحصول على معرفات لها.</value>
-  </data>
-	<data name="idinfoSummary" xml:space="preserve">
-    <value>يظهر من المعلومات حول هوية.</value>
-  </data>
-	<data name="idinfoParam1" xml:space="preserve">
-    <value>معرف المغامرة.</value>
-  </data>
-	<data name="deleteSummary" xml:space="preserve">
-    <value>يزيل معرف.</value>
-  </data>
-	<data name="deleteParam1" xml:space="preserve">
-    <value>معرف المغامرة.</value>
-  </data>
-	<data name="dumpSummary" xml:space="preserve">
-    <value>مقالب عن النص من معرف.</value>
-  </data>
-	<data name="dumpParam1" xml:space="preserve">
-    <value>معرف المغامرة.</value>
-  </data>
-	<data name="RatelimitUses" xml:space="preserve">
-    <value>يستخدم {0} كل {1}</value>
-  </data>
-	<data name="Requirements" xml:space="preserve">
-    <value>المتطلبات</value>
-  </data>
-	<data name="cmdstatsSummary" xml:space="preserve">
-    <value>تبين الإحصاءات الأوامر.</value>
-  </data>
-	<data name="CommandStatsInfo" xml:space="preserve">
-    <value>إحصائيات الأوامر (منذ {0})</value>
-  </data>
-	<data name="lmgtfySummary" xml:space="preserve">
-    <value>اسمحوا لي أن جوجل ذلك لك.</value>
-  </data>
-	<data name="lmgtfyParam1" xml:space="preserve">
-    <value>الكلمة (الكلمات) الرئيسية للبحث.</value>
-  </data>
-	<data name="pasteSummary" xml:space="preserve">
-    <value>تحميل النص إلى Hastebin.</value>
-  </data>
-	<data name="pasteParam1" xml:space="preserve">
-    <value>النص للتحميل.</value>
-  </data>
-	<data name="Uploading" xml:space="preserve">
-    <value>تحميل...</value>
-  </data>
-	<data name="bigsnipeSummary" xml:space="preserve">
-    <value>يظهر آخر حذف الرسائل في القناة الحالية أو المحدد.</value>
-  </data>
-	<data name="bigsnipeParam1" xml:space="preserve">
-    <value>قناة خاصة لقنص.</value>
-  </data>
-	<data name="MinutesAgo" xml:space="preserve">
-    <value>{0} دقيقة</value>
-  </data>
-	<data name="Attachment" xml:space="preserve">
-    <value>المرفق</value>
-  </data>
-	<data name="bigeditsnipeSummary" xml:space="preserve">
-    <value>يظهر الرسائل التي تم تحريرها مشاركة في القناة الحالية أو المحدد.</value>
-  </data>
-	<data name="bigeditsnipeParam1" xml:space="preserve">
-    <value>قناة خاصة لقنص.</value>
-  </data>
-	<data name="TextChannel" xml:space="preserve">
-    <value>قناة النص</value>
-  </data>
-	<data name="AnnouncementChannel" xml:space="preserve">
-    <value>قناة إعلان</value>
-  </data>
-	<data name="VoiceChannel" xml:space="preserve">
-    <value>قناة صوت</value>
-  </data>
-	<data name="DMChannel" xml:space="preserve">
-    <value>قناة DM</value>
-  </data>
-	<data name="Bitrate" xml:space="preserve">
-    <value>معدل البت</value>
-  </data>
-	<data name="UserLimit" xml:space="preserve">
-    <value>حد المستخدم</value>
-  </data>
-	<data name="NoLimit" xml:space="preserve">
-    <value>لا حدود</value>
-  </data>
-	<data name="disableSummary" xml:space="preserve">
-    <value>تعطيل أمر محليا (لهذا الملقم).</value>
-  </data>
-	<data name="disableParam1" xml:space="preserve">
-    <value>اسم الأمر إلى تعطيل.</value>
-  </data>
-	<data name="NonDisableable" xml:space="preserve">
-    <value>{0} لا يمكن تعطيل!</value>
-  </data>
-	<data name="AlreadyDisabled" xml:space="preserve">
-    <value>{0} تم تعطيل بالفعل!</value>
-  </data>
-	<data name="CommandDisabled" xml:space="preserve">
-    <value>تم تعطيل الأمر {0} في هذا الخادم.</value>
-  </data>
-	<data name="enableSummary" xml:space="preserve">
-    <value>تمكن أمر محليا (لهذا الملقم).</value>
-  </data>
-	<data name="enableParam1" xml:space="preserve">
-    <value>اسم الأمر إلى تمكين.</value>
-  </data>
-	<data name="AlreadyEnabled" xml:space="preserve">
-    <value>{0} تمكين بالفعل!</value>
-  </data>
-	<data name="CommandEnabled" xml:space="preserve">
-    <value>تم تمكين الأمر {0} في هذا الخادم.$$</value>
-  </data>
-	<data name="globaldisableSummary" xml:space="preserve">
-    <value>تعطيل على مستوى العالم الأمر (في كافة ملقمات).$$</value>
-  </data>
-	<data name="globaldisableParam1" xml:space="preserve">
-    <value>اسم الأمر لتعطيل عالميا.</value>
-  </data>
-	<data name="globaldisableParam2" xml:space="preserve">
-    <value>السبب للاستخدام.</value>
-  </data>
-	<data name="AlreadyDisabledGlobally" xml:space="preserve">
-    <value>{0} تم تعطيل بالفعل على مستوى العالم!</value>
-  </data>
-	<data name="CommandDisabledGlobally" xml:space="preserve">
-    <value>تم تعطيل الأمر {0} في كافة الملقمات.</value>
-  </data>
-	<data name="globalenableSummary" xml:space="preserve">
-    <value>تمكن عالميا الأمر (في كافة ملقمات).</value>
-  </data>
-	<data name="globalenableParam1" xml:space="preserve">
-    <value>اسم الأمر لتمكين عالميا.</value>
-  </data>
-	<data name="AlreadyEnabledGlobally" xml:space="preserve">
-    <value>{0} تمكين بالفعل على مستوى العالم!</value>
-  </data>
-	<data name="CommandEnabledGlobally" xml:space="preserve">
-    <value>تم تمكين الأمر {0} في كافة الملقمات.</value>
-  </data>
-	<data name="Reason" xml:space="preserve">
-    <value>السبب</value>
-  </data>
-	<data name="LeftVCInactivity" xml:space="preserve">
-		<value>لقد تركت قناة صوت بسبب عدم النشاط.</value>
-	</data>
-	<data name="MusicPlayerShutdownWarning" xml:space="preserve">
-		<value>سيتم إعادة تشغيل Fergun / إيقاف في بعض ثوان، ومشغل الموسيقى الخاص بك سيتم إيقاف. آسف للإزعاج.</value>
-	</data>
-	<data name="Warning" xml:space="preserve">
-		<value>تحذير</value>
-	</data>
-	<data name="PublicIdNull" xml:space="preserve">
-		<value>معرف العام من هذه المغامرة هو باطل. يرجى إنشاء مغامرة جديدة.</value>
-	</data>
-	<data name="giveSummary" xml:space="preserve">
-		<value>يعطي (ينقل) المعرف المحدد للمستخدم.</value>
-	</data>
-	<data name="giveParam1" xml:space="preserve">
-		<value>معرف المغامرة.</value>
-	</data>
-	<data name="giveParam2" xml:space="preserve">
-		<value>يعطي المستخدم المعرف.</value>
-	</data>
-	<data name="CannotGiveYourself" xml:space="preserve">
-		<value>لا يمكنك إعطاء الهوية بنفسك!</value>
-	</data>
-	<data name="GaveId" xml:space="preserve">
-		<value>تم نقل المعرف بنجاح إلى {0}.</value>
-	</data>
-	<data name="Invite" xml:space="preserve">
-		<value>يدعو</value>
-	</data>
-	<data name="TopGGBotPage" xml:space="preserve">
-		<value>صفحة Top.GG Bot</value>
-	</data>
-	<data name="VoteLink" xml:space="preserve">
-		<value>رابط التصويت</value>
-	</data>
-	<data name="SupportServer" xml:space="preserve">
-		<value>خادم الدعم</value>
-	</data>
-	<data name="ContactInfoNoServer" xml:space="preserve">
-		<value>إذا كان لديك أي تقرير خطأ أو سؤال أو اقتراح ، يمكنك الاتصال بي عبر رسالة مباشرة ({0}).</value>
-	</data>
-	<data name="ValueNotSetInConfig" xml:space="preserve">
-		<value>لم يتم تحديد قيمة `{0}` في التهيئة. إذا استمرت هذه المشكلة ، فاتصل بالمطورين.</value>
-	</data>
-	<data name="CannotGiveToBot" xml:space="preserve">
-		<value>لا يمكنك إعطاء المعرف لروبوت!</value>
-	</data>
-	<data name="NoAvailableLanguages" xml:space="preserve">
-		<value>يبدو أنه لا توجد لغات متاحة.</value>
-	</data>
-	<data name="RequestTimedOut" xml:space="preserve">
-		<value>الطلب منتهي المدة.</value>
-	</data>
-	<data name="DiscordServerError" xml:space="preserve">
-		<value>خطأ في خوادم الخلاف</value>
-	</data>
-	<data name="DiscordServerErrorInfo" xml:space="preserve">
-		<value>فشل الأمر نظرا لوجود مشكلة على جانب الخلاف في.
-الرجاء معاودة المحاولة في وقت لاحق.</value>
-	</data>
-	<data name="ErrorDetails" xml:space="preserve">
-		<value>تفاصيل الخطأ</value>
-	</data>
-	<data name="NowhereToVote" xml:space="preserve">
-		<value>لا يوجد مكان للتصويت.</value>
-	</data>
-	<data name="LavalinkNotConnected" xml:space="preserve">
-		<value>تعذر الاتصال بخادم Lavalink. الرجاء المحاولة مرة أخرى لاحقاً.</value>
-	</data>
-	<data name="ServerBlacklisted" xml:space="preserve">
-		<value>تم وضع الخادم {0} في القائمة السوداء.</value>
-	</data>
-	<data name="ServerBlacklistedWithReason" xml:space="preserve">
-		<value>تم وضع الخادم {0} في القائمة السوداء للسبب: {1}</value>
-	</data>
-	<data name="ServerBlacklistRemoved" xml:space="preserve">
-		<value>تمت إزالة الخادم {0} من القائمة السوداء.</value>
-	</data>
-	<data name="blacklistserverSummary" xml:space="preserve">
-		<value>يضيف خادمًا إلى القائمة السوداء أو يزيله.</value>
-	</data>
-	<data name="blacklistserverParam1" xml:space="preserve">
-		<value>معرف الخادم إلى القائمة السوداء.</value>
-	</data>
-	<data name="blacklistserverParam2" xml:space="preserve">
-		<value>سبب إدراجها في القائمة السوداء.</value>
-	</data>
-	<data name="NoPresenceIntent" xml:space="preserve">
-		<value>لا يمكن الحصول على حالة Spotify لأنني لا أمتلك: Guild Presences Intent.</value>
-	</data>
-	<data name="NoSpotifyStatus" xml:space="preserve">
-		<value>لم يتم العثور على حالة Spotify للمستخدم {0}.</value>
-	</data>
-	<data name="ClickHere" xml:space="preserve">
-		<value>انقر هنا</value>
-	</data>
-	<data name="Title" xml:space="preserve">
-		<value>عنوان</value>
-	</data>
-	<data name="Artists" xml:space="preserve">
-		<value>الفنانين</value>
-	</data>
-	<data name="Album" xml:space="preserve">
-		<value>البوم</value>
-	</data>
-	<data name="Duration" xml:space="preserve">
-		<value>المدة الزمنية</value>
-	</data>
-	<data name="Lyrics" xml:space="preserve">
-		<value>كلمات الاغنية</value>
-	</data>
-	<data name="TrackUrl" xml:space="preserve">
-		<value>تتبع عنوان Url</value>
-	</data>
-	<data name="spotifySummary" xml:space="preserve">
-		<value>يحصل على معلومات حالة Spotify لمستخدم.</value>
-	</data>
-	<data name="spotifyParam1" xml:space="preserve">
-		<value>يحصل المستخدم على معلومات حالة Spotify الخاصة به.</value>
-	</data>
-	<data name="wolframalphaSummary" xml:space="preserve">
-		<value>يحصل على استجابة من Wolfram | Alpha بناءً على الاستعلام ،</value>
-	</data>
-	<data name="wolframalphaParam1" xml:space="preserve">
-		<value>الاستعلام المراد إرساله.</value>
-	</data>
-	<data name="defineSummary" xml:space="preserve">
-		<value>يحصل على تعريفات للكلمة.</value>
-	</data>
-	<data name="defineParam1" xml:space="preserve">
-		<value>كلمة البحث.</value>
-	</data>
-	<data name="Word" xml:space="preserve">
-		<value>كلمة</value>
-	</data>
-	<data name="Definition" xml:space="preserve">
-		<value>تعريف</value>
-	</data>
-	<data name="Synonyms" xml:space="preserve">
-		<value>المرادفات</value>
-	</data>
-	<data name="Antonyms" xml:space="preserve">
-		<value>التضاد</value>
-	</data>
-	<data name="archiveParam1" xml:space="preserve">
-		<value>عنوان Url الخاص بالموقع لأخذ لقطة شاشة.</value>
-	</data>
-	<data name="archiveParam2" xml:space="preserve">
-		<value>طابع زمني بالتنسيق المحدد.</value>
-	</data>
-	<data name="TimestampFormat" xml:space="preserve">
-		<value>يجب أن يكون الطابع الزمني بتنسيق `YYYYMMDDhhmmss` ، حيث يكون `YYYY `مطلوبًا وتكون القيم الأخرى اختيارية.</value>
-	</data>
-	<data name="InvalidTimestamp" xml:space="preserve">
-		<value>الطابع الزمني غير صالح.</value>
-	</data>
-	<data name="NoSnapshots" xml:space="preserve">
-		<value>لم يتم العثور على لقطات لعنوان url والطابع الزمني المحددين.</value>
-	</data>
-	<data name="archiveSummary" xml:space="preserve">
-		<value>الحصول على لقطة شاشة لموقع ويب في السنة / الشهر / اليوم المحدد باستخدام طابع زمني.</value>
-	</data>
-	<data name="Timestamp" xml:space="preserve">
-		<value>الطابع الزمني</value>
-	</data>
-	<data name="CouldNotFindLine" xml:space="preserve">
-		<value>تعذر العثور على سطر أسلوب الأوامر.</value>
-	</data>
-	<data name="shortenSummary" xml:space="preserve">
-		<value>تقصير عنوان Url.</value>
-	</data>
-	<data name="shortenParam1" xml:space="preserve">
-		<value>عنوان Url المطلوب اختصاره.</value>
-	</data>
-	<data name="Badges" xml:space="preserve">
-		<value>شارات</value>
-	</data>
-	<data name="img2Summary" xml:space="preserve">
-		<value>يبحث عن الصور باستخدام DuckDuckGo.</value>
-	</data>
-	<data name="img2Param1" xml:space="preserve">
-		<value>الكلمة (الكلمات) الرئيسية للبحث.</value>
-	</data>
-	<data name="privacySummary" xml:space="preserve">
-		<value>يعرض سياسة الخصوصية وتكوين الخصوصية.</value>
-	</data>
-	<data name="PrivacyPolicy" xml:space="preserve">
-		<value>سياسة خاصة</value>
-	</data>
-	<data name="WhatDataWeCollect" xml:space="preserve">
-		<value>ما البيانات التي نجمعها</value>
-	</data>
-	<data name="WhatDataWeCollectList" xml:space="preserve">
-		<value>أ. تكوين الخادم (معرف الخادم ، البادئة المخصصة ، لغة الروبوت ، الأوامر المعطلة ، إلخ)
-ب. تكوين / إحصائيات المستخدم (معرّف المستخدم ونقاط لعبة Trivia وتكوين الخصوصية وحالة القائمة السوداء)
-ج. مغامرات AI Dungeon (معرف المغامرة ومعرف المالك وتوافر الاستخدام)
-د. مجموعة مؤقتة من الرسائل المحذوفة / المعدلة ، والمستخدمة في أمري "snipe" (`snipe`, `editsnipe`, `bigsnipe`  و `bigeditsnipe`)</value>
-	</data>
-	<data name="WhenWeCollectData" xml:space="preserve">
-		<value>متى نجمعها</value>
-	</data>
-	<data name="WhenWeCollectDataList" xml:space="preserve">
-		<value>أ. عندما تقوم بتعيين بادئة مخصصة أو لغة أو تكوين آخر. تتم إزالة هذه البيانات عندما يغادر الروبوت ذلك الخادم.
-ب. عندما تلعب لعبة Trivia ، فإنك تستخدم تكوين الخصوصية أو يتم إدراجك في القائمة السوداء.
-ج. عندما تنشئ مغامرة AI Dungeon مع الروبوت. تتم إزالة هذه البيانات عند استخدام الأمر `aid delete`.
-د. عندما يتم حذف / تحرير رسالة. يمكن رؤية هذه الرسائل فقط باستخدام أوامر "snipe" في القناة حيث تم حذف / تحرير الرسالة.
-يتم تخزين هذه الرسائل لمدة 6 ساعات فقط ، ويمكنك أن تقرر إلغاء الاشتراك باستخدام ردود الفعل أدناه.</value>
-	</data>
-	<data name="PrivacyConfig" xml:space="preserve">
-		<value>تكوين الخصوصية</value>
-	</data>
-	<data name="PrivacyConfigInfo" xml:space="preserve">
-		<value>ستجد هنا بعض الإعدادات التي يمكنك تغييرها لتحسين خصوصيتك.</value>
-	</data>
-	<data name="PrivacyConfigList" xml:space="preserve">
-		<value>إلغاء الاشتراك من المجموعة المؤقتة للرسائل المحذوفة / المعدلة في أوامر "snipe"</value>
-	</data>
-	<data name="Privacy" xml:space="preserve">
-		<value>خصوصية</value>
-	</data>
-	<data name="SnipePrivacy" xml:space="preserve">
-		<value>ألا تريد عرض رسالتك هنا؟ راجع `privacy`.</value>
-	</data>
-	<data name="CannotUseThisInteraction" xml:space="preserve">
-		<value>لا يمكنك استخدام هذا التفاعل.</value>
-	</data>
-	<data name="DeletingAdventure" xml:space="preserve">
-		<value>حذف المغامرة...</value>
-	</data>
-	<data name="Donate" xml:space="preserve">
-		<value>يتبرع</value>
-	</data>
-	<data name="LanguageList" xml:space="preserve">
-		<value>قائمة اللغات</value>
-	</data>
-	<data name="img3Summary" xml:space="preserve">
-		<value>يبحث عن الصور مع Brave.</value>
-	</data>
-	<data name="img3Param1" xml:space="preserve">
-		<value>الكلمة (الكلمات) للبحث.</value>
-	</data>
-	<data name="Thread" xml:space="preserve">
-		<value>خيط</value>
-	</data>
-	<data name="AutoArchive" xml:space="preserve">
-		<value>الأرشفة التلقائية</value>
-	</data>
-	<data name="StageChannel" xml:space="preserve">
-		<value>قناة المرحلة</value>
-	</data>
-	<data name="IsLive" xml:space="preserve">
-		<value>قناة المرحلة</value>
-	</data>
-	<data name="Archived" xml:space="preserve">
-		<value>مؤرشف</value>
-	</data>
-</root>
\ No newline at end of file
diff --git a/src/strings.es.resx b/src/strings.es.resx
deleted file mode 100644
index 919c26f..0000000
--- a/src/strings.es.resx
+++ /dev/null
@@ -1,2179 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<root>
-	<!-- 
-    Microsoft ResX Schema 
-    
-    Version 2.0
-    
-    The primary goals of this format is to allow a simple XML format 
-    that is mostly human readable. The generation and parsing of the 
-    various data types are done through the TypeConverter classes 
-    associated with the data types.
-    
-    Example:
-    
-    ... ado.net/XML headers & schema ...
-    <resheader name="resmimetype">text/microsoft-resx</resheader>
-    <resheader name="version">2.0</resheader>
-    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
-    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
-    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
-    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
-    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
-        <value>[base64 mime encoded serialized .NET Framework object]</value>
-    </data>
-    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
-        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
-        <comment>This is a comment</comment>
-    </data>
-                
-    There are any number of "resheader" rows that contain simple 
-    name/value pairs.
-    
-    Each data row contains a name, and value. The row also contains a 
-    type or mimetype. Type corresponds to a .NET class that support 
-    text/value conversion through the TypeConverter architecture. 
-    Classes that don't support this are serialized and stored with the 
-    mimetype set.
-    
-    The mimetype is used for serialized objects, and tells the 
-    ResXResourceReader how to depersist the object. This is currently not 
-    extensible. For a given mimetype the value must be set accordingly:
-    
-    Note - application/x-microsoft.net.object.binary.base64 is the format 
-    that the ResXResourceWriter will generate, however the reader can 
-    read any of the formats listed below.
-    
-    mimetype: application/x-microsoft.net.object.binary.base64
-    value   : The object must be serialized with 
-            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
-            : and then encoded with base64 encoding.
-    
-    mimetype: application/x-microsoft.net.object.soap.base64
-    value   : The object must be serialized with 
-            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
-            : and then encoded with base64 encoding.
-
-    mimetype: application/x-microsoft.net.object.bytearray.base64
-    value   : The object must be serialized into a byte array 
-            : using a System.ComponentModel.TypeConverter
-            : and then encoded with base64 encoding.
-    -->
-	<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
-		<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
-		<xsd:element name="root" msdata:IsDataSet="true">
-			<xsd:complexType>
-				<xsd:choice maxOccurs="unbounded">
-					<xsd:element name="metadata">
-						<xsd:complexType>
-							<xsd:sequence>
-								<xsd:element name="value" type="xsd:string" minOccurs="0" />
-							</xsd:sequence>
-							<xsd:attribute name="name" use="required" type="xsd:string" />
-							<xsd:attribute name="type" type="xsd:string" />
-							<xsd:attribute name="mimetype" type="xsd:string" />
-							<xsd:attribute ref="xml:space" />
-						</xsd:complexType>
-					</xsd:element>
-					<xsd:element name="assembly">
-						<xsd:complexType>
-							<xsd:attribute name="alias" type="xsd:string" />
-							<xsd:attribute name="name" type="xsd:string" />
-						</xsd:complexType>
-					</xsd:element>
-					<xsd:element name="data">
-						<xsd:complexType>
-							<xsd:sequence>
-								<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
-								<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
-							</xsd:sequence>
-							<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
-							<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
-							<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
-							<xsd:attribute ref="xml:space" />
-						</xsd:complexType>
-					</xsd:element>
-					<xsd:element name="resheader">
-						<xsd:complexType>
-							<xsd:sequence>
-								<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
-							</xsd:sequence>
-							<xsd:attribute name="name" type="xsd:string" use="required" />
-						</xsd:complexType>
-					</xsd:element>
-				</xsd:choice>
-			</xsd:complexType>
-		</xsd:element>
-	</xsd:schema>
-	<resheader name="resmimetype">
-		<value>text/microsoft-resx</value>
-	</resheader>
-	<resheader name="version">
-		<value>2.0</value>
-	</resheader>
-	<resheader name="reader">
-		<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
-	</resheader>
-	<resheader name="writer">
-		<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
-	</resheader>
-	<data name="CommandList" xml:space="preserve">
-    <value>Lista de comandos</value>
-  </data>
-	<data name="EntertainmentCommands" xml:space="preserve">
-    <value>Comandos de entretenimiento</value>
-  </data>
-	<data name="HelpFooter" xml:space="preserve">
-    <value>Fergun {0} - Cantidad total de comandos: {1}</value>
-  </data>
-	<data name="ModerationCommands" xml:space="preserve">
-    <value>Comandos de moderación</value>
-  </data>
-	<data name="MusicCommands" xml:space="preserve">
-    <value>Comandos de música</value>
-  </data>
-	<data name="Notes" xml:space="preserve">
-    <value>Notas</value>
-  </data>
-	<data name="NotesInfo" xml:space="preserve">
-    <value>Usa `{0}help [comando]` para obtener mas información sobre un comando.</value>
-  </data>
-	<data name="OtherCommands" xml:space="preserve">
-    <value>Otros comandos</value>
-  </data>
-	<data name="TextCommands" xml:space="preserve">
-    <value>Comandos de texto</value>
-  </data>
-	<data name="UtilityCommands" xml:space="preserve">
-    <value>Comandos de utilidad</value>
-  </data>
-	<data name="UpcomingCommands" xml:space="preserve">
-    <value>Próximos comandos</value>
-  </data>
-	<data name="CommandNotFound" xml:space="preserve">
-    <value>Comando no encontrado. Usa `{0}help` para ver la lista de comandos.</value>
-  </data>
-	<data name="NoDescription" xml:space="preserve">
-    <value>(Descripción no disponible)</value>
-  </data>
-	<data name="Optional" xml:space="preserve">
-    <value>(Opcional)</value>
-  </data>
-	<data name="Usage" xml:space="preserve">
-    <value>Uso</value>
-  </data>
-	<data name="Alias" xml:space="preserve">
-    <value>Alias</value>
-  </data>
-	<data name="Parameters" xml:space="preserve">
-    <value>Parámetros</value>
-  </data>
-	<data name="mojibakeSummary" xml:space="preserve">
-    <value>Muestra caráctereres unicode aleatorios.</value>
-  </data>
-	<data name="mojibakeParam1" xml:space="preserve">
-    <value>El tamaño del resultado.</value>
-  </data>
-	<data name="3orMoreChars" xml:space="preserve">
-    <value>El texto debe tener 3 o mas carácteres.</value>
-  </data>
-	<data name="TooLongToDisplay" xml:space="preserve">
-    <value>El texto resultante es demasiado largo para ser mostrado ({0}).</value>
-  </data>
-	<data name="normalizeSummary" xml:space="preserve">
-    <value>Normaliza un texto.</value>
-  </data>
-	<data name="normalizeParam1" xml:space="preserve">
-    <value>El texto a normalizar.</value>
-  </data>
-	<data name="randomizeSummary" xml:space="preserve">
-    <value>Aleatoriza un texto.</value>
-  </data>
-	<data name="randomizeParam1" xml:space="preserve">
-    <value>El texto a aleatorizar.</value>
-  </data>
-	<data name="repeatSummary" xml:space="preserve">
-    <value>Repite un texto un número de veces.</value>
-  </data>
-	<data name="repeatParam1" xml:space="preserve">
-    <value>Las veces a repetir.</value>
-  </data>
-	<data name="repeatParam2" xml:space="preserve">
-    <value>El texto a repetir.</value>
-  </data>
-	<data name="reverseSummary" xml:space="preserve">
-    <value>Invierte un texto.</value>
-  </data>
-	<data name="reverseParam1" xml:space="preserve">
-    <value>El texto a invertir.</value>
-  </data>
-	<data name="reverselinesSummary" xml:space="preserve">
-    <value>Invierte el orden de las líneas de un texto.</value>
-  </data>
-	<data name="reverselinesParam1" xml:space="preserve">
-    <value>El texto a invertir sus líneas.</value>
-  </data>
-	<data name="reversewordsSummary" xml:space="preserve">
-    <value>Invierte el orden de las palabras de un texto.</value>
-  </data>
-	<data name="reversewordsParam1" xml:space="preserve">
-    <value>El texto a invertir sus palabras.</value>
-  </data>
-	<data name="sarcasmSummary" xml:space="preserve">
-    <value>tEXto sARcaStIco.</value>
-  </data>
-	<data name="sarcasmParam1" xml:space="preserve">
-    <value>El texto a convertir.</value>
-  </data>
-	<data name="vaporwaveSummary" xml:space="preserve">
-    <value>Convierte un texto a vaporwave.</value>
-  </data>
-	<data name="vaporwaveParam1" xml:space="preserve">
-    <value>El texto a convertir.</value>
-  </data>
-	<data name="avatarSummary" xml:space="preserve">
-    <value>Retorna el avatar del usuario actual, o de un usuario en específico, si se pasa alguno.</value>
-  </data>
-	<data name="avatarParam1" xml:space="preserve">
-    <value>El usuario a obtener su avatar.</value>
-  </data>
-	<data name="base64encodeSummary" xml:space="preserve">
-    <value>Codifica un texto a Base64.</value>
-  </data>
-	<data name="base64encodeParam1" xml:space="preserve">
-    <value>El texto a codificar.</value>
-  </data>
-	<data name="base64decodeSummary" xml:space="preserve">
-    <value>Decodifica un texto en Base64.</value>
-  </data>
-	<data name="base64decodeParam1" xml:space="preserve">
-    <value>El texto a decodificar.</value>
-  </data>
-	<data name="base64decodeInvalid" xml:space="preserve">
-    <value>Texto codificado inválido.</value>
-  </data>
-	<data name="choiceSummary" xml:space="preserve">
-    <value>Escoje una opción de una lista.</value>
-  </data>
-	<data name="choiceParam1" xml:space="preserve">
-    <value>Una lista separada por espacios de opciones.</value>
-  </data>
-	<data name="IChoose" xml:space="preserve">
-    <value>Yo escojo...</value>
-  </data>
-	<data name="OneChoice" xml:space="preserve">
-    <value>
-...porque solo me distes una opción</value>
-  </data>
-	<data name="colorSummary" xml:space="preserve">
-	<value>Muestra un color aleatorio o uno específico.</value>
-  </data>
-	<data name="colorParam1" xml:space="preserve">
-	<value>El color específico a usar. Debe ser un valor en hex, un valor en bruto o el nombre de un color conocido.</value>
-  </data>
-	<data name="helpSummary" xml:space="preserve">
-    <value>Muestra el menú de ayuda o la información de un comando, si se pasa alguno.</value>
-  </data>
-	<data name="helpParam1" xml:space="preserve">
-    <value>El comando a obtener su información.</value>
-  </data>
-	<data name="identifySummary" xml:space="preserve">
-    <value>Identifica una imagen con Microsoft CaptionBot.</value>
-  </data>
-	<data name="identifyParam1" xml:space="preserve">
-    <value>La url de una imagen a usar.</value>
-  </data>
-	<data name="NoUrlPassed" xml:space="preserve">
-    <value>El no especifiar una url hará que el comando busque en los últimos x mensajes en la caché por un link o un archivo adjunto.</value>
-  </data>
-	<data name="AttachmentNotImage" xml:space="preserve">
-    <value>El archivo adjunto no es una imagen.</value>
-  </data>
-	<data name="UrlNotFound" xml:space="preserve">
-    <value>No se pudo encontrar alguna url o archivo adjunto en los últimos {0} mensajes.</value>
-  </data>
-	<data name="UrlNotImage" xml:space="preserve">
-    <value>La url no es una imagen válida.</value>
-  </data>
-	<data name="imgSummary" xml:space="preserve">
-    <value>Busca imágenes con Google Images.</value>
-  </data>
-	<data name="imgParam1" xml:space="preserve">
-    <value>Las palabras clave a buscar.</value>
-  </data>
-	<data name="NoResultsFound" xml:space="preserve">
-    <value>No se encontraron resultados.</value>
-  </data>
-	<data name="IfNSFW" xml:space="preserve">
-    <value>Si la búsqueda es NSFW, usa el comando en un canal NSFW.</value>
-  </data>
-	<data name="ImageSearch" xml:space="preserve">
-    <value>Búsqueda de imagen</value>
-  </data>
-	<data name="UrbanFooter" xml:space="preserve">
-    <value>Urban Dictionary - Página {0} de {1}</value>
-  </data>
-	<data name="ImageSearchCap" xml:space="preserve">
-    <value>Límite de búsqueda de imágenes alcanzada :(</value>
-  </data>
-	<data name="ocrSummary" xml:space="preserve">
-    <value>Realiza OCR a una imagen.</value>
-  </data>
-	<data name="ocrParam1" xml:space="preserve">
-    <value>La url de una imagen a usar.</value>
-  </data>
-	<data name="UrlFileTooLarge" xml:space="preserve">
-    <value>El archivo es demasiado grande.</value>
-  </data>
-	<data name="OcrEmpty" xml:space="preserve">
-    <value>El OCR no dió resultados.</value>
-  </data>
-	<data name="ocr2Summary" xml:space="preserve">
-    <value>Realiza OCR a una imagen con Tesseract.</value>
-  </data>
-	<data name="ocr2Param1" xml:space="preserve">
-    <value>La url de una imagen a usar.</value>
-  </data>
-	<data name="StatusCode" xml:space="preserve">
-    <value>Código de estado:</value>
-  </data>
-	<data name="pingSummary" xml:space="preserve">
-    <value>Obtiene la latencia al enviar un mensaje y la latencia de la base de datos.</value>
-  </data>
-	<data name="resizeSummary" xml:space="preserve">
-    <value>Redimensiona una imagen con waifu2x.</value>
-  </data>
-	<data name="resizeParam1" xml:space="preserve">
-    <value>La url de una imagen a usar.</value>
-  </data>
-	<data name="AnErrorOccurred" xml:space="preserve">
-    <value>Ocurrió un error.</value>
-  </data>
-	<data name="ResizeResults" xml:space="preserve">
-    <value>Resultados de redimensión</value>
-  </data>
-	<data name="screenshotSummary" xml:space="preserve">
-    <value>Toma una captura de pantalla a un sitio web.</value>
-  </data>
-	<data name="screenshotParam1" xml:space="preserve">
-    <value>El sitio web a tomar una captura de pantalla.</value>
-  </data>
-	<data name="serverinfoSummary" xml:space="preserve">
-    <value>Retorna información del servidor.</value>
-  </data>
-	<data name="ServerInfo" xml:space="preserve">
-    <value>Información de servidor</value>
-  </data>
-	<data name="Name" xml:space="preserve">
-    <value>Nombre</value>
-  </data>
-	<data name="Owner" xml:space="preserve">
-    <value>Dueño</value>
-  </data>
-	<data name="RoleCount" xml:space="preserve">
-    <value>Número de roles</value>
-  </data>
-	<data name="UserCount" xml:space="preserve">
-    <value>Número de usuarios</value>
-  </data>
-	<data name="VerificationLevel" xml:space="preserve">
-    <value>Nivel de verificación</value>
-  </data>
-	<data name="None" xml:space="preserve">
-    <value>(Nada)</value>
-  </data>
-	<data name="CreatedAt" xml:space="preserve">
-    <value>Creado en</value>
-  </data>
-	<data name="snipeSummary" xml:space="preserve">
-    <value>Muestra el último mensaje eliminado en el canal actual.</value>
-  </data>
-	<data name="NothingToSnipe" xml:space="preserve">
-    <value>Nada que mostrar en {0}</value>
-  </data>
-	<data name="translateSummary" xml:space="preserve">
-    <value>Traduce un texto.</value>
-  </data>
-	<data name="translateParam1" xml:space="preserve">
-    <value>El idioma de destino en el código ISO (en, es, br, etc.)</value>
-  </data>
-	<data name="translateParam2" xml:space="preserve">
-    <value>El texto a traducir.</value>
-  </data>
-	<data name="InvalidLanguage" xml:space="preserve">
-    <value>Idioma de destino inválido. Usa `{0}translate language codes` para ver la lista de idiomas.</value>
-  </data>
-	<data name="GoogleIPBanned" xml:space="preserve">
-    <value>Google me dió IP ban :( lol</value>
-  </data>
-	<data name="ttsSummary" xml:space="preserve">
-    <value>Texto a voz.</value>
-  </data>
-	<data name="ttsParam1" xml:space="preserve">
-    <value>El idioma de destino en el código ISO (en, es, br, etc.), Inglés por defecto en caso de idioma inválido.</value>
-  </data>
-	<data name="ttsParam2" xml:space="preserve">
-    <value>El texto a convertir a voz.</value>
-  </data>
-	<data name="urbanSummary" xml:space="preserve">
-    <value>Búsqueda en Urban Dictionary.</value>
-  </data>
-	<data name="urbanParam1" xml:space="preserve">
-    <value>Las palabras clave a buscar.</value>
-  </data>
-	<data name="NoResults" xml:space="preserve">
-    <value>Sin resultados.</value>
-  </data>
-	<data name="By" xml:space="preserve">
-    <value>Por</value>
-  </data>
-	<data name="Example" xml:space="preserve">
-    <value>Ejemplo</value>
-  </data>
-	<data name="NoExample" xml:space="preserve">
-    <value>(Sin ejemplo proporcionado)</value>
-  </data>
-	<data name="userinfoSummary" xml:space="preserve">
-    <value>Retorna información del usuario actual, o de un usuario en específico, si se pasa alguno.</value>
-  </data>
-	<data name="userinfoParam1" xml:space="preserve">
-    <value>El usuario a obtener su información.</value>
-  </data>
-	<data name="UserInfo" xml:space="preserve">
-    <value>Información del usuario</value>
-  </data>
-	<data name="Activity" xml:space="preserve">
-    <value>Actividad</value>
-  </data>
-	<data name="IsBot" xml:space="preserve">
-    <value>Es bot</value>
-  </data>
-	<data name="GuildJoinDate" xml:space="preserve">
-    <value>Dia de ingreso al servidor</value>
-  </data>
-	<data name="UserNotFound" xml:space="preserve">
-    <value>Usuario no encontrado.
-Intenta usar un tag ({0}), una mención ({1}) o una ID ({2}).</value>
-  </data>
-	<data name="wikipediaSummary" xml:space="preserve">
-    <value>Busca un artículo en Wikipedia.</value>
-  </data>
-	<data name="wikipediaParam1" xml:space="preserve">
-    <value>Las palabras clave a buscar.</value>
-  </data>
-	<data name="WikipediaSearch" xml:space="preserve">
-    <value>Búsqueda en Wikipedia</value>
-  </data>
-	<data name="xkcdSummary" xml:space="preserve">
-    <value>Retorna un comic de xkcd aleatorio o uno en específico, si un número es pasado.</value>
-  </data>
-	<data name="xkcdParam1" xml:space="preserve">
-    <value>El número de cómic.</value>
-  </data>
-	<data name="InvalidxkcdNumber" xml:space="preserve">
-    <value>El número debe estar entre 1 y {0}.</value>
-  </data>
-	<data name="youtubeSummary" xml:space="preserve">
-    <value>Busca un video en YouTube.</value>
-  </data>
-	<data name="youtubeParam1" xml:space="preserve">
-    <value>Las palabras clave a buscar.</value>
-  </data>
-	<data name="YouTubeNoResults" xml:space="preserve">
-    <value>No se encontró resultados.</value>
-  </data>
-	<data name="ytrandomSummary" xml:space="preserve">
-    <value>Envía un video "aleatorio" de YouTube.</value>
-  </data>
-	<data name="EmptyCache" xml:space="preserve">
-    <value>Sin videos en el caché
-Creando caché...</value>
-  </data>
-	<data name="banSummary" xml:space="preserve">
-    <value>Banea un usuario.</value>
-  </data>
-	<data name="banParam1" xml:space="preserve">
-    <value>El usuario a banear.</value>
-  </data>
-	<data name="banParam2" xml:space="preserve">
-    <value>La razón del baneo.</value>
-  </data>
-	<data name="BanSameUser" xml:space="preserve">
-    <value>Que? Te quieres banear a ti mismo?</value>
-  </data>
-	<data name="BanMyself" xml:space="preserve">
-    <value>No me banearé lol</value>
-  </data>
-	<data name="AlreadyBanned" xml:space="preserve">
-    <value>El usuario ya está baneado.</value>
-  </data>
-	<data name="Banned" xml:space="preserve">
-    <value>El usuario {0} fue baneado.</value>
-  </data>
-	<data name="clearSummary" xml:space="preserve">
-    <value>Elimina los últimos x mensajes en el canal actual.</value>
-  </data>
-	<data name="clearParam1" xml:space="preserve">
-    <value>El número de mensajes a eliminar.</value>
-  </data>
-	<data name="clearParam2" xml:space="preserve">
-    <value>Información del usuario</value>
-  </data>
-	<data name="NumberOutOfIndex" xml:space="preserve">
-    <value>El número debe estar entre {0} y {1}.</value>
-  </data>
-	<data name="ClearNotFound" xml:space="preserve">
-    <value>No se pudo encontrar algún mensaje de {0} en los últimos {1} mensajes.</value>
-  </data>
-	<data name="By2" xml:space="preserve">
-    <value>por</value>
-  </data>
-	<data name="hackbanSummary" xml:space="preserve">
-    <value>Hackbanea un usuario.</value>
-  </data>
-	<data name="hackbanParam1" xml:space="preserve">
-    <value>La ID del usuario a hackbanear.</value>
-  </data>
-	<data name="hackbanParam2" xml:space="preserve">
-    <value>La razón del hackbaneo.</value>
-  </data>
-	<data name="Hackbanned" xml:space="preserve">
-    <value>El usuario {0} fue hackbaneado.</value>
-  </data>
-	<data name="kickSummary" xml:space="preserve">
-    <value>Expulsa un usuario.</value>
-  </data>
-	<data name="kickParam1" xml:space="preserve">
-    <value>El usuario a expulsar.</value>
-  </data>
-	<data name="kickParam2" xml:space="preserve">
-    <value>La razón de la expulsión.</value>
-  </data>
-	<data name="Kicked" xml:space="preserve">
-    <value>El usuario {0} fue expulsado.</value>
-  </data>
-	<data name="nickSummary" xml:space="preserve">
-    <value>Cambia el nickname de un usuario.</value>
-  </data>
-	<data name="nickParam1" xml:space="preserve">
-    <value>El usuario a cambiar su nickname.</value>
-  </data>
-	<data name="nickParam2" xml:space="preserve">
-    <value>El nuevo nickname. Deja esto vacío para eliminar el nickname.</value>
-  </data>
-	<data name="unbanSummary" xml:space="preserve">
-    <value>Desbanea un usuario.</value>
-  </data>
-	<data name="unbanParam1" xml:space="preserve">
-    <value>La ID del usuario a desbanear.</value>
-  </data>
-	<data name="Unbanned" xml:space="preserve">
-    <value>El usuario {0} fue desbaneado.</value>
-  </data>
-	<data name="triviaSummary" xml:space="preserve">
-    <value>Hora de trivia!</value>
-  </data>
-	<data name="triviaParam1" xml:space="preserve">
-    <value>La categoría a seleccionar. Especifica "categories" para ver la lista de categorias o "leaderboard"/"ranks" para ver la tabla de clasificación.</value>
-  </data>
-	<data name="CategoryList" xml:space="preserve">
-    <value>Lista de categorías</value>
-  </data>
-	<data name="TriviaLeaderboard" xml:space="preserve">
-    <value>Tabla de clasificación de Trivia</value>
-  </data>
-	<data name="AllQuestionsAnswered" xml:space="preserve">
-    <value>Parece que respondistes todas las preguntas en la categoría especificada, selecciona otra categoría.</value>
-  </data>
-	<data name="TriviaError" xml:space="preserve">
-    <value>Ocurrió un error. Código de error</value>
-  </data>
-	<data name="Category" xml:space="preserve">
-    <value>Categoría</value>
-  </data>
-	<data name="Type" xml:space="preserve">
-    <value>Tipo</value>
-  </data>
-	<data name="Difficulty" xml:space="preserve">
-    <value>Dificultad</value>
-  </data>
-	<data name="Question" xml:space="preserve">
-    <value>Pregunta</value>
-  </data>
-	<data name="Options" xml:space="preserve">
-    <value>Opciones</value>
-  </data>
-	<data name="TimeLeft" xml:space="preserve">
-    <value>Tienes {0} segundos para responder.</value>
-  </data>
-	<data name="InvalidOption" xml:space="preserve">
-    <value>Opcion invalida!</value>
-  </data>
-	<data name="Lost1Point" xml:space="preserve">
-    <value>Perdiste 1 punto.</value>
-  </data>
-	<data name="CorrectAnswer" xml:space="preserve">
-    <value>Respuesta correcta!</value>
-  </data>
-	<data name="Won1Point" xml:space="preserve">
-    <value>Ganaste 1 punto.</value>
-  </data>
-	<data name="TheAnswerIs" xml:space="preserve">
-    <value>La respuesta es</value>
-  </data>
-	<data name="TimesUp" xml:space="preserve">
-    <value>Se acabó el tiempo!</value>
-  </data>
-	<data name="Points" xml:space="preserve">
-    <value>Puntos</value>
-  </data>
-	<data name="joinSummary" xml:space="preserve">
-    <value>Ingresa a un canal de voz.</value>
-  </data>
-	<data name="UserNotInVC" xml:space="preserve">
-    <value>Debes conectarte a un canal de voz.</value>
-  </data>
-	<data name="NowConnected" xml:space="preserve">
-    <value>Conectado a {0}.</value>
-  </data>
-	<data name="leaveSummary" xml:space="preserve">
-    <value>Deja un canal de voz.</value>
-  </data>
-	<data name="LeaveNotInVC" xml:space="preserve">
-    <value>Ingresa al canal donde está el bot para hacerlo salir.</value>
-  </data>
-	<data name="LeftVC" xml:space="preserve">
-    <value>El bot ha salido de {0}.</value>
-  </data>
-	<data name="moveSummary" xml:space="preserve">
-    <value>Mueve el bot donde está el usuario actual.</value>
-  </data>
-	<data name="MoveNotInVC" xml:space="preserve">
-    <value>Entra a un canal de voz donde quieras que el bot esté.</value>
-  </data>
-	<data name="playSummary" xml:space="preserve">
-    <value>Busca y reproduce un video de YouTube.</value>
-  </data>
-	<data name="playParam1" xml:space="preserve">
-    <value>Las palabras clave a buscar.</value>
-  </data>
-	<data name="SelectTrack" xml:space="preserve">
-    <value>Envía un número de la lista</value>
-  </data>
-	<data name="SearchCanceled" xml:space="preserve">
-    <value>Búsqueda cancelada.</value>
-  </data>
-	<data name="OutOfIndex" xml:space="preserve">
-    <value>Opción fuera de índice.</value>
-  </data>
-	<data name="ReplyTimeout" xml:space="preserve">
-    <value>No has respondido antes del límite de tiempo!</value>
-  </data>
-	<data name="replaySummary" xml:space="preserve">
-    <value>Vuelve a reproducir el video que está en curso, si hay alguno.</value>
-  </data>
-	<data name="pauseSummary" xml:space="preserve">
-    <value>Pausa el reproductor.</value>
-  </data>
-	<data name="resumeSummary" xml:space="preserve">
-    <value>Reanuda la reproducción.</value>
-  </data>
-	<data name="stopSummary" xml:space="preserve">
-    <value>Detiene el reproductor.</value>
-  </data>
-	<data name="skipSummary" xml:space="preserve">
-    <value>Salta la pista actual, si hay alguno</value>
-  </data>
-	<data name="volumeSummary" xml:space="preserve">
-    <value>Establece el volumen del reproductor.</value>
-  </data>
-	<data name="volumeParam1" xml:space="preserve">
-    <value>El volumen a establecer (2 - 150)</value>
-  </data>
-	<data name="queueSummary" xml:space="preserve">
-    <value>Muestra la cola de reproducción.</value>
-  </data>
-	<data name="shuffleSummary" xml:space="preserve">
-    <value>Aleatoriza la cola de reproducción.</value>
-  </data>
-	<data name="removeSummary" xml:space="preserve">
-    <value>Elimina una pista en la cola en el índice especificado.</value>
-  </data>
-	<data name="removeParam1" xml:space="preserve">
-    <value>El índice de la pista a eliminar.</value>
-  </data>
-	<data name="lyricsSummary" xml:space="preserve">
-    <value>Muestra la letra de la canción especificada, o de la canción actual en el reproductor si no se pasa alguna canción.</value>
-  </data>
-	<data name="lyricsParam1" xml:space="preserve">
-    <value>La canción a buscar su letra.</value>
-  </data>
-	<data name="artworkSummary" xml:space="preserve">
-    <value>Muestra el artwork de la pista actual en el reproductor.</value>
-  </data>
-	<data name="NoTracks" xml:space="preserve">
-    <value>Sin mas pistas para reproducir.</value>
-  </data>
-	<data name="NowPlaying" xml:space="preserve">
-    <value>Reproduciendo ahora</value>
-  </data>
-	<data name="PlayerError" xml:space="preserve">
-    <value>Ocurrió un error</value>
-  </data>
-	<data name="PlayerStuck" xml:space="preserve">
-    <value>El reproductor se quedó atascado en la pista {0} por {1} segundos.</value>
-  </data>
-	<data name="PlayerMoved" xml:space="preserve">
-    <value>Movido de **{0}** a **{1}**</value>
-  </data>
-	<data name="PlayerNoMatches" xml:space="preserve">
-    <value>No se encontraron resultados. Prueba usando un link de YouTube o un link de un archivo mp3.</value>
-  </data>
-	<data name="PlayerPlaylistAdded" xml:space="preserve">
-    <value>Playlist **{0}** ({1} pistas) ({2}) ha sido agregado a la cola.</value>
-  </data>
-	<data name="PlayerEmptyPlaylistAdded" xml:space="preserve">
-    <value>Añadido a la cola {0} pistas ({1})
-
-Reproduciendo ahora: {2}</value>
-  </data>
-	<data name="PlayerTrackAdded" xml:space="preserve">
-    <value>{0} ha sido añadido a la cola.</value>
-  </data>
-	<data name="PlayerNowPlaying" xml:space="preserve">
-    <value>Reproduciendo ahora: {0}</value>
-  </data>
-	<data name="InvalidTrack" xml:space="preserve">
-    <value>Pista inválida.</value>
-  </data>
-	<data name="Replaying" xml:space="preserve">
-    <value>Volviendo a reproducir {0}</value>
-  </data>
-	<data name="EmptyQueue" xml:space="preserve">
-    <value>La cola está vacia.</value>
-  </data>
-	<data name="PlayerNotPlaying" xml:space="preserve">
-    <value>El reproductor no está reproduciendo nada.</value>
-  </data>
-	<data name="PlayerStopped" xml:space="preserve">
-    <value>El reproductor fue detenido.</value>
-  </data>
-	<data name="PlayerTrackSkipped" xml:space="preserve">
-    <value>Saltado: {0}
-
-Reproduciendo ahora: {1}</value>
-  </data>
-	<data name="VolumeOutOfIndex" xml:space="preserve">
-    <value>Usa un número entre 2 y 150.</value>
-  </data>
-	<data name="VolumeSet" xml:space="preserve">
-    <value>Volumen establecido a: {0}.</value>
-  </data>
-	<data name="PlayerPaused" xml:space="preserve">
-    <value>El reproductor está pausado ahora.</value>
-  </data>
-	<data name="PlaybackResumed" xml:space="preserve">
-    <value>Reproducción reanudada.</value>
-  </data>
-	<data name="PlayerNotPaused" xml:space="preserve">
-    <value>El reproductor no está pausado.</value>
-  </data>
-	<data name="CurrentlyPlaying" xml:space="preserve">
-    <value>Reproduciendo actualmente: {0} ({1} de {2})</value>
-  </data>
-	<data name="MusicInQueue" xml:space="preserve">
-    <value>Pistas en la cola:</value>
-  </data>
-	<data name="Queue1Item" xml:space="preserve">
-    <value>Solo hay 1 item en la cola.</value>
-  </data>
-	<data name="QueueShuffled" xml:space="preserve">
-    <value>La cola fue aleatorizada.</value>
-  </data>
-	<data name="IndexOutOfRange" xml:space="preserve">
-    <value>Índice fuera de rango.</value>
-  </data>
-	<data name="TrackRemoved" xml:space="preserve">
-    <value>La pista {0} en la posición {1} fue eliminada.</value>
-  </data>
-	<data name="LyricsNotFound" xml:space="preserve">
-    <value>No se encontró la letra para {0}</value>
-  </data>
-	<data name="Incorrect" xml:space="preserve">
-    <value>Incorrecto!</value>
-  </data>
-	<data name="botgameSummary" xml:space="preserve">
-    <value>Establece el estado de juego del bot.</value>
-  </data>
-	<data name="botgameParam1" xml:space="preserve">
-    <value>El texto a establecer.</value>
-  </data>
-	<data name="BotOwnerOnly" xml:space="preserve">
-    <value>Solo el dueño del bot puede usar esto.</value>
-  </data>
-	<data name="botstatusSummary" xml:space="preserve">
-    <value>Establece el estado del bot.</value>
-  </data>
-	<data name="botstatusParam1" xml:space="preserve">
-    <value>El estado a establecer (0 - 5).</value>
-  </data>
-	<data name="codeSummary" xml:space="preserve">
-    <value>Muestra el código fuente de un comando.</value>
-  </data>
-	<data name="codeParam1" xml:space="preserve">
-    <value>El comando a obtener su código.</value>
-  </data>
-	<data name="botcolorSummary" xml:space="preserve">
-    <value>Establece el color de los embeds.</value>
-  </data>
-	<data name="botcolorParam1" xml:space="preserve">
-    <value>El nuevo color en hexadecimal o decimal.</value>
-  </data>
-	<data name="InvalidColor" xml:space="preserve">
-    <value>Color inválido.</value>
-  </data>
-	<data name="cringeSummary" xml:space="preserve">
-    <value>Acabas de postear cringe...</value>
-  </data>
-	<data name="globalprefixSummary" xml:space="preserve">
-    <value>Establece el prefijo global del bot.</value>
-  </data>
-	<data name="globalprefixParam1" xml:space="preserve">
-    <value>El nuevo prefijo global.</value>
-  </data>
-	<data name="CurrentGlobalPrefix" xml:space="preserve">
-    <value>El prefijo global actual es:</value>
-  </data>
-	<data name="PrefixSameCurrentTarget" xml:space="preserve">
-    <value>El prefijo de destino y el prefijo actual son iguales.</value>
-  </data>
-	<data name="NewGlobalPrefix" xml:space="preserve">
-    <value>El nuevo prefijo global es: "{0}"</value>
-  </data>
-	<data name="inviteSummary" xml:space="preserve">
-    <value>Envía el link de invitación del bot.</value>
-  </data>
-	<data name="prefixSummary" xml:space="preserve">
-    <value>Establece el prefijo del bot.</value>
-  </data>
-	<data name="prefixParam1" xml:space="preserve">
-    <value>El nuevo prefijo.</value>
-  </data>
-	<data name="CurrentPrefix" xml:space="preserve">
-    <value>El prefijo actual es:</value>
-  </data>
-	<data name="NewPrefix" xml:space="preserve">
-    <value>El nuevo prefijo del servidor es: "{0}"</value>
-  </data>
-	<data name="restartSummary" xml:space="preserve">
-    <value>Reinicia el bot.</value>
-  </data>
-	<data name="saySummary" xml:space="preserve">
-    <value>Dice algo.</value>
-  </data>
-	<data name="sayParam1" xml:space="preserve">
-    <value>El texto a decir.</value>
-  </data>
-	<data name="uptimeSummary" xml:space="preserve">
-    <value>Muestra el tiempo de actividad del bot.</value>
-  </data>
-	<data name="languageSummary" xml:space="preserve">
-    <value>Establece el idioma del bot.</value>
-  </data>
-	<data name="NewLanguage" xml:space="preserve">
-    <value>El idioma del bot ahora es: Español</value>
-  </data>
-	<data name="PaginatorFooter" xml:space="preserve">
-    <value>Página {0} de {1}</value>
-  </data>
-	<data name="FailedExecution" xml:space="preserve">
-    <value>Error al ejecutar</value>
-  </data>
-	<data name="nowplayingSummary" xml:space="preserve">
-    <value>Obtiene la pista actual en el reproductor.</value>
-  </data>
-	<data name="changelogSummary" xml:space="preserve">
-    <value>Muestra el registro de cambios del bot.</value>
-  </data>
-	<data name="TempDisabled" xml:space="preserve">
-    <value>Deshabilitado temporalmente.</value>
-  </data>
-	<data name="inspirobotSummary" xml:space="preserve">
-    <value>Obtiene algunas frases inspiradoras.</value>
-  </data>
-	<data name="PermissionRequired" xml:space="preserve">
-    <value>Necesito el permiso `{0}` para ejecutar este comando.</value>
-  </data>
-	<data name="ManageMessages" xml:space="preserve">
-    <value>Gestionar mensajes</value>
-  </data>
-	<data name="Connect" xml:space="preserve">
-    <value>Conectar</value>
-  </data>
-	<data name="Speak" xml:space="preserve">
-    <value>Hablar</value>
-  </data>
-	<data name="evalSummary" xml:space="preserve">
-    <value>Evalúa código.</value>
-  </data>
-	<data name="evalParam1" xml:space="preserve">
-    <value>El código a evaluar.</value>
-  </data>
-	<data name="ScreenshotNSFW" xml:space="preserve">
-    <value>La captura de la página puede tener contenido NSFW y no será mostrado. Intenta usar el comando en un canal NSFW o usa otra url.</value>
-  </data>
-	<data name="BotNotConnected" xml:space="preserve">
-    <value>El bot no está conectado a un canal de voz.</value>
-  </data>
-	<data name="CreationCanceled" xml:space="preserve">
-    <value>Creación cancelada.</value>
-  </data>
-	<data name="AIDungeonWelcome" xml:space="preserve">
-    <value>Bienvenido a AI Dungeon</value>
-  </data>
-	<data name="ModeSelect" xml:space="preserve">
-    <value>Asegúrate de leer la información y los comandos con `{0}aid info` antes de continuar.
-
-Selecciona un modo:</value>
-  </data>
-	<data name="CharacterSelect" xml:space="preserve">
-    <value>Selecciona un personaje</value>
-  </data>
-	<data name="AttachFiles" xml:space="preserve">
-    <value>Adjuntar archivos</value>
-  </data>
-	<data name="EvalResults" xml:space="preserve">
-    <value>Resultados del Eval</value>
-  </data>
-	<data name="Output" xml:space="preserve">
-    <value>Salida</value>
-  </data>
-	<data name="EvalFooter" xml:space="preserve">
-    <value>Ejecutado en {0}ms</value>
-  </data>
-	<data name="EvalNoReturnValue" xml:space="preserve">
-    <value>Código ejecutado sin ningún valor de retorno.</value>
-  </data>
-	<data name="NSFWOnly" xml:space="preserve">
-    <value>Este comando solo puede usarse en un canal NSFW.</value>
-  </data>
-	<data name="UserOnWait" xml:space="preserve">
-    <value>Espera unos segundos antes de usar este comando!</value>
-  </data>
-	<data name="snipeParam1" xml:space="preserve">
-    <value>Un canal en específico a buscar.</value>
-  </data>
-	<data name="IDNotFound" xml:space="preserve">
-    <value>ID no encontrada.</value>
-  </data>
-	<data name="IDNotPublic" xml:space="preserve">
-    <value>Esta ID de historia no es pública y no podrás usarla. Pide al dueño de la ID ({0}) que lo haga público con `makepublic &lt;ID&gt;`</value>
-  </data>
-	<data name="IDOnWait" xml:space="preserve">
-    <value>Debes esperar que la historia se genere antes de usar esta ID!</value>
-  </data>
-	<data name="GeneratingNewAdventure" xml:space="preserve">
-    <value>Generando una nueva aventura
-con el modo: **{0}**
-y el personaje: **{1}**...</value>
-  </data>
-	<data name="CustomCharacterCreation" xml:space="preserve">
-    <value>Creación personalizada de personaje</value>
-  </data>
-	<data name="CustomCharacterPrompt" xml:space="preserve">
-    <value>Ingresa un texto que describa quién eres y las primeras dos oraciones de dónde empiezas.
-Este aviso expirará en 5 minutos.</value>
-  </data>
-	<data name="GeneratingNewCustomAdventure" xml:space="preserve">
-    <value>Generando una nueva aventura con el texto personalizado...</value>
-  </data>
-	<data name="GeneratingStory" xml:space="preserve">
-    <value>Generando historia...</value>
-  </data>
-	<data name="EditingStoryContext" xml:space="preserve">
-    <value>Editando el contexto de la historia...</value>
-  </data>
-	<data name="TheAIWillNowRemember" xml:space="preserve">
-    <value>La IA ahora recordará:</value>
-  </data>
-	<data name="AlteringLastOutput" xml:space="preserve">
-    <value>Alterando la última salida...</value>
-  </data>
-	<data name="ChangedLastOutput" xml:space="preserve">
-    <value>Cambiada la última salida a:</value>
-  </data>
-	<data name="NotIDOwner" xml:space="preserve">
-    <value>No eres el dueño de esta ID.</value>
-  </data>
-	<data name="IDAlreadyPublic" xml:space="preserve">
-    <value>La ID ya es pública.</value>
-  </data>
-	<data name="IDNowPublic" xml:space="preserve">
-    <value>La ID ahora es pública y todos pueden usarla. Puedes volver a establecerlo como privado con `makeprivate &lt;ID&gt;`</value>
-  </data>
-	<data name="IDAlreadyPrivate" xml:space="preserve">
-    <value>La ID ya es privada.</value>
-  </data>
-	<data name="IDNowPrivate" xml:space="preserve">
-    <value>La ID ahora es privada y solo tú puedes usarla. Puedes volver a establecerlo como público con `makepublic &lt;ID&gt;`</value>
-  </data>
-	<data name="NoIDs" xml:space="preserve">
-    <value>{0} no tiene ningun(a) ID de aventura.</value>
-  </data>
-	<data name="IDList" xml:space="preserve">
-    <value>Lista de IDs para {0}</value>
-  </data>
-	<data name="IsPublic" xml:space="preserve">
-    <value>Es pública</value>
-  </data>
-	<data name="IDListFooter" xml:space="preserve">
-    <value>Usa 'idinfo &lt;ID&gt;' para obtener información de una ID de aventura.</value>
-  </data>
-	<data name="IDInfo" xml:space="preserve">
-    <value>Información de la ID de aventura</value>
-  </data>
-	<data name="AboutAIDText" xml:space="preserve">
-    <value>AI Dungeon es la primera aventura de texto generada por IA. Usando un modelo de aprendizaje automático de parámetros 1.5B llamado GPT-2, AI Dungeon genera la historia y los resultados de tus acciones mientras juegas en este mundo virtual. A diferencia de prácticamente todos los demás juegos existentes, no estás limitado por la imaginación del desarrollador en lo que puedes hacer. Cualquier cosa que puedas expresar en lenguaje puede ser tu acción y el maestro de mazmorras de IA decidirá cómo responderá el mundo a tus acciones.</value>
-  </data>
-	<data name="AIDHowToPlayText" xml:space="preserve">
-    <value>Las reglas de AI Dungeon son simples:
-1. El juego pone "Tú" al frente de cada acción, por lo que cada acción debe comenzar con un verbo. Ex. "atacar al dragón", "convocar un sándwich", etc.
-2. Como excepción a lo anterior: si pone su acción entre comillas Ej. "¿Te unirás a mí en mi búsqueda?", Entonces el juego agregará "Tú dices" al frente.</value>
-  </data>
-	<data name="AboutAIDTitle" xml:space="preserve">
-    <value>Sobre AI Dungeon</value>
-  </data>
-	<data name="AIDHelp" xml:space="preserve">
-    <value>Ayuda de AI Dungeon</value>
-  </data>
-	<data name="AIDHowToPlayTitle" xml:space="preserve">
-    <value>Cómo jugar</value>
-  </data>
-	<data name="Commands" xml:space="preserve">
-    <value>Comandos</value>
-  </data>
-	<data name="AIDTips" xml:space="preserve">
-    <value>Intenta usar palabras nuevas con frecuencia, la IA se vuelve más creativa con variedad.
-Recuerda comenzar con "do" junto a un verbo, ex: Atacar el orco.
-Usa el comando undo para eliminar tu última entrada junta a la respuesta de la IA.
-¡Las oraciones largas para acciones no son un problema! ¡Se creativo!
-¿Quieres más historia para generar? Usa el comando continue sin ningún texto.
-Usa el comando remember para editar el contexto de la historia que la IA siempre recuerda.
-Intenta usar palabras nuevas con frecuencia, la IA se vuelve más creativa con variedad.
-Puedes usar Say luego de el texto para hablar.</value>
-  </data>
-	<data name="In" xml:space="preserve">
-    <value>En</value>
-  </data>
-	<data name="configSummary" xml:space="preserve">
-    <value>Muestra un embed con las opciones configuración del bot a nivel de servidor.</value>
-  </data>
-	<data name="AdminRequired" xml:space="preserve">
-    <value>Debes ser un administrador para usar este comando!</value>
-  </data>
-	<data name="Loading" xml:space="preserve">
-    <value>Cargando...</value>
-  </data>
-	<data name="FergunConfiguration" xml:space="preserve">
-    <value>Configración de Fergun</value>
-  </data>
-	<data name="ConfigCanceled" xml:space="preserve">
-    <value>Configuración cancelada.</value>
-  </data>
-	<data name="CantDoThat" xml:space="preserve">
-    <value>No puedo hacer eso.</value>
-  </data>
-	<data name="softbanSummary" xml:space="preserve">
-    <value>Softbanea un usuario (kick + eliminar los mensajes del usuario).</value>
-  </data>
-	<data name="softbanParam1" xml:space="preserve">
-    <value>El usuario a softbanear.</value>
-  </data>
-	<data name="softbanParam2" xml:space="preserve">
-    <value>El número de días a eliminar sus últimos mensajes. 7 por defecto.</value>
-  </data>
-	<data name="softbanParam3" xml:space="preserve">
-    <value>La razón del softbaneo.</value>
-  </data>
-	<data name="SoftbanSameUser" xml:space="preserve">
-    <value>Que? Te quieres softbanear a ti mismo?</value>
-  </data>
-	<data name="SoftbanMyself" xml:space="preserve">
-    <value>No me voy a softbanear lol</value>
-  </data>
-	<data name="Softbanned" xml:space="preserve">
-    <value>El usuario {0} fue softbaneado.</value>
-  </data>
-	<data name="AIDungeonCommands" xml:space="preserve">
-    <value>AI Dungeon (usa {0}aid &lt;comando&gt;)</value>
-  </data>
-	<data name="RevertingLastAction" xml:space="preserve">
-    <value>Revirtiendo la última acción...</value>
-  </data>
-	<data name="140CharsMax" xml:space="preserve">
-    <value>Máximo 140 carácteres.</value>
-  </data>
-	<data name="InviteLink" xml:space="preserve">
-    <value>Link de invitación</value>
-  </data>
-	<data name="NoManageMessages" xml:space="preserve">
-    <value>Algunos comandos funcionan mejor con el permiso `Gestionar mensajes` (`img`, `urban`, `config`)</value>
-  </data>
-	<data name="InvalidText" xml:space="preserve">
-    <value>Texto inválido.</value>
-  </data>
-	<data name="clearRemarks" xml:space="preserve">
-    <value>Si se pasa un usuario, el comando intentará eliminar todos los mensajes del usuario en los últimos `count` mensajes.</value>
-  </data>
-	<data name="BanMembers" xml:space="preserve">
-    <value>Banear miembros</value>
-  </data>
-	<data name="KickMembers" xml:space="preserve">
-    <value>Expulsar miembros</value>
-  </data>
-	<data name="UserNotBanned" xml:space="preserve">
-    <value>El usuario no está baneado.</value>
-  </data>
-	<data name="BotMention" xml:space="preserve">
-    <value>Mi prefijo aquí es `{0}`. Usa `{0}help` para ver mi lista de comandos y `{0}prefix` para cambiar el prefijo.</value>
-  </data>
-	<data name="NotSupportedInDM" xml:space="preserve">
-    <value>No creo que puedas usar eso aquí.</value>
-  </data>
-	<data name="tcdneSummary" xml:space="preserve">
-    <value>Este gato no existe</value>
-  </data>
-	<data name="tpdneSummary" xml:space="preserve">
-    <value>Esta persona no existe</value>
-  </data>
-	<data name="YTSearchCap" xml:space="preserve">
-    <value>Límite de búsqueda de YouTube alcanzada :(</value>
-  </data>
-	<data name="CreatingVideoCache" xml:space="preserve">
-    <value>Alguien ya está creando un caché de video, espera..</value>
-  </data>
-	<data name="User" xml:space="preserve">
-    <value>Usuario</value>
-  </data>
-	<data name="editsnipeSummary" xml:space="preserve">
-    <value>Muestra el último mensaje editado en el canal actual.</value>
-  </data>
-	<data name="reactionSummary" xml:space="preserve">
-    <value>Agrega una reacción a un mensaje.</value>
-  </data>
-	<data name="reactionParam1" xml:space="preserve">
-    <value>La reacción a agregar (solo una).</value>
-  </data>
-	<data name="reactionParam2" xml:space="preserve">
-    <value>La ID del mensaje a agregar la reacción (debe ser del mismo canal donde el comando es ejecutado).</value>
-  </data>
-	<data name="InvalidMessageID" xml:space="preserve">
-    <value>ID de mensaje inválido. El mensaje debe ser de este canal.</value>
-  </data>
-	<data name="InvalidReaction" xml:space="preserve">
-    <value>Emoji/Emote inválido.</value>
-  </data>
-	<data name="BadArgumentCount" xml:space="preserve">
-    <value>Cantidad de parámetros invalida. Usa `{0}help {1}` para obtener mas información del comando.</value>
-  </data>
-	<data name="CommandParseFailed" xml:space="preserve">
-    <value>Error al analizar los parámetros del comando. Algún parametro es inválido o el orden de los argumentos es incorrecto.
-Usa `{0}help {1}` para obtener mas información del comando.</value>
-  </data>
-	<data name="TranslationResults" xml:space="preserve">
-    <value>Resultados de traducción</value>
-  </data>
-	<data name="SourceLanguage" xml:space="preserve">
-    <value>Lenguaje de origen (Detectado)</value>
-  </data>
-	<data name="TargetLanguage" xml:space="preserve">
-    <value>Lenguaje de destino</value>
-  </data>
-	<data name="Result" xml:space="preserve">
-    <value>Resultado</value>
-  </data>
-	<data name="ErrorType" xml:space="preserve">
-    <value>Tipo de error</value>
-  </data>
-	<data name="ErrorMessage" xml:space="preserve">
-    <value>Mensaje del error</value>
-  </data>
-	<data name="seekSummary" xml:space="preserve">
-    <value>Cambia la posición de reproducción de la pista a la especificada.</value>
-  </data>
-	<data name="seekParam1" xml:space="preserve">
-    <value>La posición del reproductor en el segundo número n o un tiempo válido con el formato `m:ss`, `mm:ss`, `h:mm:ss` o `hh:mm:ss`.</value>
-  </data>
-	<data name="CannotSeek" xml:space="preserve">
-    <value>No puedo cambiar la posición de esta pista.</value>
-  </data>
-	<data name="SeekHigherOrEqual" xml:space="preserve">
-    <value>El segundo a ir ({0}) no puede ser mayor o igual que la longitud de la pista ({1}).</value>
-  </data>
-	<data name="SeekComplete" xml:space="preserve">
-    <value>Saltado al segundo {0} ({1} de {2}).</value>
-  </data>
-	<data name="ErrorInTranslation" xml:space="preserve">
-    <value>Ocurrió un error en la API de traducción.</value>
-  </data>
-	<data name="statsSummary" xml:space="preserve">
-    <value>Muestra las estadísticas del bot.</value>
-  </data>
-	<data name="AdventureDeleted" xml:space="preserve">
-    <value>La aventura fue eliminada.</value>
-  </data>
-	<data name="CPUUsage" xml:space="preserve">
-    <value>Uso de CPU</value>
-  </data>
-	<data name="RAMUsage" xml:space="preserve">
-    <value>Uso de RAM</value>
-  </data>
-	<data name="Library" xml:space="preserve">
-    <value>Librería</value>
-  </data>
-	<data name="BotVersion" xml:space="preserve">
-    <value>Versión del bot</value>
-  </data>
-	<data name="OperatingSystem" xml:space="preserve">
-    <value>Sistema Operativo</value>
-  </data>
-	<data name="BotOwner" xml:space="preserve">
-    <value>Dueño del bot</value>
-  </data>
-	<data name="CurrentNewNickEqual" xml:space="preserve">
-    <value>El nickname actual y el nuevo nickname son iguales.</value>
-  </data>
-	<data name="Yes" xml:space="preserve">
-    <value>Si</value>
-  </data>
-	<data name="No" xml:space="preserve">
-    <value>No</value>
-  </data>
-	<data name="True" xml:space="preserve">
-    <value>Verdadero</value>
-  </data>
-	<data name="False" xml:space="preserve">
-    <value>Falso</value>
-  </data>
-	<data name="ConfigList" xml:space="preserve">
-    <value>Auto traducir resultados de entrada y salida de AI Dungeon
-Selección de pista para `play`</value>
-  </data>
-	<data name="ConfigPrompt" xml:space="preserve">
-    <value>Usa las reacciones para habilitar o deshabilitar una opción.</value>
-  </data>
-	<data name="FergunConfig" xml:space="preserve">
-    <value>Configuración de Fergun</value>
-  </data>
-	<data name="Option" xml:space="preserve">
-    <value>Opción</value>
-  </data>
-	<data name="Value" xml:space="preserve">
-    <value>Valor</value>
-  </data>
-	<data name="MissingPermissions" xml:space="preserve">
-    <value>No tienes los permisos necesarios para ejecutar este comando.</value>
-  </data>
-	<data name="UserBlacklisted" xml:space="preserve">
-    <value>El usuario {0} fue agregado a la lista negra.</value>
-  </data>
-	<data name="UserBlacklistedWithReason" xml:space="preserve">
-    <value>El usuario {0} fue agregado a la lista negra con la razón: {1}</value>
-  </data>
-	<data name="UserBlacklistRemoved" xml:space="preserve">
-    <value>El usuario {0} fue eliminado de la lista negra.</value>
-  </data>
-	<data name="blacklistSummary" xml:space="preserve">
-    <value>Agrega un usuario a la lista negra, o lo elimina de ella.</value>
-  </data>
-	<data name="blacklistParam1" xml:space="preserve">
-    <value>La ID del usuario a agregar a la lista negra.</value>
-  </data>
-	<data name="blacklistParam2" xml:space="preserve">
-    <value>La razón de ser incluido en la lista negra.</value>
-  </data>
-	<data name="Blacklisted" xml:space="preserve">
-    <value>Estás en la lista negra.</value>
-  </data>
-	<data name="BlacklistedWithReason" xml:space="preserve">
-    <value>Estás en la lista negra con la razón: {0}</value>
-  </data>
-	<data name="UserRequireBanMembers" xml:space="preserve">
-    <value>Necesitas el permiso `Banear miembros` para usar este comando.</value>
-  </data>
-	<data name="BotRequireBanMembers" xml:space="preserve">
-    <value>Necesito el permiso `Banear miembros` para ejecutar este comando.</value>
-  </data>
-	<data name="UserRequireManageMessages" xml:space="preserve">
-    <value>Necesitas el permiso `Manejar mensajes` para usar este comando.</value>
-  </data>
-	<data name="BotRequireManageMessages" xml:space="preserve">
-    <value>Necesito el permiso `Manejar mensajes` para ejecutar este comando.</value>
-  </data>
-	<data name="UserRequireKickMembers" xml:space="preserve">
-    <value>Necesitas el permiso `Expulsar miembros` para usar este comando.</value>
-  </data>
-	<data name="BotRequireKickMembers" xml:space="preserve">
-    <value>Necesito el permiso `Expulsar miembros` para ejecutar este comando.</value>
-  </data>
-	<data name="UserRequireManageNicknames" xml:space="preserve">
-    <value>Necesitas el permiso `Gestionar apodos` para usar este comando.</value>
-  </data>
-	<data name="BotRequireManageNicknames" xml:space="preserve">
-    <value>ID de mensaje inválido. El mensaje debe ser de este canal.</value>
-  </data>
-	<data name="UserNotLowerHierarchy" xml:space="preserve">
-    <value>El/La usuario(a) especificado(a) debe ser inferior en jerarquía.</value>
-  </data>
-	<data name="BotRequireConnect" xml:space="preserve">
-    <value>Necesito el permiso `Conectar` para entrar al canal de voz.</value>
-  </data>
-	<data name="BotRequireSpeak" xml:space="preserve">
-    <value>Necesito el permiso `Hablar` para continuar.</value>
-  </data>
-	<data name="MoveSameChannel" xml:space="preserve">
-    <value>Ve al canal de voz donde quieres que vaya.</value>
-  </data>
-	<data name="ProcessingTime" xml:space="preserve">
-    <value>Tiempo de procesado: {0}ms</value>
-  </data>
-	<data name="OcrResults" xml:space="preserve">
-    <value>Resultados de OCR</value>
-  </data>
-	<data name="BotRequireAttachFiles" xml:space="preserve">
-    <value>Necesito el permiso `Adjuntar archivos` para ejecutar este comando.</value>
-  </data>
-	<data name="OcrApiError" xml:space="preserve">
-    <value>Error en el API de OCR.</value>
-  </data>
-	<data name="forceprefixSummary" xml:space="preserve">
-    <value>Fuerza el prefijo en este servidor.</value>
-  </data>
-	<data name="FirstTip" xml:space="preserve">
-    <value>Usa {0}aid continue &lt;ID&gt; [texto] para continuar tu aventura.</value>
-  </data>
-	<data name="PrefixTooLarge" xml:space="preserve">
-    <value>El prefijo es demasiado largo. El tamaño máximo del prefijo es: {0}.</value>
-  </data>
-	<data name="InvalidUrl" xml:space="preserve">
-    <value>URL inválida.</value>
-  </data>
-	<data name="ocrtranslateSummary" xml:space="preserve">
-    <value>OCR y traducción.</value>
-  </data>
-	<data name="ocrtranslateParam1" xml:space="preserve">
-    <value>El idioma de destino en el código ISO (en, es, br, etc.)</value>
-  </data>
-	<data name="ocrtranslateParam2" xml:space="preserve">
-    <value>La url de una imagen a usar.</value>
-  </data>
-	<data name="Input" xml:space="preserve">
-    <value>Entrada</value>
-  </data>
-	<data name="RoleNotFound" xml:space="preserve">
-    <value>Rol no encontrado.</value>
-  </data>
-	<data name="RoleInfo" xml:space="preserve">
-    <value>Información de rol</value>
-  </data>
-	<data name="Color" xml:space="preserve">
-    <value>Color</value>
-  </data>
-	<data name="IsMentionable" xml:space="preserve">
-    <value>Es mencionable</value>
-  </data>
-	<data name="Permissions" xml:space="preserve">
-    <value>Permisos</value>
-  </data>
-	<data name="MemberCount" xml:space="preserve">
-    <value>Cantidad de miembros</value>
-  </data>
-	<data name="Mention" xml:space="preserve">
-    <value>Mención</value>
-  </data>
-	<data name="roleinfoSummary" xml:space="preserve">
-    <value>Obtiene información sobre un rol.</value>
-  </data>
-	<data name="roleinfoParam1" xml:space="preserve">
-    <value>El rol a obtener información (no es necesario mencionarlo).</value>
-  </data>
-	<data name="logoutSummary" xml:space="preserve">
-    <value>Desconecta el bot.</value>
-  </data>
-	<data name="nothingSummary" xml:space="preserve">
-    <value>El mejor comando.</value>
-  </data>
-	<data name="someoneSummary" xml:space="preserve">
-    <value>Retorna un usuario aleatorio.</value>
-  </data>
-	<data name="ActiveClients" xml:space="preserve">
-    <value>Clientes activos</value>
-  </data>
-	<data name="Low" xml:space="preserve">
-    <value>Bajo</value>
-  </data>
-	<data name="Medium" xml:space="preserve">
-    <value>Medio</value>
-  </data>
-	<data name="High" xml:space="preserve">
-    <value>Alto</value>
-  </data>
-	<data name="Extreme" xml:space="preserve">
-    <value>Extremo</value>
-  </data>
-	<data name="IsHoisted" xml:space="preserve">
-    <value>Es un rol separado</value>
-  </data>
-	<data name="Position" xml:space="preserve">
-    <value>Posición</value>
-  </data>
-	<data name="ServerFeatures" xml:space="preserve">
-    <value>Mejoras</value>
-  </data>
-	<data name="BoostTier" xml:space="preserve">
-    <value>Nivel de mejora</value>
-  </data>
-	<data name="BoostCount" xml:space="preserve">
-    <value>Cantidad de mejoras</value>
-  </data>
-	<data name="SeekTimeHigherOrEqual" xml:space="preserve">
-    <value>El tiempo a ir ({0}) no puede ser mayor o igual que la longitud de la pista ({1}).</value>
-  </data>
-	<data name="SeekTimeComplete" xml:space="preserve">
-    <value>Saltado a {0} de {1}</value>
-  </data>
-	<data name="SeekInvalidFormat" xml:space="preserve">
-    <value>Debes pasar un número o un texto con el formato `m:ss`, `mm:ss`, `h:mm:ss` o `hh:mm:ss`.</value>
-  </data>
-	<data name="Ratelimited" xml:space="preserve">
-    <value>Espera {0} segundos antes de volver a usar este comando!</value>
-  </data>
-	<data name="ObjectNotFound" xml:space="preserve">
-    <value>El objeto no fue encontrado.</value>
-  </data>
-	<data name="MultipleMatches" xml:space="preserve">
-    <value>Se encontraron múltiples coincidencias.
-Intenta usar un tag ({0}), una mención ({1}) o una ID ({2}).</value>
-  </data>
-	<data name="Roles" xml:space="preserve">
-    <value>Roles</value>
-  </data>
-	<data name="BoostingSince" xml:space="preserve">
-    <value>Mejorando desde</value>
-  </data>
-	<data name="AlreadyConnected" xml:space="preserve">
-    <value>El bot ya está conectado a un canal de voz.</value>
-  </data>
-	<data name="InvalidFileType" xml:space="preserve">
-    <value>Tipo de archivo invalido.</value>
-  </data>
-	<data name="OwnerCommands" xml:space="preserve">
-    <value>Comandos del dueño</value>
-  </data>
-	<data name="Vote" xml:space="preserve">
-    <value>Puedes votar por mi [aquí]({0}). Gracias.</value>
-  </data>
-	<data name="voteSummary" xml:space="preserve">
-    <value>Muestra un embed con el link de votación.</value>
-  </data>
-	<data name="TotalServers" xml:space="preserve">
-    <value>Servidores totales</value>
-  </data>
-	<data name="TotalUsers" xml:space="preserve">
-    <value>Usuarios totales</value>
-  </data>
-	<data name="ContactInfo" xml:space="preserve">
-    <value>Si tienes algún reporte de bug, pregunta o sugerencia, puedes entrar al [servidor de soporte]({0}) o contactarme vía mensaje directo ({1}).</value>
-  </data>
-	<data name="RedoingLastAction" xml:space="preserve">
-    <value>Rehaciendo la última acción...</value>
-  </data>
-	<data name="SelfNoIDs" xml:space="preserve">
-    <value>No tienes ninguna ID de aventura.
-Puedes usar el comando `new` para crear una nueva aventura.</value>
-  </data>
-	<data name="QueueExcess" xml:space="preserve">
-    <value>..y {0} pistas mas!</value>
-  </data>
-	<data name="ErrorHelp" xml:space="preserve">
-    <value>Si esto sigue sucediendo, reporte este error en el [servidor de soporte]({0}) o abra un issue (propuesta) en el [repositorio de GitHub]({1}) describiendo el error y los pasos para reproducirlo.</value>
-  </data>
-	<data name="bashSummary" xml:space="preserve">
-    <value>Ejecuta un comando de bash.</value>
-  </data>
-	<data name="supportSummary" xml:space="preserve">
-    <value>Muestra la información de soporte.</value>
-  </data>
-	<data name="ErrorInAPI" xml:space="preserve">
-    <value>Ocurrió un error mientras se llamaba a la API.</value>
-  </data>
-	<data name="Topic" xml:space="preserve">
-    <value>Tema</value>
-  </data>
-	<data name="IsNSFW" xml:space="preserve">
-    <value>Es NSFW</value>
-  </data>
-	<data name="SlowMode" xml:space="preserve">
-    <value>Modo pausado</value>
-  </data>
-	<data name="ChannelInfo" xml:space="preserve">
-    <value>Información del canal</value>
-  </data>
-	<data name="channelinfoSummary" xml:space="preserve">
-    <value>Muestra información acerca de un canal.</value>
-  </data>
-	<data name="OcrtrResults" xml:space="preserve">
-    <value>Resultados de OCR y traducción</value>
-  </data>
-	<data name="NoChoices" xml:space="preserve">
-    <value>Debes pasar al menos una opción.</value>
-  </data>
-	<data name="ChannelNotFound" xml:space="preserve">
-    <value>Canal no encontrado.</value>
-  </data>
-	<data name="channelinfoParam1" xml:space="preserve">
-    <value>El canal a obtener la información.</value>
-  </data>
-	<data name="UserRequireManageServer" xml:space="preserve">
-    <value>Necesitas el permiso `Gestionar servidor` para usar este comando.</value>
-  </data>
-	<data name="badtranslatorSummary" xml:space="preserve">
-    <value>Pasa un texto a través de un traductor malo.</value>
-  </data>
-	<data name="badtranslatorParam1" xml:space="preserve">
-    <value>El texto a usar.</value>
-  </data>
-	<data name="LanguageChain" xml:space="preserve">
-    <value>Cadena de idiomas</value>
-  </data>
-	<data name="VersionNotFound" xml:space="preserve">
-    <value>Versión no encontrada. Las versiones son: {0}</value>
-  </data>
-	<data name="OtherVersions" xml:space="preserve">
-    <value>Otras versiones: {0}</value>
-  </data>
-	<data name="DeletedMessages" xml:space="preserve">
-    <value>Eliminado {0} mensajes.</value>
-  </data>
-	<data name="DeletedMessagesByUser" xml:space="preserve">
-    <value>Eliminado {0} mensajes por {1}.</value>
-  </data>
-	<data name="calcSummary" xml:space="preserve">
-    <value>Evalúa una expresion matemática.</value>
-  </data>
-	<data name="calcParam1" xml:space="preserve">
-    <value>La expresión a evaluar.</value>
-  </data>
-	<data name="InvalidExpression" xml:space="preserve">
-    <value>Expresión inválida.</value>
-  </data>
-	<data name="CalcResults" xml:space="preserve">
-    <value>Resultados de calc</value>
-  </data>
-	<data name="LoopUpdated" xml:space="preserve">
-    <value>El reproductor fue actualizado para repetir la pista {0} veces. Usa este comando sin parámetros para deshabilitar la repetición de pistas.</value>
-  </data>
-	<data name="LoopNoValuePassed" xml:space="preserve">
-    <value>Pasa el número de veces que quieres repetir la pista, ej:
-`{0}loop 5`</value>
-  </data>
-	<data name="NowLooping" xml:space="preserve">
-    <value>Ahora la pista actual se repetirá {0} veces.
-Para desactivar la repetición, usa este comando sin pasar ningún parámetro.</value>
-  </data>
-	<data name="LoopEnded" xml:space="preserve">
-    <value>La repetición para la pista: {0} ha terminado.</value>
-  </data>
-	<data name="loopSummary" xml:space="preserve">
-    <value>Repite la pista en reproducción un número de veces.</value>
-  </data>
-	<data name="loopParam1" xml:space="preserve">
-    <value>El número de veces a repetir la pista.</value>
-  </data>
-	<data name="LoopDisabled" xml:space="preserve">
-    <value>La repetición de la pista fue deshabilitada.</value>
-  </data>
-	<data name="LyricsQueryNotPassed" xml:space="preserve">
-    <value>Debes reproducir una pista con el bot o pasar el nombre de una pista.</value>
-  </data>
-	<data name="LyricsByGenius" xml:space="preserve">
-    <value>Letra por Genius</value>
-  </data>
-	<data name="PaginatorHelp" xml:space="preserve">
-    <value>Esto es un paginador. Reacciona a los íconos respectivos para cambiar la página.</value>
-  </data>
-	<data name="ArtistPage" xml:space="preserve">
-    <value>Página del artista</value>
-  </data>
-	<data name="AdventureDeletionPrompt" xml:space="preserve">
-    <value>Presiona el botón/emoji debajo para eliminar la aventura.</value>
-  </data>
-	<data name="ReactTimeout" xml:space="preserve">
-    <value>No reaccionaste antes del límite de tiempo!</value>
-  </data>
-	<data name="LanguageSelection" xml:space="preserve">
-    <value>Selección de idioma</value>
-  </data>
-	<data name="LanguagePrompt" xml:space="preserve">
-    <value>Selecciona el idioma que quieres establecer.</value>
-  </data>
-	<data name="lyricsRemarks" xml:space="preserve">
-    <value>Usa `-headers` al final del comando para mantener los tags (`[info]`).</value>
-  </data>
-	<data name="HelpFooter2" xml:space="preserve">
-    <value>&lt;&gt; = Requerido | [] = Opcional | No escribas estos símbolos con el comando.</value>
-  </data>
-	<data name="invertSummary" xml:space="preserve">
-    <value>Invierte (convierte a negativo) una imagen.</value>
-  </data>
-	<data name="invertParam1" xml:space="preserve">
-    <value>La url de una imagen a usar.</value>
-  </data>
-	<data name="MessagesOlderThan2Weeks" xml:space="preserve">
-    <value>Los mensajes deben tener menos de dos semanas de antigüedad.</value>
-  </data>
-	<data name="SomeMessagesNotDeleted" xml:space="preserve">
-    <value>No se pudieron eliminar {0} mensajes porque tienen mas de dos semanas de antigüedad.</value>
-  </data>
-	<data name="GuildNotFound" xml:space="preserve">
-    <value>Servidor no encontrado.</value>
-  </data>
-	<data name="ErrorParsingLyrics" xml:space="preserve">
-    <value>Ocurrió un error mientras se extraía la letra de {0}.
-Tal vez es un instrumental?</value>
-  </data>
-	<data name="MustBeLowerThan" xml:space="preserve">
-    <value>El parámetro `{0}` debe ser menor que {1}.</value>
-  </data>
-	<data name="InvalidID" xml:space="preserve">
-    <value>ID inválida.</value>
-  </data>
-	<data name="HastebinLink" xml:space="preserve">
-    <value>Link de Hastebin</value>
-  </data>
-	<data name="NotAvailable" xml:space="preserve">
-    <value>No disponible.</value>
-  </data>
-	<data name="WaitingQueue" xml:space="preserve">
-    <value>Esperando que la cola se vacíe...</value>
-  </data>
-	<data name="NewOutputPrompt" xml:space="preserve">
-    <value>Ingresa el texto nuevo</value>
-  </data>
-	<data name="GeneratingNewResponse" xml:space="preserve">
-    <value>Generando una nueva respuesta...</value>
-  </data>
-	<data name="InputTypes" xml:space="preserve">
-    <value>Opciones de entrada</value>
-  </data>
-	<data name="InputTypesList" xml:space="preserve">
-    <value>Do: Una acción que quieres tomar en la historia. Siempre comienza por una acción, ej. "Buscar el tesoro perdido". Este es el tipo de entrada por defecto si ningúno es detectado.
-
-Say: Puedes usar esto para hablar, ej. "Déjalos solos!".
-
-Story: Previene que la IA pongo "Tú" en frente de tu entrada.</value>
-  </data>
-	<data name="Members" xml:space="preserve">
-    <value>Miembros</value>
-  </data>
-	<data name="Online" xml:space="preserve">
-    <value>En línea</value>
-  </data>
-	<data name="Idle" xml:space="preserve">
-    <value>Ausente</value>
-  </data>
-	<data name="DnD" xml:space="preserve">
-    <value>No molestar</value>
-  </data>
-	<data name="Offline" xml:space="preserve">
-    <value>Desconectado</value>
-  </data>
-	<data name="ChannelCount" xml:space="preserve">
-    <value>Número de canales</value>
-  </data>
-	<data name="Region" xml:space="preserve">
-    <value>Región</value>
-  </data>
-	<data name="DefaultChannel" xml:space="preserve">
-    <value>Canal por defecto</value>
-  </data>
-	<data name="LanguageNotFound" xml:space="preserve">
-    <value>Idioma no encontrado.</value>
-  </data>
-	<data name="CategoryCount" xml:space="preserve">
-    <value>Número de categorías</value>
-  </data>
-	<data name="DumpingAdventure" xml:space="preserve">
-    <value>Volcando la aventura...</value>
-  </data>
-	<data name="EditCanceled" xml:space="preserve">
-    <value>Edición cancelada.</value>
-  </data>
-	<data name="NothingToDelete" xml:space="preserve">
-    <value>Nada que eliminar.</value>
-  </data>
-	<data name="aidinfoSummary" xml:space="preserve">
-    <value>Muestra la info de AI Dungeon.</value>
-  </data>
-	<data name="aidnewSummary" xml:space="preserve">
-    <value>Crea una nueva aventura.</value>
-  </data>
-	<data name="continueSummary" xml:space="preserve">
-    <value>Continúa la aventura con el texto ingresado. Si no se da un texto, la IA generará una historia.</value>
-  </data>
-	<data name="continueParam1" xml:space="preserve">
-    <value>La ID de aventura.</value>
-  </data>
-	<data name="continueParam2" xml:space="preserve">
-    <value>El texto a usar.</value>
-  </data>
-	<data name="undoSummary" xml:space="preserve">
-    <value>Deshace la última acción.</value>
-  </data>
-	<data name="undoParam1" xml:space="preserve">
-    <value>La ID de aventura.</value>
-  </data>
-	<data name="redoSummary" xml:space="preserve">
-    <value>Rehace la última acción deshecha.</value>
-  </data>
-	<data name="redoParam1" xml:space="preserve">
-    <value>La ID de aventura.</value>
-  </data>
-	<data name="rememberSummary" xml:space="preserve">
-    <value>Agrega texto en el contexto de la memoria.</value>
-  </data>
-	<data name="rememberParam1" xml:space="preserve">
-    <value>La ID de aventura.</value>
-  </data>
-	<data name="rememberParam2" xml:space="preserve">
-    <value>El texto a agregar.</value>
-  </data>
-	<data name="alterSummary" xml:space="preserve">
-    <value>Edita la última respuesta.</value>
-  </data>
-	<data name="alterParam1" xml:space="preserve">
-    <value>La ID de aventura.</value>
-  </data>
-	<data name="retrySummary" xml:space="preserve">
-    <value>Reintenta la última acción y genera una nueva respuesta.</value>
-  </data>
-	<data name="retryParam1" xml:space="preserve">
-    <value>La ID de aventura.</value>
-  </data>
-	<data name="makepublicSummary" xml:space="preserve">
-    <value>Hace público una ID.</value>
-  </data>
-	<data name="makepublicParam1" xml:space="preserve">
-    <value>La ID de aventura. Tienes que ser el dueño de esta ID.</value>
-  </data>
-	<data name="makeprivateSummary" xml:space="preserve">
-    <value>Hace privado una ID.</value>
-  </data>
-	<data name="makeprivateParam1" xml:space="preserve">
-    <value>La ID de aventura. Tienes que ser el dueño de esta ID.</value>
-  </data>
-	<data name="idlistSummary" xml:space="preserve">
-    <value>Muestra la lista de IDs de un usuario.</value>
-  </data>
-	<data name="idlistParam1" xml:space="preserve">
-    <value>El usuario a obtener sus IDs.</value>
-  </data>
-	<data name="idinfoSummary" xml:space="preserve">
-    <value>Muestra información acerca de una ID.</value>
-  </data>
-	<data name="idinfoParam1" xml:space="preserve">
-    <value>La ID de aventura.</value>
-  </data>
-	<data name="deleteSummary" xml:space="preserve">
-    <value>Elimina una ID.</value>
-  </data>
-	<data name="deleteParam1" xml:space="preserve">
-    <value>La ID de aventura.</value>
-  </data>
-	<data name="dumpSummary" xml:space="preserve">
-    <value>Extrae todo el texto de una ID.</value>
-  </data>
-	<data name="dumpParam1" xml:space="preserve">
-    <value>La ID de aventura.</value>
-  </data>
-	<data name="RatelimitUses" xml:space="preserve">
-    <value>{0} uso(s) cada {1}</value>
-  </data>
-	<data name="Requirements" xml:space="preserve">
-    <value>Requisitos</value>
-  </data>
-	<data name="cmdstatsSummary" xml:space="preserve">
-    <value>Muestra las estadísticas de los comandos.</value>
-  </data>
-	<data name="CommandStatsInfo" xml:space="preserve">
-    <value>Command stats (desde {0})</value>
-  </data>
-	<data name="lmgtfySummary" xml:space="preserve">
-    <value>Permíteme que use Google por ti.</value>
-  </data>
-	<data name="lmgtfyParam1" xml:space="preserve">
-    <value>Las palabras clave a buscar.</value>
-  </data>
-	<data name="pasteSummary" xml:space="preserve">
-    <value>Sube texto a Hastebin.</value>
-  </data>
-	<data name="pasteParam1" xml:space="preserve">
-    <value>El texto a subir.</value>
-  </data>
-	<data name="Uploading" xml:space="preserve">
-    <value>Subiendo...</value>
-  </data>
-	<data name="bigsnipeSummary" xml:space="preserve">
-    <value>Muestra los últimos mensajes eliminados en el canal actual.</value>
-  </data>
-	<data name="bigsnipeParam1" xml:space="preserve">
-    <value>Un canal en específico a buscar.</value>
-  </data>
-	<data name="MinutesAgo" xml:space="preserve">
-    <value>Hace {0} minutos</value>
-  </data>
-	<data name="Attachment" xml:space="preserve">
-    <value>Archivo adjunto</value>
-  </data>
-	<data name="bigeditsnipeSummary" xml:space="preserve">
-    <value>Muestra los últimos mensajes editados en el canal actual.</value>
-  </data>
-	<data name="bigeditsnipeParam1" xml:space="preserve">
-    <value>Un canal en específico a buscar.</value>
-  </data>
-	<data name="TextChannel" xml:space="preserve">
-    <value>Canal de Texto</value>
-  </data>
-	<data name="AnnouncementChannel" xml:space="preserve">
-    <value>Canal de Anuncios</value>
-  </data>
-	<data name="VoiceChannel" xml:space="preserve">
-    <value>Canal de Voz</value>
-  </data>
-	<data name="DMChannel" xml:space="preserve">
-    <value>Canal de MD</value>
-  </data>
-	<data name="Bitrate" xml:space="preserve">
-    <value>Tasa de bits</value>
-  </data>
-	<data name="UserLimit" xml:space="preserve">
-    <value>Límite de usuarios</value>
-  </data>
-	<data name="NoLimit" xml:space="preserve">
-    <value>Sin límite</value>
-  </data>
-	<data name="disableSummary" xml:space="preserve">
-    <value>Deshabilita un comando localmente (para este servidor).</value>
-  </data>
-	<data name="disableParam1" xml:space="preserve">
-    <value>El nombre del comando a deshabilitar.</value>
-  </data>
-	<data name="NonDisableable" xml:space="preserve">
-    <value>{0} no puede ser deshabilitado!</value>
-  </data>
-	<data name="AlreadyDisabled" xml:space="preserve">
-    <value>{0} ya está deshabilitado!</value>
-  </data>
-	<data name="CommandDisabled" xml:space="preserve">
-    <value>El comando {0} ha sido deshabilitado en este servidor.</value>
-  </data>
-	<data name="enableSummary" xml:space="preserve">
-    <value>Habilita un comando localmente (para este servidor).</value>
-  </data>
-	<data name="enableParam1" xml:space="preserve">
-    <value>El nombre del comando a habilitar.</value>
-  </data>
-	<data name="AlreadyEnabled" xml:space="preserve">
-    <value>{0} ya está habilitado!</value>
-  </data>
-	<data name="CommandEnabled" xml:space="preserve">
-    <value>El comando {0} ha sido habilitado en este servidor.</value>
-  </data>
-	<data name="globaldisableSummary" xml:space="preserve">
-    <value>Deshabilita un comando globalmente (en todos los servidores).</value>
-  </data>
-	<data name="globaldisableParam1" xml:space="preserve">
-    <value>El nombre del comando a deshabilitar globalmente.</value>
-  </data>
-	<data name="globaldisableParam2" xml:space="preserve">
-    <value>The razón a usar.</value>
-  </data>
-	<data name="AlreadyDisabledGlobally" xml:space="preserve">
-    <value>{0} ya está deshabilitado globalmente!</value>
-  </data>
-	<data name="CommandDisabledGlobally" xml:space="preserve">
-    <value>El comando {0} ha sido deshabilitado en todos los servidores.</value>
-  </data>
-	<data name="globalenableSummary" xml:space="preserve">
-    <value>Habilita un comando globalmente (en todos los servidores).</value>
-  </data>
-	<data name="AlreadyEnabledGlobally" xml:space="preserve">
-    <value>{0} ya está habilitado globalmente!</value>
-  </data>
-	<data name="CommandEnabledGlobally" xml:space="preserve">
-    <value>El comando {0} ha sido habilitado en todos los servidores.</value>
-  </data>
-	<data name="globalenableParam1" xml:space="preserve">
-    <value>El nombre del comando a habilitar globalmente.</value>
-  </data>
-	<data name="Reason" xml:space="preserve">
-    <value>Razón</value>
-  </data>
-	<data name="LeftVCInactivity" xml:space="preserve">
-		<value>He salido del canal de voz por inactividad.</value>
-	</data>
-	<data name="MusicPlayerShutdownWarning" xml:space="preserve">
-		<value>Fergun será reiniciado/apagado en unos segundos y tu reproductor de música sera cerrado. Lo siento por los inconvenientes ocasionados.</value>
-	</data>
-	<data name="Warning" xml:space="preserve">
-		<value>Advertencia</value>
-	</data>
-	<data name="PublicIdNull" xml:space="preserve">
-		<value>La ID pública de esta aventura es nula. Por favor crea una nueva aventura.</value>
-	</data>
-	<data name="giveSummary" xml:space="preserve">
-		<value>Regala (transfiere) la ID especificada a un usuario.</value>
-	</data>
-	<data name="giveParam1" xml:space="preserve">
-		<value>La ID de aventura.</value>
-	</data>
-	<data name="giveParam2" xml:space="preserve">
-		<value>El usuario a regalar la ID.</value>
-	</data>
-	<data name="CannotGiveYourself" xml:space="preserve">
-		<value>No puedes regalar la ID a ti mismo!</value>
-	</data>
-	<data name="GaveId" xml:space="preserve">
-		<value>Se transfirió correctamente la ID a {0}.</value>
-	</data>
-	<data name="Invite" xml:space="preserve">
-		<value>Invitación</value>
-	</data>
-	<data name="TopGGBotPage" xml:space="preserve">
-		<value>Página de Top.GG</value>
-	</data>
-	<data name="VoteLink" xml:space="preserve">
-		<value>Link de Votación</value>
-	</data>
-	<data name="SupportServer" xml:space="preserve">
-		<value>Servidor de Soporte</value>
-	</data>
-	<data name="ContactInfoNoServer" xml:space="preserve">
-		<value>Si tienes algún reporte de bug, pregunta o sugerencia, puedes contactarme vía mensaje directo ({0}).</value>
-	</data>
-	<data name="ValueNotSetInConfig" xml:space="preserve">
-		<value>El valor de `{0}` no ha sido establecido en la configuración. Si este problema persiste, contacta a los desarrolladores.</value>
-	</data>
-	<data name="CannotGiveToBot" xml:space="preserve">
-		<value>No puedes dar la ID a un bot!</value>
-	</data>
-	<data name="NoAvailableLanguages" xml:space="preserve">
-		<value>Parece que no hay idiomas disponibles.</value>
-	</data>
-	<data name="RequestTimedOut" xml:space="preserve">
-		<value>Tiempo de espera agotado para la solicitud.</value>
-	</data>
-	<data name="DiscordServerError" xml:space="preserve">
-		<value>Error en los servidores de Discord</value>
-	</data>
-	<data name="DiscordServerErrorInfo" xml:space="preserve">
-		<value>El comando ha fallado debido a un problema en los servidores de Discord.
-Por favor inténtelo de nuevo más tarde.</value>
-	</data>
-	<data name="ErrorDetails" xml:space="preserve">
-		<value>Detalles del error</value>
-	</data>
-	<data name="NowhereToVote" xml:space="preserve">
-		<value>No hay ningún lugar para votar.</value>
-	</data>
-	<data name="LavalinkNotConnected" xml:space="preserve">
-		<value>No se pudo conectar al servidor de Lavalink. Por favor inténtelo de nuevo más tarde.</value>
-	</data>
-	<data name="ServerBlacklisted" xml:space="preserve">
-		<value>El servidor {0} fue agregado a la lista negra.</value>
-	</data>
-	<data name="ServerBlacklistedWithReason" xml:space="preserve">
-		<value>El servidor {0} fue agregado a la lista negra con la razón: {1}</value>
-	</data>
-	<data name="blacklistserverSummary" xml:space="preserve">
-		<value>Agrega un servidor a la lista negra, o lo elimina de ella.</value>
-	</data>
-	<data name="blacklistserverParam1" xml:space="preserve">
-		<value>La ID del servidor a agregar a la lista negra.</value>
-	</data>
-	<data name="blacklistserverParam2" xml:space="preserve">
-		<value>La razón de ser incluido en la lista negra.</value>
-	</data>
-	<data name="ServerBlacklistRemoved" xml:space="preserve">
-		<value>El servidor {0} fue eliminado de la lista negra.</value>
-	</data>
-	<data name="NoPresenceIntent" xml:space="preserve">
-		<value>No puedo obtener el estado de Spotify porque no tengo: Guild Presences Intent.</value>
-	</data>
-	<data name="NoSpotifyStatus" xml:space="preserve">
-		<value>No se encontró ningún estado de Spotify para el usuario {0}.</value>
-	</data>
-	<data name="ClickHere" xml:space="preserve">
-		<value>Click aquí</value>
-	</data>
-	<data name="Title" xml:space="preserve">
-		<value>Título</value>
-	</data>
-	<data name="Artists" xml:space="preserve">
-		<value>Artista(s)</value>
-	</data>
-	<data name="Album" xml:space="preserve">
-		<value>Álbum</value>
-	</data>
-	<data name="Duration" xml:space="preserve">
-		<value>Duración</value>
-	</data>
-	<data name="Lyrics" xml:space="preserve">
-		<value>Letra</value>
-	</data>
-	<data name="TrackUrl" xml:space="preserve">
-		<value>Url de la pista musical</value>
-	</data>
-	<data name="spotifySummary" xml:space="preserve">
-		<value>Obtiene la información del estado de Spotify de un usuario.</value>
-	</data>
-	<data name="spotifyParam1" xml:space="preserve">
-		<value>El usuario a obtener la información de estado de Spotify.</value>
-	</data>
-	<data name="wolframalphaSummary" xml:space="preserve">
-		<value>Obtiene una respuesta de Wolfram|Alpha basado en la consulta.</value>
-	</data>
-	<data name="wolframalphaParam1" xml:space="preserve">
-		<value>La consulta a enviar.</value>
-	</data>
-	<data name="defineSummary" xml:space="preserve">
-		<value>Obtiene definiciones de una palabra.</value>
-	</data>
-	<data name="defineParam1" xml:space="preserve">
-		<value>La palabra a buscar.</value>
-	</data>
-	<data name="Word" xml:space="preserve">
-		<value>Palabra</value>
-	</data>
-	<data name="Definition" xml:space="preserve">
-		<value>Definición</value>
-	</data>
-	<data name="Synonyms" xml:space="preserve">
-		<value>Sinónimos</value>
-	</data>
-	<data name="Antonyms" xml:space="preserve">
-		<value>Antónimos</value>
-	</data>
-	<data name="archiveSummary" xml:space="preserve">
-		<value>Obtiene una captura de pantalla de un sitio web en el año/mes/día especifado usando una marca de tiempo.</value>
-	</data>
-	<data name="archiveParam1" xml:space="preserve">
-		<value>La Url del sitio web a tomar una captura de pantalla.</value>
-	</data>
-	<data name="archiveParam2" xml:space="preserve">
-		<value>La marca de tiempo con el formato especificado.</value>
-	</data>
-	<data name="TimestampFormat" xml:space="preserve">
-		<value>La marca de tiempo debe tener el formato `YYYYMMDDhhmmss`, donde `YYYY` es requerido y los otros valores son opcionales.</value>
-	</data>
-	<data name="InvalidTimestamp" xml:space="preserve">
-		<value>Marca de tiempo inválida.</value>
-	</data>
-	<data name="NoSnapshots" xml:space="preserve">
-		<value>No se encontraron instantáneas para la url y marca de tiempo especificados.</value>
-	</data>
-	<data name="Timestamp" xml:space="preserve">
-		<value>Marca de tiempo</value>
-	</data>
-	<data name="CouldNotFindLine" xml:space="preserve">
-		<value>No se pudo encontrar el número de línea del método del comando.</value>
-	</data>
-	<data name="shortenSummary" xml:space="preserve">
-		<value>Acorta una Url.</value>
-	</data>
-	<data name="shortenParam1" xml:space="preserve">
-		<value>La Url a acortar.</value>
-	</data>
-	<data name="Badges" xml:space="preserve">
-		<value>Insignias</value>
-	</data>
-	<data name="img2Summary" xml:space="preserve">
-		<value>Busca imágenes con DuckDuckGo,</value>
-	</data>
-	<data name="img2Param1" xml:space="preserve">
-		<value>Las palabras clave a buscar.</value>
-	</data>
-	<data name="privacySummary" xml:space="preserve">
-		<value>Muestra la política de privacidad y la configuración de privacidad.</value>
-	</data>
-	<data name="PrivacyPolicy" xml:space="preserve">
-		<value>Política de Privacidad</value>
-	</data>
-	<data name="WhatDataWeCollect" xml:space="preserve">
-		<value>Que datos recopilamos</value>
-	</data>
-	<data name="WhatDataWeCollectList" xml:space="preserve">
-		<value>a. Configuración de servidores (ID de servidor, prefijo personalizado, idioma del bot, comandos deshabilitados, etc)
-b. Configuración/Estadísticas de usuario (ID de usuario, Puntos de juego de trivia, configuración de privacidad y estado en la lista negra)
-c. Aventuras de AI Dungeon (ID de aventura, ID del dueño y disponibilidad de uso)
-d. Recopilado temporal de mensajes eliminados/editados, usado en los comandos de "snipe" (`snipe`, `editsnipe`, `bigsnipe` and `bigeditsnipe`)</value>
-	</data>
-	<data name="WhenWeCollectData" xml:space="preserve">
-		<value>Cuándo lo recopilamos</value>
-	</data>
-	<data name="WhenWeCollectDataList" xml:space="preserve">
-		<value>a. Cuando estableces un prefijo personalizado, idioma u otra configuración. Estos datos son eliminados cuando el bot sale de ese servidor.
-b. Cuando juegas Trivia, usas la configuración de privacidad o estás en la lista negra.
-c. Cuando creas una aventura de AI Dungeon con el bot. Estos datos son eliminados cuando usas el comando `aid delete`.
-d. Cuando un mensaje es eliminado/editado. Estos mensajes solo pueden ser vistos usando los comandos de "snipe" en el canal donde el mensaje fue eliminado/editado.
-Estos mensajes solo se almacenan por 6 horas, y puede optar por no participar utilizando las siguientes reacciones.</value>
-	</data>
-	<data name="PrivacyConfig" xml:space="preserve">
-		<value>Configuración de privacidad</value>
-	</data>
-	<data name="PrivacyConfigInfo" xml:space="preserve">
-		<value>Aquí encontrarás algunos ajustes que puedes cambiar para mejorar tu privacidad.</value>
-	</data>
-	<data name="PrivacyConfigList" xml:space="preserve">
-		<value>Optar por no participar en la colección temporal de mensajes eliminados/editados en los comandos de "snipe"</value>
-	</data>
-	<data name="Privacy" xml:space="preserve">
-		<value>Privacidad</value>
-	</data>
-	<data name="SnipePrivacy" xml:space="preserve">
-		<value>¿No quieres que tu mensaje se muestre aquí? Vea `privacy`.</value>
-	</data>
-	<data name="CannotUseThisInteraction" xml:space="preserve">
-		<value>No puedes usar esta interacción.</value>
-	</data>
-	<data name="DeletingAdventure" xml:space="preserve">
-		<value>Eliminando la aventura...</value>
-	</data>
-	<data name="Donate" xml:space="preserve">
-		<value>Donar</value>
-	</data>
-	<data name="LanguageList" xml:space="preserve">
-		<value>Lista de Idiomas</value>
-	</data>
-	<data name="img3Summary" xml:space="preserve">
-		<value>Busca imágenes con Brave.</value>
-	</data>
-	<data name="img3Param1" xml:space="preserve">
-		<value>Las palabras clave a buscar.</value>
-	</data>
-	<data name="Thread" xml:space="preserve">
-		<value>Hilo</value>
-	</data>
-	<data name="AutoArchive" xml:space="preserve">
-		<value>Auto Archivado</value>
-	</data>
-	<data name="StageChannel" xml:space="preserve">
-		<value>Canal de escenario</value>
-	</data>
-	<data name="IsLive" xml:space="preserve">
-		<value>Iniciado</value>
-	</data>
-	<data name="Archived" xml:space="preserve">
-		<value>Archivado</value>
-	</data>
-</root>
\ No newline at end of file
diff --git a/src/strings.resx b/src/strings.resx
deleted file mode 100644
index 54d5973..0000000
--- a/src/strings.resx
+++ /dev/null
@@ -1,2180 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<root>
-	<!-- 
-    Microsoft ResX Schema 
-    
-    Version 2.0
-    
-    The primary goals of this format is to allow a simple XML format 
-    that is mostly human readable. The generation and parsing of the 
-    various data types are done through the TypeConverter classes 
-    associated with the data types.
-    
-    Example:
-    
-    ... ado.net/XML headers & schema ...
-    <resheader name="resmimetype">text/microsoft-resx</resheader>
-    <resheader name="version">2.0</resheader>
-    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
-    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
-    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
-    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
-    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
-        <value>[base64 mime encoded serialized .NET Framework object]</value>
-    </data>
-    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
-        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
-        <comment>This is a comment</comment>
-    </data>
-                
-    There are any number of "resheader" rows that contain simple 
-    name/value pairs.
-    
-    Each data row contains a name, and value. The row also contains a 
-    type or mimetype. Type corresponds to a .NET class that support 
-    text/value conversion through the TypeConverter architecture. 
-    Classes that don't support this are serialized and stored with the 
-    mimetype set.
-    
-    The mimetype is used for serialized objects, and tells the 
-    ResXResourceReader how to depersist the object. This is currently not 
-    extensible. For a given mimetype the value must be set accordingly:
-    
-    Note - application/x-microsoft.net.object.binary.base64 is the format 
-    that the ResXResourceWriter will generate, however the reader can 
-    read any of the formats listed below.
-    
-    mimetype: application/x-microsoft.net.object.binary.base64
-    value   : The object must be serialized with 
-            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
-            : and then encoded with base64 encoding.
-    
-    mimetype: application/x-microsoft.net.object.soap.base64
-    value   : The object must be serialized with 
-            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
-            : and then encoded with base64 encoding.
-
-    mimetype: application/x-microsoft.net.object.bytearray.base64
-    value   : The object must be serialized into a byte array 
-            : using a System.ComponentModel.TypeConverter
-            : and then encoded with base64 encoding.
-    -->
-	<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
-		<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
-		<xsd:element name="root" msdata:IsDataSet="true">
-			<xsd:complexType>
-				<xsd:choice maxOccurs="unbounded">
-					<xsd:element name="metadata">
-						<xsd:complexType>
-							<xsd:sequence>
-								<xsd:element name="value" type="xsd:string" minOccurs="0" />
-							</xsd:sequence>
-							<xsd:attribute name="name" use="required" type="xsd:string" />
-							<xsd:attribute name="type" type="xsd:string" />
-							<xsd:attribute name="mimetype" type="xsd:string" />
-							<xsd:attribute ref="xml:space" />
-						</xsd:complexType>
-					</xsd:element>
-					<xsd:element name="assembly">
-						<xsd:complexType>
-							<xsd:attribute name="alias" type="xsd:string" />
-							<xsd:attribute name="name" type="xsd:string" />
-						</xsd:complexType>
-					</xsd:element>
-					<xsd:element name="data">
-						<xsd:complexType>
-							<xsd:sequence>
-								<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
-								<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
-							</xsd:sequence>
-							<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
-							<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
-							<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
-							<xsd:attribute ref="xml:space" />
-						</xsd:complexType>
-					</xsd:element>
-					<xsd:element name="resheader">
-						<xsd:complexType>
-							<xsd:sequence>
-								<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
-							</xsd:sequence>
-							<xsd:attribute name="name" type="xsd:string" use="required" />
-						</xsd:complexType>
-					</xsd:element>
-				</xsd:choice>
-			</xsd:complexType>
-		</xsd:element>
-	</xsd:schema>
-	<resheader name="resmimetype">
-		<value>text/microsoft-resx</value>
-	</resheader>
-	<resheader name="version">
-		<value>2.0</value>
-	</resheader>
-	<resheader name="reader">
-		<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
-	</resheader>
-	<resheader name="writer">
-		<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
-	</resheader>
-	<data name="CommandList" xml:space="preserve">
-    <value>Command List</value>
-  </data>
-	<data name="EntertainmentCommands" xml:space="preserve">
-    <value>Entertainment commands</value>
-  </data>
-	<data name="HelpFooter" xml:space="preserve">
-    <value>Fergun {0} - Total command count: {1}</value>
-  </data>
-	<data name="ModerationCommands" xml:space="preserve">
-    <value>Moderation commands</value>
-  </data>
-	<data name="MusicCommands" xml:space="preserve">
-    <value>Music commands</value>
-  </data>
-	<data name="Notes" xml:space="preserve">
-    <value>Notes</value>
-  </data>
-	<data name="NotesInfo" xml:space="preserve">
-    <value>Use `{0}help [command]` to get more info about a command.</value>
-  </data>
-	<data name="OtherCommands" xml:space="preserve">
-    <value>Other commands</value>
-  </data>
-	<data name="TextCommands" xml:space="preserve">
-    <value>Text commands</value>
-  </data>
-	<data name="UtilityCommands" xml:space="preserve">
-    <value>Utility commands</value>
-  </data>
-	<data name="UpcomingCommands" xml:space="preserve">
-    <value>Upcoming commands</value>
-  </data>
-	<data name="CommandNotFound" xml:space="preserve">
-    <value>Command not found. Use `{0}help` to view the command list.</value>
-  </data>
-	<data name="NoDescription" xml:space="preserve">
-    <value>(No description available)</value>
-  </data>
-	<data name="Optional" xml:space="preserve">
-    <value>(Optional)</value>
-  </data>
-	<data name="Usage" xml:space="preserve">
-    <value>Usage</value>
-  </data>
-	<data name="Alias" xml:space="preserve">
-    <value>Alias(es)</value>
-  </data>
-	<data name="Parameters" xml:space="preserve">
-    <value>Parameters</value>
-  </data>
-	<data name="mojibakeSummary" xml:space="preserve">
-    <value>Shows random unicode characters.</value>
-  </data>
-	<data name="mojibakeParam1" xml:space="preserve">
-    <value>The result length.</value>
-  </data>
-	<data name="3orMoreChars" xml:space="preserve">
-    <value>The text must have 3 or more characters.</value>
-  </data>
-	<data name="TooLongToDisplay" xml:space="preserve">
-    <value>The resulting text is too long to display ({0}).</value>
-  </data>
-	<data name="normalizeSummary" xml:space="preserve">
-    <value>Normalizes a text.</value>
-  </data>
-	<data name="normalizeParam1" xml:space="preserve">
-    <value>The text to normalize.</value>
-  </data>
-	<data name="randomizeSummary" xml:space="preserve">
-    <value>Randomizes a text.</value>
-  </data>
-	<data name="randomizeParam1" xml:space="preserve">
-    <value>The text to randomize.</value>
-  </data>
-	<data name="repeatSummary" xml:space="preserve">
-    <value>Repeats a text a number of times.</value>
-  </data>
-	<data name="repeatParam1" xml:space="preserve">
-    <value>The times to repeat.</value>
-  </data>
-	<data name="repeatParam2" xml:space="preserve">
-    <value>The text to repeat.</value>
-  </data>
-	<data name="reverseSummary" xml:space="preserve">
-    <value>Reverses a text.</value>
-  </data>
-	<data name="reverseParam1" xml:space="preserve">
-    <value>The text to reverse.</value>
-  </data>
-	<data name="reverselinesSummary" xml:space="preserve">
-    <value>Reverses the line order of a text.</value>
-  </data>
-	<data name="reverselinesParam1" xml:space="preserve">
-    <value>The text to reverse its lines.</value>
-  </data>
-	<data name="reversewordsSummary" xml:space="preserve">
-    <value>Reverses the words order of a text.</value>
-  </data>
-	<data name="reversewordsParam1" xml:space="preserve">
-    <value>The text to reverse its words.</value>
-  </data>
-	<data name="sarcasmSummary" xml:space="preserve">
-    <value>sARcAstIC teXt.</value>
-  </data>
-	<data name="sarcasmParam1" xml:space="preserve">
-    <value>The text to convert.</value>
-  </data>
-	<data name="vaporwaveSummary" xml:space="preserve">
-    <value>Converts a text to vaporwave.</value>
-  </data>
-	<data name="vaporwaveParam1" xml:space="preserve">
-    <value>The text to convert.</value>
-  </data>
-	<data name="avatarSummary" xml:space="preserve">
-    <value>Returns the avatar of the current user, or a specific user, if passed.</value>
-  </data>
-	<data name="avatarParam1" xml:space="preserve">
-    <value>The user to get its avatar.</value>
-  </data>
-	<data name="base64encodeSummary" xml:space="preserve">
-    <value>Encodes a text to Base64.</value>
-  </data>
-	<data name="base64encodeParam1" xml:space="preserve">
-    <value>The text to encode.</value>
-  </data>
-	<data name="base64decodeSummary" xml:space="preserve">
-    <value>Decodes a text from Base64.</value>
-  </data>
-	<data name="base64decodeParam1" xml:space="preserve">
-    <value>The text to decode.</value>
-  </data>
-	<data name="base64decodeInvalid" xml:space="preserve">
-    <value>Invalid encoded text.</value>
-  </data>
-	<data name="choiceSummary" xml:space="preserve">
-    <value>Chooses an option from a list.</value>
-  </data>
-	<data name="choiceParam1" xml:space="preserve">
-    <value>A space separated list of choices.</value>
-  </data>
-	<data name="IChoose" xml:space="preserve">
-    <value>I choose...</value>
-  </data>
-	<data name="OneChoice" xml:space="preserve">
-    <value>
-...because you only gave me one choice</value>
-  </data>
-	<data name="colorSummary" xml:space="preserve">
-    <value>Shows a random or specific color.</value>
-  </data>
-	<data name="colorParam1" xml:space="preserve">
-    <value>The specific color to use. Must be a hex value, raw value or a known color name.</value>
-  </data>
-	<data name="helpSummary" xml:space="preserve">
-    <value>Shows the help menu or the info of a command, if passed.</value>
-  </data>
-	<data name="helpParam1" xml:space="preserve">
-    <value>The command to get info from.</value>
-  </data>
-	<data name="identifySummary" xml:space="preserve">
-    <value>Identifies an image with Microsoft CaptionBot.</value>
-  </data>
-	<data name="identifyParam1" xml:space="preserve">
-    <value>The url of an image to use.</value>
-  </data>
-	<data name="NoUrlPassed" xml:space="preserve">
-    <value>Not specifying a url will make the command to search for the last x messages in cache for a link or an attachment.</value>
-  </data>
-	<data name="AttachmentNotImage" xml:space="preserve">
-    <value>The attachment is not an image.</value>
-  </data>
-	<data name="UrlNotFound" xml:space="preserve">
-    <value>Could not find any url or attachment in the last {0} messages.</value>
-  </data>
-	<data name="UrlNotImage" xml:space="preserve">
-    <value>The url is not a valid image.</value>
-  </data>
-	<data name="imgSummary" xml:space="preserve">
-    <value>Searches for images with Google Images.</value>
-  </data>
-	<data name="imgParam1" xml:space="preserve">
-    <value>The keyword(s) to search.</value>
-  </data>
-	<data name="NoResultsFound" xml:space="preserve">
-    <value>Could not find any results.</value>
-  </data>
-	<data name="IfNSFW" xml:space="preserve">
-    <value>If the search is NSFW, use the command in a NSFW channel.</value>
-  </data>
-	<data name="ImageSearch" xml:space="preserve">
-    <value>Image search</value>
-  </data>
-	<data name="UrbanFooter" xml:space="preserve">
-    <value>Urban Dictionary - Page {0} of {1}</value>
-  </data>
-	<data name="ImageSearchCap" xml:space="preserve">
-    <value>Image search cap reached :(</value>
-  </data>
-	<data name="ocrSummary" xml:space="preserve">
-    <value>Performs OCR to an image.</value>
-  </data>
-	<data name="ocrParam1" xml:space="preserve">
-    <value>The url of an image to use.</value>
-  </data>
-	<data name="UrlFileTooLarge" xml:space="preserve">
-    <value>The file is too large.</value>
-  </data>
-	<data name="OcrEmpty" xml:space="preserve">
-    <value>The OCR did not give results.</value>
-  </data>
-	<data name="ocr2Summary" xml:space="preserve">
-    <value>Performs OCR to an image with Tesseract.</value>
-  </data>
-	<data name="ocr2Param1" xml:space="preserve">
-    <value>The url of an image to use.</value>
-  </data>
-	<data name="StatusCode" xml:space="preserve">
-    <value>Status code:</value>
-  </data>
-	<data name="pingSummary" xml:space="preserve">
-    <value>Gets the latency in sending a message and the latency of the Database.</value>
-  </data>
-	<data name="resizeSummary" xml:space="preserve">
-    <value>Resizes an image with waifu2x.</value>
-  </data>
-	<data name="resizeParam1" xml:space="preserve">
-    <value>The url of an image to use.</value>
-  </data>
-	<data name="AnErrorOccurred" xml:space="preserve">
-    <value>An error occurred.</value>
-  </data>
-	<data name="ResizeResults" xml:space="preserve">
-    <value>Resize results</value>
-  </data>
-	<data name="screenshotSummary" xml:space="preserve">
-    <value>Takes a screenshot to a website.</value>
-  </data>
-	<data name="screenshotParam1" xml:space="preserve">
-    <value>The website to take a screenshot.</value>
-  </data>
-	<data name="serverinfoSummary" xml:space="preserve">
-    <value>Returns info about the current server.</value>
-  </data>
-	<data name="ServerInfo" xml:space="preserve">
-    <value>Server info</value>
-  </data>
-	<data name="Name" xml:space="preserve">
-    <value>Name</value>
-  </data>
-	<data name="Owner" xml:space="preserve">
-    <value>Owner</value>
-  </data>
-	<data name="RoleCount" xml:space="preserve">
-    <value>Role count</value>
-  </data>
-	<data name="UserCount" xml:space="preserve">
-    <value>User count</value>
-  </data>
-	<data name="VerificationLevel" xml:space="preserve">
-    <value>Verification level</value>
-  </data>
-	<data name="None" xml:space="preserve">
-    <value>(None)</value>
-  </data>
-	<data name="CreatedAt" xml:space="preserve">
-    <value>Created at</value>
-  </data>
-	<data name="snipeSummary" xml:space="preserve">
-    <value>Shows the last deleted message in the current channel or the specified.</value>
-  </data>
-	<data name="NothingToSnipe" xml:space="preserve">
-    <value>Nothing to snipe in {0}</value>
-  </data>
-	<data name="translateSummary" xml:space="preserve">
-    <value>Translates a text.</value>
-  </data>
-	<data name="translateParam1" xml:space="preserve">
-    <value>The target language in ISO code (`en`, `es`, `br`, etc.)</value>
-  </data>
-	<data name="translateParam2" xml:space="preserve">
-    <value>The text to translate.</value>
-  </data>
-	<data name="InvalidLanguage" xml:space="preserve">
-    <value>Invalid target language. Use `{0}translate language codes` to see the language list.</value>
-  </data>
-	<data name="GoogleIPBanned" xml:space="preserve">
-    <value>I got IP banned by Google :( lol</value>
-  </data>
-	<data name="ttsSummary" xml:space="preserve">
-    <value>Text to speech.</value>
-  </data>
-	<data name="ttsParam1" xml:space="preserve">
-    <value>The target language in ISO code (`en`, `es`, `br`, etc.), fallback to English if the target is invalid.</value>
-  </data>
-	<data name="ttsParam2" xml:space="preserve">
-    <value>The text to convert.</value>
-  </data>
-	<data name="urbanSummary" xml:space="preserve">
-    <value>Urban Dictionary search.</value>
-  </data>
-	<data name="urbanParam1" xml:space="preserve">
-    <value>The keyword(s) to search.</value>
-  </data>
-	<data name="NoResults" xml:space="preserve">
-    <value>No results.</value>
-  </data>
-	<data name="By" xml:space="preserve">
-    <value>By</value>
-  </data>
-	<data name="Example" xml:space="preserve">
-    <value>Example</value>
-  </data>
-	<data name="NoExample" xml:space="preserve">
-    <value>(No example provided)</value>
-  </data>
-	<data name="userinfoSummary" xml:space="preserve">
-    <value>Returns info about the current user, or a specific user, if passed.</value>
-  </data>
-	<data name="userinfoParam1" xml:space="preserve">
-    <value>The user to get info from.</value>
-  </data>
-	<data name="UserInfo" xml:space="preserve">
-    <value>User info</value>
-  </data>
-	<data name="Activity" xml:space="preserve">
-    <value>Activity</value>
-  </data>
-	<data name="IsBot" xml:space="preserve">
-    <value>Is bot</value>
-  </data>
-	<data name="GuildJoinDate" xml:space="preserve">
-    <value>Guild join date</value>
-  </data>
-	<data name="UserNotFound" xml:space="preserve">
-    <value>User not found.
-Try using a tag ({0}), a mention ({1}) or an ID ({2}).</value>
-  </data>
-	<data name="wikipediaSummary" xml:space="preserve">
-    <value>Searches for an article on Wikipedia.</value>
-  </data>
-	<data name="wikipediaParam1" xml:space="preserve">
-    <value>The keyword(s) to search.</value>
-  </data>
-	<data name="WikipediaSearch" xml:space="preserve">
-    <value>Wikipedia Search</value>
-  </data>
-	<data name="xkcdSummary" xml:space="preserve">
-    <value>Returns a random xkcd comic or a specific comic, if a number is passed.</value>
-  </data>
-	<data name="xkcdParam1" xml:space="preserve">
-    <value>The comic number.</value>
-  </data>
-	<data name="InvalidxkcdNumber" xml:space="preserve">
-    <value>Number must be between 1 and {0}.</value>
-  </data>
-	<data name="youtubeSummary" xml:space="preserve">
-    <value>Searches a video on YouTube.</value>
-  </data>
-	<data name="youtubeParam1" xml:space="preserve">
-    <value>The keyword(s) to search.</value>
-  </data>
-	<data name="YouTubeNoResults" xml:space="preserve">
-    <value>Could not find any results.</value>
-  </data>
-	<data name="ytrandomSummary" xml:space="preserve">
-    <value>Returns a "random" YouTube video.</value>
-  </data>
-	<data name="EmptyCache" xml:space="preserve">
-    <value>Out of cached videos
-Creating cache...</value>
-  </data>
-	<data name="banSummary" xml:space="preserve">
-    <value>Bans a user.</value>
-  </data>
-	<data name="banParam1" xml:space="preserve">
-    <value>The user to ban.</value>
-  </data>
-	<data name="banParam2" xml:space="preserve">
-    <value>The reason of the ban.</value>
-  </data>
-	<data name="BanSameUser" xml:space="preserve">
-    <value>What? You want to ban yourself?</value>
-  </data>
-	<data name="BanMyself" xml:space="preserve">
-    <value>I won't ban myself lol</value>
-  </data>
-	<data name="AlreadyBanned" xml:space="preserve">
-    <value>The user is already banned.</value>
-  </data>
-	<data name="Banned" xml:space="preserve">
-    <value>User {0} was banned.</value>
-  </data>
-	<data name="clearSummary" xml:space="preserve">
-    <value>Clears the last x messages in the current channel.</value>
-  </data>
-	<data name="clearParam1" xml:space="preserve">
-    <value>The number of messages to clear.</value>
-  </data>
-	<data name="clearParam2" xml:space="preserve">
-    <value>A user to delete its messages.</value>
-  </data>
-	<data name="NumberOutOfIndex" xml:space="preserve">
-    <value>The number must be between {0} and {1}.</value>
-  </data>
-	<data name="ClearNotFound" xml:space="preserve">
-    <value>Could not find any messages by {0} in the last {1} messages.</value>
-  </data>
-	<data name="By2" xml:space="preserve">
-    <value>by</value>
-  </data>
-	<data name="hackbanSummary" xml:space="preserve">
-    <value>Hackbans a user.</value>
-  </data>
-	<data name="hackbanParam1" xml:space="preserve">
-    <value>The ID of the user to hackban.</value>
-  </data>
-	<data name="hackbanParam2" xml:space="preserve">
-    <value>The reason of the hackban.</value>
-  </data>
-	<data name="Hackbanned" xml:space="preserve">
-    <value>User {0} was hackbanned.</value>
-  </data>
-	<data name="kickSummary" xml:space="preserve">
-    <value>Kicks a user.</value>
-  </data>
-	<data name="kickParam1" xml:space="preserve">
-    <value>The user to kick.</value>
-  </data>
-	<data name="kickParam2" xml:space="preserve">
-    <value>The reason of the kick.</value>
-  </data>
-	<data name="Kicked" xml:space="preserve">
-    <value>User {0} was kicked.</value>
-  </data>
-	<data name="nickSummary" xml:space="preserve">
-    <value>Changes the nickname of a user.</value>
-  </data>
-	<data name="nickParam1" xml:space="preserve">
-    <value>The user to change its nickname.</value>
-  </data>
-	<data name="nickParam2" xml:space="preserve">
-    <value>The new nickname. Leave this empty to remove the nickname.</value>
-  </data>
-	<data name="unbanSummary" xml:space="preserve">
-    <value>Unbans a user.</value>
-  </data>
-	<data name="unbanParam1" xml:space="preserve">
-    <value>The user ID to unban.</value>
-  </data>
-	<data name="Unbanned" xml:space="preserve">
-    <value>User {0} was unbanned.</value>
-  </data>
-	<data name="triviaSummary" xml:space="preserve">
-    <value>Trivia time!</value>
-  </data>
-	<data name="triviaParam1" xml:space="preserve">
-    <value>The category to select. Specify "categories" to see the list of categories or "leaderboard"/"ranks" to see the leaderboard.</value>
-  </data>
-	<data name="CategoryList" xml:space="preserve">
-    <value>Category list</value>
-  </data>
-	<data name="TriviaLeaderboard" xml:space="preserve">
-    <value>Trivia leaderboard</value>
-  </data>
-	<data name="AllQuestionsAnswered" xml:space="preserve">
-    <value>Seems that you answered all the questions in the specified category, select another category..</value>
-  </data>
-	<data name="TriviaError" xml:space="preserve">
-    <value>An error occurred. Error code</value>
-  </data>
-	<data name="Category" xml:space="preserve">
-    <value>Category</value>
-  </data>
-	<data name="Type" xml:space="preserve">
-    <value>Type</value>
-  </data>
-	<data name="Difficulty" xml:space="preserve">
-    <value>Difficulty</value>
-  </data>
-	<data name="Question" xml:space="preserve">
-    <value>Question</value>
-  </data>
-	<data name="Options" xml:space="preserve">
-    <value>Options</value>
-  </data>
-	<data name="TimeLeft" xml:space="preserve">
-    <value>You have {0} seconds to answer.</value>
-  </data>
-	<data name="InvalidOption" xml:space="preserve">
-    <value>Invalid option!</value>
-  </data>
-	<data name="Lost1Point" xml:space="preserve">
-    <value>You lost 1 point.</value>
-  </data>
-	<data name="CorrectAnswer" xml:space="preserve">
-    <value>Correct answer!</value>
-  </data>
-	<data name="Won1Point" xml:space="preserve">
-    <value>You won 1 point.</value>
-  </data>
-	<data name="Incorrect" xml:space="preserve">
-    <value>Incorrect!</value>
-  </data>
-	<data name="TheAnswerIs" xml:space="preserve">
-    <value>The answer is</value>
-  </data>
-	<data name="TimesUp" xml:space="preserve">
-    <value>Time's up!</value>
-  </data>
-	<data name="Points" xml:space="preserve">
-    <value>Points</value>
-  </data>
-	<data name="joinSummary" xml:space="preserve">
-    <value>Joins a voice channel.</value>
-  </data>
-	<data name="UserNotInVC" xml:space="preserve">
-    <value>You need to connect to a voice channel.</value>
-  </data>
-	<data name="NowConnected" xml:space="preserve">
-    <value>Now connected to {0}.</value>
-  </data>
-	<data name="leaveSummary" xml:space="preserve">
-    <value>Leaves a voice channel.</value>
-  </data>
-	<data name="LeaveNotInVC" xml:space="preserve">
-    <value>Join the channel the bot is in to make it leave.</value>
-  </data>
-	<data name="LeftVC" xml:space="preserve">
-    <value>Bot has now left {0}.</value>
-  </data>
-	<data name="moveSummary" xml:space="preserve">
-    <value>Moves the bot to the voice channel the current user is.</value>
-  </data>
-	<data name="MoveNotInVC" xml:space="preserve">
-    <value>Join a voice channel where you want the bot to be.</value>
-  </data>
-	<data name="playSummary" xml:space="preserve">
-    <value>Searches and plays a track from YouTube.</value>
-  </data>
-	<data name="playParam1" xml:space="preserve">
-    <value>The keyword(s) to search.</value>
-  </data>
-	<data name="SelectTrack" xml:space="preserve">
-    <value>Send a number from the list</value>
-  </data>
-	<data name="SearchCanceled" xml:space="preserve">
-    <value>Search canceled.</value>
-  </data>
-	<data name="OutOfIndex" xml:space="preserve">
-    <value>Option out of index.</value>
-  </data>
-	<data name="ReplyTimeout" xml:space="preserve">
-    <value>You did not reply before the timeout!</value>
-  </data>
-	<data name="replaySummary" xml:space="preserve">
-    <value>Replays the current track that is playing, if there's any.</value>
-  </data>
-	<data name="pauseSummary" xml:space="preserve">
-    <value>Pauses the player.</value>
-  </data>
-	<data name="resumeSummary" xml:space="preserve">
-    <value>Resumes the playback.</value>
-  </data>
-	<data name="stopSummary" xml:space="preserve">
-    <value>Stops the player.</value>
-  </data>
-	<data name="skipSummary" xml:space="preserve">
-    <value>Skips the current track, if there's any.</value>
-  </data>
-	<data name="volumeSummary" xml:space="preserve">
-    <value>Sets the player volume.</value>
-  </data>
-	<data name="volumeParam1" xml:space="preserve">
-    <value>The volume to set (2 - 150)</value>
-  </data>
-	<data name="queueSummary" xml:space="preserve">
-    <value>Shows the queue.</value>
-  </data>
-	<data name="shuffleSummary" xml:space="preserve">
-    <value>Shuffles the queue.</value>
-  </data>
-	<data name="removeSummary" xml:space="preserve">
-    <value>Removes a track in the queue at a specified index.</value>
-  </data>
-	<data name="removeParam1" xml:space="preserve">
-    <value>The index of the track to remove.</value>
-  </data>
-	<data name="lyricsSummary" xml:space="preserve">
-    <value>Shows the lyrics of the specified song, or the current track in the player if none passed.</value>
-  </data>
-	<data name="lyricsParam1" xml:space="preserve">
-    <value>The song to search its lyrics.</value>
-  </data>
-	<data name="artworkSummary" xml:space="preserve">
-    <value>Shows the artwork of the current track in the player.</value>
-  </data>
-	<data name="NoTracks" xml:space="preserve">
-    <value>No more tracks to play.</value>
-  </data>
-	<data name="NowPlaying" xml:space="preserve">
-    <value>Now playing</value>
-  </data>
-	<data name="PlayerError" xml:space="preserve">
-    <value>An error occurred</value>
-  </data>
-	<data name="PlayerStuck" xml:space="preserve">
-    <value>The player got stuck with the track **{0}** for {1} seconds.</value>
-  </data>
-	<data name="PlayerMoved" xml:space="preserve">
-    <value>Moved from **{0}** to **{1}**</value>
-  </data>
-	<data name="PlayerNoMatches" xml:space="preserve">
-    <value>No matches found. Try using a Youtube link or a mp3 file link.</value>
-  </data>
-	<data name="PlayerPlaylistAdded" xml:space="preserve">
-    <value>Playlist **{0}** ({1} tracks) ({2}) has been added to the queue.</value>
-  </data>
-	<data name="PlayerEmptyPlaylistAdded" xml:space="preserve">
-    <value>Queued {0} tracks ({1})
-
-Now playing: {2}</value>
-  </data>
-	<data name="PlayerTrackAdded" xml:space="preserve">
-    <value>{0} has been added to the queue.</value>
-  </data>
-	<data name="PlayerNowPlaying" xml:space="preserve">
-    <value>Now playing: {0}</value>
-  </data>
-	<data name="InvalidTrack" xml:space="preserve">
-    <value>Invalid track.</value>
-  </data>
-	<data name="Replaying" xml:space="preserve">
-    <value>Replaying {0}</value>
-  </data>
-	<data name="EmptyQueue" xml:space="preserve">
-    <value>The queue is empty.</value>
-  </data>
-	<data name="PlayerNotPlaying" xml:space="preserve">
-    <value>Player isn't playing.</value>
-  </data>
-	<data name="PlayerStopped" xml:space="preserve">
-    <value>Player now stopped.</value>
-  </data>
-	<data name="PlayerTrackSkipped" xml:space="preserve">
-    <value>Skipped: {0}
-
-Now playing: {1}</value>
-  </data>
-	<data name="VolumeOutOfIndex" xml:space="preserve">
-    <value>Use a number between 2 and 150.</value>
-  </data>
-	<data name="VolumeSet" xml:space="preserve">
-    <value>Volume set to: {0}.</value>
-  </data>
-	<data name="PlayerPaused" xml:space="preserve">
-    <value>Player is now paused.</value>
-  </data>
-	<data name="PlaybackResumed" xml:space="preserve">
-    <value>Playback resumed.</value>
-  </data>
-	<data name="PlayerNotPaused" xml:space="preserve">
-    <value>Player isn't paused.</value>
-  </data>
-	<data name="CurrentlyPlaying" xml:space="preserve">
-    <value>Currently playing: {0} ({1} of {2})</value>
-  </data>
-	<data name="MusicInQueue" xml:space="preserve">
-    <value>Tracks in queue:</value>
-  </data>
-	<data name="Queue1Item" xml:space="preserve">
-    <value>There's only 1 item in the queue.</value>
-  </data>
-	<data name="QueueShuffled" xml:space="preserve">
-    <value>The queue was shuffled.</value>
-  </data>
-	<data name="IndexOutOfRange" xml:space="preserve">
-    <value>Index out of range.</value>
-  </data>
-	<data name="TrackRemoved" xml:space="preserve">
-    <value>The track {0} at position {1} was removed.</value>
-  </data>
-	<data name="LyricsNotFound" xml:space="preserve">
-    <value>No lyrics found for {0}.</value>
-  </data>
-	<data name="botgameSummary" xml:space="preserve">
-    <value>Sets the game status of the bot.</value>
-  </data>
-	<data name="botgameParam1" xml:space="preserve">
-    <value>The text to set.</value>
-  </data>
-	<data name="BotOwnerOnly" xml:space="preserve">
-    <value>Bot owner only.</value>
-  </data>
-	<data name="botstatusSummary" xml:space="preserve">
-    <value>Sets the status of the bot.</value>
-  </data>
-	<data name="botstatusParam1" xml:space="preserve">
-    <value>The status to set (0 - 5).</value>
-  </data>
-	<data name="codeSummary" xml:space="preserve">
-    <value>Shows the source code of a command.</value>
-  </data>
-	<data name="codeParam1" xml:space="preserve">
-    <value>The command to get its code.</value>
-  </data>
-	<data name="botcolorSummary" xml:space="preserve">
-    <value>Sets the embed color.</value>
-  </data>
-	<data name="botcolorParam1" xml:space="preserve">
-    <value>The new color in hexadecimal or decimal.</value>
-  </data>
-	<data name="InvalidColor" xml:space="preserve">
-    <value>Invalid color.</value>
-  </data>
-	<data name="cringeSummary" xml:space="preserve">
-    <value>Bro you just posted cringe!</value>
-  </data>
-	<data name="globalprefixSummary" xml:space="preserve">
-    <value>Sets the bot global prefix.</value>
-  </data>
-	<data name="globalprefixParam1" xml:space="preserve">
-    <value>The new global prefix.</value>
-  </data>
-	<data name="CurrentGlobalPrefix" xml:space="preserve">
-    <value>The current global prefix is:</value>
-  </data>
-	<data name="PrefixSameCurrentTarget" xml:space="preserve">
-    <value>The target prefix and the current prefix are the same.</value>
-  </data>
-	<data name="NewGlobalPrefix" xml:space="preserve">
-    <value>The new global prefix is: "{0}"</value>
-  </data>
-	<data name="inviteSummary" xml:space="preserve">
-    <value>Sends the bot invite link.</value>
-  </data>
-	<data name="prefixSummary" xml:space="preserve">
-    <value>Sets the bot prefix.</value>
-  </data>
-	<data name="prefixParam1" xml:space="preserve">
-    <value>The new prefix.</value>
-  </data>
-	<data name="CurrentPrefix" xml:space="preserve">
-    <value>The current prefix is:</value>
-  </data>
-	<data name="NewPrefix" xml:space="preserve">
-    <value>The new guild prefix is: "{0}"</value>
-  </data>
-	<data name="restartSummary" xml:space="preserve">
-    <value>Restarts the bot.</value>
-  </data>
-	<data name="saySummary" xml:space="preserve">
-    <value>Says something.</value>
-  </data>
-	<data name="sayParam1" xml:space="preserve">
-    <value>The text to say.</value>
-  </data>
-	<data name="uptimeSummary" xml:space="preserve">
-    <value>Shows the bot up-time.</value>
-  </data>
-	<data name="languageSummary" xml:space="preserve">
-    <value>Sets the bot language.</value>
-  </data>
-	<data name="NewLanguage" xml:space="preserve">
-    <value>The bot language is now: English</value>
-  </data>
-	<data name="PaginatorFooter" xml:space="preserve">
-    <value>Page {0} of {1}</value>
-  </data>
-	<data name="FailedExecution" xml:space="preserve">
-    <value>Failed to execute</value>
-  </data>
-	<data name="nowplayingSummary" xml:space="preserve">
-    <value>Gets the current track in the player.</value>
-  </data>
-	<data name="changelogSummary" xml:space="preserve">
-    <value>Shows the bot changelog.</value>
-  </data>
-	<data name="TempDisabled" xml:space="preserve">
-    <value>Disabled temporarily.</value>
-  </data>
-	<data name="inspirobotSummary" xml:space="preserve">
-    <value>Get some inspirational quotes.</value>
-  </data>
-	<data name="PermissionRequired" xml:space="preserve">
-    <value>I need the `{0}` permission to execute this command.</value>
-  </data>
-	<data name="ManageMessages" xml:space="preserve">
-    <value>Manage Messages</value>
-  </data>
-	<data name="Connect" xml:space="preserve">
-    <value>Connect</value>
-  </data>
-	<data name="Speak" xml:space="preserve">
-    <value>Speak</value>
-  </data>
-	<data name="evalSummary" xml:space="preserve">
-    <value>Evaluates code.</value>
-  </data>
-	<data name="evalParam1" xml:space="preserve">
-    <value>The code to evaluate.</value>
-  </data>
-	<data name="ScreenshotNSFW" xml:space="preserve">
-    <value>The screenshot may have NSFW content and it won't be showed. Try using the command on a NSFW channel or use other url.</value>
-  </data>
-	<data name="BotNotConnected" xml:space="preserve">
-    <value>The bot isn't connected to a voice channel.</value>
-  </data>
-	<data name="CreationCanceled" xml:space="preserve">
-    <value>Creation canceled.</value>
-  </data>
-	<data name="AIDungeonWelcome" xml:space="preserve">
-    <value>Welcome to AI Dungeon</value>
-  </data>
-	<data name="ModeSelect" xml:space="preserve">
-    <value>Make sure to read the info and commands with `{0}aid info` before continuing.
-
-Select a mode:</value>
-  </data>
-	<data name="CharacterSelect" xml:space="preserve">
-    <value>Select a character</value>
-  </data>
-	<data name="AttachFiles" xml:space="preserve">
-    <value>Attach Files</value>
-  </data>
-	<data name="EvalResults" xml:space="preserve">
-    <value>Eval results</value>
-  </data>
-	<data name="Output" xml:space="preserve">
-    <value>Output</value>
-  </data>
-	<data name="EvalFooter" xml:space="preserve">
-    <value>Executed in {0}ms</value>
-  </data>
-	<data name="EvalNoReturnValue" xml:space="preserve">
-    <value>Code executed without any return value.</value>
-  </data>
-	<data name="NSFWOnly" xml:space="preserve">
-    <value>This command can only be used on a NSFW channel.</value>
-  </data>
-	<data name="UserOnWait" xml:space="preserve">
-    <value>Wait some seconds before using this command!</value>
-  </data>
-	<data name="snipeParam1" xml:space="preserve">
-    <value>A specific channel to snipe.</value>
-  </data>
-	<data name="IDNotFound" xml:space="preserve">
-    <value>ID not found.</value>
-  </data>
-	<data name="IDNotPublic" xml:space="preserve">
-    <value>This story ID is not public and you can't use it. Ask the ID owner ({0}) to make it public with `makepublic &lt;ID&gt;`</value>
-  </data>
-	<data name="IDOnWait" xml:space="preserve">
-    <value>You must wait for the story to be generated before using this ID!</value>
-  </data>
-	<data name="GeneratingNewAdventure" xml:space="preserve">
-    <value>Generating a new adventure
-with the mode: **{0}**
-and the character: **{1}**...</value>
-  </data>
-	<data name="CustomCharacterCreation" xml:space="preserve">
-    <value>Custom character creation</value>
-  </data>
-	<data name="CustomCharacterPrompt" xml:space="preserve">
-    <value>Enter a text that describes who you are and the first couple sentences of where you start out.
-This prompt will timeout in 5 minutes.</value>
-  </data>
-	<data name="GeneratingNewCustomAdventure" xml:space="preserve">
-    <value>Generating a new adventure with the custom prompt...</value>
-  </data>
-	<data name="GeneratingStory" xml:space="preserve">
-    <value>Generating story...</value>
-  </data>
-	<data name="EditingStoryContext" xml:space="preserve">
-    <value>Editing the story context...</value>
-  </data>
-	<data name="TheAIWillNowRemember" xml:space="preserve">
-    <value>The AI will now remember:</value>
-  </data>
-	<data name="AlteringLastOutput" xml:space="preserve">
-    <value>Altering the last output...</value>
-  </data>
-	<data name="ChangedLastOutput" xml:space="preserve">
-    <value>Changed the last output to:</value>
-  </data>
-	<data name="NotIDOwner" xml:space="preserve">
-    <value>You are not the owner of this ID.</value>
-  </data>
-	<data name="IDAlreadyPublic" xml:space="preserve">
-    <value>The ID is already public.</value>
-  </data>
-	<data name="IDNowPublic" xml:space="preserve">
-    <value>The ID is now public and everyone can use it. You can set it back to private with `makeprivate &lt;ID&gt;`</value>
-  </data>
-	<data name="IDAlreadyPrivate" xml:space="preserve">
-    <value>The ID is already private.</value>
-  </data>
-	<data name="IDNowPrivate" xml:space="preserve">
-    <value>The ID is now private and and only you can use it. You can set it back to public with `makepublic &lt;ID&gt;`</value>
-  </data>
-	<data name="NoIDs" xml:space="preserve">
-    <value>{0} doesn't have any adventure IDs.</value>
-  </data>
-	<data name="IDList" xml:space="preserve">
-    <value>ID list for {0}</value>
-  </data>
-	<data name="IsPublic" xml:space="preserve">
-    <value>Is public</value>
-  </data>
-	<data name="IDListFooter" xml:space="preserve">
-    <value>Use 'idinfo &lt;ID&gt;' to get info about an adventure ID.</value>
-  </data>
-	<data name="IDInfo" xml:space="preserve">
-    <value>Adventure ID info</value>
-  </data>
-	<data name="AboutAIDText" xml:space="preserve">
-    <value>AI Dungeon is a first of its kind AI generated text adventure. Using a 1.5B parameter machine learning model called GPT-2, AI Dungeon generates the story and results of your actions as you play in this virtual world. Unlike virtually every other game in existence, you are not limited by the imagination of the developer in what you can do. Any thing you can express in language can be your action and the AI dungeon master will decide how the world responds to your actions.</value>
-  </data>
-	<data name="AIDHowToPlayText" xml:space="preserve">
-    <value>The rules of AI Dungeon are simple:
-1. The game puts "You " to the front of each action so every action should start with a verb. Ex. "attack the dragon", "summon a sandwich" etc...
-2. As an exception to the above: If you put your action in quotes Ex. "Will you join me on my quest ? " then the game will add "You say " to the front of it.</value>
-  </data>
-	<data name="AboutAIDTitle" xml:space="preserve">
-    <value>About AI Dungeon</value>
-  </data>
-	<data name="AIDHelp" xml:space="preserve">
-    <value>AI Dungeon Help</value>
-  </data>
-	<data name="AIDHowToPlayTitle" xml:space="preserve">
-    <value>How to play</value>
-  </data>
-	<data name="Commands" xml:space="preserve">
-    <value>Commands</value>
-  </data>
-	<data name="AIDTips" xml:space="preserve">
-    <value>Try using new words often, the AI gets more creative with variety.
-Remember to start a "do" input with a verb, ex: Attack the orc.
-Use the undo button to delete your last input along with the AI's response.
-Long sentences for actions are no problem! Get creative!
-Want more story to generate? Use the continue command without any text.
-Try using new words often, the AI gets more creative with variety.
-Use the remember command to edit the story context that the AI always remembers.
-You can use Say before the text to talk.</value>
-  </data>
-	<data name="In" xml:space="preserve">
-    <value>In</value>
-  </data>
-	<data name="configSummary" xml:space="preserve">
-    <value>Shows an embed with the bot configuration options at guild level.</value>
-  </data>
-	<data name="AdminRequired" xml:space="preserve">
-    <value>You need to be an administrator to use this command.</value>
-  </data>
-	<data name="Loading" xml:space="preserve">
-    <value>Loading...</value>
-  </data>
-	<data name="FergunConfiguration" xml:space="preserve">
-    <value>Fergun configuration</value>
-  </data>
-	<data name="ConfigCanceled" xml:space="preserve">
-    <value>Config canceled.</value>
-  </data>
-	<data name="CantDoThat" xml:space="preserve">
-    <value>I can't do that.</value>
-  </data>
-	<data name="softbanSummary" xml:space="preserve">
-    <value>Softbans a user (kick + delete user messages).</value>
-  </data>
-	<data name="softbanParam1" xml:space="preserve">
-    <value>The user to softban.</value>
-  </data>
-	<data name="softbanParam2" xml:space="preserve">
-    <value>The number of days to delete it's last messages. 7 by default.</value>
-  </data>
-	<data name="softbanParam3" xml:space="preserve">
-    <value>The reason of the softban.</value>
-  </data>
-	<data name="SoftbanSameUser" xml:space="preserve">
-    <value>What? You want to softban yourself?</value>
-  </data>
-	<data name="SoftbanMyself" xml:space="preserve">
-    <value>I won't softban myself lol</value>
-  </data>
-	<data name="Softbanned" xml:space="preserve">
-    <value>User {0} was softbanned.</value>
-  </data>
-	<data name="AIDungeonCommands" xml:space="preserve">
-    <value>AI Dungeon (use {0}aid &lt;command&gt;)</value>
-  </data>
-	<data name="RevertingLastAction" xml:space="preserve">
-    <value>Reverting last action...</value>
-  </data>
-	<data name="140CharsMax" xml:space="preserve">
-    <value>140 characters max.</value>
-  </data>
-	<data name="InviteLink" xml:space="preserve">
-    <value>Invite link</value>
-  </data>
-	<data name="NoManageMessages" xml:space="preserve">
-    <value>Some commands work better with the `Manage Messages` permission (`img`, `urban`, `config`)</value>
-  </data>
-	<data name="InvalidText" xml:space="preserve">
-    <value>Invalid text.</value>
-  </data>
-	<data name="clearRemarks" xml:space="preserve">
-    <value>If a user is passed, the command will try to delete all the messages from the user in the last `count` messages.
-The limit of days that the bot can search messages is 14.</value>
-  </data>
-	<data name="BanMembers" xml:space="preserve">
-    <value>Ban Members</value>
-  </data>
-	<data name="KickMembers" xml:space="preserve">
-    <value>Kick Members</value>
-  </data>
-	<data name="UserNotBanned" xml:space="preserve">
-    <value>The user isn't banned.</value>
-  </data>
-	<data name="BotMention" xml:space="preserve">
-    <value>My prefix here is `{0}`. Use `{0}help` to see my command list and `{0}prefix` to change the prefix.</value>
-  </data>
-	<data name="NotSupportedInDM" xml:space="preserve">
-    <value>I don't think you can use that here.</value>
-  </data>
-	<data name="tcdneSummary" xml:space="preserve">
-    <value>This cat does not exist</value>
-  </data>
-	<data name="tpdneSummary" xml:space="preserve">
-    <value>This person does not exist</value>
-  </data>
-	<data name="YTSearchCap" xml:space="preserve">
-    <value>YouTube search cap reached :(</value>
-  </data>
-	<data name="CreatingVideoCache" xml:space="preserve">
-    <value>Someone is already creating a video cache, please wait..</value>
-  </data>
-	<data name="User" xml:space="preserve">
-    <value>User</value>
-  </data>
-	<data name="editsnipeSummary" xml:space="preserve">
-    <value>Shows the last edited message in the current channel.</value>
-  </data>
-	<data name="reactionSummary" xml:space="preserve">
-    <value>Adds a reaction to a message.</value>
-  </data>
-	<data name="reactionParam1" xml:space="preserve">
-    <value>The reaction to add (only one).</value>
-  </data>
-	<data name="reactionParam2" xml:space="preserve">
-    <value>The message ID to add a reaction (must be from same channel the command is executed).</value>
-  </data>
-	<data name="InvalidMessageID" xml:space="preserve">
-    <value>Invalid message ID. Make sure the message is from this channel.</value>
-  </data>
-	<data name="InvalidReaction" xml:space="preserve">
-    <value>Invalid emote/emoji.</value>
-  </data>
-	<data name="BadArgumentCount" xml:space="preserve">
-    <value>The command is missing required arguments. Use `{0}help {1}` to get more info about the command.</value>
-  </data>
-	<data name="CommandParseFailed" xml:space="preserve">
-    <value>Error parsing the command arguments. An argument is invalid or the order of the arguments is incorrect.
-Use `{0}help {1}` to get more info about the command.</value>
-  </data>
-	<data name="TranslationResults" xml:space="preserve">
-    <value>Translation results</value>
-  </data>
-	<data name="SourceLanguage" xml:space="preserve">
-    <value>Source language (Detected)</value>
-  </data>
-	<data name="TargetLanguage" xml:space="preserve">
-    <value>Target language</value>
-  </data>
-	<data name="Result" xml:space="preserve">
-    <value>Result</value>
-  </data>
-	<data name="ErrorType" xml:space="preserve">
-    <value>Error type</value>
-  </data>
-	<data name="ErrorMessage" xml:space="preserve">
-    <value>Error message</value>
-  </data>
-	<data name="seekSummary" xml:space="preserve">
-    <value>Seeks the current track to the specified position.</value>
-  </data>
-	<data name="seekParam1" xml:space="preserve">
-    <value>The nth second to seek or a valid time with the format `m:ss`, `mm:ss`, `h:mm:ss` or `hh:mm:ss`.</value>
-  </data>
-	<data name="CannotSeek" xml:space="preserve">
-    <value>Cannot seek this track.</value>
-  </data>
-	<data name="SeekHigherOrEqual" xml:space="preserve">
-    <value>The second to go ({0}) cannot be higher or equal than the track's length ({1}).</value>
-  </data>
-	<data name="SeekComplete" xml:space="preserve">
-    <value>Skipped to second {0} ({1} of {2}).</value>
-  </data>
-	<data name="ErrorInTranslation" xml:space="preserve">
-    <value>An error occurred in the translation API. Please try again later.</value>
-  </data>
-	<data name="statsSummary" xml:space="preserve">
-    <value>Shows the bot stats.</value>
-  </data>
-	<data name="AdventureDeleted" xml:space="preserve">
-    <value>The adventure was deleted.</value>
-  </data>
-	<data name="CPUUsage" xml:space="preserve">
-    <value>CPU Usage</value>
-  </data>
-	<data name="RAMUsage" xml:space="preserve">
-    <value>RAM Usage</value>
-  </data>
-	<data name="Library" xml:space="preserve">
-    <value>Library</value>
-  </data>
-	<data name="BotVersion" xml:space="preserve">
-    <value>Bot version</value>
-  </data>
-	<data name="OperatingSystem" xml:space="preserve">
-    <value>Operating System</value>
-  </data>
-	<data name="BotOwner" xml:space="preserve">
-    <value>Bot owner</value>
-  </data>
-	<data name="CurrentNewNickEqual" xml:space="preserve">
-    <value>The current nickname and the new nick are the same.</value>
-  </data>
-	<data name="Yes" xml:space="preserve">
-    <value>Yes</value>
-  </data>
-	<data name="No" xml:space="preserve">
-    <value>No</value>
-  </data>
-	<data name="True" xml:space="preserve">
-    <value>True</value>
-  </data>
-	<data name="False" xml:space="preserve">
-    <value>False</value>
-  </data>
-	<data name="ConfigList" xml:space="preserve">
-    <value>Auto translate AI Dungeon input &amp; output
-Track selection for `play`</value>
-  </data>
-	<data name="ConfigPrompt" xml:space="preserve">
-    <value>Use the reactions to enable or disable an option.</value>
-  </data>
-	<data name="FergunConfig" xml:space="preserve">
-    <value>Fergun configuration</value>
-  </data>
-	<data name="Option" xml:space="preserve">
-    <value>Option</value>
-  </data>
-	<data name="Value" xml:space="preserve">
-    <value>Value</value>
-  </data>
-	<data name="MissingPermissions" xml:space="preserve">
-    <value>You don't have the permissions to run this command.</value>
-  </data>
-	<data name="UserBlacklisted" xml:space="preserve">
-    <value>The user {0} was blacklisted.</value>
-  </data>
-	<data name="UserBlacklistedWithReason" xml:space="preserve">
-    <value>The user {0} was blacklisted with the reason: {1}</value>
-  </data>
-	<data name="UserBlacklistRemoved" xml:space="preserve">
-    <value>The user {0} was removed from the blacklist.</value>
-  </data>
-	<data name="ServerBlacklisted" xml:space="preserve">
-    <value>The server {0} was blacklisted.</value>
-  </data>
-	<data name="ServerBlacklistedWithReason" xml:space="preserve">
-    <value>The server {0} was blacklisted with the reason: {1}</value>
-  </data>
-	<data name="ServerBlacklistRemoved" xml:space="preserve">
-    <value>The server {0} was removed from the blacklist.</value>
-  </data>
-	<data name="blacklistSummary" xml:space="preserve">
-    <value>Adds a user to the blacklist, or removes it.</value>
-  </data>
-	<data name="blacklistParam1" xml:space="preserve">
-    <value>The ID of the user to blacklist.</value>
-  </data>
-	<data name="blacklistParam2" xml:space="preserve">
-    <value>The reason for being blacklisted.</value>
-  </data>
-	<data name="blacklistserverSummary" xml:space="preserve">
-    <value>Adds a server to the blacklist, or removes it.</value>
-  </data>
-	<data name="blacklistserverParam1" xml:space="preserve">
-    <value>The ID of the server to blacklist.</value>
-  </data>
-	<data name="blacklistserverParam2" xml:space="preserve">
-    <value>The reason for being blacklisted.</value>
-  </data>
-	<data name="Blacklisted" xml:space="preserve">
-    <value>You are in the blacklist.</value>
-  </data>
-	<data name="BlacklistedWithReason" xml:space="preserve">
-    <value>You are in the blacklist with the reason: {0}</value>
-  </data>
-	<data name="UserRequireBanMembers" xml:space="preserve">
-    <value>You need the `Ban Members` permission to use this command.</value>
-  </data>
-	<data name="BotRequireBanMembers" xml:space="preserve">
-    <value>I need the `Ban Members` permission to execute this command.</value>
-  </data>
-	<data name="UserRequireManageMessages" xml:space="preserve">
-    <value>You need the `Manage Messages` permission to use this command.</value>
-  </data>
-	<data name="BotRequireManageMessages" xml:space="preserve">
-    <value>I need the `Manage Messages` permission to execute this command.</value>
-  </data>
-	<data name="UserRequireKickMembers" xml:space="preserve">
-    <value>You need the `Kick Members` permission to use this command.</value>
-  </data>
-	<data name="BotRequireKickMembers" xml:space="preserve">
-    <value>I need the `Kick Members` permission to execute this command.</value>
-  </data>
-	<data name="UserRequireManageNicknames" xml:space="preserve">
-    <value>You need the `Manage Nicknames` permission to use this command.</value>
-  </data>
-	<data name="BotRequireManageNicknames" xml:space="preserve">
-    <value>I need the `Manage Nicknames` permission to execute this command.</value>
-  </data>
-	<data name="UserNotLowerHierarchy" xml:space="preserve">
-    <value>Specified user must be lower in hierarchy.</value>
-  </data>
-	<data name="BotRequireConnect" xml:space="preserve">
-    <value>I need the `Connect` permission to join the voice channel.</value>
-  </data>
-	<data name="BotRequireSpeak" xml:space="preserve">
-    <value>I need the `Speak` permission to continue.</value>
-  </data>
-	<data name="MoveSameChannel" xml:space="preserve">
-    <value>Move to the voice channel you want me to go.</value>
-  </data>
-	<data name="ProcessingTime" xml:space="preserve">
-    <value>Processing time: {0}ms</value>
-  </data>
-	<data name="OcrResults" xml:space="preserve">
-    <value>OCR Results</value>
-  </data>
-	<data name="BotRequireAttachFiles" xml:space="preserve">
-    <value>I need the `Attach Files` permission to execute this command.</value>
-  </data>
-	<data name="OcrApiError" xml:space="preserve">
-    <value>Error in the OCR API.</value>
-  </data>
-	<data name="forceprefixSummary" xml:space="preserve">
-    <value>Force sets the prefix in this guild.</value>
-  </data>
-	<data name="FirstTip" xml:space="preserve">
-    <value>Use {0}aid continue &lt;ID&gt; [text] to continue your adventure.</value>
-  </data>
-	<data name="PrefixTooLarge" xml:space="preserve">
-    <value>The prefix is too large. The max. prefix length is: {0}.</value>
-  </data>
-	<data name="InvalidUrl" xml:space="preserve">
-    <value>Invalid URL.</value>
-  </data>
-	<data name="ocrtranslateSummary" xml:space="preserve">
-    <value>OCR and translate.</value>
-  </data>
-	<data name="ocrtranslateParam1" xml:space="preserve">
-    <value>The target language in ISO code (en, es, br, etc.)</value>
-  </data>
-	<data name="ocrtranslateParam2" xml:space="preserve">
-    <value>The url of an image to use.</value>
-  </data>
-	<data name="Input" xml:space="preserve">
-    <value>Input</value>
-  </data>
-	<data name="RoleNotFound" xml:space="preserve">
-    <value>Role not found.</value>
-  </data>
-	<data name="RoleInfo" xml:space="preserve">
-    <value>Role info</value>
-  </data>
-	<data name="Color" xml:space="preserve">
-    <value>Color</value>
-  </data>
-	<data name="IsMentionable" xml:space="preserve">
-    <value>Is mentionable</value>
-  </data>
-	<data name="Permissions" xml:space="preserve">
-    <value>Permissions</value>
-  </data>
-	<data name="MemberCount" xml:space="preserve">
-    <value>Member count</value>
-  </data>
-	<data name="Mention" xml:space="preserve">
-    <value>Mention</value>
-  </data>
-	<data name="roleinfoSummary" xml:space="preserve">
-    <value>Gets information about a role.</value>
-  </data>
-	<data name="roleinfoParam1" xml:space="preserve">
-    <value>The role to get info from (it isn't necessary to mention it).</value>
-  </data>
-	<data name="logoutSummary" xml:space="preserve">
-    <value>Disconnects the bot.</value>
-  </data>
-	<data name="nothingSummary" xml:space="preserve">
-    <value>The best command.</value>
-  </data>
-	<data name="someoneSummary" xml:space="preserve">
-    <value>Returns a random user.</value>
-  </data>
-	<data name="ActiveClients" xml:space="preserve">
-    <value>Active clients</value>
-  </data>
-	<data name="Low" xml:space="preserve">
-    <value>Low</value>
-  </data>
-	<data name="Medium" xml:space="preserve">
-    <value>Medium</value>
-  </data>
-	<data name="High" xml:space="preserve">
-    <value>High</value>
-  </data>
-	<data name="Extreme" xml:space="preserve">
-    <value>Extreme</value>
-  </data>
-	<data name="IsHoisted" xml:space="preserve">
-    <value>Is hoisted</value>
-  </data>
-	<data name="Position" xml:space="preserve">
-    <value>Position</value>
-  </data>
-	<data name="ServerFeatures" xml:space="preserve">
-    <value>Features</value>
-  </data>
-	<data name="BoostTier" xml:space="preserve">
-    <value>Nitro Boost Tier</value>
-  </data>
-	<data name="BoostCount" xml:space="preserve">
-    <value>Nitro Boost Count</value>
-  </data>
-	<data name="SeekTimeHigherOrEqual" xml:space="preserve">
-    <value>The time to go ({0}) cannot be higher or equal than the track's length ({1}).</value>
-  </data>
-	<data name="SeekTimeComplete" xml:space="preserve">
-    <value>Skipped to {0} of {1}</value>
-  </data>
-	<data name="SeekInvalidFormat" xml:space="preserve">
-    <value>You must pass an integer or a string with the format `m:ss`, `mm:ss`, `h:mm:ss` or `hh:mm:ss`.</value>
-  </data>
-	<data name="Ratelimited" xml:space="preserve">
-    <value>Wait {0} seconds before using this command again!</value>
-  </data>
-	<data name="ObjectNotFound" xml:space="preserve">
-    <value>Object not found.</value>
-  </data>
-	<data name="MultipleMatches" xml:space="preserve">
-    <value>Multiple matches found.
-Try using a tag ({0}), a mention ({1}) or an ID ({2}).</value>
-  </data>
-	<data name="Roles" xml:space="preserve">
-    <value>Roles</value>
-  </data>
-	<data name="BoostingSince" xml:space="preserve">
-    <value>Boosting since</value>
-  </data>
-	<data name="AlreadyConnected" xml:space="preserve">
-    <value>The bot is already connected to a voice channel.</value>
-  </data>
-	<data name="InvalidFileType" xml:space="preserve">
-    <value>Invalid file type.</value>
-  </data>
-	<data name="OwnerCommands" xml:space="preserve">
-    <value>Owner commands</value>
-  </data>
-	<data name="Vote" xml:space="preserve">
-    <value>You can vote for me [here]({0}). Thank you.</value>
-  </data>
-	<data name="voteSummary" xml:space="preserve">
-    <value>Shows an embed with the vote link.</value>
-  </data>
-	<data name="TotalServers" xml:space="preserve">
-    <value>Total servers</value>
-  </data>
-	<data name="TotalUsers" xml:space="preserve">
-    <value>Total users</value>
-  </data>
-	<data name="Invite" xml:space="preserve">
-    <value>Invite</value>
-  </data>
-	<data name="TopGGBotPage" xml:space="preserve">
-    <value>Top.GG Bot Page</value>
-  </data>
-	<data name="VoteLink" xml:space="preserve">
-    <value>Vote Link</value>
-  </data>
-	<data name="SupportServer" xml:space="preserve">
-    <value>Support Server</value>
-  </data>
-	<data name="ContactInfo" xml:space="preserve">
-    <value>If you have any bug report, question or suggestion, you can join the [support server]({0}) or contact me via direct message ({1}).</value>
-  </data>
-	<data name="ContactInfoNoServer" xml:space="preserve">
-    <value>If you have any bug report, question or suggestion, you can contact me via direct message ({0}).</value>
-  </data>
-	<data name="RedoingLastAction" xml:space="preserve">
-    <value>Redoing last action...</value>
-  </data>
-	<data name="SelfNoIDs" xml:space="preserve">
-    <value>You don't have any adventure IDs.
-You can use the `new` command to create a new adventure.</value>
-  </data>
-	<data name="QueueExcess" xml:space="preserve">
-    <value>..and {0} more tracks!</value>
-  </data>
-	<data name="ErrorHelp" xml:space="preserve">
-    <value>If this keeps happening please report this error in the [support server]({0}) or open an issue in the [GitHub repository]({1}) describing the error and the steps to reproduce it.</value>
-  </data>
-	<data name="bashSummary" xml:space="preserve">
-    <value>Runs a bash command.</value>
-  </data>
-	<data name="supportSummary" xml:space="preserve">
-    <value>Shows the support info.</value>
-  </data>
-	<data name="ErrorInAPI" xml:space="preserve">
-    <value>An error occurred while calling the API.</value>
-  </data>
-	<data name="Topic" xml:space="preserve">
-    <value>Topic</value>
-  </data>
-	<data name="IsNSFW" xml:space="preserve">
-    <value>Is NSFW</value>
-  </data>
-	<data name="SlowMode" xml:space="preserve">
-    <value>Slow mode</value>
-  </data>
-	<data name="ChannelInfo" xml:space="preserve">
-    <value>Channel Info</value>
-  </data>
-	<data name="channelinfoSummary" xml:space="preserve">
-    <value>Shows info about a channel.</value>
-  </data>
-	<data name="OcrtrResults" xml:space="preserve">
-    <value>OCR and translation results</value>
-  </data>
-	<data name="NoChoices" xml:space="preserve">
-    <value>You need to pass at least 1 choice.</value>
-  </data>
-	<data name="ChannelNotFound" xml:space="preserve">
-    <value>Channel not found.</value>
-  </data>
-	<data name="channelinfoParam1" xml:space="preserve">
-    <value>The channel to get info from.</value>
-  </data>
-	<data name="UserRequireManageServer" xml:space="preserve">
-    <value>You need the `Manage Server` permission to use this command.</value>
-  </data>
-	<data name="badtranslatorSummary" xml:space="preserve">
-    <value>Passes a text through a bad translator.</value>
-  </data>
-	<data name="badtranslatorParam1" xml:space="preserve">
-    <value>The text to use.</value>
-  </data>
-	<data name="LanguageChain" xml:space="preserve">
-    <value>Language chain</value>
-  </data>
-	<data name="VersionNotFound" xml:space="preserve">
-    <value>Version not found. The versions are: {0}</value>
-  </data>
-	<data name="OtherVersions" xml:space="preserve">
-    <value>Other versions: {0}</value>
-  </data>
-	<data name="DeletedMessages" xml:space="preserve">
-    <value>Deleted {0} messages.</value>
-  </data>
-	<data name="DeletedMessagesByUser" xml:space="preserve">
-    <value>Deleted {0} messages by {1}.</value>
-  </data>
-	<data name="calcSummary" xml:space="preserve">
-    <value>Evaluates a math expression.</value>
-  </data>
-	<data name="calcParam1" xml:space="preserve">
-    <value>The expression to evaluate.</value>
-  </data>
-	<data name="InvalidExpression" xml:space="preserve">
-    <value>Invalid expression.</value>
-  </data>
-	<data name="CalcResults" xml:space="preserve">
-    <value>Calc results</value>
-  </data>
-	<data name="LoopUpdated" xml:space="preserve">
-    <value>The player was updated to repeat the track {0} times. Use this command without parameters to disable the track looping.</value>
-  </data>
-	<data name="LoopNoValuePassed" xml:space="preserve">
-    <value>Pass the number of times you want to repeat the track, ex:
-`{0}loop 5`</value>
-  </data>
-	<data name="NowLooping" xml:space="preserve">
-    <value>The current track will now be repeated {0} times.
-To disable the loop, use this command without parameters.</value>
-  </data>
-	<data name="LoopEnded" xml:space="preserve">
-    <value>The looping for the track: {0} has ended.</value>
-  </data>
-	<data name="loopSummary" xml:space="preserve">
-    <value>Repeats the current track a number of times.</value>
-  </data>
-	<data name="loopParam1" xml:space="preserve">
-    <value>The number of times to repeat the track.</value>
-  </data>
-	<data name="LoopDisabled" xml:space="preserve">
-    <value>The track looping has been disabled.</value>
-  </data>
-	<data name="LyricsQueryNotPassed" xml:space="preserve">
-    <value>You need to play a track or pass a track name.</value>
-  </data>
-	<data name="LyricsByGenius" xml:space="preserve">
-    <value>Lyrics by Genius</value>
-  </data>
-	<data name="PaginatorHelp" xml:space="preserve">
-    <value>This is a paginator. React with the respective icons to change page.</value>
-  </data>
-	<data name="ArtistPage" xml:space="preserve">
-    <value>Artist Page</value>
-  </data>
-	<data name="AdventureDeletionPrompt" xml:space="preserve">
-    <value>Press the button/emoji below to delete the adventure.</value>
-  </data>
-	<data name="ReactTimeout" xml:space="preserve">
-    <value>You did not react before the timeout!</value>
-  </data>
-	<data name="LanguageSelection" xml:space="preserve">
-    <value>Language Selection</value>
-  </data>
-	<data name="LanguagePrompt" xml:space="preserve">
-    <value>Select the language you want to set.</value>
-  </data>
-	<data name="lyricsRemarks" xml:space="preserve">
-    <value>Use `-headers` at the end of the command to keep the tags (`[info]`).</value>
-  </data>
-	<data name="HelpFooter2" xml:space="preserve">
-    <value>&lt;&gt; = Required | [] = Optional | Do not type these symbols when writing the command.</value>
-  </data>
-	<data name="invertSummary" xml:space="preserve">
-    <value>Inverts (negates) an image.</value>
-  </data>
-	<data name="invertParam1" xml:space="preserve">
-    <value>The url of an image to use.</value>
-  </data>
-	<data name="MessagesOlderThan2Weeks" xml:space="preserve">
-    <value>Messages must be younger than two weeks.</value>
-  </data>
-	<data name="SomeMessagesNotDeleted" xml:space="preserve">
-    <value>{0} messages could not be deleted because they are older than two weeks.</value>
-  </data>
-	<data name="GuildNotFound" xml:space="preserve">
-    <value>Server not found.</value>
-  </data>
-	<data name="ErrorParsingLyrics" xml:space="preserve">
-    <value>An error occurred while parsing the lyrics for {0}.
-Maybe it's an instrumental?</value>
-  </data>
-	<data name="MustBeLowerThan" xml:space="preserve">
-    <value>Parameter `{0}` must be lower than {1}.</value>
-  </data>
-	<data name="InvalidID" xml:space="preserve">
-    <value>Invalid ID.</value>
-  </data>
-	<data name="HastebinLink" xml:space="preserve">
-    <value>Hastebin link</value>
-  </data>
-	<data name="NotAvailable" xml:space="preserve">
-    <value>Not available.</value>
-  </data>
-	<data name="WaitingQueue" xml:space="preserve">
-    <value>Waiting for the queue to empty...</value>
-  </data>
-	<data name="NewOutputPrompt" xml:space="preserve">
-    <value>Enter the new text</value>
-  </data>
-	<data name="GeneratingNewResponse" xml:space="preserve">
-    <value>Generating a new response...</value>
-  </data>
-	<data name="InputTypes" xml:space="preserve">
-    <value>Input options</value>
-  </data>
-	<data name="InputTypesList" xml:space="preserve">
-    <value>Do: An action you want to take in the story. Always start with an action, ex. "Search for the hidden treasure". This is the default option if no one is detected.
-
-Say: You can use this to speak, ex. "Leave them alone!"
-
-Story: This input stops the AI from putting "You" in front of your input.</value>
-  </data>
-	<data name="Members" xml:space="preserve">
-    <value>Members</value>
-  </data>
-	<data name="Online" xml:space="preserve">
-    <value>Online</value>
-  </data>
-	<data name="Idle" xml:space="preserve">
-    <value>Idle</value>
-  </data>
-	<data name="DnD" xml:space="preserve">
-    <value>DnD</value>
-  </data>
-	<data name="Offline" xml:space="preserve">
-    <value>Offline</value>
-  </data>
-	<data name="ChannelCount" xml:space="preserve">
-    <value>Channel count</value>
-  </data>
-	<data name="Region" xml:space="preserve">
-    <value>Region</value>
-  </data>
-	<data name="DefaultChannel" xml:space="preserve">
-    <value>Default channel</value>
-  </data>
-	<data name="LanguageNotFound" xml:space="preserve">
-    <value>Language not found.</value>
-  </data>
-	<data name="CategoryCount" xml:space="preserve">
-    <value>Category count</value>
-  </data>
-	<data name="DumpingAdventure" xml:space="preserve">
-    <value>Dumping the adventure...</value>
-  </data>
-	<data name="EditCanceled" xml:space="preserve">
-    <value>Edit canceled.</value>
-  </data>
-	<data name="NothingToDelete" xml:space="preserve">
-    <value>Nothing to delete.</value>
-  </data>
-	<data name="aidinfoSummary" xml:space="preserve">
-    <value>Shows the AI Dungeon info.</value>
-  </data>
-	<data name="aidnewSummary" xml:space="preserve">
-    <value>Creates a new adventure.</value>
-  </data>
-	<data name="continueSummary" xml:space="preserve">
-    <value>Continues the adventure with the provided text. If no text is passed, the AI will generate the story.</value>
-  </data>
-	<data name="continueParam1" xml:space="preserve">
-    <value>The adventure ID.</value>
-  </data>
-	<data name="continueParam2" xml:space="preserve">
-    <value>The text to use.</value>
-  </data>
-	<data name="undoSummary" xml:space="preserve">
-    <value>Undoes the last action.</value>
-  </data>
-	<data name="undoParam1" xml:space="preserve">
-    <value>The adventure ID.</value>
-  </data>
-	<data name="redoSummary" xml:space="preserve">
-    <value>Redoes the last undone action.</value>
-  </data>
-	<data name="redoParam1" xml:space="preserve">
-    <value>The adventure ID.</value>
-  </data>
-	<data name="rememberSummary" xml:space="preserve">
-    <value>Adds text to the memory context.</value>
-  </data>
-	<data name="rememberParam1" xml:space="preserve">
-    <value>The adventure ID.</value>
-  </data>
-	<data name="rememberParam2" xml:space="preserve">
-    <value>The text to add.</value>
-  </data>
-	<data name="alterSummary" xml:space="preserve">
-    <value>Edits the last response.</value>
-  </data>
-	<data name="alterParam1" xml:space="preserve">
-    <value>The adventure ID.</value>
-  </data>
-	<data name="retrySummary" xml:space="preserve">
-    <value>Retries the last action and generates a new response.</value>
-  </data>
-	<data name="retryParam1" xml:space="preserve">
-    <value>The adventure ID.</value>
-  </data>
-	<data name="makepublicSummary" xml:space="preserve">
-    <value>Makes an ID public.</value>
-  </data>
-	<data name="makepublicParam1" xml:space="preserve">
-    <value>The adventure ID. You have to own this ID.</value>
-  </data>
-	<data name="makeprivateSummary" xml:space="preserve">
-    <value>Makes an ID private.</value>
-  </data>
-	<data name="makeprivateParam1" xml:space="preserve">
-    <value>The adventure ID. You have to own this ID.</value>
-  </data>
-	<data name="idlistSummary" xml:space="preserve">
-    <value>Gets the ID list of a user.</value>
-  </data>
-	<data name="idlistParam1" xml:space="preserve">
-    <value>The user to get its IDs.</value>
-  </data>
-	<data name="idinfoSummary" xml:space="preserve">
-    <value>Shows info about a ID.</value>
-  </data>
-	<data name="idinfoParam1" xml:space="preserve">
-    <value>The adventure ID.</value>
-  </data>
-	<data name="deleteSummary" xml:space="preserve">
-    <value>Removes an ID.</value>
-  </data>
-	<data name="deleteParam1" xml:space="preserve">
-    <value>The adventure ID.</value>
-  </data>
-	<data name="dumpSummary" xml:space="preserve">
-    <value>Dumps all the text from an ID.</value>
-  </data>
-	<data name="dumpParam1" xml:space="preserve">
-    <value>The adventure ID.</value>
-  </data>
-	<data name="RatelimitUses" xml:space="preserve">
-    <value>{0} use(s) every {1}</value>
-  </data>
-	<data name="Requirements" xml:space="preserve">
-    <value>Requirements</value>
-  </data>
-	<data name="cmdstatsSummary" xml:space="preserve">
-    <value>Shows the command stats.</value>
-  </data>
-	<data name="CommandStatsInfo" xml:space="preserve">
-    <value>Command stats (since {0})</value>
-  </data>
-	<data name="lmgtfySummary" xml:space="preserve">
-    <value>Let me Google that for you.</value>
-  </data>
-	<data name="lmgtfyParam1" xml:space="preserve">
-    <value>The keyword(s) to search.</value>
-  </data>
-	<data name="pasteSummary" xml:space="preserve">
-    <value>Uploads text to Hastebin.</value>
-  </data>
-	<data name="pasteParam1" xml:space="preserve">
-    <value>The text to upload.</value>
-  </data>
-	<data name="Uploading" xml:space="preserve">
-    <value>Uploading...</value>
-  </data>
-	<data name="bigsnipeSummary" xml:space="preserve">
-    <value>Shows the last deleted messages in the current channel or the specified.</value>
-  </data>
-	<data name="bigsnipeParam1" xml:space="preserve">
-    <value>A specific channel to snipe.</value>
-  </data>
-	<data name="MinutesAgo" xml:space="preserve">
-    <value>{0} minutes ago</value>
-  </data>
-	<data name="Attachment" xml:space="preserve">
-    <value>Attachment</value>
-  </data>
-	<data name="bigeditsnipeSummary" xml:space="preserve">
-    <value>Shows the last edited messages in the current channel or the specified.</value>
-  </data>
-	<data name="bigeditsnipeParam1" xml:space="preserve">
-    <value>A specific channel to snipe.</value>
-  </data>
-	<data name="TextChannel" xml:space="preserve">
-    <value>Text Channel</value>
-  </data>
-	<data name="AnnouncementChannel" xml:space="preserve">
-    <value>Announcement Channel</value>
-  </data>
-	<data name="VoiceChannel" xml:space="preserve">
-    <value>Voice Channel</value>
-  </data>
-	<data name="DMChannel" xml:space="preserve">
-    <value>DM Channel</value>
-  </data>
-	<data name="Bitrate" xml:space="preserve">
-    <value>Bitrate</value>
-  </data>
-	<data name="UserLimit" xml:space="preserve">
-    <value>User Limit</value>
-  </data>
-	<data name="NoLimit" xml:space="preserve">
-    <value>No Limit</value>
-  </data>
-	<data name="disableSummary" xml:space="preserve">
-    <value>Disables a command locally (for this server).</value>
-  </data>
-	<data name="disableParam1" xml:space="preserve">
-    <value>The name of the command to disable.</value>
-  </data>
-	<data name="NonDisableable" xml:space="preserve">
-    <value>{0} can't be disabled!</value>
-  </data>
-	<data name="AlreadyDisabled" xml:space="preserve">
-    <value>{0} is already disabled!</value>
-  </data>
-	<data name="CommandDisabled" xml:space="preserve">
-    <value>The command {0} has been disabled in this server.</value>
-  </data>
-	<data name="enableSummary" xml:space="preserve">
-    <value>Enables a command locally (for this server).</value>
-  </data>
-	<data name="enableParam1" xml:space="preserve">
-    <value>The name of the command to enable.</value>
-  </data>
-	<data name="AlreadyEnabled" xml:space="preserve">
-    <value>{0} is already enabled!</value>
-  </data>
-	<data name="CommandEnabled" xml:space="preserve">
-    <value>The command {0} has been enabled in this server.</value>
-  </data>
-	<data name="globaldisableSummary" xml:space="preserve">
-    <value>Disables a command globally (in all servers).</value>
-  </data>
-	<data name="globaldisableParam1" xml:space="preserve">
-    <value>The name of the command to disable globally.</value>
-  </data>
-	<data name="globaldisableParam2" xml:space="preserve">
-    <value>The reason to use.</value>
-  </data>
-	<data name="AlreadyDisabledGlobally" xml:space="preserve">
-    <value>{0} is already disabled globally!</value>
-  </data>
-	<data name="CommandDisabledGlobally" xml:space="preserve">
-    <value>The command {0} has been disabled in all servers.</value>
-  </data>
-	<data name="globalenableSummary" xml:space="preserve">
-    <value>Enables a command globally (in all servers).</value>
-  </data>
-	<data name="globalenableParam1" xml:space="preserve">
-    <value>The name of the command to enable globally.</value>
-  </data>
-	<data name="AlreadyEnabledGlobally" xml:space="preserve">
-    <value>{0} is already enabled globally!</value>
-  </data>
-	<data name="CommandEnabledGlobally" xml:space="preserve">
-    <value>The command {0} has been enabled in all servers.</value>
-  </data>
-	<data name="Reason" xml:space="preserve">
-    <value>Reason</value>
-  </data>
-	<data name="LeftVCInactivity" xml:space="preserve">
-		<value>I have left the voice channel due to inactivity.</value>
-	</data>
-	<data name="MusicPlayerShutdownWarning" xml:space="preserve">
-		<value>Fergun will be restarted/turned off in some seconds and your music player will be shut down. Sorry for the inconvenience.</value>
-	</data>
-	<data name="Warning" xml:space="preserve">
-		<value>Warning</value>
-	</data>
-	<data name="PublicIdNull" xml:space="preserve">
-		<value>The public ID of this adventure is null. Please create a new adventure.</value>
-	</data>
-	<data name="giveSummary" xml:space="preserve">
-		<value>Gives (transfers) the specified ID to a user.</value>
-	</data>
-	<data name="giveParam1" xml:space="preserve">
-		<value>The adventure ID.</value>
-	</data>
-	<data name="giveParam2" xml:space="preserve">
-		<value>The user to give the ID.</value>
-	</data>
-	<data name="CannotGiveYourself" xml:space="preserve">
-		<value>You cannot give the ID yourself!</value>
-	</data>
-	<data name="CannotGiveToBot" xml:space="preserve">
-		<value>You cannot give the ID to a bot!</value>
-	</data>
-	<data name="GaveId" xml:space="preserve">
-		<value>The ID was successfully transferred to {0}.</value>
-	</data>
-	<data name="ValueNotSetInConfig" xml:space="preserve">
-		<value>The value of `{0}` has not been established in the config. If this problem persists, contact the developers.</value>
-	</data>
-	<data name="NoAvailableLanguages" xml:space="preserve">
-		<value>It seems there are no available languages.</value>
-	</data>
-	<data name="RequestTimedOut" xml:space="preserve">
-		<value>Request timed out.</value>
-	</data>
-	<data name="DiscordServerError" xml:space="preserve">
-		<value>Error in Discord Servers</value>
-	</data>
-	<data name="DiscordServerErrorInfo" xml:space="preserve">
-		<value>The command failed due to an issue on Discord's side.
-Please try again later.</value>
-	</data>
-	<data name="ErrorDetails" xml:space="preserve">
-		<value>Error Details</value>
-	</data>
-	<data name="NowhereToVote" xml:space="preserve">
-		<value>There's nowhere to vote.</value>
-	</data>
-	<data name="LavalinkNotConnected" xml:space="preserve">
-		<value>Could not connect to the Lavalink server. Please try again later.</value>
-	</data>
-	<data name="NoPresenceIntent" xml:space="preserve">
-		<value>Cannot get Spotify status because I don't have the Guild Presences intent.</value>
-	</data>
-	<data name="NoSpotifyStatus" xml:space="preserve">
-		<value>No Spotify status found for user {0}.</value>
-	</data>
-	<data name="ClickHere" xml:space="preserve">
-		<value>Click here</value>
-	</data>
-	<data name="Title" xml:space="preserve">
-		<value>Title</value>
-	</data>
-	<data name="Artists" xml:space="preserve">
-		<value>Artist(s)</value>
-	</data>
-	<data name="Album" xml:space="preserve">
-		<value>Album</value>
-	</data>
-	<data name="Duration" xml:space="preserve">
-		<value>Duration</value>
-	</data>
-	<data name="Lyrics" xml:space="preserve">
-		<value>Lyrics</value>
-	</data>
-	<data name="TrackUrl" xml:space="preserve">
-		<value>Track Url</value>
-	</data>
-	<data name="spotifySummary" xml:space="preserve">
-		<value>Gets the Spotify status info of a user.</value>
-	</data>
-	<data name="spotifyParam1" xml:space="preserve">
-		<value>The user to get its Spotify status info.</value>
-	</data>
-	<data name="wolframalphaSummary" xml:space="preserve">
-		<value>Gets a response from Wolfram|Alpha based on the query.</value>
-	</data>
-	<data name="wolframalphaParam1" xml:space="preserve">
-		<value>The query to send.</value>
-	</data>
-	<data name="defineSummary" xml:space="preserve">
-		<value>Gets definitions of a word.</value>
-	</data>
-	<data name="defineParam1" xml:space="preserve">
-		<value>The word to search.</value>
-	</data>
-	<data name="Word" xml:space="preserve">
-		<value>Word</value>
-	</data>
-	<data name="Definition" xml:space="preserve">
-		<value>Definition</value>
-	</data>
-	<data name="Synonyms" xml:space="preserve">
-		<value>Synonyms</value>
-	</data>
-	<data name="Antonyms" xml:space="preserve">
-		<value>Antonyms</value>
-	</data>
-	<data name="archiveSummary" xml:space="preserve">
-		<value>Gets a screenshot of a website in the specified year/month/day using a timestamp.</value>
-	</data>
-	<data name="archiveParam1" xml:space="preserve">
-		<value>The Url of the website to take a screenshot.</value>
-	</data>
-	<data name="archiveParam2" xml:space="preserve">
-		<value>A timestamp with the specified format.</value>
-	</data>
-	<data name="TimestampFormat" xml:space="preserve">
-		<value>The timestamp must have format `YYYYMMDDhhmmss`, where `YYYY` is required and the other values are optional.</value>
-	</data>
-	<data name="InvalidTimestamp" xml:space="preserve">
-		<value>Invalid timestamp.</value>
-	</data>
-	<data name="NoSnapshots" xml:space="preserve">
-		<value>No snapshots found for specified url and timestamp.</value>
-	</data>
-	<data name="Timestamp" xml:space="preserve">
-		<value>Timestamp</value>
-	</data>
-	<data name="CouldNotFindLine" xml:space="preserve">
-		<value>Could not find the command method line.</value>
-	</data>
-	<data name="shortenSummary" xml:space="preserve">
-		<value>Shortens a Url.</value>
-	</data>
-	<data name="shortenParam1" xml:space="preserve">
-		<value>The Url to shorten.</value>
-	</data>
-	<data name="Badges" xml:space="preserve">
-		<value>Badges</value>
-	</data>
-	<data name="img2Summary" xml:space="preserve">
-		<value>Searches for images with DuckDuckGo.</value>
-	</data>
-	<data name="img2Param1" xml:space="preserve">
-		<value>The keyword(s) to search.</value>
-	</data>
-	<data name="privacySummary" xml:space="preserve">
-		<value>Displays the privacy policy and the privacy configuration.</value>
-	</data>
-	<data name="PrivacyPolicy" xml:space="preserve">
-		<value>Privacy Policy</value>
-	</data>
-	<data name="WhatDataWeCollect" xml:space="preserve">
-		<value>What data we collect</value>
-	</data>
-	<data name="WhatDataWeCollectList" xml:space="preserve">
-		<value>a. Server configuration (server ID, custom prefix, bot language, disabled commands, etc)
-b. User configuration/stats (user ID, Trivia game points, privacy configuration and blacklist status)
-c. AI Dungeon adventures (adventure ID, owner ID and availability of use)
-d. Temporary collection of deleted/edited messages, used in the "snipe" commands (`snipe`, `editsnipe`, `bigsnipe` and `bigeditsnipe`)</value>
-	</data>
-	<data name="WhenWeCollectData" xml:space="preserve">
-		<value>When we collect it</value>
-	</data>
-	<data name="WhenWeCollectDataList" xml:space="preserve">
-		<value>a. When you set a custom prefix, language, or other configuration. This data is removed when the bot leaves that server.
-b. When you play a Trivia game, you use the privacy configuration or you're blacklisted.
-c. When you create an AI Dungeon adventure with the bot. This data is removed when you use the `aid delete` command.
-d. When a message is deleted/edited. These messages can only be seen using the "snipe" commands in the channel where the message was deleted/edited.
-These messages are only stored for 6 hours, and you can decide to opt out using the reactions below.</value>
-	</data>
-	<data name="PrivacyConfig" xml:space="preserve">
-		<value>Privacy configuration</value>
-	</data>
-	<data name="PrivacyConfigInfo" xml:space="preserve">
-		<value>Here you'll find some settings that you can change to improve your privacy.</value>
-	</data>
-	<data name="PrivacyConfigList" xml:space="preserve">
-		<value>Opt out of the temporary collection of deleted/edited messages in the "snipe" commands</value>
-	</data>
-	<data name="Privacy" xml:space="preserve">
-		<value>Privacy</value>
-	</data>
-	<data name="SnipePrivacy" xml:space="preserve">
-		<value>Don't want your message to be displayed here? See `privacy`.</value>
-	</data>
-	<data name="CannotUseThisInteraction" xml:space="preserve">
-		<value>You can't use this interaction.</value>
-	</data>
-	<data name="DeletingAdventure" xml:space="preserve">
-		<value>Deleting the adventure...</value>
-	</data>
-	<data name="Donate" xml:space="preserve">
-		<value>Donate</value>
-	</data>
-	<data name="LanguageList" xml:space="preserve">
-		<value>Language List</value>
-	</data>
-	<data name="img3Summary" xml:space="preserve">
-		<value>Searches for images with Brave.</value>
-	</data>
-	<data name="img3Param1" xml:space="preserve">
-		<value>The keyword(s) to search.</value>
-	</data>
-	<data name="Thread" xml:space="preserve">
-		<value>Thread</value>
-	</data>
-	<data name="AutoArchive" xml:space="preserve">
-		<value>Auto Archive</value>
-	</data>
-	<data name="StageChannel" xml:space="preserve">
-		<value>Stage Channel</value>
-	</data>
-	<data name="IsLive" xml:space="preserve">
-		<value>Is Live</value>
-	</data>
-	<data name="Archived" xml:space="preserve">
-		<value>Archived</value>
-	</data>
-</root>
\ No newline at end of file
diff --git a/src/strings.ru.resx b/src/strings.ru.resx
deleted file mode 100644
index aad5b7d..0000000
--- a/src/strings.ru.resx
+++ /dev/null
@@ -1,2181 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<root>
-	<!-- 
-    Microsoft ResX Schema 
-    
-    Version 2.0
-    
-    The primary goals of this format is to allow a simple XML format 
-    that is mostly human readable. The generation and parsing of the 
-    various data types are done through the TypeConverter classes 
-    associated with the data types.
-    
-    Example:
-    
-    ... ado.net/XML headers & schema ...
-    <resheader name="resmimetype">text/microsoft-resx</resheader>
-    <resheader name="version">2.0</resheader>
-    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
-    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
-    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
-    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
-    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
-        <value>[base64 mime encoded serialized .NET Framework object]</value>
-    </data>
-    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
-        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
-        <comment>This is a comment</comment>
-    </data>
-                
-    There are any number of "resheader" rows that contain simple 
-    name/value pairs.
-    
-    Each data row contains a name, and value. The row also contains a 
-    type or mimetype. Type corresponds to a .NET class that support 
-    text/value conversion through the TypeConverter architecture. 
-    Classes that don't support this are serialized and stored with the 
-    mimetype set.
-    
-    The mimetype is used for serialized objects, and tells the 
-    ResXResourceReader how to depersist the object. This is currently not 
-    extensible. For a given mimetype the value must be set accordingly:
-    
-    Note - application/x-microsoft.net.object.binary.base64 is the format 
-    that the ResXResourceWriter will generate, however the reader can 
-    read any of the formats listed below.
-    
-    mimetype: application/x-microsoft.net.object.binary.base64
-    value   : The object must be serialized with 
-            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
-            : and then encoded with base64 encoding.
-    
-    mimetype: application/x-microsoft.net.object.soap.base64
-    value   : The object must be serialized with 
-            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
-            : and then encoded with base64 encoding.
-
-    mimetype: application/x-microsoft.net.object.bytearray.base64
-    value   : The object must be serialized into a byte array 
-            : using a System.ComponentModel.TypeConverter
-            : and then encoded with base64 encoding.
-    -->
-	<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
-		<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
-		<xsd:element name="root" msdata:IsDataSet="true">
-			<xsd:complexType>
-				<xsd:choice maxOccurs="unbounded">
-					<xsd:element name="metadata">
-						<xsd:complexType>
-							<xsd:sequence>
-								<xsd:element name="value" type="xsd:string" minOccurs="0" />
-							</xsd:sequence>
-							<xsd:attribute name="name" use="required" type="xsd:string" />
-							<xsd:attribute name="type" type="xsd:string" />
-							<xsd:attribute name="mimetype" type="xsd:string" />
-							<xsd:attribute ref="xml:space" />
-						</xsd:complexType>
-					</xsd:element>
-					<xsd:element name="assembly">
-						<xsd:complexType>
-							<xsd:attribute name="alias" type="xsd:string" />
-							<xsd:attribute name="name" type="xsd:string" />
-						</xsd:complexType>
-					</xsd:element>
-					<xsd:element name="data">
-						<xsd:complexType>
-							<xsd:sequence>
-								<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
-								<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
-							</xsd:sequence>
-							<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
-							<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
-							<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
-							<xsd:attribute ref="xml:space" />
-						</xsd:complexType>
-					</xsd:element>
-					<xsd:element name="resheader">
-						<xsd:complexType>
-							<xsd:sequence>
-								<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
-							</xsd:sequence>
-							<xsd:attribute name="name" type="xsd:string" use="required" />
-						</xsd:complexType>
-					</xsd:element>
-				</xsd:choice>
-			</xsd:complexType>
-		</xsd:element>
-	</xsd:schema>
-	<resheader name="resmimetype">
-		<value>text/microsoft-resx</value>
-	</resheader>
-	<resheader name="version">
-		<value>2.0</value>
-	</resheader>
-	<resheader name="reader">
-		<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
-	</resheader>
-	<resheader name="writer">
-		<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
-	</resheader>
-	<data name="CommandList" xml:space="preserve">
-    <value>Список команд</value>
-  </data>
-	<data name="EntertainmentCommands" xml:space="preserve">
-    <value>Развлекательные команды</value>
-  </data>
-	<data name="HelpFooter" xml:space="preserve">
-    <value>Fergun {0} - Общее количество командование: {1}</value>
-  </data>
-	<data name="ModerationCommands" xml:space="preserve">
-    <value>Команды модерации</value>
-  </data>
-	<data name="MusicCommands" xml:space="preserve">
-    <value>Музыкальные команды</value>
-  </data>
-	<data name="Notes" xml:space="preserve">
-    <value>Ноты</value>
-  </data>
-	<data name="NotesInfo" xml:space="preserve">
-    <value>Используйте `{0}help [command]`, чтобы получить больше информации о команде.</value>
-  </data>
-	<data name="OtherCommands" xml:space="preserve">
-    <value>Другие команды</value>
-  </data>
-	<data name="TextCommands" xml:space="preserve">
-    <value>Текстовые команды</value>
-  </data>
-	<data name="UtilityCommands" xml:space="preserve">
-    <value>Служебные команды</value>
-  </data>
-	<data name="UpcomingCommands" xml:space="preserve">
-    <value>Предстоящие команды</value>
-  </data>
-	<data name="CommandNotFound" xml:space="preserve">
-    <value>Команда не найдена. Используйте `{0}help` для просмотра списка команд.</value>
-  </data>
-	<data name="NoDescription" xml:space="preserve">
-    <value>(Нет доступного описания)</value>
-  </data>
-	<data name="Optional" xml:space="preserve">
-    <value>(Необязательный)</value>
-  </data>
-	<data name="Usage" xml:space="preserve">
-    <value>Применение</value>
-  </data>
-	<data name="Alias" xml:space="preserve">
-    <value>Псевдоним(ы)</value>
-  </data>
-	<data name="Parameters" xml:space="preserve">
-    <value>Параметры</value>
-  </data>
-	<data name="mojibakeSummary" xml:space="preserve">
-    <value>Показывает случайные символы Юникода.</value>
-  </data>
-	<data name="mojibakeParam1" xml:space="preserve">
-    <value>Длина результата.</value>
-  </data>
-	<data name="3orMoreChars" xml:space="preserve">
-    <value>Текст должен содержать 3 или более символов.</value>
-  </data>
-	<data name="TooLongToDisplay" xml:space="preserve">
-    <value>Полученный текст слишком длинный для отображения ({0})</value>
-  </data>
-	<data name="normalizeSummary" xml:space="preserve">
-    <value>Нормализует текст.</value>
-  </data>
-	<data name="normalizeParam1" xml:space="preserve">
-    <value>Текст для нормализации.</value>
-  </data>
-	<data name="randomizeSummary" xml:space="preserve">
-    <value>Рандомизирует текст.</value>
-  </data>
-	<data name="randomizeParam1" xml:space="preserve">
-    <value>Текст для рандомизации.</value>
-  </data>
-	<data name="repeatSummary" xml:space="preserve">
-    <value>Повторяет текст несколько раз.</value>
-  </data>
-	<data name="repeatParam1" xml:space="preserve">
-    <value>Время повторить.</value>
-  </data>
-	<data name="repeatParam2" xml:space="preserve">
-    <value>Текст повторить.</value>
-  </data>
-	<data name="reverseSummary" xml:space="preserve">
-    <value>Переворачивает текст.</value>
-  </data>
-	<data name="reverseParam1" xml:space="preserve">
-    <value>Текст для реверса.</value>
-  </data>
-	<data name="reverselinesSummary" xml:space="preserve">
-    <value>Меняет порядок строк текста.</value>
-  </data>
-	<data name="reverselinesParam1" xml:space="preserve">
-    <value>Текст, чтобы перевернуть его строки.</value>
-  </data>
-	<data name="reversewordsSummary" xml:space="preserve">
-    <value>Меняет порядок слов в тексте.</value>
-  </data>
-	<data name="reversewordsParam1" xml:space="preserve">
-    <value>Текст, чтобы поменять его слова.</value>
-  </data>
-	<data name="sarcasmSummary" xml:space="preserve">
-    <value>саркастический текст</value>
-  </data>
-	<data name="sarcasmParam1" xml:space="preserve">
-    <value>Текст для конвертации.</value>
-  </data>
-	<data name="vaporwaveSummary" xml:space="preserve">
-    <value>Преобразует текст в steamwave.</value>
-  </data>
-	<data name="vaporwaveParam1" xml:space="preserve">
-    <value>Текст для конвертации.</value>
-  </data>
-	<data name="avatarSummary" xml:space="preserve">
-    <value>Возвращает аватар команды вызывающего абонента или определенного пользователя, если он пройден.</value>
-  </data>
-	<data name="avatarParam1" xml:space="preserve">
-    <value>Пользователь, чтобы получить свой аватар.</value>
-  </data>
-	<data name="base64encodeSummary" xml:space="preserve">
-    <value>Кодирует текст в Base64.</value>
-  </data>
-	<data name="base64encodeParam1" xml:space="preserve">
-    <value>Текст для кодирования.</value>
-  </data>
-	<data name="base64decodeSummary" xml:space="preserve">
-    <value>Декодирует текст из Base64.</value>
-  </data>
-	<data name="base64decodeParam1" xml:space="preserve">
-    <value>Текст для декодирования.</value>
-  </data>
-	<data name="base64decodeInvalid" xml:space="preserve">
-    <value>Неверный закодированный текст.</value>
-  </data>
-	<data name="choiceSummary" xml:space="preserve">
-    <value>Выбирает вариант из списка.</value>
-  </data>
-	<data name="choiceParam1" xml:space="preserve">
-    <value>Разделенный пробелами список вариантов.</value>
-  </data>
-	<data name="IChoose" xml:space="preserve">
-    <value>Я выбираю...</value>
-  </data>
-	<data name="OneChoice" xml:space="preserve">
-    <value>... потому что ты дал мне только один выбор</value>
-  </data>
-	<data name="colorSummary" xml:space="preserve">
-    <value>Показывает случайный или определенный цвет.</value>
-  </data>
-	<data name="colorParam1" xml:space="preserve">
-    <value>Определенный цвет для использования. Должно быть шестнадцатеричное значение, исходное значение или известное название цвета.</value>
-  </data>
-	<data name="helpSummary" xml:space="preserve">
-    <value>Показывает меню справки или информацию о команде, если она прошла.</value>
-  </data>
-	<data name="helpParam1" xml:space="preserve">
-    <value>Команда для получения информации.</value>
-  </data>
-	<data name="identifyParam1" xml:space="preserve">
-    <value>URL изображения для использования.</value>
-  </data>
-	<data name="AttachmentNotImage" xml:space="preserve">
-    <value>Вложение не является изображением.</value>
-  </data>
-	<data name="UrlNotFound" xml:space="preserve">
-    <value>Не удалось найти ни url-адреса, ни вложения в последних сообщениях ({0}).</value>
-  </data>
-	<data name="imgParam1" xml:space="preserve">
-    <value>Ключевое слово (а) для поиска.</value>
-  </data>
-	<data name="NoResultsFound" xml:space="preserve">
-    <value>Не удалось найти никаких результатов.</value>
-  </data>
-	<data name="IfNSFW" xml:space="preserve">
-    <value>Если поиск - NSFW, используйте команду в канале NSFW.</value>
-  </data>
-	<data name="ImageSearch" xml:space="preserve">
-    <value>Поиск изображения</value>
-  </data>
-	<data name="ImageSearchCap" xml:space="preserve">
-    <value>Достигнут предел поиска изображений :(</value>
-  </data>
-	<data name="ocrSummary" xml:space="preserve">
-    <value>Выполняет распознавание текста для изображения.</value>
-  </data>
-	<data name="OcrEmpty" xml:space="preserve">
-    <value>OCR не дал результатов.</value>
-  </data>
-	<data name="ocr2Summary" xml:space="preserve">
-    <value>Выполняет OCR для изображения.</value>
-  </data>
-	<data name="pingSummary" xml:space="preserve">
-    <value>Получает задержку в отправке сообщения и латентность базы данных.</value>
-  </data>
-	<data name="identifySummary" xml:space="preserve">
-    <value>Определяет изображение с помощью Microsoft CaptionBot.</value>
-  </data>
-	<data name="NoUrlPassed" xml:space="preserve">
-    <value>Не указав URL заставит команду для поиска последних х сообщений в кэше ссылки или вложения.</value>
-  </data>
-	<data name="UrlNotImage" xml:space="preserve">
-    <value>URL не является допустимым изображением.</value>
-  </data>
-	<data name="imgSummary" xml:space="preserve">
-    <value>Ищет изображения с помощью Google Images.</value>
-  </data>
-	<data name="UrbanFooter" xml:space="preserve">
-    <value>Городской словарь - Страница {0} из {1}</value>
-  </data>
-	<data name="ocrParam1" xml:space="preserve">
-    <value>URL изображения для использования.</value>
-  </data>
-	<data name="UrlFileTooLarge" xml:space="preserve">
-    <value>Файл слишком большой.</value>
-  </data>
-	<data name="ocr2Param1" xml:space="preserve">
-    <value>URL изображения для использования.</value>
-  </data>
-	<data name="StatusCode" xml:space="preserve">
-    <value>Код состояния:</value>
-  </data>
-	<data name="resizeSummary" xml:space="preserve">
-    <value>Изменяет размер изображения с waifu2x.</value>
-  </data>
-	<data name="resizeParam1" xml:space="preserve">
-    <value>URL изображения для использования.</value>
-  </data>
-	<data name="AnErrorOccurred" xml:space="preserve">
-    <value>Произошла ошибка.</value>
-  </data>
-	<data name="ResizeResults" xml:space="preserve">
-    <value>Изменить размер результатов</value>
-  </data>
-	<data name="screenshotSummary" xml:space="preserve">
-    <value>Делает скриншот на сайт.</value>
-  </data>
-	<data name="screenshotParam1" xml:space="preserve">
-    <value>На сайте сделать скриншот.</value>
-  </data>
-	<data name="serverinfoSummary" xml:space="preserve">
-    <value>Возвращает информацию о текущем сервере.</value>
-  </data>
-	<data name="ServerInfo" xml:space="preserve">
-    <value>Информация о сервере</value>
-  </data>
-	<data name="Name" xml:space="preserve">
-    <value>имя</value>
-  </data>
-	<data name="Owner" xml:space="preserve">
-    <value>владелец</value>
-  </data>
-	<data name="RoleCount" xml:space="preserve">
-    <value>Количество ролей</value>
-  </data>
-	<data name="UserCount" xml:space="preserve">
-    <value>Количество пользователей</value>
-  </data>
-	<data name="VerificationLevel" xml:space="preserve">
-    <value>Уровень проверки</value>
-  </data>
-	<data name="None" xml:space="preserve">
-    <value>(Никто)</value>
-  </data>
-	<data name="CreatedAt" xml:space="preserve">
-    <value>Создано на</value>
-  </data>
-	<data name="snipeSummary" xml:space="preserve">
-    <value>Показывает последнее удаленное сообщение в текущем канале.</value>
-  </data>
-	<data name="NothingToSnipe" xml:space="preserve">
-    <value>Нечего стрелять из {0}</value>
-  </data>
-	<data name="translateSummary" xml:space="preserve">
-    <value>Переводит текст.</value>
-  </data>
-	<data name="translateParam1" xml:space="preserve">
-    <value>Целевой язык в коде ISO (`en`, `es`, `br` и т. Д.)</value>
-  </data>
-	<data name="translateParam2" xml:space="preserve">
-    <value>Текст для перевода.</value>
-  </data>
-	<data name="InvalidLanguage" xml:space="preserve">
-    <value>Недействительный целевой язык. Используйте `{0}translate language codes` для просмотра списка языков.</value>
-  </data>
-	<data name="GoogleIPBanned" xml:space="preserve">
-    <value>Я получил IP, забаненный Google :( LOL</value>
-  </data>
-	<data name="ttsSummary" xml:space="preserve">
-    <value>Текст в речь.</value>
-  </data>
-	<data name="ttsParam1" xml:space="preserve">
-    <value>Целевой язык в коде ISO (`en`, `es`, `br` и т. Д.), Отступает на английский, если цель неверна.</value>
-  </data>
-	<data name="ttsParam2" xml:space="preserve">
-    <value>Текст для конвертации.</value>
-  </data>
-	<data name="urbanSummary" xml:space="preserve">
-    <value>Городской словарь поиска.</value>
-  </data>
-	<data name="urbanParam1" xml:space="preserve">
-    <value>Ключевое слово (а) для поиска.</value>
-  </data>
-	<data name="NoResults" xml:space="preserve">
-    <value>Нет результатов.</value>
-  </data>
-	<data name="By" xml:space="preserve">
-    <value>По</value>
-  </data>
-	<data name="Example" xml:space="preserve">
-    <value>пример</value>
-  </data>
-	<data name="NoExample" xml:space="preserve">
-    <value>(Пример не предоставлен)</value>
-  </data>
-	<data name="userinfoSummary" xml:space="preserve">
-    <value>Возвращает информацию о текущем или конкретном пользователе, если он был пройден.</value>
-  </data>
-	<data name="userinfoParam1" xml:space="preserve">
-    <value>Пользователь, чтобы получить информацию от.</value>
-  </data>
-	<data name="wikipediaSummary" xml:space="preserve">
-    <value>Ищет статью в Википедии.</value>
-  </data>
-	<data name="wikipediaParam1" xml:space="preserve">
-    <value>Ключевое слово (а) для поиска.</value>
-  </data>
-	<data name="WikipediaSearch" xml:space="preserve">
-    <value>Поиск в Википедии</value>
-  </data>
-	<data name="xkcdSummary" xml:space="preserve">
-    <value>Возвращает случайный комикс xkcd или определенный комикс, если передано число.</value>
-  </data>
-	<data name="xkcdParam1" xml:space="preserve">
-    <value>Комический номер.</value>
-  </data>
-	<data name="InvalidxkcdNumber" xml:space="preserve">
-    <value>Число должно быть от 1 до {0}.</value>
-  </data>
-	<data name="youtubeSummary" xml:space="preserve">
-    <value>Ищет видео на YouTube.</value>
-  </data>
-	<data name="youtubeParam1" xml:space="preserve">
-    <value>Ключевое слово (а) для поиска.</value>
-  </data>
-	<data name="YouTubeNoResults" xml:space="preserve">
-    <value>Не удалось найти никаких результатов.</value>
-  </data>
-	<data name="ytrandomSummary" xml:space="preserve">
-    <value>Возвращает «случайное» видео с YouTube.</value>
-  </data>
-	<data name="EmptyCache" xml:space="preserve">
-    <value>Из кэшированных видео
-Создание кеша ...</value>
-  </data>
-	<data name="banSummary" xml:space="preserve">
-    <value>Запрет пользователя.</value>
-  </data>
-	<data name="banParam1" xml:space="preserve">
-    <value>Пользователь забанить.</value>
-  </data>
-	<data name="banParam2" xml:space="preserve">
-    <value>Причина запрета.</value>
-  </data>
-	<data name="BanMyself" xml:space="preserve">
-    <value>Я не буду банить себя лол</value>
-  </data>
-	<data name="AlreadyBanned" xml:space="preserve">
-    <value>Пользователь уже забанен.</value>
-  </data>
-	<data name="Banned" xml:space="preserve">
-    <value>Пользователь {0} был запрещен.</value>
-  </data>
-	<data name="clearSummary" xml:space="preserve">
-    <value>Удаляет последние x сообщений в текущем канале.</value>
-  </data>
-	<data name="clearParam1" xml:space="preserve">
-    <value>Количество сообщений для очистки.</value>
-  </data>
-	<data name="clearParam2" xml:space="preserve">
-    <value>Информация о пользователе</value>
-  </data>
-	<data name="NumberOutOfIndex" xml:space="preserve">
-    <value>Число должно быть между {0} и {1}.</value>
-  </data>
-	<data name="ClearNotFound" xml:space="preserve">
-    <value>Не удалось найти ни одного сообщения {0} в последних {1} сообщениях.</value>
-  </data>
-	<data name="kickSummary" xml:space="preserve">
-    <value>Пинает пользователя.</value>
-  </data>
-	<data name="kickParam1" xml:space="preserve">
-    <value>Пользователь пнуть.</value>
-  </data>
-	<data name="nickSummary" xml:space="preserve">
-    <value>Изменяет ник пользователя.</value>
-  </data>
-	<data name="nickParam2" xml:space="preserve">
-    <value>Новый ник. Оставьте это поле пустым, чтобы удалить псевдоним.</value>
-  </data>
-	<data name="unbanParam1" xml:space="preserve">
-    <value>Идентификатор пользователя для разблокировки.</value>
-  </data>
-	<data name="triviaSummary" xml:space="preserve">
-    <value>Мелочи время!</value>
-  </data>
-	<data name="ErrorInAPI" xml:space="preserve">
-    <value>Произошла ошибка при вызове API.</value>
-  </data>
-	<data name="UserInfo" xml:space="preserve">
-    <value>Информация о пользователе</value>
-  </data>
-	<data name="Activity" xml:space="preserve">
-    <value>Деятельность</value>
-  </data>
-	<data name="IsBot" xml:space="preserve">
-    <value>Бот</value>
-  </data>
-	<data name="GuildJoinDate" xml:space="preserve">
-    <value>Дата вступления в гильдию</value>
-  </data>
-	<data name="UserNotFound" xml:space="preserve">
-    <value>Пользователь не найден.
-Попробуйте использовать тег ({0}), упоминание ({1}) или идентификатор ({2}).</value>
-  </data>
-	<data name="BanSameUser" xml:space="preserve">
-    <value>Какая? Вы хотите забанить себя?</value>
-  </data>
-	<data name="By2" xml:space="preserve">
-    <value>по</value>
-  </data>
-	<data name="hackbanSummary" xml:space="preserve">
-    <value>Взломать пользователя.</value>
-  </data>
-	<data name="hackbanParam1" xml:space="preserve">
-    <value>Идентификатор пользователя для хакбана.</value>
-  </data>
-	<data name="hackbanParam2" xml:space="preserve">
-    <value>Причина взлома.</value>
-  </data>
-	<data name="Hackbanned" xml:space="preserve">
-    <value>Пользователь {0} был взломан.</value>
-  </data>
-	<data name="kickParam2" xml:space="preserve">
-    <value>Причина удара.</value>
-  </data>
-	<data name="Kicked" xml:space="preserve">
-    <value>Пользователь {0} был выгнан.</value>
-  </data>
-	<data name="nickParam1" xml:space="preserve">
-    <value>Пользователь меняет свой ник.</value>
-  </data>
-	<data name="unbanSummary" xml:space="preserve">
-    <value>Unbans пользователя.</value>
-  </data>
-	<data name="Unbanned" xml:space="preserve">
-    <value>Пользователь {0} был заблокирован.</value>
-  </data>
-	<data name="triviaParam1" xml:space="preserve">
-    <value>Категория для выбора. Укажите «категории», чтобы увидеть список категорий, или «список лидеров» / «ранги», чтобы увидеть список лидеров.</value>
-  </data>
-	<data name="CategoryList" xml:space="preserve">
-    <value>Список категорий</value>
-  </data>
-	<data name="TriviaLeaderboard" xml:space="preserve">
-    <value>Таблица лидеров викторины</value>
-  </data>
-	<data name="AllQuestionsAnswered" xml:space="preserve">
-    <value>Похоже, что вы ответили на все вопросы в указанной категории, выберите другую категорию.</value>
-  </data>
-	<data name="TriviaError" xml:space="preserve">
-    <value>Произошла ошибка. Код ошибки</value>
-  </data>
-	<data name="Category" xml:space="preserve">
-    <value>категория</value>
-  </data>
-	<data name="Type" xml:space="preserve">
-    <value>Тип</value>
-  </data>
-	<data name="Difficulty" xml:space="preserve">
-    <value>трудность</value>
-  </data>
-	<data name="Question" xml:space="preserve">
-    <value>Вопрос</value>
-  </data>
-	<data name="Options" xml:space="preserve">
-    <value>Опции</value>
-  </data>
-	<data name="TimeLeft" xml:space="preserve">
-    <value>У вас есть {0} секунд, чтобы ответить.</value>
-  </data>
-	<data name="InvalidOption" xml:space="preserve">
-    <value>Неверный вариант!</value>
-  </data>
-	<data name="Lost1Point" xml:space="preserve">
-    <value>Вы потеряли 1 очко.</value>
-  </data>
-	<data name="CorrectAnswer" xml:space="preserve">
-    <value>Правильный ответ!</value>
-  </data>
-	<data name="Won1Point" xml:space="preserve">
-    <value>Вы выиграли 1 очко.</value>
-  </data>
-	<data name="Incorrect" xml:space="preserve">
-    <value>Некорректное!</value>
-  </data>
-	<data name="TheAnswerIs" xml:space="preserve">
-    <value>Ответ</value>
-  </data>
-	<data name="TimesUp" xml:space="preserve">
-    <value>Время вышло!</value>
-  </data>
-	<data name="Points" xml:space="preserve">
-    <value>Точки</value>
-  </data>
-	<data name="joinSummary" xml:space="preserve">
-    <value>Присоединяется к голосовому каналу.</value>
-  </data>
-	<data name="UserNotInVC" xml:space="preserve">
-    <value>Вам необходимо подключиться к голосовому каналу.</value>
-  </data>
-	<data name="NowConnected" xml:space="preserve">
-    <value>Теперь подключен к {0}.</value>
-  </data>
-	<data name="leaveSummary" xml:space="preserve">
-    <value>Оставляет голосовой канал.</value>
-  </data>
-	<data name="LeaveNotInVC" xml:space="preserve">
-    <value>Присоединяйтесь к каналу, в котором находится бот, чтобы он ушел.</value>
-  </data>
-	<data name="LeftVC" xml:space="preserve">
-    <value>Bot теперь осталось {0}.</value>
-  </data>
-	<data name="moveSummary" xml:space="preserve">
-    <value>Перемещает бота на голосовой канал, которым является текущий пользователь.</value>
-  </data>
-	<data name="MoveNotInVC" xml:space="preserve">
-    <value>Присоединяйтесь к голосовому каналу, где вы хотите, чтобы бот был.</value>
-  </data>
-	<data name="playSummary" xml:space="preserve">
-    <value>Поиск и воспроизведение трека с YouTube.</value>
-  </data>
-	<data name="playParam1" xml:space="preserve">
-    <value>Ключевое слово (а) для поиска.</value>
-  </data>
-	<data name="SelectTrack" xml:space="preserve">
-    <value>Отправить номер из списка</value>
-  </data>
-	<data name="SearchCanceled" xml:space="preserve">
-    <value>Поиск отменен.</value>
-  </data>
-	<data name="OutOfIndex" xml:space="preserve">
-    <value>Вариант вне индекса.</value>
-  </data>
-	<data name="ReplyTimeout" xml:space="preserve">
-    <value>Вы не ответили до истечения времени ожидания!</value>
-  </data>
-	<data name="replaySummary" xml:space="preserve">
-    <value>Воспроизводит текущий воспроизводимый трек, если таковой имеется.</value>
-  </data>
-	<data name="pauseSummary" xml:space="preserve">
-    <value>Пауза игрока.</value>
-  </data>
-	<data name="resumeSummary" xml:space="preserve">
-    <value>Возобновляет воспроизведение.</value>
-  </data>
-	<data name="stopSummary" xml:space="preserve">
-    <value>Останавливает плеер.</value>
-  </data>
-	<data name="skipSummary" xml:space="preserve">
-    <value>Пропускает текущий трек, если есть.</value>
-  </data>
-	<data name="volumeSummary" xml:space="preserve">
-    <value>Устанавливает громкость проигрывателя.</value>
-  </data>
-	<data name="volumeParam1" xml:space="preserve">
-    <value>Объем для установки (2 - 150)</value>
-  </data>
-	<data name="queueSummary" xml:space="preserve">
-    <value>Показывает очередь.</value>
-  </data>
-	<data name="shuffleSummary" xml:space="preserve">
-    <value>Перетасовывает очередь.</value>
-  </data>
-	<data name="removeSummary" xml:space="preserve">
-    <value>Удаляет трек в очереди по указанному индексу.</value>
-  </data>
-	<data name="removeParam1" xml:space="preserve">
-    <value>Индекс дорожки для удаления.</value>
-  </data>
-	<data name="lyricsSummary" xml:space="preserve">
-    <value>Показывает текст указанной песни или текущий трек в плеере, если ничего не пропущено.</value>
-  </data>
-	<data name="lyricsParam1" xml:space="preserve">
-    <value>Песня для поиска ее текст.</value>
-  </data>
-	<data name="artworkSummary" xml:space="preserve">
-    <value>Показывает обложку текущего трека в плеере.</value>
-  </data>
-	<data name="NoTracks" xml:space="preserve">
-    <value>Больше нет треков для воспроизведения.</value>
-  </data>
-	<data name="NowPlaying" xml:space="preserve">
-    <value>Сейчас играет</value>
-  </data>
-	<data name="PlayerError" xml:space="preserve">
-    <value>Произошла ошибка</value>
-  </data>
-	<data name="PlayerStuck" xml:space="preserve">
-    <value>Игрок застрял с дорожкой ** {0} ** на {1} секунд.</value>
-  </data>
-	<data name="PlayerMoved" xml:space="preserve">
-    <value>Перемещено с ** {0} ** на ** {1} **</value>
-  </data>
-	<data name="PlayerNoMatches" xml:space="preserve">
-    <value>Совпадений не найдено. Попробуйте использовать ссылку Youtube или ссылку mp3 файл.</value>
-  </data>
-	<data name="PlayerPlaylistAdded" xml:space="preserve">
-    <value>Плейлист ** {0} ** ({1} дорожек) ({2}) добавлен в очередь.</value>
-  </data>
-	<data name="PlayerEmptyPlaylistAdded" xml:space="preserve">
-    <value>В очереди {0} треков ({1})
-
-Сейчас играет: {2}</value>
-  </data>
-	<data name="PlayerTrackAdded" xml:space="preserve">
-    <value>{0} был добавлен в очередь.</value>
-  </data>
-	<data name="PlayerNowPlaying" xml:space="preserve">
-    <value>Сейчас играет: {0}</value>
-  </data>
-	<data name="InvalidTrack" xml:space="preserve">
-    <value>Неверный трек.</value>
-  </data>
-	<data name="Replaying" xml:space="preserve">
-    <value>Воспроизведение {0}</value>
-  </data>
-	<data name="EmptyQueue" xml:space="preserve">
-    <value>Очередь пуста.</value>
-  </data>
-	<data name="PlayerNotPlaying" xml:space="preserve">
-    <value>Игрок не играет.</value>
-  </data>
-	<data name="PlayerStopped" xml:space="preserve">
-    <value>Игрок теперь остановлен.</value>
-  </data>
-	<data name="PlayerTrackSkipped" xml:space="preserve">
-    <value>Пропущено: {0}
-
-Сейчас играет: {1}</value>
-  </data>
-	<data name="VolumeOutOfIndex" xml:space="preserve">
-    <value>Используйте число от 2 до 150.</value>
-  </data>
-	<data name="VolumeSet" xml:space="preserve">
-    <value>Громкость установлена ​​на: {0}.</value>
-  </data>
-	<data name="PlayerPaused" xml:space="preserve">
-    <value>Игрок теперь приостановлен.</value>
-  </data>
-	<data name="PlaybackResumed" xml:space="preserve">
-    <value>Воспроизведение возобновлено.</value>
-  </data>
-	<data name="PlayerNotPaused" xml:space="preserve">
-    <value>Игрок не остановлен.</value>
-  </data>
-	<data name="CurrentlyPlaying" xml:space="preserve">
-    <value>На данный момент играют: {0} ({1} {2})</value>
-  </data>
-	<data name="MusicInQueue" xml:space="preserve">
-    <value>Треки в очереди:</value>
-  </data>
-	<data name="Queue1Item" xml:space="preserve">
-    <value>В очереди только 1 предмет.</value>
-  </data>
-	<data name="QueueShuffled" xml:space="preserve">
-    <value>Очередь была перетасована.</value>
-  </data>
-	<data name="IndexOutOfRange" xml:space="preserve">
-    <value>Индекс вне диапазона.</value>
-  </data>
-	<data name="TrackRemoved" xml:space="preserve">
-    <value>Трек {0} в позиции {1} был удален.</value>
-  </data>
-	<data name="LyricsNotFound" xml:space="preserve">
-    <value>Нет текст не найдено для {0}.</value>
-  </data>
-	<data name="botgameSummary" xml:space="preserve">
-    <value>Устанавливает игровой статус бота.</value>
-  </data>
-	<data name="botgameParam1" xml:space="preserve">
-    <value>Текст для установки.</value>
-  </data>
-	<data name="BotOwnerOnly" xml:space="preserve">
-    <value>Только владелец бота.</value>
-  </data>
-	<data name="botstatusSummary" xml:space="preserve">
-    <value>Устанавливает статус бота.</value>
-  </data>
-	<data name="botstatusParam1" xml:space="preserve">
-    <value>Статус для установки (0 - 5).</value>
-  </data>
-	<data name="codeSummary" xml:space="preserve">
-    <value>Показывает исходный код команды.</value>
-  </data>
-	<data name="codeParam1" xml:space="preserve">
-    <value>Команда для получения своего кода.</value>
-  </data>
-	<data name="botcolorSummary" xml:space="preserve">
-    <value>Устанавливает цвет встраивания.</value>
-  </data>
-	<data name="botcolorParam1" xml:space="preserve">
-    <value>Новый цвет в шестнадцатеричном или десятичном виде.</value>
-  </data>
-	<data name="InvalidColor" xml:space="preserve">
-    <value>Неправильный цвет.</value>
-  </data>
-	<data name="cringeSummary" xml:space="preserve">
-    <value>Братан ты только что выложил передергиваться!</value>
-  </data>
-	<data name="globalprefixSummary" xml:space="preserve">
-    <value>Устанавливает глобальный префикс бота.</value>
-  </data>
-	<data name="globalprefixParam1" xml:space="preserve">
-    <value>Новый глобальный префикс.</value>
-  </data>
-	<data name="CurrentGlobalPrefix" xml:space="preserve">
-    <value>Текущий глобальный префикс:</value>
-  </data>
-	<data name="PrefixSameCurrentTarget" xml:space="preserve">
-    <value>Префикс цели и текущий префикс совпадают.</value>
-  </data>
-	<data name="NewGlobalPrefix" xml:space="preserve">
-    <value>Новый глобальный префикс: "{0}"</value>
-  </data>
-	<data name="inviteSummary" xml:space="preserve">
-    <value>Отправляет ссылку приглашения бота.</value>
-  </data>
-	<data name="prefixSummary" xml:space="preserve">
-    <value>Устанавливает префикс бота.</value>
-  </data>
-	<data name="prefixParam1" xml:space="preserve">
-    <value>Новый префикс.</value>
-  </data>
-	<data name="CurrentPrefix" xml:space="preserve">
-    <value>Текущий префикс:</value>
-  </data>
-	<data name="NewPrefix" xml:space="preserve">
-    <value>Новый префикс гильдии: "{0}"</value>
-  </data>
-	<data name="restartSummary" xml:space="preserve">
-    <value>Перезапускает бот.</value>
-  </data>
-	<data name="saySummary" xml:space="preserve">
-    <value>Что-то говорит</value>
-  </data>
-	<data name="sayParam1" xml:space="preserve">
-    <value>Текст сказать.</value>
-  </data>
-	<data name="uptimeSummary" xml:space="preserve">
-    <value>Показывает время работы бота.</value>
-  </data>
-	<data name="languageSummary" xml:space="preserve">
-    <value>Устанавливает язык бота.</value>
-  </data>
-	<data name="NewLanguage" xml:space="preserve">
-    <value>Язык бот теперь: Русский
-⚠ Google Translate был использован для перевода бота России, так что может быть какая-то ошибка с текстом.</value>
-  </data>
-	<data name="PaginatorFooter" xml:space="preserve">
-    <value>Страница {0} из {1}</value>
-  </data>
-	<data name="FailedExecution" xml:space="preserve">
-    <value>Не удалось выполнить</value>
-  </data>
-	<data name="nowplayingSummary" xml:space="preserve">
-    <value>Получает текущий трек в плеере.</value>
-  </data>
-	<data name="changelogSummary" xml:space="preserve">
-    <value>Показывает журнал изменений бота.</value>
-  </data>
-	<data name="TempDisabled" xml:space="preserve">
-    <value>Временно отключен</value>
-  </data>
-	<data name="inspirobotSummary" xml:space="preserve">
-    <value>Получить вдохновляющие цитаты.</value>
-  </data>
-	<data name="PermissionRequired" xml:space="preserve">
-    <value>Мне нужно`{0}` разрешение на выполнение этой команды.</value>
-  </data>
-	<data name="ManageMessages" xml:space="preserve">
-    <value>Управление сообщениями</value>
-  </data>
-	<data name="Connect" xml:space="preserve">
-    <value>Подключить</value>
-  </data>
-	<data name="Speak" xml:space="preserve">
-    <value>Разговаривать</value>
-  </data>
-	<data name="evalSummary" xml:space="preserve">
-    <value>Оценивает код .</value>
-  </data>
-	<data name="evalParam1" xml:space="preserve">
-    <value>Код для оценки.</value>
-  </data>
-	<data name="ScreenshotNSFW" xml:space="preserve">
-    <value>На скриншоте может быть содержимое NSFW, и оно не будет отображаться. Попробуйте использовать команду на канале NSFW или использовать другой URL.</value>
-  </data>
-	<data name="BotNotConnected" xml:space="preserve">
-    <value>Бот не подключен к голосовому каналу.</value>
-  </data>
-	<data name="CreationCanceled" xml:space="preserve">
-    <value>Создание отменено.</value>
-  </data>
-	<data name="AIDungeonWelcome" xml:space="preserve">
-    <value>Добро пожаловать в AI Dungeon</value>
-  </data>
-	<data name="ModeSelect" xml:space="preserve">
-    <value>Обязательно прочитайте информацию и команды с `{0}aid info`, прежде чем продолжить.
-
-Выберите режим:</value>
-  </data>
-	<data name="CharacterSelect" xml:space="preserve">
-    <value>Выберите символ</value>
-  </data>
-	<data name="AttachFiles" xml:space="preserve">
-    <value>Прикреплять файлы</value>
-  </data>
-	<data name="EvalResults" xml:space="preserve">
-    <value>Результаты Eval</value>
-  </data>
-	<data name="Output" xml:space="preserve">
-    <value>Вывод</value>
-  </data>
-	<data name="EvalFooter" xml:space="preserve">
-    <value>Выполнено за {0} мс</value>
-  </data>
-	<data name="EvalNoReturnValue" xml:space="preserve">
-    <value>Код выполняется без какого-либо возвращаемого значения.</value>
-  </data>
-	<data name="NSFWOnly" xml:space="preserve">
-    <value>Эта команда может использоваться только на канале NSFW.</value>
-  </data>
-	<data name="UserOnWait" xml:space="preserve">
-    <value>Подождите несколько секунд, прежде чем использовать эту команду!</value>
-  </data>
-	<data name="snipeParam1" xml:space="preserve">
-    <value>Конкретный канал, чтобы стрелять.</value>
-  </data>
-	<data name="IDNotFound" xml:space="preserve">
-    <value>Идентификатор не найден.</value>
-  </data>
-	<data name="IDNotPublic" xml:space="preserve">
-    <value>Этот идентификатор истории не является общедоступным, и вы не можете его использовать. Попросите владельца идентификатора ({0}) сделать его общедоступным с помощью `makepublic &lt;ID&gt;`</value>
-  </data>
-	<data name="IDOnWait" xml:space="preserve">
-    <value>Вы должны дождаться генерации истории, прежде чем использовать этот идентификатор!</value>
-  </data>
-	<data name="GeneratingNewAdventure" xml:space="preserve">
-    <value>Генерация нового приключения
-в режиме: ** {0} **
-и персонаж: ** {1} ** ...</value>
-  </data>
-	<data name="CustomCharacterCreation" xml:space="preserve">
-    <value>Создание собственного персонажа</value>
-  </data>
-	<data name="CustomCharacterPrompt" xml:space="preserve">
-    <value>Введите текст, который описывает, кто вы, и первые пару предложений о том, с чего вы начали.
-Это приглашение истечет через 5 минут.</value>
-  </data>
-	<data name="GeneratingNewCustomAdventure" xml:space="preserve">
-    <value>Создание нового приключения с пользовательской строкой...</value>
-  </data>
-	<data name="GeneratingStory" xml:space="preserve">
-    <value>Генерация истории ...</value>
-  </data>
-	<data name="EditingStoryContext" xml:space="preserve">
-    <value>Редактирование контекста истории ...</value>
-  </data>
-	<data name="TheAIWillNowRemember" xml:space="preserve">
-    <value>ИИ теперь запомнит:</value>
-  </data>
-	<data name="AlteringLastOutput" xml:space="preserve">
-    <value>Изменение последнего вывода ...</value>
-  </data>
-	<data name="ChangedLastOutput" xml:space="preserve">
-    <value>Изменен последний вывод на:</value>
-  </data>
-	<data name="NotIDOwner" xml:space="preserve">
-    <value>Вы не являетесь владельцем этого идентификатора.</value>
-  </data>
-	<data name="IDAlreadyPublic" xml:space="preserve">
-    <value>Идентификатор уже открыт.</value>
-  </data>
-	<data name="IDNowPublic" xml:space="preserve">
-    <value>Идентификатор теперь общедоступен, и каждый может его использовать. Вы можете установить его обратно в приватное состояние с помощью `makeprivate &lt;ID&gt;`</value>
-  </data>
-	<data name="IDAlreadyPrivate" xml:space="preserve">
-    <value>Идентификатор уже закрыт.</value>
-  </data>
-	<data name="IDNowPrivate" xml:space="preserve">
-    <value>Идентификатор теперь является частным, и только вы можете использовать его. Вы можете установить его обратно в public с помощью `makepublic &lt;ID&gt;`</value>
-  </data>
-	<data name="NoIDs" xml:space="preserve">
-    <value>{0} не имеет идентификаторов приключений.</value>
-  </data>
-	<data name="IDList" xml:space="preserve">
-    <value>Список идентификаторов для {0}</value>
-  </data>
-	<data name="IsPublic" xml:space="preserve">
-    <value>Является публичным</value>
-  </data>
-	<data name="IDListFooter" xml:space="preserve">
-    <value>Используйте 'idinfo &lt;ID&gt;', чтобы получить информацию об идентификаторе приключения.</value>
-  </data>
-	<data name="IDInfo" xml:space="preserve">
-    <value>Информация об идентификаторе приключения</value>
-  </data>
-	<data name="AboutAIDText" xml:space="preserve">
-    <value>AI Dungeon - первое в своем роде текстовое приключение, созданное AI. Используя модель машинного обучения с параметром 1,5B под названием GPT-2, AI Dungeon генерирует историю и результаты ваших действий во время игры в этом виртуальном мире. В отличие от практически всех существующих игр, вы не ограничены воображением разработчика в том, что вы можете сделать. Любая вещь, которую вы можете выразить на языке, может быть вашим действием, и мастер искусственного интеллекта будет решать, как мир реагирует на ваши действия.</value>
-  </data>
-	<data name="AIDHowToPlayText" xml:space="preserve">
-    <value>Правила AI Dungeon просты:
-1. Игра ставит «Вы» впереди каждого действия, поэтому каждое действие должно начинаться с глагола. Ex. «напасть на дракона», «вызвать сэндвич» и т. д.
-2. В качестве исключения из вышеперечисленного: если вы поместите свое действие в кавычки. «Вы присоединитесь ко мне в моем квесте?», Тогда игра добавит «Вы говорите» в начале.</value>
-  </data>
-	<data name="AboutAIDTitle" xml:space="preserve">
-    <value>Об AI Dungeon</value>
-  </data>
-	<data name="AIDHelp" xml:space="preserve">
-    <value>Справка AI Dungeon</value>
-  </data>
-	<data name="AIDHowToPlayTitle" xml:space="preserve">
-    <value>Как играть</value>
-  </data>
-	<data name="Commands" xml:space="preserve">
-    <value>команды</value>
-  </data>
-	<data name="AIDTips" xml:space="preserve">
-    <value>Попробуйте использовать новые слова часто, ИИ становится более креативным с разнообразием.
-Не забудьте начать ввод "do" с глагола, например: Атака орка.
-Используйте кнопку отмены, чтобы удалить ваш последний ввод вместе с ответом ИИ.
-Длинные предложения для действий не проблема! Проявите творческий подход!
-Хотите больше истории для создания? Используйте команду continue без текста.
-Попробуйте использовать новые слова часто, ИИ становится более креативным с разнообразием.
-Используйте команду запомнить, чтобы редактировать контекст истории, который ИИ всегда запоминает.
-Вы можете использовать Say перед текстом, чтобы поговорить.</value>
-  </data>
-	<data name="In" xml:space="preserve">
-    <value>Ð’</value>
-  </data>
-	<data name="configSummary" xml:space="preserve">
-    <value>Показывает встраивание с параметрами конфигурации бота на уровне гильдии.</value>
-  </data>
-	<data name="AdminRequired" xml:space="preserve">
-    <value>Вы должны быть администратором, чтобы использовать эту команду.</value>
-  </data>
-	<data name="Loading" xml:space="preserve">
-    <value>загрузка...</value>
-  </data>
-	<data name="FergunConfiguration" xml:space="preserve">
-    <value>Конфигурация Fergun</value>
-  </data>
-	<data name="ConfigCanceled" xml:space="preserve">
-    <value>Конфиг отменен.</value>
-  </data>
-	<data name="CantDoThat" xml:space="preserve">
-    <value>Я не могу этого сделать.</value>
-  </data>
-	<data name="softbanSummary" xml:space="preserve">
-    <value>Софтбаны пользователя (кик + удаление сообщений пользователя).</value>
-  </data>
-	<data name="softbanParam1" xml:space="preserve">
-    <value>Пользователь на софтбан.</value>
-  </data>
-	<data name="softbanParam2" xml:space="preserve">
-    <value>Количество дней, чтобы удалить его последние сообщения. 7 по умолчанию.</value>
-  </data>
-	<data name="softbanParam3" xml:space="preserve">
-    <value>Причина софтбана.</value>
-  </data>
-	<data name="SoftbanSameUser" xml:space="preserve">
-    <value>Какая? Хочешь софтбан сам?</value>
-  </data>
-	<data name="SoftbanMyself" xml:space="preserve">
-    <value>Я не буду софтбан сам LOL</value>
-  </data>
-	<data name="Softbanned" xml:space="preserve">
-    <value>Пользователь {0} был заблокирован.</value>
-  </data>
-	<data name="AIDungeonCommands" xml:space="preserve">
-    <value>AI Dungeon (используйте {0}aid &lt;command&gt;)</value>
-  </data>
-	<data name="RevertingLastAction" xml:space="preserve">
-    <value>Возврат последнего действия ...</value>
-  </data>
-	<data name="140CharsMax" xml:space="preserve">
-    <value>Макс. 140 символов</value>
-  </data>
-	<data name="InviteLink" xml:space="preserve">
-    <value>Пригласить ссылку</value>
-  </data>
-	<data name="NoManageMessages" xml:space="preserve">
-    <value>Некоторые команды работают лучше с разрешением «Управление сообщениями» («img», «urban», «config»)</value>
-  </data>
-	<data name="InvalidText" xml:space="preserve">
-    <value>Неверный текст.</value>
-  </data>
-	<data name="clearRemarks" xml:space="preserve">
-    <value>Если пользователь пропущен, команда попытается удалить все сообщения пользователя в последних сообщениях `count`.
-Максимальное количество дней, в течение которых бот может искать сообщения, - 14.</value>
-  </data>
-	<data name="BanMembers" xml:space="preserve">
-    <value>Забанить участников</value>
-  </data>
-	<data name="KickMembers" xml:space="preserve">
-    <value>Участники Kick</value>
-  </data>
-	<data name="UserNotBanned" xml:space="preserve">
-    <value>Пользователь не забанен.</value>
-  </data>
-	<data name="BotMention" xml:space="preserve">
-    <value>Мой префикс здесь `{0}`. Используйте `{0}help`, чтобы увидеть мой список команд, и` {0}prefix`, чтобы изменить префикс.</value>
-  </data>
-	<data name="NotSupportedInDM" xml:space="preserve">
-    <value>Я не думаю, что вы можете использовать это здесь.</value>
-  </data>
-	<data name="tcdneSummary" xml:space="preserve">
-    <value>Этот кот не существует</value>
-  </data>
-	<data name="tpdneSummary" xml:space="preserve">
-    <value>Этот человек не существует</value>
-  </data>
-	<data name="YTSearchCap" xml:space="preserve">
-    <value>Достигнут лимит поиска в YouTube :(</value>
-  </data>
-	<data name="CreatingVideoCache" xml:space="preserve">
-    <value>Кто-то уже создает видео кеш, пожалуйста, подождите ..</value>
-  </data>
-	<data name="User" xml:space="preserve">
-    <value>пользователь</value>
-  </data>
-	<data name="editsnipeSummary" xml:space="preserve">
-    <value>Показывает последнее отредактированное сообщение в текущем канале.</value>
-  </data>
-	<data name="reactionSummary" xml:space="preserve">
-    <value>Добавляет реакцию на сообщение.</value>
-  </data>
-	<data name="reactionParam1" xml:space="preserve">
-    <value>Реакция на добавление (только одна)</value>
-  </data>
-	<data name="reactionParam2" xml:space="preserve">
-    <value>Идентификатор сообщения для добавления реакции (должно быть с того же канала, на котором выполняется команда).</value>
-  </data>
-	<data name="InvalidMessageID" xml:space="preserve">
-    <value>Неверный идентификатор сообщения. Убедитесь, что сообщение пришло с этого канала.</value>
-  </data>
-	<data name="InvalidReaction" xml:space="preserve">
-    <value>Недопустимая эмоция / эмодзи.</value>
-  </data>
-	<data name="BadArgumentCount" xml:space="preserve">
-    <value>В команде отсутствуют обязательные аргументы. Используйте `{0} help {1}`, чтобы получить больше информации о команде.</value>
-  </data>
-	<data name="CommandParseFailed" xml:space="preserve">
-    <value>Ошибка разбора аргументов команды. Аргумент неверен или порядок аргументов неверен.
-Используйте `{0} help {1}`, чтобы получить больше информации о команде.</value>
-  </data>
-	<data name="TranslationResults" xml:space="preserve">
-    <value>Результаты перевода</value>
-  </data>
-	<data name="SourceLanguage" xml:space="preserve">
-    <value>Исходный язык (обнаружено)</value>
-  </data>
-	<data name="TargetLanguage" xml:space="preserve">
-    <value>Язык перевода</value>
-  </data>
-	<data name="Result" xml:space="preserve">
-    <value>Результат</value>
-  </data>
-	<data name="ErrorType" xml:space="preserve">
-    <value>Тип ошибки</value>
-  </data>
-	<data name="ErrorMessage" xml:space="preserve">
-    <value>Сообщение об ошибке</value>
-  </data>
-	<data name="seekSummary" xml:space="preserve">
-    <value>Поиск текущей дорожки в указанной позиции.</value>
-  </data>
-	<data name="seekParam1" xml:space="preserve">
-    <value>Девятая секунда для поиска или правильное время в формате `m: ss`,` mm: ss`, `h: mm: ss` или` hh: mm: ss`.</value>
-  </data>
-	<data name="CannotSeek" xml:space="preserve">
-    <value>Не могу найти этот трек.</value>
-  </data>
-	<data name="SeekHigherOrEqual" xml:space="preserve">
-    <value>Второй ход ({0}) не может быть больше или равен длине трека ({1})</value>
-  </data>
-	<data name="SeekComplete" xml:space="preserve">
-    <value>Пропущен до второго {0} ({1} из {2})</value>
-  </data>
-	<data name="ErrorInTranslation" xml:space="preserve">
-    <value>Произошла ошибка в API перевода.</value>
-  </data>
-	<data name="statsSummary" xml:space="preserve">
-    <value>Показывает статистику ботов.</value>
-  </data>
-	<data name="AdventureDeleted" xml:space="preserve">
-    <value>Приключение было удалено.</value>
-  </data>
-	<data name="CPUUsage" xml:space="preserve">
-    <value>Использование процессора</value>
-  </data>
-	<data name="RAMUsage" xml:space="preserve">
-    <value>Использование ОЗУ</value>
-  </data>
-	<data name="Library" xml:space="preserve">
-    <value>Библиотека</value>
-  </data>
-	<data name="BotVersion" xml:space="preserve">
-    <value>Бот версия</value>
-  </data>
-	<data name="OperatingSystem" xml:space="preserve">
-    <value>Операционная система</value>
-  </data>
-	<data name="BotOwner" xml:space="preserve">
-    <value>Владелец бота</value>
-  </data>
-	<data name="CurrentNewNickEqual" xml:space="preserve">
-    <value>Текущий ник и новый ник совпадают.</value>
-  </data>
-	<data name="Yes" xml:space="preserve">
-    <value>да</value>
-  </data>
-	<data name="No" xml:space="preserve">
-    <value>нет</value>
-  </data>
-	<data name="True" xml:space="preserve">
-    <value>Правда</value>
-  </data>
-	<data name="False" xml:space="preserve">
-    <value>Ложь</value>
-  </data>
-	<data name="ConfigList" xml:space="preserve">
-    <value>Автоматический перевод ввода и вывода AI Dungeon
-Выбор трека для `play`</value>
-  </data>
-	<data name="ConfigPrompt" xml:space="preserve">
-    <value>Используйте реакции, чтобы включить или выключить опцию.</value>
-  </data>
-	<data name="FergunConfig" xml:space="preserve">
-    <value>Конфигурация Fergun</value>
-  </data>
-	<data name="Option" xml:space="preserve">
-    <value>вариант</value>
-  </data>
-	<data name="Value" xml:space="preserve">
-    <value>Ценность</value>
-  </data>
-	<data name="MissingPermissions" xml:space="preserve">
-    <value>У вас нет прав для запуска этой команды.</value>
-  </data>
-	<data name="UserBlacklisted" xml:space="preserve">
-    <value>Пользователь {0} был в черном списке.</value>
-  </data>
-	<data name="UserBlacklistedWithReason" xml:space="preserve">
-    <value>Пользователь {0} занесен в черный список по причине: {1}</value>
-  </data>
-	<data name="UserBlacklistRemoved" xml:space="preserve">
-    <value>Пользователь {0} был удален из черного списка.</value>
-  </data>
-	<data name="blacklistSummary" xml:space="preserve">
-    <value>Добавляет пользователя в черный список или удаляет его.</value>
-  </data>
-	<data name="blacklistParam1" xml:space="preserve">
-    <value>Идентификатор пользователя в черный список.</value>
-  </data>
-	<data name="blacklistParam2" xml:space="preserve">
-    <value>Причина попадания в черный список.</value>
-  </data>
-	<data name="Blacklisted" xml:space="preserve">
-    <value>Вы в черном списке.</value>
-  </data>
-	<data name="BlacklistedWithReason" xml:space="preserve">
-    <value>Вы в черном списке с причиной: {0}</value>
-  </data>
-	<data name="UserRequireBanMembers" xml:space="preserve">
-    <value>Вам нужно разрешение `Ban Members`, чтобы использовать эту команду.</value>
-  </data>
-	<data name="BotRequireBanMembers" xml:space="preserve">
-    <value>Мне нужно разрешение `Ban Members` для выполнения этой команды.</value>
-  </data>
-	<data name="UserRequireManageMessages" xml:space="preserve">
-    <value>Для использования этой команды вам необходимо разрешение «Управление сообщениями».</value>
-  </data>
-	<data name="BotRequireManageMessages" xml:space="preserve">
-    <value>Мне нужно разрешение «Управление сообщениями» для выполнения этой команды.</value>
-  </data>
-	<data name="UserRequireKickMembers" xml:space="preserve">
-    <value>Для использования этой команды вам необходимо разрешение `Kick Members`.</value>
-  </data>
-	<data name="BotRequireKickMembers" xml:space="preserve">
-    <value>Мне нужно разрешение `Kick Members` для выполнения этой команды.</value>
-  </data>
-	<data name="UserRequireManageNicknames" xml:space="preserve">
-    <value>Чтобы использовать эту команду, вам необходимо разрешение «Управление никами».</value>
-  </data>
-	<data name="BotRequireManageNicknames" xml:space="preserve">
-    <value>Мне нужно разрешение «Управление никами» для выполнения этой команды.</value>
-  </data>
-	<data name="UserNotLowerHierarchy" xml:space="preserve">
-    <value>Указанный пользователь должен быть ниже по иерархии.</value>
-  </data>
-	<data name="BotRequireConnect" xml:space="preserve">
-    <value>Мне нужно разрешение `Connect ', чтобы присоединиться к голосовому каналу.</value>
-  </data>
-	<data name="BotRequireSpeak" xml:space="preserve">
-    <value>Мне нужно разрешение «Говорить», чтобы продолжить.</value>
-  </data>
-	<data name="MoveSameChannel" xml:space="preserve">
-    <value>Перейдите на голосовой канал, на который вы хотите, чтобы я пошел.</value>
-  </data>
-	<data name="ProcessingTime" xml:space="preserve">
-    <value>Время обработки: {0} мс</value>
-  </data>
-	<data name="OcrResults" xml:space="preserve">
-    <value>Результаты OCR</value>
-  </data>
-	<data name="BotRequireAttachFiles" xml:space="preserve">
-    <value>Мне нужно разрешение `Attach Files` для выполнения этой команды.</value>
-  </data>
-	<data name="OcrApiError" xml:space="preserve">
-    <value>Ошибка в OCR API.</value>
-  </data>
-	<data name="forceprefixSummary" xml:space="preserve">
-    <value>Force устанавливает префикс в этой гильдии.</value>
-  </data>
-	<data name="FirstTip" xml:space="preserve">
-    <value>Используйте {0} помощи, продолжайте &lt;ID&gt; [текст], чтобы продолжить ваше приключение.</value>
-  </data>
-	<data name="PrefixTooLarge" xml:space="preserve">
-    <value>Префикс слишком большой. Макс. длина префикса: {0}.</value>
-  </data>
-	<data name="InvalidUrl" xml:space="preserve">
-    <value>Неправильный адрес.</value>
-  </data>
-	<data name="ocrtranslateSummary" xml:space="preserve">
-    <value>OCR и перевести.</value>
-  </data>
-	<data name="ocrtranslateParam1" xml:space="preserve">
-    <value>Целевой язык в коде ISO (en, es, br и т. Д.)</value>
-  </data>
-	<data name="ocrtranslateParam2" xml:space="preserve">
-    <value>URL изображения для использования.</value>
-  </data>
-	<data name="Input" xml:space="preserve">
-    <value>вход</value>
-  </data>
-	<data name="RoleNotFound" xml:space="preserve">
-    <value>Роль не найдена.</value>
-  </data>
-	<data name="RoleInfo" xml:space="preserve">
-    <value>Информация о роли</value>
-  </data>
-	<data name="Color" xml:space="preserve">
-    <value>цвет</value>
-  </data>
-	<data name="IsMentionable" xml:space="preserve">
-    <value>Упоминается</value>
-  </data>
-	<data name="Permissions" xml:space="preserve">
-    <value>права доступа</value>
-  </data>
-	<data name="MemberCount" xml:space="preserve">
-    <value>Количество участников</value>
-  </data>
-	<data name="Mention" xml:space="preserve">
-    <value>Упоминание</value>
-  </data>
-	<data name="roleinfoSummary" xml:space="preserve">
-    <value>Получает информацию о роли.</value>
-  </data>
-	<data name="roleinfoParam1" xml:space="preserve">
-    <value>Роль для получения информации (упоминать ее не обязательно).</value>
-  </data>
-	<data name="logoutSummary" xml:space="preserve">
-    <value>Отключает бот.</value>
-  </data>
-	<data name="nothingSummary" xml:space="preserve">
-    <value>Лучшая команда.</value>
-  </data>
-	<data name="someoneSummary" xml:space="preserve">
-    <value>Возвращает случайного пользователя.</value>
-  </data>
-	<data name="ActiveClients" xml:space="preserve">
-    <value>Активные клиенты</value>
-  </data>
-	<data name="Low" xml:space="preserve">
-    <value>Низкий</value>
-  </data>
-	<data name="Medium" xml:space="preserve">
-    <value>средний</value>
-  </data>
-	<data name="High" xml:space="preserve">
-    <value>Высокая</value>
-  </data>
-	<data name="Extreme" xml:space="preserve">
-    <value>крайность</value>
-  </data>
-	<data name="IsHoisted" xml:space="preserve">
-    <value>Лапы</value>
-  </data>
-	<data name="Position" xml:space="preserve">
-    <value>Должность</value>
-  </data>
-	<data name="ServerFeatures" xml:space="preserve">
-    <value>особенности</value>
-  </data>
-	<data name="BoostTier" xml:space="preserve">
-    <value>Уровень Nitro Boost</value>
-  </data>
-	<data name="BoostCount" xml:space="preserve">
-    <value>Счетчик нитроускорения</value>
-  </data>
-	<data name="SeekTimeHigherOrEqual" xml:space="preserve">
-    <value>Время перехода ({0}) не может быть больше или равно длине трека ({1})</value>
-  </data>
-	<data name="SeekTimeComplete" xml:space="preserve">
-    <value>Пропущено до {0} из {1}</value>
-  </data>
-	<data name="SeekInvalidFormat" xml:space="preserve">
-    <value>Вы должны передать целое число или строку в формате `m: ss`,` mm: ss`, `h: mm: ss` или` hh: mm: ss`.</value>
-  </data>
-	<data name="Ratelimited" xml:space="preserve">
-    <value>Подождите {0} секунд, прежде чем снова использовать эту команду!</value>
-  </data>
-	<data name="ObjectNotFound" xml:space="preserve">
-    <value>Объект не найден.</value>
-  </data>
-	<data name="MultipleMatches" xml:space="preserve">
-    <value>Найдено несколько совпадений.
-Попробуйте использовать тег ({0}), упоминание ({1}) или идентификатор ({2}).</value>
-  </data>
-	<data name="Roles" xml:space="preserve">
-    <value>Роли</value>
-  </data>
-	<data name="BoostingSince" xml:space="preserve">
-    <value>Повышение с</value>
-  </data>
-	<data name="AlreadyConnected" xml:space="preserve">
-    <value>Бот уже подключен к голосовому каналу.</value>
-  </data>
-	<data name="InvalidFileType" xml:space="preserve">
-    <value>Неверный тип файла.</value>
-  </data>
-	<data name="OwnerCommands" xml:space="preserve">
-    <value>Владелец команды</value>
-  </data>
-	<data name="Vote" xml:space="preserve">
-    <value>Вы можете проголосовать за меня [здесь] ({0}). Спасибо.</value>
-  </data>
-	<data name="voteSummary" xml:space="preserve">
-    <value>Показывает вставку со ссылкой для голосования.</value>
-  </data>
-	<data name="TotalServers" xml:space="preserve">
-    <value>Всего серверов</value>
-  </data>
-	<data name="TotalUsers" xml:space="preserve">
-    <value>Всего пользователей</value>
-  </data>
-	<data name="ContactInfo" xml:space="preserve">
-    <value>Если у вас есть отчет об ошибке, вопрос или предложение, вы можете присоединиться к [серверу поддержки] ({0}) или связаться со мной через прямое сообщение ({1}).</value>
-  </data>
-	<data name="RedoingLastAction" xml:space="preserve">
-    <value>Повтор последнего действия ...</value>
-  </data>
-	<data name="SelfNoIDs" xml:space="preserve">
-    <value>У вас нет идентификаторов приключений.
-Вы можете использовать команду `new`, чтобы создать новое приключение.</value>
-  </data>
-	<data name="QueueExcess" xml:space="preserve">
-    <value>..и еще {0} треков!</value>
-  </data>
-	<data name="ErrorHelp" xml:space="preserve">
-    <value>Если это продолжится, сообщите об этой ошибке на [сервере поддержки]({0}) или откройте проблему в [репозитории GitHub]({1}), описав ошибку и шаги по ее воспроизведению.</value>
-  </data>
-	<data name="bashSummary" xml:space="preserve">
-    <value>Запускает команду bash.</value>
-  </data>
-	<data name="supportSummary" xml:space="preserve">
-    <value>Показывает информацию поддержки.</value>
-  </data>
-	<data name="Topic" xml:space="preserve">
-    <value>Тема</value>
-  </data>
-	<data name="IsNSFW" xml:space="preserve">
-    <value>Является NSFW</value>
-  </data>
-	<data name="SlowMode" xml:space="preserve">
-    <value>Медленный режим</value>
-  </data>
-	<data name="ChannelInfo" xml:space="preserve">
-    <value>Информация о канале</value>
-  </data>
-	<data name="channelinfoSummary" xml:space="preserve">
-    <value>Показывает информацию о канале.</value>
-  </data>
-	<data name="OcrtrResults" xml:space="preserve">
-    <value>OCR и результаты перевода</value>
-  </data>
-	<data name="NoChoices" xml:space="preserve">
-    <value>Вам необходимо пройти хотя бы 1 выбор.</value>
-  </data>
-	<data name="ChannelNotFound" xml:space="preserve">
-    <value>Канал не найден.</value>
-  </data>
-	<data name="channelinfoParam1" xml:space="preserve">
-    <value>Канал для получения информации.</value>
-  </data>
-	<data name="UserRequireManageServer" xml:space="preserve">
-    <value>Вам нужно разрешение `Manage Server`, чтобы использовать эту команду.</value>
-  </data>
-	<data name="badtranslatorSummary" xml:space="preserve">
-    <value>Пропускает текст через плохого переводчика.</value>
-  </data>
-	<data name="badtranslatorParam1" xml:space="preserve">
-    <value>Текст для использования.</value>
-  </data>
-	<data name="LanguageChain" xml:space="preserve">
-    <value>Языковая цепочка</value>
-  </data>
-	<data name="VersionNotFound" xml:space="preserve">
-    <value>Версия не найдена. Версии: {0}</value>
-  </data>
-	<data name="OtherVersions" xml:space="preserve">
-    <value>Другие версии: {0}</value>
-  </data>
-	<data name="DeletedMessages" xml:space="preserve">
-    <value>Удалено {0} сообщений.</value>
-  </data>
-	<data name="DeletedMessagesByUser" xml:space="preserve">
-    <value>Удалено {0} сообщений от {1}.</value>
-  </data>
-	<data name="calcSummary" xml:space="preserve">
-    <value>Оценивает математическое выражение.</value>
-  </data>
-	<data name="calcParam1" xml:space="preserve">
-    <value>Выражение для оценки.</value>
-  </data>
-	<data name="InvalidExpression" xml:space="preserve">
-    <value>Неверное выражение.</value>
-  </data>
-	<data name="CalcResults" xml:space="preserve">
-    <value>Результаты вычислений</value>
-  </data>
-	<data name="LoopUpdated" xml:space="preserve">
-    <value>Проигрыватель обновился, чтобы повторить трек {0} раз. Используйте эту команду без параметров, чтобы отключить зацикливание дорожки.</value>
-  </data>
-	<data name="LoopNoValuePassed" xml:space="preserve">
-    <value>Передайте количество раз, которое вы хотите повторить трек, например:
-`{0}loop 5`</value>
-  </data>
-	<data name="NowLooping" xml:space="preserve">
-    <value>Текущий трек теперь будет повторяться {0} раз.
-Чтобы отключить цикл, используйте эту команду без параметров.</value>
-  </data>
-	<data name="LoopEnded" xml:space="preserve">
-    <value>Цикл для дорожки: {0} завершен.</value>
-  </data>
-	<data name="loopSummary" xml:space="preserve">
-    <value>Повторяет текущую дорожку несколько раз.</value>
-  </data>
-	<data name="loopParam1" xml:space="preserve">
-    <value>Количество раз, чтобы повторить трек.</value>
-  </data>
-	<data name="LoopDisabled" xml:space="preserve">
-    <value>Зацикливание трека отключено.</value>
-  </data>
-	<data name="LyricsQueryNotPassed" xml:space="preserve">
-    <value>Вам необходимо воспроизвести трек или передать название трека.</value>
-  </data>
-	<data name="LyricsByGenius" xml:space="preserve">
-    <value>Текст песни Genius</value>
-  </data>
-	<data name="PaginatorHelp" xml:space="preserve">
-    <value>Это пагинатор. Реагируйте с соответствующими значками, чтобы изменить страницу.</value>
-  </data>
-	<data name="ArtistPage" xml:space="preserve">
-    <value>Страница исполнителя</value>
-  </data>
-	<data name="AdventureDeletionPrompt" xml:space="preserve">
-    <value>Нажмите кнопку / смайлик ниже, чтобы удалить приключение.</value>
-  </data>
-	<data name="ReactTimeout" xml:space="preserve">
-    <value>Вы не реагировали до истечения времени ожидания!</value>
-  </data>
-	<data name="LanguageSelection" xml:space="preserve">
-    <value>Выбор языка</value>
-  </data>
-	<data name="LanguagePrompt" xml:space="preserve">
-    <value>Выберите язык, который хотите установить.</value>
-  </data>
-	<data name="lyricsRemarks" xml:space="preserve">
-    <value>Используйте `-headers` в конце команды, чтобы сохранить теги (` [info] `).</value>
-  </data>
-	<data name="HelpFooter2" xml:space="preserve">
-    <value>&lt;&gt; = необходимые | [] = По желанию | Не вводите эти символы при написании команды.</value>
-  </data>
-	<data name="invertSummary" xml:space="preserve">
-    <value>Инвертирует (отрицает) изображение.</value>
-  </data>
-	<data name="invertParam1" xml:space="preserve">
-    <value>URL изображения для использования.</value>
-  </data>
-	<data name="MessagesOlderThan2Weeks" xml:space="preserve">
-    <value>Сообщения должны быть моложе двух недель.</value>
-  </data>
-	<data name="SomeMessagesNotDeleted" xml:space="preserve">
-    <value>{0} Сообщения не могут быть удалены, потому что они старше, чем через две недели.</value>
-  </data>
-	<data name="GuildNotFound" xml:space="preserve">
-    <value>Сервер не найден.</value>
-  </data>
-	<data name="ErrorParsingLyrics" xml:space="preserve">
-    <value>при разборе текст для {0} произошла ошибка.
-Может быть, это инструментальное?</value>
-  </data>
-	<data name="MustBeLowerThan" xml:space="preserve">
-    <value>Параметр {0} должен быть ниже, чем {1}.</value>
-  </data>
-	<data name="InvalidID" xml:space="preserve">
-    <value>Неверный ID.</value>
-  </data>
-	<data name="HastebinLink" xml:space="preserve">
-    <value>Hastebin ссылка</value>
-  </data>
-	<data name="NotAvailable" xml:space="preserve">
-    <value>Недоступно.</value>
-  </data>
-	<data name="WaitingQueue" xml:space="preserve">
-    <value>Ожидание, пока очередь опустеет ...</value>
-  </data>
-	<data name="NewOutputPrompt" xml:space="preserve">
-    <value>Введите новый текст</value>
-  </data>
-	<data name="GeneratingNewResponse" xml:space="preserve">
-    <value>Генерация нового ответа ...</value>
-  </data>
-	<data name="InputTypes" xml:space="preserve">
-    <value>Варианты ввода</value>
-  </data>
-	<data name="InputTypesList" xml:space="preserve">
-    <value>Do: действие, которое вы хотите предпринять в истории. Всегда начинай с действия, напр. «Поиск спрятанных сокровищ». Это опция по умолчанию, если никто не обнаружен.
-
-Скажите: Вы можете использовать это, чтобы говорить, напр. "Оставь их в покое!"
-
-История: Этот вход не дает ИИ поставить «Вы» перед вашим входом.</value>
-  </data>
-	<data name="ChannelCount" xml:space="preserve">
-    <value>Количество каналов</value>
-  </data>
-	<data name="Region" xml:space="preserve">
-    <value>Область</value>
-  </data>
-	<data name="DefaultChannel" xml:space="preserve">
-    <value>Канал по умолчанию</value>
-  </data>
-	<data name="LanguageNotFound" xml:space="preserve">
-    <value>Язык не найден.</value>
-  </data>
-	<data name="CategoryCount" xml:space="preserve">
-    <value>Количество категорий</value>
-  </data>
-	<data name="DumpingAdventure" xml:space="preserve">
-    <value>Демпинг приключения...</value>
-  </data>
-	<data name="EditCanceled" xml:space="preserve">
-    <value>Редактировать отменен.</value>
-  </data>
-	<data name="NothingToDelete" xml:space="preserve">
-    <value>Нечего удалять.</value>
-  </data>
-	<data name="Members" xml:space="preserve">
-    <value>члены</value>
-  </data>
-	<data name="Online" xml:space="preserve">
-    <value>В сети</value>
-  </data>
-	<data name="Idle" xml:space="preserve">
-    <value>вхолостую</value>
-  </data>
-	<data name="DnD" xml:space="preserve">
-    <value>DnD</value>
-  </data>
-	<data name="Offline" xml:space="preserve">
-    <value>Не в сети</value>
-  </data>
-	<data name="aidinfoSummary" xml:space="preserve">
-    <value>Показывает информацию AI Dungeon.</value>
-  </data>
-	<data name="aidnewSummary" xml:space="preserve">
-    <value>Создает новое приключение.</value>
-  </data>
-	<data name="continueSummary" xml:space="preserve">
-    <value>Продолжает приключение с предоставленному текста. Если текст не будет принят, ИИ будет генерировать историю.</value>
-  </data>
-	<data name="continueParam1" xml:space="preserve">
-    <value>Приключение ID.</value>
-  </data>
-	<data name="continueParam2" xml:space="preserve">
-    <value>Текст для использования.</value>
-  </data>
-	<data name="undoSummary" xml:space="preserve">
-    <value>Отменяет последнее действие.</value>
-  </data>
-	<data name="undoParam1" xml:space="preserve">
-    <value>Приключение ID.</value>
-  </data>
-	<data name="redoSummary" xml:space="preserve">
-    <value>Повторяет последнее действие отмененного.</value>
-  </data>
-	<data name="redoParam1" xml:space="preserve">
-    <value>Приключение ID.</value>
-  </data>
-	<data name="rememberSummary" xml:space="preserve">
-    <value>Добавляет текст в контекст памяти.</value>
-  </data>
-	<data name="rememberParam1" xml:space="preserve">
-    <value>Приключение ID.</value>
-  </data>
-	<data name="rememberParam2" xml:space="preserve">
-    <value>Текст для добавления.</value>
-  </data>
-	<data name="alterSummary" xml:space="preserve">
-    <value>Редактирует последний ответ.</value>
-  </data>
-	<data name="alterParam1" xml:space="preserve">
-    <value>Приключение ID.</value>
-  </data>
-	<data name="retrySummary" xml:space="preserve">
-    <value>Повторит последнее действие и порождает новый ответ.</value>
-  </data>
-	<data name="retryParam1" xml:space="preserve">
-    <value>Приключение ID.</value>
-  </data>
-	<data name="makepublicSummary" xml:space="preserve">
-    <value>Делает ID общественности.</value>
-  </data>
-	<data name="makepublicParam1" xml:space="preserve">
-    <value>Приключение IF. Вы должны иметь этот идентификатор.</value>
-  </data>
-	<data name="makeprivateSummary" xml:space="preserve">
-    <value>Делает ID приватным.</value>
-  </data>
-	<data name="makeprivateParam1" xml:space="preserve">
-    <value>Приключение IF. Вы должны иметь этот идентификатор.</value>
-  </data>
-	<data name="idlistSummary" xml:space="preserve">
-    <value>Получает список идентификаторов, принадлежащий пользователя.</value>
-  </data>
-	<data name="idlistParam1" xml:space="preserve">
-    <value>Пользователю получить свои идентификаторы.</value>
-  </data>
-	<data name="idinfoSummary" xml:space="preserve">
-    <value>Показывает информацию о идентификатора.</value>
-  </data>
-	<data name="idinfoParam1" xml:space="preserve">
-    <value>Приключение ID.</value>
-  </data>
-	<data name="deleteSummary" xml:space="preserve">
-    <value>Удаляет идентификатор.</value>
-  </data>
-	<data name="deleteParam1" xml:space="preserve">
-    <value>Приключение ID.</value>
-  </data>
-	<data name="dumpSummary" xml:space="preserve">
-    <value>Сплин весь текст из идентификатора.</value>
-  </data>
-	<data name="dumpParam1" xml:space="preserve">
-    <value>Приключение ID.</value>
-  </data>
-	<data name="RatelimitUses" xml:space="preserve">
-    <value>{0} использует каждый {1}</value>
-  </data>
-	<data name="Requirements" xml:space="preserve">
-    <value>Требования</value>
-  </data>
-	<data name="cmdstatsSummary" xml:space="preserve">
-    <value>Показывает статистику команд.</value>
-  </data>
-	<data name="CommandStatsInfo" xml:space="preserve">
-    <value>Команда Статистика (с {0})</value>
-  </data>
-	<data name="lmgtfySummary" xml:space="preserve">
-    <value>Позвольте мне Google, что для Вас.</value>
-  </data>
-	<data name="lmgtfyParam1" xml:space="preserve">
-    <value>Ключевое слово (а) для поиска.</value>
-  </data>
-	<data name="pasteSummary" xml:space="preserve">
-    <value>Закачивает текст Hastebin.</value>
-  </data>
-	<data name="pasteParam1" xml:space="preserve">
-    <value>Текст для загрузки.</value>
-  </data>
-	<data name="Uploading" xml:space="preserve">
-    <value>Загрузка...</value>
-  </data>
-	<data name="bigsnipeSummary" xml:space="preserve">
-    <value>Показывает последние удаленные сообщения в текущем канале или указанный.</value>
-  </data>
-	<data name="bigsnipeParam1" xml:space="preserve">
-    <value>Конкретный канал бекас.</value>
-  </data>
-	<data name="MinutesAgo" xml:space="preserve">
-    <value>{0} минут назад</value>
-  </data>
-	<data name="Attachment" xml:space="preserve">
-    <value>прикрепление</value>
-  </data>
-	<data name="bigeditsnipeSummary" xml:space="preserve">
-    <value>Показывает последние отредактированные сообщения в текущем канале или указанный.</value>
-  </data>
-	<data name="bigeditsnipeParam1" xml:space="preserve">
-    <value>Конкретный канал бекас.</value>
-  </data>
-	<data name="TextChannel" xml:space="preserve">
-    <value>Текст канал</value>
-  </data>
-	<data name="AnnouncementChannel" xml:space="preserve">
-    <value>Объявление канала</value>
-  </data>
-	<data name="VoiceChannel" xml:space="preserve">
-    <value>речевой канал</value>
-  </data>
-	<data name="DMChannel" xml:space="preserve">
-    <value>Канал DM</value>
-  </data>
-	<data name="Bitrate" xml:space="preserve">
-    <value>Битрейт</value>
-  </data>
-	<data name="UserLimit" xml:space="preserve">
-    <value>Лимит пользователя</value>
-  </data>
-	<data name="NoLimit" xml:space="preserve">
-    <value>Нет ограничений</value>
-  </data>
-	<data name="disableSummary" xml:space="preserve">
-    <value>Отключает команду локально (для этого сервера).</value>
-  </data>
-	<data name="disableParam1" xml:space="preserve">
-    <value>Имя команды, чтобы отключить.</value>
-  </data>
-	<data name="NonDisableable" xml:space="preserve">
-    <value>{0} не может быть отключена!</value>
-  </data>
-	<data name="AlreadyDisabled" xml:space="preserve">
-    <value>{0} уже отключена!</value>
-  </data>
-	<data name="CommandDisabled" xml:space="preserve">
-    <value>Команда {0} была отключена в этом сервере.</value>
-  </data>
-	<data name="enableSummary" xml:space="preserve">
-    <value>Включает команду локально (для этого сервера).</value>
-  </data>
-	<data name="enableParam1" xml:space="preserve">
-    <value>Имя команды, чтобы включить.</value>
-  </data>
-	<data name="AlreadyEnabled" xml:space="preserve">
-    <value>{0} уже включен!</value>
-  </data>
-	<data name="CommandEnabled" xml:space="preserve">
-    <value>Команда {0} была включена в этом сервере.$$</value>
-  </data>
-	<data name="globaldisableSummary" xml:space="preserve">
-    <value>Отключение команды глобально (на всех серверах).$$</value>
-  </data>
-	<data name="globaldisableParam1" xml:space="preserve">
-    <value>Имя команды, чтобы отключить глобально.</value>
-  </data>
-	<data name="globaldisableParam2" xml:space="preserve">
-    <value>Причина использования.</value>
-  </data>
-	<data name="AlreadyDisabledGlobally" xml:space="preserve">
-    <value>{0} уже отключена во всем мире!</value>
-  </data>
-	<data name="CommandDisabledGlobally" xml:space="preserve">
-    <value>Команда {0} была отключена на всех серверах.</value>
-  </data>
-	<data name="globalenableSummary" xml:space="preserve">
-    <value>Включает команду глобально (на всех серверах).</value>
-  </data>
-	<data name="globalenableParam1" xml:space="preserve">
-    <value>Имя команды, чтобы включить в глобальном масштабе.</value>
-  </data>
-	<data name="AlreadyEnabledGlobally" xml:space="preserve">
-    <value>{0} уже включена в глобальном масштабе!</value>
-  </data>
-	<data name="CommandEnabledGlobally" xml:space="preserve">
-    <value>Команда {0} была включена во всех серверах.</value>
-  </data>
-	<data name="Reason" xml:space="preserve">
-    <value>причина</value>
-  </data>
-	<data name="LeftVCInactivity" xml:space="preserve">
-		<value>Я оставил голосовой канал из-за неактивности.</value>
-	</data>
-	<data name="MusicPlayerShutdownWarning" xml:space="preserve">
-		<value>Fergun будет перезапущена / выключена через несколько секунд, и ваш музыкальный проигрыватель будет закрыт. Приносим извинения за неудобства.</value>
-	</data>
-	<data name="Warning" xml:space="preserve">
-		<value>Предупреждение</value>
-	</data>
-	<data name="PublicIdNull" xml:space="preserve">
-		<value>Общественность ID этой авантюры является недействительным. Пожалуйста, создайте новое приключение.</value>
-	</data>
-	<data name="giveSummary" xml:space="preserve">
-		<value>Выдает (передает) указанный ID пользователю.</value>
-	</data>
-	<data name="giveParam1" xml:space="preserve">
-		<value>Идентификатор приключения.</value>
-	</data>
-	<data name="giveParam2" xml:space="preserve">
-		<value>Пользователь, которому необходимо присвоить идентификатор.</value>
-	</data>
-	<data name="CannotGiveYourself" xml:space="preserve">
-		<value>Вы не можете сами дать ID!</value>
-	</data>
-	<data name="GaveId" xml:space="preserve">
-		<value>Идентификатор был успешно перенесен на {0}.</value>
-	</data>
-	<data name="Invite" xml:space="preserve">
-		<value>приглашение</value>
-	</data>
-	<data name="TopGGBotPage" xml:space="preserve">
-		<value>Top.GG Страница бота</value>
-	</data>
-	<data name="VoteLink" xml:space="preserve">
-		<value>Ссылка для голосования</value>
-	</data>
-	<data name="SupportServer" xml:space="preserve">
-		<value>Сервер поддержки</value>
-	</data>
-	<data name="ContactInfoNoServer" xml:space="preserve">
-		<value>Если у вас есть отчет об ошибке, вопрос или предложение, вы можете связаться со мной через прямое сообщение ({0}).</value>
-	</data>
-	<data name="ValueNotSetInConfig" xml:space="preserve">
-		<value>Значение `{0}` не установлено в конфигурации. Если проблема не исчезнет, обратитесь к разработчикам.</value>
-	</data>
-	<data name="CannotGiveToBot" xml:space="preserve">
-		<value>Вы не можете дать ID боту!</value>
-	</data>
-	<data name="NoAvailableLanguages" xml:space="preserve">
-		<value>Вроде нет доступных языков.</value>
-	</data>
-	<data name="RequestTimedOut" xml:space="preserve">
-		<value>Истекло время запроса.</value>
-	</data>
-	<data name="DiscordServerError" xml:space="preserve">
-		<value>Ошибка на серверах Discord</value>
-	</data>
-	<data name="DiscordServerErrorInfo" xml:space="preserve">
-		<value>Команда не удалась из-за проблемы на стороне Discord.
-Пожалуйста, повторите попытку позже.</value>
-	</data>
-	<data name="ErrorDetails" xml:space="preserve">
-		<value>Детали ошибки</value>
-	</data>
-	<data name="NowhereToVote" xml:space="preserve">
-		<value>Голосовать негде.</value>
-	</data>
-	<data name="LavalinkNotConnected" xml:space="preserve">
-		<value>Не удалось подключиться к серверу Lavalink. Пожалуйста, повторите попытку позже.</value>
-	</data>
-	<data name="ServerBlacklisted" xml:space="preserve">
-		<value>Сервер {0} занесен в черный список.</value>
-	</data>
-	<data name="ServerBlacklistedWithReason" xml:space="preserve">
-		<value>Сервер {0} был занесен в черный список по причине: {1}</value>
-	</data>
-	<data name="blacklistserverSummary" xml:space="preserve">
-		<value>Добавляет сервер в черный список или удаляет его.</value>
-	</data>
-	<data name="blacklistserverParam1" xml:space="preserve">
-		<value>ID сервера в черный список.</value>
-	</data>
-	<data name="blacklistserverParam2" xml:space="preserve">
-		<value>Причина попадания в черный список.</value>
-	</data>
-	<data name="ServerBlacklistRemoved" xml:space="preserve">
-		<value>Сервер {0} был удален из черного списка.</value>
-	</data>
-	<data name="NoPresenceIntent" xml:space="preserve">
-		<value>Не могу получить статус Spotify, потому что у меня нет: Guild Presences Intent.</value>
-	</data>
-	<data name="NoSpotifyStatus" xml:space="preserve">
-		<value>Для пользователя {0} не найден статус Spotify.</value>
-	</data>
-	<data name="ClickHere" xml:space="preserve">
-		<value>кликните сюда</value>
-	</data>
-	<data name="Title" xml:space="preserve">
-		<value>заглавие</value>
-	</data>
-	<data name="Artists" xml:space="preserve">
-		<value>Художники</value>
-	</data>
-	<data name="Album" xml:space="preserve">
-		<value>Альбом</value>
-	</data>
-	<data name="Duration" xml:space="preserve">
-		<value>Продолжительность</value>
-	</data>
-	<data name="Lyrics" xml:space="preserve">
-		<value>Текст песни</value>
-	</data>
-	<data name="TrackUrl" xml:space="preserve">
-		<value>URL отслеживания</value>
-	</data>
-	<data name="spotifySummary" xml:space="preserve">
-		<value>Получает информацию о статусе Spotify пользователя.</value>
-	</data>
-	<data name="spotifyParam1" xml:space="preserve">
-		<value>Пользователь, который получает информацию о статусе Spotify.</value>
-	</data>
-	<data name="wolframalphaSummary" xml:space="preserve">
-		<value>Получает ответ от Wolfram | Alpha на основе запроса,</value>
-	</data>
-	<data name="wolframalphaParam1" xml:space="preserve">
-		<value>Запрос для отправки.</value>
-	</data>
-	<data name="defineSummary" xml:space="preserve">
-		<value>Получает определения слова.</value>
-	</data>
-	<data name="defineParam1" xml:space="preserve">
-		<value>Слово для поиска.</value>
-	</data>
-	<data name="Word" xml:space="preserve">
-		<value>слово</value>
-	</data>
-	<data name="Definition" xml:space="preserve">
-		<value>Определение</value>
-	</data>
-	<data name="Synonyms" xml:space="preserve">
-		<value>Синонимы</value>
-	</data>
-	<data name="Antonyms" xml:space="preserve">
-		<value>Антонимы</value>
-	</data>
-	<data name="archiveParam1" xml:space="preserve">
-		<value>URL-адрес веб-сайта, на котором нужно сделать снимок экрана.</value>
-	</data>
-	<data name="archiveParam2" xml:space="preserve">
-		<value>Отметка времени в указанном формате.</value>
-	</data>
-	<data name="TimestampFormat" xml:space="preserve">
-		<value>Отметка времени должна иметь формат `YYYYMMDDhhmmss`, где `YYYY` является обязательным, а другие значения необязательны.</value>
-	</data>
-	<data name="InvalidTimestamp" xml:space="preserve">
-		<value>Неверная метка времени.</value>
-	</data>
-	<data name="NoSnapshots" xml:space="preserve">
-		<value>Для указанного URL и отметки времени не найдено снимков.</value>
-	</data>
-	<data name="archiveSummary" xml:space="preserve">
-		<value>Получает снимок экрана веб-сайта в указанный год / месяц / день с использованием метки времени.</value>
-	</data>
-	<data name="Timestamp" xml:space="preserve">
-		<value>Отметка времени</value>
-	</data>
-	<data name="CouldNotFindLine" xml:space="preserve">
-		<value>Не удалось найти строку командного метода.</value>
-	</data>
-	<data name="shortenSummary" xml:space="preserve">
-		<value>Укорачивает URL.</value>
-	</data>
-	<data name="shortenParam1" xml:space="preserve">
-		<value>URL-адрес, который нужно сократить.</value>
-	</data>
-	<data name="Badges" xml:space="preserve">
-		<value>Значки</value>
-	</data>
-	<data name="img2Summary" xml:space="preserve">
-		<value>Ищет изображения с DuckDuckGo.</value>
-	</data>
-	<data name="img2Param1" xml:space="preserve">
-		<value>Ключевое слово (а) для поиска.</value>
-	</data>
-	<data name="privacySummary" xml:space="preserve">
-		<value>Отображает политику конфиденциальности и конфигурацию конфиденциальности.</value>
-	</data>
-	<data name="PrivacyPolicy" xml:space="preserve">
-		<value>Политика конфиденциальности</value>
-	</data>
-	<data name="WhatDataWeCollect" xml:space="preserve">
-		<value>Какие данные мы собираем</value>
-	</data>
-	<data name="WhatDataWeCollectList" xml:space="preserve">
-		<value>а. Конфигурация сервера (ID сервера, настраиваемый префикс, язык бота, отключенные команды и т. Д.)
-б. Конфигурация пользователя / статистика (идентификатор пользователя, игровые очки викторины, конфигурация конфиденциальности и статус черного списка)
-c. AI Dungeon adventures (идентификатор приключения, идентификатор владельца и доступность использования)
-d. Временный сбор удаленных / отредактированных сообщений, используемый в командах «snipe» (`snipe`,` editsnipe`, `bigsnipe` и `bigeditsnipe`)</value>
-	</data>
-	<data name="WhenWeCollectData" xml:space="preserve">
-		<value>Когда мы его соберем</value>
-	</data>
-	<data name="WhenWeCollectDataList" xml:space="preserve">
-		<value>а. Когда вы устанавливаете собственный префикс, язык или другую конфигурацию. Эти данные удаляются, когда бот покидает этот сервер.
-б. Когда вы играете в викторину, вы используете конфигурацию конфиденциальности или попадаете в черный список.
-c. Когда вы создаете приключение AI Dungeon с ботом. Эти данные удаляются, когда вы используете команду `aid delete`.
-d. Когда сообщение удалено / отредактировано. Эти сообщения можно увидеть только с помощью команд "snipe" в канале, где сообщение было удалено / отредактировано.
-Эти сообщения хранятся только 6 часов, и вы можете отказаться от них, используя приведенные ниже реакции.</value>
-	</data>
-	<data name="PrivacyConfig" xml:space="preserve">
-		<value>Конфигурация конфиденциальности</value>
-	</data>
-	<data name="PrivacyConfigInfo" xml:space="preserve">
-		<value>Здесь вы найдете некоторые настройки, которые вы можете изменить, чтобы улучшить свою конфиденциальность.</value>
-	</data>
-	<data name="PrivacyConfigList" xml:space="preserve">
-		<value>Отказаться от временного сбора удаленных / отредактированных сообщений в командах "snipe"</value>
-	</data>
-	<data name="Privacy" xml:space="preserve">
-		<value>Конфиденциальность</value>
-	</data>
-	<data name="SnipePrivacy" xml:space="preserve">
-		<value>Не хотите, чтобы ваше сообщение отображалось здесь? См. `privacy`.</value>
-	</data>
-	<data name="CannotUseThisInteraction" xml:space="preserve">
-		<value>Вы не можете использовать это взаимодействие.</value>
-	</data>
-	<data name="DeletingAdventure" xml:space="preserve">
-		<value>Удаление приключения...</value>
-	</data>
-	<data name="Donate" xml:space="preserve">
-		<value>Пожертвовать</value>
-	</data>
-	<data name="LanguageList" xml:space="preserve">
-		<value>Список языков</value>
-	</data>
-	<data name="img3Summary" xml:space="preserve">
-		<value>Ищет изображения с Brave.
-</value>
-	</data>
-	<data name="img3Param1" xml:space="preserve">
-		<value>Ключевые слова для поиска.</value>
-	</data>
-	<data name="Thread" xml:space="preserve">
-		<value>Нить</value>
-	</data>
-	<data name="AutoArchive" xml:space="preserve">
-		<value>Автоархив</value>
-	</data>
-	<data name="StageChannel" xml:space="preserve">
-		<value>Сценический канал</value>
-	</data>
-	<data name="IsLive" xml:space="preserve">
-		<value>Сценический канал</value>
-	</data>
-	<data name="Archived" xml:space="preserve">
-		<value>Архивировано</value>
-	</data>
-</root>
\ No newline at end of file
diff --git a/src/strings.tr.resx b/src/strings.tr.resx
deleted file mode 100644
index 76216b5..0000000
--- a/src/strings.tr.resx
+++ /dev/null
@@ -1,2180 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<root>
-	<!-- 
-    Microsoft ResX Schema 
-    
-    Version 2.0
-    
-    The primary goals of this format is to allow a simple XML format 
-    that is mostly human readable. The generation and parsing of the 
-    various data types are done through the TypeConverter classes 
-    associated with the data types.
-    
-    Example:
-    
-    ... ado.net/XML headers & schema ...
-    <resheader name="resmimetype">text/microsoft-resx</resheader>
-    <resheader name="version">2.0</resheader>
-    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
-    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
-    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
-    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
-    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
-        <value>[base64 mime encoded serialized .NET Framework object]</value>
-    </data>
-    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
-        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
-        <comment>This is a comment</comment>
-    </data>
-                
-    There are any number of "resheader" rows that contain simple 
-    name/value pairs.
-    
-    Each data row contains a name, and value. The row also contains a 
-    type or mimetype. Type corresponds to a .NET class that support 
-    text/value conversion through the TypeConverter architecture. 
-    Classes that don't support this are serialized and stored with the 
-    mimetype set.
-    
-    The mimetype is used for serialized objects, and tells the 
-    ResXResourceReader how to depersist the object. This is currently not 
-    extensible. For a given mimetype the value must be set accordingly:
-    
-    Note - application/x-microsoft.net.object.binary.base64 is the format 
-    that the ResXResourceWriter will generate, however the reader can 
-    read any of the formats listed below.
-    
-    mimetype: application/x-microsoft.net.object.binary.base64
-    value   : The object must be serialized with 
-            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
-            : and then encoded with base64 encoding.
-    
-    mimetype: application/x-microsoft.net.object.soap.base64
-    value   : The object must be serialized with 
-            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
-            : and then encoded with base64 encoding.
-
-    mimetype: application/x-microsoft.net.object.bytearray.base64
-    value   : The object must be serialized into a byte array 
-            : using a System.ComponentModel.TypeConverter
-            : and then encoded with base64 encoding.
-    -->
-	<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
-		<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
-		<xsd:element name="root" msdata:IsDataSet="true">
-			<xsd:complexType>
-				<xsd:choice maxOccurs="unbounded">
-					<xsd:element name="metadata">
-						<xsd:complexType>
-							<xsd:sequence>
-								<xsd:element name="value" type="xsd:string" minOccurs="0" />
-							</xsd:sequence>
-							<xsd:attribute name="name" use="required" type="xsd:string" />
-							<xsd:attribute name="type" type="xsd:string" />
-							<xsd:attribute name="mimetype" type="xsd:string" />
-							<xsd:attribute ref="xml:space" />
-						</xsd:complexType>
-					</xsd:element>
-					<xsd:element name="assembly">
-						<xsd:complexType>
-							<xsd:attribute name="alias" type="xsd:string" />
-							<xsd:attribute name="name" type="xsd:string" />
-						</xsd:complexType>
-					</xsd:element>
-					<xsd:element name="data">
-						<xsd:complexType>
-							<xsd:sequence>
-								<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
-								<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
-							</xsd:sequence>
-							<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
-							<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
-							<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
-							<xsd:attribute ref="xml:space" />
-						</xsd:complexType>
-					</xsd:element>
-					<xsd:element name="resheader">
-						<xsd:complexType>
-							<xsd:sequence>
-								<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
-							</xsd:sequence>
-							<xsd:attribute name="name" type="xsd:string" use="required" />
-						</xsd:complexType>
-					</xsd:element>
-				</xsd:choice>
-			</xsd:complexType>
-		</xsd:element>
-	</xsd:schema>
-	<resheader name="resmimetype">
-		<value>text/microsoft-resx</value>
-	</resheader>
-	<resheader name="version">
-		<value>2.0</value>
-	</resheader>
-	<resheader name="reader">
-		<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
-	</resheader>
-	<resheader name="writer">
-		<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
-	</resheader>
-	<data name="CommandList" xml:space="preserve">
-    <value>Komut listesi</value>
-  </data>
-	<data name="EntertainmentCommands" xml:space="preserve">
-    <value>Eğlence komutları</value>
-  </data>
-	<data name="HelpFooter" xml:space="preserve">
-    <value>Fergun {0} - Toplam komut sayısı: {1}</value>
-  </data>
-	<data name="ModerationCommands" xml:space="preserve">
-    <value>Denetleme komutları</value>
-  </data>
-	<data name="MusicCommands" xml:space="preserve">
-    <value>Müzik komutları</value>
-  </data>
-	<data name="Notes" xml:space="preserve">
-    <value>Notlar</value>
-  </data>
-	<data name="NotesInfo" xml:space="preserve">
-    <value>Bir komut hakkında daha fazla bilgi almak için `{0}help  [komut] 'kullanın.</value>
-  </data>
-	<data name="OtherCommands" xml:space="preserve">
-    <value>DiÄŸer komutlar</value>
-  </data>
-	<data name="TextCommands" xml:space="preserve">
-    <value>Metin komutları</value>
-  </data>
-	<data name="UtilityCommands" xml:space="preserve">
-    <value>Yardımcı program komutları</value>
-  </data>
-	<data name="UpcomingCommands" xml:space="preserve">
-    <value>YaklaÅŸan komutlar</value>
-  </data>
-	<data name="CommandNotFound" xml:space="preserve">
-    <value>Komut bulunamadı. Kullanım `{0}help` komut listesini görmek için.</value>
-  </data>
-	<data name="NoDescription" xml:space="preserve">
-    <value>(Açıklama yok)</value>
-  </data>
-	<data name="Optional" xml:space="preserve">
-    <value>(İsteğe bağlı)</value>
-  </data>
-	<data name="Usage" xml:space="preserve">
-    <value>Kullanımı</value>
-  </data>
-	<data name="Alias" xml:space="preserve">
-    <value>Takma ad(lar)</value>
-  </data>
-	<data name="Parameters" xml:space="preserve">
-    <value>Parametreler</value>
-  </data>
-	<data name="mojibakeSummary" xml:space="preserve">
-    <value>Rasgele unicode karakterleri gösterir.</value>
-  </data>
-	<data name="mojibakeParam1" xml:space="preserve">
-    <value>Sonuç uzunluğu.</value>
-  </data>
-	<data name="3orMoreChars" xml:space="preserve">
-    <value>Metin 3 veya daha fazla karakter içermelidir.</value>
-  </data>
-	<data name="TooLongToDisplay" xml:space="preserve">
-    <value>Ortaya çıkan metin görüntülenemeyecek kadar uzun ({0})</value>
-  </data>
-	<data name="normalizeSummary" xml:space="preserve">
-    <value>Bir metni normalleÅŸtirir.</value>
-  </data>
-	<data name="normalizeParam1" xml:space="preserve">
-    <value>NormalleÅŸtirilecek metin.</value>
-  </data>
-	<data name="randomizeSummary" xml:space="preserve">
-    <value>Metni rastgele seçer.</value>
-  </data>
-	<data name="randomizeParam1" xml:space="preserve">
-    <value>Rastgele yazılacak metin.</value>
-  </data>
-	<data name="repeatSummary" xml:space="preserve">
-    <value>Bir metni birkaç kez tekrarlar.</value>
-  </data>
-	<data name="repeatParam1" xml:space="preserve">
-    <value>Tekrarlama zamanı.</value>
-  </data>
-	<data name="repeatParam2" xml:space="preserve">
-    <value>Tekrarlanacak metin.</value>
-  </data>
-	<data name="reverseSummary" xml:space="preserve">
-    <value>Metni ters çevirir.</value>
-  </data>
-	<data name="reverseParam1" xml:space="preserve">
-    <value>Tersine çevrilecek metin.</value>
-  </data>
-	<data name="reverselinesSummary" xml:space="preserve">
-    <value>Metnin satır sırasını tersine çevirir.</value>
-  </data>
-	<data name="reverselinesParam1" xml:space="preserve">
-    <value>Çizgilerini ters çevirecek metin.</value>
-  </data>
-	<data name="reversewordsSummary" xml:space="preserve">
-    <value>Bir metnin sözcük sırasını tersine çevirir.</value>
-  </data>
-	<data name="reversewordsParam1" xml:space="preserve">
-    <value>Kelimelerini tersine çevirmek için metin.</value>
-  </data>
-	<data name="sarcasmSummary" xml:space="preserve">
-    <value>SARCASTÄ°K TEXT.</value>
-  </data>
-	<data name="sarcasmParam1" xml:space="preserve">
-    <value>Dönüştürülecek metin.</value>
-  </data>
-	<data name="vaporwaveSummary" xml:space="preserve">
-    <value>Bir metni vaporwave'e dönüştürür.</value>
-  </data>
-	<data name="vaporwaveParam1" xml:space="preserve">
-    <value>Dönüştürülecek metin.</value>
-  </data>
-	<data name="avatarSummary" xml:space="preserve">
-    <value>Geçerli kullanıcının veya belirli bir kullanıcının avatarını iletilirse döndürür.</value>
-  </data>
-	<data name="avatarParam1" xml:space="preserve">
-    <value>Avatarını almak için kullanıcı.</value>
-  </data>
-	<data name="base64encodeSummary" xml:space="preserve">
-    <value>Metni Base64'e kodlar.</value>
-  </data>
-	<data name="base64encodeParam1" xml:space="preserve">
-    <value>Kodlanacak metin.</value>
-  </data>
-	<data name="base64decodeSummary" xml:space="preserve">
-    <value>Base64'ten bir metni çözer.</value>
-  </data>
-	<data name="base64decodeParam1" xml:space="preserve">
-    <value>Kod çözülecek metin.</value>
-  </data>
-	<data name="base64decodeInvalid" xml:space="preserve">
-    <value>Geçersiz kodlanmış metin.</value>
-  </data>
-	<data name="choiceSummary" xml:space="preserve">
-    <value>Listeden bir seçenek seçer.</value>
-  </data>
-	<data name="choiceParam1" xml:space="preserve">
-    <value>Boşluktan ayrılmış seçenekler listesi.</value>
-  </data>
-	<data name="IChoose" xml:space="preserve">
-    <value>Seçerim...</value>
-  </data>
-	<data name="OneChoice" xml:space="preserve">
-    <value>... çünkü bana sadece bir seçim yaptın</value>
-  </data>
-	<data name="colorSummary" xml:space="preserve">
-	<value>Rastgele veya belirli bir renk gösterir.</value>
-  </data>
-	<data name="colorParam1" xml:space="preserve">
-	<value>Kullanılacak belirli renk. Onaltılık değer, ham değer veya bilinen bir renk adı olmalıdır.</value>
-  </data>
-	<data name="helpSummary" xml:space="preserve">
-    <value>Aktarılırsa yardım menüsünü veya komutun bilgilerini gösterir.</value>
-  </data>
-	<data name="helpParam1" xml:space="preserve">
-    <value>Bilgi alma komutu.</value>
-  </data>
-	<data name="identifySummary" xml:space="preserve">
-    <value>Microsoft CaptionBot ile bir görüntü tanımlar.</value>
-  </data>
-	<data name="identifyParam1" xml:space="preserve">
-    <value>Kullanılacak bir resmin URL'si.</value>
-  </data>
-	<data name="NoUrlPassed" xml:space="preserve">
-    <value>komutu bir bağlantı veya bir eki için önbellekte Son x ileti aramak için yapacak bir url belirterek Değil.</value>
-  </data>
-	<data name="AttachmentNotImage" xml:space="preserve">
-    <value>Ek bir resim deÄŸil.</value>
-  </data>
-	<data name="UrlNotFound" xml:space="preserve">
-    <value>Son {0} iletide herhangi bir url veya ek bulunamadı.</value>
-  </data>
-	<data name="UrlNotImage" xml:space="preserve">
-    <value>URL geçerli bir resim değil.</value>
-  </data>
-	<data name="imgSummary" xml:space="preserve">
-    <value>Google Görseller ile görselleri arar.</value>
-  </data>
-	<data name="imgParam1" xml:space="preserve">
-    <value>Aranacak anahtar kelimeler.</value>
-  </data>
-	<data name="NoResultsFound" xml:space="preserve">
-    <value>Herhangi bir sonuç bulunamadı.</value>
-  </data>
-	<data name="IfNSFW" xml:space="preserve">
-    <value>Arama NSFW ise, komutu bir NSFW kanalında kullanın.</value>
-  </data>
-	<data name="ImageSearch" xml:space="preserve">
-    <value>Görsel arama</value>
-  </data>
-	<data name="UrbanFooter" xml:space="preserve">
-    <value>Kentsel Sözlük - Sayfa {0} / {1}</value>
-  </data>
-	<data name="ImageSearchCap" xml:space="preserve">
-    <value>Görsel arama sınırına ulaşıldı :(</value>
-  </data>
-	<data name="ocrSummary" xml:space="preserve">
-    <value>Bir görüntüye OCR uygular.</value>
-  </data>
-	<data name="ocrParam1" xml:space="preserve">
-    <value>Kullanılacak bir resmin URL'si.</value>
-  </data>
-	<data name="UrlFileTooLarge" xml:space="preserve">
-    <value>Dosya çok büyük.</value>
-  </data>
-	<data name="OcrEmpty" xml:space="preserve">
-    <value>OCR sonuç vermedi.</value>
-  </data>
-	<data name="ocr2Summary" xml:space="preserve">
-    <value>Tesseract ile bir görüntüye OCR işlemi yapar.</value>
-  </data>
-	<data name="ocr2Param1" xml:space="preserve">
-    <value>Kullanılacak bir resmin URL'si.</value>
-  </data>
-	<data name="StatusCode" xml:space="preserve">
-    <value>Durum kodu:</value>
-  </data>
-	<data name="pingSummary" xml:space="preserve">
-    <value>Bir mesaj ve Veritabanı gecikme gönderme gecikmeyi alır.</value>
-  </data>
-	<data name="resizeSummary" xml:space="preserve">
-    <value>Bir görüntüyü waifu2x ile yeniden boyutlandırır.</value>
-  </data>
-	<data name="resizeParam1" xml:space="preserve">
-    <value>Kullanılacak bir resmin URL'si.</value>
-  </data>
-	<data name="AnErrorOccurred" xml:space="preserve">
-    <value>Bir hata oluÅŸtu.</value>
-  </data>
-	<data name="ResizeResults" xml:space="preserve">
-    <value>Sonuçları yeniden boyutlandır</value>
-  </data>
-	<data name="screenshotSummary" xml:space="preserve">
-    <value>Bir web sitesine ekran görüntüsü alır.</value>
-  </data>
-	<data name="screenshotParam1" xml:space="preserve">
-    <value>Ekran görüntüsü alacak web sitesi.</value>
-  </data>
-	<data name="serverinfoSummary" xml:space="preserve">
-    <value>Geçerli sunucu hakkında bilgi döndürür.</value>
-  </data>
-	<data name="ServerInfo" xml:space="preserve">
-    <value>Sunucu bilgisi</value>
-  </data>
-	<data name="Name" xml:space="preserve">
-    <value>ad</value>
-  </data>
-	<data name="Owner" xml:space="preserve">
-    <value>Sahip</value>
-  </data>
-	<data name="RoleCount" xml:space="preserve">
-    <value>Rol sayısı</value>
-  </data>
-	<data name="UserCount" xml:space="preserve">
-    <value>Kullanıcı sayısı</value>
-  </data>
-	<data name="VerificationLevel" xml:space="preserve">
-    <value>DoÄŸrulama seviyesi</value>
-  </data>
-	<data name="None" xml:space="preserve">
-    <value>(Yok)</value>
-  </data>
-	<data name="CreatedAt" xml:space="preserve">
-    <value>OluÅŸturma tarihi</value>
-  </data>
-	<data name="snipeSummary" xml:space="preserve">
-    <value>Geçerli kanaldaki son silinen mesajı gösterir.</value>
-  </data>
-	<data name="NothingToSnipe" xml:space="preserve">
-    <value>{0} içinde kaçacak bir şey yok</value>
-  </data>
-	<data name="translateSummary" xml:space="preserve">
-    <value>Bir metni çevirir.</value>
-  </data>
-	<data name="translateParam1" xml:space="preserve">
-    <value>ISO kodundaki (`en`, `es`, `br`, vb.)</value>
-  </data>
-	<data name="translateParam2" xml:space="preserve">
-    <value>Çevrilecek metin.</value>
-  </data>
-	<data name="InvalidLanguage" xml:space="preserve">
-    <value>Geçersiz hedef dil. Dil listesini görmek için `{0}translate language codes` kullanın.</value>
-  </data>
-	<data name="GoogleIPBanned" xml:space="preserve">
-    <value>Google tarafından IP yasaklandı :( lol</value>
-  </data>
-	<data name="ttsSummary" xml:space="preserve">
-    <value>KonuÅŸma metni.</value>
-  </data>
-	<data name="ttsParam1" xml:space="preserve">
-    <value>ISO kodundaki (`en`, `es`, `br`, vb.) Hedef dil, hedef geçersizse İngilizce'ye döner.</value>
-  </data>
-	<data name="ttsParam2" xml:space="preserve">
-    <value>Dönüştürülecek metin.</value>
-  </data>
-	<data name="urbanSummary" xml:space="preserve">
-    <value>Kentsel Sözlük arama.</value>
-  </data>
-	<data name="urbanParam1" xml:space="preserve">
-    <value>Aranacak anahtar kelimeler.</value>
-  </data>
-	<data name="NoResults" xml:space="preserve">
-    <value>Sonuç yok.</value>
-  </data>
-	<data name="By" xml:space="preserve">
-    <value>Tarafından</value>
-  </data>
-	<data name="Example" xml:space="preserve">
-    <value>Misal</value>
-  </data>
-	<data name="NoExample" xml:space="preserve">
-    <value>(Örnek verilmemiştir)</value>
-  </data>
-	<data name="userinfoSummary" xml:space="preserve">
-    <value>Geçerli kullanıcı veya geçildiyse belirli bir kullanıcı hakkında bilgi döndürür.</value>
-  </data>
-	<data name="userinfoParam1" xml:space="preserve">
-    <value>Bilgi almak için kullanıcı.</value>
-  </data>
-	<data name="UserInfo" xml:space="preserve">
-    <value>Kullanıcı bilgisi</value>
-  </data>
-	<data name="Activity" xml:space="preserve">
-    <value>Aktivite</value>
-  </data>
-	<data name="IsBot" xml:space="preserve">
-    <value>Bot mu</value>
-  </data>
-	<data name="GuildJoinDate" xml:space="preserve">
-    <value>Lonca katılma tarihi</value>
-  </data>
-	<data name="UserNotFound" xml:space="preserve">
-    <value>Kullanıcı bulunamadı.
-Bir etiket ({0}), bir söz ({1}) veya bir kimlik ({2}) kullanmayı deneyin.</value>
-  </data>
-	<data name="wikipediaSummary" xml:space="preserve">
-    <value>Wikipedia'da bir makale arar.</value>
-  </data>
-	<data name="wikipediaParam1" xml:space="preserve">
-    <value>Aranacak anahtar kelimeler.</value>
-  </data>
-	<data name="WikipediaSearch" xml:space="preserve">
-    <value>Wikipedia Arama</value>
-  </data>
-	<data name="xkcdSummary" xml:space="preserve">
-    <value>Bir sayı iletilirse rastgele bir xkcd komik veya belirli bir komik döndürür.</value>
-  </data>
-	<data name="xkcdParam1" xml:space="preserve">
-    <value>Komik numara.</value>
-  </data>
-	<data name="InvalidxkcdNumber" xml:space="preserve">
-    <value>Sayı 1 ile {0} arasında olmalıdır.</value>
-  </data>
-	<data name="EmptyCache" xml:space="preserve">
-    <value>Önbelleğe alınmış videoların dışında
-Önbellek oluşturuluyor ...</value>
-  </data>
-	<data name="banSummary" xml:space="preserve">
-    <value>Bir kullanıcıyı yasaklar.</value>
-  </data>
-	<data name="banParam1" xml:space="preserve">
-    <value>Yasaklanacak kullanıcı.</value>
-  </data>
-	<data name="banParam2" xml:space="preserve">
-    <value>Yasağın nedeni.</value>
-  </data>
-	<data name="BanSameUser" xml:space="preserve">
-    <value>Ne? Kendinizi yasaklamak mı istiyorsunuz?</value>
-  </data>
-	<data name="BanMyself" xml:space="preserve">
-    <value>Kendimi yasaklamayacağım lol</value>
-  </data>
-	<data name="Banned" xml:space="preserve">
-    <value>Kullanıcı {0} yasaklandı.</value>
-  </data>
-	<data name="clearSummary" xml:space="preserve">
-    <value>Geçerli kanaldaki son x iletiyi siler.</value>
-  </data>
-	<data name="clearParam1" xml:space="preserve">
-    <value>Silinecek mesaj sayısı.</value>
-  </data>
-	<data name="clearParam2" xml:space="preserve">
-    <value>Bir kullanıcı mesajlarını silebilir.</value>
-  </data>
-	<data name="hackbanSummary" xml:space="preserve">
-    <value>Bir kullanıcıyı hack eder.</value>
-  </data>
-	<data name="hackbanParam1" xml:space="preserve">
-    <value>Hackban için kullanıcının kimliği.</value>
-  </data>
-	<data name="hackbanParam2" xml:space="preserve">
-    <value>Hackban'ın nedeni.</value>
-  </data>
-	<data name="Hackbanned" xml:space="preserve">
-    <value>{0} kullanıcısı hackbanned.</value>
-  </data>
-	<data name="kickSummary" xml:space="preserve">
-    <value>Bir kullanıcıyı vurur.</value>
-  </data>
-	<data name="kickParam1" xml:space="preserve">
-    <value>Kullanılacak kullanıcı.</value>
-  </data>
-	<data name="kickParam2" xml:space="preserve">
-    <value>VuruÅŸ sebebi.</value>
-  </data>
-	<data name="unbanSummary" xml:space="preserve">
-    <value>Bir kullanıcının engellemesini kaldırır.</value>
-  </data>
-	<data name="unbanParam1" xml:space="preserve">
-    <value>Yasağının kaldırılacağı kullanıcı kimliği.</value>
-  </data>
-	<data name="Unbanned" xml:space="preserve">
-    <value>{0} kullanıcısının yasağı kaldırıldı.</value>
-  </data>
-	<data name="youtubeSummary" xml:space="preserve">
-    <value>YouTube'da bir video arar.</value>
-  </data>
-	<data name="youtubeParam1" xml:space="preserve">
-    <value>Aranacak anahtar kelimeler.</value>
-  </data>
-	<data name="YouTubeNoResults" xml:space="preserve">
-    <value>Herhangi bir sonuç bulunamadı.</value>
-  </data>
-	<data name="ytrandomSummary" xml:space="preserve">
-    <value>"Rastgele" bir YouTube videosu döndürür.</value>
-  </data>
-	<data name="AlreadyBanned" xml:space="preserve">
-    <value>Kullanıcı zaten yasaklanmış.</value>
-  </data>
-	<data name="NumberOutOfIndex" xml:space="preserve">
-    <value>Sayı {0} ve {1} arasında olmalıdır.</value>
-  </data>
-	<data name="ClearNotFound" xml:space="preserve">
-    <value>Son {1} mesajda {0} tarafından herhangi bir mesaj bulunamadı.</value>
-  </data>
-	<data name="By2" xml:space="preserve">
-    <value>tarafından</value>
-  </data>
-	<data name="Kicked" xml:space="preserve">
-    <value>{0} kullanıcısı tekmelendi.</value>
-  </data>
-	<data name="nickSummary" xml:space="preserve">
-    <value>Kullanıcının takma adını değiştirir.</value>
-  </data>
-	<data name="nickParam1" xml:space="preserve">
-    <value>Kullanıcı takma adını değiştirir.</value>
-  </data>
-	<data name="nickParam2" xml:space="preserve">
-    <value>Yeni takma ad. Takma adı kaldırmak için bunu boş bırakın.</value>
-  </data>
-	<data name="triviaSummary" xml:space="preserve">
-    <value>Diğer bilgiler zamanı!</value>
-  </data>
-	<data name="triviaParam1" xml:space="preserve">
-    <value>Seçilecek kategori. Kategori listesini görmek için "kategoriler" veya skor tablosunu görmek için "büyük şerit" / "sıralar" belirtin.</value>
-  </data>
-	<data name="CategoryList" xml:space="preserve">
-    <value>Kategori Listesi</value>
-  </data>
-	<data name="TriviaLeaderboard" xml:space="preserve">
-    <value>DiÄŸer bilgiler afiÅŸ</value>
-  </data>
-	<data name="AllQuestionsAnswered" xml:space="preserve">
-    <value>Belirtilen kategorideki tüm soruları yanıtladığınız anlaşılıyor, başka bir kategori seçin.</value>
-  </data>
-	<data name="TriviaError" xml:space="preserve">
-    <value>Bir hata oluÅŸtu. Hata kodu</value>
-  </data>
-	<data name="Category" xml:space="preserve">
-    <value>Kategori</value>
-  </data>
-	<data name="Type" xml:space="preserve">
-    <value>tip</value>
-  </data>
-	<data name="Difficulty" xml:space="preserve">
-    <value>zorluk</value>
-  </data>
-	<data name="Question" xml:space="preserve">
-    <value>Soru</value>
-  </data>
-	<data name="Options" xml:space="preserve">
-    <value>Seçenekler</value>
-  </data>
-	<data name="TimeLeft" xml:space="preserve">
-    <value>Cevaplamak için {0} saniyeniz var.</value>
-  </data>
-	<data name="InvalidOption" xml:space="preserve">
-    <value>Geçersiz seçenek!</value>
-  </data>
-	<data name="Lost1Point" xml:space="preserve">
-    <value>1 puan kaybettin.</value>
-  </data>
-	<data name="CorrectAnswer" xml:space="preserve">
-    <value>DoÄŸru cevap!</value>
-  </data>
-	<data name="Won1Point" xml:space="preserve">
-    <value>1 puan kazandınız.</value>
-  </data>
-	<data name="Incorrect" xml:space="preserve">
-    <value>Yanlış!</value>
-  </data>
-	<data name="TheAnswerIs" xml:space="preserve">
-    <value>Cevap</value>
-  </data>
-	<data name="TimesUp" xml:space="preserve">
-    <value>Süre doldu!</value>
-  </data>
-	<data name="Points" xml:space="preserve">
-    <value>makas</value>
-  </data>
-	<data name="joinSummary" xml:space="preserve">
-    <value>Bir ses kanalına katılır.</value>
-  </data>
-	<data name="UserNotInVC" xml:space="preserve">
-    <value>Bir ses kanalına bağlanmanız gerekir.</value>
-  </data>
-	<data name="NowConnected" xml:space="preserve">
-    <value>Şimdi bağlı {0}.</value>
-  </data>
-	<data name="leaveSummary" xml:space="preserve">
-    <value>Bir ses kanalı bırakır.</value>
-  </data>
-	<data name="LeaveNotInVC" xml:space="preserve">
-    <value>Botun ayrılmak için bulunduğu kanala katılın.</value>
-  </data>
-	<data name="LeftVC" xml:space="preserve">
-    <value>Bot şimdi {0} bıraktı.</value>
-  </data>
-	<data name="moveSummary" xml:space="preserve">
-    <value>Botu geçerli kullanıcının bulunduğu ses kanalına taşır.</value>
-  </data>
-	<data name="MoveNotInVC" xml:space="preserve">
-    <value>Botun olmasını istediğiniz bir ses kanalına katılın.</value>
-  </data>
-	<data name="playSummary" xml:space="preserve">
-    <value>YouTube'dan bir parça arar ve oynatır.</value>
-  </data>
-	<data name="playParam1" xml:space="preserve">
-    <value>Aranacak anahtar kelimeler.</value>
-  </data>
-	<data name="SelectTrack" xml:space="preserve">
-    <value>Listeden bir numara gönderme</value>
-  </data>
-	<data name="SearchCanceled" xml:space="preserve">
-    <value>Arama iptal edildi.</value>
-  </data>
-	<data name="OutOfIndex" xml:space="preserve">
-    <value>Seçenek dizin dışında.</value>
-  </data>
-	<data name="ReplyTimeout" xml:space="preserve">
-    <value>Zaman aşımından önce cevap vermediniz!</value>
-  </data>
-	<data name="replaySummary" xml:space="preserve">
-    <value>Varsa, çalmakta olan parçayı yeniden çalar.</value>
-  </data>
-	<data name="pauseSummary" xml:space="preserve">
-    <value>Oynatıcıyı duraklatır.</value>
-  </data>
-	<data name="resumeSummary" xml:space="preserve">
-    <value>Oynatmaya devam eder.</value>
-  </data>
-	<data name="stopSummary" xml:space="preserve">
-    <value>Oynatıcıyı durdurur.</value>
-  </data>
-	<data name="skipSummary" xml:space="preserve">
-    <value>Varsa, geçerli parçayı atlar.</value>
-  </data>
-	<data name="volumeSummary" xml:space="preserve">
-    <value>Oynatıcı ses seviyesini ayarlar.</value>
-  </data>
-	<data name="volumeParam1" xml:space="preserve">
-    <value>Ayarlanacak hacim (2 - 150)</value>
-  </data>
-	<data name="queueSummary" xml:space="preserve">
-    <value>Kuyruğu gösterir.</value>
-  </data>
-	<data name="shuffleSummary" xml:space="preserve">
-    <value>Kuyruğu karıştırır.</value>
-  </data>
-	<data name="removeSummary" xml:space="preserve">
-    <value>Belirli bir dizindeki kuyruktaki bir parçayı kaldırır.</value>
-  </data>
-	<data name="removeParam1" xml:space="preserve">
-    <value>Kaldırılacak parçanın dizini.</value>
-  </data>
-	<data name="lyricsSummary" xml:space="preserve">
-    <value>Belirtilen şarkının sözlerini veya müzikçalardan hiç geçmediyse geçerli parçayı gösterir.</value>
-  </data>
-	<data name="lyricsParam1" xml:space="preserve">
-    <value>Şarkı sözlerini aramak için şarkı.</value>
-  </data>
-	<data name="artworkSummary" xml:space="preserve">
-    <value>Oynatıcıdaki geçerli parçanın resmini gösterir.</value>
-  </data>
-	<data name="NoTracks" xml:space="preserve">
-    <value>Çalınacak başka parça yok.</value>
-  </data>
-	<data name="NowPlaying" xml:space="preserve">
-    <value>Åžimdi oynuyor</value>
-  </data>
-	<data name="PlayerError" xml:space="preserve">
-    <value>Bir hata oluÅŸtu</value>
-  </data>
-	<data name="PlayerStuck" xml:space="preserve">
-    <value>Oyuncu {1} saniye ** {0} ** parçasına sıkıştı.</value>
-  </data>
-	<data name="PlayerMoved" xml:space="preserve">
-    <value>** {0} ** 'den ** {1} **' ye taşındı</value>
-  </data>
-	<data name="PlayerNoMatches" xml:space="preserve">
-    <value>Hiçbir sonuç bulunamadı. Bir Youtube bağlantı veya bir mp3 dosyası bağlantısını kullanmayı deneyin.</value>
-  </data>
-	<data name="PlayerPlaylistAdded" xml:space="preserve">
-    <value>Oynatma listesi ** {0} ** ({1} parça) ({2}) kuyruğa eklendi.</value>
-  </data>
-	<data name="PlayerEmptyPlaylistAdded" xml:space="preserve">
-    <value>Sıraya alınmış {0} parça ({1})
-
-Şimdi çalıyor: {2}</value>
-  </data>
-	<data name="PlayerTrackAdded" xml:space="preserve">
-    <value>{0} kuyruÄŸa eklendi.</value>
-  </data>
-	<data name="PlayerNowPlaying" xml:space="preserve">
-    <value>Şimdi çalıyor: {0}</value>
-  </data>
-	<data name="InvalidTrack" xml:space="preserve">
-    <value>Geçersiz parça.</value>
-  </data>
-	<data name="Replaying" xml:space="preserve">
-    <value>Tekrar oynatılıyor {0}</value>
-  </data>
-	<data name="EmptyQueue" xml:space="preserve">
-    <value>Sıra boş.</value>
-  </data>
-	<data name="PlayerNotPlaying" xml:space="preserve">
-    <value>Oyuncu oynamıyor.</value>
-  </data>
-	<data name="PlayerStopped" xml:space="preserve">
-    <value>Oyuncu ÅŸimdi durdu.</value>
-  </data>
-	<data name="PlayerTrackSkipped" xml:space="preserve">
-    <value>Atlandı: {0}
-
-Şimdi çalıyor: {1}</value>
-  </data>
-	<data name="VolumeOutOfIndex" xml:space="preserve">
-    <value>2 ile 150 arasında bir sayı kullanın.</value>
-  </data>
-	<data name="VolumeSet" xml:space="preserve">
-    <value>Ses ÅŸiddeti: {0}.</value>
-  </data>
-	<data name="PlayerPaused" xml:space="preserve">
-    <value>Oyuncu şimdi duraklatıldı.</value>
-  </data>
-	<data name="PlaybackResumed" xml:space="preserve">
-    <value>Oynatma devam etti.</value>
-  </data>
-	<data name="PlayerNotPaused" xml:space="preserve">
-    <value>Oyuncu duraklatılmadı.</value>
-  </data>
-	<data name="CurrentlyPlaying" xml:space="preserve">
-    <value>Şu anda çalmaya: {0} ({1} ve {2})</value>
-  </data>
-	<data name="MusicInQueue" xml:space="preserve">
-    <value>Kuyruktaki parçalar:</value>
-  </data>
-	<data name="Queue1Item" xml:space="preserve">
-    <value>Kuyrukta sadece 1 öğe var.</value>
-  </data>
-	<data name="QueueShuffled" xml:space="preserve">
-    <value>Kuyruk karıştırıldı.</value>
-  </data>
-	<data name="IndexOutOfRange" xml:space="preserve">
-    <value>Dizin aralık dışında.</value>
-  </data>
-	<data name="TrackRemoved" xml:space="preserve">
-    <value>{1} konumundaki {0} parçası kaldırıldı.</value>
-  </data>
-	<data name="LyricsNotFound" xml:space="preserve">
-    <value>Hiçbir şarkı {0} bulundu.</value>
-  </data>
-	<data name="botgameSummary" xml:space="preserve">
-    <value>Botun oyun durumunu ayarlar.</value>
-  </data>
-	<data name="botgameParam1" xml:space="preserve">
-    <value>Ayarlanacak metin.</value>
-  </data>
-	<data name="BotOwnerOnly" xml:space="preserve">
-    <value>Sadece bot sahibi.</value>
-  </data>
-	<data name="botstatusSummary" xml:space="preserve">
-    <value>Botun durumunu ayarlar.</value>
-  </data>
-	<data name="botstatusParam1" xml:space="preserve">
-    <value>Ayarlanacak durum (0 - 5).</value>
-  </data>
-	<data name="codeSummary" xml:space="preserve">
-    <value>Bir komutun kaynak kodunu gösterir.</value>
-  </data>
-	<data name="codeParam1" xml:space="preserve">
-    <value>Kodunu alma komutu.</value>
-  </data>
-	<data name="botcolorSummary" xml:space="preserve">
-    <value>Gömme rengini ayarlar.</value>
-  </data>
-	<data name="botcolorParam1" xml:space="preserve">
-    <value>Onaltılık veya ondalık olarak yeni renk.</value>
-  </data>
-	<data name="InvalidColor" xml:space="preserve">
-    <value>Geçersiz renk.</value>
-  </data>
-	<data name="cringeSummary" xml:space="preserve">
-    <value>Abi az önce yayınladın!</value>
-  </data>
-	<data name="globalprefixSummary" xml:space="preserve">
-    <value>Bot genel önekini ayarlar.</value>
-  </data>
-	<data name="globalprefixParam1" xml:space="preserve">
-    <value>Yeni global önek.</value>
-  </data>
-	<data name="CurrentGlobalPrefix" xml:space="preserve">
-    <value>Geçerli genel önek:</value>
-  </data>
-	<data name="PrefixSameCurrentTarget" xml:space="preserve">
-    <value>Hedef önek ve geçerli önek aynıdır.</value>
-  </data>
-	<data name="NewGlobalPrefix" xml:space="preserve">
-    <value>Yeni küresel öneki: "{0}"</value>
-  </data>
-	<data name="inviteSummary" xml:space="preserve">
-    <value>Bot davet bağlantısını gönderir.</value>
-  </data>
-	<data name="prefixSummary" xml:space="preserve">
-    <value>Bot önekini ayarlar.</value>
-  </data>
-	<data name="prefixParam1" xml:space="preserve">
-    <value>Yeni önek.</value>
-  </data>
-	<data name="CurrentPrefix" xml:space="preserve">
-    <value>Geçerli önek:</value>
-  </data>
-	<data name="NewPrefix" xml:space="preserve">
-    <value>Yeni lonca öneki: "{0}"</value>
-  </data>
-	<data name="restartSummary" xml:space="preserve">
-    <value>Botu yeniden başlatır.</value>
-  </data>
-	<data name="saySummary" xml:space="preserve">
-    <value>Bir şeyler söylüyor.</value>
-  </data>
-	<data name="sayParam1" xml:space="preserve">
-    <value>Söylenecek metin.</value>
-  </data>
-	<data name="uptimeSummary" xml:space="preserve">
-    <value>Botun çalışma süresini gösterir.</value>
-  </data>
-	<data name="languageSummary" xml:space="preserve">
-    <value>Bot dilini ayarlar.</value>
-  </data>
-	<data name="NewLanguage" xml:space="preserve">
-    <value>Bot dili artık şudur: Türk
-⚠ Google, bu çevirmek için kullanıldı Çevir yüzden metin ile bazı hatalar olabilir.</value>
-  </data>
-	<data name="PaginatorFooter" xml:space="preserve">
-    <value>Sayfa {0} / {1}</value>
-  </data>
-	<data name="FailedExecution" xml:space="preserve">
-    <value>Çalıştırılamadı</value>
-  </data>
-	<data name="nowplayingSummary" xml:space="preserve">
-    <value>Oynatıcıdaki geçerli parçayı alır.</value>
-  </data>
-	<data name="changelogSummary" xml:space="preserve">
-    <value>Bot değişiklik günlüğünü gösterir.</value>
-  </data>
-	<data name="TempDisabled" xml:space="preserve">
-    <value>Geçici olarak devre dışı bırakıldı.</value>
-  </data>
-	<data name="inspirobotSummary" xml:space="preserve">
-    <value>Bazı ilham verici alıntılar alın.</value>
-  </data>
-	<data name="PermissionRequired" xml:space="preserve">
-    <value>Ben bu komutu çalıştırmak için {0} izni gerekir.</value>
-  </data>
-	<data name="ManageMessages" xml:space="preserve">
-    <value>Mesajları Yönet</value>
-  </data>
-	<data name="Connect" xml:space="preserve">
-    <value>BaÄŸlan</value>
-  </data>
-	<data name="Speak" xml:space="preserve">
-    <value>KonuÅŸ</value>
-  </data>
-	<data name="evalSummary" xml:space="preserve">
-    <value>Kodu deÄŸerlendirir.</value>
-  </data>
-	<data name="evalParam1" xml:space="preserve">
-    <value>DeÄŸerlendirilecek kod.</value>
-  </data>
-	<data name="ScreenshotNSFW" xml:space="preserve">
-    <value>Ekran görüntüsü NSFW içeriğine sahip olabilir ve gösterilmez. Bir NSFW kanalında komutu kullanmayı veya başka bir URL'yi kullanmayı deneyin.</value>
-  </data>
-	<data name="BotNotConnected" xml:space="preserve">
-    <value>Bot bir ses kanalına bağlı değil.</value>
-  </data>
-	<data name="CreationCanceled" xml:space="preserve">
-    <value>OluÅŸturma iptal edildi.</value>
-  </data>
-	<data name="AIDungeonWelcome" xml:space="preserve">
-    <value>AI Dungeon'a HoÅŸgeldiniz</value>
-  </data>
-	<data name="ModeSelect" xml:space="preserve">
-    <value>Devam etmeden önce `{0}aid info` ile bilgi ve komutları okuduğunuzdan emin olun.
-
-Bir mod seçin:</value>
-  </data>
-	<data name="CharacterSelect" xml:space="preserve">
-    <value>Bir karakter seçin</value>
-  </data>
-	<data name="AttachFiles" xml:space="preserve">
-    <value>Dosyaları ekle</value>
-  </data>
-	<data name="EvalResults" xml:space="preserve">
-    <value>Sonuçları değerlendirin</value>
-  </data>
-	<data name="Output" xml:space="preserve">
-    <value>Çıktı</value>
-  </data>
-	<data name="EvalFooter" xml:space="preserve">
-    <value>{0} ms içinde yürütüldü</value>
-  </data>
-	<data name="EvalNoReturnValue" xml:space="preserve">
-    <value>Kod herhangi bir dönüş değeri olmadan yürütülür.</value>
-  </data>
-	<data name="NSFWOnly" xml:space="preserve">
-    <value>Bu komut yalnızca bir NSFW kanalında kullanılabilir.</value>
-  </data>
-	<data name="UserOnWait" xml:space="preserve">
-    <value>Bu komutu kullanmadan önce birkaç saniye bekleyin!</value>
-  </data>
-	<data name="snipeParam1" xml:space="preserve">
-    <value>Su çulluğu yapmak için belirli bir kana.l</value>
-  </data>
-	<data name="IDNotFound" xml:space="preserve">
-    <value>Kimlik bulunamadı.</value>
-  </data>
-	<data name="IDNotPublic" xml:space="preserve">
-    <value>Bu hikaye kimliği herkese açık değil ve onu kullanamazsınız. Kimlik sahibinden ({0}) `makepublic &lt;ID&gt;` ile herkese açık hale getirmesini isteyin</value>
-  </data>
-	<data name="IDOnWait" xml:space="preserve">
-    <value>Bu kimliği kullanmadan önce hikayenin oluşturulmasını beklemelisiniz!</value>
-  </data>
-	<data name="GeneratingNewAdventure" xml:space="preserve">
-    <value>Yeni bir macera yaratmak
-modu ile: ** {0} **
-ve karakteri: ** {1} ** ...</value>
-  </data>
-	<data name="CustomCharacterCreation" xml:space="preserve">
-    <value>Özel karakter oluşturma</value>
-  </data>
-	<data name="CustomCharacterPrompt" xml:space="preserve">
-    <value>Kim olduğunuzu ve başladığınız yerin ilk birkaç cümlesini açıklayan bir metin girin.
-Bu komut istemi 5 dakika içinde zaman aşımına uğrayacaktır.</value>
-  </data>
-	<data name="GeneratingNewCustomAdventure" xml:space="preserve">
-    <value>Özel istemi ile yeni bir macera oluşturuluyor...</value>
-  </data>
-	<data name="GeneratingStory" xml:space="preserve">
-    <value>Hikaye oluÅŸturuluyor ...</value>
-  </data>
-	<data name="EditingStoryContext" xml:space="preserve">
-    <value>Hikaye bağlamını düzenleme ...</value>
-  </data>
-	<data name="TheAIWillNowRemember" xml:space="preserve">
-    <value>AI şimdi hatırlayacak:</value>
-  </data>
-	<data name="AlteringLastOutput" xml:space="preserve">
-    <value>Son çıktının değiştirilmesi ...</value>
-  </data>
-	<data name="ChangedLastOutput" xml:space="preserve">
-    <value>Son çıktı şu şekilde değiştirildi:</value>
-  </data>
-	<data name="NotIDOwner" xml:space="preserve">
-    <value>Bu kimliÄŸin sahibi deÄŸilsiniz.</value>
-  </data>
-	<data name="IDAlreadyPublic" xml:space="preserve">
-    <value>Kimlik zaten herkese açık.</value>
-  </data>
-	<data name="IDNowPublic" xml:space="preserve">
-    <value>Kimlik artık herkese açık ve herkes kullanabilir. `Makeprivate &lt;ID&gt;` ile tekrar özel olarak ayarlayabilirsiniz.</value>
-  </data>
-	<data name="IDAlreadyPrivate" xml:space="preserve">
-    <value>Kimlik zaten özel.</value>
-  </data>
-	<data name="IDNowPrivate" xml:space="preserve">
-    <value>Kimlik artık özeldir ve yalnızca siz kullanabilirsiniz. `Makepublic &lt;ID&gt;` ile herkese açık olarak ayarlayabilirsiniz.</value>
-  </data>
-	<data name="NoIDs" xml:space="preserve">
-    <value>{0} hiçbir macera kimliğine sahip değil.</value>
-  </data>
-	<data name="IDList" xml:space="preserve">
-    <value>{0} için kimlik listesi</value>
-  </data>
-	<data name="IsPublic" xml:space="preserve">
-    <value>Herkese açık mı</value>
-  </data>
-	<data name="IDListFooter" xml:space="preserve">
-    <value>Bir macera kimliği hakkında bilgi almak için 'idinfo &lt;ID&gt;' kullanın.</value>
-  </data>
-	<data name="IDInfo" xml:space="preserve">
-    <value>Macera KimliÄŸi bilgisi</value>
-  </data>
-	<data name="AboutAIDText" xml:space="preserve">
-    <value>AI Dungeon, AI tarafından üretilen türünün ilk örneğidir. GPT-2 adı verilen 1.5B parametre makine öğrenme modeli kullanan AI Dungeon, bu sanal dünyada oynarken eylemlerinizin hikayesini ve sonuçlarını üretir. Var olan hemen hemen her oyunun aksine, geliştiricinin yapabileceğiniz şeyde hayal gücüyle sınırlı değilsiniz. Dilde ifade edebileceğiniz herhangi bir şey sizin eyleminiz olabilir ve AI zindan ustası dünyanın eylemlerinize nasıl tepki vereceğine karar verecektir.</value>
-  </data>
-	<data name="AIDHowToPlayText" xml:space="preserve">
-    <value>AI Dungeon'ın kuralları basittir:
-1. Oyun her eylemin önüne "You" koyar, böylece her eylem bir fiil ile başlamalıdır. Ör. "ejderhaya saldır", "bir sandviç çağır" vb ...
-2. Yukarıdakilere bir istisna olarak: Eyleminizi tırnak içine alırsanız Örn. "Bana görevimde katılacak mısın?" Oyunun önüne "Sen de" diyeceksin.</value>
-  </data>
-	<data name="AboutAIDTitle" xml:space="preserve">
-    <value>AI Dungeon Hakkında</value>
-  </data>
-	<data name="AIDHelp" xml:space="preserve">
-    <value>AI Dungeon Yardımı</value>
-  </data>
-	<data name="AIDHowToPlayTitle" xml:space="preserve">
-    <value>Nasıl oynanır</value>
-  </data>
-	<data name="Commands" xml:space="preserve">
-    <value>Komutları</value>
-  </data>
-	<data name="AIDTips" xml:space="preserve">
-    <value>Sık sık yeni kelimeler kullanmayı deneyin, AI çeşitlilik ile daha yaratıcı hale gelir.
-Bir fiil ile bir "do" girdisi başlatmayı unutmayın, örn: Ork'a saldırın.
-AI'nın yanıtıyla birlikte son girişinizi silmek için geri al düğmesini kullanın.
-Eylemler için uzun cümleler problem değil! Yaratıcı ol!
-Oluşturmak için daha fazla hikaye ister misiniz? Continue komutunu herhangi bir metin olmadan kullanın.
-Sık sık yeni kelimeler kullanmayı deneyin, AI çeşitlilik ile daha yaratıcı hale gelir.
-AI'nın her zaman hatırladığı hikaye içeriğini düzenlemek için remember komutunu kullanın.
-Konuşmak için metinden önce Say'ı kullanabilirsiniz.</value>
-  </data>
-	<data name="In" xml:space="preserve">
-    <value>İçinde</value>
-  </data>
-	<data name="configSummary" xml:space="preserve">
-    <value>Lonca seviyesinde bot yapılandırma seçenekleriyle bir gömme gösterir.</value>
-  </data>
-	<data name="AdminRequired" xml:space="preserve">
-    <value>Bu komutu kullanabilmek için yönetici olmanız gerekir.</value>
-  </data>
-	<data name="Loading" xml:space="preserve">
-    <value>Yükleniyor...</value>
-  </data>
-	<data name="FergunConfiguration" xml:space="preserve">
-    <value>Fergun yapılandırması</value>
-  </data>
-	<data name="ConfigCanceled" xml:space="preserve">
-    <value>Yapılandırma iptal edildi.</value>
-  </data>
-	<data name="CantDoThat" xml:space="preserve">
-    <value>Bunu yapamam.</value>
-  </data>
-	<data name="softbanSummary" xml:space="preserve">
-    <value>Bir kullanıcıyı devre dışı bırakır (kullanıcı mesajlarını tekme + silme).</value>
-  </data>
-	<data name="softbanParam1" xml:space="preserve">
-    <value>Softban kullanıcısı.</value>
-  </data>
-	<data name="softbanParam2" xml:space="preserve">
-    <value>Silinecek son gün sayısı. 7 varsayılan olarak.</value>
-  </data>
-	<data name="softbanParam3" xml:space="preserve">
-    <value>Softbanın nedeni.</value>
-  </data>
-	<data name="SoftbanSameUser" xml:space="preserve">
-    <value>Ne? Kendinizi yasaklamak ister misiniz?</value>
-  </data>
-	<data name="SoftbanMyself" xml:space="preserve">
-    <value>Kendimi softban yapmayacağım lol</value>
-  </data>
-	<data name="Softbanned" xml:space="preserve">
-    <value>{0} kullanıcısı yazılımla yasaklandı.</value>
-  </data>
-	<data name="AIDungeonCommands" xml:space="preserve">
-    <value>AI Dungeon (kullanım {0}aid &lt;komut&gt;)</value>
-  </data>
-	<data name="RevertingLastAction" xml:space="preserve">
-    <value>Son işlem geri döndürülüyor ...</value>
-  </data>
-	<data name="140CharsMax" xml:space="preserve">
-    <value>140 karakter maks.</value>
-  </data>
-	<data name="InviteLink" xml:space="preserve">
-    <value>Davet bağlantısı</value>
-  </data>
-	<data name="NoManageMessages" xml:space="preserve">
-    <value>Bazı komutlar `` Mesajları Yönet '' izniyle daha iyi çalışır (`img`,` kentsel`, `config`)</value>
-  </data>
-	<data name="InvalidText" xml:space="preserve">
-    <value>Geçersiz metin.</value>
-  </data>
-	<data name="clearRemarks" xml:space="preserve">
-    <value>Bir kullanıcı iletilirse, komut son "sayım" iletisindeki kullanıcıdan tüm iletileri silmeye çalışır.
-Bot'un mesajları arayabileceği gün sınırı 14'tür.</value>
-  </data>
-	<data name="tcdneSummary" xml:space="preserve">
-    <value>Bu kedi mevcut deÄŸil</value>
-  </data>
-	<data name="tpdneSummary" xml:space="preserve">
-    <value>Bu kiÅŸi mevcut deÄŸil</value>
-  </data>
-	<data name="YTSearchCap" xml:space="preserve">
-    <value>YouTube arama sınırına ulaşıldı :(</value>
-  </data>
-	<data name="CreatingVideoCache" xml:space="preserve">
-    <value>Birisi zaten bir video önbelleği oluşturuyor, lütfen bekleyin ..</value>
-  </data>
-	<data name="User" xml:space="preserve">
-    <value>kullanıcı</value>
-  </data>
-	<data name="editsnipeSummary" xml:space="preserve">
-    <value>Geçerli kanalda en son düzenlenen mesajı gösterir.</value>
-  </data>
-	<data name="reactionSummary" xml:space="preserve">
-    <value>Mesaja bir tepki ekler.</value>
-  </data>
-	<data name="reactionParam1" xml:space="preserve">
-    <value>Ekleme reaksiyonu (sadece bir tane)</value>
-  </data>
-	<data name="reactionParam2" xml:space="preserve">
-    <value>Reaksiyon eklemek için ileti kimliği (komutun yürütüldüğü kanaldan aynı olmalıdır).</value>
-  </data>
-	<data name="TargetLanguage" xml:space="preserve">
-    <value>Hedef dil</value>
-  </data>
-	<data name="Result" xml:space="preserve">
-    <value>Sonuç</value>
-  </data>
-	<data name="ErrorType" xml:space="preserve">
-    <value>Hata türü</value>
-  </data>
-	<data name="ErrorMessage" xml:space="preserve">
-    <value>Hata mesajı</value>
-  </data>
-	<data name="seekSummary" xml:space="preserve">
-    <value>Geçerli parçayı belirtilen konuma arar.</value>
-  </data>
-	<data name="seekParam1" xml:space="preserve">
-    <value>Aranacak n. Saniye veya `m: ss`,` mm: ss`, `h: mm: ss` veya` ss: dd: ss` biçiminde geçerli bir saat.</value>
-  </data>
-	<data name="CannotSeek" xml:space="preserve">
-    <value>Bu parça aranamıyor.</value>
-  </data>
-	<data name="SeekHigherOrEqual" xml:space="preserve">
-    <value>İkinci saniye ({0}) parçanın uzunluğundan ({1}) daha yüksek veya eşit olamaz</value>
-  </data>
-	<data name="SeekComplete" xml:space="preserve">
-    <value>İkinci {0} 'a atlandı ({2} / {1})</value>
-  </data>
-	<data name="ErrorInTranslation" xml:space="preserve">
-    <value>Çeviri API'sında bir hata oluştu.</value>
-  </data>
-	<data name="statsSummary" xml:space="preserve">
-    <value>Bot istatistiklerini gösterir.</value>
-  </data>
-	<data name="AdventureDeleted" xml:space="preserve">
-    <value>Macera silindi.</value>
-  </data>
-	<data name="CPUUsage" xml:space="preserve">
-    <value>CPU kullanımı</value>
-  </data>
-	<data name="RAMUsage" xml:space="preserve">
-    <value>RAM Kullanımı</value>
-  </data>
-	<data name="Library" xml:space="preserve">
-    <value>Kütüphane</value>
-  </data>
-	<data name="BotVersion" xml:space="preserve">
-    <value>Bot versiyonu</value>
-  </data>
-	<data name="OperatingSystem" xml:space="preserve">
-    <value>Ä°ÅŸletim sistemi</value>
-  </data>
-	<data name="BotOwner" xml:space="preserve">
-    <value>Bot sahibi</value>
-  </data>
-	<data name="CurrentNewNickEqual" xml:space="preserve">
-    <value>Mevcut takma ad ve yeni takma ad aynı.</value>
-  </data>
-	<data name="Yes" xml:space="preserve">
-    <value>Evet</value>
-  </data>
-	<data name="No" xml:space="preserve">
-    <value>Hayır</value>
-  </data>
-	<data name="True" xml:space="preserve">
-    <value>DoÄŸru</value>
-  </data>
-	<data name="False" xml:space="preserve">
-    <value>Yanlış</value>
-  </data>
-	<data name="ConfigList" xml:space="preserve">
-    <value>AI Dungeon giriş ve çıkışını otomatik çevir
-"Oynat" için parça seçimi</value>
-  </data>
-	<data name="ConfigPrompt" xml:space="preserve">
-    <value>Bir seçeneği etkinleştirmek veya devre dışı bırakmak için tepkiler kullanın.</value>
-  </data>
-	<data name="FergunConfig" xml:space="preserve">
-    <value>Fergun yapılandırması</value>
-  </data>
-	<data name="Option" xml:space="preserve">
-    <value>seçenek</value>
-  </data>
-	<data name="Value" xml:space="preserve">
-    <value>deÄŸer</value>
-  </data>
-	<data name="MissingPermissions" xml:space="preserve">
-    <value>Bu komutu çalıştırma izniniz yok.</value>
-  </data>
-	<data name="UserBlacklisted" xml:space="preserve">
-    <value>{0} kullanıcısı kara listeye alındı.</value>
-  </data>
-	<data name="UserBlacklistedWithReason" xml:space="preserve">
-    <value>{0} kullanıcısı şu nedenden dolayı kara listeye alındı: {1}</value>
-  </data>
-	<data name="UserBlacklistRemoved" xml:space="preserve">
-    <value>{0} kullanıcısı kara listeden çıkarıldı.</value>
-  </data>
-	<data name="blacklistSummary" xml:space="preserve">
-    <value>Kara listeye bir kullanıcı ekler veya listeyi kaldırır.</value>
-  </data>
-	<data name="blacklistParam1" xml:space="preserve">
-    <value>Kara listeye alınacak kullanıcının kimliği.</value>
-  </data>
-	<data name="UserRequireKickMembers" xml:space="preserve">
-    <value>Bu komutu kullanabilmek için `Kick Members 'iznine ihtiyacınız var.</value>
-  </data>
-	<data name="BotRequireKickMembers" xml:space="preserve">
-    <value>Bu komutu yürütmek için `` Kick Members '' iznine ihtiyacım var.</value>
-  </data>
-	<data name="UserRequireManageNicknames" xml:space="preserve">
-    <value>Bu komutu kullanabilmek için `Takma Adları Yönet 'iznine ihtiyacınız var.</value>
-  </data>
-	<data name="BotRequireManageNicknames" xml:space="preserve">
-    <value>Geçersiz mesaj kimliği. İletinin bu kanaldan geldiğinden emin olun.</value>
-  </data>
-	<data name="UserNotLowerHierarchy" xml:space="preserve">
-    <value>Belirtilen kullanıcı hiyerarşide daha düşük olmalıdır.</value>
-  </data>
-	<data name="BotRequireConnect" xml:space="preserve">
-    <value>Ses kanalına katılmak için `` Bağlan '' iznine ihtiyacım var.</value>
-  </data>
-	<data name="BotRequireSpeak" xml:space="preserve">
-    <value>Devam etmek için `` Konuş '' iznine ihtiyacım var.</value>
-  </data>
-	<data name="MoveSameChannel" xml:space="preserve">
-    <value>Gitmemi istediğin ses kanalına git.</value>
-  </data>
-	<data name="ProcessingTime" xml:space="preserve">
-    <value>İşlem süresi: {0} ms</value>
-  </data>
-	<data name="RoleNotFound" xml:space="preserve">
-    <value>Rol bulunamadı.</value>
-  </data>
-	<data name="RoleInfo" xml:space="preserve">
-    <value>Rol bilgileri</value>
-  </data>
-	<data name="Color" xml:space="preserve">
-    <value>Renk</value>
-  </data>
-	<data name="IsMentionable" xml:space="preserve">
-    <value>Kayda deÄŸer</value>
-  </data>
-	<data name="Permissions" xml:space="preserve">
-    <value>Ä°zinler</value>
-  </data>
-	<data name="roleinfoParam1" xml:space="preserve">
-    <value>Bilgi alma rolü (bundan bahsetmek gerekli değildir).</value>
-  </data>
-	<data name="logoutSummary" xml:space="preserve">
-    <value>Botu ayırır.</value>
-  </data>
-	<data name="nothingSummary" xml:space="preserve">
-    <value>En iyi komut.</value>
-  </data>
-	<data name="someoneSummary" xml:space="preserve">
-    <value>Rasgele bir kullanıcı döndürür.</value>
-  </data>
-	<data name="ActiveClients" xml:space="preserve">
-    <value>Aktif müşteriler</value>
-  </data>
-	<data name="Low" xml:space="preserve">
-    <value>Düşük</value>
-  </data>
-	<data name="Medium" xml:space="preserve">
-    <value>Orta</value>
-  </data>
-	<data name="High" xml:space="preserve">
-    <value>Yüksek</value>
-  </data>
-	<data name="Extreme" xml:space="preserve">
-    <value>Aşırı</value>
-  </data>
-	<data name="IsHoisted" xml:space="preserve">
-    <value>Kaldırıldı</value>
-  </data>
-	<data name="Position" xml:space="preserve">
-    <value>Durum</value>
-  </data>
-	<data name="BoostTier" xml:space="preserve">
-    <value>Nitro Boost Seviyesi</value>
-  </data>
-	<data name="BoostCount" xml:space="preserve">
-    <value>Nitro Boost Sayısı</value>
-  </data>
-	<data name="SeekTimeHigherOrEqual" xml:space="preserve">
-    <value>Gitme süresi ({0}) parçanın uzunluğundan ({1}) daha yüksek veya eşit olamaz</value>
-  </data>
-	<data name="Roles" xml:space="preserve">
-    <value>Roller</value>
-  </data>
-	<data name="BoostingSince" xml:space="preserve">
-    <value>O zamandan beri artıyor</value>
-  </data>
-	<data name="InvalidMessageID" xml:space="preserve">
-    <value>Geçersiz mesaj kimliği. Emin olun mesajı bu kanaldan olduğunu.</value>
-  </data>
-	<data name="BanMembers" xml:space="preserve">
-    <value>Ãœyeleri Yasakla</value>
-  </data>
-	<data name="KickMembers" xml:space="preserve">
-    <value>Kick Ãœyeler</value>
-  </data>
-	<data name="InvalidReaction" xml:space="preserve">
-    <value>Geçersiz emote / emoji.</value>
-  </data>
-	<data name="BadArgumentCount" xml:space="preserve">
-    <value>Komutta gerekli bağımsız değişkenler eksik. Komut hakkında daha fazla bilgi almak için {{0} yardım {1} `kullanın.</value>
-  </data>
-	<data name="UserRequireManageMessages" xml:space="preserve">
-    <value>Bu komutu kullanmak için `` Mesajları Yönet '' iznine ihtiyacınız vardır.</value>
-  </data>
-	<data name="BotRequireManageMessages" xml:space="preserve">
-    <value>Bu komutu yürütmek için `` Mesajları Yönet '' iznine ihtiyacım var.</value>
-  </data>
-	<data name="PrefixTooLarge" xml:space="preserve">
-    <value>Önek çok büyük. Maks. önek uzunluğu: {0}.</value>
-  </data>
-	<data name="InvalidUrl" xml:space="preserve">
-    <value>Geçersiz URL.</value>
-  </data>
-	<data name="ocrtranslateSummary" xml:space="preserve">
-    <value>OCR ve tercüme.</value>
-  </data>
-	<data name="OcrApiError" xml:space="preserve">
-    <value>OCR API'sında hata.</value>
-  </data>
-	<data name="UserNotBanned" xml:space="preserve">
-    <value>Kullanıcı yasaklanmadı.</value>
-  </data>
-	<data name="BotMention" xml:space="preserve">
-    <value>Buradaki önekim `{0}`. Komut listemi görmek için `{0}help` ve öneki değiştirmek için `{0}prefix ` kullanın.</value>
-  </data>
-	<data name="NotSupportedInDM" xml:space="preserve">
-    <value>Bunu burada kullanabileceğini sanmıyorum.</value>
-  </data>
-	<data name="CommandParseFailed" xml:space="preserve">
-    <value>Komut bağımsız değişkenleri ayrıştırılırken hata oluştu. Bağımsız değişken geçersiz veya bağımsız değişkenlerin sırası yanlış.
-Komut hakkında daha fazla bilgi almak için {{0} yardım {1} `kullanın.</value>
-  </data>
-	<data name="TranslationResults" xml:space="preserve">
-    <value>Çeviri sonuçları</value>
-  </data>
-	<data name="SourceLanguage" xml:space="preserve">
-    <value>Kaynak dil (Tespit edildi)</value>
-  </data>
-	<data name="blacklistParam2" xml:space="preserve">
-    <value>Kara listeye alınma nedeni.</value>
-  </data>
-	<data name="Blacklisted" xml:space="preserve">
-    <value>Kara listede bulunuyorsunuz.</value>
-  </data>
-	<data name="BlacklistedWithReason" xml:space="preserve">
-    <value>{0}</value>
-  </data>
-	<data name="UserRequireBanMembers" xml:space="preserve">
-    <value>Bu komutu kullanmak için `Üyeleri Yasakla 'iznine ihtiyacınız var.</value>
-  </data>
-	<data name="BotRequireBanMembers" xml:space="preserve">
-    <value>Bu komutu yürütmek için `` Üyeleri Yasakla '' iznine ihtiyacım var.</value>
-  </data>
-	<data name="OcrResults" xml:space="preserve">
-    <value>OCR Sonuçları</value>
-  </data>
-	<data name="BotRequireAttachFiles" xml:space="preserve">
-    <value>Bu komutu yürütmek için `` Dosya Ekle '' iznine ihtiyacım var.</value>
-  </data>
-	<data name="forceprefixSummary" xml:space="preserve">
-    <value>Force bu guild'deki öneki ayarlar.</value>
-  </data>
-	<data name="FirstTip" xml:space="preserve">
-    <value>Maceranıza devam etmek için {0} yardım devam &lt;ID&gt; [metin] kullanın.</value>
-  </data>
-	<data name="ocrtranslateParam1" xml:space="preserve">
-    <value>ISO kodunda hedef dil (en, es, br, vb.)</value>
-  </data>
-	<data name="ocrtranslateParam2" xml:space="preserve">
-    <value>Kullanılacak bir resmin URL'si.</value>
-  </data>
-	<data name="Input" xml:space="preserve">
-    <value>GiriÅŸ</value>
-  </data>
-	<data name="MemberCount" xml:space="preserve">
-    <value>Üye Sayısı</value>
-  </data>
-	<data name="Mention" xml:space="preserve">
-    <value>Anma</value>
-  </data>
-	<data name="roleinfoSummary" xml:space="preserve">
-    <value>Bir rol hakkında bilgi alır.</value>
-  </data>
-	<data name="ServerFeatures" xml:space="preserve">
-    <value>Özellikleri</value>
-  </data>
-	<data name="SeekTimeComplete" xml:space="preserve">
-    <value>{1} / {0} 'a atlandı</value>
-  </data>
-	<data name="SeekInvalidFormat" xml:space="preserve">
-    <value>`M: ss`,` mm: ss`, `h: mm: ss` veya` ss: dd: ss` biçiminde bir tam sayı veya dize iletmeniz gerekir.</value>
-  </data>
-	<data name="Ratelimited" xml:space="preserve">
-    <value>Bu komutu tekrar kullanmadan önce {0} saniye bekleyin!</value>
-  </data>
-	<data name="ObjectNotFound" xml:space="preserve">
-    <value>Nesne bulunamadı.</value>
-  </data>
-	<data name="MultipleMatches" xml:space="preserve">
-    <value>Birden fazla eÅŸleÅŸme bulundu.
-Bir etiket ({0}), bir söz ({1}) veya bir kimlik ({2}) kullanmayı deneyin.</value>
-  </data>
-	<data name="AlreadyConnected" xml:space="preserve">
-    <value>Bot zaten bir ses kanalına bağlı.</value>
-  </data>
-	<data name="InvalidFileType" xml:space="preserve">
-    <value>Geçersiz dosya türü.</value>
-  </data>
-	<data name="OwnerCommands" xml:space="preserve">
-    <value>Sahip komutları</value>
-  </data>
-	<data name="Vote" xml:space="preserve">
-    <value>Bana oy verebilirsiniz [burada] ({0}). Teşekkür ederim.</value>
-  </data>
-	<data name="voteSummary" xml:space="preserve">
-    <value>Oylama bağlantısı içeren bir yerleştirme gösterir.</value>
-  </data>
-	<data name="TotalServers" xml:space="preserve">
-    <value>Toplam sunucu</value>
-  </data>
-	<data name="TotalUsers" xml:space="preserve">
-    <value>Toplam kullanıcı</value>
-  </data>
-	<data name="ContactInfo" xml:space="preserve">
-    <value>Herhangi bir hata raporunuz, sorunuz veya öneriniz varsa, [destek sunucusuna] ({0}) katılabilir veya doğrudan mesaj ({1}) aracılığıyla benimle iletişime geçebilirsiniz.</value>
-  </data>
-	<data name="RedoingLastAction" xml:space="preserve">
-    <value>Son işlem yeniden yapılıyor ...</value>
-  </data>
-	<data name="SelfNoIDs" xml:space="preserve">
-    <value>Hiç macera kimliğiniz yok.
-Yeni bir macera oluşturmak için `new` komutunu kullanabilirsiniz.</value>
-  </data>
-	<data name="QueueExcess" xml:space="preserve">
-    <value>..ve {0} parça daha!</value>
-  </data>
-	<data name="ErrorHelp" xml:space="preserve">
-    <value>Bu devam ederse lütfen bu hatayı [destek sunucusunda]({0}) bildirin veya [GitHub deposunda]({1}) hatayı ve hatayı yeniden oluşturma adımlarını açıklayan bir sorunu açın.</value>
-  </data>
-	<data name="bashSummary" xml:space="preserve">
-    <value>Bir bash komutu çalıştırır.</value>
-  </data>
-	<data name="supportSummary" xml:space="preserve">
-    <value>destek bilgisi gösterir.</value>
-  </data>
-	<data name="ErrorInAPI" xml:space="preserve">
-    <value>API çağrılırken bir hata oluştu.</value>
-  </data>
-	<data name="Topic" xml:space="preserve">
-    <value>konu</value>
-  </data>
-	<data name="IsNSFW" xml:space="preserve">
-    <value>NSFW mı</value>
-  </data>
-	<data name="SlowMode" xml:space="preserve">
-    <value>YavaÅŸ mod</value>
-  </data>
-	<data name="ChannelInfo" xml:space="preserve">
-    <value>Kanal Bilgisi</value>
-  </data>
-	<data name="channelinfoSummary" xml:space="preserve">
-    <value>Bir kanal hakkında bilgi gösterir.</value>
-  </data>
-	<data name="ChannelNotFound" xml:space="preserve">
-    <value>Kanal bulunamadı.</value>
-  </data>
-	<data name="channelinfoParam1" xml:space="preserve">
-    <value>Bilgi alacağınız kanal.</value>
-  </data>
-	<data name="UserRequireManageServer" xml:space="preserve">
-    <value>Bu komutu kullanmak için `` Sunucuyu Yönet '' iznine ihtiyacınız vardır.</value>
-  </data>
-	<data name="badtranslatorSummary" xml:space="preserve">
-    <value>Metni kötü bir çevirmenden geçirir.</value>
-  </data>
-	<data name="badtranslatorParam1" xml:space="preserve">
-    <value>Kullanılacak metin.</value>
-  </data>
-	<data name="LanguageChain" xml:space="preserve">
-    <value>Dil zinciri</value>
-  </data>
-	<data name="VersionNotFound" xml:space="preserve">
-    <value>Sürüm bulunamadı. Sürümler: {0}</value>
-  </data>
-	<data name="OtherVersions" xml:space="preserve">
-    <value>Diğer sürümler: {0}</value>
-  </data>
-	<data name="DeletedMessages" xml:space="preserve">
-    <value>{0} mesaj silindi.</value>
-  </data>
-	<data name="DeletedMessagesByUser" xml:space="preserve">
-    <value>{1} tarafından {0} mesaj silindi.</value>
-  </data>
-	<data name="calcSummary" xml:space="preserve">
-    <value>Matematik ifadesini deÄŸerlendirir.</value>
-  </data>
-	<data name="calcParam1" xml:space="preserve">
-    <value>DeÄŸerlendirilecek ifade.</value>
-  </data>
-	<data name="InvalidExpression" xml:space="preserve">
-    <value>Geçersiz ifade.</value>
-  </data>
-	<data name="CalcResults" xml:space="preserve">
-    <value>Kireç sonuçları</value>
-  </data>
-	<data name="LoopUpdated" xml:space="preserve">
-    <value>Müzikçalar parçayı {0} kez tekrarlayacak şekilde güncellendi. İz döngüsünü devre dışı bırakmak için parametre olmadan bu komutu kullanın.</value>
-  </data>
-	<data name="LoopNoValuePassed" xml:space="preserve">
-    <value>Parçayı tekrarlamak istediğiniz sayıyı girin, örn:
-"{0}loop 5`</value>
-  </data>
-	<data name="NowLooping" xml:space="preserve">
-    <value>Geçerli parça şimdi {0} kez tekrarlanacaktır.
-Döngüyü devre dışı bırakmak için bu komutu parametre kullanmadan kullanın.</value>
-  </data>
-	<data name="LoopEnded" xml:space="preserve">
-    <value>{0} parçasının döngüsü sona erdi.</value>
-  </data>
-	<data name="loopSummary" xml:space="preserve">
-    <value>Geçerli parçayı birkaç kez tekrarlar.</value>
-  </data>
-	<data name="LanguagePrompt" xml:space="preserve">
-    <value>Ayarlamak istediğiniz dili seçin.</value>
-  </data>
-	<data name="lyricsRemarks" xml:space="preserve">
-    <value>Etiketleri korumak için komutun sonunda `-headers` kullanın (` [info] `).</value>
-  </data>
-	<data name="HelpFooter2" xml:space="preserve">
-    <value>&lt;&gt; = gereklidir | [] = İsteğe bağlı | Komutu yazarken bu sembolleri yazmayın.</value>
-  </data>
-	<data name="invertSummary" xml:space="preserve">
-    <value>Görüntüyü tersine çevirir (reddeder).</value>
-  </data>
-	<data name="invertParam1" xml:space="preserve">
-    <value>Kullanılacak bir resmin URL'si.</value>
-  </data>
-	<data name="OcrtrResults" xml:space="preserve">
-    <value>OCR ve çeviri sonuçları</value>
-  </data>
-	<data name="ArtistPage" xml:space="preserve">
-    <value>Sanatçı Sayfası</value>
-  </data>
-	<data name="AdventureDeletionPrompt" xml:space="preserve">
-    <value>Macerayı silmek için aşağıdaki düğmeye/emojiye basın.</value>
-  </data>
-	<data name="ReactTimeout" xml:space="preserve">
-    <value>Zaman aşımından önce tepki göstermediniz!</value>
-  </data>
-	<data name="NoChoices" xml:space="preserve">
-    <value>En az 1 seçenek geçmeniz gerekiyor.</value>
-  </data>
-	<data name="loopParam1" xml:space="preserve">
-    <value>Parçayı tekrarlama sayısı.</value>
-  </data>
-	<data name="LoopDisabled" xml:space="preserve">
-    <value>İz döngüsü devre dışı bırakıldı.</value>
-  </data>
-	<data name="LyricsQueryNotPassed" xml:space="preserve">
-    <value>Bir parça çalmanız veya bir parça adı geçmeniz gerekiyor.</value>
-  </data>
-	<data name="LyricsByGenius" xml:space="preserve">
-    <value>Şarkı sözleri Genius</value>
-  </data>
-	<data name="PaginatorHelp" xml:space="preserve">
-    <value>Bu bir sayfa gösterici. Sayfayı değiştirmek için ilgili simgelerle reaksiyona geçin.</value>
-  </data>
-	<data name="LanguageSelection" xml:space="preserve">
-    <value>Dil seçimi</value>
-  </data>
-	<data name="MessagesOlderThan2Weeks" xml:space="preserve">
-    <value>Mesajlar iki hafta yaşından küçük olması gerekmektedir.</value>
-  </data>
-	<data name="SomeMessagesNotDeleted" xml:space="preserve">
-    <value>iki hafta daha eski olduğu için {0} mesajlar silinemedi.</value>
-  </data>
-	<data name="GuildNotFound" xml:space="preserve">
-    <value>Sunucu bulunamadı.</value>
-  </data>
-	<data name="ErrorParsingLyrics" xml:space="preserve">
-    <value>{0} sözlerini ayrıştırılırken bir hata meydana geldi.
-Belki bu bir vesile deÄŸil mi?</value>
-  </data>
-	<data name="MustBeLowerThan" xml:space="preserve">
-    <value>Parametre {0} '{1} daha düşük olması gerekir.</value>
-  </data>
-	<data name="InvalidID" xml:space="preserve">
-    <value>Geçersiz kimlik.</value>
-  </data>
-	<data name="HastebinLink" xml:space="preserve">
-    <value>Hastebin bağlantı</value>
-  </data>
-	<data name="NotAvailable" xml:space="preserve">
-    <value>Müsait değil.</value>
-  </data>
-	<data name="WaitingQueue" xml:space="preserve">
-    <value>Sıranın boşalması bekleniyor ...</value>
-  </data>
-	<data name="NewOutputPrompt" xml:space="preserve">
-    <value>Yeni metni girin</value>
-  </data>
-	<data name="GeneratingNewResponse" xml:space="preserve">
-    <value>Yeni bir yanıt oluşturuluyor ...</value>
-  </data>
-	<data name="InputTypes" xml:space="preserve">
-    <value>Giriş seçenekleri</value>
-  </data>
-	<data name="InputTypesList" xml:space="preserve">
-    <value>Yapın: Hikayede yapmak istediğiniz bir eylem. Her zaman bir eylemle başlayın, örn. Msgstr "Gizli hazineyi ara". Hiç kimse algılanmazsa bu varsayılan seçenektir.
-
-De ki: Bunu konuşmak için kullanabilirsiniz, ör. "Onları yalnız bırak!"
-
-Hikaye: Bu giriş yapay zekanın girişinizin önüne "Siz" koymasını engeller.</value>
-  </data>
-	<data name="ChannelCount" xml:space="preserve">
-    <value>Kanal sayısı</value>
-  </data>
-	<data name="Region" xml:space="preserve">
-    <value>bölge</value>
-  </data>
-	<data name="DefaultChannel" xml:space="preserve">
-    <value>Standart kanal</value>
-  </data>
-	<data name="LanguageNotFound" xml:space="preserve">
-    <value>Dil bulunamadı.</value>
-  </data>
-	<data name="CategoryCount" xml:space="preserve">
-    <value>Kategori sayımı</value>
-  </data>
-	<data name="DumpingAdventure" xml:space="preserve">
-    <value>macera Damping...</value>
-  </data>
-	<data name="EditCanceled" xml:space="preserve">
-    <value>Düzenleme iptal edildi.</value>
-  </data>
-	<data name="NothingToDelete" xml:space="preserve">
-    <value>Silinecek bir ÅŸey yok.</value>
-  </data>
-	<data name="Members" xml:space="preserve">
-    <value>Ãœyeler</value>
-  </data>
-	<data name="Online" xml:space="preserve">
-    <value>İnternet üzerinden</value>
-  </data>
-	<data name="Idle" xml:space="preserve">
-    <value>BoÅŸta</value>
-  </data>
-	<data name="DnD" xml:space="preserve">
-    <value>DnD</value>
-  </data>
-	<data name="Offline" xml:space="preserve">
-    <value>Çevrim</value>
-  </data>
-	<data name="aidinfoSummary" xml:space="preserve">
-    <value>AI Zindan bilgi gösterir.</value>
-  </data>
-	<data name="aidnewSummary" xml:space="preserve">
-    <value>Yeni bir macera oluÅŸturur.</value>
-  </data>
-	<data name="continueSummary" xml:space="preserve">
-    <value>sağlanan metin ile macera devam ediyor. Hiçbir metin iletilirse, AI hikaye üretecektir.</value>
-  </data>
-	<data name="continueParam1" xml:space="preserve">
-    <value>macera kimliÄŸi.</value>
-  </data>
-	<data name="continueParam2" xml:space="preserve">
-    <value>Metin kullanmak.</value>
-  </data>
-	<data name="undoSummary" xml:space="preserve">
-    <value>Son işlemi geri alır.</value>
-  </data>
-	<data name="undoParam1" xml:space="preserve">
-    <value>macera kimliÄŸi.</value>
-  </data>
-	<data name="redoSummary" xml:space="preserve">
-    <value>Son eylemi yineler.</value>
-  </data>
-	<data name="redoParam1" xml:space="preserve">
-    <value>macera kimliÄŸi.</value>
-  </data>
-	<data name="rememberSummary" xml:space="preserve">
-    <value>Hafıza bağlamında metin ekler.</value>
-  </data>
-	<data name="rememberParam1" xml:space="preserve">
-    <value>macera kimliÄŸi.</value>
-  </data>
-	<data name="rememberParam2" xml:space="preserve">
-    <value>Metin eklemek için.</value>
-  </data>
-	<data name="alterSummary" xml:space="preserve">
-    <value>Geçen tepkisini düzenler.</value>
-  </data>
-	<data name="alterParam1" xml:space="preserve">
-    <value>macera kimliÄŸi.</value>
-  </data>
-	<data name="retrySummary" xml:space="preserve">
-    <value>Son eylemi yeniden dener ve yeni bir yanıt oluşturur.</value>
-  </data>
-	<data name="retryParam1" xml:space="preserve">
-    <value>macera kimliÄŸi.</value>
-  </data>
-	<data name="makepublicSummary" xml:space="preserve">
-    <value>bir kimlik halka yapar.</value>
-  </data>
-	<data name="makepublicParam1" xml:space="preserve">
-    <value>macera IF. Bu KimliÄŸi sahibi olmak gerekir.</value>
-  </data>
-	<data name="makeprivateSummary" xml:space="preserve">
-    <value>bir kimlik olarak gizli yapar.</value>
-  </data>
-	<data name="makeprivateParam1" xml:space="preserve">
-    <value>macera IF. Bu KimliÄŸi sahibi olmak gerekir.</value>
-  </data>
-	<data name="idlistSummary" xml:space="preserve">
-    <value>bir kullanıcının kimliği liste alır.</value>
-  </data>
-	<data name="idlistParam1" xml:space="preserve">
-    <value>Kullanıcı, Kimliğini almak için.</value>
-  </data>
-	<data name="idinfoSummary" xml:space="preserve">
-    <value>Bir Kimliği hakkında Gösterileri bilgi.</value>
-  </data>
-	<data name="idinfoParam1" xml:space="preserve">
-    <value>macera kimliÄŸi.</value>
-  </data>
-	<data name="deleteSummary" xml:space="preserve">
-    <value>Bir kimlik kaldırır.</value>
-  </data>
-	<data name="deleteParam1" xml:space="preserve">
-    <value>macera kimliÄŸi.</value>
-  </data>
-	<data name="dumpSummary" xml:space="preserve">
-    <value>Bir kimliğinden tüm metin döker.</value>
-  </data>
-	<data name="dumpParam1" xml:space="preserve">
-    <value>macera kimliÄŸi.</value>
-  </data>
-	<data name="RatelimitUses" xml:space="preserve">
-    <value>Her {1} için {0} kullanım</value>
-  </data>
-	<data name="Requirements" xml:space="preserve">
-    <value>Gereksinimler</value>
-  </data>
-	<data name="cmdstatsSummary" xml:space="preserve">
-    <value>Komut istatistikleri gösterir.</value>
-  </data>
-	<data name="CommandStatsInfo" xml:space="preserve">
-    <value>Komut Ä°statistikler ({0} beri)</value>
-  </data>
-	<data name="lmgtfySummary" xml:space="preserve">
-    <value>Bunu senin için Google'da arayalım.</value>
-  </data>
-	<data name="lmgtfyParam1" xml:space="preserve">
-    <value>Aranacak anahtar kelimeler.</value>
-  </data>
-	<data name="pasteSummary" xml:space="preserve">
-    <value>Hastebin yükledikleriniz metni.</value>
-  </data>
-	<data name="pasteParam1" xml:space="preserve">
-    <value>Metin yüklemek için.</value>
-  </data>
-	<data name="Uploading" xml:space="preserve">
-    <value>Yükleme...</value>
-  </data>
-	<data name="bigsnipeSummary" xml:space="preserve">
-    <value>Gösteriler son mevcut kanal veya belirtilen mesajlar silindi.</value>
-  </data>
-	<data name="bigsnipeParam1" xml:space="preserve">
-    <value>Belirli bir kanal snipe.</value>
-  </data>
-	<data name="MinutesAgo" xml:space="preserve">
-    <value>{0} dakika önce</value>
-  </data>
-	<data name="Attachment" xml:space="preserve">
-    <value>Ek dosya</value>
-  </data>
-	<data name="bigeditsnipeSummary" xml:space="preserve">
-    <value>Gösterim mevcut kanal veya belirtilen son düzenlenmiş mesajlar.</value>
-  </data>
-	<data name="bigeditsnipeParam1" xml:space="preserve">
-    <value>Belirli bir kanal snipe.</value>
-  </data>
-	<data name="TextChannel" xml:space="preserve">
-    <value>Metin Kanal</value>
-  </data>
-	<data name="AnnouncementChannel" xml:space="preserve">
-    <value>duyuru Kanal</value>
-  </data>
-	<data name="VoiceChannel" xml:space="preserve">
-    <value>Ses Kanalı</value>
-  </data>
-	<data name="DMChannel" xml:space="preserve">
-    <value>DM Kanal</value>
-  </data>
-	<data name="Bitrate" xml:space="preserve">
-    <value>Bit Hızı</value>
-  </data>
-	<data name="UserLimit" xml:space="preserve">
-    <value>Kullanıcı Sınırı</value>
-  </data>
-	<data name="NoLimit" xml:space="preserve">
-    <value>Limit yok</value>
-  </data>
-	<data name="disableSummary" xml:space="preserve">
-    <value>(Bu sunucu için) yerel olarak bir komut devre dışı bırakır.</value>
-  </data>
-	<data name="disableParam1" xml:space="preserve">
-    <value>komutunun adı devre dışı bırakmak için.</value>
-  </data>
-	<data name="NonDisableable" xml:space="preserve">
-    <value>{0} devre dışı bırakılamaz!</value>
-  </data>
-	<data name="AlreadyDisabled" xml:space="preserve">
-    <value>{0} zaten devre dışı bırakılmış!</value>
-  </data>
-	<data name="CommandDisabled" xml:space="preserve">
-    <value>Komut {0} bu sunucuda devre dışı bırakıldı.</value>
-  </data>
-	<data name="enableSummary" xml:space="preserve">
-    <value>(Bu sunucu için) yerel olarak bir komut sağlar.</value>
-  </data>
-	<data name="enableParam1" xml:space="preserve">
-    <value>komutunun adı etkinleştirmek için.</value>
-  </data>
-	<data name="AlreadyEnabled" xml:space="preserve">
-    <value>{0} zaten etkindir!</value>
-  </data>
-	<data name="CommandEnabled" xml:space="preserve">
-    <value>Komut {0} bu sunucuda etkinleÅŸtirilmiÅŸtir.$$</value>
-  </data>
-	<data name="globaldisableSummary" xml:space="preserve">
-    <value>Devre dışı bırakır (bütün sunucularda) bir komut global.$$</value>
-  </data>
-	<data name="globaldisableParam1" xml:space="preserve">
-    <value>komutunun adı global olarak devre dışı bırakmak için.</value>
-  </data>
-	<data name="globaldisableParam2" xml:space="preserve">
-    <value>sebebi kullanmak.</value>
-  </data>
-	<data name="AlreadyDisabledGlobally" xml:space="preserve">
-    <value>{0} zaten küresel devre dışı bırakılmış!</value>
-  </data>
-	<data name="CommandDisabledGlobally" xml:space="preserve">
-    <value>Komut {0} Tüm sunucularda devre dışı bırakılmıştır.</value>
-  </data>
-	<data name="globalenableSummary" xml:space="preserve">
-    <value>(Bütün sunucularda) bir komut tüm dünyada sağlar.</value>
-  </data>
-	<data name="globalenableParam1" xml:space="preserve">
-    <value>komutunun adı global olarak etkinleştirmek için.</value>
-  </data>
-	<data name="AlreadyEnabledGlobally" xml:space="preserve">
-    <value>{0} zaten küresel etkindir!</value>
-  </data>
-	<data name="CommandEnabledGlobally" xml:space="preserve">
-    <value>Komut {0} Tüm sunucularda etkinleştirildi.</value>
-  </data>
-	<data name="Reason" xml:space="preserve">
-    <value>neden</value>
-  </data>
-	<data name="LeftVCInactivity" xml:space="preserve">
-		<value>Ben etkin olmaması nedeniyle ses kanalını bıraktık.</value>
-	</data>
-	<data name="MusicPlayerShutdownWarning" xml:space="preserve">
-		<value>Fergun bazı saniyede kapalı / yeniden başlatılacak ve müzik çalar kapatılacak. Rahatsızlıktan dolayı özür dileriz.</value>
-	</data>
-	<data name="Warning" xml:space="preserve">
-		<value>Uyarı</value>
-	</data>
-	<data name="PublicIdNull" xml:space="preserve">
-		<value>Bu maceranın kamu kimliği boş. Yeni bir macera oluşturun.</value>
-	</data>
-	<data name="giveSummary" xml:space="preserve">
-		<value>Belirtilen kimliği bir kullanıcıya verir (aktarır).</value>
-	</data>
-	<data name="giveParam1" xml:space="preserve">
-		<value>Macera kimliÄŸi.</value>
-	</data>
-	<data name="giveParam2" xml:space="preserve">
-		<value>Kullanıcı kimliği verecek.</value>
-	</data>
-	<data name="CannotGiveYourself" xml:space="preserve">
-		<value>KimliÄŸi kendiniz veremezsiniz!</value>
-	</data>
-	<data name="GaveId" xml:space="preserve">
-		<value>Kimlik başarıyla {0} 'a aktarıldı.</value>
-	</data>
-	<data name="Invite" xml:space="preserve">
-		<value>Davet et</value>
-	</data>
-	<data name="TopGGBotPage" xml:space="preserve">
-		<value>Top.GG Bot Sayfası</value>
-	</data>
-	<data name="VoteLink" xml:space="preserve">
-		<value>Oylama Bağlantısı</value>
-	</data>
-	<data name="SupportServer" xml:space="preserve">
-		<value>Destek Sunucusu</value>
-	</data>
-	<data name="ContactInfoNoServer" xml:space="preserve">
-		<value>Herhangi bir hata raporunuz, sorunuz veya öneriniz varsa, doğrudan mesaj ({0}) aracılığıyla benimle iletişime geçebilirsiniz.</value>
-	</data>
-	<data name="ValueNotSetInConfig" xml:space="preserve">
-		<value>Yapılandırmada `{0}` değeri belirlenmedi. Bu sorun devam ederse, geliştiricilerle iletişime geçin.</value>
-	</data>
-	<data name="CannotGiveToBot" xml:space="preserve">
-		<value>KimliÄŸi bir bota veremezsiniz!</value>
-	</data>
-	<data name="NoAvailableLanguages" xml:space="preserve">
-		<value>Görünüşe göre mevcut dil yok.</value>
-	</data>
-	<data name="RequestTimedOut" xml:space="preserve">
-		<value>İstek zaman aşımına uğradı.</value>
-	</data>
-	<data name="DiscordServerError" xml:space="preserve">
-		<value>Discord Sunucularında Hata</value>
-	</data>
-	<data name="DiscordServerErrorInfo" xml:space="preserve">
-		<value>Discord tarafındaki bir sorun nedeniyle komut başarısız oldu.
-Lütfen daha sonra tekrar deneyiniz.</value>
-	</data>
-	<data name="ErrorDetails" xml:space="preserve">
-		<value>Hata detayları</value>
-	</data>
-	<data name="NowhereToVote" xml:space="preserve">
-		<value>Oy verecek yer yok.</value>
-	</data>
-	<data name="LavalinkNotConnected" xml:space="preserve">
-		<value>Lavalink sunucusuna bağlanılamadı. Lütfen daha sonra tekrar deneyiniz.</value>
-	</data>
-	<data name="ServerBlacklisted" xml:space="preserve">
-		<value>{0} sunucusu kara listeye alındı.</value>
-	</data>
-	<data name="ServerBlacklistedWithReason" xml:space="preserve">
-		<value>{0} sunucusu şu nedenle kara listeye alındı: {1}</value>
-	</data>
-	<data name="blacklistserverSummary" xml:space="preserve">
-		<value>Kara listeye bir sunucu ekler veya onu kaldırır.</value>
-	</data>
-	<data name="blacklistserverParam1" xml:space="preserve">
-		<value>Kara listeye alınacak sunucunun kimliği.</value>
-	</data>
-	<data name="blacklistserverParam2" xml:space="preserve">
-		<value>Kara listeye alınma nedeni.</value>
-	</data>
-	<data name="ServerBlacklistRemoved" xml:space="preserve">
-		<value>{0} sunucusu kara listeden kaldırıldı.</value>
-	</data>
-	<data name="NoPresenceIntent" xml:space="preserve">
-		<value>Guild Presences Intent sahip olmadığım için Spotify durumu alınamıyor</value>
-	</data>
-	<data name="NoSpotifyStatus" xml:space="preserve">
-		<value>{0} kullanıcısı için Spotify durumu bulunamadı.</value>
-	</data>
-	<data name="ClickHere" xml:space="preserve">
-		<value>Buraya Tıkla</value>
-	</data>
-	<data name="Title" xml:space="preserve">
-		<value>Başlık</value>
-	</data>
-	<data name="Artists" xml:space="preserve">
-		<value>Sanatçı(lar)</value>
-	</data>
-	<data name="Album" xml:space="preserve">
-		<value>Albüm</value>
-	</data>
-	<data name="Duration" xml:space="preserve">
-		<value>Süresi</value>
-	</data>
-	<data name="Lyrics" xml:space="preserve">
-		<value>Şarkı sözleri</value>
-	</data>
-	<data name="TrackUrl" xml:space="preserve">
-		<value>Takip URL'si</value>
-	</data>
-	<data name="spotifySummary" xml:space="preserve">
-		<value>Bir kullanıcının Spotify durum bilgisini alır.</value>
-	</data>
-	<data name="spotifyParam1" xml:space="preserve">
-		<value>Kullanıcı, Spotify durum bilgisini alacak.</value>
-	</data>
-	<data name="wolframalphaSummary" xml:space="preserve">
-		<value>Wolfram | Alpha'dan sorguya göre bir yanıt alır,</value>
-	</data>
-	<data name="wolframalphaParam1" xml:space="preserve">
-		<value>Gönderilecek sorgu.</value>
-	</data>
-	<data name="defineSummary" xml:space="preserve">
-		<value>Bir kelimenin tanımlarını alır.</value>
-	</data>
-	<data name="defineParam1" xml:space="preserve">
-		<value>Слово для поиска.</value>
-	</data>
-	<data name="Word" xml:space="preserve">
-		<value>Kelime</value>
-	</data>
-	<data name="Definition" xml:space="preserve">
-		<value>Tanım</value>
-	</data>
-	<data name="Synonyms" xml:space="preserve">
-		<value>Eş anlamlı</value>
-	</data>
-	<data name="Antonyms" xml:space="preserve">
-		<value>Zıt anlamlılar</value>
-	</data>
-	<data name="archiveParam1" xml:space="preserve">
-		<value>Ekran görüntüsü almak için web sitesinin URL'si.</value>
-	</data>
-	<data name="archiveParam2" xml:space="preserve">
-		<value>Belirtilen biçime sahip bir zaman damgası.</value>
-	</data>
-	<data name="TimestampFormat" xml:space="preserve">
-		<value>Zaman damgası, `YYYYMMDDhhmmss` biçiminde olmalıdır; burada `YYYY` gereklidir ve diğer değerler isteğe bağlıdır.</value>
-	</data>
-	<data name="InvalidTimestamp" xml:space="preserve">
-		<value>Geçersiz zaman damgası.</value>
-	</data>
-	<data name="NoSnapshots" xml:space="preserve">
-		<value>Belirtilen url ve zaman damgası için anlık görüntü bulunamadı.</value>
-	</data>
-	<data name="archiveSummary" xml:space="preserve">
-		<value>Zaman damgası kullanarak belirtilen yıl / ay / gün içindeki bir web sitesinin ekran görüntüsünü alır.</value>
-	</data>
-	<data name="Timestamp" xml:space="preserve">
-		<value>Zaman damgası</value>
-	</data>
-	<data name="CouldNotFindLine" xml:space="preserve">
-		<value>Komut yöntemi satırı bulunamadı.</value>
-	</data>
-	<data name="shortenSummary" xml:space="preserve">
-		<value>Bir URL'yi kısaltır.</value>
-	</data>
-	<data name="shortenParam1" xml:space="preserve">
-		<value>Kısaltılacak URL.</value>
-	</data>
-	<data name="Badges" xml:space="preserve">
-		<value>Rozet</value>
-	</data>
-	<data name="img2Summary" xml:space="preserve">
-		<value>DuckDuckGo ile görüntüleri arar.</value>
-	</data>
-	<data name="img2Param1" xml:space="preserve">
-		<value>Aranacak anahtar kelimeler.</value>
-	</data>
-	<data name="privacySummary" xml:space="preserve">
-		<value>Gizlilik politikasını ve gizlilik yapılandırmasını görüntüler.</value>
-	</data>
-	<data name="PrivacyPolicy" xml:space="preserve">
-		<value>Gizlilik Politikası</value>
-	</data>
-	<data name="WhatDataWeCollect" xml:space="preserve">
-		<value>Hangi verileri topluyoruz</value>
-	</data>
-	<data name="WhatDataWeCollectList" xml:space="preserve">
-		<value>a. Sunucu yapılandırması (sunucu kimliği, özel önek, bot dili, devre dışı bırakılan komutlar vb.)
-b. Kullanıcı yapılandırması / istatistikleri (kullanıcı kimliği, Trivia oyun puanları, gizlilik yapılandırması ve kara liste durumu)
-c. AI Dungeon maceraları (macera kimliği, sahip kimliği ve kullanım durumu)
-d. "snipe" komutlarında kullanılan silinmiş / düzenlenmiş mesajların geçici koleksiyonu (`snipe`, `editsnipe`, `bigsnipe` ve `bigeditsnipe`)</value>
-	</data>
-	<data name="WhenWeCollectData" xml:space="preserve">
-		<value>Ne zaman toplayacağız</value>
-	</data>
-	<data name="WhenWeCollectDataList" xml:space="preserve">
-		<value>a. Özel bir önek, dil veya başka bir yapılandırma ayarladığınızda. Bot bu sunucudan çıktığında bu veriler kaldırılır.
-b. Bir Trivia oyunu oynadığınızda, gizlilik yapılandırmasını kullanırsınız veya kara listeye alınırsınız.
-c. Botla bir AI Dungeon macerası oluşturduğunuzda. `aid delete` komutunu kullandığınızda bu veriler kaldırılır.
-d. Bir mesaj silindiğinde / düzenlendiğinde. Bu mesajlar, sadece mesajın silindiği / düzenlendiği kanalda "snipe" komutları kullanılarak görülebilir.
-Bu mesajlar yalnızca 6 saat süreyle saklanır ve aşağıdaki reaksiyonları kullanarak vazgeçmeye karar verebilirsiniz.</value>
-	</data>
-	<data name="PrivacyConfig" xml:space="preserve">
-		<value>Gizlilik yapılandırması</value>
-	</data>
-	<data name="PrivacyConfigInfo" xml:space="preserve">
-		<value>Burada, gizliliğinizi iyileştirmek için değiştirebileceğiniz bazı ayarlar bulacaksınız.</value>
-	</data>
-	<data name="PrivacyConfigList" xml:space="preserve">
-		<value>"snipe" komutlarında silinen / düzenlenen mesajların geçici olarak toplanmasını devre dışı bırakın</value>
-	</data>
-	<data name="Privacy" xml:space="preserve">
-		<value>Gizlilik</value>
-	</data>
-	<data name="SnipePrivacy" xml:space="preserve">
-		<value>Mesajınızın burada görüntülenmesini istemiyor musunuz? Bkz. `privacy`.</value>
-	</data>
-	<data name="CannotUseThisInteraction" xml:space="preserve">
-		<value>Bu etkileşimi kullanamazsınız.</value>
-	</data>
-	<data name="DeletingAdventure" xml:space="preserve">
-		<value>Macera siliniyor...</value>
-	</data>
-	<data name="Donate" xml:space="preserve">
-		<value>bağış yap</value>
-	</data>
-	<data name="LanguageList" xml:space="preserve">
-		<value>Dil Listesi</value>
-	</data>
-	<data name="img3Summary" xml:space="preserve">
-		<value>Cesur ile görüntüleri arar.</value>
-	</data>
-	<data name="img3Param1" xml:space="preserve">
-		<value>Aranacak anahtar kelime(ler).</value>
-	</data>
-	<data name="Thread" xml:space="preserve">
-		<value>Konu</value>
-	</data>
-	<data name="AutoArchive" xml:space="preserve">
-		<value>Oto ArÅŸiv</value>
-	</data>
-	<data name="StageChannel" xml:space="preserve">
-		<value>Sahne Kanalı</value>
-	</data>
-	<data name="IsLive" xml:space="preserve">
-		<value>Sahne Kanalı</value>
-	</data>
-	<data name="Archived" xml:space="preserve">
-		<value>ArÅŸivlendi</value>
-	</data>
-</root>
\ No newline at end of file

From 5e2fc102d0cfd609b1332cab4eff785670cdc47a Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Fri, 25 Feb 2022 19:29:33 -0500
Subject: [PATCH 02/83] Add slash command  `badtranslator`

---
 src/Modules/MessageModule.cs |  81 +-------------------------
 src/Modules/UtilityModule.cs | 109 ++++++++++++++++++++++++++++++++++-
 2 files changed, 107 insertions(+), 83 deletions(-)

diff --git a/src/Modules/MessageModule.cs b/src/Modules/MessageModule.cs
index daab86b..6698328 100644
--- a/src/Modules/MessageModule.cs
+++ b/src/Modules/MessageModule.cs
@@ -97,84 +97,5 @@ public async Task TTS(IUserMessage message)
         }
     }
 
-    [MessageCommand("Bad Translator")]
-    public async Task BadTranslator(IUserMessage message)
-    {
-        string text = message.GetText();
-
-        if (string.IsNullOrWhiteSpace(text))
-        {
-            await Context.Interaction.RespondWarningAsync("The message must contain text.", true);
-            return;
-        }
-
-        await DeferAsync();
-
-        // Create an aggregated translator manually so we can randomize the initial order of the translators and shift them.
-        // Bing Translator is not included because it only allows max. 1000 chars per translation
-        var translators = new ITranslator[] { _googleTranslator, _googleTranslator2, _microsoftTranslator, _yandexTranslator };
-        translators.Shuffle();
-        var badTranslator = new AggregateTranslator(translators);
-
-        var languageChain = new List<ILanguage>();
-        const int chainCount = 8;
-        ILanguage? sourceLanguage = null;
-        for (int i = 0; i < chainCount; i++)
-        {
-            ILanguage targetLanguage;
-            if (i == chainCount - 1)
-            {
-                targetLanguage = sourceLanguage!;
-            }
-            else
-            {
-                // Get unique and random languages.
-                do
-                {
-                    targetLanguage = _lazyFilteredLanguages.Value[Random.Shared.Next(_lazyFilteredLanguages.Value.Length)];
-                } while (languageChain.Contains(targetLanguage));
-            }
-
-            // Shift the translators to avoid spamming them and get more variety
-            var last = translators[^1];
-            Array.Copy(translators, 0, translators, 1, translators.Length - 1);
-            translators[0] = last;
-
-            ITranslationResult result;
-            try
-            {
-                _logger.LogInformation("Translating to: {target}", targetLanguage.ISO6391);
-                result = await badTranslator.TranslateAsync(text, targetLanguage);
-            }
-            catch (Exception e) when (e is TranslatorException or HttpRequestException)
-            {
-                _logger.LogWarning(e, "Error translating");
-                await Context.Interaction.FollowupWarning(e.Message);
-                return;
-            }
-
-            if (i == 0)
-            {
-                sourceLanguage = result.SourceLanguage;
-                _logger.LogDebug("Badtranslator: Original language: {source}", sourceLanguage.ISO6391);
-                languageChain.Add(sourceLanguage);
-            }
-
-            _logger.LogDebug("Badtranslator: Translated from {source} to {target}, Service: {service}", result.SourceLanguage.ISO6391, result.TargetLanguage.ISO6391, result.Service);
-
-            text = result.Translation;
-            languageChain.Add(targetLanguage);
-        }
-
-        string embedText = $"**Language Chain**\n{string.Join(" -> ", languageChain.Select(x => x.ISO6391))}\n\n**Result**\n";
-
-        var embed = new EmbedBuilder()
-            .WithTitle("Bad translator")
-            .WithDescription($"{embedText}{text.Truncate(EmbedBuilder.MaxDescriptionLength - embedText.Length)}")
-            .WithThumbnailUrl(Constants.BadTranslatorLogoUrl)
-            .WithColor(Color.Orange)
-            .Build();
-
-        await FollowupAsync(embed: embed);
-    }
+    
 }
\ No newline at end of file
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 75cf774..318f312 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -27,23 +27,126 @@ public class UtilityModule : InteractionModuleBase<ShardedInteractionContext>
     private readonly ILogger<UtilityModule> _logger;
     private readonly InteractiveService _interactive;
     private readonly AggregateTranslator _translator;
+    private readonly GoogleTranslator _googleTranslator;
+    private readonly GoogleTranslator2 _googleTranslator2;
+    private readonly BingTranslator _bingTranslator;
+    private readonly MicrosoftTranslator _microsoftTranslator;
+    private readonly YandexTranslator _yandexTranslator;
     private readonly GoogleScraper _googleScraper;
     private readonly DuckDuckGoScraper _duckDuckGoScraper;
     private readonly BraveScraper _braveScraper;
     private readonly SearchClient _searchClient;
-
-    public UtilityModule(ILogger<UtilityModule> logger, InteractiveService interactive, AggregateTranslator translator, GoogleScraper googleScraper,
-        DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper, SearchClient searchClient)
+    private static readonly Lazy<Language[]> _lazyFilteredLanguages = new(() => Language.LanguageDictionary
+        .Values
+        .Where(x => x.SupportedServices == (TranslationServices.Google | TranslationServices.Bing | TranslationServices.Yandex | TranslationServices.Microsoft))
+        .ToArray());
+
+    public UtilityModule(ILogger<UtilityModule> logger, InteractiveService interactive, AggregateTranslator translator, GoogleTranslator googleTranslator,
+        GoogleTranslator2 googleTranslator2, BingTranslator bingTranslator, MicrosoftTranslator microsoftTranslator, YandexTranslator yandexTranslator,
+        GoogleScraper googleScraper, DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper, SearchClient searchClient)
     {
         _logger = logger;
         _interactive = interactive;
         _translator = translator;
+        _googleTranslator = googleTranslator;
+        _googleTranslator2 = googleTranslator2;
+        _bingTranslator = bingTranslator;
+        _microsoftTranslator = microsoftTranslator;
+        _yandexTranslator = yandexTranslator;
         _googleScraper = googleScraper;
         _duckDuckGoScraper = duckDuckGoScraper;
         _braveScraper = braveScraper;
         _searchClient = searchClient;
     }
 
+    [SlashCommand("badtranslator", "Passes a text through multiple, different translators.")]
+    public async Task BadTranslator([Summary(description: "The text to use.")] string text,
+        [Summary(description: "The amount of times to translate the text (2-10).")] [MinValue(2)] [MaxValue(10)] int chainCount = 8)
+    {
+        if (string.IsNullOrWhiteSpace(text))
+        {
+            await Context.Interaction.RespondWarningAsync("The message must contain text.", true);
+            return;
+        }
+
+        if (chainCount is < 2 or > 10)
+        {
+            await Context.Interaction.RespondWarningAsync("The chain count must be between 2 and 10 (inclusive).", true);
+            return;
+        }
+
+        await DeferAsync();
+
+        // Create an aggregated translator manually so we can randomize the initial order of the translators and shift them.
+        // Bing Translator is not included because it only allows max. 1000 chars per translation
+        var translators = new ITranslator[] { _googleTranslator, _googleTranslator2, _microsoftTranslator, _yandexTranslator };
+        translators.Shuffle();
+        var badTranslator = new AggregateTranslator(translators);
+
+        var languageChain = new List<ILanguage>(chainCount + 1);
+        ILanguage? source = null;
+        for (int i = 0; i < chainCount; i++)
+        {
+            ILanguage target;
+            if (i == chainCount - 1)
+            {
+                target = source!;
+            }
+            else
+            {
+                // Get unique and random languages.
+                do
+                {
+                    target = _lazyFilteredLanguages.Value[Random.Shared.Next(_lazyFilteredLanguages.Value.Length)];
+                } while (languageChain.Contains(target));
+            }
+
+            // Shift the translators to avoid spamming them and get more variety
+            var last = translators[^1];
+            Array.Copy(translators, 0, translators, 1, translators.Length - 1);
+            translators[0] = last;
+
+            ITranslationResult result;
+            try
+            {
+                _logger.LogInformation("Translating to: {target}", target.ISO6391);
+                result = await badTranslator.TranslateAsync(text, target);
+            }
+            catch (Exception e)
+            {
+                _logger.LogWarning(e, "Error translating text {text} ({source} -> {target})", text, source?.ISO6391 ?? "auto", target.ISO6391);
+                await Context.Interaction.FollowupWarning(e.Message);
+                return;
+            }
+
+            if (i == 0)
+            {
+                source = result.SourceLanguage;
+                _logger.LogDebug("Badtranslator: Original language: {source}", source.ISO6391);
+                languageChain.Add(source);
+            }
+
+            _logger.LogDebug("Badtranslator: Translated from {source} to {target}, Service: {service}", result.SourceLanguage.ISO6391, result.TargetLanguage.ISO6391, result.Service);
+
+            text = result.Translation;
+            languageChain.Add(target);
+        }
+
+        string embedText = $"**Language Chain**\n{string.Join(" -> ", languageChain.Select(x => x.ISO6391))}\n\n**Result**\n";
+
+        var embed = new EmbedBuilder()
+            .WithTitle("Bad translator")
+            .WithDescription($"{embedText}{text.Truncate(EmbedBuilder.MaxDescriptionLength - embedText.Length)}")
+            .WithThumbnailUrl(Constants.BadTranslatorLogoUrl)
+            .WithColor(Color.Orange)
+            .Build();
+
+        await FollowupAsync(embed: embed);
+    }
+
+    [MessageCommand("Bad Translator")]
+    public async Task BadTranslator(IUserMessage message) => await BadTranslator(message.GetText());
+
     [RequireOwner]
     [SlashCommand("cmd", "(Owner only) Executes a command.")]
     public async Task Cmd([Summary(description: "The command to execute")] string command, [Summary("noembed", "No embed.")] bool noEmbed = false)

From c415dd539833646c69730973a2294b7c529b072b Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 26 Feb 2022 00:48:22 -0500
Subject: [PATCH 03/83] Update dependencies

---
 src/Fergun.csproj | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/Fergun.csproj b/src/Fergun.csproj
index aeca764..2154640 100644
--- a/src/Fergun.csproj
+++ b/src/Fergun.csproj
@@ -13,16 +13,16 @@
     <PackageReference Include="Discord.Net.Interactions" Version="3.3.2" />
     <PackageReference Include="Fergun.Interactive" Version="1.4.1" />
     <PackageReference Include="GScraper" Version="1.0.2" />
-    <PackageReference Include="GTranslate" Version="2.0.0" />
+    <PackageReference Include="GTranslate" Version="2.0.1" />
     <PackageReference Include="Humanizer.Core" Version="2.14.1" />
-    <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.0" />
-    <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.1" />
+    <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
+    <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.2" />
     <PackageReference Include="Polly.Caching.Memory" Version="3.0.2" />
     <PackageReference Include="Serilog.Extensions.Hosting" Version="4.2.0" />
     <PackageReference Include="Serilog.Extensions.Logging.File" Version="2.0.0" />
     <PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
     <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
-    <PackageReference Include="YoutubeExplode" Version="6.0.7" />
+    <PackageReference Include="YoutubeExplode" Version="6.1.0" />
   </ItemGroup>
 
 </Project>

From 66c59ad9c13f411bf1c4addf06553f8d6e89d3e5 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 26 Feb 2022 19:45:39 -0500
Subject: [PATCH 04/83] Add tests

---
 .github/workflows/dotnet.yml                  |  25 ++
 Fergun.sln                                    |   8 +-
 src/Extensions/InteractionExtensions.cs       |   6 +-
 src/Extensions/MessageExtensions.cs           |   6 +-
 src/Extensions/TimestampExtensions.cs         |   6 -
 .../Handlers/GoogleAutocompleteHandler.cs     |   2 +-
 .../Handlers/YouTubeAutocompleteHandler.cs    |   2 +-
 src/Modules/MessageModule.cs                  |   2 +-
 src/Modules/UserModule.cs                     |   2 +-
 src/Modules/UtilityModule.cs                  |   4 +-
 tests/Fergun.Tests/ExtensionsTests.cs         | 259 ++++++++++++++++++
 tests/Fergun.Tests/Fergun.Tests.csproj        |  29 ++
 12 files changed, 332 insertions(+), 19 deletions(-)
 create mode 100644 .github/workflows/dotnet.yml
 create mode 100644 tests/Fergun.Tests/ExtensionsTests.cs
 create mode 100644 tests/Fergun.Tests/Fergun.Tests.csproj

diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
new file mode 100644
index 0000000..6e20ba0
--- /dev/null
+++ b/.github/workflows/dotnet.yml
@@ -0,0 +1,25 @@
+name: .NET
+
+on:
+  push:
+  pull_request:
+    branches:
+      - '*'
+
+jobs:
+  build:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v2
+    - name: Setup .NET
+      uses: actions/setup-dotnet@v1
+      with:
+        dotnet-version: 6.0.x
+    - name: Restore dependencies
+      run: dotnet restore
+    - name: Build
+      run: dotnet build --configuration Release --no-restore
+    - name: Test
+      run: dotnet test --no-restore --verbosity normal
diff --git a/Fergun.sln b/Fergun.sln
index 2192950..5a49e6d 100644
--- a/Fergun.sln
+++ b/Fergun.sln
@@ -3,7 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00
 # Visual Studio Version 17
 VisualStudioVersion = 17.1.31911.260
 MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fergun", "src\Fergun.csproj", "{2084CFF0-83BC-46FA-A969-479DE6282984}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Fergun", "src\Fergun.csproj", "{2084CFF0-83BC-46FA-A969-479DE6282984}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fergun.Tests", "tests\Fergun.Tests\Fergun.Tests.csproj", "{A80CD8CE-6020-47B7-B01D-827FB90C2F1C}"
 EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -15,6 +17,10 @@ Global
 		{2084CFF0-83BC-46FA-A969-479DE6282984}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{2084CFF0-83BC-46FA-A969-479DE6282984}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{2084CFF0-83BC-46FA-A969-479DE6282984}.Release|Any CPU.Build.0 = Release|Any CPU
+		{A80CD8CE-6020-47B7-B01D-827FB90C2F1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{A80CD8CE-6020-47B7-B01D-827FB90C2F1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{A80CD8CE-6020-47B7-B01D-827FB90C2F1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{A80CD8CE-6020-47B7-B01D-827FB90C2F1C}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
diff --git a/src/Extensions/InteractionExtensions.cs b/src/Extensions/InteractionExtensions.cs
index edc67e9..82ed490 100644
--- a/src/Extensions/InteractionExtensions.cs
+++ b/src/Extensions/InteractionExtensions.cs
@@ -26,7 +26,7 @@ public static async Task FollowupWarning(this IDiscordInteraction interaction, s
         await interaction.FollowupAsync(embed: embed, ephemeral: ephemeral);
     }
 
-    public static string GetTwoLetterLanguageCode(this IDiscordInteraction interaction, string defaultLanguage = "en")
+    public static string GetLanguageCode(this IDiscordInteraction interaction, string defaultLanguage = "en")
     {
         string language = interaction.UserLocale ?? interaction.GuildLocale;
         if (string.IsNullOrEmpty(language))
@@ -43,8 +43,8 @@ public static string GetTwoLetterLanguageCode(this IDiscordInteraction interacti
 
     public static bool TryGetLanguage(this IDiscordInteraction interaction, [MaybeNullWhen(false)] out Language language)
     {
-        string lang = interaction.GetTwoLetterLanguageCode();
-        return Language.TryGetLanguage(lang, out language);
+        return Language.TryGetLanguage(interaction.GetLocale(), out language) ||
+               Language.TryGetLanguage(interaction.GetLanguageCode(), out language);
     }
 
     public static string GetLocale(this IDiscordInteraction interaction, string defaultLocale = "en-US")
diff --git a/src/Extensions/MessageExtensions.cs b/src/Extensions/MessageExtensions.cs
index 6778155..2bea5ff 100644
--- a/src/Extensions/MessageExtensions.cs
+++ b/src/Extensions/MessageExtensions.cs
@@ -7,11 +7,11 @@ public static class MessageExtensions
 {
     public static string GetText(this IMessage message)
     {
-        var builder = new StringBuilder(message.Content.Length + 1);
-        builder.Append(message.Content);
-        builder.Append('\n');
+        var builder = new StringBuilder(message.Content, message.Content.Length);
+
         if (message.Embeds.Count > 0)
         {
+            builder.Append('\n');
             var embed = message.Embeds.First();
             builder.Append(embed.Author?.Name);
             builder.Append('\n');
diff --git a/src/Extensions/TimestampExtensions.cs b/src/Extensions/TimestampExtensions.cs
index ec0e1ee..36cdc5f 100644
--- a/src/Extensions/TimestampExtensions.cs
+++ b/src/Extensions/TimestampExtensions.cs
@@ -5,12 +5,6 @@ public static class TimestampExtensions
     public static string ToDiscordTimestamp(this DateTimeOffset dateTime, char style = 'f')
         => dateTime.ToUnixTimeSeconds().ToDiscordTimestamp(style);
 
-    public static string ToDiscordTimestamp(this DateTimeOffset? dateTime, char style = 'f')
-        => dateTime.GetValueOrDefault().ToDiscordTimestamp(style);
-
-    public static string ToDiscordTimestamp(this ulong timestamp, char style = 'f')
-        => ((long)timestamp).ToDiscordTimestamp(style);
-
     public static string ToDiscordTimestamp(this long timestamp, char style = 'f')
         => $"<t:{timestamp}:{style}>";
 }
\ No newline at end of file
diff --git a/src/Modules/Handlers/GoogleAutocompleteHandler.cs b/src/Modules/Handlers/GoogleAutocompleteHandler.cs
index bdaf60f..1973ab9 100644
--- a/src/Modules/Handlers/GoogleAutocompleteHandler.cs
+++ b/src/Modules/Handlers/GoogleAutocompleteHandler.cs
@@ -28,7 +28,7 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             .GetRequiredService<IReadOnlyPolicyRegistry<string>>()
             .Get<IAsyncPolicy<HttpResponseMessage>>("AutocompletePolicy");
 
-        string language = autocompleteInteraction.GetTwoLetterLanguageCode();
+        string language = autocompleteInteraction.GetLanguageCode();
 
         string url = $"https://www.google.com/complete/search?q={Uri.EscapeDataString(value)}&client=chrome&hl={language}&xhr=t";
         var response = await policy.ExecuteAsync(_ => client.GetAsync(new Uri(url)), new Context(url));
diff --git a/src/Modules/Handlers/YouTubeAutocompleteHandler.cs b/src/Modules/Handlers/YouTubeAutocompleteHandler.cs
index 57f0dfc..50e377f 100644
--- a/src/Modules/Handlers/YouTubeAutocompleteHandler.cs
+++ b/src/Modules/Handlers/YouTubeAutocompleteHandler.cs
@@ -28,7 +28,7 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             .GetRequiredService<IReadOnlyPolicyRegistry<string>>()
             .Get<IAsyncPolicy<HttpResponseMessage>>("AutocompletePolicy");
 
-        string language = autocompleteInteraction.GetTwoLetterLanguageCode();
+        string language = autocompleteInteraction.GetLanguageCode();
         string url = $"https://suggestqueries-clients6.youtube.com/complete/search?client=youtube&hl={language}&gs_ri=youtube&ds=yt&q={Uri.EscapeDataString(value)}&xhr=t";
 
         var response = await policy.ExecuteAsync(_ => client.GetAsync(new Uri(url)), new Context(url));
diff --git a/src/Modules/MessageModule.cs b/src/Modules/MessageModule.cs
index 6698328..0502d89 100644
--- a/src/Modules/MessageModule.cs
+++ b/src/Modules/MessageModule.cs
@@ -70,7 +70,7 @@ public async Task TTS(IUserMessage message)
             return;
         }
 
-        string target = Context.Interaction.GetTwoLetterLanguageCode();
+        string target = Context.Interaction.GetLanguageCode();
 
         if (!Language.TryGetLanguage(target, out var language) || !GoogleTranslator2.TextToSpeechLanguages.Contains(language))
         {
diff --git a/src/Modules/UserModule.cs b/src/Modules/UserModule.cs
index 5d1065e..9c89dd2 100644
--- a/src/Modules/UserModule.cs
+++ b/src/Modules/UserModule.cs
@@ -72,6 +72,6 @@ public async Task UserInfo(IUser user)
         await RespondAsync(embed: builder.Build());
 
         static string GetTimestamp(DateTimeOffset? dateTime)
-            => dateTime == null ? "N/A" : $"{dateTime.ToDiscordTimestamp()} ({dateTime.ToDiscordTimestamp('R')})";
+            => dateTime == null ? "N/A" : $"{dateTime.Value.ToDiscordTimestamp()} ({dateTime.Value.ToDiscordTimestamp('R')})";
     }
 }
\ No newline at end of file
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 318f312..ef3b1d3 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -232,7 +232,7 @@ public async Task Img([Autocomplete(typeof(GoogleAutocompleteHandler))] [Summary
         bool isNsfw = Context.Channel.IsNsfw();
         _logger.LogInformation(new EventId(0, "img"), "Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
 
-        var images = await _googleScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict, language: Context.Interaction.GetTwoLetterLanguageCode());
+        var images = await _googleScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict, language: Context.Interaction.GetLanguageCode());
 
         var filteredImages = images
             .Where(x => x.Url.StartsWith("http") && x.SourceUrl.StartsWith("http"))
@@ -581,7 +581,7 @@ public async Task Translate([Summary(description: "The text to translate")] stri
 
     [MessageCommand("Translate")]
     public async Task Translate(IUserMessage message)
-        => await Translate(message.GetText(), Context.Interaction.GetTwoLetterLanguageCode());
+        => await Translate(message.GetText(), Context.Interaction.GetLanguageCode());
 
     [SlashCommand("youtube", "Sends a paginator containing YouTube videos.")]
     public async Task YouTube([Autocomplete(typeof(YouTubeAutocompleteHandler))] [Summary(description: "The query.")] string query)
diff --git a/tests/Fergun.Tests/ExtensionsTests.cs b/tests/Fergun.Tests/ExtensionsTests.cs
new file mode 100644
index 0000000..e8b0047
--- /dev/null
+++ b/tests/Fergun.Tests/ExtensionsTests.cs
@@ -0,0 +1,259 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Bogus;
+using Discord;
+using Fergun.Extensions;
+using GTranslate;
+using Moq;
+using Xunit;
+
+namespace Fergun.Tests;
+
+public class ExtensionsTests
+{
+    // Channel extensions
+    [Fact]
+    public void IMessageChannel_IsNsfw_Should_Return_True_When_Channel_Is_NSFW()
+    {
+        var channelMock1 = new Mock<ITextChannel>();
+        channelMock1.SetupGet(x => x.IsNsfw).Returns(true);
+
+        var channelMock2 = new Mock<ITextChannel>();
+        channelMock2.SetupGet(x => x.IsNsfw).Returns(false);
+
+        var channelMock3 = new Mock<IDMChannel>();
+
+        Assert.True(channelMock1.Object.IsNsfw());
+        Assert.False(channelMock2.Object.IsNsfw());
+        Assert.False(channelMock3.Object.IsNsfw());
+    }
+
+    [Fact]
+    public void IChannel_IsPrivate_Should_Return_True_When_Channel_Is_IPrivateChannel()
+    {
+        var channelMock1 = new Mock<IChannel>();
+        var channelMock2 = new Mock<ITextChannel>();
+        var channelMock3 = new Mock<IDMChannel>();
+
+        Assert.False(channelMock1.Object.IsPrivate());
+        Assert.False(channelMock2.Object.IsPrivate());
+        Assert.True(channelMock3.Object.IsPrivate());
+    }
+
+    // Interaction extensions
+    [Fact]
+    public async Task Interaction_RespondWarningAsync_Should_Call_RespondAsync_Once()
+    {
+        var interactionMock = new Mock<IDiscordInteraction>();
+        await interactionMock.Object.RespondWarningAsync(It.IsAny<string>(), It.IsAny<bool>());
+
+        interactionMock.Verify(x => x.RespondAsync(It.IsAny<string>(), It.IsAny<Embed[]>(),
+            It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
+    }
+
+    [Fact]
+    public async Task Interaction_FollowupWarningAsync_Should_Call_FollowupAsync_Once()
+    {
+        var interactionMock = new Mock<IDiscordInteraction>();
+        await interactionMock.Object.FollowupWarning(It.IsAny<string>(), It.IsAny<bool>());
+
+        interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(),
+            It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
+    }
+
+    [Theory]
+    [MemberData(nameof(GetLocales))]
+    [MemberData(nameof(GetRandomStrings))]
+    public void Interaction_GetLanguageCode_Should_Return_Code(string locale)
+    {
+        string expected = locale.Contains('-') ? locale.Split('-')[0] : locale;
+
+        var interactionMock = new Mock<IDiscordInteraction>();
+        interactionMock.SetupGet(x => x.UserLocale).Returns(locale);
+
+        string language = interactionMock.Object.GetLanguageCode();
+        Assert.Equal(expected, language);
+    }
+
+    [Theory]
+    [InlineData("", "en")]
+    [InlineData(null, "es")]
+    public void Interaction_GetLanguageCode_Should_Return_Default(string locale, string defaultLanguage)
+    {
+        var interactionMock = new Mock<IDiscordInteraction>();
+        interactionMock.SetupGet(x => x.UserLocale).Returns(locale);
+        var interactionMock2 = new Mock<IDiscordInteraction>();
+        interactionMock2.SetupGet(x => x.UserLocale).Returns((string)null!);
+        interactionMock2.SetupGet(x => x.GuildLocale).Returns(locale);
+
+        string language = interactionMock.Object.GetLanguageCode(defaultLanguage);
+        string language2 = interactionMock2.Object.GetLanguageCode(defaultLanguage);
+
+        interactionMock.VerifyGet(x => x.UserLocale, Times.Once);
+        interactionMock2.VerifyGet(x => x.UserLocale, Times.Once);
+        interactionMock2.VerifyGet(x => x.GuildLocale, Times.Once);
+        Assert.Equal(defaultLanguage, language);
+        Assert.Equal(defaultLanguage, language2);
+    }
+
+    [Theory]
+    [MemberData(nameof(GetLanguages))]
+    public void Interaction_TryGetLanguage_Should_Return_True_If_Valid(string language)
+    {
+        var interactionMock = new Mock<IDiscordInteraction>();
+        if (Random.Shared.Next(2) == 0)
+        {
+            interactionMock.SetupGet(x => x.UserLocale).Returns(language);
+        }
+        else
+        {
+            interactionMock.SetupGet(x => x.GuildLocale).Returns(language);
+        }
+
+        bool success = interactionMock.Object.TryGetLanguage(out _);
+
+        Assert.True(success);
+    }
+
+    // Message extensions
+    [Theory]
+    [MemberData(nameof(GetRandomStrings))]
+    public void IMessage_GetText_Should_Return_Text_From_Content(string content)
+    {
+        var messageMock = new Mock<IMessage>();
+        messageMock.SetupGet(x => x.Content).Returns(content);
+        messageMock.SetupGet(x => x.Embeds).Returns(Array.Empty<IEmbed>());
+
+        string text = messageMock.Object.GetText();
+
+        messageMock.VerifyGet(x => x.Content);
+        Assert.Equal(content, text);
+    }
+
+    [Theory]
+    [MemberData(nameof(GetContentsAndEmbeds))]
+    public void IMessage_GetText_Should_Return_Text_From_Content_And_Embed(string content, Embed embed)
+    {
+        var messageMock = new Mock<IMessage>();
+        messageMock.SetupGet(x => x.Content).Returns(content);
+        messageMock.SetupGet(x => x.Embeds).Returns(new[] { embed });
+
+        string[] parts = messageMock.Object.GetText().Split('\n', StringSplitOptions.RemoveEmptyEntries);
+
+        messageMock.VerifyGet(x => x.Content);
+        messageMock.VerifyGet(x => x.Embeds);
+
+        int index = 0;
+        Assert.True(parts.Length >= 3);
+        Assert.Equal(content, parts[index++]);
+        if (embed.Author is not null)
+        {
+            Assert.Equal(embed.Author.Value.Name, parts[index++]);
+        }
+        Assert.Equal(embed.Title, parts[index++]);
+        Assert.Equal(embed.Description, parts[index]);
+        if (embed.Footer is not null)
+        {
+            Assert.Equal(embed.Footer.Value.Text, parts[^1]);
+        }
+    }
+
+    // String extensions
+    [Theory]
+    [InlineData("one two three", "one", "three")]
+    [InlineData("1234", "123", "456")]
+    [InlineData("1234", "012", "234")]
+    [InlineData("abcde", "efg", "hij")]
+    public void String_ContainsAny_Should_Return_Expected(string str, string str0, string str1)
+    {
+        bool containsFirst = str.Contains(str0);
+        bool containsSecond = str.Contains(str1);
+
+        bool containsAny = str.ContainsAny(str0, str1);
+
+        Assert.Equal(containsAny, containsFirst || containsSecond);
+    }
+
+    // Timestamp extensions
+    [Theory]
+    [MemberData(nameof(GetDatesAndStyles))]
+    public void DateTimeOffset_ToDiscordTimestamp_Should_Return_Expected(DateTimeOffset dateTimeOffset, char style)
+    {
+        var unixSeconds = dateTimeOffset.ToUnixTimeSeconds();
+
+        string timestamp = dateTimeOffset.ToDiscordTimestamp(style);
+
+        Assert.Equal(timestamp, $"<t:{unixSeconds}:{style}>");
+    }
+
+    private static IEnumerable<object[]> GetLocales()
+    {
+        var faker = new Faker();
+
+        return faker.MakeLazy(10, () => faker.Random.RandomLocale().Replace('_', '-'))
+            .Select(x => new object[] { x });
+    }
+
+    private static IEnumerable<object[]> GetRandomStrings()
+    {
+        var faker = new Faker();
+
+        return faker.MakeLazy(10, () => faker.Random.String2(2))
+            .Select(x => new object[] { x });
+    }
+
+    private static IEnumerable<object?[]> GetLanguages()
+    {
+        var faker = new Faker();
+
+        return Language.LanguageDictionary.Values
+            .Select(x => x.ISO6391.Contains('-') ? x.ISO6391 : $"{x.ISO6391}-{faker.Random.String2(2)}")
+            .Append(null)
+            .Select(x => new object?[] { x });
+    }
+
+    private static IEnumerable<object[]> GetEmbeds()
+    {
+        var embedFaker = new Faker<EmbedBuilder>()
+            .StrictMode(false)
+            .RuleFor(x => x.Author, f => new EmbedAuthorBuilder
+            {
+                IconUrl = f.Internet.Avatar(),
+                Name = f.Internet.UserName(),
+                Url = f.Internet.Url()
+            }.OrNull(f))
+            .RuleFor(x => x.Title, f => f.Lorem.Sentence())
+            .RuleFor(x => x.Description, f => f.Lorem.Paragraph())
+            .RuleFor(x => x.Fields, f =>
+            {
+                return f.MakeLazy(f.Random.Number(1, 10), () => new EmbedFieldBuilder
+                {
+                    IsInline = f.Random.Bool(),
+                    Name = f.Random.Word(),
+                    Value = f.Random.Word()
+                }).Append(new EmbedFieldBuilder { Name = "\u200b", Value = "\u200b" }).ToList();
+            })
+            .RuleFor(x => x.Footer, f => new EmbedFooterBuilder
+            {
+                IconUrl = f.Image.PlaceImgUrl(),
+                Text = f.Commerce.ProductName()
+            }.OrNull(f))
+            .RuleFor(x => x.Color, f => new Color(f.Random.UInt(0, 0xFFFFFFU)));
+
+        return embedFaker.GenerateLazy(10).Select(x => new object[] { x.Build() });
+    }
+
+    private static IEnumerable<object[]> GetContentsAndEmbeds()
+    {
+        return GetRandomStrings().Zip(GetEmbeds()).Select(x => new[] { x.First[0], x.Second[0] });
+    }
+
+    private static IEnumerable<object[]> GetDatesAndStyles()
+    {
+        var faker = new Faker();
+        return faker.MakeLazy(10, () => (faker.Date.BetweenOffset(DateTimeOffset.MinValue, DateTimeOffset.MaxValue), faker.Random.Char()))
+            .Select(x => new object[] { x.Item1, x.Item2 });
+    }
+}
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Fergun.Tests.csproj b/tests/Fergun.Tests/Fergun.Tests.csproj
new file mode 100644
index 0000000..89b7fbf
--- /dev/null
+++ b/tests/Fergun.Tests/Fergun.Tests.csproj
@@ -0,0 +1,29 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>net6.0</TargetFramework>
+    <Nullable>enable</Nullable>
+
+    <IsPackable>false</IsPackable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Bogus" Version="34.0.1" />
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
+    <PackageReference Include="Moq" Version="4.17.1" />
+    <PackageReference Include="xunit" Version="2.4.1" />
+    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
+      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+      <PrivateAssets>all</PrivateAssets>
+    </PackageReference>
+    <PackageReference Include="coverlet.collector" Version="3.1.2">
+      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+      <PrivateAssets>all</PrivateAssets>
+    </PackageReference>
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\..\src\Fergun.csproj" />
+  </ItemGroup>
+
+</Project>

From 4ea83546d0a4017ff5cc74c58de8ac00f62e7be5 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 27 Feb 2022 21:29:36 -0500
Subject: [PATCH 05/83] Add more tests

---
 src/Extensions/Extensions.cs                  | 26 ++++++
 .../Handlers/BraveAutocompleteHandler.cs      | 26 ++++--
 .../Handlers/DuckDuckGoAutocompleteHandler.cs | 30 ++++---
 .../Handlers/GoogleAutocompleteHandler.cs     | 29 ++++---
 .../Handlers/TranslateAutocompleteHandler.cs  |  8 +-
 .../Handlers/YouTubeAutocompleteHandler.cs    | 28 ++++--
 src/Modules/UserModule.cs                     |  6 +-
 src/Program.cs                                | 26 +-----
 .../Fergun.Tests/AutocompleteHandlerTests.cs  | 87 +++++++++++++++++++
 9 files changed, 197 insertions(+), 69 deletions(-)
 create mode 100644 tests/Fergun.Tests/AutocompleteHandlerTests.cs

diff --git a/src/Extensions/Extensions.cs b/src/Extensions/Extensions.cs
index ebb1858..e1cc233 100644
--- a/src/Extensions/Extensions.cs
+++ b/src/Extensions/Extensions.cs
@@ -3,7 +3,10 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Polly;
+using Polly.Caching;
+using Polly.Caching.Memory;
 using Polly.Extensions.Http;
+using Polly.Registry;
 
 namespace Fergun.Extensions;
 
@@ -13,6 +16,29 @@ public static IHttpClientBuilder AddRetryPolicy(this IHttpClientBuilder builder)
         => builder.AddTransientHttpErrorPolicy(policyBuilder
             => policyBuilder.OrTransientHttpStatusCode().WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))));
 
+    public static IServiceCollection AddFergunPolicies(this IServiceCollection services)
+    {
+        return services.AddMemoryCache()
+            .AddSingleton<IAsyncCacheProvider, MemoryCacheProvider>()
+            .AddSingleton<IReadOnlyPolicyRegistry<string>, PolicyRegistry>(provider =>
+            {
+                var cacheProvider = provider.GetRequiredService<IAsyncCacheProvider>().AsyncFor<HttpResponseMessage>();
+                var cachePolicy = Policy.CacheAsync(cacheProvider, new SlidingTtl(TimeSpan.FromHours(2)));
+
+                var retryPolicy = HttpPolicyExtensions.HandleTransientHttpError()
+                    .OrTransientHttpStatusCode()
+                    .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
+
+                var timeoutPolicy = Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(3));
+
+                return new PolicyRegistry
+                {
+                    { "GeneralPolicy", Policy.WrapAsync(cachePolicy, retryPolicy) },
+                    { "AutocompletePolicy", Policy.WrapAsync(cachePolicy, retryPolicy, timeoutPolicy) }
+                };
+            });
+    }
+
     public static LogLevel ToLogLevel(this LogSeverity logSeverity)
         => logSeverity switch
         {
diff --git a/src/Modules/Handlers/BraveAutocompleteHandler.cs b/src/Modules/Handlers/BraveAutocompleteHandler.cs
index 0e239c3..61d885e 100644
--- a/src/Modules/Handlers/BraveAutocompleteHandler.cs
+++ b/src/Modules/Handlers/BraveAutocompleteHandler.cs
@@ -13,11 +13,22 @@ public class BraveAutocompleteHandler : AutocompleteHandler
     public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context,
         IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
     {
-        var value = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
+        var text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
 
-        if (string.IsNullOrEmpty(value))
+        if (string.IsNullOrEmpty(text))
             return AutocompletionResult.FromSuccess();
 
+        var suggestions = await GetBraveSuggestionsAsync(text, services);
+
+        var results = suggestions
+            .Select(x => new AutocompleteResult(x, x))
+            .Take(25);
+
+        return AutocompletionResult.FromSuccess(results);
+    }
+
+    public static async Task<string?[]> GetBraveSuggestionsAsync(string text, IServiceProvider services)
+    {
         var client = services
             .GetRequiredService<IHttpClientFactory>()
             .CreateClient("autocomplete");
@@ -26,19 +37,18 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             .GetRequiredService<IReadOnlyPolicyRegistry<string>>()
             .Get<IAsyncPolicy<HttpResponseMessage>>("AutocompletePolicy");
 
-        string url = $"https://search.brave.com/api/suggest?q={Uri.EscapeDataString(value)}&source=web";
+        string url = $"https://search.brave.com/api/suggest?q={Uri.EscapeDataString(text)}&source=web";
 
         var response = await policy.ExecuteAsync(_ => client.GetAsync(new Uri(url)), new Context(url));
+
         var bytes = await response.Content.ReadAsByteArrayAsync();
 
         using var document = JsonDocument.Parse(bytes);
 
-        var results = document
+        return document
             .RootElement[1]
             .EnumerateArray()
-            .Select(x => new AutocompleteResult(x.GetString(), x.GetString()))
-            .Take(25);
-
-        return AutocompletionResult.FromSuccess(results);
+            .Select(x => x.GetString())
+            .ToArray();
     }
 }
\ No newline at end of file
diff --git a/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs b/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs
index 55b2b65..7936857 100644
--- a/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs
+++ b/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs
@@ -14,11 +14,25 @@ public class DuckDuckGoAutocompleteHandler : AutocompleteHandler
     public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context,
         IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
     {
-        var value = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
+        var text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
 
-        if (string.IsNullOrEmpty(value))
+        if (string.IsNullOrEmpty(text))
             return AutocompletionResult.FromSuccess();
 
+        string locale = autocompleteInteraction.GetLocale("wt-wt").ToLowerInvariant();
+        bool isNsfw = context.Channel.IsNsfw();
+
+        var suggestions = await GetDuckDuckGoSuggestionsAsync(text, services, locale, isNsfw);
+
+        var results = suggestions
+            .Select(x => new AutocompleteResult(x, x))
+            .Take(25);
+
+        return AutocompletionResult.FromSuccess(results);
+    }
+
+    public static async Task<string?[]> GetDuckDuckGoSuggestionsAsync(string text, IServiceProvider services, string locale = "wt-wt", bool isNsfw = false)
+    {
         var client = services
             .GetRequiredService<IHttpClientFactory>()
             .CreateClient("autocomplete");
@@ -27,23 +41,19 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             .GetRequiredService<IReadOnlyPolicyRegistry<string>>()
             .Get<IAsyncPolicy<HttpResponseMessage>>("AutocompletePolicy");
 
-        bool isNsfw = context.Channel.IsNsfw();
         client.DefaultRequestHeaders.TryAddWithoutValidation("cookie", $"p={(isNsfw ? -2 : 1)}");
 
-        string locale = autocompleteInteraction.GetLocale("wt-wt").ToLowerInvariant();
-        string url = $"https://duckduckgo.com/ac/?q={Uri.EscapeDataString(value)}&kl={locale}";
+        string url = $"https://duckduckgo.com/ac/?q={Uri.EscapeDataString(text)}&kl={locale}";
 
         var response = await policy.ExecuteAsync(_ => client.GetAsync(new Uri(url)), new Context($"{url}-nsfw:{isNsfw}"));
         var bytes = await response.Content.ReadAsByteArrayAsync();
 
         using var document = JsonDocument.Parse(bytes);
 
-        var results = document
+        return document
             .RootElement
             .EnumerateArray()
-            .Select(x => new AutocompleteResult(x.GetProperty("phrase").GetString(), x.GetProperty("phrase").GetString()))
-            .Take(25);
-
-        return AutocompletionResult.FromSuccess(results);
+            .Select(x => x.GetProperty("phrase").GetString())
+            .ToArray();
     }
 }
\ No newline at end of file
diff --git a/src/Modules/Handlers/GoogleAutocompleteHandler.cs b/src/Modules/Handlers/GoogleAutocompleteHandler.cs
index 1973ab9..f06461e 100644
--- a/src/Modules/Handlers/GoogleAutocompleteHandler.cs
+++ b/src/Modules/Handlers/GoogleAutocompleteHandler.cs
@@ -15,11 +15,24 @@ public class GoogleAutocompleteHandler : AutocompleteHandler
     public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context,
         IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
     {
-        var value = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim().Truncate(100, string.Empty);
+        var text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim().Truncate(100, string.Empty);
 
-        if (string.IsNullOrEmpty(value))
+        if (string.IsNullOrEmpty(text))
             return AutocompletionResult.FromSuccess();
 
+        string language = autocompleteInteraction.GetLanguageCode();
+
+        var suggestions = await GetGoogleSuggestionsAsync(text, services, language);
+
+        var results = suggestions
+            .Select(x => new AutocompleteResult(x, x))
+            .Take(25);
+
+        return AutocompletionResult.FromSuccess(results);
+    }
+
+    public static async Task<string?[]> GetGoogleSuggestionsAsync(string text, IServiceProvider services, string language = "en")
+    {
         var client = services
             .GetRequiredService<IHttpClientFactory>()
             .CreateClient("autocomplete");
@@ -28,20 +41,16 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             .GetRequiredService<IReadOnlyPolicyRegistry<string>>()
             .Get<IAsyncPolicy<HttpResponseMessage>>("AutocompletePolicy");
 
-        string language = autocompleteInteraction.GetLanguageCode();
-
-        string url = $"https://www.google.com/complete/search?q={Uri.EscapeDataString(value)}&client=chrome&hl={language}&xhr=t";
+        string url = $"https://www.google.com/complete/search?q={Uri.EscapeDataString(text)}&client=chrome&hl={language}&xhr=t";
         var response = await policy.ExecuteAsync(_ => client.GetAsync(new Uri(url)), new Context(url));
         var bytes = await response.Content.ReadAsByteArrayAsync();
 
         using var document = JsonDocument.Parse(bytes);
 
-        var results = document
+        return document
             .RootElement[1]
             .EnumerateArray()
-            .Select(x => new AutocompleteResult(x.GetString(), x.GetString()))
-            .Take(25);
-
-        return AutocompletionResult.FromSuccess(results);
+            .Select(x => x.GetString())
+            .ToArray();
     }
 }
\ No newline at end of file
diff --git a/src/Modules/Handlers/TranslateAutocompleteHandler.cs b/src/Modules/Handlers/TranslateAutocompleteHandler.cs
index 308ca31..a3f615f 100644
--- a/src/Modules/Handlers/TranslateAutocompleteHandler.cs
+++ b/src/Modules/Handlers/TranslateAutocompleteHandler.cs
@@ -11,14 +11,14 @@ public class TranslateAutocompleteHandler : AutocompleteHandler
     public override Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context,
         IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
     {
-        var value = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
+        var text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
 
         IEnumerable<Language> languages = Language
             .LanguageDictionary
             .Values
-            .Where(x => x.Name.StartsWith(value, StringComparison.OrdinalIgnoreCase) ||
-                        x.ISO6391.StartsWith(value, StringComparison.OrdinalIgnoreCase) ||
-                        x.ISO6393.StartsWith(value, StringComparison.OrdinalIgnoreCase))
+            .Where(x => x.Name.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
+                        x.ISO6391.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
+                        x.ISO6393.StartsWith(text, StringComparison.OrdinalIgnoreCase))
             .OrderBy(x => x.Name);
 
         if (parameter.Name == "source")
diff --git a/src/Modules/Handlers/YouTubeAutocompleteHandler.cs b/src/Modules/Handlers/YouTubeAutocompleteHandler.cs
index 50e377f..43841b2 100644
--- a/src/Modules/Handlers/YouTubeAutocompleteHandler.cs
+++ b/src/Modules/Handlers/YouTubeAutocompleteHandler.cs
@@ -15,11 +15,24 @@ public class YouTubeAutocompleteHandler : AutocompleteHandler
     public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context,
         IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
     {
-        var value = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim().Truncate(100, string.Empty);
+        var text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim().Truncate(100, string.Empty);
 
-        if (string.IsNullOrEmpty(value))
+        if (string.IsNullOrEmpty(text))
             return AutocompletionResult.FromSuccess();
 
+        string language = autocompleteInteraction.GetLanguageCode();
+
+        var suggestions = await GetYouTubeSuggestionsAsync(text, services, language);
+
+        var results = suggestions
+            .Select(x => new AutocompleteResult(x, x))
+            .Take(25);
+
+        return AutocompletionResult.FromSuccess(results);
+    }
+
+    public static async Task<string?[]> GetYouTubeSuggestionsAsync(string text, IServiceProvider services, string language = "en")
+    {
         var client = services
             .GetRequiredService<IHttpClientFactory>()
             .CreateClient("autocomplete");
@@ -28,20 +41,17 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             .GetRequiredService<IReadOnlyPolicyRegistry<string>>()
             .Get<IAsyncPolicy<HttpResponseMessage>>("AutocompletePolicy");
 
-        string language = autocompleteInteraction.GetLanguageCode();
-        string url = $"https://suggestqueries-clients6.youtube.com/complete/search?client=youtube&hl={language}&gs_ri=youtube&ds=yt&q={Uri.EscapeDataString(value)}&xhr=t";
+        string url = $"https://suggestqueries-clients6.youtube.com/complete/search?client=youtube&hl={language}&gs_ri=youtube&ds=yt&q={Uri.EscapeDataString(text)}&xhr=t";
 
         var response = await policy.ExecuteAsync(_ => client.GetAsync(new Uri(url)), new Context(url));
         var bytes = await response.Content.ReadAsByteArrayAsync();
 
         using var document = JsonDocument.Parse(bytes);
 
-        var results = document
+        return document
             .RootElement[1]
             .EnumerateArray()
-            .Select(x => new AutocompleteResult(x[0].GetString(), x[0].GetString()))
-            .Take(25);
-
-        return AutocompletionResult.FromSuccess(results);
+            .Select(x => x[0].GetString())
+            .ToArray();
     }
 }
\ No newline at end of file
diff --git a/src/Modules/UserModule.cs b/src/Modules/UserModule.cs
index 9c89dd2..c33ab82 100644
--- a/src/Modules/UserModule.cs
+++ b/src/Modules/UserModule.cs
@@ -4,10 +4,10 @@
 
 namespace Fergun.Modules;
 
-public class UserModule : InteractionModuleBase<ShardedInteractionContext>
+public class UserModule : InteractionModuleBase<IInteractionContext>
 {
     [UserCommand("Avatar")]
-    public async Task GetAvatar(IUser user)
+    public async Task Avatar(IUser user)
     {
         string url = (user as IGuildUser)?.GetGuildAvatarUrl(size: 2048) ?? user.GetAvatarUrl(size: 2048) ?? user.GetDefaultAvatarUrl();
 
@@ -62,7 +62,7 @@ public async Task UserInfo(IUser user)
             .AddField("ID", user.Id)
             .AddField("Activity", activities, true)
             .AddField("Active Clients", clients, true)
-            .AddField("IsBot", user.IsBot)
+            .AddField("Is Bot", user.IsBot)
             .AddField("Created At", GetTimestamp(user.CreatedAt))
             .AddField("Guild Join Date", GetTimestamp(guildUser?.JoinedAt))
             .AddField("Boosting Since", GetTimestamp(guildUser?.PremiumSince))
diff --git a/src/Program.cs b/src/Program.cs
index 70db21f..4394120 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -14,11 +14,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Logging;
-using Polly;
-using Polly.Caching;
-using Polly.Caching.Memory;
-using Polly.Extensions.Http;
-using Polly.Registry;
 using Serilog;
 using Serilog.Events;
 using Serilog.Filters;
@@ -58,26 +53,7 @@ await Host.CreateDefaultBuilder()
     {
         services.AddHostedService<InteractionHandlingService>();
         services.AddSingleton<InteractiveService>();
-
-        services.AddMemoryCache();
-        services.AddSingleton<IAsyncCacheProvider, MemoryCacheProvider>();
-        services.AddSingleton<IReadOnlyPolicyRegistry<string>, PolicyRegistry>(provider =>
-        {
-            var cacheProvider = provider.GetRequiredService<IAsyncCacheProvider>().AsyncFor<HttpResponseMessage>();
-            var cachePolicy = Policy.CacheAsync(cacheProvider, new SlidingTtl(TimeSpan.FromHours(2)));
-
-            var retryPolicy = HttpPolicyExtensions.HandleTransientHttpError()
-                .OrTransientHttpStatusCode()
-                .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
-
-            var timeoutPolicy = Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(3));
-
-            return new PolicyRegistry
-            {
-                { "GeneralPolicy", Policy.WrapAsync(cachePolicy, retryPolicy) },
-                { "AutocompletePolicy", Policy.WrapAsync(cachePolicy, retryPolicy, timeoutPolicy) }
-            };
-        });
+        services.AddFergunPolicies();
 
         services.AddHttpClient<GoogleTranslator>()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
diff --git a/tests/Fergun.Tests/AutocompleteHandlerTests.cs b/tests/Fergun.Tests/AutocompleteHandlerTests.cs
new file mode 100644
index 0000000..2247679
--- /dev/null
+++ b/tests/Fergun.Tests/AutocompleteHandlerTests.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Bogus;
+using Fergun.Extensions;
+using Fergun.Modules.Handlers;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Fergun.Tests;
+
+public class AutocompleteHandlerTests
+{
+    [Theory]
+    [MemberData(nameof(GetTestData))]
+    public async Task BraveAutocomplete_Should_Return_Valid_Suggestions(string text)
+    {
+        var services = GetServiceProvider();
+
+        var results = await BraveAutocompleteHandler.GetBraveSuggestionsAsync(text, services);
+
+        Assert.NotNull(results);
+        Assert.NotEmpty(results);
+        Assert.All(results, Assert.NotNull);
+        Assert.All(results, Assert.NotEmpty);
+    }
+
+    [Theory]
+    [MemberData(nameof(GetTestData))]
+    public async Task DuckDuckGoAutocomplete_Should_Return_Valid_Suggestions(string text)
+    {
+        var services = GetServiceProvider();
+
+        var results = await DuckDuckGoAutocompleteHandler.GetDuckDuckGoSuggestionsAsync(text, services);
+
+        Assert.NotNull(results);
+        Assert.NotEmpty(results);
+        Assert.All(results, Assert.NotNull);
+        Assert.All(results, Assert.NotEmpty);
+    }
+
+    [Theory]
+    [MemberData(nameof(GetTestData))]
+    public async Task GoogleAutocomplete_Should_Return_Valid_Suggestions(string text)
+    {
+        var services = GetServiceProvider();
+
+        var results = await GoogleAutocompleteHandler.GetGoogleSuggestionsAsync(text, services);
+
+        Assert.NotNull(results);
+        Assert.NotEmpty(results);
+        Assert.All(results, Assert.NotNull);
+        Assert.All(results, Assert.NotEmpty);
+    }
+
+    [Theory]
+    [MemberData(nameof(GetTestData))]
+    public async Task YouTubeAutocomplete_Should_Return_Valid_Suggestions(string text)
+    {
+        var services = GetServiceProvider();
+
+        var results = await YouTubeAutocompleteHandler.GetYouTubeSuggestionsAsync(text, services);
+
+        Assert.NotNull(results);
+        Assert.NotEmpty(results);
+        Assert.All(results, Assert.NotNull);
+        Assert.All(results, Assert.NotEmpty);
+    }
+
+    private static IServiceProvider GetServiceProvider()
+    {
+        var services = new ServiceCollection()
+            .AddFergunPolicies();
+
+        services.AddHttpClient("autocomplete", client => client.DefaultRequestHeaders.UserAgent.ParseAdd(Constants.ChromeUserAgent))
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30));
+
+        return services.BuildServiceProvider();
+    }
+
+    private static IEnumerable<object[]> GetTestData()
+    {
+        var faker = new Faker();
+        return faker.MakeLazy(10, () => faker.Music.Genre()).Select(x => new object[] { x });
+    }
+}
\ No newline at end of file

From e5ae0a95c81f2a6932ed46a4fa59dd7db95a302b Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Tue, 1 Mar 2022 00:23:14 -0500
Subject: [PATCH 06/83] Add UserModule tests

---
 .../Handlers/BraveAutocompleteHandler.cs      |  19 +-
 .../Handlers/DuckDuckGoAutocompleteHandler.cs |  31 ++--
 .../Handlers/GoogleAutocompleteHandler.cs     |  25 +--
 .../Handlers/YouTubeAutocompleteHandler.cs    |  23 +--
 .../Fergun.Tests/AutocompleteHandlerTests.cs  | 144 +++++++++++----
 tests/Fergun.Tests/Extensions.cs              |  11 ++
 tests/Fergun.Tests/Fergun.Tests.csproj        |   2 +-
 tests/Fergun.Tests/UserModuleTests.cs         | 172 ++++++++++++++++++
 tests/Fergun.Tests/Utils.cs                   |  11 ++
 9 files changed, 338 insertions(+), 100 deletions(-)
 create mode 100644 tests/Fergun.Tests/Extensions.cs
 create mode 100644 tests/Fergun.Tests/UserModuleTests.cs
 create mode 100644 tests/Fergun.Tests/Utils.cs

diff --git a/src/Modules/Handlers/BraveAutocompleteHandler.cs b/src/Modules/Handlers/BraveAutocompleteHandler.cs
index 61d885e..fb1da1d 100644
--- a/src/Modules/Handlers/BraveAutocompleteHandler.cs
+++ b/src/Modules/Handlers/BraveAutocompleteHandler.cs
@@ -18,17 +18,6 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
         if (string.IsNullOrEmpty(text))
             return AutocompletionResult.FromSuccess();
 
-        var suggestions = await GetBraveSuggestionsAsync(text, services);
-
-        var results = suggestions
-            .Select(x => new AutocompleteResult(x, x))
-            .Take(25);
-
-        return AutocompletionResult.FromSuccess(results);
-    }
-
-    public static async Task<string?[]> GetBraveSuggestionsAsync(string text, IServiceProvider services)
-    {
         var client = services
             .GetRequiredService<IHttpClientFactory>()
             .CreateClient("autocomplete");
@@ -45,10 +34,12 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
 
         using var document = JsonDocument.Parse(bytes);
 
-        return document
+        var results = document
             .RootElement[1]
             .EnumerateArray()
-            .Select(x => x.GetString())
-            .ToArray();
+            .Select(x => new AutocompleteResult(x.GetString(), x.GetString()))
+            .Take(25);
+
+        return AutocompletionResult.FromSuccess(results);
     }
 }
\ No newline at end of file
diff --git a/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs b/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs
index 7936857..e119e0c 100644
--- a/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs
+++ b/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs
@@ -19,20 +19,6 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
         if (string.IsNullOrEmpty(text))
             return AutocompletionResult.FromSuccess();
 
-        string locale = autocompleteInteraction.GetLocale("wt-wt").ToLowerInvariant();
-        bool isNsfw = context.Channel.IsNsfw();
-
-        var suggestions = await GetDuckDuckGoSuggestionsAsync(text, services, locale, isNsfw);
-
-        var results = suggestions
-            .Select(x => new AutocompleteResult(x, x))
-            .Take(25);
-
-        return AutocompletionResult.FromSuccess(results);
-    }
-
-    public static async Task<string?[]> GetDuckDuckGoSuggestionsAsync(string text, IServiceProvider services, string locale = "wt-wt", bool isNsfw = false)
-    {
         var client = services
             .GetRequiredService<IHttpClientFactory>()
             .CreateClient("autocomplete");
@@ -41,6 +27,15 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             .GetRequiredService<IReadOnlyPolicyRegistry<string>>()
             .Get<IAsyncPolicy<HttpResponseMessage>>("AutocompletePolicy");
 
+        string locale = autocompleteInteraction.GetLocale("wt-wt").ToLowerInvariant();
+        var temp = locale.Split('-');
+        if (temp.Length == 2)
+        {
+            locale = $"{temp[1]}-{temp[0]}";
+        }
+
+        bool isNsfw = context.Channel.IsNsfw();
+
         client.DefaultRequestHeaders.TryAddWithoutValidation("cookie", $"p={(isNsfw ? -2 : 1)}");
 
         string url = $"https://duckduckgo.com/ac/?q={Uri.EscapeDataString(text)}&kl={locale}";
@@ -50,10 +45,12 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
 
         using var document = JsonDocument.Parse(bytes);
 
-        return document
+        var results = document
             .RootElement
             .EnumerateArray()
-            .Select(x => x.GetProperty("phrase").GetString())
-            .ToArray();
+            .Select(x => new AutocompleteResult(x.GetProperty("phrase").GetString(), x.GetProperty("phrase").GetString()))
+            .Take(25);
+
+        return AutocompletionResult.FromSuccess(results);
     }
 }
\ No newline at end of file
diff --git a/src/Modules/Handlers/GoogleAutocompleteHandler.cs b/src/Modules/Handlers/GoogleAutocompleteHandler.cs
index f06461e..65759cb 100644
--- a/src/Modules/Handlers/GoogleAutocompleteHandler.cs
+++ b/src/Modules/Handlers/GoogleAutocompleteHandler.cs
@@ -20,19 +20,6 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
         if (string.IsNullOrEmpty(text))
             return AutocompletionResult.FromSuccess();
 
-        string language = autocompleteInteraction.GetLanguageCode();
-
-        var suggestions = await GetGoogleSuggestionsAsync(text, services, language);
-
-        var results = suggestions
-            .Select(x => new AutocompleteResult(x, x))
-            .Take(25);
-
-        return AutocompletionResult.FromSuccess(results);
-    }
-
-    public static async Task<string?[]> GetGoogleSuggestionsAsync(string text, IServiceProvider services, string language = "en")
-    {
         var client = services
             .GetRequiredService<IHttpClientFactory>()
             .CreateClient("autocomplete");
@@ -41,16 +28,20 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             .GetRequiredService<IReadOnlyPolicyRegistry<string>>()
             .Get<IAsyncPolicy<HttpResponseMessage>>("AutocompletePolicy");
 
-        string url = $"https://www.google.com/complete/search?q={Uri.EscapeDataString(text)}&client=chrome&hl={language}&xhr=t";
+        string locale = autocompleteInteraction.GetLocale();
+
+        string url = $"https://www.google.com/complete/search?q={Uri.EscapeDataString(text)}&client=chrome&hl={locale}&xhr=t";
         var response = await policy.ExecuteAsync(_ => client.GetAsync(new Uri(url)), new Context(url));
         var bytes = await response.Content.ReadAsByteArrayAsync();
 
         using var document = JsonDocument.Parse(bytes);
 
-        return document
+        var results = document
             .RootElement[1]
             .EnumerateArray()
-            .Select(x => x.GetString())
-            .ToArray();
+            .Select(x => new AutocompleteResult(x.GetString(), x.GetString()))
+            .Take(25);
+
+        return AutocompletionResult.FromSuccess(results);
     }
 }
\ No newline at end of file
diff --git a/src/Modules/Handlers/YouTubeAutocompleteHandler.cs b/src/Modules/Handlers/YouTubeAutocompleteHandler.cs
index 43841b2..d17faea 100644
--- a/src/Modules/Handlers/YouTubeAutocompleteHandler.cs
+++ b/src/Modules/Handlers/YouTubeAutocompleteHandler.cs
@@ -20,19 +20,6 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
         if (string.IsNullOrEmpty(text))
             return AutocompletionResult.FromSuccess();
 
-        string language = autocompleteInteraction.GetLanguageCode();
-
-        var suggestions = await GetYouTubeSuggestionsAsync(text, services, language);
-
-        var results = suggestions
-            .Select(x => new AutocompleteResult(x, x))
-            .Take(25);
-
-        return AutocompletionResult.FromSuccess(results);
-    }
-
-    public static async Task<string?[]> GetYouTubeSuggestionsAsync(string text, IServiceProvider services, string language = "en")
-    {
         var client = services
             .GetRequiredService<IHttpClientFactory>()
             .CreateClient("autocomplete");
@@ -41,6 +28,8 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             .GetRequiredService<IReadOnlyPolicyRegistry<string>>()
             .Get<IAsyncPolicy<HttpResponseMessage>>("AutocompletePolicy");
 
+        string language = autocompleteInteraction.GetLanguageCode();
+
         string url = $"https://suggestqueries-clients6.youtube.com/complete/search?client=youtube&hl={language}&gs_ri=youtube&ds=yt&q={Uri.EscapeDataString(text)}&xhr=t";
 
         var response = await policy.ExecuteAsync(_ => client.GetAsync(new Uri(url)), new Context(url));
@@ -48,10 +37,12 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
 
         using var document = JsonDocument.Parse(bytes);
 
-        return document
+        var results = document
             .RootElement[1]
             .EnumerateArray()
-            .Select(x => x[0].GetString())
-            .ToArray();
+            .Select(x => new AutocompleteResult(x[0].GetString(), x[0].GetString()))
+            .Take(25);
+
+        return AutocompletionResult.FromSuccess(results);
     }
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/AutocompleteHandlerTests.cs b/tests/Fergun.Tests/AutocompleteHandlerTests.cs
index 2247679..0842ea7 100644
--- a/tests/Fergun.Tests/AutocompleteHandlerTests.cs
+++ b/tests/Fergun.Tests/AutocompleteHandlerTests.cs
@@ -3,69 +3,124 @@
 using System.Linq;
 using System.Threading.Tasks;
 using Bogus;
+using Discord;
+using Discord.Interactions;
 using Fergun.Extensions;
 using Fergun.Modules.Handlers;
 using Microsoft.Extensions.DependencyInjection;
+using Moq;
 using Xunit;
 
 namespace Fergun.Tests;
 
 public class AutocompleteHandlerTests
 {
+    private readonly Mock<IInteractionContext> _contextMock = new();
+    private readonly Mock<ITextChannel> _channelMock = new();
+    private readonly IParameterInfo _parameter = Mock.Of<IParameterInfo>();
+    private readonly IServiceProvider _services = GetServiceProvider();
+    private readonly Mock<IAutocompleteInteraction> _interactionMock = new();
+    private readonly Mock<IAutocompleteInteractionData> _dataMock = new();
+
     [Theory]
-    [MemberData(nameof(GetTestData))]
+    [MemberData(nameof(GetBraveTestData))]
     public async Task BraveAutocomplete_Should_Return_Valid_Suggestions(string text)
     {
-        var services = GetServiceProvider();
+        var handler = new BraveAutocompleteHandler();
+        var option = Utils.CreateInstance<AutocompleteOption>(ApplicationCommandOptionType.String, text, text, true);
+
+        _interactionMock.SetupGet(x => x.Data).Returns(_dataMock.Object);
+        _dataMock.SetupGet(x => x.Current).Returns(option);
+
+        var results = await handler.GenerateSuggestionsAsync(_contextMock.Object, _interactionMock.Object, _parameter, _services);
 
-        var results = await BraveAutocompleteHandler.GetBraveSuggestionsAsync(text, services);
+        Assert.True(results.IsSuccess);
 
-        Assert.NotNull(results);
-        Assert.NotEmpty(results);
-        Assert.All(results, Assert.NotNull);
-        Assert.All(results, Assert.NotEmpty);
+        if (!string.IsNullOrEmpty(text))
+        {
+            Assert.NotNull(results.Suggestions);
+            Assert.NotEmpty(results.Suggestions);
+            Assert.All(results.Suggestions, Assert.NotNull);
+            Assert.All(results.Suggestions, x => Assert.NotNull(x.Name));
+            Assert.All(results.Suggestions, x => Assert.NotNull(x.Value));
+        }
     }
 
     [Theory]
-    [MemberData(nameof(GetTestData))]
-    public async Task DuckDuckGoAutocomplete_Should_Return_Valid_Suggestions(string text)
+    [MemberData(nameof(GetDuckDuckGoTestData))]
+    public async Task DuckDuckGoAutocomplete_Should_Return_Valid_Suggestions(string text, string locale, bool isNsfw)
     {
-        var services = GetServiceProvider();
-
-        var results = await DuckDuckGoAutocompleteHandler.GetDuckDuckGoSuggestionsAsync(text, services);
-
-        Assert.NotNull(results);
-        Assert.NotEmpty(results);
-        Assert.All(results, Assert.NotNull);
-        Assert.All(results, Assert.NotEmpty);
+        var handler = new DuckDuckGoAutocompleteHandler();
+        var option = Utils.CreateInstance<AutocompleteOption>(ApplicationCommandOptionType.String, text, text, true);
+
+        _contextMock.SetupGet(x => x.Channel).Returns(_channelMock.Object);
+        _channelMock.SetupGet(x => x.IsNsfw).Returns(isNsfw);
+        _interactionMock.SetupGet(x => x.Data).Returns(_dataMock.Object);
+        _interactionMock.SetupGet(x => x.UserLocale).Returns(locale);
+        _dataMock.SetupGet(x => x.Current).Returns(option);
+
+        var results = await handler.GenerateSuggestionsAsync(_contextMock.Object, _interactionMock.Object, _parameter, _services);
+
+        Assert.True(results.IsSuccess);
+
+        if (!string.IsNullOrEmpty(text))
+        {
+            Assert.NotNull(results.Suggestions);
+            Assert.NotEmpty(results.Suggestions);
+            Assert.All(results.Suggestions, Assert.NotNull);
+            Assert.All(results.Suggestions, x => Assert.NotNull(x.Name));
+            Assert.All(results.Suggestions, x => Assert.NotNull(x.Value));
+        }
     }
 
     [Theory]
-    [MemberData(nameof(GetTestData))]
-    public async Task GoogleAutocomplete_Should_Return_Valid_Suggestions(string text)
+    [MemberData(nameof(GetGoogleTestData))]
+    public async Task GoogleAutocomplete_Should_Return_Valid_Suggestions(string text, string locale)
     {
-        var services = GetServiceProvider();
+        var handler = new GoogleAutocompleteHandler();
+        var option = Utils.CreateInstance<AutocompleteOption>(ApplicationCommandOptionType.String, text, text, true);
 
-        var results = await GoogleAutocompleteHandler.GetGoogleSuggestionsAsync(text, services);
+        _interactionMock.SetupGet(x => x.Data).Returns(_dataMock.Object);
+        _interactionMock.SetupGet(x => x.UserLocale).Returns(locale);
+        _dataMock.SetupGet(x => x.Current).Returns(option);
 
-        Assert.NotNull(results);
-        Assert.NotEmpty(results);
-        Assert.All(results, Assert.NotNull);
-        Assert.All(results, Assert.NotEmpty);
+        var results = await handler.GenerateSuggestionsAsync(_contextMock.Object, _interactionMock.Object, _parameter, _services);
+
+        Assert.True(results.IsSuccess);
+
+        if (!string.IsNullOrEmpty(text))
+        {
+            Assert.NotNull(results.Suggestions);
+            Assert.NotEmpty(results.Suggestions);
+            Assert.All(results.Suggestions, Assert.NotNull);
+            Assert.All(results.Suggestions, x => Assert.NotNull(x.Name));
+            Assert.All(results.Suggestions, x => Assert.NotNull(x.Value));
+        }
     }
 
     [Theory]
-    [MemberData(nameof(GetTestData))]
-    public async Task YouTubeAutocomplete_Should_Return_Valid_Suggestions(string text)
+    [MemberData(nameof(GetGoogleTestData))]
+    public async Task YouTubeAutocomplete_Should_Return_Valid_Suggestions(string text, string locale)
     {
-        var services = GetServiceProvider();
+        var handler = new YouTubeAutocompleteHandler();
+        var option = Utils.CreateInstance<AutocompleteOption>(ApplicationCommandOptionType.String, text, text, true);
+
+        _interactionMock.SetupGet(x => x.Data).Returns(_dataMock.Object);
+        _interactionMock.SetupGet(x => x.UserLocale).Returns(locale);
+        _dataMock.SetupGet(x => x.Current).Returns(option);
+
+        var results = await handler.GenerateSuggestionsAsync(_contextMock.Object, _interactionMock.Object, _parameter, _services);
 
-        var results = await YouTubeAutocompleteHandler.GetYouTubeSuggestionsAsync(text, services);
+        Assert.True(results.IsSuccess);
 
-        Assert.NotNull(results);
-        Assert.NotEmpty(results);
-        Assert.All(results, Assert.NotNull);
-        Assert.All(results, Assert.NotEmpty);
+        if (!string.IsNullOrEmpty(text))
+        {
+            Assert.NotNull(results.Suggestions);
+            Assert.NotEmpty(results.Suggestions);
+            Assert.All(results.Suggestions, Assert.NotNull);
+            Assert.All(results.Suggestions, x => Assert.NotNull(x.Name));
+            Assert.All(results.Suggestions, x => Assert.NotNull(x.Value));
+        }
     }
 
     private static IServiceProvider GetServiceProvider()
@@ -79,9 +134,28 @@ private static IServiceProvider GetServiceProvider()
         return services.BuildServiceProvider();
     }
 
-    private static IEnumerable<object[]> GetTestData()
+    private static IEnumerable<object?[]> GetBraveTestData()
+    {
+        var faker = new Faker();
+        return faker.MakeLazy(10, () => faker.Music.Genre())
+            .Append(string.Empty).Append(null).Select(x => new object?[] { x });
+    }
+
+    private static IEnumerable<object?[]> GetDuckDuckGoTestData()
+    {
+        var faker = new Faker();
+        return faker.MakeLazy(10, () => faker.Music.Genre())
+            .Append(string.Empty).Append(null)
+            .Zip(faker.MakeLazy(12, () => faker.Random.RandomLocale().Replace('_', '-')), faker.MakeLazy(12, () => faker.Random.Bool()))
+            .Select(x => new object?[] { x.First, x.Second, x.Third });
+    }
+
+    private static IEnumerable<object?[]> GetGoogleTestData()
     {
         var faker = new Faker();
-        return faker.MakeLazy(10, () => faker.Music.Genre()).Select(x => new object[] { x });
+        return faker.MakeLazy(10, () => faker.Music.Genre())
+            .Append(string.Empty).Append(null)
+            .Zip(faker.MakeLazy(12, () => faker.Random.RandomLocale().Replace('_', '-')))
+            .Select(x => new object?[] { x.First, x.Second });
     }
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Extensions.cs b/tests/Fergun.Tests/Extensions.cs
new file mode 100644
index 0000000..9a36c26
--- /dev/null
+++ b/tests/Fergun.Tests/Extensions.cs
@@ -0,0 +1,11 @@
+using System;
+using System.Linq.Expressions;
+using System.Reflection;
+
+namespace Fergun.Tests;
+
+internal static class Extensions
+{
+    public static void SetPropertyValue<TSource, TProperty>(this TSource obj, Expression<Func<TSource, TProperty>> expression, TProperty newValue)
+        => ((PropertyInfo)((MemberExpression)expression.Body).Member).SetValue(obj, newValue);
+}
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Fergun.Tests.csproj b/tests/Fergun.Tests/Fergun.Tests.csproj
index 89b7fbf..3572990 100644
--- a/tests/Fergun.Tests/Fergun.Tests.csproj
+++ b/tests/Fergun.Tests/Fergun.Tests.csproj
@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
     <TargetFramework>net6.0</TargetFramework>
diff --git a/tests/Fergun.Tests/UserModuleTests.cs b/tests/Fergun.Tests/UserModuleTests.cs
new file mode 100644
index 0000000..90bf1d8
--- /dev/null
+++ b/tests/Fergun.Tests/UserModuleTests.cs
@@ -0,0 +1,172 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Bogus;
+using Discord;
+using Discord.Interactions;
+using Fergun.Modules;
+using Moq;
+using Moq.Protected;
+using Xunit;
+
+namespace Fergun.Tests;
+
+public class UserModuleTests
+{
+    private readonly Mock<IInteractionContext> _contextMock = new();
+    private readonly Mock<IDiscordInteraction> _interactionMock = new();
+    private readonly Mock<UserModule> _userModuleMock = new();
+
+    public UserModuleTests()
+    {
+        _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
+        ((IInteractionModuleBase)_userModuleMock.Object).SetContext(_contextMock.Object);
+    }
+
+    [Theory]
+    [MemberData(nameof(GetFakeUsers))]
+    public async Task Avatar_Should_Return_Embed_With_Avatar(Mock<IUser> userMock)
+    {
+        await _userModuleMock.Object.Avatar(userMock.Object);
+
+        userMock.Verify(x => x.ToString());
+        userMock.Verify(x => x.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>()));
+        if (userMock.Object.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>()) is null)
+        {
+            userMock.Verify(x => x.GetDefaultAvatarUrl());
+        }
+
+        VerifyRespondAsyncCall(userMock.Object);
+    }
+
+    [Theory]
+    [MemberData(nameof(GetFakeGuildUsers))]
+    public async Task Avatar_Should_Return_Embed_With_Guild_Avatar(Mock<IGuildUser> guildUserMock)
+    {
+        await _userModuleMock.Object.Avatar(guildUserMock.Object);
+
+        guildUserMock.Verify(x => x.ToString());
+        guildUserMock.Verify(x => x.GetGuildAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>()));
+        VerifyRespondAsyncCall(guildUserMock.Object);
+    }
+
+    [Theory]
+    [MemberData(nameof(GetFakeUsers))]
+    public async Task UserInfo_Should_Return_Embed_With_Avatar(Mock<IUser> userMock)
+    {
+        await _userModuleMock.Object.UserInfo(userMock.Object);
+
+        userMock.Verify(x => x.ToString());
+        userMock.Verify(x => x.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>()));
+        if (userMock.Object.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>()) is null)
+        {
+            userMock.Verify(x => x.GetDefaultAvatarUrl());
+        }
+
+        userMock.VerifyGet(x => x.Activities);
+        userMock.VerifyGet(x => x.ActiveClients);
+        userMock.VerifyGet(x => x.Id);
+        userMock.VerifyGet(x => x.IsBot);
+        userMock.VerifyGet(x => x.CreatedAt);
+
+        VerifyRespondAsyncCall(userMock.Object);
+    }
+
+    [Theory]
+    [MemberData(nameof(GetFakeGuildUsers))]
+    public async Task UserInfo_Should_Return_Embed_With_Guild_Avatar(Mock<IGuildUser> guildUserMock)
+    {
+        await _userModuleMock.Object.UserInfo(guildUserMock.Object);
+
+        guildUserMock.Verify(x => x.ToString());
+        guildUserMock.Verify(x => x.GetGuildAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>()));
+        guildUserMock.VerifyGet(x => x.Activities);
+        guildUserMock.VerifyGet(x => x.ActiveClients);
+        guildUserMock.VerifyGet(x => x.Id);
+        guildUserMock.VerifyGet(x => x.IsBot);
+        guildUserMock.VerifyGet(x => x.CreatedAt);
+        guildUserMock.VerifyGet(x => x.Nickname);
+        guildUserMock.VerifyGet(x => x.JoinedAt);
+        guildUserMock.VerifyGet(x => x.PremiumSince);
+
+        VerifyRespondAsyncCall(guildUserMock.Object);
+    }
+
+    private void VerifyRespondAsyncCall(IUser user)
+    {
+        _userModuleMock.Protected().Verify<Task>("RespondAsync", Times.Once(), ItExpr.IsAny<string>(),
+            ItExpr.IsAny<Embed[]>(), ItExpr.IsAny<bool>(), ItExpr.IsAny<bool>(), ItExpr.IsAny<AllowedMentions>(),
+            ItExpr.IsAny<RequestOptions>(), ItExpr.IsAny<MessageComponent>(),
+            ItExpr.Is<Embed>(e => EmbedImageUrlIsUserAvatarUrl(user, e)));
+    }
+
+    private static bool EmbedImageUrlIsUserAvatarUrl(IUser user, Embed embed)
+        => (embed.Image.GetValueOrDefault().Url ?? embed.Thumbnail.GetValueOrDefault().Url)
+           == ((user as IGuildUser)?.GetGuildAvatarUrl() ?? user.GetAvatarUrl() ?? user.GetDefaultAvatarUrl());
+
+    private static IEnumerable<object[]> GetFakeUsers()
+    {
+        var faker = new Faker();
+
+        return faker.MakeLazy(20, () =>
+        {
+            var userMock = new Mock<IUser>();
+
+            userMock.SetupGet(x => x.Username).Returns(faker.Internet.UserName());
+            userMock.SetupGet(x => x.DiscriminatorValue).Returns(faker.Random.UShort(1, 9999));
+            userMock.SetupGet(x => x.Discriminator).Returns(() => userMock.Object.DiscriminatorValue.ToString("D4"));
+            userMock.SetupGet(x => x.Activities).Returns(() => faker.MakeLazy(faker.Random.Number(3), () => new Game(faker.Hacker.IngVerb(), faker.Random.Enum(ActivityType.CustomStatus))
+                .OrDefault(faker, 0.5f, CreateCustomStatusGame(faker))).ToArray());
+            userMock.SetupGet(x => x.ActiveClients).Returns(() => faker.MakeLazy(faker.Random.Number(3),
+                () => faker.PickRandom(Enum.GetValues<ClientType>()).OrDefault(faker, 0.5f, (ClientType)3)).ToArray());
+            userMock.SetupGet(x => x.CreatedAt).Returns(() => faker.Date.PastOffset(5));
+            userMock.SetupGet(x => x.Id).Returns(() => faker.Random.ULong());
+            userMock.SetupGet(x => x.IsBot).Returns(() => faker.Random.Bool());
+            userMock.Setup(x => x.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>())).Returns(faker.Internet.Avatar().OrNull(faker));
+            userMock.Setup(x => x.GetDefaultAvatarUrl()).Returns(faker.Internet.Avatar());
+            userMock.Setup(x => x.ToString()).Returns(() => $"{userMock.Object.Username}#{userMock.Object.Discriminator}");
+
+            return userMock;
+        }).Select(x => new object[] { x });
+    }
+
+    private static IEnumerable<object[]> GetFakeGuildUsers()
+    {
+        var faker = new Faker();
+
+        return faker.MakeLazy(20, () =>
+        {
+            var userMock = new Mock<IGuildUser>();
+
+            userMock.SetupGet(x => x.Username).Returns(faker.Internet.UserName());
+            userMock.SetupGet(x => x.DiscriminatorValue).Returns(faker.Random.UShort(1, 9999));
+            userMock.SetupGet(x => x.Discriminator).Returns(() => userMock.Object.DiscriminatorValue.ToString("D4"));
+            userMock.SetupGet(x => x.Activities).Returns(() => faker.MakeLazy(faker.Random.Number(3), () => new Game(faker.Hacker.IngVerb(), faker.Random.Enum(ActivityType.CustomStatus))
+                .OrDefault(faker, 0.5f, CreateCustomStatusGame(faker))).ToArray());
+            userMock.SetupGet(x => x.ActiveClients).Returns(() => faker.MakeLazy(faker.Random.Number(3),
+                () => faker.PickRandom(Enum.GetValues<ClientType>()).OrDefault(faker, 0.5f, (ClientType)3)).ToArray());
+            userMock.SetupGet(x => x.CreatedAt).Returns(() => faker.Date.PastOffset(5));
+            userMock.SetupGet(x => x.Id).Returns(() => faker.Random.ULong());
+            userMock.SetupGet(x => x.IsBot).Returns(() => faker.Random.Bool());
+            userMock.SetupGet(x => x.Nickname).Returns(() => faker.Internet.UserName().OrNull(faker));
+            userMock.SetupGet(x => x.JoinedAt).Returns(() => faker.Date.PastOffset());
+            userMock.SetupGet(x => x.PremiumSince).Returns(() => faker.Date.PastOffset().OrNull(faker));
+            userMock.Setup(x => x.GetGuildAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>())).Returns(faker.Internet.Avatar());
+            userMock.Setup(x => x.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>())).Returns(faker.Internet.Avatar());
+            userMock.Setup(x => x.GetDefaultAvatarUrl()).Returns(faker.Internet.Avatar());
+            userMock.Setup(x => x.ToString()).Returns(() => $"{userMock.Object.Username}#{userMock.Object.Discriminator}");
+
+            return userMock;
+        }).Select(x => new object[] { x });
+    }
+
+    private static CustomStatusGame CreateCustomStatusGame(Faker faker)
+    {
+        var status = Utils.CreateInstance<CustomStatusGame>();
+        status.SetPropertyValue(x => x.Emote, Emote.Parse($"<:{faker.Random.String2(10)}:{faker.Random.ULong()}>"));
+        status.SetPropertyValue(x => x.State, faker.Hacker.IngVerb());
+        status.SetPropertyValue(x => x.Type, ActivityType.CustomStatus);
+        return status;
+    }
+}
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Utils.cs b/tests/Fergun.Tests/Utils.cs
new file mode 100644
index 0000000..e77c192
--- /dev/null
+++ b/tests/Fergun.Tests/Utils.cs
@@ -0,0 +1,11 @@
+using System;
+using System.Globalization;
+using System.Reflection;
+
+namespace Fergun.Tests;
+
+internal static class Utils
+{
+    public static T CreateInstance<T>(params object?[]? args) where T : class
+        => (T)Activator.CreateInstance(typeof(T), BindingFlags.NonPublic | BindingFlags.Instance, null, args, CultureInfo.InvariantCulture)!;
+}
\ No newline at end of file

From 97d24bedec0f9dafa0fd62a1d15fbc6d68edfc12 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Wed, 2 Mar 2022 15:27:35 -0500
Subject: [PATCH 07/83] Add slash command `tts`

---
 .../Handlers/TtsAutocompleteHandler.cs        | 33 +++++++++++++++
 src/Modules/MessageModule.cs                  | 40 ------------------
 src/Modules/UtilityModule.cs                  | 42 +++++++++++++++++--
 3 files changed, 72 insertions(+), 43 deletions(-)
 create mode 100644 src/Modules/Handlers/TtsAutocompleteHandler.cs

diff --git a/src/Modules/Handlers/TtsAutocompleteHandler.cs b/src/Modules/Handlers/TtsAutocompleteHandler.cs
new file mode 100644
index 0000000..4fc8603
--- /dev/null
+++ b/src/Modules/Handlers/TtsAutocompleteHandler.cs
@@ -0,0 +1,33 @@
+using Discord;
+using Discord.Interactions;
+using Fergun.Extensions;
+using GTranslate;
+using GTranslate.Translators;
+
+namespace Fergun.Modules.Handlers;
+
+public class TtsAutocompleteHandler : AutocompleteHandler
+{
+    public override Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
+    {
+        var text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
+
+        IEnumerable<ILanguage> languages = GoogleTranslator2
+            .TextToSpeechLanguages
+            .Where(x => x.Name.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
+                        x.ISO6391.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
+                        x.ISO6393.StartsWith(text, StringComparison.OrdinalIgnoreCase))
+            .OrderBy(x => x.Name);
+
+        if (context.Interaction.TryGetLanguage(out var language))
+        {
+            languages = languages.Where(x => !x.Equals(language)).Prepend(language);
+        }
+
+        var results = languages
+            .Select(x => new AutocompleteResult($"{x.Name} ({x.ISO6391})", x.ISO6391))
+            .Take(25);
+
+        return Task.FromResult(AutocompletionResult.FromSuccess(results));
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/MessageModule.cs b/src/Modules/MessageModule.cs
index 0502d89..094f6a8 100644
--- a/src/Modules/MessageModule.cs
+++ b/src/Modules/MessageModule.cs
@@ -58,44 +58,4 @@ public async Task GetReferencedMessage(IUserMessage message)
 
         await RespondAsync("\u200b", ephemeral: true, components: button);
     }
-
-    [MessageCommand("TTS")]
-    public async Task TTS(IUserMessage message)
-    {
-        string text = message.GetText();
-
-        if (string.IsNullOrWhiteSpace(text))
-        {
-            await Context.Interaction.RespondWarningAsync("The message must contain text.", true);
-            return;
-        }
-
-        string target = Context.Interaction.GetLanguageCode();
-
-        if (!Language.TryGetLanguage(target, out var language) || !GoogleTranslator2.TextToSpeechLanguages.Contains(language))
-        {
-            await Context.Interaction.RespondWarningAsync($"Language \"{target}\" not supported.", true);
-            return;
-        }
-
-        await DeferAsync();
-
-        try
-        {
-            await using var stream = await _googleTranslator2.TextToSpeechAsync(text, language);
-            await Context.Interaction.FollowupWithFileAsync(new FileAttachment(stream, "tts.mp3"));
-        }
-        catch (HttpRequestException e)
-        {
-            _logger.LogWarning(e, "TTS: Error while getting TTS");
-            await Context.Interaction.FollowupWarning("An error occurred.");
-        }
-        catch (TaskCanceledException e)
-        {
-            _logger.LogWarning(e, "TTS: Error while getting TTS");
-            await Context.Interaction.FollowupWarning("Request timed out.");
-        }
-    }
-
-    
 }
\ No newline at end of file
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index ef3b1d3..ff0e410 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -518,9 +518,9 @@ public async Task Stats()
     }
 
     [SlashCommand("translate", "Translates a text.")]
-    public async Task Translate([Summary(description: "The text to translate")] string text,
-        [Autocomplete(typeof(TranslateAutocompleteHandler))] [Summary(description: "Target language (name, code or alias)")] string target,
-        [Autocomplete(typeof(TranslateAutocompleteHandler))] [Summary(description: "Source language (name, code or alias)")] string? source = null)
+    public async Task Translate([Summary(description: "The text to translate.")] string text,
+        [Autocomplete(typeof(TranslateAutocompleteHandler))] [Summary(description: "Target language (name, code or alias).")] string target,
+        [Autocomplete(typeof(TranslateAutocompleteHandler))] [Summary(description: "Source language (name, code or alias).")] string? source = null)
     {
         if (string.IsNullOrWhiteSpace(text))
         {
@@ -583,6 +583,42 @@ public async Task Translate([Summary(description: "The text to translate")] stri
     public async Task Translate(IUserMessage message)
         => await Translate(message.GetText(), Context.Interaction.GetLanguageCode());
 
+    [SlashCommand("tts", "Converts text into synthesized speech.")]
+    public async Task TTS([Summary(description: "The text to convert.")] string text,
+        [Autocomplete(typeof(TtsAutocompleteHandler))] [Summary(description: "The target language.")] string? target = null)
+    {
+        if (string.IsNullOrWhiteSpace(text))
+        {
+            await Context.Interaction.RespondWarningAsync("The message must contain text.", true);
+            return;
+        }
+
+        target ??= Context.Interaction.GetLanguageCode();
+
+        if (!Language.TryGetLanguage(target, out var language) || !GoogleTranslator2.TextToSpeechLanguages.Contains(language))
+        {
+            await Context.Interaction.RespondWarningAsync($"Language \"{target}\" not supported.", true);
+            return;
+        }
+
+        await DeferAsync();
+
+        try
+        {
+            await using var stream = await _googleTranslator2.TextToSpeechAsync(text, language);
+            await Context.Interaction.FollowupWithFileAsync(new FileAttachment(stream, "tts.mp3"));
+        }
+        catch (Exception e)
+        {
+            _logger.LogWarning(e, "TTS: Error obtaining TTS from text {text} ({language})", text, language);
+            await Context.Interaction.FollowupWarning(e.Message);
+        }
+    }
+
+    [MessageCommand("TTS")]
+    public async Task TTS(IUserMessage message)
+        => await TTS(message.GetText(), Context.Interaction.GetLanguageCode());
+
     [SlashCommand("youtube", "Sends a paginator containing YouTube videos.")]
     public async Task YouTube([Autocomplete(typeof(YouTubeAutocompleteHandler))] [Summary(description: "The query.")] string query)
     {

From fc366cf3c83040eac6b7d7f9df0684c238cfa8f7 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Thu, 3 Mar 2022 00:07:00 -0500
Subject: [PATCH 08/83] Update Discord.Net

---
 src/Fergun.csproj            |  2 +-
 src/Modules/MessageModule.cs |  2 +-
 src/Modules/UtilityModule.cs | 21 +++++++++++----------
 3 files changed, 13 insertions(+), 12 deletions(-)

diff --git a/src/Fergun.csproj b/src/Fergun.csproj
index 2154640..b7150c0 100644
--- a/src/Fergun.csproj
+++ b/src/Fergun.csproj
@@ -10,7 +10,7 @@
 
   <ItemGroup>
     <PackageReference Include="Discord.Addons.Hosting" Version="5.1.0" />
-    <PackageReference Include="Discord.Net.Interactions" Version="3.3.2" />
+    <PackageReference Include="Discord.Net.Interactions" Version="3.4.0" />
     <PackageReference Include="Fergun.Interactive" Version="1.4.1" />
     <PackageReference Include="GScraper" Version="1.0.2" />
     <PackageReference Include="GTranslate" Version="2.0.1" />
diff --git a/src/Modules/MessageModule.cs b/src/Modules/MessageModule.cs
index 094f6a8..aa8233a 100644
--- a/src/Modules/MessageModule.cs
+++ b/src/Modules/MessageModule.cs
@@ -36,7 +36,7 @@ public MessageModule(ILogger<MessageModule> logger, AggregateTranslator translat
     }
 
     [MessageCommand("Get Reference")]
-    public async Task GetReferencedMessage(IUserMessage message)
+    public async Task GetReferencedMessage(IMessage message)
     {
         if (message.Type != MessageType.Reply)
         {
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index ff0e410..5fd4383 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -59,6 +59,10 @@ public UtilityModule(ILogger<UtilityModule> logger, InteractiveService interacti
         _searchClient = searchClient;
     }
 
+    [MessageCommand("Bad Translator")]
+    public async Task BadTranslator(IMessage message)
+        => await BadTranslator(message.GetText());
+
     [SlashCommand("badtranslator", "Passes a text through multiple, different translators.")]
     public async Task BadTranslator([Summary(description: "The text to use.")] string text,
         [Summary(description: "The amount of times to translate the text (2-10).")] [MinValue(2)] [MaxValue(10)] int chainCount = 8)
@@ -144,9 +148,6 @@ public async Task BadTranslator([Summary(description: "The text to use.")] strin
         await FollowupAsync(embed: embed);
     }
 
-    [MessageCommand("Bad Translator")]
-    public async Task BadTranslator(IUserMessage message) => await BadTranslator(message.GetText());
-
     [RequireOwner]
     [SlashCommand("cmd", "(Owner only) Executes a command.")]
     public async Task Cmd([Summary(description: "The command to execute")] string command, [Summary("noembed", "No embed.")] bool noEmbed = false)
@@ -517,6 +518,10 @@ public async Task Stats()
         await FollowupAsync(embed: builder.Build());
     }
 
+    [MessageCommand("Translate")]
+    public async Task Translate(IMessage message)
+        => await Translate(message.GetText(), Context.Interaction.GetLanguageCode());
+
     [SlashCommand("translate", "Translates a text.")]
     public async Task Translate([Summary(description: "The text to translate.")] string text,
         [Autocomplete(typeof(TranslateAutocompleteHandler))] [Summary(description: "Target language (name, code or alias).")] string target,
@@ -579,9 +584,9 @@ public async Task Translate([Summary(description: "The text to translate.")] str
         await FollowupAsync(embed: builder.Build());
     }
 
-    [MessageCommand("Translate")]
-    public async Task Translate(IUserMessage message)
-        => await Translate(message.GetText(), Context.Interaction.GetLanguageCode());
+    [MessageCommand("TTS")]
+    public async Task TTS(IMessage message)
+        => await TTS(message.GetText());
 
     [SlashCommand("tts", "Converts text into synthesized speech.")]
     public async Task TTS([Summary(description: "The text to convert.")] string text,
@@ -615,10 +620,6 @@ public async Task TTS([Summary(description: "The text to convert.")] string text
         }
     }
 
-    [MessageCommand("TTS")]
-    public async Task TTS(IUserMessage message)
-        => await TTS(message.GetText(), Context.Interaction.GetLanguageCode());
-
     [SlashCommand("youtube", "Sends a paginator containing YouTube videos.")]
     public async Task YouTube([Autocomplete(typeof(YouTubeAutocompleteHandler))] [Summary(description: "The query.")] string query)
     {

From 73c9e1fd1a0b2a119060b834233995c4959732bc Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 5 Mar 2022 16:44:29 -0500
Subject: [PATCH 09/83] Add Bing/Yandex OCR and OCR commands

---
 src/Apis/Bing/BingException.cs       |  46 ++++++++++
 src/Apis/Bing/BingVisualSearch.cs    | 129 +++++++++++++++++++++++++++
 src/Apis/Yandex/YandexException.cs   |  46 ++++++++++
 src/Apis/Yandex/YandexImageSearch.cs | 106 ++++++++++++++++++++++
 src/Constants.cs                     |   2 +
 src/Extensions/JsonExtensions.cs     |  24 +++++
 src/Modules/UtilityModule.cs         | 114 +++++++++++++++++++++--
 src/Program.cs                       |  10 +++
 8 files changed, 468 insertions(+), 9 deletions(-)
 create mode 100644 src/Apis/Bing/BingException.cs
 create mode 100644 src/Apis/Bing/BingVisualSearch.cs
 create mode 100644 src/Apis/Yandex/YandexException.cs
 create mode 100644 src/Apis/Yandex/YandexImageSearch.cs
 create mode 100644 src/Extensions/JsonExtensions.cs

diff --git a/src/Apis/Bing/BingException.cs b/src/Apis/Bing/BingException.cs
new file mode 100644
index 0000000..945090e
--- /dev/null
+++ b/src/Apis/Bing/BingException.cs
@@ -0,0 +1,46 @@
+using System.Runtime.Serialization;
+
+namespace Fergun.Apis.Bing;
+
+/// <summary>
+/// The exception that is thrown when Bing Visual Search fails to retrieve the results of an operation.
+/// </summary>
+[Serializable]
+public class BingException : Exception
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="BingException"/> class.
+    /// </summary>
+    public BingException()
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="BingException"/> class with a specified error message.
+    /// </summary>
+    /// <param name="message">The message that describes the error.</param>
+    public BingException(string? message)
+        : base(message)
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="BingException"/> class with a specified error message and a reference to the inner exception that is the cause of this exception.
+    /// </summary>
+    /// <param name="message">The error message that explains the reason for the exception.</param>
+    /// <param name="innerException">The exception that is the cause of the current exception, or a null reference if no inner exception is specified.</param>
+    public BingException(string? message, Exception? innerException)
+        : base(message, innerException)
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="BingException"/> class with serialized data.
+    /// </summary>
+    /// <param name="serializationInfo">The <see cref="SerializationInfo"/> that holds the serialized object data about the exception being thrown.</param>
+    /// <param name="streamingContext">The <see cref="StreamingContext"/> that contains contextual information about the source or destination.</param>
+    protected BingException(SerializationInfo serializationInfo, StreamingContext streamingContext)
+        : base(serializationInfo, streamingContext)
+    {
+    }
+}
\ No newline at end of file
diff --git a/src/Apis/Bing/BingVisualSearch.cs b/src/Apis/Bing/BingVisualSearch.cs
new file mode 100644
index 0000000..1645442
--- /dev/null
+++ b/src/Apis/Bing/BingVisualSearch.cs
@@ -0,0 +1,129 @@
+using System.Text.Json;
+using Fergun.Extensions;
+
+namespace Fergun.Apis.Bing;
+
+/// <summary>
+/// Represents a wrapper over Bing Visual Search internal API.
+/// </summary>
+public sealed class BingVisualSearch : IDisposable
+{
+    private static readonly Uri _apiEndpoint = new("https://www.bing.com/images/api/custom/knowledge/");
+
+    private static readonly Dictionary<string, string> _imageCategories = new()
+    {
+        ["ImageByteSizeExceedsLimit"] = "Image size exceeds the limit (Max. 20MB)",
+        ["ImageDimensionsExceedLimit"] = "Image dimensions exceeds the limit (Max. 4000px)",
+        ["ImageDownloadFailed"] = "Image download failed",
+        ["ServiceUnavailable"] = "Bing Visual search is currently unavailable. Try again later",
+        ["UnknownFormat"] = "Unknown format (Only JPEG, PNG or BMP allowed)."
+    };
+
+    private const string _sKey = "ZbQI4MYyHrlk2E7L-vIV2VLrieGlbMfV8FcK-WCY3ug";
+    private const string _defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36";
+    private readonly HttpClient _httpClient;
+    private bool _disposed;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="BingVisualSearch"/> class.
+    /// </summary>
+    public BingVisualSearch()
+        : this(new HttpClient())
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="BingVisualSearch"/> class using the specified <see cref="HttpClient"/>.
+    /// </summary>
+    /// <param name="httpClient">An instance of <see cref="HttpClient"/>.</param>
+    public BingVisualSearch(HttpClient httpClient)
+    {
+        _httpClient = httpClient;
+
+        _httpClient.BaseAddress ??= _apiEndpoint;
+
+        if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0)
+        {
+            _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_defaultUserAgent);
+        }
+    }
+
+    /// <summary>
+    /// Performs OCR to the specified image URL.
+    /// </summary>
+    /// <param name="url">The URL of an image.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous OCR operation. The result contains the recognized text.</returns>
+    public async Task<string> OcrAsync(string url)
+    {
+        using var request = BuildRequest(url, "OCR");
+        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
+
+        response.EnsureSuccessStatusCode();
+
+        await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+
+        string? imageCategory = document
+            .RootElement
+            .GetPropertyOrDefault("imageQualityHints")
+            .FirstOrDefault()
+            .GetPropertyOrDefault("category")
+            .GetStringOrDefault();
+
+        if (imageCategory is not null && _imageCategories.TryGetValue(imageCategory, out var message))
+        {
+            throw new BingException(message);
+        }
+
+        var textRegions = document
+            .RootElement
+            .GetPropertyOrDefault("tags")
+            .FirstOrDefault(x => x.GetPropertyOrDefault("displayName").GetStringOrDefault() == "##TextRecognition")
+            .GetPropertyOrDefault("actions")
+            .FirstOrDefault()
+            .GetPropertyOrDefault("data")
+            .GetPropertyOrDefault("regions")
+            .EnumerateArrayOrEmpty()
+            .Select(x => string.Join('\n',
+                x.GetPropertyOrDefault("lines")
+                    .EnumerateArrayOrEmpty()
+                    .Select(y => y.GetPropertyOrDefault("text").GetStringOrDefault())));
+
+        return string.Join("\n\n", textRegions);
+    }
+
+    private static HttpRequestMessage BuildRequest(string url, string invokedSkill)
+    {
+        string jsonRequest = $"{{\"imageInfo\":{{\"url\":\"{url}\",\"source\":\"Url\"}},\"knowledgeRequest\":{{\"invokedSkills\":[\"{invokedSkill}\"]}}}}";
+        var content = new MultipartFormDataContent
+        {
+            { new StringContent(jsonRequest), "knowledgeRequest" }
+        };
+
+        var request = new HttpRequestMessage
+        {
+            Method = HttpMethod.Post,
+            RequestUri = new Uri($"?skey={_sKey}", UriKind.Relative),
+            Content = content
+        };
+
+        request.Headers.Referrer = new Uri($"https://www.bing.com/images/search?view=detailv2&iss=sbi&q=imgurl:{url}");
+
+        return request;
+    }
+
+    /// <inheritdoc/>
+    public void Dispose() => Dispose(true);
+
+    /// <inheritdoc cref="Dispose()"/>
+    private void Dispose(bool disposing)
+    {
+        if (!disposing || _disposed)
+        {
+            return;
+        }
+
+        _httpClient.Dispose();
+        _disposed = true;
+    }
+}
\ No newline at end of file
diff --git a/src/Apis/Yandex/YandexException.cs b/src/Apis/Yandex/YandexException.cs
new file mode 100644
index 0000000..6ca7cd5
--- /dev/null
+++ b/src/Apis/Yandex/YandexException.cs
@@ -0,0 +1,46 @@
+using System.Runtime.Serialization;
+
+namespace Fergun.Apis.Yandex;
+
+/// <summary>
+/// The exception that is thrown when Yandex Image search fails to retrieve the results of an operation.
+/// </summary>
+[Serializable]
+public class YandexException : Exception
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="YandexException"/> class.
+    /// </summary>
+    public YandexException()
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="YandexException"/> class with a specified error message.
+    /// </summary>
+    /// <param name="message">The message that describes the error.</param>
+    public YandexException(string? message)
+        : base(message)
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="YandexException"/> class with a specified error message and a reference to the inner exception that is the cause of this exception.
+    /// </summary>
+    /// <param name="message">The error message that explains the reason for the exception.</param>
+    /// <param name="innerException">The exception that is the cause of the current exception, or a null reference if no inner exception is specified.</param>
+    public YandexException(string? message, Exception? innerException)
+        : base(message, innerException)
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="YandexException"/> class with serialized data.
+    /// </summary>
+    /// <param name="serializationInfo">The <see cref="SerializationInfo"/> that holds the serialized object data about the exception being thrown.</param>
+    /// <param name="streamingContext">The <see cref="StreamingContext"/> that contains contextual information about the source or destination.</param>
+    protected YandexException(SerializationInfo serializationInfo, StreamingContext streamingContext)
+        : base(serializationInfo, streamingContext)
+    {
+    }
+}
\ No newline at end of file
diff --git a/src/Apis/Yandex/YandexImageSearch.cs b/src/Apis/Yandex/YandexImageSearch.cs
new file mode 100644
index 0000000..df5f208
--- /dev/null
+++ b/src/Apis/Yandex/YandexImageSearch.cs
@@ -0,0 +1,106 @@
+using System.Text.Json;
+using Fergun.Extensions;
+
+namespace Fergun.Apis.Yandex;
+
+/// <summary>
+/// Represents a wrapper over Yandex Image Search internal API.
+/// </summary>
+public sealed class YandexImageSearch : IDisposable
+{
+    private const string _defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36";
+    private readonly HttpClient _httpClient;
+    private bool _disposed;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="YandexImageSearch"/> class.
+    /// </summary>
+    public YandexImageSearch()
+        : this(new HttpClient())
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="YandexImageSearch"/> class using the specified <see cref="HttpClient"/>.
+    /// </summary>
+    /// <param name="httpClient">An instance of <see cref="HttpClient"/>.</param>
+    public YandexImageSearch(HttpClient httpClient)
+    {
+        _httpClient = httpClient;
+
+        if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0)
+        {
+            _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_defaultUserAgent);
+        }
+    }
+
+    /// <summary>
+    /// Performs OCR to the specified image URL.
+    /// </summary>
+    /// <param name="url">The URL of an image.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous OCR operation. The result contains the recognized text.</returns>
+    public async Task<string> OcrAsync(string url)
+    {
+        // Get CBIR ID
+
+        using var request = new HttpRequestMessage
+        {
+            Method = HttpMethod.Get,
+            RequestUri = new Uri($"https://yandex.com/images-apphost/image-download?url={Uri.EscapeDataString(url)}&cbird=37&images_avatars_size=orig&images_avatars_namespace=images-cbir")
+        };
+
+        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
+        response.EnsureSuccessStatusCode();
+
+        await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+
+        string imageId = document
+            .RootElement
+            .GetProperty("image_id")
+            .GetString() ?? throw new YandexException("Unable to get the ID of the image.");
+
+        int imageShard = document
+            .RootElement
+            .GetProperty("image_shard")
+            .GetInt32();
+
+        // Get OCR text
+        const string ocrJsonRequest = @"{""blocks"":[{""block"":{""block"":""i-react-ajax-adapter:ajax""},""params"":{""type"":""CbirOcr""},""version"":2}]}";
+
+        using var ocrRequest = new HttpRequestMessage
+        {
+            Method = HttpMethod.Get,
+            RequestUri = new Uri($"https://yandex.com/images/search?format=json&request={ocrJsonRequest}&rpt=ocr&cbir_id={imageShard}/{imageId}")
+        };
+
+        using var ocrResponse = await _httpClient.SendAsync(ocrRequest, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
+        ocrResponse.EnsureSuccessStatusCode();
+
+        await using var ocrStream = await ocrResponse.Content.ReadAsStreamAsync().ConfigureAwait(false);
+        using var ocrDocument = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+
+        return ocrDocument
+            .RootElement
+            .GetProperty("blocks")[0]
+            .GetProperty("params")
+            .GetPropertyOrDefault("adapterData")
+            .GetPropertyOrDefault("plainText")
+            .GetStringOrDefault() ?? "";
+    }
+
+    /// <inheritdoc/>
+    public void Dispose() => Dispose(true);
+
+    /// <inheritdoc cref="Dispose()"/>
+    private void Dispose(bool disposing)
+    {
+        if (!disposing || _disposed)
+        {
+            return;
+        }
+
+        _httpClient.Dispose();
+        _disposed = true;
+    }
+}
\ No newline at end of file
diff --git a/src/Constants.cs b/src/Constants.cs
index 2655072..86ae24b 100644
--- a/src/Constants.cs
+++ b/src/Constants.cs
@@ -20,5 +20,7 @@ public static class Constants
 
     public const string BadTranslatorLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/944755022816763914/unknown.png";
 
+    public const string BingIconUrl = "https://cdn.discordapp.com/attachments/838832564583661638/949767220232339507/Bing_Icon.png";
+
     public const string ChromeUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36";
 }
\ No newline at end of file
diff --git a/src/Extensions/JsonExtensions.cs b/src/Extensions/JsonExtensions.cs
new file mode 100644
index 0000000..561af3b
--- /dev/null
+++ b/src/Extensions/JsonExtensions.cs
@@ -0,0 +1,24 @@
+using System.Text.Json;
+
+namespace Fergun.Extensions;
+
+public static class JsonExtensions
+{
+    public static IEnumerable<JsonElement> EnumerateArrayOrEmpty(this JsonElement element)
+        => element.ValueKind == JsonValueKind.Array ? element.EnumerateArray() : Enumerable.Empty<JsonElement>();
+
+    public static JsonElement FirstOrDefault(this JsonElement element)
+        => element.ValueKind == JsonValueKind.Array ? element.EnumerateArray().FirstOrDefault() : default;
+
+    public static JsonElement FirstOrDefault(this JsonElement element, Func<JsonElement, bool> predicate)
+        => element.ValueKind == JsonValueKind.Array ? element.EnumerateArray().FirstOrDefault(predicate) : default;
+
+    public static JsonElement GetPropertyOrDefault(this JsonElement element, string propertyName)
+        => element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var value) ? value : default;
+
+    public static string? GetStringOrDefault(this JsonElement element)
+        => element.ValueKind == JsonValueKind.String ? element.GetString() : default;
+
+    public static int GetInt32OrDefault(this JsonElement element)
+        => element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out int value) ? value : default;
+}
\ No newline at end of file
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 5fd4383..06d70e2 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -3,6 +3,8 @@
 using System.Runtime.InteropServices;
 using Discord;
 using Discord.Interactions;
+using Fergun.Apis.Bing;
+using Fergun.Apis.Yandex;
 using Fergun.Extensions;
 using Fergun.Interactive;
 using Fergun.Interactive.Pagination;
@@ -36,6 +38,8 @@ public class UtilityModule : InteractionModuleBase<ShardedInteractionContext>
     private readonly DuckDuckGoScraper _duckDuckGoScraper;
     private readonly BraveScraper _braveScraper;
     private readonly SearchClient _searchClient;
+    private readonly BingVisualSearch _bingVisualSearch;
+    private readonly YandexImageSearch _yandexImageSearch;
     private static readonly Lazy<Language[]> _lazyFilteredLanguages = new(() => Language.LanguageDictionary
         .Values
         .Where(x => x.SupportedServices == (TranslationServices.Google | TranslationServices.Bing | TranslationServices.Yandex | TranslationServices.Microsoft))
@@ -43,7 +47,8 @@ public class UtilityModule : InteractionModuleBase<ShardedInteractionContext>
 
     public UtilityModule(ILogger<UtilityModule> logger, InteractiveService interactive, AggregateTranslator translator, GoogleTranslator googleTranslator,
         GoogleTranslator2 googleTranslator2, BingTranslator bingTranslator, MicrosoftTranslator microsoftTranslator, YandexTranslator yandexTranslator,
-        GoogleScraper googleScraper, DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper, SearchClient searchClient)
+        GoogleScraper googleScraper, DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper, SearchClient searchClient, BingVisualSearch bingVisualSearch,
+        YandexImageSearch yandexImageSearch)
     {
         _logger = logger;
         _interactive = interactive;
@@ -57,6 +62,8 @@ public UtilityModule(ILogger<UtilityModule> logger, InteractiveService interacti
         _duckDuckGoScraper = duckDuckGoScraper;
         _braveScraper = braveScraper;
         _searchClient = searchClient;
+        _bingVisualSearch = bingVisualSearch;
+        _yandexImageSearch = yandexImageSearch;
     }
 
     [MessageCommand("Bad Translator")]
@@ -370,6 +377,93 @@ Task<PageBuilder> GeneratePageAsync(int index)
         }
     }
 
+    [MessageCommand("OCR")]
+    public async Task Ocr(IMessage message)
+    {
+        var embed = message.Embeds.FirstOrDefault(x => x.Image is not null || x.Thumbnail is not null);
+
+        string? url = embed?.Image?.Url ?? embed?.Thumbnail?.Url;
+
+        if (url is null)
+        {
+            await Context.Interaction.RespondWarningAsync("Unable to get an image URL from the message.", true);
+            return;
+        }
+
+        await Ocr(url);
+    }
+
+    [SlashCommand("ocr", "Performs ocr to an image.")]
+    public async Task Ocr([Summary(description: "An image URL.")] string url)
+    {
+        if (!Uri.IsWellFormedUriString(url, UriKind.Absolute))
+        {
+            await Context.Interaction.RespondWarningAsync("The URL is not well formed.", true);
+        }
+
+        await DeferAsync();
+
+        var stopwatch = Stopwatch.StartNew();
+        string text;
+
+        try
+        {
+            text = await _bingVisualSearch.OcrAsync(url);
+        }
+        catch (Exception e)
+        {
+            _logger.LogWarning(e, "Failed to perform OCR to url {url}", url);
+            await Context.Interaction.FollowupWarning(e.Message);
+            return;
+        }
+
+        if (string.IsNullOrWhiteSpace(text))
+        {
+            await Context.Interaction.FollowupWarning("The OCR did not give results.");
+            return;
+        }
+
+        stopwatch.Stop();
+
+        Context.Interaction.TryGetLanguage(out var language);
+
+        string embedText = "**Output**\n";
+
+        var builder = new EmbedBuilder()
+            .WithTitle("OCR Results")
+            .WithDescription($"{embedText}```{text.Replace('`', '´').Truncate(EmbedBuilder.MaxDescriptionLength - embedText.Length - 6)}```")
+            .WithThumbnailUrl(url)
+            .WithFooter($"Bing Visual Search | Processing time: {stopwatch.ElapsedMilliseconds}ms", Constants.BingIconUrl)
+            .WithColor(Color.Orange);
+
+        var components = new ComponentBuilder()
+            .WithButton($"Translate{(language is null ? "" : $" to {language.Name}")}", "ocrtranslate", ButtonStyle.Secondary)
+            .WithButton("TTS", "ocrtts", ButtonStyle.Secondary)
+            .Build();
+
+        await FollowupAsync(embed: builder.Build(), components: components);
+    }
+
+    [ComponentInteraction("ocrtranslate")]
+    public async Task OcrTranslate()
+    {
+        string text = ((IComponentInteraction)Context.Interaction).Message.Embeds.First().Description;
+        int startIndex = text.IndexOf('`', StringComparison.Ordinal) + 3;
+        text = text[startIndex..^3];
+
+        await Translate(text, Context.Interaction.GetLanguageCode(), ephemeral: true);
+    }
+
+    [ComponentInteraction("ocrtts")]
+    public async Task OcrTts()
+    {
+        string text = ((IComponentInteraction)Context.Interaction).Message.Embeds.First().Description;
+        int startIndex = text.IndexOf('`', StringComparison.Ordinal) + 3;
+        text = text[startIndex..^3];
+
+        await TTS(text, ephemeral: true);
+    }
+
     [SlashCommand("say", "Says something.")]
     public async Task Say([Summary(description: "The text to send.")] string text)
     {
@@ -525,7 +619,8 @@ public async Task Translate(IMessage message)
     [SlashCommand("translate", "Translates a text.")]
     public async Task Translate([Summary(description: "The text to translate.")] string text,
         [Autocomplete(typeof(TranslateAutocompleteHandler))] [Summary(description: "Target language (name, code or alias).")] string target,
-        [Autocomplete(typeof(TranslateAutocompleteHandler))] [Summary(description: "Source language (name, code or alias).")] string? source = null)
+        [Autocomplete(typeof(TranslateAutocompleteHandler))] [Summary(description: "Source language (name, code or alias).")] string? source = null,
+        [Summary(description: "Whether to respond ephemerally.")] bool ephemeral = false)
     {
         if (string.IsNullOrWhiteSpace(text))
         {
@@ -545,7 +640,7 @@ public async Task Translate([Summary(description: "The text to translate.")] str
             return;
         }
 
-        await DeferAsync();
+        await DeferAsync(ephemeral);
         ITranslationResult result;
 
         try
@@ -555,7 +650,7 @@ public async Task Translate([Summary(description: "The text to translate.")] str
         catch (Exception e)
         {
             _logger.LogWarning(new(0, "Translate"), e, "Error translating text {text} ({source} -> {target})", text, source ?? "auto", target);
-            await Context.Interaction.FollowupWarning(e.Message);
+            await Context.Interaction.FollowupWarning(e.Message, ephemeral);
             return;
         }
 
@@ -581,7 +676,7 @@ public async Task Translate([Summary(description: "The text to translate.")] str
             .WithThumbnailUrl(thumbnailUrl)
             .WithColor(Color.Orange);
 
-        await FollowupAsync(embed: builder.Build());
+        await FollowupAsync(embed: builder.Build(), ephemeral: ephemeral);
     }
 
     [MessageCommand("TTS")]
@@ -590,7 +685,8 @@ public async Task TTS(IMessage message)
 
     [SlashCommand("tts", "Converts text into synthesized speech.")]
     public async Task TTS([Summary(description: "The text to convert.")] string text,
-        [Autocomplete(typeof(TtsAutocompleteHandler))] [Summary(description: "The target language.")] string? target = null)
+        [Autocomplete(typeof(TtsAutocompleteHandler))] [Summary(description: "The target language.")] string? target = null,
+        [Summary(description: "Whether to respond ephemerally.")] bool ephemeral = false)
     {
         if (string.IsNullOrWhiteSpace(text))
         {
@@ -606,17 +702,17 @@ public async Task TTS([Summary(description: "The text to convert.")] string text
             return;
         }
 
-        await DeferAsync();
+        await DeferAsync(ephemeral);
 
         try
         {
             await using var stream = await _googleTranslator2.TextToSpeechAsync(text, language);
-            await Context.Interaction.FollowupWithFileAsync(new FileAttachment(stream, "tts.mp3"));
+            await Context.Interaction.FollowupWithFileAsync(new FileAttachment(stream, "tts.mp3"), ephemeral: ephemeral);
         }
         catch (Exception e)
         {
             _logger.LogWarning(e, "TTS: Error obtaining TTS from text {text} ({language})", text, language);
-            await Context.Interaction.FollowupWarning(e.Message);
+            await Context.Interaction.FollowupWarning(e.Message, ephemeral);
         }
     }
 
diff --git a/src/Program.cs b/src/Program.cs
index 4394120..ff7eabd 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -3,6 +3,8 @@
 using Discord.Interactions;
 using Discord.WebSocket;
 using Fergun;
+using Fergun.Apis.Bing;
+using Fergun.Apis.Yandex;
 using Fergun.Extensions;
 using Fergun.Interactive;
 using Fergun.Modules;
@@ -55,6 +57,14 @@ await Host.CreateDefaultBuilder()
         services.AddSingleton<InteractiveService>();
         services.AddFergunPolicies();
 
+        services.AddHttpClient<BingVisualSearch>()
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
+        services.AddHttpClient<YandexImageSearch>()
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
         services.AddHttpClient<GoogleTranslator>()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();

From 20a150aba3fbd1fa1171669af6a4125e69e83421 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 5 Mar 2022 22:20:02 -0500
Subject: [PATCH 10/83] [ocr] Return if the url is invalid

---
 src/Modules/UtilityModule.cs | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 06d70e2..7447b86 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -399,6 +399,7 @@ public async Task Ocr([Summary(description: "An image URL.")] string url)
         if (!Uri.IsWellFormedUriString(url, UriKind.Absolute))
         {
             await Context.Interaction.RespondWarningAsync("The URL is not well formed.", true);
+            return;
         }
 
         await DeferAsync();

From 274113a097eda9289d672d65275aeddd309aa026 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 5 Mar 2022 22:46:13 -0500
Subject: [PATCH 11/83] [ocr] Add support for attachments in message command

---
 src/Modules/UtilityModule.cs | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 7447b86..6a558ea 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -380,9 +380,10 @@ Task<PageBuilder> GeneratePageAsync(int index)
     [MessageCommand("OCR")]
     public async Task Ocr(IMessage message)
     {
+        var attachment = message.Attachments.FirstOrDefault();
         var embed = message.Embeds.FirstOrDefault(x => x.Image is not null || x.Thumbnail is not null);
 
-        string? url = embed?.Image?.Url ?? embed?.Thumbnail?.Url;
+        string? url = attachment?.Url ?? embed?.Image?.Url ?? embed?.Thumbnail?.Url;
 
         if (url is null)
         {

From 455bddfcf0c54ce6057032463c2d399a6e6ce62e Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Fri, 11 Mar 2022 17:25:51 -0500
Subject: [PATCH 12/83] Add Urban Dictionary API wrapper and slash commands

---
 src/Apis/UrbanDictionary.cs                   | 254 ++++++++++++++++++
 src/Constants.cs                              |   2 +
 .../Handlers/UrbanAutocompleteHandler.cs      |  29 ++
 src/Modules/UrbanModule.cs                    | 106 ++++++++
 src/Program.cs                                |   5 +
 5 files changed, 396 insertions(+)
 create mode 100644 src/Apis/UrbanDictionary.cs
 create mode 100644 src/Modules/Handlers/UrbanAutocompleteHandler.cs
 create mode 100644 src/Modules/UrbanModule.cs

diff --git a/src/Apis/UrbanDictionary.cs b/src/Apis/UrbanDictionary.cs
new file mode 100644
index 0000000..c0e3a19
--- /dev/null
+++ b/src/Apis/UrbanDictionary.cs
@@ -0,0 +1,254 @@
+using System.Diagnostics;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Fergun.Apis;
+
+/// <summary>
+/// Represents an API wrapper for Urban Dictionary.
+/// </summary>
+public sealed class UrbanDictionary : IDisposable
+{
+    private static readonly Uri _apiEndpoint = new("https://api.urbandictionary.com/v0/");
+
+    private const string _defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36";
+    private readonly HttpClient _httpClient;
+    private bool _disposed;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="UrbanDictionary"/> class.
+    /// </summary>
+    public UrbanDictionary()
+        : this(new HttpClient())
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="UrbanDictionary"/> class using the specified <see cref="HttpClient"/>.
+    /// </summary>
+    /// <param name="httpClient">An instance of <see cref="HttpClient"/>.</param>
+    public UrbanDictionary(HttpClient httpClient)
+    {
+        _httpClient = httpClient;
+
+        _httpClient.BaseAddress ??= _apiEndpoint;
+
+        if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0)
+        {
+            _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_defaultUserAgent);
+        }
+    }
+
+    /// <summary>
+    /// Gets definitions for a term.
+    /// </summary>
+    /// <param name="term">The term to search.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of definitions.</returns>
+    public async Task<IEnumerable<UrbanDefinition>> GetDefinitionsAsync(string term)
+    {
+        await using var stream = await _httpClient.GetStreamAsync(new Uri($"define?term={Uri.EscapeDataString(term)}", UriKind.Relative)).ConfigureAwait(false);
+        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+        return document.RootElement.GetProperty("list").Deserialize<IEnumerable<UrbanDefinition>>()!;
+    }
+
+    /// <summary>
+    /// Gets random definitions.
+    /// </summary>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of random definitions.</returns>
+    public async Task<IEnumerable<UrbanDefinition>> GetRandomDefinitionsAsync()
+    {
+        await using var stream = await _httpClient.GetStreamAsync(new Uri("random", UriKind.Relative)).ConfigureAwait(false);
+        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+        return document.RootElement.GetProperty("list").Deserialize<IEnumerable<UrbanDefinition>>()!;
+    }
+
+    /// <summary>
+    /// Gets a definition by its ID.
+    /// </summary>
+    /// <param name="id">The ID of the definition.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains the definition, or <c>null</c> if not found.</returns>
+    public async Task<UrbanDefinition?> GetDefinitionAsync(int id)
+    {
+        await using var stream = await _httpClient.GetStreamAsync(new Uri($"define?defid={id}", UriKind.Relative)).ConfigureAwait(false);
+        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+        var list = document.RootElement.GetProperty("list");
+
+        return list.GetArrayLength() == 0 ? null : list[0].Deserialize<UrbanDefinition>()!;
+    }
+
+    /// <summary>
+    /// Gets the words of the day.
+    /// </summary>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of definitions.</returns>
+    public async Task<IEnumerable<UrbanDefinition>> GetWordsOfTheDayAsync()
+    {
+        await using var stream = await _httpClient.GetStreamAsync(new Uri("words_of_the_day", UriKind.Relative)).ConfigureAwait(false);
+        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+        return document.RootElement.GetProperty("list").Deserialize<IEnumerable<UrbanDefinition>>()!;
+    }
+
+    /// <summary>
+    /// Gets autocomplete results for a term.
+    /// </summary>
+    /// <param name="term">The term to search.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of suggested terms.</returns>
+    public async Task<IEnumerable<string>> GetAutocompleteResultsAsync(string term)
+    {
+        await using var stream = await _httpClient.GetStreamAsync(new Uri($"autocomplete?term={Uri.EscapeDataString(term)}", UriKind.Relative)).ConfigureAwait(false);
+        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+        return document.RootElement.Deserialize<IEnumerable<string>>()!;
+    }
+
+    /// <summary>
+    /// Gets autocomplete results for a term. The results contain the term and a preview definition.
+    /// </summary>
+    /// <param name="term">The term to search.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of suggested terms.</returns>
+    public async Task<IEnumerable<UrbanAutocompleteResult>> GetAutocompleteResultsExtraAsync(string term)
+    {
+        await using var stream = await _httpClient.GetStreamAsync(new Uri($"autocomplete-extra?term={Uri.EscapeDataString(term)}", UriKind.Relative)).ConfigureAwait(false);
+        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+        return document.RootElement.GetProperty("results").Deserialize<IEnumerable<UrbanAutocompleteResult>>()!;
+    }
+
+    /// <inheritdoc/>
+    public void Dispose() => Dispose(true);
+
+    /// <inheritdoc cref="Dispose()"/>
+    private void Dispose(bool disposing)
+    {
+        if (!disposing || _disposed)
+        {
+            return;
+        }
+
+        _httpClient.Dispose();
+        _disposed = true;
+    }
+}
+
+/// <summary>
+/// Represent an Urban Dictionary autocomplete result.
+/// </summary>
+[DebuggerDisplay($"{{{nameof(DebuggerDisplay)}}}")]
+public class UrbanAutocompleteResult
+{
+    [JsonConstructor]
+    public UrbanAutocompleteResult(string term, string preview)
+    {
+        Term = term;
+        Preview = preview;
+    }
+
+    /// <summary>
+    /// Gets the term of this result.
+    /// </summary>
+    [JsonPropertyName("term")]
+    public string Term { get; }
+
+    /// <summary>
+    /// Gets a preview definition of the term.
+    /// </summary>
+    [JsonPropertyName("preview")]
+    public string Preview { get; }
+
+    /// <inheritdoc/>
+    public override string ToString() => $"Term = {Term}, Preview = {Preview}";
+
+    private string DebuggerDisplay => ToString();
+}
+
+/// <summary>
+/// Represents an Urban Dictionary definition.
+/// </summary>
+[DebuggerDisplay($"{{{nameof(DebuggerDisplay)}}}")]
+public class UrbanDefinition
+{
+    [JsonConstructor]
+    public UrbanDefinition(string definition, string? date, string permalink, int thumbsUp, IReadOnlyCollection<string> soundUrls,
+        string author, string word, int id, DateTimeOffset writtenOn, string example, int thumbsDown)
+    {
+        Definition = definition;
+        Date = date;
+        Permalink = permalink;
+        ThumbsUp = thumbsUp;
+        SoundUrls = soundUrls;
+        Author = author;
+        Word = word;
+        Id = id;
+        WrittenOn = writtenOn;
+        Example = example;
+        ThumbsDown = thumbsDown;
+    }
+
+    /// <summary>
+    /// Gets the definition.
+    /// </summary>
+    [JsonPropertyName("definition")]
+    public string Definition { get; }
+
+    /// <summary>
+    /// Gets the date this definition was posted on the front page as a word of the day.
+    /// </summary>
+    [JsonPropertyName("date")]
+    public string? Date { get; }
+
+    /// <summary>
+    /// Gets a permalink to the page containing this definition.
+    /// </summary>
+    [JsonPropertyName("permalink")]
+    public string Permalink { get; }
+
+    /// <summary>
+    /// Gets the number of thumps-up.
+    /// </summary>
+    [JsonPropertyName("thumbs_up")]
+    public int ThumbsUp { get; }
+
+    /// <summary>
+    /// Gets a collection of sound URLs.
+    /// </summary>
+    [JsonPropertyName("sound_urls")]
+    public IReadOnlyCollection<string> SoundUrls { get; }
+
+    /// <summary>
+    /// Gets the author of this definition.
+    /// </summary>
+    [JsonPropertyName("author")]
+    public string Author { get; }
+
+    /// <summary>
+    /// Gets the word (term) being defined.
+    /// </summary>
+    [JsonPropertyName("word")]
+    public string Word { get; }
+
+    /// <summary>
+    /// Gets the ID of this definition.
+    /// </summary>
+    [JsonPropertyName("defid")]
+    public int Id { get; }
+
+    /// <summary>
+    /// Gets the date this definition was written.
+    /// </summary>
+    [JsonPropertyName("written_on")]
+    public DateTimeOffset WrittenOn { get; }
+
+    /// <summary>
+    /// Gets an example usage of the definition.
+    /// </summary>
+    [JsonPropertyName("example")]
+    public string Example { get; }
+
+    /// <summary>
+    /// Gets the number of thumps-down.
+    /// </summary>
+    [JsonPropertyName("thumbs_down")]
+    public int ThumbsDown { get; }
+
+    /// <inheritdoc/>
+    public override string ToString() => $"Word = {Word}, Definition = {Definition}";
+
+    private string DebuggerDisplay => ToString();
+}
\ No newline at end of file
diff --git a/src/Constants.cs b/src/Constants.cs
index 86ae24b..f99163d 100644
--- a/src/Constants.cs
+++ b/src/Constants.cs
@@ -22,5 +22,7 @@ public static class Constants
 
     public const string BingIconUrl = "https://cdn.discordapp.com/attachments/838832564583661638/949767220232339507/Bing_Icon.png";
 
+    public const string UrbanDictionaryIconUrl = "https://cdn.discordapp.com/attachments/838832564583661638/951936600273715300/UrbanDictionary.png";
+
     public const string ChromeUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36";
 }
\ No newline at end of file
diff --git a/src/Modules/Handlers/UrbanAutocompleteHandler.cs b/src/Modules/Handlers/UrbanAutocompleteHandler.cs
new file mode 100644
index 0000000..15a4cf9
--- /dev/null
+++ b/src/Modules/Handlers/UrbanAutocompleteHandler.cs
@@ -0,0 +1,29 @@
+using Discord;
+using Discord.Interactions;
+using Fergun.Apis;
+using Humanizer;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Fergun.Modules.Handlers;
+
+public class UrbanAutocompleteHandler : AutocompleteHandler
+{
+    /// <inheritdoc />
+    public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context,
+        IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
+    {
+        var text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim().Truncate(100, string.Empty);
+
+        if (string.IsNullOrEmpty(text))
+            return AutocompletionResult.FromSuccess();
+
+        var urbanDictionary = services
+            .GetRequiredService<UrbanDictionary>();
+
+        var results = (await urbanDictionary.GetAutocompleteResultsAsync(text))
+            .Select(x => new AutocompleteResult(x, x))
+            .Take(25);
+
+        return AutocompletionResult.FromSuccess(results);
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/UrbanModule.cs b/src/Modules/UrbanModule.cs
new file mode 100644
index 0000000..d839728
--- /dev/null
+++ b/src/Modules/UrbanModule.cs
@@ -0,0 +1,106 @@
+using System.Text;
+using Discord;
+using Discord.Interactions;
+using Fergun.Apis;
+using Fergun.Extensions;
+using Fergun.Interactive;
+using Fergun.Interactive.Pagination;
+using Fergun.Modules.Handlers;
+
+namespace Fergun.Modules;
+
+[Group("urban", "Urban Dictionary commands")]
+public class UrbanModule : InteractionModuleBase<ShardedInteractionContext>
+{
+    private readonly UrbanDictionary _urbanDictionary;
+    private readonly InteractiveService _interactive;
+
+    public UrbanModule(UrbanDictionary urbanDictionary, InteractiveService interactive)
+    {
+        _urbanDictionary = urbanDictionary;
+        _interactive = interactive;
+    }
+
+    [SlashCommand("search", "Searches for definitions for a term in Urban Dictionary.")]
+    public async Task Search([Autocomplete(typeof(UrbanAutocompleteHandler))] [Summary(description: "The term to search.")] string term)
+        => await SearchInternalAsync(UrbanSearchType.Search, term);
+
+    [SlashCommand("random", "Gets random definitions from Urban Dictionary.")]
+    public async Task Random() => await SearchInternalAsync(UrbanSearchType.Random);
+
+    [SlashCommand("words-of-the-day", "Gets the words of the day in Urban Dictionary.")]
+    public async Task WordsOfTheDay() => await SearchInternalAsync(UrbanSearchType.WordsOfTheDay);
+
+    private async Task SearchInternalAsync(UrbanSearchType searchType, string? term = null)
+    {
+        await DeferAsync();
+
+        var definitions = searchType switch
+        {
+            UrbanSearchType.Search => (await _urbanDictionary.GetDefinitionsAsync(term!)).ToArray(),
+            UrbanSearchType.Random => (await _urbanDictionary.GetRandomDefinitionsAsync()).ToArray(),
+            UrbanSearchType.WordsOfTheDay => (await _urbanDictionary.GetWordsOfTheDayAsync()).ToArray(),
+            _ => throw new InvalidOperationException(),
+        };
+
+        if (definitions.Length == 0)
+        {
+            await Context.Interaction.FollowupWarning("No results.");
+            return;
+        }
+
+        var paginator = new LazyPaginatorBuilder()
+            .WithPageFactory(GeneratePage)
+            .WithFergunEmotes()
+            .WithActionOnCancellation(ActionOnStop.DisableInput)
+            .WithActionOnTimeout(ActionOnStop.DisableInput)
+            .WithMaxPageIndex(definitions.Length - 1)
+            .WithFooter(PaginatorFooter.None)
+            .AddUser(Context.User)
+            .Build();
+
+        _ = _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+
+        PageBuilder GeneratePage(int i)
+        {
+            var description = new StringBuilder(definitions[i].Definition.Length + definitions[i].Example.Length);
+            description.Append(Format.Sanitize(definitions[i].Definition));
+            if (!string.IsNullOrEmpty(definitions[i].Example))
+            {
+                description.Append("\n\n");
+                description.Append(Format.Italics(Format.Sanitize(definitions[i].Example.Trim())));
+            }
+
+            var footer = new StringBuilder("Urban Dictionary ");
+            switch (searchType)
+            {
+                case UrbanSearchType.Random:
+                    footer.Append("(Random Definitions) ");
+                    break;
+                case UrbanSearchType.WordsOfTheDay:
+                    footer.Append($"(Words of the day, {definitions[i].Date}) ");
+                    break;
+            }
+
+            footer.Append($"- Page {i + 1} of {definitions.Length}");
+
+            return new PageBuilder()
+                .WithTitle(definitions[i].Word)
+                .WithUrl(definitions[i].Permalink)
+                .WithAuthor($"By {definitions[i].Author}", url: $"https://www.urbandictionary.com/author.php?author={Uri.EscapeDataString(definitions[i].Author)}")
+                .WithDescription(description.ToString())
+                .AddField("👍", definitions[i].ThumbsUp, true)
+                .AddField("👎", definitions[i].ThumbsDown, true)
+                .WithFooter(footer.ToString(), Constants.UrbanDictionaryIconUrl)
+                .WithTimestamp(definitions[i].WrittenOn)
+                .WithColor(Color.Orange); // 0x10151BU 0x1B2936U
+        }
+    }
+
+    private enum UrbanSearchType
+    {
+        Search,
+        Random,
+        WordsOfTheDay
+    }
+}
\ No newline at end of file
diff --git a/src/Program.cs b/src/Program.cs
index ff7eabd..b98af1f 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -3,6 +3,7 @@
 using Discord.Interactions;
 using Discord.WebSocket;
 using Fergun;
+using Fergun.Apis;
 using Fergun.Apis.Bing;
 using Fergun.Apis.Yandex;
 using Fergun.Extensions;
@@ -65,6 +66,10 @@ await Host.CreateDefaultBuilder()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
 
+        services.AddHttpClient<UrbanDictionary>()
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
         services.AddHttpClient<GoogleTranslator>()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();

From 5273ea9c274ed6bbee93e0113dc39b6d64707d22 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 13 Mar 2022 15:08:54 -0500
Subject: [PATCH 13/83] Change return type of UrbanDictionary API methods to
 `IReadOnlyList`

---
 src/Apis/UrbanDictionary.cs | 21 ++++++++++-----------
 src/Modules/UrbanModule.cs  | 12 ++++++------
 2 files changed, 16 insertions(+), 17 deletions(-)

diff --git a/src/Apis/UrbanDictionary.cs b/src/Apis/UrbanDictionary.cs
index c0e3a19..58dfd4b 100644
--- a/src/Apis/UrbanDictionary.cs
+++ b/src/Apis/UrbanDictionary.cs
@@ -44,22 +44,22 @@ public UrbanDictionary(HttpClient httpClient)
     /// </summary>
     /// <param name="term">The term to search.</param>
     /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of definitions.</returns>
-    public async Task<IEnumerable<UrbanDefinition>> GetDefinitionsAsync(string term)
+    public async Task<IReadOnlyList<UrbanDefinition>> GetDefinitionsAsync(string term)
     {
         await using var stream = await _httpClient.GetStreamAsync(new Uri($"define?term={Uri.EscapeDataString(term)}", UriKind.Relative)).ConfigureAwait(false);
         using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
-        return document.RootElement.GetProperty("list").Deserialize<IEnumerable<UrbanDefinition>>()!;
+        return document.RootElement.GetProperty("list").Deserialize<IReadOnlyList<UrbanDefinition>>()!;
     }
 
     /// <summary>
     /// Gets random definitions.
     /// </summary>
     /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of random definitions.</returns>
-    public async Task<IEnumerable<UrbanDefinition>> GetRandomDefinitionsAsync()
+    public async Task<IReadOnlyList<UrbanDefinition>> GetRandomDefinitionsAsync()
     {
         await using var stream = await _httpClient.GetStreamAsync(new Uri("random", UriKind.Relative)).ConfigureAwait(false);
         using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
-        return document.RootElement.GetProperty("list").Deserialize<IEnumerable<UrbanDefinition>>()!;
+        return document.RootElement.GetProperty("list").Deserialize<IReadOnlyList<UrbanDefinition>>()!;
     }
 
     /// <summary>
@@ -80,11 +80,11 @@ public async Task<IEnumerable<UrbanDefinition>> GetRandomDefinitionsAsync()
     /// Gets the words of the day.
     /// </summary>
     /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of definitions.</returns>
-    public async Task<IEnumerable<UrbanDefinition>> GetWordsOfTheDayAsync()
+    public async Task<IReadOnlyList<UrbanDefinition>> GetWordsOfTheDayAsync()
     {
         await using var stream = await _httpClient.GetStreamAsync(new Uri("words_of_the_day", UriKind.Relative)).ConfigureAwait(false);
         using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
-        return document.RootElement.GetProperty("list").Deserialize<IEnumerable<UrbanDefinition>>()!;
+        return document.RootElement.GetProperty("list").Deserialize<IReadOnlyList<UrbanDefinition>>()!;
     }
 
     /// <summary>
@@ -92,11 +92,10 @@ public async Task<IEnumerable<UrbanDefinition>> GetWordsOfTheDayAsync()
     /// </summary>
     /// <param name="term">The term to search.</param>
     /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of suggested terms.</returns>
-    public async Task<IEnumerable<string>> GetAutocompleteResultsAsync(string term)
+    public async Task<IReadOnlyList<string>> GetAutocompleteResultsAsync(string term)
     {
         await using var stream = await _httpClient.GetStreamAsync(new Uri($"autocomplete?term={Uri.EscapeDataString(term)}", UriKind.Relative)).ConfigureAwait(false);
-        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
-        return document.RootElement.Deserialize<IEnumerable<string>>()!;
+        return (await JsonSerializer.DeserializeAsync<IReadOnlyList<string>>(stream).ConfigureAwait(false))!;
     }
 
     /// <summary>
@@ -104,11 +103,11 @@ public async Task<IEnumerable<string>> GetAutocompleteResultsAsync(string term)
     /// </summary>
     /// <param name="term">The term to search.</param>
     /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of suggested terms.</returns>
-    public async Task<IEnumerable<UrbanAutocompleteResult>> GetAutocompleteResultsExtraAsync(string term)
+    public async Task<IReadOnlyList<UrbanAutocompleteResult>> GetAutocompleteResultsExtraAsync(string term)
     {
         await using var stream = await _httpClient.GetStreamAsync(new Uri($"autocomplete-extra?term={Uri.EscapeDataString(term)}", UriKind.Relative)).ConfigureAwait(false);
         using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
-        return document.RootElement.GetProperty("results").Deserialize<IEnumerable<UrbanAutocompleteResult>>()!;
+        return document.RootElement.GetProperty("results").Deserialize<IReadOnlyList<UrbanAutocompleteResult>>()!;
     }
 
     /// <inheritdoc/>
diff --git a/src/Modules/UrbanModule.cs b/src/Modules/UrbanModule.cs
index d839728..6b5cbbd 100644
--- a/src/Modules/UrbanModule.cs
+++ b/src/Modules/UrbanModule.cs
@@ -37,13 +37,13 @@ private async Task SearchInternalAsync(UrbanSearchType searchType, string? term
 
         var definitions = searchType switch
         {
-            UrbanSearchType.Search => (await _urbanDictionary.GetDefinitionsAsync(term!)).ToArray(),
-            UrbanSearchType.Random => (await _urbanDictionary.GetRandomDefinitionsAsync()).ToArray(),
-            UrbanSearchType.WordsOfTheDay => (await _urbanDictionary.GetWordsOfTheDayAsync()).ToArray(),
+            UrbanSearchType.Search => await _urbanDictionary.GetDefinitionsAsync(term!),
+            UrbanSearchType.Random => await _urbanDictionary.GetRandomDefinitionsAsync(),
+            UrbanSearchType.WordsOfTheDay => await _urbanDictionary.GetWordsOfTheDayAsync(),
             _ => throw new InvalidOperationException(),
         };
 
-        if (definitions.Length == 0)
+        if (definitions.Count == 0)
         {
             await Context.Interaction.FollowupWarning("No results.");
             return;
@@ -54,7 +54,7 @@ private async Task SearchInternalAsync(UrbanSearchType searchType, string? term
             .WithFergunEmotes()
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
-            .WithMaxPageIndex(definitions.Length - 1)
+            .WithMaxPageIndex(definitions.Count - 1)
             .WithFooter(PaginatorFooter.None)
             .AddUser(Context.User)
             .Build();
@@ -82,7 +82,7 @@ PageBuilder GeneratePage(int i)
                     break;
             }
 
-            footer.Append($"- Page {i + 1} of {definitions.Length}");
+            footer.Append($"- Page {i + 1} of {definitions.Count}");
 
             return new PageBuilder()
                 .WithTitle(definitions[i].Word)

From 0bb2ec3d58d3fe953bf2d0dbe93fe5f7f8c17ec4 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 13 Mar 2022 18:18:27 -0500
Subject: [PATCH 14/83] Add UrbanDictionary unit tests

---
 src/Apis/UrbanDictionary.cs                |  28 +++--
 tests/Fergun.Tests/UrbanDictionaryTests.cs | 131 +++++++++++++++++++++
 2 files changed, 152 insertions(+), 7 deletions(-)
 create mode 100644 tests/Fergun.Tests/UrbanDictionaryTests.cs

diff --git a/src/Apis/UrbanDictionary.cs b/src/Apis/UrbanDictionary.cs
index 58dfd4b..5dee94c 100644
--- a/src/Apis/UrbanDictionary.cs
+++ b/src/Apis/UrbanDictionary.cs
@@ -1,4 +1,5 @@
 using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
 using System.Text.Json;
 using System.Text.Json.Serialization;
 
@@ -46,6 +47,7 @@ public UrbanDictionary(HttpClient httpClient)
     /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of definitions.</returns>
     public async Task<IReadOnlyList<UrbanDefinition>> GetDefinitionsAsync(string term)
     {
+        EnsureNotDisposed();
         await using var stream = await _httpClient.GetStreamAsync(new Uri($"define?term={Uri.EscapeDataString(term)}", UriKind.Relative)).ConfigureAwait(false);
         using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
         return document.RootElement.GetProperty("list").Deserialize<IReadOnlyList<UrbanDefinition>>()!;
@@ -57,6 +59,7 @@ public async Task<IReadOnlyList<UrbanDefinition>> GetDefinitionsAsync(string ter
     /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of random definitions.</returns>
     public async Task<IReadOnlyList<UrbanDefinition>> GetRandomDefinitionsAsync()
     {
+        EnsureNotDisposed();
         await using var stream = await _httpClient.GetStreamAsync(new Uri("random", UriKind.Relative)).ConfigureAwait(false);
         using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
         return document.RootElement.GetProperty("list").Deserialize<IReadOnlyList<UrbanDefinition>>()!;
@@ -69,6 +72,7 @@ public async Task<IReadOnlyList<UrbanDefinition>> GetRandomDefinitionsAsync()
     /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains the definition, or <c>null</c> if not found.</returns>
     public async Task<UrbanDefinition?> GetDefinitionAsync(int id)
     {
+        EnsureNotDisposed();
         await using var stream = await _httpClient.GetStreamAsync(new Uri($"define?defid={id}", UriKind.Relative)).ConfigureAwait(false);
         using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
         var list = document.RootElement.GetProperty("list");
@@ -82,6 +86,7 @@ public async Task<IReadOnlyList<UrbanDefinition>> GetRandomDefinitionsAsync()
     /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of definitions.</returns>
     public async Task<IReadOnlyList<UrbanDefinition>> GetWordsOfTheDayAsync()
     {
+        EnsureNotDisposed();
         await using var stream = await _httpClient.GetStreamAsync(new Uri("words_of_the_day", UriKind.Relative)).ConfigureAwait(false);
         using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
         return document.RootElement.GetProperty("list").Deserialize<IReadOnlyList<UrbanDefinition>>()!;
@@ -94,6 +99,7 @@ public async Task<IReadOnlyList<UrbanDefinition>> GetWordsOfTheDayAsync()
     /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of suggested terms.</returns>
     public async Task<IReadOnlyList<string>> GetAutocompleteResultsAsync(string term)
     {
+        EnsureNotDisposed();
         await using var stream = await _httpClient.GetStreamAsync(new Uri($"autocomplete?term={Uri.EscapeDataString(term)}", UriKind.Relative)).ConfigureAwait(false);
         return (await JsonSerializer.DeserializeAsync<IReadOnlyList<string>>(stream).ConfigureAwait(false))!;
     }
@@ -105,18 +111,16 @@ public async Task<IReadOnlyList<string>> GetAutocompleteResultsAsync(string term
     /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of suggested terms.</returns>
     public async Task<IReadOnlyList<UrbanAutocompleteResult>> GetAutocompleteResultsExtraAsync(string term)
     {
+        EnsureNotDisposed();
         await using var stream = await _httpClient.GetStreamAsync(new Uri($"autocomplete-extra?term={Uri.EscapeDataString(term)}", UriKind.Relative)).ConfigureAwait(false);
         using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
         return document.RootElement.GetProperty("results").Deserialize<IReadOnlyList<UrbanAutocompleteResult>>()!;
     }
 
     /// <inheritdoc/>
-    public void Dispose() => Dispose(true);
-
-    /// <inheritdoc cref="Dispose()"/>
-    private void Dispose(bool disposing)
+    public void Dispose()
     {
-        if (!disposing || _disposed)
+        if (_disposed)
         {
             return;
         }
@@ -124,6 +128,14 @@ private void Dispose(bool disposing)
         _httpClient.Dispose();
         _disposed = true;
     }
+
+    private void EnsureNotDisposed()
+    {
+        if (_disposed)
+        {
+            throw new ObjectDisposedException(nameof(UrbanDictionary));
+        }
+    }
 }
 
 /// <summary>
@@ -152,8 +164,9 @@ public UrbanAutocompleteResult(string term, string preview)
     public string Preview { get; }
 
     /// <inheritdoc/>
-    public override string ToString() => $"Term = {Term}, Preview = {Preview}";
+    public override string ToString() => $"{nameof(Term)} = {Term}, {nameof(Preview)} = {Preview}";
 
+    [ExcludeFromCodeCoverage]
     private string DebuggerDisplay => ToString();
 }
 
@@ -247,7 +260,8 @@ public UrbanDefinition(string definition, string? date, string permalink, int th
     public int ThumbsDown { get; }
 
     /// <inheritdoc/>
-    public override string ToString() => $"Word = {Word}, Definition = {Definition}";
+    public override string ToString() => $"{nameof(Word)} = {Word}, {nameof(Definition)} = {Definition}";
 
+    [ExcludeFromCodeCoverage]
     private string DebuggerDisplay => ToString();
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/UrbanDictionaryTests.cs b/tests/Fergun.Tests/UrbanDictionaryTests.cs
new file mode 100644
index 0000000..f4e30e1
--- /dev/null
+++ b/tests/Fergun.Tests/UrbanDictionaryTests.cs
@@ -0,0 +1,131 @@
+using System;
+using System.Threading.Tasks;
+using Fergun.Apis;
+using Moq;
+using Xunit;
+
+namespace Fergun.Tests;
+
+public class UrbanDictionaryTests
+{
+    private readonly UrbanDictionary _urbanDictionary = new();
+
+    [InlineData("lol")]
+    [InlineData("cringe")]
+    [InlineData("yikes")]
+    [InlineData("bruh")]
+    [Theory]
+    public async Task GetDefinitionsAsync_Returns_Definitions(string term)
+    {
+        var definitions = await _urbanDictionary.GetDefinitionsAsync(term);
+
+        Assert.NotNull(definitions);
+        Assert.NotEmpty(definitions);
+        Assert.All(definitions, AssertDefinitionProperties);
+    }
+
+    [Fact]
+    public async Task GetRandomDefinitionsAsync_Returns_Definitions()
+    {
+        var definitions = await _urbanDictionary.GetRandomDefinitionsAsync();
+
+        Assert.NotNull(definitions);
+        Assert.NotEmpty(definitions);
+        Assert.All(definitions, AssertDefinitionProperties);
+    }
+
+    [InlineData(871139)]
+    [InlineData(15369452)]
+    [Theory]
+    public async Task GetDefinitionAsync_Returns_Definition(int id)
+    {
+        var definition = await _urbanDictionary.GetDefinitionAsync(id);
+
+        Assert.NotNull(definition);
+        Assert.Equal(id, definition!.Id);
+    }
+
+    [InlineData(int.MaxValue)]
+    [InlineData(0)]
+    [Theory]
+    public async Task GetDefinitionAsync_Returns_Null_If_Id_Is_Invalid(int id)
+    {
+        var definition = await _urbanDictionary.GetDefinitionAsync(id);
+
+        Assert.Null(definition);
+    }
+
+    [Fact]
+    public async Task GetWordsOfTheDayAsync_Returns_Definitions()
+    {
+        var definitions = await _urbanDictionary.GetWordsOfTheDayAsync();
+
+        Assert.NotNull(definitions);
+        Assert.NotEmpty(definitions);
+        Assert.All(definitions, x => Assert.NotNull(x.Date));
+        Assert.All(definitions, x => Assert.NotEmpty(x.Date!));
+        Assert.All(definitions, AssertDefinitionProperties);
+    }
+
+    [InlineData("lo")]
+    [InlineData("g")]
+    [InlineData("h")]
+    [InlineData("s")]
+    [Theory]
+    public async Task GetAutocompleteResultsAsync_Returns_Results(string term)
+    {
+        var results = await _urbanDictionary.GetAutocompleteResultsAsync(term);
+
+        Assert.NotNull(results);
+        Assert.All(results, Assert.NotNull);
+        Assert.All(results, Assert.NotEmpty);
+    }
+
+    [InlineData("lo")]
+    [InlineData("g")]
+    [InlineData("s")]
+    [Theory]
+    public async Task GetAutocompleteResultsExtraAsync_Returns_Results(string term)
+    {
+        var results = await _urbanDictionary.GetAutocompleteResultsExtraAsync(term);
+
+        Assert.NotNull(results);
+        Assert.All(results, x => Assert.NotNull(x.Term));
+        Assert.All(results, x => Assert.NotEmpty(x.Term));
+        Assert.All(results, x => Assert.NotNull(x.Preview));
+        Assert.All(results, x => Assert.NotEmpty(x.Preview));
+        Assert.All(results, x => Assert.NotNull(x.ToString()));
+    }
+
+    [Fact]
+    public async Task Disposed_UrbanDictionary_Usage_Should_Throw_ObjectDisposedException()
+    {
+        _urbanDictionary.Dispose();
+        _urbanDictionary.Dispose();
+
+        await Assert.ThrowsAsync<ObjectDisposedException>(() => _urbanDictionary.GetDefinitionsAsync(It.IsAny<string>()));
+        await Assert.ThrowsAsync<ObjectDisposedException>(() => _urbanDictionary.GetRandomDefinitionsAsync());
+        await Assert.ThrowsAsync<ObjectDisposedException>(() => _urbanDictionary.GetDefinitionAsync(It.IsAny<int>()));
+        await Assert.ThrowsAsync<ObjectDisposedException>(() => _urbanDictionary.GetWordsOfTheDayAsync());
+        await Assert.ThrowsAsync<ObjectDisposedException>(() => _urbanDictionary.GetAutocompleteResultsAsync(It.IsAny<string>()));
+        await Assert.ThrowsAsync<ObjectDisposedException>(() => _urbanDictionary.GetAutocompleteResultsExtraAsync(It.IsAny<string>()));
+    }
+
+    private static void AssertDefinitionProperties(UrbanDefinition definition)
+    {
+        Assert.NotNull(definition.Word);
+        Assert.NotEmpty(definition.Word);
+        Assert.NotNull(definition.Definition);
+        Assert.NotEmpty(definition.Definition);
+        Assert.NotNull(definition.Permalink);
+        Assert.NotEmpty(definition.Permalink);
+        Assert.NotNull(definition.Author);
+        Assert.NotEmpty(definition.Author);
+        Assert.NotNull(definition.SoundUrls);
+        Assert.NotNull(definition.Example);
+        Assert.True(definition.ThumbsDown >= 0);
+        Assert.True(definition.ThumbsUp >= 0);
+        Assert.NotEqual(default, definition.WrittenOn);
+        Assert.NotNull(definition.ToString());
+    }
+}
\ No newline at end of file

From 9c7f764f7efba7beb4e1b6ced53da481e8f6fc82 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Wed, 16 Mar 2022 21:21:10 -0500
Subject: [PATCH 15/83] Add more tests

---
 src/Apis/Urban/IUrbanDictionary.cs            |  47 +++
 src/Apis/Urban/UrbanAutocompleteResult.cs     |  37 +++
 src/Apis/Urban/UrbanDefinition.cs             | 101 +++++++
 src/Apis/Urban/UrbanDictionary.cs             | 114 ++++++++
 src/Apis/UrbanDictionary.cs                   | 267 ------------------
 .../Handlers/UrbanAutocompleteHandler.cs      |   4 +-
 src/Modules/UrbanModule.cs                    |  29 +-
 src/Program.cs                                |   3 +-
 .../Fergun.Tests/AutocompleteHandlerTests.cs  |  37 +++
 tests/Fergun.Tests/Fergun.Tests.csproj        |   2 +
 tests/Fergun.Tests/UrbanDictionaryTests.cs    |   2 +-
 tests/Fergun.Tests/UrbanModuleTests.cs        | 105 +++++++
 12 files changed, 465 insertions(+), 283 deletions(-)
 create mode 100644 src/Apis/Urban/IUrbanDictionary.cs
 create mode 100644 src/Apis/Urban/UrbanAutocompleteResult.cs
 create mode 100644 src/Apis/Urban/UrbanDefinition.cs
 create mode 100644 src/Apis/Urban/UrbanDictionary.cs
 delete mode 100644 src/Apis/UrbanDictionary.cs
 create mode 100644 tests/Fergun.Tests/UrbanModuleTests.cs

diff --git a/src/Apis/Urban/IUrbanDictionary.cs b/src/Apis/Urban/IUrbanDictionary.cs
new file mode 100644
index 0000000..f8eab31
--- /dev/null
+++ b/src/Apis/Urban/IUrbanDictionary.cs
@@ -0,0 +1,47 @@
+namespace Fergun.Apis.Urban;
+
+/// <summary>
+/// Represents an Urban Dictionary API.
+/// </summary>
+public interface IUrbanDictionary
+{
+    /// <summary>
+    /// Gets definitions for a term.
+    /// </summary>
+    /// <param name="term">The term to search.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of definitions.</returns>
+    Task<IReadOnlyList<UrbanDefinition>> GetDefinitionsAsync(string term);
+
+    /// <summary>
+    /// Gets random definitions.
+    /// </summary>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of random definitions.</returns>
+    Task<IReadOnlyList<UrbanDefinition>> GetRandomDefinitionsAsync();
+
+    /// <summary>
+    /// Gets a definition by its ID.
+    /// </summary>
+    /// <param name="id">The ID of the definition.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains the definition, or <c>null</c> if not found.</returns>
+    Task<UrbanDefinition?> GetDefinitionAsync(int id);
+
+    /// <summary>
+    /// Gets the words of the day.
+    /// </summary>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of definitions.</returns>
+    Task<IReadOnlyList<UrbanDefinition>> GetWordsOfTheDayAsync();
+
+    /// <summary>
+    /// Gets autocomplete results for a term.
+    /// </summary>
+    /// <param name="term">The term to search.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of suggested terms.</returns>
+    Task<IReadOnlyList<string>> GetAutocompleteResultsAsync(string term);
+
+    /// <summary>
+    /// Gets autocomplete results for a term. The results contain the term and a preview definition.
+    /// </summary>
+    /// <param name="term">The term to search.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of suggested terms.</returns>
+    Task<IReadOnlyList<UrbanAutocompleteResult>> GetAutocompleteResultsExtraAsync(string term);
+}
\ No newline at end of file
diff --git a/src/Apis/Urban/UrbanAutocompleteResult.cs b/src/Apis/Urban/UrbanAutocompleteResult.cs
new file mode 100644
index 0000000..6cab585
--- /dev/null
+++ b/src/Apis/Urban/UrbanAutocompleteResult.cs
@@ -0,0 +1,37 @@
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+namespace Fergun.Apis.Urban;
+
+/// <summary>
+/// Represent an Urban Dictionary autocomplete result.
+/// </summary>
+[DebuggerDisplay($"{{{nameof(DebuggerDisplay)}}}")]
+public class UrbanAutocompleteResult
+{
+    [JsonConstructor]
+    public UrbanAutocompleteResult(string term, string preview)
+    {
+        Term = term;
+        Preview = preview;
+    }
+
+    /// <summary>
+    /// Gets the term of this result.
+    /// </summary>
+    [JsonPropertyName("term")]
+    public string Term { get; }
+
+    /// <summary>
+    /// Gets a preview definition of the term.
+    /// </summary>
+    [JsonPropertyName("preview")]
+    public string Preview { get; }
+
+    /// <inheritdoc/>
+    public override string ToString() => $"{nameof(Term)} = {Term}, {nameof(Preview)} = {Preview}";
+
+    [ExcludeFromCodeCoverage]
+    private string DebuggerDisplay => ToString();
+}
\ No newline at end of file
diff --git a/src/Apis/Urban/UrbanDefinition.cs b/src/Apis/Urban/UrbanDefinition.cs
new file mode 100644
index 0000000..54b7829
--- /dev/null
+++ b/src/Apis/Urban/UrbanDefinition.cs
@@ -0,0 +1,101 @@
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+namespace Fergun.Apis.Urban;
+
+/// <summary>
+/// Represents an Urban Dictionary definition.
+/// </summary>
+[DebuggerDisplay($"{{{nameof(DebuggerDisplay)}}}")]
+public class UrbanDefinition
+{
+    [JsonConstructor]
+    public UrbanDefinition(string definition, string? date, string permalink, int thumbsUp, IReadOnlyCollection<string> soundUrls,
+        string author, string word, int id, DateTimeOffset writtenOn, string example, int thumbsDown)
+    {
+        Definition = definition;
+        Date = date;
+        Permalink = permalink;
+        ThumbsUp = thumbsUp;
+        SoundUrls = soundUrls;
+        Author = author;
+        Word = word;
+        Id = id;
+        WrittenOn = writtenOn;
+        Example = example;
+        ThumbsDown = thumbsDown;
+    }
+
+    /// <summary>
+    /// Gets the definition.
+    /// </summary>
+    [JsonPropertyName("definition")]
+    public string Definition { get; }
+
+    /// <summary>
+    /// Gets the date this definition was posted on the front page as a word of the day.
+    /// </summary>
+    [JsonPropertyName("date")]
+    public string? Date { get; }
+
+    /// <summary>
+    /// Gets a permalink to the page containing this definition.
+    /// </summary>
+    [JsonPropertyName("permalink")]
+    public string Permalink { get; }
+
+    /// <summary>
+    /// Gets the number of thumps-up.
+    /// </summary>
+    [JsonPropertyName("thumbs_up")]
+    public int ThumbsUp { get; }
+
+    /// <summary>
+    /// Gets a collection of sound URLs.
+    /// </summary>
+    [JsonPropertyName("sound_urls")]
+    public IReadOnlyCollection<string> SoundUrls { get; }
+
+    /// <summary>
+    /// Gets the author of this definition.
+    /// </summary>
+    [JsonPropertyName("author")]
+    public string Author { get; }
+
+    /// <summary>
+    /// Gets the word (term) being defined.
+    /// </summary>
+    [JsonPropertyName("word")]
+    public string Word { get; }
+
+    /// <summary>
+    /// Gets the ID of this definition.
+    /// </summary>
+    [JsonPropertyName("defid")]
+    public int Id { get; }
+
+    /// <summary>
+    /// Gets the date this definition was written.
+    /// </summary>
+    [JsonPropertyName("written_on")]
+    public DateTimeOffset WrittenOn { get; }
+
+    /// <summary>
+    /// Gets an example usage of the definition.
+    /// </summary>
+    [JsonPropertyName("example")]
+    public string Example { get; }
+
+    /// <summary>
+    /// Gets the number of thumps-down.
+    /// </summary>
+    [JsonPropertyName("thumbs_down")]
+    public int ThumbsDown { get; }
+
+    /// <inheritdoc/>
+    public override string ToString() => $"{nameof(Word)} = {Word}, {nameof(Definition)} = {Definition}";
+
+    [ExcludeFromCodeCoverage]
+    private string DebuggerDisplay => ToString();
+}
\ No newline at end of file
diff --git a/src/Apis/Urban/UrbanDictionary.cs b/src/Apis/Urban/UrbanDictionary.cs
new file mode 100644
index 0000000..0efb1a7
--- /dev/null
+++ b/src/Apis/Urban/UrbanDictionary.cs
@@ -0,0 +1,114 @@
+using System.Text.Json;
+
+namespace Fergun.Apis.Urban;
+
+/// <summary>
+/// Represents an API wrapper for Urban Dictionary.
+/// </summary>
+public sealed class UrbanDictionary : IDisposable, IUrbanDictionary
+{
+    private static readonly Uri _apiEndpoint = new("https://api.urbandictionary.com/v0/");
+
+    private const string _defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36";
+    private readonly HttpClient _httpClient;
+    private bool _disposed;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="UrbanDictionary"/> class.
+    /// </summary>
+    public UrbanDictionary()
+        : this(new HttpClient())
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="UrbanDictionary"/> class using the specified <see cref="HttpClient"/>.
+    /// </summary>
+    /// <param name="httpClient">An instance of <see cref="HttpClient"/>.</param>
+    public UrbanDictionary(HttpClient httpClient)
+    {
+        _httpClient = httpClient;
+
+        _httpClient.BaseAddress ??= _apiEndpoint;
+
+        if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0)
+        {
+            _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_defaultUserAgent);
+        }
+    }
+
+    /// <inheritdoc/>
+    public async Task<IReadOnlyList<UrbanDefinition>> GetDefinitionsAsync(string term)
+    {
+        EnsureNotDisposed();
+        await using var stream = await _httpClient.GetStreamAsync(new Uri($"define?term={Uri.EscapeDataString(term)}", UriKind.Relative)).ConfigureAwait(false);
+        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+        return document.RootElement.GetProperty("list").Deserialize<IReadOnlyList<UrbanDefinition>>()!;
+    }
+
+    /// <inheritdoc/>
+    public async Task<IReadOnlyList<UrbanDefinition>> GetRandomDefinitionsAsync()
+    {
+        EnsureNotDisposed();
+        await using var stream = await _httpClient.GetStreamAsync(new Uri("random", UriKind.Relative)).ConfigureAwait(false);
+        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+        return document.RootElement.GetProperty("list").Deserialize<IReadOnlyList<UrbanDefinition>>()!;
+    }
+
+    /// <inheritdoc/>
+    public async Task<UrbanDefinition?> GetDefinitionAsync(int id)
+    {
+        EnsureNotDisposed();
+        await using var stream = await _httpClient.GetStreamAsync(new Uri($"define?defid={id}", UriKind.Relative)).ConfigureAwait(false);
+        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+        var list = document.RootElement.GetProperty("list");
+
+        return list.GetArrayLength() == 0 ? null : list[0].Deserialize<UrbanDefinition>()!;
+    }
+
+    /// <inheritdoc/>
+    public async Task<IReadOnlyList<UrbanDefinition>> GetWordsOfTheDayAsync()
+    {
+        EnsureNotDisposed();
+        await using var stream = await _httpClient.GetStreamAsync(new Uri("words_of_the_day", UriKind.Relative)).ConfigureAwait(false);
+        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+        return document.RootElement.GetProperty("list").Deserialize<IReadOnlyList<UrbanDefinition>>()!;
+    }
+
+    /// <inheritdoc/>
+    public async Task<IReadOnlyList<string>> GetAutocompleteResultsAsync(string term)
+    {
+        EnsureNotDisposed();
+        await using var stream = await _httpClient.GetStreamAsync(new Uri($"autocomplete?term={Uri.EscapeDataString(term)}", UriKind.Relative)).ConfigureAwait(false);
+        return (await JsonSerializer.DeserializeAsync<IReadOnlyList<string>>(stream).ConfigureAwait(false))!;
+    }
+
+    /// <inheritdoc/>
+    public async Task<IReadOnlyList<UrbanAutocompleteResult>> GetAutocompleteResultsExtraAsync(string term)
+    {
+        EnsureNotDisposed();
+        await using var stream = await _httpClient.GetStreamAsync(new Uri($"autocomplete-extra?term={Uri.EscapeDataString(term)}", UriKind.Relative)).ConfigureAwait(false);
+        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+        return document.RootElement.GetProperty("results").Deserialize<IReadOnlyList<UrbanAutocompleteResult>>()!;
+    }
+
+    /// <inheritdoc/>
+    public void Dispose()
+    {
+        if (_disposed)
+        {
+            return;
+        }
+
+        _httpClient.Dispose();
+        _disposed = true;
+    }
+
+    private void EnsureNotDisposed()
+    {
+        if (_disposed)
+        {
+            throw new ObjectDisposedException(nameof(UrbanDictionary));
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Apis/UrbanDictionary.cs b/src/Apis/UrbanDictionary.cs
deleted file mode 100644
index 5dee94c..0000000
--- a/src/Apis/UrbanDictionary.cs
+++ /dev/null
@@ -1,267 +0,0 @@
-using System.Diagnostics;
-using System.Diagnostics.CodeAnalysis;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace Fergun.Apis;
-
-/// <summary>
-/// Represents an API wrapper for Urban Dictionary.
-/// </summary>
-public sealed class UrbanDictionary : IDisposable
-{
-    private static readonly Uri _apiEndpoint = new("https://api.urbandictionary.com/v0/");
-
-    private const string _defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36";
-    private readonly HttpClient _httpClient;
-    private bool _disposed;
-
-    /// <summary>
-    /// Initializes a new instance of the <see cref="UrbanDictionary"/> class.
-    /// </summary>
-    public UrbanDictionary()
-        : this(new HttpClient())
-    {
-    }
-
-    /// <summary>
-    /// Initializes a new instance of the <see cref="UrbanDictionary"/> class using the specified <see cref="HttpClient"/>.
-    /// </summary>
-    /// <param name="httpClient">An instance of <see cref="HttpClient"/>.</param>
-    public UrbanDictionary(HttpClient httpClient)
-    {
-        _httpClient = httpClient;
-
-        _httpClient.BaseAddress ??= _apiEndpoint;
-
-        if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0)
-        {
-            _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_defaultUserAgent);
-        }
-    }
-
-    /// <summary>
-    /// Gets definitions for a term.
-    /// </summary>
-    /// <param name="term">The term to search.</param>
-    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of definitions.</returns>
-    public async Task<IReadOnlyList<UrbanDefinition>> GetDefinitionsAsync(string term)
-    {
-        EnsureNotDisposed();
-        await using var stream = await _httpClient.GetStreamAsync(new Uri($"define?term={Uri.EscapeDataString(term)}", UriKind.Relative)).ConfigureAwait(false);
-        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
-        return document.RootElement.GetProperty("list").Deserialize<IReadOnlyList<UrbanDefinition>>()!;
-    }
-
-    /// <summary>
-    /// Gets random definitions.
-    /// </summary>
-    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of random definitions.</returns>
-    public async Task<IReadOnlyList<UrbanDefinition>> GetRandomDefinitionsAsync()
-    {
-        EnsureNotDisposed();
-        await using var stream = await _httpClient.GetStreamAsync(new Uri("random", UriKind.Relative)).ConfigureAwait(false);
-        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
-        return document.RootElement.GetProperty("list").Deserialize<IReadOnlyList<UrbanDefinition>>()!;
-    }
-
-    /// <summary>
-    /// Gets a definition by its ID.
-    /// </summary>
-    /// <param name="id">The ID of the definition.</param>
-    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains the definition, or <c>null</c> if not found.</returns>
-    public async Task<UrbanDefinition?> GetDefinitionAsync(int id)
-    {
-        EnsureNotDisposed();
-        await using var stream = await _httpClient.GetStreamAsync(new Uri($"define?defid={id}", UriKind.Relative)).ConfigureAwait(false);
-        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
-        var list = document.RootElement.GetProperty("list");
-
-        return list.GetArrayLength() == 0 ? null : list[0].Deserialize<UrbanDefinition>()!;
-    }
-
-    /// <summary>
-    /// Gets the words of the day.
-    /// </summary>
-    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of definitions.</returns>
-    public async Task<IReadOnlyList<UrbanDefinition>> GetWordsOfTheDayAsync()
-    {
-        EnsureNotDisposed();
-        await using var stream = await _httpClient.GetStreamAsync(new Uri("words_of_the_day", UriKind.Relative)).ConfigureAwait(false);
-        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
-        return document.RootElement.GetProperty("list").Deserialize<IReadOnlyList<UrbanDefinition>>()!;
-    }
-
-    /// <summary>
-    /// Gets autocomplete results for a term.
-    /// </summary>
-    /// <param name="term">The term to search.</param>
-    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of suggested terms.</returns>
-    public async Task<IReadOnlyList<string>> GetAutocompleteResultsAsync(string term)
-    {
-        EnsureNotDisposed();
-        await using var stream = await _httpClient.GetStreamAsync(new Uri($"autocomplete?term={Uri.EscapeDataString(term)}", UriKind.Relative)).ConfigureAwait(false);
-        return (await JsonSerializer.DeserializeAsync<IReadOnlyList<string>>(stream).ConfigureAwait(false))!;
-    }
-
-    /// <summary>
-    /// Gets autocomplete results for a term. The results contain the term and a preview definition.
-    /// </summary>
-    /// <param name="term">The term to search.</param>
-    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains a read-only collection of suggested terms.</returns>
-    public async Task<IReadOnlyList<UrbanAutocompleteResult>> GetAutocompleteResultsExtraAsync(string term)
-    {
-        EnsureNotDisposed();
-        await using var stream = await _httpClient.GetStreamAsync(new Uri($"autocomplete-extra?term={Uri.EscapeDataString(term)}", UriKind.Relative)).ConfigureAwait(false);
-        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
-        return document.RootElement.GetProperty("results").Deserialize<IReadOnlyList<UrbanAutocompleteResult>>()!;
-    }
-
-    /// <inheritdoc/>
-    public void Dispose()
-    {
-        if (_disposed)
-        {
-            return;
-        }
-
-        _httpClient.Dispose();
-        _disposed = true;
-    }
-
-    private void EnsureNotDisposed()
-    {
-        if (_disposed)
-        {
-            throw new ObjectDisposedException(nameof(UrbanDictionary));
-        }
-    }
-}
-
-/// <summary>
-/// Represent an Urban Dictionary autocomplete result.
-/// </summary>
-[DebuggerDisplay($"{{{nameof(DebuggerDisplay)}}}")]
-public class UrbanAutocompleteResult
-{
-    [JsonConstructor]
-    public UrbanAutocompleteResult(string term, string preview)
-    {
-        Term = term;
-        Preview = preview;
-    }
-
-    /// <summary>
-    /// Gets the term of this result.
-    /// </summary>
-    [JsonPropertyName("term")]
-    public string Term { get; }
-
-    /// <summary>
-    /// Gets a preview definition of the term.
-    /// </summary>
-    [JsonPropertyName("preview")]
-    public string Preview { get; }
-
-    /// <inheritdoc/>
-    public override string ToString() => $"{nameof(Term)} = {Term}, {nameof(Preview)} = {Preview}";
-
-    [ExcludeFromCodeCoverage]
-    private string DebuggerDisplay => ToString();
-}
-
-/// <summary>
-/// Represents an Urban Dictionary definition.
-/// </summary>
-[DebuggerDisplay($"{{{nameof(DebuggerDisplay)}}}")]
-public class UrbanDefinition
-{
-    [JsonConstructor]
-    public UrbanDefinition(string definition, string? date, string permalink, int thumbsUp, IReadOnlyCollection<string> soundUrls,
-        string author, string word, int id, DateTimeOffset writtenOn, string example, int thumbsDown)
-    {
-        Definition = definition;
-        Date = date;
-        Permalink = permalink;
-        ThumbsUp = thumbsUp;
-        SoundUrls = soundUrls;
-        Author = author;
-        Word = word;
-        Id = id;
-        WrittenOn = writtenOn;
-        Example = example;
-        ThumbsDown = thumbsDown;
-    }
-
-    /// <summary>
-    /// Gets the definition.
-    /// </summary>
-    [JsonPropertyName("definition")]
-    public string Definition { get; }
-
-    /// <summary>
-    /// Gets the date this definition was posted on the front page as a word of the day.
-    /// </summary>
-    [JsonPropertyName("date")]
-    public string? Date { get; }
-
-    /// <summary>
-    /// Gets a permalink to the page containing this definition.
-    /// </summary>
-    [JsonPropertyName("permalink")]
-    public string Permalink { get; }
-
-    /// <summary>
-    /// Gets the number of thumps-up.
-    /// </summary>
-    [JsonPropertyName("thumbs_up")]
-    public int ThumbsUp { get; }
-
-    /// <summary>
-    /// Gets a collection of sound URLs.
-    /// </summary>
-    [JsonPropertyName("sound_urls")]
-    public IReadOnlyCollection<string> SoundUrls { get; }
-
-    /// <summary>
-    /// Gets the author of this definition.
-    /// </summary>
-    [JsonPropertyName("author")]
-    public string Author { get; }
-
-    /// <summary>
-    /// Gets the word (term) being defined.
-    /// </summary>
-    [JsonPropertyName("word")]
-    public string Word { get; }
-
-    /// <summary>
-    /// Gets the ID of this definition.
-    /// </summary>
-    [JsonPropertyName("defid")]
-    public int Id { get; }
-
-    /// <summary>
-    /// Gets the date this definition was written.
-    /// </summary>
-    [JsonPropertyName("written_on")]
-    public DateTimeOffset WrittenOn { get; }
-
-    /// <summary>
-    /// Gets an example usage of the definition.
-    /// </summary>
-    [JsonPropertyName("example")]
-    public string Example { get; }
-
-    /// <summary>
-    /// Gets the number of thumps-down.
-    /// </summary>
-    [JsonPropertyName("thumbs_down")]
-    public int ThumbsDown { get; }
-
-    /// <inheritdoc/>
-    public override string ToString() => $"{nameof(Word)} = {Word}, {nameof(Definition)} = {Definition}";
-
-    [ExcludeFromCodeCoverage]
-    private string DebuggerDisplay => ToString();
-}
\ No newline at end of file
diff --git a/src/Modules/Handlers/UrbanAutocompleteHandler.cs b/src/Modules/Handlers/UrbanAutocompleteHandler.cs
index 15a4cf9..626ab96 100644
--- a/src/Modules/Handlers/UrbanAutocompleteHandler.cs
+++ b/src/Modules/Handlers/UrbanAutocompleteHandler.cs
@@ -1,6 +1,6 @@
 using Discord;
 using Discord.Interactions;
-using Fergun.Apis;
+using Fergun.Apis.Urban;
 using Humanizer;
 using Microsoft.Extensions.DependencyInjection;
 
@@ -18,7 +18,7 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             return AutocompletionResult.FromSuccess();
 
         var urbanDictionary = services
-            .GetRequiredService<UrbanDictionary>();
+            .GetRequiredService<IUrbanDictionary>();
 
         var results = (await urbanDictionary.GetAutocompleteResultsAsync(text))
             .Select(x => new AutocompleteResult(x, x))
diff --git a/src/Modules/UrbanModule.cs b/src/Modules/UrbanModule.cs
index 6b5cbbd..fd43d73 100644
--- a/src/Modules/UrbanModule.cs
+++ b/src/Modules/UrbanModule.cs
@@ -1,7 +1,9 @@
-using System.Text;
+using System.Runtime.CompilerServices;
+using System.Text;
 using Discord;
 using Discord.Interactions;
-using Fergun.Apis;
+using Discord.WebSocket;
+using Fergun.Apis.Urban;
 using Fergun.Extensions;
 using Fergun.Interactive;
 using Fergun.Interactive.Pagination;
@@ -10,12 +12,12 @@
 namespace Fergun.Modules;
 
 [Group("urban", "Urban Dictionary commands")]
-public class UrbanModule : InteractionModuleBase<ShardedInteractionContext>
+public class UrbanModule : InteractionModuleBase
 {
-    private readonly UrbanDictionary _urbanDictionary;
+    private readonly IUrbanDictionary _urbanDictionary;
     private readonly InteractiveService _interactive;
 
-    public UrbanModule(UrbanDictionary urbanDictionary, InteractiveService interactive)
+    public UrbanModule(IUrbanDictionary urbanDictionary, InteractiveService interactive)
     {
         _urbanDictionary = urbanDictionary;
         _interactive = interactive;
@@ -23,15 +25,15 @@ public UrbanModule(UrbanDictionary urbanDictionary, InteractiveService interacti
 
     [SlashCommand("search", "Searches for definitions for a term in Urban Dictionary.")]
     public async Task Search([Autocomplete(typeof(UrbanAutocompleteHandler))] [Summary(description: "The term to search.")] string term)
-        => await SearchInternalAsync(UrbanSearchType.Search, term);
+        => await SearchAndSendAsync(UrbanSearchType.Search, term);
 
     [SlashCommand("random", "Gets random definitions from Urban Dictionary.")]
-    public async Task Random() => await SearchInternalAsync(UrbanSearchType.Random);
+    public async Task Random() => await SearchAndSendAsync(UrbanSearchType.Random);
 
     [SlashCommand("words-of-the-day", "Gets the words of the day in Urban Dictionary.")]
-    public async Task WordsOfTheDay() => await SearchInternalAsync(UrbanSearchType.WordsOfTheDay);
+    public async Task WordsOfTheDay() => await SearchAndSendAsync(UrbanSearchType.WordsOfTheDay);
 
-    private async Task SearchInternalAsync(UrbanSearchType searchType, string? term = null)
+    public async Task SearchAndSendAsync(UrbanSearchType searchType, string? term = null)
     {
         await DeferAsync();
 
@@ -40,7 +42,7 @@ private async Task SearchInternalAsync(UrbanSearchType searchType, string? term
             UrbanSearchType.Search => await _urbanDictionary.GetDefinitionsAsync(term!),
             UrbanSearchType.Random => await _urbanDictionary.GetRandomDefinitionsAsync(),
             UrbanSearchType.WordsOfTheDay => await _urbanDictionary.GetWordsOfTheDayAsync(),
-            _ => throw new InvalidOperationException(),
+            _ => throw new ArgumentException("Invalid search type.", nameof(searchType))
         };
 
         if (definitions.Count == 0)
@@ -59,7 +61,10 @@ private async Task SearchInternalAsync(UrbanSearchType searchType, string? term
             .AddUser(Context.User)
             .Build();
 
-        _ = _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+        if (Context.Interaction is SocketInteraction socketInteraction)
+        {
+            _ = _interactive.SendPaginatorAsync(paginator, socketInteraction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+        }
 
         PageBuilder GeneratePage(int i)
         {
@@ -97,7 +102,7 @@ PageBuilder GeneratePage(int i)
         }
     }
 
-    private enum UrbanSearchType
+    public enum UrbanSearchType
     {
         Search,
         Random,
diff --git a/src/Program.cs b/src/Program.cs
index b98af1f..9539c55 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -5,6 +5,7 @@
 using Fergun;
 using Fergun.Apis;
 using Fergun.Apis.Bing;
+using Fergun.Apis.Urban;
 using Fergun.Apis.Yandex;
 using Fergun.Extensions;
 using Fergun.Interactive;
@@ -66,7 +67,7 @@ await Host.CreateDefaultBuilder()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
 
-        services.AddHttpClient<UrbanDictionary>()
+        services.AddHttpClient<IUrbanDictionary, UrbanDictionary>()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
 
diff --git a/tests/Fergun.Tests/AutocompleteHandlerTests.cs b/tests/Fergun.Tests/AutocompleteHandlerTests.cs
index 0842ea7..48fa4e5 100644
--- a/tests/Fergun.Tests/AutocompleteHandlerTests.cs
+++ b/tests/Fergun.Tests/AutocompleteHandlerTests.cs
@@ -5,6 +5,7 @@
 using Bogus;
 using Discord;
 using Discord.Interactions;
+using Fergun.Apis.Urban;
 using Fergun.Extensions;
 using Fergun.Modules.Handlers;
 using Microsoft.Extensions.DependencyInjection;
@@ -123,6 +124,30 @@ public async Task YouTubeAutocomplete_Should_Return_Valid_Suggestions(string tex
         }
     }
 
+    [Theory]
+    [MemberData(nameof(GetUrbanTestData))]
+    public async Task UrbanAutocomplete_Should_Return_Valid_Suggestions(string text)
+    {
+        var handler = new UrbanAutocompleteHandler();
+        var option = Utils.CreateInstance<AutocompleteOption>(ApplicationCommandOptionType.String, text, text, true);
+
+        _interactionMock.SetupGet(x => x.Data).Returns(_dataMock.Object);
+        _dataMock.SetupGet(x => x.Current).Returns(option);
+
+        var results = await handler.GenerateSuggestionsAsync(_contextMock.Object, _interactionMock.Object, _parameter, _services);
+
+        Assert.True(results.IsSuccess);
+
+        if (!string.IsNullOrEmpty(text))
+        {
+            Assert.NotNull(results.Suggestions);
+            Assert.NotEmpty(results.Suggestions);
+            Assert.All(results.Suggestions, Assert.NotNull);
+            Assert.All(results.Suggestions, x => Assert.NotNull(x.Name));
+            Assert.All(results.Suggestions, x => Assert.NotNull(x.Value));
+        }
+    }
+
     private static IServiceProvider GetServiceProvider()
     {
         var services = new ServiceCollection()
@@ -131,6 +156,10 @@ private static IServiceProvider GetServiceProvider()
         services.AddHttpClient("autocomplete", client => client.DefaultRequestHeaders.UserAgent.ParseAdd(Constants.ChromeUserAgent))
             .SetHandlerLifetime(TimeSpan.FromMinutes(30));
 
+        services.AddHttpClient<IUrbanDictionary, UrbanDictionary>()
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
         return services.BuildServiceProvider();
     }
 
@@ -158,4 +187,12 @@ private static IServiceProvider GetServiceProvider()
             .Zip(faker.MakeLazy(12, () => faker.Random.RandomLocale().Replace('_', '-')))
             .Select(x => new object?[] { x.First, x.Second });
     }
+
+    private static IEnumerable<object?[]> GetUrbanTestData()
+    {
+        var faker = new Faker();
+        return faker.MakeLazy(10, () => faker.Hacker.Noun())
+            .Append(string.Empty).Append(null)
+            .Select(x => new object?[] { x });
+    }
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Fergun.Tests.csproj b/tests/Fergun.Tests/Fergun.Tests.csproj
index 3572990..27882a3 100644
--- a/tests/Fergun.Tests/Fergun.Tests.csproj
+++ b/tests/Fergun.Tests/Fergun.Tests.csproj
@@ -8,6 +8,8 @@
   </PropertyGroup>
 
   <ItemGroup>
+    <PackageReference Include="AutoBogus" Version="2.13.1" />
+    <PackageReference Include="AutoBogus.Moq" Version="2.13.1" />
     <PackageReference Include="Bogus" Version="34.0.1" />
     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
     <PackageReference Include="Moq" Version="4.17.1" />
diff --git a/tests/Fergun.Tests/UrbanDictionaryTests.cs b/tests/Fergun.Tests/UrbanDictionaryTests.cs
index f4e30e1..0ef522e 100644
--- a/tests/Fergun.Tests/UrbanDictionaryTests.cs
+++ b/tests/Fergun.Tests/UrbanDictionaryTests.cs
@@ -1,6 +1,6 @@
 using System;
 using System.Threading.Tasks;
-using Fergun.Apis;
+using Fergun.Apis.Urban;
 using Moq;
 using Xunit;
 
diff --git a/tests/Fergun.Tests/UrbanModuleTests.cs b/tests/Fergun.Tests/UrbanModuleTests.cs
new file mode 100644
index 0000000..0f12447
--- /dev/null
+++ b/tests/Fergun.Tests/UrbanModuleTests.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using AutoBogus;
+using AutoBogus.Moq;
+using Bogus;
+using Discord;
+using Discord.Interactions;
+using Discord.WebSocket;
+using Fergun.Apis.Urban;
+using Fergun.Interactive;
+using Fergun.Modules;
+using Moq;
+using Moq.Protected;
+using Xunit;
+
+namespace Fergun.Tests;
+
+public class UrbanModuleTests
+{
+    private readonly Mock<IInteractionContext> _contextMock = new();
+    private readonly Mock<IDiscordInteraction> _interactionMock = new();
+    private readonly Mock<IUrbanDictionary> _urbanDictionaryMock = CreateMockedUrbanDictionary();
+    private readonly Mock<UrbanModule> _urbanModuleMock;
+    private readonly DiscordSocketClient _client = new();
+    private readonly InteractiveService _interactive;
+
+    public UrbanModuleTests()
+    {
+        _interactive = new InteractiveService(_client);
+        _urbanModuleMock = new Mock<UrbanModule>(() => new UrbanModule(_urbanDictionaryMock.Object, _interactive));
+        _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
+        _contextMock.SetupGet(x => x.User).Returns(() => AutoFaker.Generate<IUser>(b => b.WithBinder(new MoqBinder())));
+        ((IInteractionModuleBase)_urbanModuleMock.Object).SetContext(_contextMock.Object);
+    }
+
+    [MemberData(nameof(GetRandomWords))]
+    [Theory]
+    public async Task Search_Calls_GetDefinitionsAsync(string term)
+    {
+        var module = _urbanModuleMock.Object;
+
+        await module.Search(term);
+
+        _urbanModuleMock.Protected().Verify<Task>("DeferAsync", Times.Once(), ItExpr.IsAny<bool>(), ItExpr.IsAny<RequestOptions>());
+        _urbanDictionaryMock.Verify(u => u.GetDefinitionsAsync(It.Is<string>(x => x == term)), Times.Once);
+        int count = (await _urbanDictionaryMock.Object.GetDefinitionsAsync(It.IsAny<string>())).Count;
+
+        if (count == 0)
+        {
+            _interactionMock.Verify(i => i.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<AllowedMentions>(),
+                It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
+        }
+    }
+
+    [Fact]
+    public async Task Random_Calls_GetRandomDefinitionsAsync()
+    {
+        var module = _urbanModuleMock.Object;
+
+        await module.Random();
+
+        _urbanModuleMock.Protected().Verify<Task>("DeferAsync", Times.Once(), ItExpr.IsAny<bool>(), ItExpr.IsAny<RequestOptions>());
+        _urbanDictionaryMock.Verify(u => u.GetRandomDefinitionsAsync(), Times.Once);
+    }
+
+    [Fact]
+    public async Task WordsOfTheDay_Calls_GetWordsOfTheDayAsync()
+    {
+        var module = _urbanModuleMock.Object;
+
+        await module.WordsOfTheDay();
+
+        _urbanModuleMock.Protected().Verify<Task>("DeferAsync", Times.Once(), ItExpr.IsAny<bool>(), ItExpr.IsAny<RequestOptions>());
+        _urbanDictionaryMock.Verify(u => u.GetWordsOfTheDayAsync(), Times.Once);
+    }
+
+    [Fact]
+    public async Task Invalid_SearchType_Throws_ArgumentException()
+    {
+        var module = _urbanModuleMock.Object;
+
+        var task = module.SearchAndSendAsync((UrbanModule.UrbanSearchType)3);
+
+        await Assert.ThrowsAsync<ArgumentException>(() => task);
+    }
+
+    private static IEnumerable<object?[]> GetRandomWords() => AutoFaker.Generate<string>(20).Select(x => new object[] { x });
+
+    private static Mock<IUrbanDictionary> CreateMockedUrbanDictionary()
+    {
+        var faker = new Faker();
+        var mock = new Mock<IUrbanDictionary>();
+
+        mock.Setup(u => u.GetDefinitionsAsync(It.IsAny<string>())).ReturnsAsync(AutoFaker.Generate<UrbanDefinition>(10).OrDefault(faker, defaultValue: new()));
+        mock.Setup(u => u.GetRandomDefinitionsAsync()).ReturnsAsync(AutoFaker.Generate<UrbanDefinition>(10));
+        mock.Setup(u => u.GetDefinitionAsync(It.IsAny<int>())).ReturnsAsync(AutoFaker.Generate<UrbanDefinition>());
+        mock.Setup(u => u.GetWordsOfTheDayAsync()).ReturnsAsync(AutoFaker.Generate<UrbanDefinition>(10));
+        mock.Setup(u => u.GetAutocompleteResultsAsync(It.IsAny<string>())).ReturnsAsync(AutoFaker.Generate<string>(20));
+        mock.Setup(u => u.GetAutocompleteResultsExtraAsync(It.IsAny<string>())).ReturnsAsync(AutoFaker.Generate<UrbanAutocompleteResult>(20));
+
+        return mock;
+    }
+}
\ No newline at end of file

From 61b4b11ab5231f5c192e6796a2f45e3909ff83c2 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Fri, 18 Mar 2022 15:05:57 -0500
Subject: [PATCH 16/83] Move OCR commands to a separate module, add Yandex OCR
 command

---
 src/Apis/Yandex/YandexImageSearch.cs |   5 +-
 src/Constants.cs                     |   2 +
 src/Modules/OcrModule.cs             | 148 ++++++++++++++++++++
 src/Modules/SharedModule.cs          | 131 ++++++++++++++++++
 src/Modules/UtilityModule.cs         | 195 +--------------------------
 src/Program.cs                       |   2 +-
 6 files changed, 292 insertions(+), 191 deletions(-)
 create mode 100644 src/Modules/OcrModule.cs
 create mode 100644 src/Modules/SharedModule.cs

diff --git a/src/Apis/Yandex/YandexImageSearch.cs b/src/Apis/Yandex/YandexImageSearch.cs
index df5f208..85ab5f7 100644
--- a/src/Apis/Yandex/YandexImageSearch.cs
+++ b/src/Apis/Yandex/YandexImageSearch.cs
@@ -77,8 +77,9 @@ public async Task<string> OcrAsync(string url)
         using var ocrResponse = await _httpClient.SendAsync(ocrRequest, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
         ocrResponse.EnsureSuccessStatusCode();
 
-        await using var ocrStream = await ocrResponse.Content.ReadAsStreamAsync().ConfigureAwait(false);
-        using var ocrDocument = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+        // Using an stream here causes Parse(Async) to throw a JsonReaderException for some reason
+        var bytes = await ocrResponse.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
+        using var ocrDocument = JsonDocument.Parse(bytes);
 
         return ocrDocument
             .RootElement
diff --git a/src/Constants.cs b/src/Constants.cs
index f99163d..af904a7 100644
--- a/src/Constants.cs
+++ b/src/Constants.cs
@@ -22,6 +22,8 @@ public static class Constants
 
     public const string BingIconUrl = "https://cdn.discordapp.com/attachments/838832564583661638/949767220232339507/Bing_Icon.png";
 
+    public const string YandexIconUrl = "https://cdn.discordapp.com/attachments/838832564583661638/954428306533523476/Yandex_Icon.png";
+
     public const string UrbanDictionaryIconUrl = "https://cdn.discordapp.com/attachments/838832564583661638/951936600273715300/UrbanDictionary.png";
 
     public const string ChromeUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36";
diff --git a/src/Modules/OcrModule.cs b/src/Modules/OcrModule.cs
new file mode 100644
index 0000000..9dfad3e
--- /dev/null
+++ b/src/Modules/OcrModule.cs
@@ -0,0 +1,148 @@
+using System.Diagnostics;
+using Discord;
+using Discord.Interactions;
+using Fergun.Apis.Bing;
+using Fergun.Apis.Yandex;
+using Fergun.Extensions;
+using Humanizer;
+using Microsoft.Extensions.Logging;
+
+namespace Fergun.Modules;
+
+[Group("ocr", "OCR commands.")]
+public class OcrModule : InteractionModuleBase
+{
+    private readonly ILogger<OcrModule> _logger;
+    private readonly SharedModule _shared;
+    private readonly BingVisualSearch _bingVisualSearch;
+    private readonly YandexImageSearch _yandexImageSearch;
+
+    public OcrModule(ILogger<OcrModule> logger, SharedModule shared, BingVisualSearch bingVisualSearch, YandexImageSearch yandexImageSearch)
+    {
+        _logger = logger;
+        _shared = shared;
+        _bingVisualSearch = bingVisualSearch;
+        _yandexImageSearch = yandexImageSearch;
+    }
+
+    [MessageCommand("OCR")]
+    public async Task Ocr(IMessage message)
+    {
+        var attachment = message.Attachments.FirstOrDefault();
+        var embed = message.Embeds.FirstOrDefault(x => x.Image is not null || x.Thumbnail is not null);
+
+        string? url = attachment?.Url ?? embed?.Image?.Url ?? embed?.Thumbnail?.Url;
+
+        if (url is null)
+        {
+            await Context.Interaction.RespondWarningAsync("Unable to get an image URL from the message.", true);
+            return;
+        }
+
+        await OcrAsync(OcrEngine.Bing, url, true);
+    }
+
+    [SlashCommand("bing", "Performs OCR to an image using Bing Visual Search.")]
+    public async Task Bing([Summary(description: "An image URL.")] string url)
+        => await OcrAsync(OcrEngine.Bing, url);
+
+    [SlashCommand("yandex", "Performs OCR to an image using Yandex.")]
+    public async Task Yandex([Summary(description: "An image URL.")] string url)
+        => await OcrAsync(OcrEngine.Yandex, url);
+
+    public async Task OcrAsync(OcrEngine ocrEngine, string url, bool ephemeral = false)
+    {
+        if (!Uri.IsWellFormedUriString(url, UriKind.Absolute))
+        {
+            await Context.Interaction.RespondWarningAsync("The URL is not well formed.", true);
+            return;
+        }
+
+        await DeferAsync(ephemeral);
+
+        var stopwatch = Stopwatch.StartNew();
+        string text;
+
+        try
+        {
+            text = ocrEngine switch
+            {
+                OcrEngine.Bing => await _bingVisualSearch.OcrAsync(url),
+                OcrEngine.Yandex => await _yandexImageSearch.OcrAsync(url),
+                _ => throw new ArgumentException("Invalid OCR engine.", nameof(ocrEngine))
+            };
+        }
+        catch (Exception e)
+        {
+            _logger.LogWarning(e, "Failed to perform OCR to url {url}", url);
+            await Context.Interaction.FollowupWarning(e.Message, ephemeral);
+            return;
+        }
+
+        if (string.IsNullOrWhiteSpace(text))
+        {
+            await Context.Interaction.FollowupWarning("The OCR did not give results.", ephemeral);
+            return;
+        }
+
+        stopwatch.Stop();
+
+        Context.Interaction.TryGetLanguage(out var language);
+
+        string name = ocrEngine switch
+        {
+            OcrEngine.Bing => "Bing Visual Search",
+            OcrEngine.Yandex => "Yandex OCR",
+            _ => throw new ArgumentException("Invalid OCR engine.", nameof(ocrEngine))
+        };
+
+        string iconUrl = ocrEngine switch
+        {
+            OcrEngine.Bing => Constants.BingIconUrl,
+            OcrEngine.Yandex => Constants.YandexIconUrl,
+            _ => throw new ArgumentException("Invalid OCR engine.", nameof(ocrEngine))
+        };
+
+        string embedText = "**Output**\n";
+
+        var builder = new EmbedBuilder()
+            .WithTitle("OCR Results")
+            .WithDescription($"{embedText}```{text.Replace('`', '´').Truncate(EmbedBuilder.MaxDescriptionLength - embedText.Length - 6)}```")
+            .WithThumbnailUrl(url)
+            .WithFooter($"{name} | Processing time: {stopwatch.ElapsedMilliseconds}ms", iconUrl)
+            .WithColor(Color.Orange);
+
+        var components = new ComponentBuilder()
+            .WithButton($"Translate{(language is null ? "" : $" to {language.Name}")}", "ocrtranslate", ButtonStyle.Secondary)
+            .WithButton("TTS", "ocrtts", ButtonStyle.Secondary)
+            .Build();
+
+        await FollowupAsync(embed: builder.Build(), components: components, ephemeral: ephemeral);
+    }
+
+    [ComponentInteraction("ocrtranslate", true)]
+    public async Task OcrTranslate()
+    {
+        string text = ((IComponentInteraction)Context.Interaction).Message.Embeds.First().Description;
+        int startIndex = text.IndexOf('`', StringComparison.Ordinal) + 3;
+        text = text[startIndex..^3];
+
+        await _shared.TranslateAsync(Context.Interaction, text, Context.Interaction.GetLanguageCode(), ephemeral: true, deferLoad: true);
+    }
+
+    [ComponentInteraction("ocrtts", true)]
+    public async Task OcrTts()
+    {
+        string text = ((IComponentInteraction)Context.Interaction).Message.Embeds.First().Description;
+        int startIndex = text.IndexOf('`', StringComparison.Ordinal) + 3;
+        text = text[startIndex..^3];
+
+        await _shared.TtsAsync(Context.Interaction, text, ephemeral: true, deferLoad: true);
+    }
+
+    public enum OcrEngine
+    {
+        Bing,
+        Yandex
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/SharedModule.cs b/src/Modules/SharedModule.cs
new file mode 100644
index 0000000..5f8e95d
--- /dev/null
+++ b/src/Modules/SharedModule.cs
@@ -0,0 +1,131 @@
+using Discord;
+using Discord.WebSocket;
+using Fergun.Extensions;
+using GTranslate;
+using GTranslate.Results;
+using GTranslate.Translators;
+using Humanizer;
+using Microsoft.Extensions.Logging;
+
+namespace Fergun.Modules;
+
+/// <summary>
+/// Module that contains shared methods used across modules.
+/// </summary>
+public class SharedModule
+{
+    private readonly ILogger<SharedModule> _logger;
+    private readonly AggregateTranslator _translator;
+    private readonly GoogleTranslator2 _googleTranslator2;
+
+    public SharedModule(ILogger<SharedModule> logger, AggregateTranslator translator, GoogleTranslator2 googleTranslator2)
+    {
+        _logger = logger;
+        _translator = translator;
+        _googleTranslator2 = googleTranslator2;
+    }
+
+    public async Task TranslateAsync(IDiscordInteraction interaction, string text, string target, string? source = null, bool ephemeral = false, bool deferLoad = false)
+    {
+        if (string.IsNullOrWhiteSpace(text))
+        {
+            await interaction.RespondWarningAsync("The message must contain text.", true);
+            return;
+        }
+
+        if (!Language.TryGetLanguage(target, out _))
+        {
+            await interaction.RespondWarningAsync($"Invalid target language \"{target}\".", true);
+            return;
+        }
+
+        if (source != null && !Language.TryGetLanguage(source, out _))
+        {
+            await interaction.RespondWarningAsync($"Invalid source language \"{source}\".", true);
+            return;
+        }
+
+        if (deferLoad && interaction is SocketMessageComponent componentInteraction)
+        {
+            await componentInteraction.DeferLoadingAsync(ephemeral);
+        }
+        else
+        {
+            await interaction.DeferAsync(ephemeral);
+        }
+
+        ITranslationResult result;
+
+        try
+        {
+            result = await _translator.TranslateAsync(text, target, source);
+        }
+        catch (Exception e)
+        {
+            _logger.LogWarning(new(0, "Translate"), e, "Error translating text {text} ({source} -> {target})", text, source ?? "auto", target);
+            await interaction.FollowupWarning(e.Message, ephemeral);
+            return;
+        }
+
+        string thumbnailUrl = result.Service switch
+        {
+            "BingTranslator" => Constants.BingTranslatorLogoUrl,
+            "MicrosoftTranslator" => Constants.MicrosoftAzureLogoUrl,
+            "YandexTranslator" => Constants.YandexTranslateLogoUrl,
+            _ => Constants.GoogleTranslateLogoUrl
+        };
+
+        string embedText = $"**Source language** {(source == null ? "**(Detected)**" : "")}\n" +
+                           $"{result.SourceLanguage.Name}\n\n" +
+                           "**Target language**\n" +
+                           $"{result.TargetLanguage.Name}" +
+                           "\n\n**Result**\n";
+
+        string translation = result.Translation.Replace('`', '´').Truncate(EmbedBuilder.MaxDescriptionLength - embedText.Length - 6);
+
+        var builder = new EmbedBuilder()
+            .WithTitle("Translation result")
+            .WithDescription($"{embedText}```{translation}```")
+            .WithThumbnailUrl(thumbnailUrl)
+            .WithColor(Color.Orange);
+
+        await interaction.FollowupAsync(embed: builder.Build(), ephemeral: ephemeral);
+    }
+
+    public async Task TtsAsync(IDiscordInteraction interaction, string text, string? target = null, bool ephemeral = false, bool deferLoad = false)
+    {
+        if (string.IsNullOrWhiteSpace(text))
+        {
+            await interaction.RespondWarningAsync("The message must contain text.", true);
+            return;
+        }
+
+        target ??= interaction.GetLanguageCode();
+
+        if (!Language.TryGetLanguage(target, out var language) || !GoogleTranslator2.TextToSpeechLanguages.Contains(language))
+        {
+            await interaction.RespondWarningAsync($"Language \"{target}\" not supported.", true);
+            return;
+        }
+
+        if (deferLoad && interaction is SocketMessageComponent componentInteraction)
+        {
+            await componentInteraction.DeferLoadingAsync(ephemeral);
+        }
+        else
+        {
+            await interaction.DeferAsync(ephemeral);
+        }
+
+        try
+        {
+            await using var stream = await _googleTranslator2.TextToSpeechAsync(text, language);
+            await interaction.FollowupWithFileAsync(new FileAttachment(stream, "tts.mp3"), ephemeral: ephemeral);
+        }
+        catch (Exception e)
+        {
+            _logger.LogWarning(e, "TTS: Error obtaining TTS from text {text} ({language})", text, language);
+            await interaction.FollowupWarning(e.Message, ephemeral);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 6a558ea..7ff968c 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -3,8 +3,6 @@
 using System.Runtime.InteropServices;
 using Discord;
 using Discord.Interactions;
-using Fergun.Apis.Bing;
-using Fergun.Apis.Yandex;
 using Fergun.Extensions;
 using Fergun.Interactive;
 using Fergun.Interactive.Pagination;
@@ -27,43 +25,36 @@ namespace Fergun.Modules;
 public class UtilityModule : InteractionModuleBase<ShardedInteractionContext>
 {
     private readonly ILogger<UtilityModule> _logger;
+    private readonly SharedModule _shared;
     private readonly InteractiveService _interactive;
-    private readonly AggregateTranslator _translator;
     private readonly GoogleTranslator _googleTranslator;
     private readonly GoogleTranslator2 _googleTranslator2;
-    private readonly BingTranslator _bingTranslator;
     private readonly MicrosoftTranslator _microsoftTranslator;
     private readonly YandexTranslator _yandexTranslator;
     private readonly GoogleScraper _googleScraper;
     private readonly DuckDuckGoScraper _duckDuckGoScraper;
     private readonly BraveScraper _braveScraper;
     private readonly SearchClient _searchClient;
-    private readonly BingVisualSearch _bingVisualSearch;
-    private readonly YandexImageSearch _yandexImageSearch;
     private static readonly Lazy<Language[]> _lazyFilteredLanguages = new(() => Language.LanguageDictionary
         .Values
         .Where(x => x.SupportedServices == (TranslationServices.Google | TranslationServices.Bing | TranslationServices.Yandex | TranslationServices.Microsoft))
         .ToArray());
 
-    public UtilityModule(ILogger<UtilityModule> logger, InteractiveService interactive, AggregateTranslator translator, GoogleTranslator googleTranslator,
-        GoogleTranslator2 googleTranslator2, BingTranslator bingTranslator, MicrosoftTranslator microsoftTranslator, YandexTranslator yandexTranslator,
-        GoogleScraper googleScraper, DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper, SearchClient searchClient, BingVisualSearch bingVisualSearch,
-        YandexImageSearch yandexImageSearch)
+    public UtilityModule(ILogger<UtilityModule> logger, SharedModule shared, InteractiveService interactive, GoogleTranslator googleTranslator,
+        GoogleTranslator2 googleTranslator2, MicrosoftTranslator microsoftTranslator, YandexTranslator yandexTranslator,
+        GoogleScraper googleScraper, DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper, SearchClient searchClient)
     {
         _logger = logger;
+        _shared = shared;
         _interactive = interactive;
-        _translator = translator;
         _googleTranslator = googleTranslator;
         _googleTranslator2 = googleTranslator2;
-        _bingTranslator = bingTranslator;
         _microsoftTranslator = microsoftTranslator;
         _yandexTranslator = yandexTranslator;
         _googleScraper = googleScraper;
         _duckDuckGoScraper = duckDuckGoScraper;
         _braveScraper = braveScraper;
         _searchClient = searchClient;
-        _bingVisualSearch = bingVisualSearch;
-        _yandexImageSearch = yandexImageSearch;
     }
 
     [MessageCommand("Bad Translator")]
@@ -377,95 +368,6 @@ Task<PageBuilder> GeneratePageAsync(int index)
         }
     }
 
-    [MessageCommand("OCR")]
-    public async Task Ocr(IMessage message)
-    {
-        var attachment = message.Attachments.FirstOrDefault();
-        var embed = message.Embeds.FirstOrDefault(x => x.Image is not null || x.Thumbnail is not null);
-
-        string? url = attachment?.Url ?? embed?.Image?.Url ?? embed?.Thumbnail?.Url;
-
-        if (url is null)
-        {
-            await Context.Interaction.RespondWarningAsync("Unable to get an image URL from the message.", true);
-            return;
-        }
-
-        await Ocr(url);
-    }
-
-    [SlashCommand("ocr", "Performs ocr to an image.")]
-    public async Task Ocr([Summary(description: "An image URL.")] string url)
-    {
-        if (!Uri.IsWellFormedUriString(url, UriKind.Absolute))
-        {
-            await Context.Interaction.RespondWarningAsync("The URL is not well formed.", true);
-            return;
-        }
-
-        await DeferAsync();
-
-        var stopwatch = Stopwatch.StartNew();
-        string text;
-
-        try
-        {
-            text = await _bingVisualSearch.OcrAsync(url);
-        }
-        catch (Exception e)
-        {
-            _logger.LogWarning(e, "Failed to perform OCR to url {url}", url);
-            await Context.Interaction.FollowupWarning(e.Message);
-            return;
-        }
-
-        if (string.IsNullOrWhiteSpace(text))
-        {
-            await Context.Interaction.FollowupWarning("The OCR did not give results.");
-            return;
-        }
-
-        stopwatch.Stop();
-
-        Context.Interaction.TryGetLanguage(out var language);
-
-        string embedText = "**Output**\n";
-
-        var builder = new EmbedBuilder()
-            .WithTitle("OCR Results")
-            .WithDescription($"{embedText}```{text.Replace('`', '´').Truncate(EmbedBuilder.MaxDescriptionLength - embedText.Length - 6)}```")
-            .WithThumbnailUrl(url)
-            .WithFooter($"Bing Visual Search | Processing time: {stopwatch.ElapsedMilliseconds}ms", Constants.BingIconUrl)
-            .WithColor(Color.Orange);
-
-        var components = new ComponentBuilder()
-            .WithButton($"Translate{(language is null ? "" : $" to {language.Name}")}", "ocrtranslate", ButtonStyle.Secondary)
-            .WithButton("TTS", "ocrtts", ButtonStyle.Secondary)
-            .Build();
-
-        await FollowupAsync(embed: builder.Build(), components: components);
-    }
-
-    [ComponentInteraction("ocrtranslate")]
-    public async Task OcrTranslate()
-    {
-        string text = ((IComponentInteraction)Context.Interaction).Message.Embeds.First().Description;
-        int startIndex = text.IndexOf('`', StringComparison.Ordinal) + 3;
-        text = text[startIndex..^3];
-
-        await Translate(text, Context.Interaction.GetLanguageCode(), ephemeral: true);
-    }
-
-    [ComponentInteraction("ocrtts")]
-    public async Task OcrTts()
-    {
-        string text = ((IComponentInteraction)Context.Interaction).Message.Embeds.First().Description;
-        int startIndex = text.IndexOf('`', StringComparison.Ordinal) + 3;
-        text = text[startIndex..^3];
-
-        await TTS(text, ephemeral: true);
-    }
-
     [SlashCommand("say", "Says something.")]
     public async Task Say([Summary(description: "The text to send.")] string text)
     {
@@ -623,63 +525,7 @@ public async Task Translate([Summary(description: "The text to translate.")] str
         [Autocomplete(typeof(TranslateAutocompleteHandler))] [Summary(description: "Target language (name, code or alias).")] string target,
         [Autocomplete(typeof(TranslateAutocompleteHandler))] [Summary(description: "Source language (name, code or alias).")] string? source = null,
         [Summary(description: "Whether to respond ephemerally.")] bool ephemeral = false)
-    {
-        if (string.IsNullOrWhiteSpace(text))
-        {
-            await Context.Interaction.RespondWarningAsync("The message must contain text.", true);
-            return;
-        }
-
-        if (!Language.TryGetLanguage(target, out _))
-        {
-            await Context.Interaction.RespondWarningAsync($"Invalid target language \"{target}\".", true);
-            return;
-        }
-
-        if (source != null && !Language.TryGetLanguage(source, out _))
-        {
-            await Context.Interaction.RespondWarningAsync($"Invalid source language \"{source}\".", true);
-            return;
-        }
-
-        await DeferAsync(ephemeral);
-        ITranslationResult result;
-
-        try
-        {
-            result = await _translator.TranslateAsync(text, target, source);
-        }
-        catch (Exception e)
-        {
-            _logger.LogWarning(new(0, "Translate"), e, "Error translating text {text} ({source} -> {target})", text, source ?? "auto", target);
-            await Context.Interaction.FollowupWarning(e.Message, ephemeral);
-            return;
-        }
-
-        string thumbnailUrl = result.Service switch
-        {
-            "BingTranslator" => Constants.BingTranslatorLogoUrl,
-            "MicrosoftTranslator" => Constants.MicrosoftAzureLogoUrl,
-            "YandexTranslator" => Constants.YandexTranslateLogoUrl,
-            _ => Constants.GoogleTranslateLogoUrl
-        };
-
-        string embedText = $"**Source language** {(source == null ? "**(Detected)**" : "")}\n" +
-                           $"{result.SourceLanguage.Name}\n\n" +
-                           "**Target language**\n" +
-                           $"{result.TargetLanguage.Name}" +
-                           "\n\n**Result**\n";
-
-        string translation = result.Translation.Replace('`', '´').Truncate(EmbedBuilder.MaxDescriptionLength - embedText.Length - 6);
-
-        var builder = new EmbedBuilder()
-            .WithTitle("Translation result")
-            .WithDescription($"{embedText}```{translation}```")
-            .WithThumbnailUrl(thumbnailUrl)
-            .WithColor(Color.Orange);
-
-        await FollowupAsync(embed: builder.Build(), ephemeral: ephemeral);
-    }
+        => await _shared.TranslateAsync(Context.Interaction, text, target, source, ephemeral);
 
     [MessageCommand("TTS")]
     public async Task TTS(IMessage message)
@@ -689,34 +535,7 @@ public async Task TTS(IMessage message)
     public async Task TTS([Summary(description: "The text to convert.")] string text,
         [Autocomplete(typeof(TtsAutocompleteHandler))] [Summary(description: "The target language.")] string? target = null,
         [Summary(description: "Whether to respond ephemerally.")] bool ephemeral = false)
-    {
-        if (string.IsNullOrWhiteSpace(text))
-        {
-            await Context.Interaction.RespondWarningAsync("The message must contain text.", true);
-            return;
-        }
-
-        target ??= Context.Interaction.GetLanguageCode();
-
-        if (!Language.TryGetLanguage(target, out var language) || !GoogleTranslator2.TextToSpeechLanguages.Contains(language))
-        {
-            await Context.Interaction.RespondWarningAsync($"Language \"{target}\" not supported.", true);
-            return;
-        }
-
-        await DeferAsync(ephemeral);
-
-        try
-        {
-            await using var stream = await _googleTranslator2.TextToSpeechAsync(text, language);
-            await Context.Interaction.FollowupWithFileAsync(new FileAttachment(stream, "tts.mp3"), ephemeral: ephemeral);
-        }
-        catch (Exception e)
-        {
-            _logger.LogWarning(e, "TTS: Error obtaining TTS from text {text} ({language})", text, language);
-            await Context.Interaction.FollowupWarning(e.Message, ephemeral);
-        }
-    }
+        => await _shared.TtsAsync(Context.Interaction, text, target, ephemeral);
 
     [SlashCommand("youtube", "Sends a paginator containing YouTube videos.")]
     public async Task YouTube([Autocomplete(typeof(YouTubeAutocompleteHandler))] [Summary(description: "The query.")] string query)
diff --git a/src/Program.cs b/src/Program.cs
index 9539c55..56e6419 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -3,7 +3,6 @@
 using Discord.Interactions;
 using Discord.WebSocket;
 using Fergun;
-using Fergun.Apis;
 using Fergun.Apis.Bing;
 using Fergun.Apis.Urban;
 using Fergun.Apis.Yandex;
@@ -125,4 +124,5 @@ await Host.CreateDefaultBuilder()
         services.AddSingleton(x => new GoogleScraper(x.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(GoogleScraper))));
         services.AddSingleton(x => new DuckDuckGoScraper(x.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(DuckDuckGoScraper))));
         services.AddSingleton(x => new BraveScraper(x.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(BraveScraper))));
+        services.AddTransient<SharedModule>();
     }).RunConsoleAsync();
\ No newline at end of file

From 700c1bcea0f4b1c1321ae52e38adced7f35f542d Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 19 Mar 2022 23:38:27 -0500
Subject: [PATCH 17/83] Add test for OCR APIs and module

---
 src/Apis/Bing/BingVisualSearch.cs            |  26 ++--
 src/Apis/Bing/IBingVisualSearch.cs           |  14 +++
 src/Apis/Yandex/IYandexImageSearch.cs        |  14 +++
 src/Apis/Yandex/YandexImageSearch.cs         |  46 ++++---
 src/Modules/OcrModule.cs                     |  37 +++---
 src/Program.cs                               |   4 +-
 tests/Fergun.Tests/BingVisualSearchTests.cs  |  43 +++++++
 tests/Fergun.Tests/OcrModuleTests.cs         | 125 +++++++++++++++++++
 tests/Fergun.Tests/YandexImageSearchTests.cs |  76 +++++++++++
 9 files changed, 333 insertions(+), 52 deletions(-)
 create mode 100644 src/Apis/Bing/IBingVisualSearch.cs
 create mode 100644 src/Apis/Yandex/IYandexImageSearch.cs
 create mode 100644 tests/Fergun.Tests/BingVisualSearchTests.cs
 create mode 100644 tests/Fergun.Tests/OcrModuleTests.cs
 create mode 100644 tests/Fergun.Tests/YandexImageSearchTests.cs

diff --git a/src/Apis/Bing/BingVisualSearch.cs b/src/Apis/Bing/BingVisualSearch.cs
index 1645442..d5f41a1 100644
--- a/src/Apis/Bing/BingVisualSearch.cs
+++ b/src/Apis/Bing/BingVisualSearch.cs
@@ -6,7 +6,7 @@ namespace Fergun.Apis.Bing;
 /// <summary>
 /// Represents a wrapper over Bing Visual Search internal API.
 /// </summary>
-public sealed class BingVisualSearch : IDisposable
+public sealed class BingVisualSearch : IBingVisualSearch, IDisposable
 {
     private static readonly Uri _apiEndpoint = new("https://www.bing.com/images/api/custom/knowledge/");
 
@@ -48,13 +48,10 @@ public BingVisualSearch(HttpClient httpClient)
         }
     }
 
-    /// <summary>
-    /// Performs OCR to the specified image URL.
-    /// </summary>
-    /// <param name="url">The URL of an image.</param>
-    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous OCR operation. The result contains the recognized text.</returns>
-    public async Task<string> OcrAsync(string url)
+    /// <inheritdoc/>
+    public async Task<string?> OcrAsync(string url)
     {
+        EnsureNotDisposed();
         using var request = BuildRequest(url, "OCR");
         using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
 
@@ -113,12 +110,9 @@ private static HttpRequestMessage BuildRequest(string url, string invokedSkill)
     }
 
     /// <inheritdoc/>
-    public void Dispose() => Dispose(true);
-
-    /// <inheritdoc cref="Dispose()"/>
-    private void Dispose(bool disposing)
+    public void Dispose()
     {
-        if (!disposing || _disposed)
+        if (_disposed)
         {
             return;
         }
@@ -126,4 +120,12 @@ private void Dispose(bool disposing)
         _httpClient.Dispose();
         _disposed = true;
     }
+
+    private void EnsureNotDisposed()
+    {
+        if (_disposed)
+        {
+            throw new ObjectDisposedException(nameof(BingVisualSearch));
+        }
+    }
 }
\ No newline at end of file
diff --git a/src/Apis/Bing/IBingVisualSearch.cs b/src/Apis/Bing/IBingVisualSearch.cs
new file mode 100644
index 0000000..3d26d13
--- /dev/null
+++ b/src/Apis/Bing/IBingVisualSearch.cs
@@ -0,0 +1,14 @@
+namespace Fergun.Apis.Bing;
+
+/// <summary>
+/// Represents a Bing Visual Search API.
+/// </summary>
+public interface IBingVisualSearch
+{
+    /// <summary>
+    /// Performs OCR to the specified image URL.
+    /// </summary>
+    /// <param name="url">The URL of an image.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous OCR operation. The result contains the recognized text.</returns>
+    Task<string?> OcrAsync(string url);
+}
\ No newline at end of file
diff --git a/src/Apis/Yandex/IYandexImageSearch.cs b/src/Apis/Yandex/IYandexImageSearch.cs
new file mode 100644
index 0000000..a65c793
--- /dev/null
+++ b/src/Apis/Yandex/IYandexImageSearch.cs
@@ -0,0 +1,14 @@
+namespace Fergun.Apis.Yandex;
+
+/// <summary>
+/// Represents a Yandex Image Search API.
+/// </summary>
+public interface IYandexImageSearch
+{
+    /// <summary>
+    /// Performs OCR to the specified image URL.
+    /// </summary>
+    /// <param name="url">The URL of an image.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous OCR operation. The result contains the recognized text.</returns>
+    Task<string?> OcrAsync(string url);
+}
\ No newline at end of file
diff --git a/src/Apis/Yandex/YandexImageSearch.cs b/src/Apis/Yandex/YandexImageSearch.cs
index 85ab5f7..c5e411b 100644
--- a/src/Apis/Yandex/YandexImageSearch.cs
+++ b/src/Apis/Yandex/YandexImageSearch.cs
@@ -6,7 +6,7 @@ namespace Fergun.Apis.Yandex;
 /// <summary>
 /// Represents a wrapper over Yandex Image Search internal API.
 /// </summary>
-public sealed class YandexImageSearch : IDisposable
+public sealed class YandexImageSearch : IYandexImageSearch, IDisposable
 {
     private const string _defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36";
     private readonly HttpClient _httpClient;
@@ -34,15 +34,12 @@ public YandexImageSearch(HttpClient httpClient)
         }
     }
 
-    /// <summary>
-    /// Performs OCR to the specified image URL.
-    /// </summary>
-    /// <param name="url">The URL of an image.</param>
-    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous OCR operation. The result contains the recognized text.</returns>
-    public async Task<string> OcrAsync(string url)
+    /// <inheritdoc/>
+    public async Task<string?> OcrAsync(string url)
     {
-        // Get CBIR ID
+        EnsureNotDisposed();
 
+        // Get CBIR ID
         using var request = new HttpRequestMessage
         {
             Method = HttpMethod.Get,
@@ -50,15 +47,20 @@ public async Task<string> OcrAsync(string url)
         };
 
         using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
-        response.EnsureSuccessStatusCode();
+
+        if (!response.IsSuccessStatusCode)
+        {
+            string message = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+            throw new YandexException(message);
+        }
 
         await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
         using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
 
-        string imageId = document
+        string? imageId = document
             .RootElement
             .GetProperty("image_id")
-            .GetString() ?? throw new YandexException("Unable to get the ID of the image.");
+            .GetString();
 
         int imageShard = document
             .RootElement
@@ -81,22 +83,24 @@ public async Task<string> OcrAsync(string url)
         var bytes = await ocrResponse.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
         using var ocrDocument = JsonDocument.Parse(bytes);
 
+        if (ocrDocument.RootElement.TryGetProperty("type", out var typeProp) && typeProp.GetString() == "captcha")
+        {
+            throw new YandexException("Yandex API returned a CAPTCHA. Try again later.");
+        }
+
         return ocrDocument
             .RootElement
             .GetProperty("blocks")[0]
             .GetProperty("params")
             .GetPropertyOrDefault("adapterData")
             .GetPropertyOrDefault("plainText")
-            .GetStringOrDefault() ?? "";
+            .GetStringOrDefault();
     }
 
     /// <inheritdoc/>
-    public void Dispose() => Dispose(true);
-
-    /// <inheritdoc cref="Dispose()"/>
-    private void Dispose(bool disposing)
+    public void Dispose()
     {
-        if (!disposing || _disposed)
+        if (_disposed)
         {
             return;
         }
@@ -104,4 +108,12 @@ private void Dispose(bool disposing)
         _httpClient.Dispose();
         _disposed = true;
     }
+
+    private void EnsureNotDisposed()
+    {
+        if (_disposed)
+        {
+            throw new ObjectDisposedException(nameof(YandexImageSearch));
+        }
+    }
 }
\ No newline at end of file
diff --git a/src/Modules/OcrModule.cs b/src/Modules/OcrModule.cs
index 9dfad3e..5ac7173 100644
--- a/src/Modules/OcrModule.cs
+++ b/src/Modules/OcrModule.cs
@@ -14,10 +14,10 @@ public class OcrModule : InteractionModuleBase
 {
     private readonly ILogger<OcrModule> _logger;
     private readonly SharedModule _shared;
-    private readonly BingVisualSearch _bingVisualSearch;
-    private readonly YandexImageSearch _yandexImageSearch;
+    private readonly IBingVisualSearch _bingVisualSearch;
+    private readonly IYandexImageSearch _yandexImageSearch;
 
-    public OcrModule(ILogger<OcrModule> logger, SharedModule shared, BingVisualSearch bingVisualSearch, YandexImageSearch yandexImageSearch)
+    public OcrModule(ILogger<OcrModule> logger, SharedModule shared, IBingVisualSearch bingVisualSearch, IYandexImageSearch yandexImageSearch)
     {
         _logger = logger;
         _shared = shared;
@@ -58,19 +58,21 @@ public async Task OcrAsync(OcrEngine ocrEngine, string url, bool ephemeral = fal
             return;
         }
 
+        var ocrTask = ocrEngine switch
+        {
+            OcrEngine.Bing => _bingVisualSearch.OcrAsync(url),
+            OcrEngine.Yandex => _yandexImageSearch.OcrAsync(url),
+            _ => throw new ArgumentException("Invalid OCR engine.", nameof(ocrEngine))
+        };
+
         await DeferAsync(ephemeral);
 
         var stopwatch = Stopwatch.StartNew();
-        string text;
+        string? text;
 
         try
         {
-            text = ocrEngine switch
-            {
-                OcrEngine.Bing => await _bingVisualSearch.OcrAsync(url),
-                OcrEngine.Yandex => await _yandexImageSearch.OcrAsync(url),
-                _ => throw new ArgumentException("Invalid OCR engine.", nameof(ocrEngine))
-            };
+            text = await ocrTask;
         }
         catch (Exception e)
         {
@@ -89,17 +91,10 @@ public async Task OcrAsync(OcrEngine ocrEngine, string url, bool ephemeral = fal
 
         Context.Interaction.TryGetLanguage(out var language);
 
-        string name = ocrEngine switch
-        {
-            OcrEngine.Bing => "Bing Visual Search",
-            OcrEngine.Yandex => "Yandex OCR",
-            _ => throw new ArgumentException("Invalid OCR engine.", nameof(ocrEngine))
-        };
-
-        string iconUrl = ocrEngine switch
+        var (name, iconUrl) = ocrEngine switch
         {
-            OcrEngine.Bing => Constants.BingIconUrl,
-            OcrEngine.Yandex => Constants.YandexIconUrl,
+            OcrEngine.Bing => ("Bing Visual Search", Constants.BingIconUrl),
+            OcrEngine.Yandex => ("Yandex OCR", Constants.YandexIconUrl),
             _ => throw new ArgumentException("Invalid OCR engine.", nameof(ocrEngine))
         };
 
@@ -117,7 +112,7 @@ public async Task OcrAsync(OcrEngine ocrEngine, string url, bool ephemeral = fal
             .WithButton("TTS", "ocrtts", ButtonStyle.Secondary)
             .Build();
 
-        await FollowupAsync(embed: builder.Build(), components: components, ephemeral: ephemeral);
+        await Context.Interaction.FollowupAsync(embed: builder.Build(), components: components, ephemeral: ephemeral);
     }
 
     [ComponentInteraction("ocrtranslate", true)]
diff --git a/src/Program.cs b/src/Program.cs
index 56e6419..6b2460e 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -58,11 +58,11 @@ await Host.CreateDefaultBuilder()
         services.AddSingleton<InteractiveService>();
         services.AddFergunPolicies();
 
-        services.AddHttpClient<BingVisualSearch>()
+        services.AddHttpClient<IBingVisualSearch, BingVisualSearch>()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
 
-        services.AddHttpClient<YandexImageSearch>()
+        services.AddHttpClient<IYandexImageSearch, YandexImageSearch>()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
 
diff --git a/tests/Fergun.Tests/BingVisualSearchTests.cs b/tests/Fergun.Tests/BingVisualSearchTests.cs
new file mode 100644
index 0000000..08f59ee
--- /dev/null
+++ b/tests/Fergun.Tests/BingVisualSearchTests.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Threading.Tasks;
+using Fergun.Apis.Bing;
+using Moq;
+using Xunit;
+
+namespace Fergun.Tests;
+
+public class BingVisualSearchTests
+{
+    private readonly BingVisualSearch _bingVisualSearch = new();
+
+    [Theory]
+    [InlineData("https://cdn.discordapp.com/attachments/838832564583661638/954474328324460544/lorem_ipsum.png")]
+    [InlineData("https://upload.wikimedia.org/wikipedia/commons/5/57/Lorem_Ipsum_Helvetica.png")]
+    public async Task OcrAsync_Returns_Text(string url)
+    {
+        string? text = await _bingVisualSearch.OcrAsync(url);
+
+        Assert.NotNull(text);
+        Assert.NotEmpty(text);
+    }
+
+    [Theory]
+    [InlineData("https://cdn.discordapp.com/attachments/838832564583661638/954475252027641886/tts.mp3")] // MP3 file
+    [InlineData("https://upload.wikimedia.org/wikipedia/commons/2/29/Suru_Bog_10000px.jpg")] // 10000px image
+    [InlineData("https://simpl.info/bigimage/bigImage.jpg")] // 91 MB file
+    public async Task OcrAsync_Throws_BingException_If_Image_Is_Invalid(string url)
+    {
+        var task = _bingVisualSearch.OcrAsync(url);
+
+        await Assert.ThrowsAsync<BingException>(() => task);
+    }
+
+    [Fact]
+    public async Task Disposed_UrbanDictionary_Usage_Throws_ObjectDisposedException()
+    {
+        _bingVisualSearch.Dispose();
+        _bingVisualSearch.Dispose();
+
+        await Assert.ThrowsAsync<ObjectDisposedException>(() => _bingVisualSearch.OcrAsync(It.IsAny<string>()));
+    }
+}
\ No newline at end of file
diff --git a/tests/Fergun.Tests/OcrModuleTests.cs b/tests/Fergun.Tests/OcrModuleTests.cs
new file mode 100644
index 0000000..d6e564f
--- /dev/null
+++ b/tests/Fergun.Tests/OcrModuleTests.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Threading.Tasks;
+using Discord;
+using Discord.Interactions;
+using Fergun.Apis.Bing;
+using Fergun.Apis.Yandex;
+using Fergun.Modules;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Moq.Protected;
+using Xunit;
+
+namespace Fergun.Tests;
+
+public class OcrModuleTests
+{
+    private readonly Mock<IInteractionContext> _contextMock = new();
+    private readonly Mock<IDiscordInteraction> _interactionMock = new();
+    private readonly Mock<IBingVisualSearch> _bingVisualSearchMock = new();
+    private readonly Mock<IYandexImageSearch> _yandexImageSearchMock = new();
+    private readonly Mock<ILogger<OcrModule>> _loggerMock = new();
+    private readonly Mock<OcrModule> _ocrModuleMock;
+    private const string _textImageUrl = "https://example.com/image.png";
+    private const string _emptyImageUrl = "https://example.com/empty.png";
+    private const string _invalidImageUrl = "https://example.com/file.bin";
+
+    public OcrModuleTests()
+    {
+        _bingVisualSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == _textImageUrl))).ReturnsAsync("test");
+        _bingVisualSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == _emptyImageUrl))).ReturnsAsync(string.Empty);
+        _bingVisualSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == _invalidImageUrl))).ThrowsAsync(new BingException("Invalid image."));
+        _yandexImageSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == _textImageUrl))).ReturnsAsync("test");
+        _yandexImageSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == _emptyImageUrl))).ReturnsAsync(string.Empty);
+        _yandexImageSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == _invalidImageUrl))).ThrowsAsync(new YandexException("Invalid image."));
+
+        var sharedLogger = Mock.Of<ILogger<SharedModule>>();
+        var shared = new SharedModule(sharedLogger, new(), new());
+
+        _ocrModuleMock = new Mock<OcrModule>(() => new OcrModule(_loggerMock.Object, shared, _bingVisualSearchMock.Object, _yandexImageSearchMock.Object));
+        _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
+        ((IInteractionModuleBase)_ocrModuleMock.Object).SetContext(_contextMock.Object);
+    }
+
+    [Theory]
+    [InlineData(_textImageUrl)]
+    [InlineData(_emptyImageUrl)]
+    public async Task Bing_Uses_BingVisualSearch(string url)
+    {
+        var module = _ocrModuleMock.Object;
+        const bool isEphemeral = false;
+
+        await module.Bing(url);
+
+        _ocrModuleMock
+            .Protected()
+            .As<IDiscordInteraction>()
+            .Verify(x => x.DeferAsync(It.Is<bool>(b => b == isEphemeral), It.IsAny<RequestOptions>()), Times.Once());
+
+        _bingVisualSearchMock.Verify(x => x.OcrAsync(It.Is<string>(s => s == url)), Times.Once);
+
+        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == isEphemeral),
+                It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once());
+    }
+
+    [Theory]
+    [InlineData(_textImageUrl)]
+    [InlineData(_emptyImageUrl)]
+    public async Task Yandex_Uses_YandexImageSearch(string url)
+    {
+        var module = _ocrModuleMock.Object;
+        const bool isEphemeral = false;
+
+        await module.Yandex(url);
+
+        _ocrModuleMock
+            .Protected()
+            .As<IDiscordInteraction>()
+            .Verify(x => x.DeferAsync(It.Is<bool>(b => b == isEphemeral), It.IsAny<RequestOptions>()), Times.Once());
+
+        _yandexImageSearchMock.Verify(x => x.OcrAsync(It.Is<string>(s => s == url)), Times.Once);
+
+        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == isEphemeral),
+                It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once());
+    }
+
+    [Fact]
+    public async Task OcrAsync_Returns_Warning_If_Url_Is_Invalid()
+    {
+        var module = _ocrModuleMock.Object;
+        const bool isEphemeral = true;
+
+        await module.OcrAsync(It.IsAny<OcrModule.OcrEngine>(), string.Empty, isEphemeral);
+
+        _interactionMock.Verify(x => x.RespondAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == isEphemeral),
+                It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once());
+    }
+
+    [Fact]
+    public async Task Invalid_OcrEngine_Throws_ArgumentException()
+    {
+        var module = _ocrModuleMock.Object;
+        const bool isEphemeral = true;
+
+        var task = module.OcrAsync((OcrModule.OcrEngine)2, _textImageUrl, isEphemeral);
+
+        await Assert.ThrowsAsync<ArgumentException>(() => task);
+    }
+
+    [Fact]
+    public async Task OcrAsync_Returns_Warning_On_Exception()
+    {
+        var module = _ocrModuleMock.Object;
+        const bool isEphemeral = true;
+
+        await module.OcrAsync(It.IsAny<OcrModule.OcrEngine>(), _invalidImageUrl, isEphemeral);
+
+        _ocrModuleMock
+            .Protected()
+            .As<IDiscordInteraction>()
+            .Verify(x => x.DeferAsync(It.Is<bool>(b => b == isEphemeral), It.IsAny<RequestOptions>()), Times.Once());
+
+        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == isEphemeral),
+                It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once());
+    }
+}
\ No newline at end of file
diff --git a/tests/Fergun.Tests/YandexImageSearchTests.cs b/tests/Fergun.Tests/YandexImageSearchTests.cs
new file mode 100644
index 0000000..4b341e3
--- /dev/null
+++ b/tests/Fergun.Tests/YandexImageSearchTests.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Fergun.Apis.Yandex;
+using Moq;
+using Moq.Protected;
+using Xunit;
+
+namespace Fergun.Tests;
+
+public class YandexImageSearchTests
+{
+    private readonly YandexImageSearch _yandexImageSearch = new();
+
+    [Theory]
+    [InlineData("https://cdn.discordapp.com/attachments/838832564583661638/954474328324460544/lorem_ipsum.png")]
+    [InlineData("https://upload.wikimedia.org/wikipedia/commons/5/57/Lorem_Ipsum_Helvetica.png")]
+    public async Task OcrAsync_Returns_Text(string url)
+    {
+        string? text = await _yandexImageSearch.OcrAsync(url);
+
+        Assert.NotNull(text);
+        Assert.NotEmpty(text);
+    }
+
+    [Fact]
+    public async Task OcrAsync_Throws_YandexException_With_Content_As_Message_On_Error()
+    {
+        const string message = "400 Bad request Incorrect avatar size";
+
+        var messageHandlerMock = new Mock<HttpMessageHandler>();
+
+        messageHandlerMock
+            .Protected()
+            .As<HttpClient>()
+            .SetupSequence(x => x.SendAsync(It.IsAny<HttpRequestMessage>(), It.IsAny<CancellationToken>()))
+            .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent(message) });
+
+        var yandexImageSearch = new YandexImageSearch(new HttpClient(messageHandlerMock.Object));
+
+        var task = yandexImageSearch.OcrAsync("https://example.com");
+
+        var exception = await Assert.ThrowsAsync<YandexException>(() => task);
+        Assert.Equal(message, exception.Message);
+    }
+
+    [Fact]
+    public async Task OcrAsync_Throws_YandexException_If_Captcha_Is_Present()
+    {
+        var messageHandlerMock = new Mock<HttpMessageHandler>();
+
+        messageHandlerMock
+            .Protected()
+            .As<HttpClient>()
+            .SetupSequence(x => x.SendAsync(It.IsAny<HttpRequestMessage>(), It.IsAny<CancellationToken>()))
+            .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"image_id\":\"test\",\"image_shard\":0}") })
+            .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"type\":\"captcha\"}") });
+
+        var yandexImageSearch = new YandexImageSearch(new HttpClient(messageHandlerMock.Object));
+
+        var task = yandexImageSearch.OcrAsync("https://example.com");
+
+        await Assert.ThrowsAsync<YandexException>(() => task);
+    }
+
+    [Fact]
+    public async Task Disposed_UrbanDictionary_Usage_Throws_ObjectDisposedException()
+    {
+        _yandexImageSearch.Dispose();
+        _yandexImageSearch.Dispose();
+
+        await Assert.ThrowsAsync<ObjectDisposedException>(() => _yandexImageSearch.OcrAsync(It.IsAny<string>()));
+    }
+}
\ No newline at end of file

From f0da4dafc6c1b7820db630f324147b353601d361 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 19 Mar 2022 23:38:47 -0500
Subject: [PATCH 18/83] Fix Brave autocomplete tests

---
 tests/Fergun.Tests/AutocompleteHandlerTests.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/Fergun.Tests/AutocompleteHandlerTests.cs b/tests/Fergun.Tests/AutocompleteHandlerTests.cs
index 48fa4e5..9af97b4 100644
--- a/tests/Fergun.Tests/AutocompleteHandlerTests.cs
+++ b/tests/Fergun.Tests/AutocompleteHandlerTests.cs
@@ -166,7 +166,7 @@ private static IServiceProvider GetServiceProvider()
     private static IEnumerable<object?[]> GetBraveTestData()
     {
         var faker = new Faker();
-        return faker.MakeLazy(10, () => faker.Music.Genre())
+        return faker.MakeLazy(10, () => faker.Random.String2(1))
             .Append(string.Empty).Append(null).Select(x => new object?[] { x });
     }
 

From fdf25618bd02394f3bc3c29a1aaee4c10fa8ff75 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Thu, 24 Mar 2022 01:20:55 -0500
Subject: [PATCH 19/83] Update dependencies and add interactivity to OCR
 message command

---
 src/Fergun.csproj                    |  4 +--
 src/Modules/OcrModule.cs             | 52 ++++++++++++++++++++++------
 src/Modules/UrbanModule.cs           |  2 +-
 src/Modules/UtilityModule.cs         |  8 ++---
 src/Program.cs                       |  1 +
 tests/Fergun.Tests/OcrModuleTests.cs | 28 +++++++--------
 6 files changed, 61 insertions(+), 34 deletions(-)

diff --git a/src/Fergun.csproj b/src/Fergun.csproj
index b7150c0..8184a6a 100644
--- a/src/Fergun.csproj
+++ b/src/Fergun.csproj
@@ -11,12 +11,12 @@
   <ItemGroup>
     <PackageReference Include="Discord.Addons.Hosting" Version="5.1.0" />
     <PackageReference Include="Discord.Net.Interactions" Version="3.4.0" />
-    <PackageReference Include="Fergun.Interactive" Version="1.4.1" />
+    <PackageReference Include="Fergun.Interactive" Version="1.5.0" />
     <PackageReference Include="GScraper" Version="1.0.2" />
     <PackageReference Include="GTranslate" Version="2.0.1" />
     <PackageReference Include="Humanizer.Core" Version="2.14.1" />
     <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
-    <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.2" />
+    <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.3" />
     <PackageReference Include="Polly.Caching.Memory" Version="3.0.2" />
     <PackageReference Include="Serilog.Extensions.Hosting" Version="4.2.0" />
     <PackageReference Include="Serilog.Extensions.Logging.File" Version="2.0.0" />
diff --git a/src/Modules/OcrModule.cs b/src/Modules/OcrModule.cs
index 5ac7173..2b20a9d 100644
--- a/src/Modules/OcrModule.cs
+++ b/src/Modules/OcrModule.cs
@@ -1,9 +1,12 @@
 using System.Diagnostics;
 using Discord;
 using Discord.Interactions;
+using Discord.WebSocket;
 using Fergun.Apis.Bing;
 using Fergun.Apis.Yandex;
 using Fergun.Extensions;
+using Fergun.Interactive;
+using Fergun.Interactive.Selection;
 using Humanizer;
 using Microsoft.Extensions.Logging;
 
@@ -14,13 +17,15 @@ public class OcrModule : InteractionModuleBase
 {
     private readonly ILogger<OcrModule> _logger;
     private readonly SharedModule _shared;
+    private readonly InteractiveService _interactive;
     private readonly IBingVisualSearch _bingVisualSearch;
     private readonly IYandexImageSearch _yandexImageSearch;
 
-    public OcrModule(ILogger<OcrModule> logger, SharedModule shared, IBingVisualSearch bingVisualSearch, IYandexImageSearch yandexImageSearch)
+    public OcrModule(ILogger<OcrModule> logger, SharedModule shared, InteractiveService interactive, IBingVisualSearch bingVisualSearch, IYandexImageSearch yandexImageSearch)
     {
         _logger = logger;
         _shared = shared;
+        _interactive = interactive;
         _bingVisualSearch = bingVisualSearch;
         _yandexImageSearch = yandexImageSearch;
     }
@@ -39,22 +44,40 @@ public async Task Ocr(IMessage message)
             return;
         }
 
-        await OcrAsync(OcrEngine.Bing, url, true);
+        var page = new PageBuilder()
+            .WithTitle("Select an OCR engine")
+            .WithColor(Color.Orange);
+
+        var selection = new SelectionBuilder<OcrEngine>()
+            .AddUser(Context.User)
+            .WithOptions(Enum.GetValues<OcrEngine>())
+            .WithSelectionPage(page)
+            .Build();
+
+        var result = await _interactive.SendSelectionAsync(selection, (SocketInteraction)Context.Interaction, TimeSpan.FromMinutes(1), ephemeral: true);
+
+        // Attempt to disable the components
+        _ = Context.Interaction.ModifyOriginalResponseAsync(x => x.Components = selection.GetOrAddComponents(true).Build());
+
+        if (result.IsSuccess)
+        {
+            await OcrAsync(result.Value, url, result.StopInteraction!, true);
+        }
     }
 
     [SlashCommand("bing", "Performs OCR to an image using Bing Visual Search.")]
     public async Task Bing([Summary(description: "An image URL.")] string url)
-        => await OcrAsync(OcrEngine.Bing, url);
+        => await OcrAsync(OcrEngine.Bing, url, Context.Interaction);
 
     [SlashCommand("yandex", "Performs OCR to an image using Yandex.")]
     public async Task Yandex([Summary(description: "An image URL.")] string url)
-        => await OcrAsync(OcrEngine.Yandex, url);
+        => await OcrAsync(OcrEngine.Yandex, url, Context.Interaction);
 
-    public async Task OcrAsync(OcrEngine ocrEngine, string url, bool ephemeral = false)
+    public async Task OcrAsync(OcrEngine ocrEngine, string url, IDiscordInteraction interaction, bool ephemeral = false)
     {
         if (!Uri.IsWellFormedUriString(url, UriKind.Absolute))
         {
-            await Context.Interaction.RespondWarningAsync("The URL is not well formed.", true);
+            await interaction.RespondWarningAsync("The URL is not well formed.", true);
             return;
         }
 
@@ -65,7 +88,14 @@ public async Task OcrAsync(OcrEngine ocrEngine, string url, bool ephemeral = fal
             _ => throw new ArgumentException("Invalid OCR engine.", nameof(ocrEngine))
         };
 
-        await DeferAsync(ephemeral);
+        if (interaction is SocketMessageComponent componentInteraction)
+        {
+            await componentInteraction.DeferLoadingAsync(ephemeral);
+        }
+        else
+        {
+            await interaction.DeferAsync(ephemeral);
+        }
 
         var stopwatch = Stopwatch.StartNew();
         string? text;
@@ -77,19 +107,19 @@ public async Task OcrAsync(OcrEngine ocrEngine, string url, bool ephemeral = fal
         catch (Exception e)
         {
             _logger.LogWarning(e, "Failed to perform OCR to url {url}", url);
-            await Context.Interaction.FollowupWarning(e.Message, ephemeral);
+            await interaction.FollowupWarning(e.Message, ephemeral);
             return;
         }
 
         if (string.IsNullOrWhiteSpace(text))
         {
-            await Context.Interaction.FollowupWarning("The OCR did not give results.", ephemeral);
+            await interaction.FollowupWarning("The OCR did not give results.", ephemeral);
             return;
         }
 
         stopwatch.Stop();
 
-        Context.Interaction.TryGetLanguage(out var language);
+        interaction.TryGetLanguage(out var language);
 
         var (name, iconUrl) = ocrEngine switch
         {
@@ -112,7 +142,7 @@ public async Task OcrAsync(OcrEngine ocrEngine, string url, bool ephemeral = fal
             .WithButton("TTS", "ocrtts", ButtonStyle.Secondary)
             .Build();
 
-        await Context.Interaction.FollowupAsync(embed: builder.Build(), components: components, ephemeral: ephemeral);
+        await interaction.FollowupAsync(embed: builder.Build(), components: components, ephemeral: ephemeral);
     }
 
     [ComponentInteraction("ocrtranslate", true)]
diff --git a/src/Modules/UrbanModule.cs b/src/Modules/UrbanModule.cs
index fd43d73..7df77f0 100644
--- a/src/Modules/UrbanModule.cs
+++ b/src/Modules/UrbanModule.cs
@@ -63,7 +63,7 @@ public async Task SearchAndSendAsync(UrbanSearchType searchType, string? term =
 
         if (Context.Interaction is SocketInteraction socketInteraction)
         {
-            _ = _interactive.SendPaginatorAsync(paginator, socketInteraction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+           await _interactive.SendPaginatorAsync(paginator, socketInteraction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
         }
 
         PageBuilder GeneratePage(int i)
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 7ff968c..4415f94 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -256,7 +256,7 @@ public async Task Img([Autocomplete(typeof(GoogleAutocompleteHandler))] [Summary
             .AddUser(Context.User)
             .Build();
 
-        _ = _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
         MultiEmbedPageBuilder GeneratePage(int index)
         {
@@ -304,7 +304,7 @@ public async Task Img2([Autocomplete(typeof(DuckDuckGoAutocompleteHandler))] [Su
             .AddUser(Context.User)
             .Build();
 
-        _ = _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
         Task<PageBuilder> GeneratePageAsync(int index)
         {
@@ -352,7 +352,7 @@ public async Task Img3([Autocomplete(typeof(BraveAutocompleteHandler))] [Summary
             .AddUser(Context.User)
             .Build();
 
-        _ = _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
         Task<PageBuilder> GeneratePageAsync(int index)
         {
@@ -564,7 +564,7 @@ public async Task YouTube([Autocomplete(typeof(YouTubeAutocompleteHandler))] [Su
                     .WithFergunEmotes()
                     .Build();
 
-                _ = _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+                await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
                 break;
         }
     }
diff --git a/src/Program.cs b/src/Program.cs
index 6b2460e..e9c8190 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -55,6 +55,7 @@ await Host.CreateDefaultBuilder()
     .ConfigureServices(services =>
     {
         services.AddHostedService<InteractionHandlingService>();
+        services.AddSingleton(new InteractiveConfig { ReturnAfterSendingPaginator = true, DeferStopSelectionInteractions = false });
         services.AddSingleton<InteractiveService>();
         services.AddFergunPolicies();
 
diff --git a/tests/Fergun.Tests/OcrModuleTests.cs b/tests/Fergun.Tests/OcrModuleTests.cs
index d6e564f..cc279dc 100644
--- a/tests/Fergun.Tests/OcrModuleTests.cs
+++ b/tests/Fergun.Tests/OcrModuleTests.cs
@@ -2,8 +2,10 @@
 using System.Threading.Tasks;
 using Discord;
 using Discord.Interactions;
+using Discord.WebSocket;
 using Fergun.Apis.Bing;
 using Fergun.Apis.Yandex;
+using Fergun.Interactive;
 using Fergun.Modules;
 using Microsoft.Extensions.Logging;
 using Moq;
@@ -19,6 +21,8 @@ public class OcrModuleTests
     private readonly Mock<IBingVisualSearch> _bingVisualSearchMock = new();
     private readonly Mock<IYandexImageSearch> _yandexImageSearchMock = new();
     private readonly Mock<ILogger<OcrModule>> _loggerMock = new();
+    private readonly DiscordSocketClient _client = new();
+    private readonly InteractiveService _interactive;
     private readonly Mock<OcrModule> _ocrModuleMock;
     private const string _textImageUrl = "https://example.com/image.png";
     private const string _emptyImageUrl = "https://example.com/empty.png";
@@ -36,7 +40,8 @@ public OcrModuleTests()
         var sharedLogger = Mock.Of<ILogger<SharedModule>>();
         var shared = new SharedModule(sharedLogger, new(), new());
 
-        _ocrModuleMock = new Mock<OcrModule>(() => new OcrModule(_loggerMock.Object, shared, _bingVisualSearchMock.Object, _yandexImageSearchMock.Object));
+        _interactive = new InteractiveService(_client);
+        _ocrModuleMock = new Mock<OcrModule>(() => new OcrModule(_loggerMock.Object, shared, _interactive, _bingVisualSearchMock.Object, _yandexImageSearchMock.Object));
         _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
         ((IInteractionModuleBase)_ocrModuleMock.Object).SetContext(_contextMock.Object);
     }
@@ -51,10 +56,7 @@ public async Task Bing_Uses_BingVisualSearch(string url)
 
         await module.Bing(url);
 
-        _ocrModuleMock
-            .Protected()
-            .As<IDiscordInteraction>()
-            .Verify(x => x.DeferAsync(It.Is<bool>(b => b == isEphemeral), It.IsAny<RequestOptions>()), Times.Once());
+        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == isEphemeral), It.IsAny<RequestOptions>()), Times.Once());
 
         _bingVisualSearchMock.Verify(x => x.OcrAsync(It.Is<string>(s => s == url)), Times.Once);
 
@@ -72,10 +74,7 @@ public async Task Yandex_Uses_YandexImageSearch(string url)
 
         await module.Yandex(url);
 
-        _ocrModuleMock
-            .Protected()
-            .As<IDiscordInteraction>()
-            .Verify(x => x.DeferAsync(It.Is<bool>(b => b == isEphemeral), It.IsAny<RequestOptions>()), Times.Once());
+        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == isEphemeral), It.IsAny<RequestOptions>()), Times.Once());
 
         _yandexImageSearchMock.Verify(x => x.OcrAsync(It.Is<string>(s => s == url)), Times.Once);
 
@@ -89,7 +88,7 @@ public async Task OcrAsync_Returns_Warning_If_Url_Is_Invalid()
         var module = _ocrModuleMock.Object;
         const bool isEphemeral = true;
 
-        await module.OcrAsync(It.IsAny<OcrModule.OcrEngine>(), string.Empty, isEphemeral);
+        await module.OcrAsync(It.IsAny<OcrModule.OcrEngine>(), string.Empty, _interactionMock.Object, isEphemeral);
 
         _interactionMock.Verify(x => x.RespondAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == isEphemeral),
                 It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once());
@@ -101,7 +100,7 @@ public async Task Invalid_OcrEngine_Throws_ArgumentException()
         var module = _ocrModuleMock.Object;
         const bool isEphemeral = true;
 
-        var task = module.OcrAsync((OcrModule.OcrEngine)2, _textImageUrl, isEphemeral);
+        var task = module.OcrAsync((OcrModule.OcrEngine)2, _textImageUrl, _interactionMock.Object, isEphemeral);
 
         await Assert.ThrowsAsync<ArgumentException>(() => task);
     }
@@ -112,12 +111,9 @@ public async Task OcrAsync_Returns_Warning_On_Exception()
         var module = _ocrModuleMock.Object;
         const bool isEphemeral = true;
 
-        await module.OcrAsync(It.IsAny<OcrModule.OcrEngine>(), _invalidImageUrl, isEphemeral);
+        await module.OcrAsync(It.IsAny<OcrModule.OcrEngine>(), _invalidImageUrl, _interactionMock.Object, isEphemeral);
 
-        _ocrModuleMock
-            .Protected()
-            .As<IDiscordInteraction>()
-            .Verify(x => x.DeferAsync(It.Is<bool>(b => b == isEphemeral), It.IsAny<RequestOptions>()), Times.Once());
+        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == isEphemeral), It.IsAny<RequestOptions>()), Times.Once());
 
         _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == isEphemeral),
                 It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once());

From b8cad47066f763e739fbb6d3426286799dd5462c Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Thu, 24 Mar 2022 01:24:31 -0500
Subject: [PATCH 20/83] Use jump to page feature in paginators

---
 src/Extensions/Extensions.cs | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/src/Extensions/Extensions.cs b/src/Extensions/Extensions.cs
index e1cc233..6224302 100644
--- a/src/Extensions/Extensions.cs
+++ b/src/Extensions/Extensions.cs
@@ -72,10 +72,9 @@ public static TBuilder WithFergunEmotes<TPaginator, TBuilder>(this PaginatorBuil
     {
         builder.Options.Clear();
 
-        builder.AddOption(Emoji.Parse("⏮️"), PaginatorAction.SkipToStart);
         builder.AddOption(Emoji.Parse("◀️"), PaginatorAction.Backward);
         builder.AddOption(Emoji.Parse("▶️"), PaginatorAction.Forward);
-        builder.AddOption(Emoji.Parse("⏭️"), PaginatorAction.SkipToEnd);
+        builder.AddOption(Emoji.Parse("🔢"), PaginatorAction.Jump);
         builder.AddOption(Emoji.Parse("🛑"), PaginatorAction.Exit);
 
         return (TBuilder)builder;

From dc30fe1b0544300397bfdc280de7aa4787d4918f Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Tue, 29 Mar 2022 17:54:17 -0500
Subject: [PATCH 21/83] Add reverse image search APIs/command and move image
 commands to a separate module

---
 src/Apis/Bing/BingReverseImageSearchResult.cs |  38 +++
 src/Apis/Bing/BingVisualSearch.cs             |  56 +++-
 .../Bing/IBingReverseImageSearchResult.cs     |  29 ++
 src/Apis/Bing/IBingVisualSearch.cs            |   8 +
 src/Apis/Yandex/IYandexImageSearch.cs         |   8 +
 .../Yandex/IYandexReverseImageSearchResult.cs |  27 ++
 src/Apis/Yandex/YandexImageSearch.cs          | 101 +++++-
 .../Yandex/YandexReverseImageSearchResult.cs  |  37 +++
 src/Apis/Yandex/YandexSearchFilterMode.cs     |  20 ++
 src/Modules/ImageModule.cs                    | 305 ++++++++++++++++++
 src/Modules/UtilityModule.cs                  | 160 +--------
 src/Program.cs                                |   2 +
 12 files changed, 629 insertions(+), 162 deletions(-)
 create mode 100644 src/Apis/Bing/BingReverseImageSearchResult.cs
 create mode 100644 src/Apis/Bing/IBingReverseImageSearchResult.cs
 create mode 100644 src/Apis/Yandex/IYandexReverseImageSearchResult.cs
 create mode 100644 src/Apis/Yandex/YandexReverseImageSearchResult.cs
 create mode 100644 src/Apis/Yandex/YandexSearchFilterMode.cs
 create mode 100644 src/Modules/ImageModule.cs

diff --git a/src/Apis/Bing/BingReverseImageSearchResult.cs b/src/Apis/Bing/BingReverseImageSearchResult.cs
new file mode 100644
index 0000000..4ea1b71
--- /dev/null
+++ b/src/Apis/Bing/BingReverseImageSearchResult.cs
@@ -0,0 +1,38 @@
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Drawing;
+
+namespace Fergun.Apis.Bing;
+
+/// <summary>
+/// Represents a Bing reverse image search result.
+/// </summary>
+[DebuggerDisplay($"{{{nameof(DebuggerDisplay)}}}")]
+public class BingReverseImageSearchResult : IBingReverseImageSearchResult
+{
+    public BingReverseImageSearchResult(string url, string sourceUrl, string text, Color accentColor)
+    {
+        Url = url;
+        SourceUrl = sourceUrl;
+        Text = text;
+        AccentColor = accentColor;
+    }
+
+    /// <inheritdoc/>
+    public string Url { get; }
+
+    /// <inheritdoc/>
+    public string SourceUrl { get; }
+
+    /// <inheritdoc/>
+    public string Text { get; }
+
+    /// <inheritdoc/>
+    public Color AccentColor { get; }
+
+    /// <inheritdoc/>
+    public override string ToString() => $"{nameof(Text)} = {Text}";
+
+    [ExcludeFromCodeCoverage]
+    private string DebuggerDisplay => ToString();
+}
\ No newline at end of file
diff --git a/src/Apis/Bing/BingVisualSearch.cs b/src/Apis/Bing/BingVisualSearch.cs
index d5f41a1..58c73df 100644
--- a/src/Apis/Bing/BingVisualSearch.cs
+++ b/src/Apis/Bing/BingVisualSearch.cs
@@ -1,4 +1,6 @@
-using System.Text.Json;
+using System.Drawing;
+using System.Globalization;
+using System.Text.Json;
 using Fergun.Extensions;
 
 namespace Fergun.Apis.Bing;
@@ -74,7 +76,7 @@ public BingVisualSearch(HttpClient httpClient)
 
         var textRegions = document
             .RootElement
-            .GetPropertyOrDefault("tags")
+            .GetProperty("tags")
             .FirstOrDefault(x => x.GetPropertyOrDefault("displayName").GetStringOrDefault() == "##TextRecognition")
             .GetPropertyOrDefault("actions")
             .FirstOrDefault()
@@ -89,6 +91,56 @@ public BingVisualSearch(HttpClient httpClient)
         return string.Join("\n\n", textRegions);
     }
 
+    /// <inheritdoc/>
+    public async Task<IEnumerable<IBingReverseImageSearchResult>> ReverseImageSearchAsync(string url, bool onlyFamilyFriendly)
+    {
+        EnsureNotDisposed();
+        using var request = BuildRequest(url, "SimilarImages");
+        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
+
+        response.EnsureSuccessStatusCode();
+
+        await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+
+        var root = document.RootElement.Clone();
+
+        var rawItems = root
+            .GetProperty("tags")
+            .EnumerateArray()
+            .Select(x => x.GetPropertyOrDefault("actions"))
+            .SelectMany(x => x.EnumerateArrayOrEmpty())
+            .FirstOrDefault(x => x.GetPropertyOrDefault("actionType").GetStringOrDefault() == "VisualSearch")
+            .GetPropertyOrDefault("data")
+            .GetPropertyOrDefault("value")
+            .EnumerateArrayOrEmpty();
+
+        return EnumerateResults(rawItems, onlyFamilyFriendly);
+    }
+
+    private static IEnumerable<BingReverseImageSearchResult> EnumerateResults(IEnumerable<JsonElement> rawItems, bool onlyFamilyFriendly)
+    {
+        foreach (var item in rawItems)
+        {
+            if (onlyFamilyFriendly && item.GetPropertyOrDefault("isFamilyFriendly").ValueKind == JsonValueKind.False)
+                continue;
+
+            var url = item.GetPropertyOrDefault("contentUrl").GetStringOrDefault();
+            var sourceUrl = item.GetPropertyOrDefault("hostPageUrl").GetStringOrDefault();
+            var text = item.GetPropertyOrDefault("name").GetStringOrDefault();
+            var rawColor = item.GetPropertyOrDefault("accentColor").GetStringOrDefault();
+
+            if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(sourceUrl) || string.IsNullOrEmpty(text))
+            {
+                continue;
+            }
+
+            bool success = int.TryParse(rawColor, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int color);
+
+            yield return new BingReverseImageSearchResult(url, sourceUrl, text, success ? Color.FromArgb(color) : default);
+        }
+    }
+
     private static HttpRequestMessage BuildRequest(string url, string invokedSkill)
     {
         string jsonRequest = $"{{\"imageInfo\":{{\"url\":\"{url}\",\"source\":\"Url\"}},\"knowledgeRequest\":{{\"invokedSkills\":[\"{invokedSkill}\"]}}}}";
diff --git a/src/Apis/Bing/IBingReverseImageSearchResult.cs b/src/Apis/Bing/IBingReverseImageSearchResult.cs
new file mode 100644
index 0000000..6f46271
--- /dev/null
+++ b/src/Apis/Bing/IBingReverseImageSearchResult.cs
@@ -0,0 +1,29 @@
+using System.Drawing;
+
+namespace Fergun.Apis.Bing;
+
+/// <summary>
+/// Represents a Bing reverse image search result.
+/// </summary>
+public interface IBingReverseImageSearchResult
+{
+    /// <summary>
+    /// Gets a URL pointing to the image.
+    /// </summary>
+    string Url { get; }
+
+    /// <summary>
+    /// Gets a URL pointing to the webpage hosting the image.
+    /// </summary>
+    string SourceUrl { get; }
+
+    /// <summary>
+    /// Gets the description of the image result.
+    /// </summary>
+    string Text { get; }
+
+    /// <summary>
+    /// Gets the accent color of this result.
+    /// </summary>
+    Color AccentColor { get; }
+}
\ No newline at end of file
diff --git a/src/Apis/Bing/IBingVisualSearch.cs b/src/Apis/Bing/IBingVisualSearch.cs
index 3d26d13..e412427 100644
--- a/src/Apis/Bing/IBingVisualSearch.cs
+++ b/src/Apis/Bing/IBingVisualSearch.cs
@@ -11,4 +11,12 @@ public interface IBingVisualSearch
     /// <param name="url">The URL of an image.</param>
     /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous OCR operation. The result contains the recognized text.</returns>
     Task<string?> OcrAsync(string url);
+
+    /// <summary>
+    /// Performs reverse image search to the specified image URL.
+    /// </summary>
+    /// <param name="url">The URL of an image.</param>
+    /// <param name="onlyFamilyFriendly">Whether to return only results that considered family friendly by Bing.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous search operation. The result contains an <see cref="IEnumerable{T}"/> of search results.</returns>
+    Task<IEnumerable<IBingReverseImageSearchResult>> ReverseImageSearchAsync(string url, bool onlyFamilyFriendly);
 }
\ No newline at end of file
diff --git a/src/Apis/Yandex/IYandexImageSearch.cs b/src/Apis/Yandex/IYandexImageSearch.cs
index a65c793..cee3c5b 100644
--- a/src/Apis/Yandex/IYandexImageSearch.cs
+++ b/src/Apis/Yandex/IYandexImageSearch.cs
@@ -11,4 +11,12 @@ public interface IYandexImageSearch
     /// <param name="url">The URL of an image.</param>
     /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous OCR operation. The result contains the recognized text.</returns>
     Task<string?> OcrAsync(string url);
+
+    /// <summary>
+    /// Performs reverse image search to the specified image URL.
+    /// </summary>
+    /// <param name="url">The URL of an image.</param>
+    /// <param name="mode">The search filter mode.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous search operation. The result contains an <see cref="IEnumerable{T}"/> of search results.</returns>
+    Task<IEnumerable<IYandexReverseImageSearchResult>> ReverseImageSearchAsync(string url, YandexSearchFilterMode mode = YandexSearchFilterMode.Moderate);
 }
\ No newline at end of file
diff --git a/src/Apis/Yandex/IYandexReverseImageSearchResult.cs b/src/Apis/Yandex/IYandexReverseImageSearchResult.cs
new file mode 100644
index 0000000..d61c358
--- /dev/null
+++ b/src/Apis/Yandex/IYandexReverseImageSearchResult.cs
@@ -0,0 +1,27 @@
+namespace Fergun.Apis.Yandex;
+
+/// <summary>
+/// Represents a Yandex reverse image search result.
+/// </summary>
+public interface IYandexReverseImageSearchResult
+{
+    /// <summary>
+    /// Gets a URL pointing to the image.
+    /// </summary>
+    string Url { get; }
+
+    /// <summary>
+    /// Gets a URL pointing to the webpage hosting the image.
+    /// </summary>
+    string SourceUrl { get; }
+
+    /// <summary>
+    /// Gets the title of the image result.
+    /// </summary>
+    string? Title { get; }
+
+    /// <summary>
+    /// Gets the description of the image result.
+    /// </summary>
+    string Text { get; }
+}
\ No newline at end of file
diff --git a/src/Apis/Yandex/YandexImageSearch.cs b/src/Apis/Yandex/YandexImageSearch.cs
index c5e411b..0341649 100644
--- a/src/Apis/Yandex/YandexImageSearch.cs
+++ b/src/Apis/Yandex/YandexImageSearch.cs
@@ -1,4 +1,6 @@
-using System.Text.Json;
+using System.Net;
+using System.Text.Json;
+using AngleSharp.Html.Parser;
 using Fergun.Extensions;
 
 namespace Fergun.Apis.Yandex;
@@ -9,6 +11,7 @@ namespace Fergun.Apis.Yandex;
 public sealed class YandexImageSearch : IYandexImageSearch, IDisposable
 {
     private const string _defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36";
+    private static readonly HtmlParser _parser = new();
     private readonly HttpClient _httpClient;
     private bool _disposed;
 
@@ -16,7 +19,7 @@ public sealed class YandexImageSearch : IYandexImageSearch, IDisposable
     /// Initializes a new instance of the <see cref="YandexImageSearch"/> class.
     /// </summary>
     public YandexImageSearch()
-        : this(new HttpClient())
+        : this(new HttpClient(new HttpClientHandler { UseCookies = false }))
     {
     }
 
@@ -97,6 +100,59 @@ public YandexImageSearch(HttpClient httpClient)
             .GetStringOrDefault();
     }
 
+    /// <inheritdoc/>
+    public async Task<IEnumerable<IYandexReverseImageSearchResult>> ReverseImageSearchAsync(string url, YandexSearchFilterMode mode = YandexSearchFilterMode.Moderate)
+    {
+        const string imageSearchRequest = @"{""blocks"":[{""block"":""content_type_similar"",""params"":{},""version"":2}]}";
+
+        using var request = new HttpRequestMessage
+        {
+            Method = HttpMethod.Get,
+            RequestUri = new Uri($"https://yandex.com/images/search?rpt=imageview&url={Uri.EscapeDataString(url)}&cbir_page=similar&format=json&request={imageSearchRequest}")
+        };
+
+        var now = DateTimeOffset.UtcNow;
+
+        string? yp = mode switch
+        {
+            YandexSearchFilterMode.None => $"{now.AddYears(10).AddDays(7).ToUnixTimeSeconds()}.sp.aflt%3A{now.ToUnixTimeSeconds()}#{now.AddDays(7).ToUnixTimeSeconds()}.szm.1%3A1920x1080%3A1272x969",
+            YandexSearchFilterMode.Family => $"{now.AddYears(10).AddDays(7).ToUnixTimeSeconds()}.sp.family%3A2#{now.AddDays(7).ToUnixTimeSeconds()}.szm.1%3A1920x1080%3A1272x969",
+            _ => null
+        };
+
+        if (yp is not null)
+        {
+            request.Headers.Add("Cookie", $"yp={yp}");
+        }
+
+        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
+        response.EnsureSuccessStatusCode();
+
+        await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+        using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+
+        if (document.RootElement.TryGetProperty("type", out var typeProp) && typeProp.GetString() == "captcha")
+        {
+            throw new YandexException("Yandex API returned a CAPTCHA. Try again later.");
+        }
+
+        string html = document
+            .RootElement
+            .GetProperty("blocks")[0]
+            .GetProperty("html")
+            .GetString() ?? string.Empty;
+
+        var htmlDocument = _parser.ParseDocument(html);
+
+        var rawItems = htmlDocument
+            .GetElementsByClassName("serp-list")
+            .FirstOrDefault()?
+            .GetElementsByClassName("serp-item")
+            .Select(x => x.GetAttribute("data-bem")) ?? Enumerable.Empty<string?>();
+
+        return EnumerateResults(rawItems);
+    }
+
     /// <inheritdoc/>
     public void Dispose()
     {
@@ -109,6 +165,47 @@ public void Dispose()
         _disposed = true;
     }
 
+    private static IEnumerable<YandexReverseImageSearchResult> EnumerateResults(IEnumerable<string?> rawItems)
+    {
+        foreach (string? rawItem in rawItems)
+        {
+            if (string.IsNullOrEmpty(rawItem))
+                continue;
+
+            JsonDocument document;
+            try
+            {
+                document = JsonDocument.Parse(rawItem);
+            }
+            catch
+            {
+                continue;
+            }
+
+            var item = document.RootElement.GetPropertyOrDefault("serp-item");
+            var snippet = item.GetPropertyOrDefault("snippet");
+
+            var url = item
+                //.GetPropertyOrDefault("dups")
+                //.LastOrDefault()
+                //.GetPropertyOrDefault("url")
+                //.GetStringOrDefault() ?? item
+                .GetPropertyOrDefault("img_href")
+                .GetStringOrDefault();
+
+            var sourceUrl = snippet.GetPropertyOrDefault("url").GetStringOrDefault();
+            var title = snippet.GetPropertyOrDefault("title").GetStringOrDefault();
+            var text = snippet.GetPropertyOrDefault("text").GetStringOrDefault();
+
+            if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(sourceUrl) || string.IsNullOrEmpty(text))
+            {
+                continue;
+            }
+
+            yield return new YandexReverseImageSearchResult(url, sourceUrl, WebUtility.HtmlDecode(title), text);
+        }
+    }
+
     private void EnsureNotDisposed()
     {
         if (_disposed)
diff --git a/src/Apis/Yandex/YandexReverseImageSearchResult.cs b/src/Apis/Yandex/YandexReverseImageSearchResult.cs
new file mode 100644
index 0000000..2b593f9
--- /dev/null
+++ b/src/Apis/Yandex/YandexReverseImageSearchResult.cs
@@ -0,0 +1,37 @@
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Fergun.Apis.Yandex;
+
+/// <summary>
+/// Represents a Yandex reverse image search result.
+/// </summary>
+[DebuggerDisplay($"{{{nameof(DebuggerDisplay)}}}")]
+public class YandexReverseImageSearchResult : IYandexReverseImageSearchResult
+{
+    internal YandexReverseImageSearchResult(string url, string sourceUrl, string? title, string text)
+    {
+        Url = url;
+        SourceUrl = sourceUrl;
+        Title = title;
+        Text = text;
+    }
+
+    /// <inheritdoc/>
+    public string Url { get; }
+
+    /// <inheritdoc/>
+    public string SourceUrl { get; }
+
+    /// <inheritdoc/>
+    public string? Title { get; }
+
+    /// <inheritdoc/>
+    public string Text { get; }
+
+    /// <inheritdoc/>
+    public override string ToString() => $"{nameof(Title)} = {Title ?? "(None)"}, {nameof(Text)} = {Text}";
+
+    [ExcludeFromCodeCoverage]
+    private string DebuggerDisplay => ToString();
+}
\ No newline at end of file
diff --git a/src/Apis/Yandex/YandexSearchFilterMode.cs b/src/Apis/Yandex/YandexSearchFilterMode.cs
new file mode 100644
index 0000000..be320e4
--- /dev/null
+++ b/src/Apis/Yandex/YandexSearchFilterMode.cs
@@ -0,0 +1,20 @@
+namespace Fergun.Apis.Yandex;
+
+/// <summary>
+/// Specifies the filter modes in Yandex.Search.
+/// </summary>
+public enum YandexSearchFilterMode
+{
+    /// <summary>
+    /// Search results include all the documents found for the query, including internet resources “for adults”.
+    /// </summary>
+    None,
+    /// <summary>
+    /// Sites “for adults” are excluded from search results if the query does not explicitly search for such resources.
+    /// </summary>
+    Moderate,
+    /// <summary>
+    /// Adult content and sites containing obscene language are completely excluded from search results (even if the query is clearly directed at finding such resources).
+    /// </summary>
+    Family
+}
\ No newline at end of file
diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
new file mode 100644
index 0000000..832a434
--- /dev/null
+++ b/src/Modules/ImageModule.cs
@@ -0,0 +1,305 @@
+using Discord;
+using Discord.Interactions;
+using Discord.WebSocket;
+using Fergun.Apis.Bing;
+using Fergun.Apis.Yandex;
+using Fergun.Extensions;
+using Fergun.Interactive;
+using Fergun.Interactive.Pagination;
+using Fergun.Modules.Handlers;
+using GScraper;
+using GScraper.Brave;
+using GScraper.DuckDuckGo;
+using GScraper.Google;
+using Microsoft.Extensions.Logging;
+
+namespace Fergun.Modules;
+
+[Group("img", "Image search commands.")]
+public class ImageModule : InteractionModuleBase
+{
+    private readonly ILogger<UtilityModule> _logger;
+    private readonly SharedModule _shared;
+    private readonly InteractiveService _interactive;
+    private readonly GoogleScraper _googleScraper;
+    private readonly DuckDuckGoScraper _duckDuckGoScraper;
+    private readonly BraveScraper _braveScraper;
+    private readonly IBingVisualSearch _bingVisualSearch;
+    private readonly IYandexImageSearch _yandexImageSearch;
+
+    public ImageModule(ILogger<UtilityModule> logger, SharedModule shared, InteractiveService interactive, GoogleScraper googleScraper,
+        DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper, IBingVisualSearch bingVisualSearch, IYandexImageSearch yandexImageSearch)
+    {
+        _logger = logger;
+        _shared = shared;
+        _interactive = interactive;
+        _googleScraper = googleScraper;
+        _duckDuckGoScraper = duckDuckGoScraper;
+        _braveScraper = braveScraper;
+        _bingVisualSearch = bingVisualSearch;
+        _yandexImageSearch = yandexImageSearch;
+    }
+
+    [SlashCommand("google", "Searches for images from Google Images and displays them in a paginator.")]
+    public async Task Google([Autocomplete(typeof(GoogleAutocompleteHandler))][Summary(description: "The query to search.")] string query,
+        [Summary(description: "Whether to display multiple images in a single page.")] bool multiImages = false)
+    {
+        await DeferAsync();
+
+        bool isNsfw = Context.Channel.IsNsfw();
+        _logger.LogInformation(new EventId(0, "img"), "Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
+
+        var images = await _googleScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict, language: Context.Interaction.GetLanguageCode());
+
+        var filteredImages = images
+            .Where(x => x.Url.StartsWith("http") && x.SourceUrl.StartsWith("http"))
+            .Chunk(multiImages ? 4 : 1)
+            .ToArray();
+
+        _logger.LogInformation(new EventId(0, "img"), "Image results: {count}", filteredImages.Length);
+
+        if (filteredImages.Length == 0)
+        {
+            await Context.Interaction.FollowupWarning("No results.");
+            return;
+        }
+
+        var paginator = new LazyPaginatorBuilder()
+            .WithPageFactory(GeneratePage)
+            .WithFergunEmotes()
+            .WithActionOnCancellation(ActionOnStop.DisableInput)
+            .WithActionOnTimeout(ActionOnStop.DisableInput)
+            .WithMaxPageIndex(filteredImages.Length - 1)
+            .WithFooter(PaginatorFooter.None)
+            .AddUser(Context.User)
+            .Build();
+
+        if (Context.Interaction is SocketInteraction socketInteraction)
+        {
+            await _interactive.SendPaginatorAsync(paginator, socketInteraction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+        }
+
+        MultiEmbedPageBuilder GeneratePage(int index)
+        {
+            var builders = filteredImages[index].Select(result => new EmbedBuilder()
+                .WithTitle(result.Title)
+                .WithDescription("Google Images results")
+                .WithUrl(multiImages ? "https://google.com" : result.SourceUrl)
+                .WithImageUrl(result.Url)
+                .WithFooter($"Page {index + 1}/{filteredImages.Length}", Constants.GoogleLogoUrl)
+                .WithColor(Color.Orange));
+
+            return new MultiEmbedPageBuilder().WithBuilders(builders);
+        }
+    }
+
+    [SlashCommand("duckduckgo", "Searches for images from DuckDuckGo and displays them in a paginator.")]
+    public async Task DuckDuckGo([Autocomplete(typeof(DuckDuckGoAutocompleteHandler))][Summary(description: "The query to search.")] string query)
+    {
+        await DeferAsync();
+
+        bool isNsfw = Context.Channel.IsNsfw();
+        _logger.LogInformation(new EventId(0, "img2"), "Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
+
+        var images = await _duckDuckGoScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict);
+
+        var filteredImages = images
+            .Where(x => x.Url.StartsWith("http") && x.SourceUrl.StartsWith("http"))
+            .ToArray();
+
+        _logger.LogInformation(new EventId(0, "img2"), "Image results: {count}", filteredImages.Length);
+
+        if (filteredImages.Length == 0)
+        {
+            await Context.Interaction.FollowupWarning("No results.");
+            return;
+        }
+
+        var paginator = new LazyPaginatorBuilder()
+            .WithPageFactory(GeneratePageAsync)
+            .WithFergunEmotes()
+            .WithActionOnCancellation(ActionOnStop.DisableInput)
+            .WithActionOnTimeout(ActionOnStop.DisableInput)
+            .WithMaxPageIndex(filteredImages.Length - 1)
+            .WithFooter(PaginatorFooter.None)
+            .AddUser(Context.User)
+            .Build();
+
+        if (Context.Interaction is SocketInteraction socketInteraction)
+        {
+            await _interactive.SendPaginatorAsync(paginator, socketInteraction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+        }
+
+        Task<PageBuilder> GeneratePageAsync(int index)
+        {
+            var pageBuilder = new PageBuilder()
+                .WithTitle(filteredImages[index].Title)
+                .WithDescription("DuckDuckGo image search")
+                .WithUrl(filteredImages[index].SourceUrl)
+                .WithImageUrl(filteredImages[index].Url)
+                .WithFooter($"Page {index + 1}/{filteredImages.Length}", Constants.DuckDuckGoLogoUrl)
+                .WithColor(Color.Orange);
+
+            return Task.FromResult(pageBuilder);
+        }
+    }
+
+    [SlashCommand("brave", "Searches for images from Brave and displays them in a paginator.")]
+    public async Task Brave([Autocomplete(typeof(BraveAutocompleteHandler))][Summary(description: "The query to search.")] string query)
+    {
+        await DeferAsync();
+
+        bool isNsfw = Context.Channel.IsNsfw();
+        _logger.LogInformation(new EventId(0, "img3"), "Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
+
+        var images = await _braveScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict);
+
+        var filteredImages = images
+            .Where(x => x.Url.StartsWith("http") && x.SourceUrl.StartsWith("http"))
+            .ToArray();
+
+        _logger.LogInformation(new EventId(0, "img3"), "Image results: {count}", filteredImages.Length);
+
+        if (filteredImages.Length == 0)
+        {
+            await Context.Interaction.FollowupWarning("No results.");
+            return;
+        }
+
+        var paginator = new LazyPaginatorBuilder()
+            .WithPageFactory(GeneratePageAsync)
+            .WithFergunEmotes()
+            .WithActionOnCancellation(ActionOnStop.DisableInput)
+            .WithActionOnTimeout(ActionOnStop.DisableInput)
+            .WithMaxPageIndex(filteredImages.Length - 1)
+            .WithFooter(PaginatorFooter.None)
+            .AddUser(Context.User)
+            .Build();
+
+        if (Context.Interaction is SocketInteraction socketInteraction)
+        {
+            await _interactive.SendPaginatorAsync(paginator, socketInteraction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+        }
+
+        Task<PageBuilder> GeneratePageAsync(int index)
+        {
+            var pageBuilder = new PageBuilder()
+                .WithTitle(filteredImages[index].Title)
+                .WithDescription("Brave image search")
+                .WithUrl(filteredImages[index].SourceUrl)
+                .WithImageUrl(filteredImages[index].Url)
+                .WithFooter($"Page {index + 1}/{filteredImages.Length}", Constants.BraveLogoUrl)
+                .WithColor(Color.Orange);
+
+            return Task.FromResult(pageBuilder);
+        }
+    }
+
+    [SlashCommand("reverse", "Reverse image search.")]
+    public async Task Reverse([Summary(description: "The url of an image.")] string url,
+        [Summary(description: $"The search engine. The default is {nameof(ReverseImageSearchEngine.Yandex)}.")] ReverseImageSearchEngine engine = ReverseImageSearchEngine.Yandex,
+        [Summary(description: "Whether to display multiple images in a single page.")] bool multiImages = false)
+    {
+        await (engine switch
+        {
+            ReverseImageSearchEngine.Yandex => YandexAsync(url, multiImages),
+            ReverseImageSearchEngine.Bing => BingAsync(url, multiImages),
+            _ => throw new ArgumentException("Invalid engine", nameof(engine))
+        });
+    }
+
+    public async Task YandexAsync(string url, bool multiImages)
+    {
+        await DeferAsync();
+        bool isNsfw = Context.Channel.IsNsfw();
+
+        var results = (await _yandexImageSearch.ReverseImageSearchAsync(url, isNsfw ? YandexSearchFilterMode.None : YandexSearchFilterMode.Family))
+            .Chunk(multiImages ? 4 : 1)
+            .ToArray();
+
+        if (results.Length == 0)
+        {
+            await Context.Interaction.FollowupWarning("No results.");
+            return;
+        }
+
+        var paginator = new LazyPaginatorBuilder()
+            .WithPageFactory(GeneratePage)
+            .WithFergunEmotes()
+            .WithActionOnCancellation(ActionOnStop.DisableInput)
+            .WithActionOnTimeout(ActionOnStop.DisableInput)
+            .WithMaxPageIndex(results.Length - 1)
+            .WithFooter(PaginatorFooter.None)
+            .AddUser(Context.User)
+            .Build();
+
+        if (Context.Interaction is SocketInteraction socketInteraction)
+        {
+            await _interactive.SendPaginatorAsync(paginator, socketInteraction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+        }
+
+        MultiEmbedPageBuilder GeneratePage(int index)
+        {
+            var builders = results[index].Select(result => new EmbedBuilder()
+                .WithTitle(result.Title ?? "")
+                .WithDescription(result.Text)
+                .WithUrl(multiImages ? "https://yandex.com/images" : result.SourceUrl)
+                .WithThumbnailUrl(url)
+                .WithImageUrl(result.Url)
+                .WithFooter($"Yandex Image Search | Page {index + 1}/{results.Length}", Constants.YandexIconUrl)
+                .WithColor(Color.Orange));
+
+            return new MultiEmbedPageBuilder().WithBuilders(builders);
+        }
+    }
+
+    public async Task BingAsync(string url, bool multiImages)
+    {
+        await DeferAsync();
+        bool isNsfw = Context.Channel.IsNsfw();
+
+        var results = (await _bingVisualSearch.ReverseImageSearchAsync(url, !isNsfw))
+            .Chunk(multiImages ? 4 : 1)
+            .ToArray();
+
+        if (results.Length == 0)
+        {
+            await Context.Interaction.FollowupWarning("No results.");
+            return;
+        }
+
+        var paginator = new LazyPaginatorBuilder()
+            .WithPageFactory(GeneratePage)
+            .WithFergunEmotes()
+            .WithActionOnCancellation(ActionOnStop.DisableInput)
+            .WithActionOnTimeout(ActionOnStop.DisableInput)
+            .WithMaxPageIndex(results.Length - 1)
+            .WithFooter(PaginatorFooter.None)
+            .AddUser(Context.User)
+            .Build();
+
+        if (Context.Interaction is SocketInteraction socketInteraction)
+        {
+            await _interactive.SendPaginatorAsync(paginator, socketInteraction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+        }
+
+        MultiEmbedPageBuilder GeneratePage(int index)
+        {
+            var builders = results[index].Select(result => new EmbedBuilder()
+                .WithTitle(result.Text)
+                .WithUrl(multiImages ? "https://www.bing.com/visualsearch" : result.SourceUrl)
+                .WithThumbnailUrl(url)
+                .WithImageUrl(result.Url)
+                .WithFooter($"Bing Visual Search | Page {index + 1}/{results.Length}", Constants.BingIconUrl)
+                .WithColor((Color)result.AccentColor));
+
+            return new MultiEmbedPageBuilder().WithBuilders(builders);
+        }
+    }
+
+    public enum ReverseImageSearchEngine
+    {
+        Bing,
+        Yandex
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 4415f94..aca92d9 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -8,10 +8,6 @@
 using Fergun.Interactive.Pagination;
 using Fergun.Modules.Handlers;
 using Fergun.Utils;
-using GScraper;
-using GScraper.Brave;
-using GScraper.DuckDuckGo;
-using GScraper.Google;
 using GTranslate;
 using GTranslate.Results;
 using GTranslate.Translators;
@@ -31,18 +27,15 @@ public class UtilityModule : InteractionModuleBase<ShardedInteractionContext>
     private readonly GoogleTranslator2 _googleTranslator2;
     private readonly MicrosoftTranslator _microsoftTranslator;
     private readonly YandexTranslator _yandexTranslator;
-    private readonly GoogleScraper _googleScraper;
-    private readonly DuckDuckGoScraper _duckDuckGoScraper;
-    private readonly BraveScraper _braveScraper;
     private readonly SearchClient _searchClient;
+
     private static readonly Lazy<Language[]> _lazyFilteredLanguages = new(() => Language.LanguageDictionary
         .Values
         .Where(x => x.SupportedServices == (TranslationServices.Google | TranslationServices.Bing | TranslationServices.Yandex | TranslationServices.Microsoft))
         .ToArray());
 
     public UtilityModule(ILogger<UtilityModule> logger, SharedModule shared, InteractiveService interactive, GoogleTranslator googleTranslator,
-        GoogleTranslator2 googleTranslator2, MicrosoftTranslator microsoftTranslator, YandexTranslator yandexTranslator,
-        GoogleScraper googleScraper, DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper, SearchClient searchClient)
+        GoogleTranslator2 googleTranslator2, MicrosoftTranslator microsoftTranslator, YandexTranslator yandexTranslator, SearchClient searchClient)
     {
         _logger = logger;
         _shared = shared;
@@ -51,9 +44,6 @@ public UtilityModule(ILogger<UtilityModule> logger, SharedModule shared, Interac
         _googleTranslator2 = googleTranslator2;
         _microsoftTranslator = microsoftTranslator;
         _yandexTranslator = yandexTranslator;
-        _googleScraper = googleScraper;
-        _duckDuckGoScraper = duckDuckGoScraper;
-        _braveScraper = braveScraper;
         _searchClient = searchClient;
     }
 
@@ -222,152 +212,6 @@ public async Task Ping()
         await Context.Interaction.ModifyOriginalResponseAsync(x => x.Embed = embed);
     }
 
-    [SlashCommand("img", "Searches for images from Google Images and displays them in a paginator.")]
-    public async Task Img([Autocomplete(typeof(GoogleAutocompleteHandler))] [Summary(description: "The query to search.")] string query,
-        [Summary(description: "Whether to display multiple images in a single page.")] bool multiImages = false)
-    {
-        await DeferAsync();
-
-        bool isNsfw = Context.Channel.IsNsfw();
-        _logger.LogInformation(new EventId(0, "img"), "Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
-
-        var images = await _googleScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict, language: Context.Interaction.GetLanguageCode());
-
-        var filteredImages = images
-            .Where(x => x.Url.StartsWith("http") && x.SourceUrl.StartsWith("http"))
-            .Chunk(multiImages ? 4 : 1)
-            .ToArray();
-
-        _logger.LogInformation(new EventId(0, "img"), "Image results: {count}", filteredImages.Length);
-
-        if (filteredImages.Length == 0)
-        {
-            await Context.Interaction.FollowupWarning("No results.");
-            return;
-        }
-
-        var paginator = new LazyPaginatorBuilder()
-            .WithPageFactory(GeneratePage)
-            .WithFergunEmotes()
-            .WithActionOnCancellation(ActionOnStop.DisableInput)
-            .WithActionOnTimeout(ActionOnStop.DisableInput)
-            .WithMaxPageIndex(filteredImages.Length - 1)
-            .WithFooter(PaginatorFooter.None)
-            .AddUser(Context.User)
-            .Build();
-
-        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
-
-        MultiEmbedPageBuilder GeneratePage(int index)
-        {
-            var builders = filteredImages[index].Select(result => new EmbedBuilder()
-                .WithTitle(result.Title)
-                .WithDescription("Google Images results")
-                .WithUrl(multiImages ? "https://google.com" : result.SourceUrl)
-                .WithImageUrl(result.Url)
-                .WithFooter($"Page {index + 1}/{filteredImages.Length}", Constants.GoogleLogoUrl)
-                .WithColor(Color.Orange));
-
-            return new MultiEmbedPageBuilder().WithBuilders(builders);
-        }
-    }
-
-    [SlashCommand("img2", "Searches for images from DuckDuckGo and displays them in a paginator.")]
-    public async Task Img2([Autocomplete(typeof(DuckDuckGoAutocompleteHandler))] [Summary(description: "The query to search.")] string query)
-    {
-        await DeferAsync();
-
-        bool isNsfw = Context.Channel.IsNsfw();
-        _logger.LogInformation(new EventId(0, "img2"), "Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
-
-        var images = await _duckDuckGoScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict);
-
-        var filteredImages = images
-            .Where(x => x.Url.StartsWith("http") && x.SourceUrl.StartsWith("http"))
-            .ToArray();
-
-        _logger.LogInformation(new EventId(0, "img2"), "Image results: {count}", filteredImages.Length);
-
-        if (filteredImages.Length == 0)
-        {
-            await Context.Interaction.FollowupWarning("No results.");
-            return;
-        }
-
-        var paginator = new LazyPaginatorBuilder()
-            .WithPageFactory(GeneratePageAsync)
-            .WithFergunEmotes()
-            .WithActionOnCancellation(ActionOnStop.DisableInput)
-            .WithActionOnTimeout(ActionOnStop.DisableInput)
-            .WithMaxPageIndex(filteredImages.Length - 1)
-            .WithFooter(PaginatorFooter.None)
-            .AddUser(Context.User)
-            .Build();
-
-        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
-
-        Task<PageBuilder> GeneratePageAsync(int index)
-        {
-            var pageBuilder = new PageBuilder()
-                .WithTitle(filteredImages[index].Title)
-                .WithDescription("DuckDuckGo image search")
-                .WithUrl(filteredImages[index].SourceUrl)
-                .WithImageUrl(filteredImages[index].Url)
-                .WithFooter($"Page {index + 1}/{filteredImages.Length}", Constants.DuckDuckGoLogoUrl)
-                .WithColor(Color.Orange);
-
-            return Task.FromResult(pageBuilder);
-        }
-    }
-
-    [SlashCommand("img3", "Searches for images from Brave and displays them in a paginator.")]
-    public async Task Img3([Autocomplete(typeof(BraveAutocompleteHandler))] [Summary(description: "The query to search.")] string query)
-    {
-        await DeferAsync();
-
-        bool isNsfw = Context.Channel.IsNsfw();
-        _logger.LogInformation(new EventId(0, "img3"), "Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
-
-        var images = await _braveScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict);
-
-        var filteredImages = images
-            .Where(x => x.Url.StartsWith("http") && x.SourceUrl.StartsWith("http"))
-            .ToArray();
-
-        _logger.LogInformation(new EventId(0, "img3"), "Image results: {count}", filteredImages.Length);
-
-        if (filteredImages.Length == 0)
-        {
-            await Context.Interaction.FollowupWarning("No results.");
-            return;
-        }
-
-        var paginator = new LazyPaginatorBuilder()
-            .WithPageFactory(GeneratePageAsync)
-            .WithFergunEmotes()
-            .WithActionOnCancellation(ActionOnStop.DisableInput)
-            .WithActionOnTimeout(ActionOnStop.DisableInput)
-            .WithMaxPageIndex(filteredImages.Length - 1)
-            .WithFooter(PaginatorFooter.None)
-            .AddUser(Context.User)
-            .Build();
-
-        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
-
-        Task<PageBuilder> GeneratePageAsync(int index)
-        {
-            var pageBuilder = new PageBuilder()
-                .WithTitle(filteredImages[index].Title)
-                .WithDescription("Brave image search")
-                .WithUrl(filteredImages[index].SourceUrl)
-                .WithImageUrl(filteredImages[index].Url)
-                .WithFooter($"Page {index + 1}/{filteredImages.Length}", Constants.BraveLogoUrl)
-                .WithColor(Color.Orange);
-
-            return Task.FromResult(pageBuilder);
-        }
-    }
-
     [SlashCommand("say", "Says something.")]
     public async Task Say([Summary(description: "The text to send.")] string text)
     {
diff --git a/src/Program.cs b/src/Program.cs
index e9c8190..8638da9 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -64,6 +64,7 @@ await Host.CreateDefaultBuilder()
             .AddRetryPolicy();
 
         services.AddHttpClient<IYandexImageSearch, YandexImageSearch>()
+            .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { UseCookies = false })
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
 
@@ -111,6 +112,7 @@ await Host.CreateDefaultBuilder()
             .AddRetryPolicy();
 
         services.AddHttpClient(nameof(DuckDuckGoScraper))
+            .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { UseCookies = false })
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
 

From bd9bbd6dae4fcc37b669d6ec4efb245ef9fb9178 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Wed, 30 Mar 2022 19:59:08 -0500
Subject: [PATCH 22/83] Add tests for reverse image search APIs

---
 src/Apis/Bing/BingReverseImageSearchResult.cs |   6 +
 src/Apis/Bing/BingVisualSearch.cs             |  26 ++--
 src/Apis/Bing/ColorJsonConverter.cs           |  20 +++
 src/Apis/Yandex/YandexImageSearch.cs          |  14 +-
 tests/Fergun.Tests/BingVisualSearchTests.cs   |  17 +++
 tests/Fergun.Tests/YandexImageSearchTests.cs  | 122 ++++++++++++++++++
 6 files changed, 181 insertions(+), 24 deletions(-)
 create mode 100644 src/Apis/Bing/ColorJsonConverter.cs

diff --git a/src/Apis/Bing/BingReverseImageSearchResult.cs b/src/Apis/Bing/BingReverseImageSearchResult.cs
index 4ea1b71..aee366b 100644
--- a/src/Apis/Bing/BingReverseImageSearchResult.cs
+++ b/src/Apis/Bing/BingReverseImageSearchResult.cs
@@ -1,6 +1,7 @@
 using System.Diagnostics;
 using System.Diagnostics.CodeAnalysis;
 using System.Drawing;
+using System.Text.Json.Serialization;
 
 namespace Fergun.Apis.Bing;
 
@@ -19,15 +20,20 @@ public BingReverseImageSearchResult(string url, string sourceUrl, string text, C
     }
 
     /// <inheritdoc/>
+    [JsonPropertyName("contentUrl")]
     public string Url { get; }
 
     /// <inheritdoc/>
+    [JsonPropertyName("hostPageUrl")]
     public string SourceUrl { get; }
 
     /// <inheritdoc/>
+    [JsonPropertyName("name")]
     public string Text { get; }
 
     /// <inheritdoc/>
+    [JsonPropertyName("accentColor")]
+    [JsonConverter(typeof(ColorJsonConverter))]
     public Color AccentColor { get; }
 
     /// <inheritdoc/>
diff --git a/src/Apis/Bing/BingVisualSearch.cs b/src/Apis/Bing/BingVisualSearch.cs
index 58c73df..1ed7ea4 100644
--- a/src/Apis/Bing/BingVisualSearch.cs
+++ b/src/Apis/Bing/BingVisualSearch.cs
@@ -1,6 +1,4 @@
-using System.Drawing;
-using System.Globalization;
-using System.Text.Json;
+using System.Text.Json;
 using Fergun.Extensions;
 
 namespace Fergun.Apis.Bing;
@@ -91,8 +89,8 @@ public BingVisualSearch(HttpClient httpClient)
         return string.Join("\n\n", textRegions);
     }
 
-    /// <inheritdoc/>
-    public async Task<IEnumerable<IBingReverseImageSearchResult>> ReverseImageSearchAsync(string url, bool onlyFamilyFriendly)
+    /// <inheritdoc cref="IBingVisualSearch.ReverseImageSearchAsync(string, bool)"/>
+    public async Task<IEnumerable<BingReverseImageSearchResult>> ReverseImageSearchAsync(string url, bool onlyFamilyFriendly)
     {
         EnsureNotDisposed();
         using var request = BuildRequest(url, "SimilarImages");
@@ -125,19 +123,7 @@ private static IEnumerable<BingReverseImageSearchResult> EnumerateResults(IEnume
             if (onlyFamilyFriendly && item.GetPropertyOrDefault("isFamilyFriendly").ValueKind == JsonValueKind.False)
                 continue;
 
-            var url = item.GetPropertyOrDefault("contentUrl").GetStringOrDefault();
-            var sourceUrl = item.GetPropertyOrDefault("hostPageUrl").GetStringOrDefault();
-            var text = item.GetPropertyOrDefault("name").GetStringOrDefault();
-            var rawColor = item.GetPropertyOrDefault("accentColor").GetStringOrDefault();
-
-            if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(sourceUrl) || string.IsNullOrEmpty(text))
-            {
-                continue;
-            }
-
-            bool success = int.TryParse(rawColor, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int color);
-
-            yield return new BingReverseImageSearchResult(url, sourceUrl, text, success ? Color.FromArgb(color) : default);
+            yield return item.Deserialize<BingReverseImageSearchResult>()!;
         }
     }
 
@@ -180,4 +166,8 @@ private void EnsureNotDisposed()
             throw new ObjectDisposedException(nameof(BingVisualSearch));
         }
     }
+
+    /// <inheritdoc/>
+    async Task<IEnumerable<IBingReverseImageSearchResult>> IBingVisualSearch.ReverseImageSearchAsync(string url, bool onlyFamilyFriendly)
+        => await ReverseImageSearchAsync(url, onlyFamilyFriendly).ConfigureAwait(false);
 }
\ No newline at end of file
diff --git a/src/Apis/Bing/ColorJsonConverter.cs b/src/Apis/Bing/ColorJsonConverter.cs
new file mode 100644
index 0000000..2e01fd5
--- /dev/null
+++ b/src/Apis/Bing/ColorJsonConverter.cs
@@ -0,0 +1,20 @@
+using System.Drawing;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Fergun.Apis.Bing;
+
+/// <summary>
+/// Converts a string to a <see cref="Color"/>.
+/// </summary>
+public class ColorJsonConverter : JsonConverter<Color>
+{
+    /// <inheritdoc/>
+    public override Color Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+        => int.TryParse(reader.GetString(), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int color) ? Color.FromArgb(color) : default;
+
+    /// <inheritdoc/>
+    public override void Write(Utf8JsonWriter writer, Color colorValue, JsonSerializerOptions options)
+        => writer.WriteStringValue(colorValue.ToArgb().ToString("X"));
+}
\ No newline at end of file
diff --git a/src/Apis/Yandex/YandexImageSearch.cs b/src/Apis/Yandex/YandexImageSearch.cs
index 0341649..44877ce 100644
--- a/src/Apis/Yandex/YandexImageSearch.cs
+++ b/src/Apis/Yandex/YandexImageSearch.cs
@@ -100,9 +100,11 @@ public YandexImageSearch(HttpClient httpClient)
             .GetStringOrDefault();
     }
 
-    /// <inheritdoc/>
-    public async Task<IEnumerable<IYandexReverseImageSearchResult>> ReverseImageSearchAsync(string url, YandexSearchFilterMode mode = YandexSearchFilterMode.Moderate)
+    /// <inheritdoc cref="IYandexImageSearch.ReverseImageSearchAsync(string, YandexSearchFilterMode)"/>
+    public async Task<IEnumerable<YandexReverseImageSearchResult>> ReverseImageSearchAsync(string url, YandexSearchFilterMode mode = YandexSearchFilterMode.Moderate)
     {
+        EnsureNotDisposed();
+
         const string imageSearchRequest = @"{""blocks"":[{""block"":""content_type_similar"",""params"":{},""version"":2}]}";
 
         using var request = new HttpRequestMessage
@@ -186,10 +188,6 @@ private static IEnumerable<YandexReverseImageSearchResult> EnumerateResults(IEnu
             var snippet = item.GetPropertyOrDefault("snippet");
 
             var url = item
-                //.GetPropertyOrDefault("dups")
-                //.LastOrDefault()
-                //.GetPropertyOrDefault("url")
-                //.GetStringOrDefault() ?? item
                 .GetPropertyOrDefault("img_href")
                 .GetStringOrDefault();
 
@@ -213,4 +211,8 @@ private void EnsureNotDisposed()
             throw new ObjectDisposedException(nameof(YandexImageSearch));
         }
     }
+
+    /// <inheritdoc/>
+    async Task<IEnumerable<IYandexReverseImageSearchResult>> IYandexImageSearch.ReverseImageSearchAsync(string url, YandexSearchFilterMode mode)
+        => await ReverseImageSearchAsync(url, mode).ConfigureAwait(false);
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/BingVisualSearchTests.cs b/tests/Fergun.Tests/BingVisualSearchTests.cs
index 08f59ee..55f8aea 100644
--- a/tests/Fergun.Tests/BingVisualSearchTests.cs
+++ b/tests/Fergun.Tests/BingVisualSearchTests.cs
@@ -1,4 +1,5 @@
 using System;
+using System.Linq;
 using System.Threading.Tasks;
 using Fergun.Apis.Bing;
 using Moq;
@@ -32,6 +33,21 @@ public async Task OcrAsync_Throws_BingException_If_Image_Is_Invalid(string url)
         await Assert.ThrowsAsync<BingException>(() => task);
     }
 
+    [Theory]
+    [InlineData("https://upload.wikimedia.org/wikipedia/commons/thumb/4/4d/Cat_November_2010-1a.jpg/1200px-Cat_November_2010-1a.jpg", true)]
+    [InlineData("https://upload.wikimedia.org/wikipedia/commons/1/18/Dog_Breeds.jpg", false)]
+    public async Task ReverseImageSearchAsync_Returns_Results(string url, bool onlyFamilyFriendly)
+    {
+        var results = (await _bingVisualSearch.ReverseImageSearchAsync(url, onlyFamilyFriendly)).ToArray();
+
+        Assert.NotNull(results);
+        Assert.NotEmpty(results);
+        Assert.All(results, x => Assert.NotNull(x.Url));
+        Assert.All(results, x => Assert.NotNull(x.SourceUrl));
+        Assert.All(results, x => Assert.NotNull(x.Text));
+        Assert.All(results, x => Assert.NotNull(x.ToString()));
+    }
+
     [Fact]
     public async Task Disposed_UrbanDictionary_Usage_Throws_ObjectDisposedException()
     {
@@ -39,5 +55,6 @@ public async Task Disposed_UrbanDictionary_Usage_Throws_ObjectDisposedException(
         _bingVisualSearch.Dispose();
 
         await Assert.ThrowsAsync<ObjectDisposedException>(() => _bingVisualSearch.OcrAsync(It.IsAny<string>()));
+        await Assert.ThrowsAsync<ObjectDisposedException>(() => _bingVisualSearch.ReverseImageSearchAsync(It.IsAny<string>(), It.IsAny<bool>()));
     }
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/YandexImageSearchTests.cs b/tests/Fergun.Tests/YandexImageSearchTests.cs
index 4b341e3..15bcb6f 100644
--- a/tests/Fergun.Tests/YandexImageSearchTests.cs
+++ b/tests/Fergun.Tests/YandexImageSearchTests.cs
@@ -1,8 +1,13 @@
 using System;
+using System.Linq;
 using System.Net;
 using System.Net.Http;
+using System.Text.Json;
 using System.Threading;
 using System.Threading.Tasks;
+using AngleSharp;
+using AngleSharp.Dom;
+using AngleSharp.Html.Dom;
 using Fergun.Apis.Yandex;
 using Moq;
 using Moq.Protected;
@@ -65,6 +70,122 @@ public async Task OcrAsync_Throws_YandexException_If_Captcha_Is_Present()
         await Assert.ThrowsAsync<YandexException>(() => task);
     }
 
+    [Theory]
+    [InlineData("https://upload.wikimedia.org/wikipedia/commons/thumb/4/4d/Cat_November_2010-1a.jpg/1200px-Cat_November_2010-1a.jpg", YandexSearchFilterMode.None)]
+    [InlineData("https://upload.wikimedia.org/wikipedia/commons/1/18/Dog_Breeds.jpg", YandexSearchFilterMode.Moderate)]
+    [InlineData("https://upload.wikimedia.org/wikipedia/commons/0/0e/Landscape-2454891_960_720.jpg", YandexSearchFilterMode.Family)]
+    public async Task ReverseImageSearchAsync_Returns_Results(string url, YandexSearchFilterMode mode)
+    {
+        var results = (await _yandexImageSearch.ReverseImageSearchAsync(url, mode)).ToArray();
+
+        Assert.NotNull(results);
+        Assert.NotEmpty(results);
+        Assert.All(results, x => Assert.NotNull(x.Url));
+        Assert.All(results, x => Assert.NotNull(x.SourceUrl));
+        Assert.All(results, x => Assert.NotNull(x.Text));
+        Assert.All(results, x => Assert.NotNull(x.ToString()));
+    }
+
+    [Fact]
+    public async Task ReverseImageSearchAsync_Throws_YandexException_If_Captcha_Is_Present()
+    {
+        var messageHandlerMock = new Mock<HttpMessageHandler>();
+
+        messageHandlerMock
+            .Protected()
+            .As<HttpClient>()
+            .SetupSequence(x => x.SendAsync(It.IsAny<HttpRequestMessage>(), It.IsAny<CancellationToken>()))
+            .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"type\":\"captcha\"}") });
+
+        var yandexImageSearch = new YandexImageSearch(new HttpClient(messageHandlerMock.Object));
+
+        var task = yandexImageSearch.ReverseImageSearchAsync("https://example.com/image.png");
+
+        await Assert.ThrowsAsync<YandexException>(() => task);
+    }
+
+    [Fact]
+    public async Task ReverseImageSearchAsync_Ignores_Invalid_Results()
+    {
+        var rawResults = new[]
+        {
+            string.Empty,
+            "{[",
+            @"
+{
+    ""serp-item"":
+    {
+        ""img_href"": null
+    }
+}",
+            @"
+{
+    ""serp-item"":
+    {
+        ""img_href"": ""https://example.com/image.png"",
+        ""snippet"":
+        {
+            ""url"": null,
+            ""text"": ""sample text""
+        }
+    }
+}",
+            @"
+{
+    ""serp-item"":
+    {
+        ""img_href"": ""https://example.com/image.png"",
+        ""snippet"":
+        {
+            ""url"": ""https://example.com"",
+            ""text"": null
+        }
+    }
+}"
+        };
+
+        var context = BrowsingContext.New();
+        var document = await context.OpenNewAsync();
+        var serpList = document.CreateElement<IHtmlDivElement>();
+        serpList.ClassName = "serp-list";
+
+        serpList.Append(rawResults.Select(x =>
+        {
+            var item = document.CreateElement<IHtmlDivElement>();
+            item.ClassName = "serp-item";
+
+            item.SetAttribute("data-bem", x);
+            return (INode)item;
+        }).ToArray());
+
+        string html = serpList.ToHtml();
+
+        string json = $@"
+{{
+    ""blocks"":
+    [
+        {{
+            ""html"": ""{{{JsonEncodedText.Encode(html)}}}""
+        }}
+    ]
+}}";
+
+        var messageHandlerMock = new Mock<HttpMessageHandler>();
+
+        messageHandlerMock
+            .Protected()
+            .As<HttpClient>()
+            .SetupSequence(x => x.SendAsync(It.IsAny<HttpRequestMessage>(), It.IsAny<CancellationToken>()))
+            .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) });
+
+        var yandexImageSearch = new YandexImageSearch(new HttpClient(messageHandlerMock.Object));
+
+        var results = (await yandexImageSearch.ReverseImageSearchAsync("https://example.com/image.png")).ToArray();
+
+        Assert.NotNull(results);
+        Assert.Empty(results);
+    }
+
     [Fact]
     public async Task Disposed_UrbanDictionary_Usage_Throws_ObjectDisposedException()
     {
@@ -72,5 +193,6 @@ public async Task Disposed_UrbanDictionary_Usage_Throws_ObjectDisposedException(
         _yandexImageSearch.Dispose();
 
         await Assert.ThrowsAsync<ObjectDisposedException>(() => _yandexImageSearch.OcrAsync(It.IsAny<string>()));
+        await Assert.ThrowsAsync<ObjectDisposedException>(() => _yandexImageSearch.ReverseImageSearchAsync(It.IsAny<string>()));
     }
 }
\ No newline at end of file

From 2251d4b63e80b4ac3eccc70a82ef4671f3f4bc74 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Fri, 1 Apr 2022 15:15:17 -0500
Subject: [PATCH 23/83] HTML-decode text in `YandexReverseImageSearchResult`

---
 src/Apis/Yandex/YandexImageSearch.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Apis/Yandex/YandexImageSearch.cs b/src/Apis/Yandex/YandexImageSearch.cs
index 44877ce..9a30491 100644
--- a/src/Apis/Yandex/YandexImageSearch.cs
+++ b/src/Apis/Yandex/YandexImageSearch.cs
@@ -200,7 +200,7 @@ private static IEnumerable<YandexReverseImageSearchResult> EnumerateResults(IEnu
                 continue;
             }
 
-            yield return new YandexReverseImageSearchResult(url, sourceUrl, WebUtility.HtmlDecode(title), text);
+            yield return new YandexReverseImageSearchResult(url, sourceUrl, WebUtility.HtmlDecode(title), WebUtility.HtmlDecode(text));
         }
     }
 

From 4b600743e50f3d4ea051f050eddf89977a1bdace Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 2 Apr 2022 18:40:01 -0500
Subject: [PATCH 24/83] Add Wikipedia API client, command and tests

---
 src/Apis/Wikipedia/IWikipediaArticle.cs       |  32 +++++
 src/Apis/Wikipedia/IWikipediaClient.cs        |  23 ++++
 src/Apis/Wikipedia/IWikipediaImage.cs         |  22 ++++
 src/Apis/Wikipedia/WikipediaArticle.cs        |  55 +++++++++
 src/Apis/Wikipedia/WikipediaClient.cs         | 110 ++++++++++++++++++
 src/Apis/Wikipedia/WikipediaImage.cs          |  40 +++++++
 .../Handlers/WikipediaAutocompleteHandler.cs  |  30 +++++
 src/Modules/UtilityModule.cs                  |  58 ++++++++-
 src/Program.cs                                |   5 +
 .../Fergun.Tests/AutocompleteHandlerTests.cs  |  33 ++++++
 tests/Fergun.Tests/WikipediaClientTests.cs    |  63 ++++++++++
 11 files changed, 470 insertions(+), 1 deletion(-)
 create mode 100644 src/Apis/Wikipedia/IWikipediaArticle.cs
 create mode 100644 src/Apis/Wikipedia/IWikipediaClient.cs
 create mode 100644 src/Apis/Wikipedia/IWikipediaImage.cs
 create mode 100644 src/Apis/Wikipedia/WikipediaArticle.cs
 create mode 100644 src/Apis/Wikipedia/WikipediaClient.cs
 create mode 100644 src/Apis/Wikipedia/WikipediaImage.cs
 create mode 100644 src/Modules/Handlers/WikipediaAutocompleteHandler.cs
 create mode 100644 tests/Fergun.Tests/WikipediaClientTests.cs

diff --git a/src/Apis/Wikipedia/IWikipediaArticle.cs b/src/Apis/Wikipedia/IWikipediaArticle.cs
new file mode 100644
index 0000000..6f3392b
--- /dev/null
+++ b/src/Apis/Wikipedia/IWikipediaArticle.cs
@@ -0,0 +1,32 @@
+namespace Fergun.Apis.Wikipedia;
+
+/// <summary>
+/// Represent a Wikipedia article.
+/// </summary>
+public interface IWikipediaArticle
+{
+    /// <summary>
+    /// Gets the title of this article.
+    /// </summary>
+    string Title { get; }
+
+    /// <summary>
+    /// Gets the description of this article.
+    /// </summary>
+    string? Description { get; }
+
+    /// <summary>
+    /// Gets the extract text of this article.
+    /// </summary>
+    string Extract { get; }
+
+    /// <summary>
+    /// Gets the image of this article.
+    /// </summary>
+    IWikipediaImage? Image { get; }
+
+    /// <summary>
+    /// Gets the ID of this article.
+    /// </summary>
+    int Id { get; }
+}
\ No newline at end of file
diff --git a/src/Apis/Wikipedia/IWikipediaClient.cs b/src/Apis/Wikipedia/IWikipediaClient.cs
new file mode 100644
index 0000000..154f134
--- /dev/null
+++ b/src/Apis/Wikipedia/IWikipediaClient.cs
@@ -0,0 +1,23 @@
+namespace Fergun.Apis.Wikipedia;
+
+/// <summary>
+/// Represents a Wikipedia API client.
+/// </summary>
+public interface IWikipediaClient
+{
+    /// <summary>
+    /// Gets a collection of articles that matches <paramref name="query"/>.
+    /// </summary>
+    /// <param name="query">The search string.</param>
+    /// <param name="language">The search language.</param>
+    /// <returns>A <see cref="Task{TResult}"/> that represents the asynchronous operation. The result contains an ordered <see cref="IEnumerable{T}"/> of articles.</returns>
+    Task<IEnumerable<IWikipediaArticle>> GetArticlesAsync(string query, string language);
+
+    /// <summary>
+    /// Gets autocomplete results.
+    /// </summary>
+    /// <param name="query">The search string.</param>
+    /// <param name="language">The search language.</param>
+    /// <returns>A <see cref="Task{TResult}"/> that represents the asynchronous operation. The result contains a read-only list of autocomplete results.</returns>
+    Task<IReadOnlyList<string>> GetAutocompleteResultsAsync(string query, string language);
+}
\ No newline at end of file
diff --git a/src/Apis/Wikipedia/IWikipediaImage.cs b/src/Apis/Wikipedia/IWikipediaImage.cs
new file mode 100644
index 0000000..18990b4
--- /dev/null
+++ b/src/Apis/Wikipedia/IWikipediaImage.cs
@@ -0,0 +1,22 @@
+namespace Fergun.Apis.Wikipedia;
+
+/// <summary>
+/// Represents a Wikipedia image.
+/// </summary>
+public interface IWikipediaImage
+{
+    /// <summary>
+    /// Gets a URL to the image.
+    /// </summary>
+    string Url { get; }
+
+    /// <summary>
+    /// Gets the width of this image.
+    /// </summary>
+    int Width { get; }
+
+    /// <summary>
+    /// Gets the height of this image.
+    /// </summary>
+    int Height { get; }
+}
\ No newline at end of file
diff --git a/src/Apis/Wikipedia/WikipediaArticle.cs b/src/Apis/Wikipedia/WikipediaArticle.cs
new file mode 100644
index 0000000..969e24c
--- /dev/null
+++ b/src/Apis/Wikipedia/WikipediaArticle.cs
@@ -0,0 +1,55 @@
+using System.Text.Json.Serialization;
+
+namespace Fergun.Apis.Wikipedia;
+
+/// <summary>
+/// Represent a Wikipedia article.
+/// </summary>
+public class WikipediaArticle : IWikipediaArticle
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="WikipediaArticle"/>.
+    /// </summary>
+    /// <param name="title">The title.</param>
+    /// <param name="description">The description.</param>
+    /// <param name="extract">The extract text.</param>
+    /// <param name="image">The image.</param>
+    /// <param name="id">The ID.</param>
+    public WikipediaArticle(string title, string? description, string extract, WikipediaImage? image, int id)
+    {
+        Title = title;
+        Description = description;
+        Extract = extract;
+        Image = image;
+        Id = id;
+    }
+
+    /// <inheritdoc/>
+    [JsonPropertyName("title")]
+    public string Title { get; }
+
+    /// <inheritdoc/>
+    [JsonPropertyName("description")]
+    public string? Description { get; }
+
+    /// <inheritdoc/>
+    [JsonPropertyName("extract")]
+    public string Extract { get; }
+
+    /// <inheritdoc cref="IWikipediaArticle.Image"/>
+    [JsonPropertyName("original")]
+    public WikipediaImage? Image { get; }
+
+    /// <inheritdoc/>
+    [JsonPropertyName("pageid")]
+    public int Id { get; }
+
+    /// <inheritdoc/>
+    IWikipediaImage? IWikipediaArticle.Image => Image;
+
+    /// <summary>
+    /// Returns the title and description of this article.
+    /// </summary>
+    /// <returns>The title and description of this article.</returns>
+    public override string ToString() => $"{Title} {(Description is null ? "" : $"({Description})")}";
+}
\ No newline at end of file
diff --git a/src/Apis/Wikipedia/WikipediaClient.cs b/src/Apis/Wikipedia/WikipediaClient.cs
new file mode 100644
index 0000000..c4fc7bf
--- /dev/null
+++ b/src/Apis/Wikipedia/WikipediaClient.cs
@@ -0,0 +1,110 @@
+using System.Text.Json;
+using Fergun.Extensions;
+
+namespace Fergun.Apis.Wikipedia;
+
+/// <summary>
+/// Represents a Wikipedia API client.
+/// </summary>
+public sealed class WikipediaClient : IWikipediaClient, IDisposable
+{
+    private readonly HttpClient _httpClient;
+    private bool _disposed;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="WikipediaClient"/> class.
+    /// </summary>
+    public WikipediaClient()
+        : this(new HttpClient())
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="WikipediaClient"/> class using the specified <see cref="HttpClient"/>.
+    /// </summary>
+    /// <param name="httpClient">An instance of <see cref="HttpClient"/>.</param>
+    public WikipediaClient(HttpClient httpClient)
+    {
+        _httpClient = httpClient;
+    }
+
+    /// <inheritdoc cref="IWikipediaClient.GetArticlesAsync(string, string)"/>
+    public async Task<IEnumerable<WikipediaArticle>> GetArticlesAsync(string query, string language)
+    {
+        EnsureNotDisposed();
+
+        string url = $"https://{language}.wikipedia.org/w/api.php?" +
+                     "action=query" +
+                     "&generator=prefixsearch" + // https://www.mediawiki.org/wiki/API:Prefixsearch
+                     "&format=json" +
+                     "&formatversion=2" +
+                     "&prop=extracts|pageimages|description" + // Get article extract, page images and short description
+                     "&exintro" + // Return only content before the first section
+                     "&explaintext" + // Return extracts as plain text
+                     "&redirects" + // Automatically resolve redirects
+                     $"&gpssearch={Uri.EscapeDataString(query)}" + // Search string
+                     "&pilicense=any" + // Get images with any license
+                     "&piprop=original"; // Get original images
+
+        var response = await _httpClient.GetAsync(new Uri(url), HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
+        response.EnsureSuccessStatusCode();
+
+        var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+        var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+
+        return document
+            .RootElement
+            .GetPropertyOrDefault("query")
+            .GetPropertyOrDefault("pages")
+            .EnumerateArrayOrEmpty()
+            .OrderBy(x => x.GetProperty("index").GetInt32())
+            .Select(x => x.Deserialize<WikipediaArticle>()!);
+    }
+
+    /// <summary>
+    /// Gets autocomplete results.
+    /// </summary>
+    /// <param name="query">The search string.</param>
+    /// <param name="language">The search language.</param>
+    /// <returns>A <see cref="Task{TResult}"/> that represents the asynchronous operation. The result contains a read-only list of autocomplete results.</returns>
+    public async Task<IReadOnlyList<string>> GetAutocompleteResultsAsync(string query, string language)
+    {
+        EnsureNotDisposed();
+
+        var response = await _httpClient.GetAsync(new Uri($"https://{language}.wikipedia.org/w/api.php?action=opensearch&search={Uri.EscapeDataString(query)}&redirects=resolve"), HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
+        response.EnsureSuccessStatusCode();
+
+        var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+        var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+
+        return document
+            .RootElement
+            .EnumerateArray()
+            .ElementAt(1)
+            .Deserialize<IReadOnlyList<string>>()!;
+    }
+
+    /// <inheritdoc/>
+    public void Dispose()
+    {
+        if (_disposed)
+        {
+            return;
+        }
+
+        _httpClient.Dispose();
+        _disposed = true;
+    }
+
+    private void EnsureNotDisposed()
+    {
+        if (_disposed)
+        {
+            throw new ObjectDisposedException(nameof(WikipediaClient));
+        }
+    }
+
+    /// <inheritdoc/>
+    async Task<IEnumerable<IWikipediaArticle>> IWikipediaClient.GetArticlesAsync(string query, string language)
+        => await GetArticlesAsync(query, language).ConfigureAwait(false);
+}
\ No newline at end of file
diff --git a/src/Apis/Wikipedia/WikipediaImage.cs b/src/Apis/Wikipedia/WikipediaImage.cs
new file mode 100644
index 0000000..db37f15
--- /dev/null
+++ b/src/Apis/Wikipedia/WikipediaImage.cs
@@ -0,0 +1,40 @@
+using System.Text.Json.Serialization;
+
+namespace Fergun.Apis.Wikipedia;
+
+/// <summary>
+/// Represents a Wikipedia image.
+/// </summary>
+public class WikipediaImage : IWikipediaImage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="WikipediaImage"/> class.
+    /// </summary>
+    /// <param name="url">The URL of the image.</param>
+    /// <param name="width">The width.</param>
+    /// <param name="height">The height.</param>
+    public WikipediaImage(string url, int width, int height)
+    {
+        Url = url;
+        Width = width;
+        Height = height;
+    }
+
+    /// <inheritdoc/>
+    [JsonPropertyName("source")]
+    public string Url { get; }
+
+    /// <inheritdoc/>
+    [JsonPropertyName("width")]
+    public int Width { get; }
+
+    /// <inheritdoc/>
+    [JsonPropertyName("height")]
+    public int Height { get; }
+
+    /// <summary>
+    /// Returns <see cref="Url"/>.
+    /// </summary>
+    /// <returns><see cref="Url"/></returns>
+    public override string ToString() => Url;
+}
\ No newline at end of file
diff --git a/src/Modules/Handlers/WikipediaAutocompleteHandler.cs b/src/Modules/Handlers/WikipediaAutocompleteHandler.cs
new file mode 100644
index 0000000..11b22e8
--- /dev/null
+++ b/src/Modules/Handlers/WikipediaAutocompleteHandler.cs
@@ -0,0 +1,30 @@
+using Discord;
+using Discord.Interactions;
+using Fergun.Apis.Wikipedia;
+using Fergun.Extensions;
+using Humanizer;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Fergun.Modules.Handlers;
+
+public class WikipediaAutocompleteHandler : AutocompleteHandler
+{
+    /// <inheritdoc />
+    public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context,
+        IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
+    {
+        var text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim().Truncate(100, string.Empty);
+
+        if (string.IsNullOrEmpty(text))
+            return AutocompletionResult.FromSuccess();
+
+        var urbanDictionary = services
+            .GetRequiredService<IWikipediaClient>();
+
+        var results = (await urbanDictionary.GetAutocompleteResultsAsync(text, autocompleteInteraction.GetLanguageCode()))
+            .Select(x => new AutocompleteResult(x, x))
+            .Take(25);
+
+        return AutocompletionResult.FromSuccess(results);
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index aca92d9..ed4e74d 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -3,6 +3,7 @@
 using System.Runtime.InteropServices;
 using Discord;
 using Discord.Interactions;
+using Fergun.Apis.Wikipedia;
 using Fergun.Extensions;
 using Fergun.Interactive;
 using Fergun.Interactive.Pagination;
@@ -28,6 +29,7 @@ public class UtilityModule : InteractionModuleBase<ShardedInteractionContext>
     private readonly MicrosoftTranslator _microsoftTranslator;
     private readonly YandexTranslator _yandexTranslator;
     private readonly SearchClient _searchClient;
+    private readonly IWikipediaClient _wikipediaClient;
 
     private static readonly Lazy<Language[]> _lazyFilteredLanguages = new(() => Language.LanguageDictionary
         .Values
@@ -35,7 +37,8 @@ public class UtilityModule : InteractionModuleBase<ShardedInteractionContext>
         .ToArray());
 
     public UtilityModule(ILogger<UtilityModule> logger, SharedModule shared, InteractiveService interactive, GoogleTranslator googleTranslator,
-        GoogleTranslator2 googleTranslator2, MicrosoftTranslator microsoftTranslator, YandexTranslator yandexTranslator, SearchClient searchClient)
+        GoogleTranslator2 googleTranslator2, MicrosoftTranslator microsoftTranslator, YandexTranslator yandexTranslator, SearchClient searchClient,
+        IWikipediaClient wikipediaClient)
     {
         _logger = logger;
         _shared = shared;
@@ -45,6 +48,7 @@ public UtilityModule(ILogger<UtilityModule> logger, SharedModule shared, Interac
         _microsoftTranslator = microsoftTranslator;
         _yandexTranslator = yandexTranslator;
         _searchClient = searchClient;
+        _wikipediaClient = wikipediaClient;
     }
 
     [MessageCommand("Bad Translator")]
@@ -381,6 +385,58 @@ public async Task TTS([Summary(description: "The text to convert.")] string text
         [Summary(description: "Whether to respond ephemerally.")] bool ephemeral = false)
         => await _shared.TtsAsync(Context.Interaction, text, target, ephemeral);
 
+    [SlashCommand("wikipedia", "Searches for Wikipedia articles.")]
+    public async Task Wikipedia([Autocomplete(typeof(WikipediaAutocompleteHandler))] [Summary(description: "The search query.")] string query)
+    {
+        await DeferAsync();
+
+        var articles = (await _wikipediaClient.GetArticlesAsync(query, Context.Interaction.GetLanguageCode())).ToArray();
+
+        if (articles.Length == 0)
+        {
+            await Context.Interaction.FollowupWarning("No results.");
+        }
+
+        var paginator = new LazyPaginatorBuilder()
+            .AddUser(Context.User)
+            .WithPageFactory(GeneratePage)
+            .WithActionOnCancellation(ActionOnStop.DisableInput)
+            .WithActionOnTimeout(ActionOnStop.DisableInput)
+            .WithMaxPageIndex(articles.Length - 1)
+            .WithFooter(PaginatorFooter.None)
+            .WithFergunEmotes()
+            .Build();
+
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+
+        PageBuilder GeneratePage(int index)
+        {
+            var article = articles[index];
+
+            var page = new PageBuilder()
+                .WithTitle(article.Title.Truncate(EmbedBuilder.MaxTitleLength))
+                .WithUrl($"https://{Context.Interaction.GetLanguageCode()}.wikipedia.org/?curid={article.Id}")
+                .WithThumbnailUrl($"https://commons.wikimedia.org/w/index.php?title=Special:Redirect/file/Wikipedia-logo-v2-{Context.Interaction.GetLanguageCode()}.png")
+                .WithDescription(article.Extract.Truncate(EmbedBuilder.MaxDescriptionLength))
+                .WithFooter($"Wikipedia Search | Page {index + 1} of {articles.Length}")
+                .WithColor(Color.Orange);
+
+            if (Context.Channel.IsNsfw() && article.Image is not null)
+            {
+                if (article.Image.Width >= 500 && article.Image.Height >= 500)
+                {
+                    page.WithImageUrl(article.Image.Url);
+                }
+                else
+                {
+                    page.WithThumbnailUrl(article.Image.Url);
+                }
+            }
+
+            return page;
+        }
+    }
+
     [SlashCommand("youtube", "Sends a paginator containing YouTube videos.")]
     public async Task YouTube([Autocomplete(typeof(YouTubeAutocompleteHandler))] [Summary(description: "The query.")] string query)
     {
diff --git a/src/Program.cs b/src/Program.cs
index 8638da9..01051ab 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -5,6 +5,7 @@
 using Fergun;
 using Fergun.Apis.Bing;
 using Fergun.Apis.Urban;
+using Fergun.Apis.Wikipedia;
 using Fergun.Apis.Yandex;
 using Fergun.Extensions;
 using Fergun.Interactive;
@@ -72,6 +73,10 @@ await Host.CreateDefaultBuilder()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
 
+        services.AddHttpClient<IWikipediaClient, WikipediaClient>()
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
         services.AddHttpClient<GoogleTranslator>()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
diff --git a/tests/Fergun.Tests/AutocompleteHandlerTests.cs b/tests/Fergun.Tests/AutocompleteHandlerTests.cs
index 9af97b4..81d6907 100644
--- a/tests/Fergun.Tests/AutocompleteHandlerTests.cs
+++ b/tests/Fergun.Tests/AutocompleteHandlerTests.cs
@@ -6,6 +6,7 @@
 using Discord;
 using Discord.Interactions;
 using Fergun.Apis.Urban;
+using Fergun.Apis.Wikipedia;
 using Fergun.Extensions;
 using Fergun.Modules.Handlers;
 using Microsoft.Extensions.DependencyInjection;
@@ -148,6 +149,34 @@ public async Task UrbanAutocomplete_Should_Return_Valid_Suggestions(string text)
         }
     }
 
+    [Theory]
+    [InlineData("", "pt")]
+    [InlineData("a", "en")]
+    [InlineData("b", "es")]
+    [InlineData("c", "fr")]
+    public async Task WikipediaAutocomplete_Should_Return_Valid_Suggestions(string text, string locale)
+    {
+        var handler = new WikipediaAutocompleteHandler();
+        var option = Utils.CreateInstance<AutocompleteOption>(ApplicationCommandOptionType.String, text, text, true);
+
+        _interactionMock.SetupGet(x => x.Data).Returns(_dataMock.Object);
+        _interactionMock.SetupGet(x => x.UserLocale).Returns(locale);
+        _dataMock.SetupGet(x => x.Current).Returns(option);
+
+        var results = await handler.GenerateSuggestionsAsync(_contextMock.Object, _interactionMock.Object, _parameter, _services);
+
+        Assert.True(results.IsSuccess);
+
+        if (!string.IsNullOrEmpty(text))
+        {
+            Assert.NotNull(results.Suggestions);
+            Assert.NotEmpty(results.Suggestions);
+            Assert.All(results.Suggestions, Assert.NotNull);
+            Assert.All(results.Suggestions, x => Assert.NotNull(x.Name));
+            Assert.All(results.Suggestions, x => Assert.NotNull(x.Value));
+        }
+    }
+
     private static IServiceProvider GetServiceProvider()
     {
         var services = new ServiceCollection()
@@ -160,6 +189,10 @@ private static IServiceProvider GetServiceProvider()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
 
+        services.AddHttpClient<IWikipediaClient, WikipediaClient>()
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
         return services.BuildServiceProvider();
     }
 
diff --git a/tests/Fergun.Tests/WikipediaClientTests.cs b/tests/Fergun.Tests/WikipediaClientTests.cs
new file mode 100644
index 0000000..951a388
--- /dev/null
+++ b/tests/Fergun.Tests/WikipediaClientTests.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Fergun.Apis.Wikipedia;
+using Moq;
+using Xunit;
+
+namespace Fergun.Tests;
+
+public class WikipediaClientTests
+{
+    private readonly IWikipediaClient _wikipediaClient = new WikipediaClient();
+
+    [Theory]
+    [InlineData("Guitar", "en")]
+    [InlineData("Wikipedia", "es")]
+    public async Task GetArticlesAsync_Returns_Articles(string term, string language)
+    {
+        var articles = (await _wikipediaClient.GetArticlesAsync(term, language)).ToArray();
+
+        Assert.NotNull(articles);
+        Assert.NotEmpty(articles);
+        Assert.All(articles, Assert.NotNull);
+        Assert.All(articles, x => Assert.NotNull(x.Title));
+        Assert.All(articles, x => Assert.NotNull(x.Extract));
+        Assert.All(articles, x => Assert.True(x.Id >= 0));
+        Assert.All(articles, x => Assert.NotNull(x.ToString()));
+
+        Assert.All(articles, x =>
+        {
+            if (x.Image is not null)
+            {
+                Assert.NotNull(x.Image.Url.ToString());
+                Assert.True(x.Image.Width > 0);
+                Assert.True(x.Image.Height > 0);
+                Assert.NotNull(x.Image.ToString());
+            }
+        });
+    }
+
+    [Theory]
+    [InlineData("a", "en")]
+    [InlineData("b", "es")]
+    [InlineData("c", "fr")]
+    public async Task GetAutocompleteResultsAsync_Returns_Results(string term, string language)
+    {
+        var results = (await _wikipediaClient.GetAutocompleteResultsAsync(term, language)).ToArray();
+
+        Assert.NotNull(results);
+        Assert.NotEmpty(results);
+        Assert.All(results, Assert.NotNull);
+    }
+
+    [Fact]
+    public async Task Disposed_UrbanDictionary_Usage_Should_Throw_ObjectDisposedException()
+    {
+        (_wikipediaClient as IDisposable)?.Dispose();
+        (_wikipediaClient as IDisposable)?.Dispose();
+
+        await Assert.ThrowsAsync<ObjectDisposedException>(() => _wikipediaClient.GetArticlesAsync(It.IsAny<string>(), It.IsAny<string>()));
+        await Assert.ThrowsAsync<ObjectDisposedException>(() => _wikipediaClient.GetAutocompleteResultsAsync(It.IsAny<string>(), It.IsAny<string>()));
+    }
+}
\ No newline at end of file

From 31e3089fb934a0c6a3303d2e5a882726ae71b845 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Wed, 6 Apr 2022 23:42:36 -0500
Subject: [PATCH 25/83] Update dependencies

---
 src/Fergun.csproj | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/Fergun.csproj b/src/Fergun.csproj
index 8184a6a..d6ea0a2 100644
--- a/src/Fergun.csproj
+++ b/src/Fergun.csproj
@@ -10,10 +10,10 @@
 
   <ItemGroup>
     <PackageReference Include="Discord.Addons.Hosting" Version="5.1.0" />
-    <PackageReference Include="Discord.Net.Interactions" Version="3.4.0" />
-    <PackageReference Include="Fergun.Interactive" Version="1.5.0" />
+    <PackageReference Include="Discord.Net.Interactions" Version="3.5.0" />
+    <PackageReference Include="Fergun.Interactive" Version="1.5.3" />
     <PackageReference Include="GScraper" Version="1.0.2" />
-    <PackageReference Include="GTranslate" Version="2.0.1" />
+    <PackageReference Include="GTranslate" Version="2.0.2" />
     <PackageReference Include="Humanizer.Core" Version="2.14.1" />
     <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.3" />

From ad315fa91145a53b22b63ba2b00d7ad610146bd9 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Wed, 6 Apr 2022 23:58:09 -0500
Subject: [PATCH 26/83] [wikipedia] Add missing return statement

---
 src/Modules/UtilityModule.cs | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index ed4e74d..8242659 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -395,6 +395,7 @@ public async Task Wikipedia([Autocomplete(typeof(WikipediaAutocompleteHandler))]
         if (articles.Length == 0)
         {
             await Context.Interaction.FollowupWarning("No results.");
+            return;
         }
 
         var paginator = new LazyPaginatorBuilder()

From fd24ed61078c1a536e69efd34e4bd92570f5f471 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Thu, 7 Apr 2022 21:37:09 -0500
Subject: [PATCH 27/83] Ignore unknown dispatch warnings using the config

---
 src/Program.cs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/Program.cs b/src/Program.cs
index 01051ab..c2b463f 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -32,7 +32,8 @@ await Host.CreateDefaultBuilder()
             LogLevel = LogSeverity.Verbose,
             GatewayIntents = GatewayIntents.Guilds,
             UseInteractionSnowflakeDate = false,
-            LogGatewayIntentWarnings = false
+            LogGatewayIntentWarnings = false,
+            SuppressUnknownDispatchWarnings = true
         };
 
         config.Token = context.Configuration["Token"];
@@ -48,7 +49,6 @@ await Host.CreateDefaultBuilder()
     {
         config.MinimumLevel.Debug()
             .Filter.ByExcluding(e => e.Level == LogEventLevel.Debug && Matching.FromSource("Discord.WebSocket.DiscordShardedClient").Invoke(e) && e.MessageTemplate.Render(e.Properties).ContainsAny("Connected to", "Disconnected from"))
-            .Filter.ByExcluding(e => e.Level == LogEventLevel.Warning && Matching.FromSource("Discord.WebSocket.DiscordShardedClient").Invoke(e) && e.MessageTemplate.Render(e.Properties).Contains("Unknown Dispatch"))
             .Filter.ByExcluding(e => e.Level <= LogEventLevel.Debug && Matching.FromSource("Microsoft.Extensions.Http").Invoke(e))
             .WriteTo.Console(LogEventLevel.Debug, theme: AnsiConsoleTheme.Literate)
             .WriteTo.Async(logger => logger.File("logs/log-.txt", LogEventLevel.Debug, rollingInterval: RollingInterval.Day));

From 71f3618179a12db04e117df4d0f7fc2be63fc949 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 9 Apr 2022 01:04:23 -0500
Subject: [PATCH 28/83] Refactoring

---
 src/Apis/Urban/UrbanDefinition.cs          | 22 +++++++++---------
 src/Extensions/JsonExtensions.cs           |  3 ---
 src/Fergun.csproj                          |  2 +-
 src/Modules/ImageModule.cs                 | 26 +++++-----------------
 src/Modules/OcrModule.cs                   |  5 ++---
 src/Modules/SharedModule.cs                |  5 ++---
 src/Modules/UrbanModule.cs                 |  9 ++------
 src/Modules/UserModule.cs                  |  2 +-
 src/Modules/UtilityModule.cs               | 15 ++++++-------
 src/Services/InteractionHandlingService.cs | 17 +++++++++-----
 tests/Fergun.Tests/OcrModuleTests.cs       |  4 ++--
 tests/Fergun.Tests/UrbanModuleTests.cs     | 26 ++++++++++++++++------
 12 files changed, 63 insertions(+), 73 deletions(-)

diff --git a/src/Apis/Urban/UrbanDefinition.cs b/src/Apis/Urban/UrbanDefinition.cs
index 54b7829..e2fed9b 100644
--- a/src/Apis/Urban/UrbanDefinition.cs
+++ b/src/Apis/Urban/UrbanDefinition.cs
@@ -31,67 +31,67 @@ public UrbanDefinition(string definition, string? date, string permalink, int th
     /// Gets the definition.
     /// </summary>
     [JsonPropertyName("definition")]
-    public string Definition { get; }
+    public string Definition { get; init; }
 
     /// <summary>
     /// Gets the date this definition was posted on the front page as a word of the day.
     /// </summary>
     [JsonPropertyName("date")]
-    public string? Date { get; }
+    public string? Date { get; init; }
 
     /// <summary>
     /// Gets a permalink to the page containing this definition.
     /// </summary>
     [JsonPropertyName("permalink")]
-    public string Permalink { get; }
+    public string Permalink { get; init; }
 
     /// <summary>
     /// Gets the number of thumps-up.
     /// </summary>
     [JsonPropertyName("thumbs_up")]
-    public int ThumbsUp { get; }
+    public int ThumbsUp { get; init; }
 
     /// <summary>
     /// Gets a collection of sound URLs.
     /// </summary>
     [JsonPropertyName("sound_urls")]
-    public IReadOnlyCollection<string> SoundUrls { get; }
+    public IReadOnlyCollection<string> SoundUrls { get; init; }
 
     /// <summary>
     /// Gets the author of this definition.
     /// </summary>
     [JsonPropertyName("author")]
-    public string Author { get; }
+    public string Author { get; init; }
 
     /// <summary>
     /// Gets the word (term) being defined.
     /// </summary>
     [JsonPropertyName("word")]
-    public string Word { get; }
+    public string Word { get; init; }
 
     /// <summary>
     /// Gets the ID of this definition.
     /// </summary>
     [JsonPropertyName("defid")]
-    public int Id { get; }
+    public int Id { get; init; }
 
     /// <summary>
     /// Gets the date this definition was written.
     /// </summary>
     [JsonPropertyName("written_on")]
-    public DateTimeOffset WrittenOn { get; }
+    public DateTimeOffset WrittenOn { get; init; }
 
     /// <summary>
     /// Gets an example usage of the definition.
     /// </summary>
     [JsonPropertyName("example")]
-    public string Example { get; }
+    public string Example { get; init; }
 
     /// <summary>
     /// Gets the number of thumps-down.
     /// </summary>
     [JsonPropertyName("thumbs_down")]
-    public int ThumbsDown { get; }
+    public int ThumbsDown { get; init; }
 
     /// <inheritdoc/>
     public override string ToString() => $"{nameof(Word)} = {Word}, {nameof(Definition)} = {Definition}";
diff --git a/src/Extensions/JsonExtensions.cs b/src/Extensions/JsonExtensions.cs
index 561af3b..28c3c84 100644
--- a/src/Extensions/JsonExtensions.cs
+++ b/src/Extensions/JsonExtensions.cs
@@ -18,7 +18,4 @@ public static JsonElement GetPropertyOrDefault(this JsonElement element, string
 
     public static string? GetStringOrDefault(this JsonElement element)
         => element.ValueKind == JsonValueKind.String ? element.GetString() : default;
-
-    public static int GetInt32OrDefault(this JsonElement element)
-        => element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out int value) ? value : default;
 }
\ No newline at end of file
diff --git a/src/Fergun.csproj b/src/Fergun.csproj
index d6ea0a2..77d9ac1 100644
--- a/src/Fergun.csproj
+++ b/src/Fergun.csproj
@@ -11,7 +11,7 @@
   <ItemGroup>
     <PackageReference Include="Discord.Addons.Hosting" Version="5.1.0" />
     <PackageReference Include="Discord.Net.Interactions" Version="3.5.0" />
-    <PackageReference Include="Fergun.Interactive" Version="1.5.3" />
+    <PackageReference Include="Fergun.Interactive" Version="1.5.4" />
     <PackageReference Include="GScraper" Version="1.0.2" />
     <PackageReference Include="GTranslate" Version="2.0.2" />
     <PackageReference Include="Humanizer.Core" Version="2.14.1" />
diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
index 832a434..777a694 100644
--- a/src/Modules/ImageModule.cs
+++ b/src/Modules/ImageModule.cs
@@ -1,6 +1,5 @@
 using Discord;
 using Discord.Interactions;
-using Discord.WebSocket;
 using Fergun.Apis.Bing;
 using Fergun.Apis.Yandex;
 using Fergun.Extensions;
@@ -74,10 +73,7 @@ public async Task Google([Autocomplete(typeof(GoogleAutocompleteHandler))][Summa
             .AddUser(Context.User)
             .Build();
 
-        if (Context.Interaction is SocketInteraction socketInteraction)
-        {
-            await _interactive.SendPaginatorAsync(paginator, socketInteraction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
-        }
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
         MultiEmbedPageBuilder GeneratePage(int index)
         {
@@ -125,10 +121,7 @@ public async Task DuckDuckGo([Autocomplete(typeof(DuckDuckGoAutocompleteHandler)
             .AddUser(Context.User)
             .Build();
 
-        if (Context.Interaction is SocketInteraction socketInteraction)
-        {
-            await _interactive.SendPaginatorAsync(paginator, socketInteraction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
-        }
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
         Task<PageBuilder> GeneratePageAsync(int index)
         {
@@ -176,10 +169,7 @@ public async Task Brave([Autocomplete(typeof(BraveAutocompleteHandler))][Summary
             .AddUser(Context.User)
             .Build();
 
-        if (Context.Interaction is SocketInteraction socketInteraction)
-        {
-            await _interactive.SendPaginatorAsync(paginator, socketInteraction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
-        }
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
         Task<PageBuilder> GeneratePageAsync(int index)
         {
@@ -233,10 +223,7 @@ public async Task YandexAsync(string url, bool multiImages)
             .AddUser(Context.User)
             .Build();
 
-        if (Context.Interaction is SocketInteraction socketInteraction)
-        {
-            await _interactive.SendPaginatorAsync(paginator, socketInteraction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
-        }
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
         MultiEmbedPageBuilder GeneratePage(int index)
         {
@@ -278,10 +265,7 @@ public async Task BingAsync(string url, bool multiImages)
             .AddUser(Context.User)
             .Build();
 
-        if (Context.Interaction is SocketInteraction socketInteraction)
-        {
-            await _interactive.SendPaginatorAsync(paginator, socketInteraction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
-        }
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
         MultiEmbedPageBuilder GeneratePage(int index)
         {
diff --git a/src/Modules/OcrModule.cs b/src/Modules/OcrModule.cs
index 2b20a9d..f6132b3 100644
--- a/src/Modules/OcrModule.cs
+++ b/src/Modules/OcrModule.cs
@@ -1,7 +1,6 @@
 using System.Diagnostics;
 using Discord;
 using Discord.Interactions;
-using Discord.WebSocket;
 using Fergun.Apis.Bing;
 using Fergun.Apis.Yandex;
 using Fergun.Extensions;
@@ -54,7 +53,7 @@ public async Task Ocr(IMessage message)
             .WithSelectionPage(page)
             .Build();
 
-        var result = await _interactive.SendSelectionAsync(selection, (SocketInteraction)Context.Interaction, TimeSpan.FromMinutes(1), ephemeral: true);
+        var result = await _interactive.SendSelectionAsync(selection, Context.Interaction, TimeSpan.FromMinutes(1), ephemeral: true);
 
         // Attempt to disable the components
         _ = Context.Interaction.ModifyOriginalResponseAsync(x => x.Components = selection.GetOrAddComponents(true).Build());
@@ -88,7 +87,7 @@ public async Task OcrAsync(OcrEngine ocrEngine, string url, IDiscordInteraction
             _ => throw new ArgumentException("Invalid OCR engine.", nameof(ocrEngine))
         };
 
-        if (interaction is SocketMessageComponent componentInteraction)
+        if (interaction is IComponentInteraction componentInteraction)
         {
             await componentInteraction.DeferLoadingAsync(ephemeral);
         }
diff --git a/src/Modules/SharedModule.cs b/src/Modules/SharedModule.cs
index 5f8e95d..911d0da 100644
--- a/src/Modules/SharedModule.cs
+++ b/src/Modules/SharedModule.cs
@@ -1,5 +1,4 @@
 using Discord;
-using Discord.WebSocket;
 using Fergun.Extensions;
 using GTranslate;
 using GTranslate.Results;
@@ -45,7 +44,7 @@ public async Task TranslateAsync(IDiscordInteraction interaction, string text, s
             return;
         }
 
-        if (deferLoad && interaction is SocketMessageComponent componentInteraction)
+        if (deferLoad && interaction is IComponentInteraction componentInteraction)
         {
             await componentInteraction.DeferLoadingAsync(ephemeral);
         }
@@ -108,7 +107,7 @@ public async Task TtsAsync(IDiscordInteraction interaction, string text, string?
             return;
         }
 
-        if (deferLoad && interaction is SocketMessageComponent componentInteraction)
+        if (deferLoad && interaction is IComponentInteraction componentInteraction)
         {
             await componentInteraction.DeferLoadingAsync(ephemeral);
         }
diff --git a/src/Modules/UrbanModule.cs b/src/Modules/UrbanModule.cs
index 7df77f0..4440716 100644
--- a/src/Modules/UrbanModule.cs
+++ b/src/Modules/UrbanModule.cs
@@ -1,8 +1,6 @@
-using System.Runtime.CompilerServices;
-using System.Text;
+using System.Text;
 using Discord;
 using Discord.Interactions;
-using Discord.WebSocket;
 using Fergun.Apis.Urban;
 using Fergun.Extensions;
 using Fergun.Interactive;
@@ -61,10 +59,7 @@ public async Task SearchAndSendAsync(UrbanSearchType searchType, string? term =
             .AddUser(Context.User)
             .Build();
 
-        if (Context.Interaction is SocketInteraction socketInteraction)
-        {
-           await _interactive.SendPaginatorAsync(paginator, socketInteraction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
-        }
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
         PageBuilder GeneratePage(int i)
         {
diff --git a/src/Modules/UserModule.cs b/src/Modules/UserModule.cs
index c33ab82..ea436d5 100644
--- a/src/Modules/UserModule.cs
+++ b/src/Modules/UserModule.cs
@@ -4,7 +4,7 @@
 
 namespace Fergun.Modules;
 
-public class UserModule : InteractionModuleBase<IInteractionContext>
+public class UserModule : InteractionModuleBase
 {
     [UserCommand("Avatar")]
     public async Task Avatar(IUser user)
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 8242659..75c8ab4 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -181,11 +181,10 @@ public async Task Help()
     {
         var embed = new EmbedBuilder()
             .WithTitle("Fergun 2")
-            .WithDescription("Hey, it seems that you found some slash commands in Fergun.\n\n" +
-                             "This is Fergun 2, a complete rewrite of Fergun 1.x, using only slash commands.\n" +
-                             "Fergun 2 is still in very alpha stages and only some commands are present, but more commands will be added soon.\n" +
-                             "Fergun 2 will be finished in early 2022 and it will include new features and commands.\n\n" +
-                             "Some modules and commands are currently in maintenance mode in Fergun 1.x and they won't be migrated to Fergun 2. These modules are:\n" +
+            .WithDescription("This is Fergun 2. Fergun 2 is a complete rewrite of Fergun 1 and it will only have slash commands.\n" +
+                             "Fergun 2 is in alpha stages and only the most used commands are present, but more commands will be added soon.\n" +
+                             "Fergun 2 will be finished in May 2022 and it will include new features and commands.\n\n" +
+                             "Some modules and commands are currently in maintenance mode in Fergun 1 and they won't be migrated to Fergun 2. These modules are:\n" +
                              "- **AI Dungeon** module\n" +
                              "- **Music** module\n" +
                              "- **Snipe** commands\n\n" +
@@ -348,7 +347,7 @@ public async Task Stats()
                 $"/ {totalRam?.ToString() ?? "?"}MB", true)
             .AddField("Library", $"Discord.Net v{DiscordConfig.Version}", true)
             .AddField("\u200b", "\u200b", true)
-            .AddField("BotVersion", version, true)
+            .AddField("Bot Version", version, true)
             .AddField("Total Servers", $"{Context.Client.Guilds.Count} (Shard: {Context.Client.GetShard(shardId).Guilds.Count})", true)
             .AddField("\u200b", "\u200b", true)
             .AddField("Total Users", $"{totalUsers} (Shard: {totalUsersInShard})", true)
@@ -357,7 +356,7 @@ public async Task Stats()
             .AddField("Shards", Context.Client.Shards.Count, true)
             .AddField("Uptime", elapsed.Humanize(), true)
             .AddField("\u200b", "\u200b", true)
-            .AddField("BotOwner", owner, true);
+            .AddField("Bot Owner", owner, true);
 
         builder.WithColor(Color.Orange);
 
@@ -458,7 +457,7 @@ public async Task YouTube([Autocomplete(typeof(YouTubeAutocompleteHandler))] [Su
             default:
                 var paginator = new StaticPaginatorBuilder()
                     .AddUser(Context.User)
-                    .WithPages(videos.Select((x, i) => new PageBuilder { Text = $"{x.Url}\nPage {i + 1} of {videos.Count}" }).ToArray())
+                    .WithPages(videos.Select((x, i) => new PageBuilder { Text = $"{x.Url}\nPage {i + 1} of {videos.Count}" } as IPageBuilder).ToArray())
                     .WithActionOnCancellation(ActionOnStop.DisableInput)
                     .WithActionOnTimeout(ActionOnStop.DisableInput)
                     .WithFooter(PaginatorFooter.None)
diff --git a/src/Services/InteractionHandlingService.cs b/src/Services/InteractionHandlingService.cs
index 3a19a10..a153cc1 100644
--- a/src/Services/InteractionHandlingService.cs
+++ b/src/Services/InteractionHandlingService.cs
@@ -85,6 +85,9 @@ private async Task SlashCommandExecuted(SlashCommandInfo slashCommand, IInteract
         _logger.LogInformation("Executed slash command \"{name}\" for {username}#{discriminator} ({id}) in {context}",
             slashCommand.Name, context.User.Username, context.User.Discriminator, context.User.Id, context.Display());
 
+        if (result.IsSuccess)
+            return;
+
         await HandleInteractionErrorAsync(context, result);
     }
 
@@ -93,31 +96,33 @@ private async Task ContextMenuCommandExecuted(ContextCommandInfo contextCommand,
         _logger.LogInformation("Executed context menu command \"{name}\" for {username}#{discriminator} ({id}) in {context}",
             contextCommand.Name, context.User.Username, context.User.Discriminator, context.User.Id, context.Display());
 
+        if (result.IsSuccess)
+            return;
+
         await HandleInteractionErrorAsync(context, result);
     }
 
-    private static async ValueTask HandleInteractionErrorAsync(IInteractionContext context, IResult result)
+    private static async Task HandleInteractionErrorAsync(IInteractionContext context, IResult result)
     {
-        if (result.IsSuccess)
-            return;
-
         string message = result.Error == InteractionCommandError.Exception
             ? $"An error occurred.\n\nError message: ```{((ExecuteResult)result).Exception.Message}```"
             : result.ErrorReason;
 
         if (context.Interaction.HasResponded)
         {
-            await context.Interaction.FollowupWarning(message, ephemeral: true);
+            await context.Interaction.FollowupWarning(message, true);
         }
         else
         {
-            await context.Interaction.RespondWarningAsync(message, ephemeral: true);
+            await context.Interaction.RespondWarningAsync(message, true);
         }
     }
 
     private Task LogInteraction(LogMessage log)
     {
+#pragma warning disable CA2254 // Template should be a static expression
         _logger.Log(log.Severity.ToLogLevel(), new EventId(0, log.Source), log.Exception, log.Message);
+#pragma warning restore CA2254 // Template should be a static expression
         return Task.CompletedTask;
     }
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/OcrModuleTests.cs b/tests/Fergun.Tests/OcrModuleTests.cs
index cc279dc..812446a 100644
--- a/tests/Fergun.Tests/OcrModuleTests.cs
+++ b/tests/Fergun.Tests/OcrModuleTests.cs
@@ -9,7 +9,6 @@
 using Fergun.Modules;
 using Microsoft.Extensions.Logging;
 using Moq;
-using Moq.Protected;
 using Xunit;
 
 namespace Fergun.Tests;
@@ -23,6 +22,7 @@ public class OcrModuleTests
     private readonly Mock<ILogger<OcrModule>> _loggerMock = new();
     private readonly DiscordSocketClient _client = new();
     private readonly InteractiveService _interactive;
+    private readonly InteractiveConfig _interactiveConfig = new() { DeferStopSelectionInteractions = false };
     private readonly Mock<OcrModule> _ocrModuleMock;
     private const string _textImageUrl = "https://example.com/image.png";
     private const string _emptyImageUrl = "https://example.com/empty.png";
@@ -40,7 +40,7 @@ public OcrModuleTests()
         var sharedLogger = Mock.Of<ILogger<SharedModule>>();
         var shared = new SharedModule(sharedLogger, new(), new());
 
-        _interactive = new InteractiveService(_client);
+        _interactive = new InteractiveService(_client, _interactiveConfig);
         _ocrModuleMock = new Mock<OcrModule>(() => new OcrModule(_loggerMock.Object, shared, _interactive, _bingVisualSearchMock.Object, _yandexImageSearchMock.Object));
         _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
         ((IInteractionModuleBase)_ocrModuleMock.Object).SetContext(_contextMock.Object);
diff --git a/tests/Fergun.Tests/UrbanModuleTests.cs b/tests/Fergun.Tests/UrbanModuleTests.cs
index 0f12447..2620570 100644
--- a/tests/Fergun.Tests/UrbanModuleTests.cs
+++ b/tests/Fergun.Tests/UrbanModuleTests.cs
@@ -24,12 +24,12 @@ public class UrbanModuleTests
     private readonly Mock<IUrbanDictionary> _urbanDictionaryMock = CreateMockedUrbanDictionary();
     private readonly Mock<UrbanModule> _urbanModuleMock;
     private readonly DiscordSocketClient _client = new();
-    private readonly InteractiveService _interactive;
+    private readonly InteractiveConfig _interactiveConfig = new() { ReturnAfterSendingPaginator = true };
 
     public UrbanModuleTests()
     {
-        _interactive = new InteractiveService(_client);
-        _urbanModuleMock = new Mock<UrbanModule>(() => new UrbanModule(_urbanDictionaryMock.Object, _interactive));
+        var interactive = new InteractiveService(_client, _interactiveConfig);
+        _urbanModuleMock = new Mock<UrbanModule>(() => new UrbanModule(_urbanDictionaryMock.Object, interactive));
         _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
         _contextMock.SetupGet(x => x.User).Returns(() => AutoFaker.Generate<IUser>(b => b.WithBinder(new MoqBinder())));
         ((IInteractionModuleBase)_urbanModuleMock.Object).SetContext(_contextMock.Object);
@@ -93,10 +93,22 @@ private static Mock<IUrbanDictionary> CreateMockedUrbanDictionary()
         var faker = new Faker();
         var mock = new Mock<IUrbanDictionary>();
 
-        mock.Setup(u => u.GetDefinitionsAsync(It.IsAny<string>())).ReturnsAsync(AutoFaker.Generate<UrbanDefinition>(10).OrDefault(faker, defaultValue: new()));
-        mock.Setup(u => u.GetRandomDefinitionsAsync()).ReturnsAsync(AutoFaker.Generate<UrbanDefinition>(10));
-        mock.Setup(u => u.GetDefinitionAsync(It.IsAny<int>())).ReturnsAsync(AutoFaker.Generate<UrbanDefinition>());
-        mock.Setup(u => u.GetWordsOfTheDayAsync()).ReturnsAsync(AutoFaker.Generate<UrbanDefinition>(10));
+        var definitionFaker = new AutoFaker<UrbanDefinition>()
+            .RuleFor(x => x.Definition, f => f.Lorem.Sentence())
+            .RuleFor(x => x.Date, f => f.Date.Weekday().OrNull(f))
+            .RuleFor(x => x.Permalink, f => f.Internet.Url())
+            .RuleFor(x => x.ThumbsUp, f => f.Random.Int())
+            .RuleFor(x => x.SoundUrls, Array.Empty<string>())
+            .RuleFor(x => x.Author, f => f.Internet.UserName())
+            .RuleFor(x => x.Word, f => f.Lorem.Word())
+            .RuleFor(x => x.Id, f => f.Random.Int())
+            .RuleFor(x => x.WrittenOn, f => f.Date.PastOffset())
+            .RuleFor(x => x.Example, f => f.Lorem.Sentence());
+
+        mock.Setup(u => u.GetDefinitionsAsync(It.IsAny<string>())).ReturnsAsync(definitionFaker.Generate(10).OrDefault(faker, defaultValue: new()));
+        mock.Setup(u => u.GetRandomDefinitionsAsync()).ReturnsAsync(definitionFaker.Generate(10));
+        mock.Setup(u => u.GetDefinitionAsync(It.IsAny<int>())).ReturnsAsync(definitionFaker.Generate());
+        mock.Setup(u => u.GetWordsOfTheDayAsync()).ReturnsAsync(definitionFaker.Generate(10));
         mock.Setup(u => u.GetAutocompleteResultsAsync(It.IsAny<string>())).ReturnsAsync(AutoFaker.Generate<string>(20));
         mock.Setup(u => u.GetAutocompleteResultsExtraAsync(It.IsAny<string>())).ReturnsAsync(AutoFaker.Generate<UrbanAutocompleteResult>(20));
 

From b34b4891572340c05869acf15eacc41244d229cc Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 9 Apr 2022 01:05:03 -0500
Subject: [PATCH 29/83] Remove MessageModule

---
 src/Modules/MessageModule.cs | 61 ------------------------------------
 1 file changed, 61 deletions(-)
 delete mode 100644 src/Modules/MessageModule.cs

diff --git a/src/Modules/MessageModule.cs b/src/Modules/MessageModule.cs
deleted file mode 100644
index aa8233a..0000000
--- a/src/Modules/MessageModule.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using Discord;
-using Discord.Interactions;
-using Fergun.Extensions;
-using GTranslate;
-using GTranslate.Results;
-using GTranslate.Translators;
-using Humanizer;
-using Microsoft.Extensions.Logging;
-
-namespace Fergun.Modules;
-
-public class MessageModule : InteractionModuleBase<ShardedInteractionContext>
-{
-    private readonly ILogger<MessageModule> _logger;
-    private readonly AggregateTranslator _translator;
-    private readonly GoogleTranslator _googleTranslator;
-    private readonly GoogleTranslator2 _googleTranslator2;
-    private readonly BingTranslator _bingTranslator;
-    private readonly MicrosoftTranslator _microsoftTranslator;
-    private readonly YandexTranslator _yandexTranslator;
-    private static readonly Lazy<Language[]> _lazyFilteredLanguages = new(() => Language.LanguageDictionary
-        .Values
-        .Where(x => x.SupportedServices == (TranslationServices.Google | TranslationServices.Bing | TranslationServices.Yandex | TranslationServices.Microsoft))
-        .ToArray());
-
-    public MessageModule(ILogger<MessageModule> logger, AggregateTranslator translator, GoogleTranslator googleTranslator,
-        GoogleTranslator2 googleTranslator2, BingTranslator bingTranslator, MicrosoftTranslator microsoftTranslator, YandexTranslator yandexTranslator)
-    {
-        _logger = logger;
-        _translator = translator;
-        _googleTranslator = googleTranslator;
-        _googleTranslator2 = googleTranslator2;
-        _bingTranslator = bingTranslator;
-        _microsoftTranslator = microsoftTranslator;
-        _yandexTranslator = yandexTranslator;
-    }
-
-    [MessageCommand("Get Reference")]
-    public async Task GetReferencedMessage(IMessage message)
-    {
-        if (message.Type != MessageType.Reply)
-        {
-            await Context.Interaction.RespondWarningAsync("Message is not an inline reply.", true);
-            return;
-        }
-
-        if (message.Reference?.MessageId.IsSpecified is not true)
-        {
-            await Context.Interaction.RespondWarningAsync("Unable to get the referenced message.", true);
-            return;
-        }
-
-        string url = $"https://discord.com/channels/{message.Reference.GuildId.ToNullable()?.ToString() ?? "@me"}/{message.Reference.ChannelId}/{message.Reference.MessageId}";
-
-        var button = new ComponentBuilder()
-            .WithButton("Jump to message", style: ButtonStyle.Link, url: url)
-            .Build();
-
-        await RespondAsync("\u200b", ephemeral: true, components: button);
-    }
-}
\ No newline at end of file

From ee4896a0bb3473a2f5bdee041e786f2de183180a Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 9 Apr 2022 01:26:44 -0500
Subject: [PATCH 30/83] Add FergunConfig

---
 src/FergunConfig.cs                        | 17 +++++++++++++++++
 src/Program.cs                             |  3 ++-
 src/Services/InteractionHandlingService.cs |  2 +-
 3 files changed, 20 insertions(+), 2 deletions(-)
 create mode 100644 src/FergunConfig.cs

diff --git a/src/FergunConfig.cs b/src/FergunConfig.cs
new file mode 100644
index 0000000..8eb7efa
--- /dev/null
+++ b/src/FergunConfig.cs
@@ -0,0 +1,17 @@
+namespace Fergun;
+
+/// <summary>
+/// Represents the configuration of Fergun.
+/// </summary>
+public class FergunConfig
+{
+    /// <summary>
+    /// Gets or sets the token of the bot.
+    /// </summary>
+    public string Token { get; set; } = string.Empty;
+
+    /// <summary>
+    /// Gets or sets the ID of the guild to register the guild commands.
+    /// </summary>
+    public ulong TargetGuildId { get; set; }
+}
\ No newline at end of file
diff --git a/src/Program.cs b/src/Program.cs
index c2b463f..e558aff 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -15,6 +15,7 @@
 using GScraper.DuckDuckGo;
 using GScraper.Google;
 using GTranslate.Translators;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Logging;
@@ -36,7 +37,7 @@ await Host.CreateDefaultBuilder()
             SuppressUnknownDispatchWarnings = true
         };
 
-        config.Token = context.Configuration["Token"];
+        config.Token = context.Configuration.Get<FergunConfig>().Token;
     })
     .UseInteractionService((_, config) =>
     {
diff --git a/src/Services/InteractionHandlingService.cs b/src/Services/InteractionHandlingService.cs
index a153cc1..7ad6353 100644
--- a/src/Services/InteractionHandlingService.cs
+++ b/src/Services/InteractionHandlingService.cs
@@ -24,7 +24,7 @@ public InteractionHandlingService(DiscordShardedClient client, InteractionServic
         _interactionService = interactionService;
         _logger = logger;
         _services = services;
-        _ = ulong.TryParse(configuration["TargetGuildId"], out _targetGuildId);
+        _targetGuildId = configuration.Get<FergunConfig>().TargetGuildId;
     }
 
     /// <inheritdoc />

From d7e97f9b89ed9f54cfd48a8af3c78a9a2080f95d Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 10 Apr 2022 03:49:39 -0500
Subject: [PATCH 31/83] Add localization

---
 src/{ => Entities}/FergunConfig.cs          |   0
 src/Entities/FergunLocalizer.cs             |  56 ++++++
 src/Entities/IFergunLocalizer.cs            |  16 ++
 src/Entities/SharedResource.cs              |   8 +
 src/Fergun.csproj                           |   1 +
 src/Modules/ImageModule.cs                  |  39 +++--
 src/Modules/OcrModule.cs                    |  28 +--
 src/Modules/SharedModule.cs                 |  33 ++--
 src/Modules/UrbanModule.cs                  |  31 ++--
 src/Modules/UserModule.cs                   |  30 ++--
 src/Modules/UtilityModule.cs                |  60 ++++---
 src/Program.cs                              |   4 +-
 src/Resources/Modules.ImageModule.es.resx   | 135 +++++++++++++++
 src/Resources/Modules.OcrModule.es.resx     | 144 ++++++++++++++++
 src/Resources/Modules.UrbanModule.es.resx   | 132 ++++++++++++++
 src/Resources/Modules.UserModule.es.resx    | 144 ++++++++++++++++
 src/Resources/Modules.UtilityModule.es.resx | 180 ++++++++++++++++++++
 src/Resources/Modules.UtilityModule.resx    | 132 ++++++++++++++
 src/Resources/SharedResource.es.resx        | 171 +++++++++++++++++++
 src/Services/InteractionHandlingService.cs  |  11 +-
 tests/Fergun.Tests/OcrModuleTests.cs        |  11 +-
 tests/Fergun.Tests/UrbanModuleTests.cs      |   7 +-
 tests/Fergun.Tests/UserModuleTests.cs       |   8 +-
 23 files changed, 1275 insertions(+), 106 deletions(-)
 rename src/{ => Entities}/FergunConfig.cs (100%)
 create mode 100644 src/Entities/FergunLocalizer.cs
 create mode 100644 src/Entities/IFergunLocalizer.cs
 create mode 100644 src/Entities/SharedResource.cs
 create mode 100644 src/Resources/Modules.ImageModule.es.resx
 create mode 100644 src/Resources/Modules.OcrModule.es.resx
 create mode 100644 src/Resources/Modules.UrbanModule.es.resx
 create mode 100644 src/Resources/Modules.UserModule.es.resx
 create mode 100644 src/Resources/Modules.UtilityModule.es.resx
 create mode 100644 src/Resources/Modules.UtilityModule.resx
 create mode 100644 src/Resources/SharedResource.es.resx

diff --git a/src/FergunConfig.cs b/src/Entities/FergunConfig.cs
similarity index 100%
rename from src/FergunConfig.cs
rename to src/Entities/FergunConfig.cs
diff --git a/src/Entities/FergunLocalizer.cs b/src/Entities/FergunLocalizer.cs
new file mode 100644
index 0000000..fa1926d
--- /dev/null
+++ b/src/Entities/FergunLocalizer.cs
@@ -0,0 +1,56 @@
+using System.Globalization;
+using Microsoft.Extensions.Localization;
+
+namespace Fergun;
+
+/// <summary>
+/// Represents the default implementation of <see cref="IFergunLocalizer{T}"/>.
+/// </summary>
+/// <remarks>By default, this localizer has a dependency in the target localizer (<typeparamref name="T"/>), and a shared localizer <see cref="SharedResource"/>,
+/// both of which are used to get a localized string.</remarks>
+/// <typeparam name="T">The <see cref="Type"/> to provide strings for.</typeparam>
+public class FergunLocalizer<T> : IFergunLocalizer<T>
+{
+    private readonly IStringLocalizer<T> _localizer;
+    private readonly IStringLocalizer<SharedResource> _sharedLocalizer;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="FergunLocalizer{T}"/> class.
+    /// </summary>
+    /// <param name="localizer">The target localizer.</param>
+    /// <param name="sharedLocalizer">The shared localizer.</param>
+    public FergunLocalizer(IStringLocalizer<T> localizer, IStringLocalizer<SharedResource> sharedLocalizer)
+    {
+        _localizer = localizer;
+        _sharedLocalizer = sharedLocalizer;
+    }
+
+    /// <inheritdoc/>
+    public CultureInfo CurrentCulture { get; set; } = CultureInfo.CurrentCulture;
+
+    /// <inheritdoc/>
+    public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
+        => _localizer.GetAllStrings(includeParentCultures).Concat(_sharedLocalizer.GetAllStrings(includeParentCultures));
+
+    /// <inheritdoc/>
+    public LocalizedString this[string name]
+    {
+        get
+        {
+            Thread.CurrentThread.CurrentUICulture = CurrentCulture;
+            var localized = _localizer[name];
+            return localized.ResourceNotFound ? _sharedLocalizer[name] : localized;
+        }
+    }
+
+    /// <inheritdoc/>
+    public LocalizedString this[string name, params object[] arguments]
+    {
+        get
+        {
+            Thread.CurrentThread.CurrentUICulture = CurrentCulture;
+            var localized = _localizer[name, arguments];
+            return localized.ResourceNotFound ? _sharedLocalizer[name, arguments] : localized;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Entities/IFergunLocalizer.cs b/src/Entities/IFergunLocalizer.cs
new file mode 100644
index 0000000..d04d7d6
--- /dev/null
+++ b/src/Entities/IFergunLocalizer.cs
@@ -0,0 +1,16 @@
+using System.Globalization;
+using Microsoft.Extensions.Localization;
+
+namespace Fergun;
+
+/// <summary>
+/// Represents a <see cref="IStringLocalizer{T}"/> with a current culture which can be changed.
+/// </summary>
+/// <typeparam name="T">The <see cref="Type"/> to provide strings for.</typeparam>
+public interface IFergunLocalizer<out T> : IStringLocalizer<T>
+{
+    /// <summary>
+    /// Gets or sets the current culture.
+    /// </summary>
+    CultureInfo CurrentCulture { get; set; }
+}
\ No newline at end of file
diff --git a/src/Entities/SharedResource.cs b/src/Entities/SharedResource.cs
new file mode 100644
index 0000000..0b28b41
--- /dev/null
+++ b/src/Entities/SharedResource.cs
@@ -0,0 +1,8 @@
+namespace Fergun;
+
+/// <summary>
+/// Used for shared localization.
+/// </summary>
+public class SharedResource
+{
+}
\ No newline at end of file
diff --git a/src/Fergun.csproj b/src/Fergun.csproj
index 77d9ac1..5bcca1c 100644
--- a/src/Fergun.csproj
+++ b/src/Fergun.csproj
@@ -17,6 +17,7 @@
     <PackageReference Include="Humanizer.Core" Version="2.14.1" />
     <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.3" />
+    <PackageReference Include="Microsoft.Extensions.Localization" Version="6.0.3" />
     <PackageReference Include="Polly.Caching.Memory" Version="3.0.2" />
     <PackageReference Include="Serilog.Extensions.Hosting" Version="4.2.0" />
     <PackageReference Include="Serilog.Extensions.Logging.File" Version="2.0.0" />
diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
index 777a694..e094488 100644
--- a/src/Modules/ImageModule.cs
+++ b/src/Modules/ImageModule.cs
@@ -1,4 +1,5 @@
-using Discord;
+using System.Globalization;
+using Discord;
 using Discord.Interactions;
 using Fergun.Apis.Bing;
 using Fergun.Apis.Yandex;
@@ -17,7 +18,8 @@ namespace Fergun.Modules;
 [Group("img", "Image search commands.")]
 public class ImageModule : InteractionModuleBase
 {
-    private readonly ILogger<UtilityModule> _logger;
+    private readonly ILogger<ImageModule> _logger;
+    private readonly IFergunLocalizer<ImageModule> _localizer;
     private readonly SharedModule _shared;
     private readonly InteractiveService _interactive;
     private readonly GoogleScraper _googleScraper;
@@ -26,10 +28,11 @@ public class ImageModule : InteractionModuleBase
     private readonly IBingVisualSearch _bingVisualSearch;
     private readonly IYandexImageSearch _yandexImageSearch;
 
-    public ImageModule(ILogger<UtilityModule> logger, SharedModule shared, InteractiveService interactive, GoogleScraper googleScraper,
-        DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper, IBingVisualSearch bingVisualSearch, IYandexImageSearch yandexImageSearch)
+    public ImageModule(ILogger<ImageModule> logger, IFergunLocalizer<ImageModule> localizer, SharedModule shared, InteractiveService interactive,
+        GoogleScraper googleScraper, DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper, IBingVisualSearch bingVisualSearch, IYandexImageSearch yandexImageSearch)
     {
         _logger = logger;
+        _localizer = localizer;
         _shared = shared;
         _interactive = interactive;
         _googleScraper = googleScraper;
@@ -39,6 +42,8 @@ public ImageModule(ILogger<UtilityModule> logger, SharedModule shared, Interacti
         _yandexImageSearch = yandexImageSearch;
     }
 
+    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = new CultureInfo(Context.Interaction.GetLanguageCode());
+
     [SlashCommand("google", "Searches for images from Google Images and displays them in a paginator.")]
     public async Task Google([Autocomplete(typeof(GoogleAutocompleteHandler))][Summary(description: "The query to search.")] string query,
         [Summary(description: "Whether to display multiple images in a single page.")] bool multiImages = false)
@@ -59,7 +64,7 @@ public async Task Google([Autocomplete(typeof(GoogleAutocompleteHandler))][Summa
 
         if (filteredImages.Length == 0)
         {
-            await Context.Interaction.FollowupWarning("No results.");
+            await Context.Interaction.FollowupWarning(_localizer["No results."]);
             return;
         }
 
@@ -79,10 +84,10 @@ MultiEmbedPageBuilder GeneratePage(int index)
         {
             var builders = filteredImages[index].Select(result => new EmbedBuilder()
                 .WithTitle(result.Title)
-                .WithDescription("Google Images results")
+                .WithDescription(_localizer["Google Images search"])
                 .WithUrl(multiImages ? "https://google.com" : result.SourceUrl)
                 .WithImageUrl(result.Url)
-                .WithFooter($"Page {index + 1}/{filteredImages.Length}", Constants.GoogleLogoUrl)
+                .WithFooter(_localizer["Page {0} of {1}", index + 1, filteredImages.Length], Constants.GoogleLogoUrl)
                 .WithColor(Color.Orange));
 
             return new MultiEmbedPageBuilder().WithBuilders(builders);
@@ -107,7 +112,7 @@ public async Task DuckDuckGo([Autocomplete(typeof(DuckDuckGoAutocompleteHandler)
 
         if (filteredImages.Length == 0)
         {
-            await Context.Interaction.FollowupWarning("No results.");
+            await Context.Interaction.FollowupWarning(_localizer["No results."]);
             return;
         }
 
@@ -127,10 +132,10 @@ Task<PageBuilder> GeneratePageAsync(int index)
         {
             var pageBuilder = new PageBuilder()
                 .WithTitle(filteredImages[index].Title)
-                .WithDescription("DuckDuckGo image search")
+                .WithDescription(_localizer["DuckDuckGo image search"])
                 .WithUrl(filteredImages[index].SourceUrl)
                 .WithImageUrl(filteredImages[index].Url)
-                .WithFooter($"Page {index + 1}/{filteredImages.Length}", Constants.DuckDuckGoLogoUrl)
+                .WithFooter(_localizer["Page {0} of {1}", index + 1, filteredImages.Length], Constants.DuckDuckGoLogoUrl)
                 .WithColor(Color.Orange);
 
             return Task.FromResult(pageBuilder);
@@ -155,7 +160,7 @@ public async Task Brave([Autocomplete(typeof(BraveAutocompleteHandler))][Summary
 
         if (filteredImages.Length == 0)
         {
-            await Context.Interaction.FollowupWarning("No results.");
+            await Context.Interaction.FollowupWarning(_localizer["No results."]);
             return;
         }
 
@@ -175,10 +180,10 @@ Task<PageBuilder> GeneratePageAsync(int index)
         {
             var pageBuilder = new PageBuilder()
                 .WithTitle(filteredImages[index].Title)
-                .WithDescription("Brave image search")
+                .WithDescription(_localizer["Brave image search"])
                 .WithUrl(filteredImages[index].SourceUrl)
                 .WithImageUrl(filteredImages[index].Url)
-                .WithFooter($"Page {index + 1}/{filteredImages.Length}", Constants.BraveLogoUrl)
+                .WithFooter(_localizer["Page {0} of {1}", index + 1, filteredImages.Length], Constants.BraveLogoUrl)
                 .WithColor(Color.Orange);
 
             return Task.FromResult(pageBuilder);
@@ -209,7 +214,7 @@ public async Task YandexAsync(string url, bool multiImages)
 
         if (results.Length == 0)
         {
-            await Context.Interaction.FollowupWarning("No results.");
+            await Context.Interaction.FollowupWarning(_localizer["No results."]);
             return;
         }
 
@@ -233,7 +238,7 @@ MultiEmbedPageBuilder GeneratePage(int index)
                 .WithUrl(multiImages ? "https://yandex.com/images" : result.SourceUrl)
                 .WithThumbnailUrl(url)
                 .WithImageUrl(result.Url)
-                .WithFooter($"Yandex Image Search | Page {index + 1}/{results.Length}", Constants.YandexIconUrl)
+                .WithFooter(_localizer["Yandex Visual Search | Page {0} of {1}", index + 1, results.Length], Constants.YandexIconUrl)
                 .WithColor(Color.Orange));
 
             return new MultiEmbedPageBuilder().WithBuilders(builders);
@@ -251,7 +256,7 @@ public async Task BingAsync(string url, bool multiImages)
 
         if (results.Length == 0)
         {
-            await Context.Interaction.FollowupWarning("No results.");
+            await Context.Interaction.FollowupWarning(_localizer["No results."]);
             return;
         }
 
@@ -274,7 +279,7 @@ MultiEmbedPageBuilder GeneratePage(int index)
                 .WithUrl(multiImages ? "https://www.bing.com/visualsearch" : result.SourceUrl)
                 .WithThumbnailUrl(url)
                 .WithImageUrl(result.Url)
-                .WithFooter($"Bing Visual Search | Page {index + 1}/{results.Length}", Constants.BingIconUrl)
+                .WithFooter(_localizer["Bing Visual Search | Page {0} of {1}", index + 1, results.Length], Constants.BingIconUrl)
                 .WithColor((Color)result.AccentColor));
 
             return new MultiEmbedPageBuilder().WithBuilders(builders);
diff --git a/src/Modules/OcrModule.cs b/src/Modules/OcrModule.cs
index f6132b3..0a15919 100644
--- a/src/Modules/OcrModule.cs
+++ b/src/Modules/OcrModule.cs
@@ -1,4 +1,5 @@
 using System.Diagnostics;
+using System.Globalization;
 using Discord;
 using Discord.Interactions;
 using Fergun.Apis.Bing;
@@ -15,20 +16,25 @@ namespace Fergun.Modules;
 public class OcrModule : InteractionModuleBase
 {
     private readonly ILogger<OcrModule> _logger;
+    private readonly IFergunLocalizer<OcrModule> _localizer;
     private readonly SharedModule _shared;
     private readonly InteractiveService _interactive;
     private readonly IBingVisualSearch _bingVisualSearch;
     private readonly IYandexImageSearch _yandexImageSearch;
 
-    public OcrModule(ILogger<OcrModule> logger, SharedModule shared, InteractiveService interactive, IBingVisualSearch bingVisualSearch, IYandexImageSearch yandexImageSearch)
+    public OcrModule(ILogger<OcrModule> logger, IFergunLocalizer<OcrModule> localizer, SharedModule shared,
+        InteractiveService interactive,IBingVisualSearch bingVisualSearch, IYandexImageSearch yandexImageSearch)
     {
         _logger = logger;
+        _localizer = localizer;
         _shared = shared;
         _interactive = interactive;
         _bingVisualSearch = bingVisualSearch;
         _yandexImageSearch = yandexImageSearch;
     }
 
+    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = new CultureInfo(Context.Interaction.GetLanguageCode());
+
     [MessageCommand("OCR")]
     public async Task Ocr(IMessage message)
     {
@@ -39,12 +45,12 @@ public async Task Ocr(IMessage message)
 
         if (url is null)
         {
-            await Context.Interaction.RespondWarningAsync("Unable to get an image URL from the message.", true);
+            await Context.Interaction.RespondWarningAsync(_localizer["Unable to get an image URL from the message."], true);
             return;
         }
 
         var page = new PageBuilder()
-            .WithTitle("Select an OCR engine")
+            .WithTitle(_localizer["Select an OCR engine"])
             .WithColor(Color.Orange);
 
         var selection = new SelectionBuilder<OcrEngine>()
@@ -76,7 +82,7 @@ public async Task OcrAsync(OcrEngine ocrEngine, string url, IDiscordInteraction
     {
         if (!Uri.IsWellFormedUriString(url, UriKind.Absolute))
         {
-            await interaction.RespondWarningAsync("The URL is not well formed.", true);
+            await interaction.RespondWarningAsync(_localizer["The URL is not well formed."], true);
             return;
         }
 
@@ -112,7 +118,7 @@ public async Task OcrAsync(OcrEngine ocrEngine, string url, IDiscordInteraction
 
         if (string.IsNullOrWhiteSpace(text))
         {
-            await interaction.FollowupWarning("The OCR did not give results.", ephemeral);
+            await interaction.FollowupWarning(_localizer["The OCR yielded no results."], ephemeral);
             return;
         }
 
@@ -122,22 +128,22 @@ public async Task OcrAsync(OcrEngine ocrEngine, string url, IDiscordInteraction
 
         var (name, iconUrl) = ocrEngine switch
         {
-            OcrEngine.Bing => ("Bing Visual Search", Constants.BingIconUrl),
-            OcrEngine.Yandex => ("Yandex OCR", Constants.YandexIconUrl),
+            OcrEngine.Bing => (_localizer["Bing Visual Search"], Constants.BingIconUrl),
+            OcrEngine.Yandex => (_localizer["Yandex OCR"], Constants.YandexIconUrl),
             _ => throw new ArgumentException("Invalid OCR engine.", nameof(ocrEngine))
         };
 
-        string embedText = "**Output**\n";
+        string embedText = $"**{_localizer["Output"]}**\n";
 
         var builder = new EmbedBuilder()
-            .WithTitle("OCR Results")
+            .WithTitle(_localizer["OCR Results"])
             .WithDescription($"{embedText}```{text.Replace('`', '´').Truncate(EmbedBuilder.MaxDescriptionLength - embedText.Length - 6)}```")
             .WithThumbnailUrl(url)
-            .WithFooter($"{name} | Processing time: {stopwatch.ElapsedMilliseconds}ms", iconUrl)
+            .WithFooter(_localizer["{0} | Processing time: {1}ms", name, stopwatch.ElapsedMilliseconds], iconUrl)
             .WithColor(Color.Orange);
 
         var components = new ComponentBuilder()
-            .WithButton($"Translate{(language is null ? "" : $" to {language.Name}")}", "ocrtranslate", ButtonStyle.Secondary)
+            .WithButton(language is null ? _localizer["Translate"] : _localizer["Translate to {0}", language.Name], "ocrtranslate", ButtonStyle.Secondary)
             .WithButton("TTS", "ocrtts", ButtonStyle.Secondary)
             .Build();
 
diff --git a/src/Modules/SharedModule.cs b/src/Modules/SharedModule.cs
index 911d0da..b0a7c2d 100644
--- a/src/Modules/SharedModule.cs
+++ b/src/Modules/SharedModule.cs
@@ -1,4 +1,5 @@
-using Discord;
+using System.Globalization;
+using Discord;
 using Fergun.Extensions;
 using GTranslate;
 using GTranslate.Results;
@@ -14,33 +15,37 @@ namespace Fergun.Modules;
 public class SharedModule
 {
     private readonly ILogger<SharedModule> _logger;
+    private readonly IFergunLocalizer<SharedResource> _localizer;
     private readonly AggregateTranslator _translator;
     private readonly GoogleTranslator2 _googleTranslator2;
 
-    public SharedModule(ILogger<SharedModule> logger, AggregateTranslator translator, GoogleTranslator2 googleTranslator2)
+    public SharedModule(ILogger<SharedModule> logger, IFergunLocalizer<SharedResource> localizer, AggregateTranslator translator, GoogleTranslator2 googleTranslator2)
     {
         _logger = logger;
+        _localizer = localizer;
         _translator = translator;
         _googleTranslator2 = googleTranslator2;
     }
 
     public async Task TranslateAsync(IDiscordInteraction interaction, string text, string target, string? source = null, bool ephemeral = false, bool deferLoad = false)
     {
+        _localizer.CurrentCulture = new CultureInfo(interaction.GetLanguageCode());
+
         if (string.IsNullOrWhiteSpace(text))
         {
-            await interaction.RespondWarningAsync("The message must contain text.", true);
+            await interaction.RespondWarningAsync(_localizer["The message must contain text."], true);
             return;
         }
 
         if (!Language.TryGetLanguage(target, out _))
         {
-            await interaction.RespondWarningAsync($"Invalid target language \"{target}\".", true);
+            await interaction.RespondWarningAsync(_localizer["Invalid target language \"{0}\".", target], true);
             return;
         }
 
         if (source != null && !Language.TryGetLanguage(source, out _))
         {
-            await interaction.RespondWarningAsync($"Invalid source language \"{source}\".", true);
+            await interaction.RespondWarningAsync(_localizer["Invalid source language \"{0}\".", source], true);
             return;
         }
 
@@ -74,16 +79,16 @@ public async Task TranslateAsync(IDiscordInteraction interaction, string text, s
             _ => Constants.GoogleTranslateLogoUrl
         };
 
-        string embedText = $"**Source language** {(source == null ? "**(Detected)**" : "")}\n" +
-                           $"{result.SourceLanguage.Name}\n\n" +
-                           "**Target language**\n" +
-                           $"{result.TargetLanguage.Name}" +
-                           "\n\n**Result**\n";
+        string embedText = $"**{_localizer[source is null ? "Source language (Detected)" : "Source language"]}**\n" +
+                            $"{result.SourceLanguage.Name}\n\n" +
+                            $"**{_localizer["Target language"]}**\n" +
+                            $"{result.TargetLanguage.Name}" +
+                            $"\n\n**{_localizer["Result"]}**\n";
 
         string translation = result.Translation.Replace('`', '´').Truncate(EmbedBuilder.MaxDescriptionLength - embedText.Length - 6);
 
         var builder = new EmbedBuilder()
-            .WithTitle("Translation result")
+            .WithTitle(_localizer["Translation result"])
             .WithDescription($"{embedText}```{translation}```")
             .WithThumbnailUrl(thumbnailUrl)
             .WithColor(Color.Orange);
@@ -93,9 +98,11 @@ public async Task TranslateAsync(IDiscordInteraction interaction, string text, s
 
     public async Task TtsAsync(IDiscordInteraction interaction, string text, string? target = null, bool ephemeral = false, bool deferLoad = false)
     {
+        _localizer.CurrentCulture = new CultureInfo(interaction.GetLanguageCode());
+
         if (string.IsNullOrWhiteSpace(text))
         {
-            await interaction.RespondWarningAsync("The message must contain text.", true);
+            await interaction.RespondWarningAsync(_localizer["The message must contain text."], true);
             return;
         }
 
@@ -103,7 +110,7 @@ public async Task TtsAsync(IDiscordInteraction interaction, string text, string?
 
         if (!Language.TryGetLanguage(target, out var language) || !GoogleTranslator2.TextToSpeechLanguages.Contains(language))
         {
-            await interaction.RespondWarningAsync($"Language \"{target}\" not supported.", true);
+            await interaction.RespondWarningAsync(_localizer["Language \"{0}\" not supported.", target], true);
             return;
         }
 
diff --git a/src/Modules/UrbanModule.cs b/src/Modules/UrbanModule.cs
index 4440716..9dcfd40 100644
--- a/src/Modules/UrbanModule.cs
+++ b/src/Modules/UrbanModule.cs
@@ -1,4 +1,5 @@
-using System.Text;
+using System.Globalization;
+using System.Text;
 using Discord;
 using Discord.Interactions;
 using Fergun.Apis.Urban;
@@ -12,15 +13,19 @@ namespace Fergun.Modules;
 [Group("urban", "Urban Dictionary commands")]
 public class UrbanModule : InteractionModuleBase
 {
+    private readonly IFergunLocalizer<UrbanModule> _localizer;
     private readonly IUrbanDictionary _urbanDictionary;
     private readonly InteractiveService _interactive;
 
-    public UrbanModule(IUrbanDictionary urbanDictionary, InteractiveService interactive)
+    public UrbanModule(IFergunLocalizer<UrbanModule> localizer, IUrbanDictionary urbanDictionary, InteractiveService interactive)
     {
+        _localizer = localizer;
         _urbanDictionary = urbanDictionary;
         _interactive = interactive;
     }
 
+    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = new CultureInfo(Context.Interaction.GetLanguageCode());
+
     [SlashCommand("search", "Searches for definitions for a term in Urban Dictionary.")]
     public async Task Search([Autocomplete(typeof(UrbanAutocompleteHandler))] [Summary(description: "The term to search.")] string term)
         => await SearchAndSendAsync(UrbanSearchType.Search, term);
@@ -45,7 +50,7 @@ public async Task SearchAndSendAsync(UrbanSearchType searchType, string? term =
 
         if (definitions.Count == 0)
         {
-            await Context.Interaction.FollowupWarning("No results.");
+            await Context.Interaction.FollowupWarning(_localizer["No results."]);
             return;
         }
 
@@ -71,27 +76,21 @@ PageBuilder GeneratePage(int i)
                 description.Append(Format.Italics(Format.Sanitize(definitions[i].Example.Trim())));
             }
 
-            var footer = new StringBuilder("Urban Dictionary ");
-            switch (searchType)
+            string footer = searchType switch
             {
-                case UrbanSearchType.Random:
-                    footer.Append("(Random Definitions) ");
-                    break;
-                case UrbanSearchType.WordsOfTheDay:
-                    footer.Append($"(Words of the day, {definitions[i].Date}) ");
-                    break;
-            }
-
-            footer.Append($"- Page {i + 1} of {definitions.Count}");
+                UrbanSearchType.Random => _localizer["Urban Dictionary (Random Definitions) | Page {0} of {1}", i + 1, definitions.Count],
+                UrbanSearchType.WordsOfTheDay => _localizer["Urban Dictionary (Words of the day, {0}) | Page {1} of {2}", definitions[i].Date!, i + 1, definitions.Count],
+                _ => _localizer["Urban Dictionary | Page {0} of {1}", i + 1, definitions.Count]
+            };
 
             return new PageBuilder()
                 .WithTitle(definitions[i].Word)
                 .WithUrl(definitions[i].Permalink)
-                .WithAuthor($"By {definitions[i].Author}", url: $"https://www.urbandictionary.com/author.php?author={Uri.EscapeDataString(definitions[i].Author)}")
+                .WithAuthor(_localizer["By {0}", definitions[i].Author], url: $"https://www.urbandictionary.com/author.php?author={Uri.EscapeDataString(definitions[i].Author)}")
                 .WithDescription(description.ToString())
                 .AddField("👍", definitions[i].ThumbsUp, true)
                 .AddField("👎", definitions[i].ThumbsDown, true)
-                .WithFooter(footer.ToString(), Constants.UrbanDictionaryIconUrl)
+                .WithFooter(footer, Constants.UrbanDictionaryIconUrl)
                 .WithTimestamp(definitions[i].WrittenOn)
                 .WithColor(Color.Orange); // 0x10151BU 0x1B2936U
         }
diff --git a/src/Modules/UserModule.cs b/src/Modules/UserModule.cs
index ea436d5..d23fce5 100644
--- a/src/Modules/UserModule.cs
+++ b/src/Modules/UserModule.cs
@@ -1,4 +1,5 @@
-using Discord;
+using System.Globalization;
+using Discord;
 using Discord.Interactions;
 using Fergun.Extensions;
 
@@ -6,6 +7,15 @@ namespace Fergun.Modules;
 
 public class UserModule : InteractionModuleBase
 {
+    private readonly IFergunLocalizer<UserModule> _localizer;
+
+    public UserModule(IFergunLocalizer<UserModule> localizer)
+    {
+        _localizer = localizer;
+    }
+
+    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = new CultureInfo(Context.Interaction.GetLanguageCode());
+
     [UserCommand("Avatar")]
     public async Task Avatar(IUser user)
     {
@@ -34,7 +44,7 @@ public async Task UserInfo(IUser user)
         }
 
         if (string.IsNullOrWhiteSpace(activities))
-            activities = "None";
+            activities = $"({_localizer["None"]})";
 
         string clients = "?";
         if (user.ActiveClients.Count > 0)
@@ -56,16 +66,16 @@ public async Task UserInfo(IUser user)
         string avatarUrl = guildUser?.GetGuildAvatarUrl(size: 2048) ?? user.GetAvatarUrl(ImageFormat.Auto, 2048) ?? user.GetDefaultAvatarUrl();
 
         var builder = new EmbedBuilder()
-            .WithTitle("User Info")
-            .AddField("Name", user.ToString())
-            .AddField("Nickname", guildUser?.Nickname ?? "None")
+            .WithTitle(_localizer["User Info"])
+            .AddField(_localizer["Name"], user.ToString())
+            .AddField("Nickname", guildUser?.Nickname ?? $"({_localizer["None"]})")
             .AddField("ID", user.Id)
-            .AddField("Activity", activities, true)
+            .AddField(_localizer["Activities"], activities, true)
             .AddField("Active Clients", clients, true)
-            .AddField("Is Bot", user.IsBot)
-            .AddField("Created At", GetTimestamp(user.CreatedAt))
-            .AddField("Guild Join Date", GetTimestamp(guildUser?.JoinedAt))
-            .AddField("Boosting Since", GetTimestamp(guildUser?.PremiumSince))
+            .AddField(_localizer["Is Bot"], user.IsBot)
+            .AddField(_localizer["Created At"], GetTimestamp(user.CreatedAt))
+            .AddField(_localizer["Server Join Date"], GetTimestamp(guildUser?.JoinedAt))
+            .AddField(_localizer["Boosting Since"], GetTimestamp(guildUser?.PremiumSince))
             .WithThumbnailUrl(avatarUrl)
             .WithColor(Color.Orange);
 
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 75c8ab4..6003a17 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -1,4 +1,5 @@
 using System.Diagnostics;
+using System.Globalization;
 using System.Reflection;
 using System.Runtime.InteropServices;
 using Discord;
@@ -22,6 +23,7 @@ namespace Fergun.Modules;
 public class UtilityModule : InteractionModuleBase<ShardedInteractionContext>
 {
     private readonly ILogger<UtilityModule> _logger;
+    private readonly IFergunLocalizer<UtilityModule> _localizer;
     private readonly SharedModule _shared;
     private readonly InteractiveService _interactive;
     private readonly GoogleTranslator _googleTranslator;
@@ -36,11 +38,12 @@ public class UtilityModule : InteractionModuleBase<ShardedInteractionContext>
         .Where(x => x.SupportedServices == (TranslationServices.Google | TranslationServices.Bing | TranslationServices.Yandex | TranslationServices.Microsoft))
         .ToArray());
 
-    public UtilityModule(ILogger<UtilityModule> logger, SharedModule shared, InteractiveService interactive, GoogleTranslator googleTranslator,
-        GoogleTranslator2 googleTranslator2, MicrosoftTranslator microsoftTranslator, YandexTranslator yandexTranslator, SearchClient searchClient,
-        IWikipediaClient wikipediaClient)
+    public UtilityModule(ILogger<UtilityModule> logger, IFergunLocalizer<UtilityModule> localizer,
+        SharedModule shared, InteractiveService interactive, GoogleTranslator googleTranslator, GoogleTranslator2 googleTranslator2,
+        MicrosoftTranslator microsoftTranslator, YandexTranslator yandexTranslator, SearchClient searchClient, IWikipediaClient wikipediaClient)
     {
         _logger = logger;
+        _localizer = localizer;
         _shared = shared;
         _interactive = interactive;
         _googleTranslator = googleTranslator;
@@ -51,6 +54,8 @@ public UtilityModule(ILogger<UtilityModule> logger, SharedModule shared, Interac
         _wikipediaClient = wikipediaClient;
     }
 
+    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = new CultureInfo(Context.Interaction.GetLanguageCode());
+
     [MessageCommand("Bad Translator")]
     public async Task BadTranslator(IMessage message)
         => await BadTranslator(message.GetText());
@@ -61,13 +66,13 @@ public async Task BadTranslator([Summary(description: "The text to use.")] strin
     {
         if (string.IsNullOrWhiteSpace(text))
         {
-            await Context.Interaction.RespondWarningAsync("The message must contain text.", true);
+            await Context.Interaction.RespondWarningAsync(_localizer["The message must contain text."], true);
             return;
         }
 
         if (chainCount is < 2 or > 10)
         {
-            await Context.Interaction.RespondWarningAsync("The chain count must be between 2 and 10 (inclusive).", true);
+            await Context.Interaction.RespondWarningAsync(_localizer["The chain count must be between 2 and 10 (inclusive)."], true);
             return;
         }
 
@@ -128,7 +133,7 @@ public async Task BadTranslator([Summary(description: "The text to use.")] strin
             languageChain.Add(target);
         }
 
-        string embedText = $"**Language Chain**\n{string.Join(" -> ", languageChain.Select(x => x.ISO6391))}\n\n**Result**\n";
+        string embedText = $"**{_localizer["Language Chain"]}**\n{string.Join(" -> ", languageChain.Select(x => x.ISO6391))}\n\n**{_localizer["Result"]}**\n";
 
         var embed = new EmbedBuilder()
             .WithTitle("Bad translator")
@@ -150,7 +155,7 @@ public async Task Cmd([Summary(description: "The command to execute")] string co
 
         if (string.IsNullOrWhiteSpace(result))
         {
-            await FollowupAsync("No output.");
+            await FollowupAsync(_localizer["No output."]);
         }
         else
         {
@@ -166,7 +171,7 @@ public async Task Cmd([Summary(description: "The command to execute")] string co
             else
             {
                 embed = new EmbedBuilder()
-                    .WithTitle("Command output")
+                    .WithTitle(_localizer["Command output"])
                     .WithDescription(sanitized)
                     .WithColor(Color.Orange)
                     .Build();
@@ -181,14 +186,7 @@ public async Task Help()
     {
         var embed = new EmbedBuilder()
             .WithTitle("Fergun 2")
-            .WithDescription("This is Fergun 2. Fergun 2 is a complete rewrite of Fergun 1 and it will only have slash commands.\n" +
-                             "Fergun 2 is in alpha stages and only the most used commands are present, but more commands will be added soon.\n" +
-                             "Fergun 2 will be finished in May 2022 and it will include new features and commands.\n\n" +
-                             "Some modules and commands are currently in maintenance mode in Fergun 1 and they won't be migrated to Fergun 2. These modules are:\n" +
-                             "- **AI Dungeon** module\n" +
-                             "- **Music** module\n" +
-                             "- **Snipe** commands\n\n" +
-                             $"You can find more info about the removals of these modules/commands {Format.Url("here", "https://github.com/d4n3436/Fergun/wiki/Command-removal-notice")}.")
+            .WithDescription(_localizer["Fergun2Info", "https://github.com/d4n3436/Fergun/wiki/Command-removal-notice"])
             .WithColor(Color.Orange)
             .Build();
 
@@ -335,28 +333,28 @@ public async Task Stats()
         var elapsed = DateTimeOffset.UtcNow - Process.GetCurrentProcess().StartTime;
 
         var builder = new EmbedBuilder()
-            .WithTitle("Fergun Stats")
-            .AddField("Operating System", os, true)
+            .WithTitle(_localizer["Fergun Stats"])
+            .AddField(_localizer["Operating System"], os, true)
             .AddField("\u200b", "\u200b", true)
             .AddField("CPU", cpu, true)
-            .AddField("CPU Usage", cpuUsage + "%", true)
+            .AddField(_localizer["CPU Usage"], cpuUsage + "%", true)
             .AddField("\u200b", "\u200b", true)
-            .AddField("RAM Usage",
+            .AddField(_localizer["RAM Usage"],
                 $"{processRamUsage}MB ({(totalRam == null ? 0 : Math.Round((double)processRamUsage / totalRam.Value * 100, 2))}%) " +
                 $"/ {(totalRamUsage == null || totalRam == null ? "?MB" : $"{totalRamUsage}MB ({Math.Round((double)totalRamUsage.Value / totalRam.Value * 100, 2)}%)")} " +
                 $"/ {totalRam?.ToString() ?? "?"}MB", true)
-            .AddField("Library", $"Discord.Net v{DiscordConfig.Version}", true)
+            .AddField(_localizer["Library"], $"Discord.Net v{DiscordConfig.Version}", true)
             .AddField("\u200b", "\u200b", true)
-            .AddField("Bot Version", version, true)
-            .AddField("Total Servers", $"{Context.Client.Guilds.Count} (Shard: {Context.Client.GetShard(shardId).Guilds.Count})", true)
+            .AddField(_localizer["Bot Version"], version, true)
+            .AddField(_localizer["Total Servers"], $"{Context.Client.Guilds.Count} (Shard: {Context.Client.GetShard(shardId).Guilds.Count})", true)
             .AddField("\u200b", "\u200b", true)
-            .AddField("Total Users", $"{totalUsers} (Shard: {totalUsersInShard})", true)
-            .AddField("Shard ID", shardId, true)
+            .AddField(_localizer["Total Users"], $"{totalUsers} (Shard: {totalUsersInShard})", true)
+            .AddField(_localizer["Shard ID"], shardId, true)
             .AddField("\u200b", "\u200b", true)
             .AddField("Shards", Context.Client.Shards.Count, true)
-            .AddField("Uptime", elapsed.Humanize(), true)
+            .AddField(_localizer["Uptime"], elapsed.Humanize(), true)
             .AddField("\u200b", "\u200b", true)
-            .AddField("Bot Owner", owner, true);
+            .AddField(_localizer["Bot Owner"], owner, true);
 
         builder.WithColor(Color.Orange);
 
@@ -393,7 +391,7 @@ public async Task Wikipedia([Autocomplete(typeof(WikipediaAutocompleteHandler))]
 
         if (articles.Length == 0)
         {
-            await Context.Interaction.FollowupWarning("No results.");
+            await Context.Interaction.FollowupWarning(_localizer["No results."]);
             return;
         }
 
@@ -418,7 +416,7 @@ PageBuilder GeneratePage(int index)
                 .WithUrl($"https://{Context.Interaction.GetLanguageCode()}.wikipedia.org/?curid={article.Id}")
                 .WithThumbnailUrl($"https://commons.wikimedia.org/w/index.php?title=Special:Redirect/file/Wikipedia-logo-v2-{Context.Interaction.GetLanguageCode()}.png")
                 .WithDescription(article.Extract.Truncate(EmbedBuilder.MaxDescriptionLength))
-                .WithFooter($"Wikipedia Search | Page {index + 1} of {articles.Length}")
+                .WithFooter(_localizer["Wikipedia Search | Page {0} of {1}", index + 1, articles.Length])
                 .WithColor(Color.Orange);
 
             if (Context.Channel.IsNsfw() && article.Image is not null)
@@ -447,7 +445,7 @@ public async Task YouTube([Autocomplete(typeof(YouTubeAutocompleteHandler))] [Su
         switch (videos.Count)
         {
             case 0:
-                await Context.Interaction.FollowupWarning("No results.");
+                await Context.Interaction.FollowupWarning(_localizer["No results."]);
                 break;
 
             case 1:
@@ -457,7 +455,7 @@ public async Task YouTube([Autocomplete(typeof(YouTubeAutocompleteHandler))] [Su
             default:
                 var paginator = new StaticPaginatorBuilder()
                     .AddUser(Context.User)
-                    .WithPages(videos.Select((x, i) => new PageBuilder { Text = $"{x.Url}\nPage {i + 1} of {videos.Count}" } as IPageBuilder).ToArray())
+                    .WithPages(videos.Select((x, i) => new PageBuilder { Text = $"{x.Url}\n{_localizer["Page {0} of {1}", i + 1, videos.Count]}" } as IPageBuilder).ToArray())
                     .WithActionOnCancellation(ActionOnStop.DisableInput)
                     .WithActionOnTimeout(ActionOnStop.DisableInput)
                     .WithFooter(PaginatorFooter.None)
diff --git a/src/Program.cs b/src/Program.cs
index e558aff..517ace7 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -50,12 +50,14 @@ await Host.CreateDefaultBuilder()
     {
         config.MinimumLevel.Debug()
             .Filter.ByExcluding(e => e.Level == LogEventLevel.Debug && Matching.FromSource("Discord.WebSocket.DiscordShardedClient").Invoke(e) && e.MessageTemplate.Render(e.Properties).ContainsAny("Connected to", "Disconnected from"))
-            .Filter.ByExcluding(e => e.Level <= LogEventLevel.Debug && Matching.FromSource("Microsoft.Extensions.Http").Invoke(e))
+            .Filter.ByExcluding(e => e.Level <= LogEventLevel.Debug && (Matching.FromSource("Microsoft.Extensions.Http").Invoke(e) || Matching.FromSource("Microsoft.Extensions.Localization").Invoke(e)))
             .WriteTo.Console(LogEventLevel.Debug, theme: AnsiConsoleTheme.Literate)
             .WriteTo.Async(logger => logger.File("logs/log-.txt", LogEventLevel.Debug, rollingInterval: RollingInterval.Day));
     })
     .ConfigureServices(services =>
     {
+        services.AddLocalization(options => options.ResourcesPath = "Resources");
+        services.AddTransient(typeof(IFergunLocalizer<>), typeof(FergunLocalizer<>));
         services.AddHostedService<InteractionHandlingService>();
         services.AddSingleton(new InteractiveConfig { ReturnAfterSendingPaginator = true, DeferStopSelectionInteractions = false });
         services.AddSingleton<InteractiveService>();
diff --git a/src/Resources/Modules.ImageModule.es.resx b/src/Resources/Modules.ImageModule.es.resx
new file mode 100644
index 0000000..3b48858
--- /dev/null
+++ b/src/Resources/Modules.ImageModule.es.resx
@@ -0,0 +1,135 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+  <!-- 
+    Microsoft ResX Schema 
+    
+    Version 2.0
+    
+    The primary goals of this format is to allow a simple XML format 
+    that is mostly human readable. The generation and parsing of the 
+    various data types are done through the TypeConverter classes 
+    associated with the data types.
+    
+    Example:
+    
+    ... ado.net/XML headers & schema ...
+    <resheader name="resmimetype">text/microsoft-resx</resheader>
+    <resheader name="version">2.0</resheader>
+    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+        <value>[base64 mime encoded serialized .NET Framework object]</value>
+    </data>
+    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+        <comment>This is a comment</comment>
+    </data>
+                
+    There are any number of "resheader" rows that contain simple 
+    name/value pairs.
+    
+    Each data row contains a name, and value. The row also contains a 
+    type or mimetype. Type corresponds to a .NET class that support 
+    text/value conversion through the TypeConverter architecture. 
+    Classes that don't support this are serialized and stored with the 
+    mimetype set.
+    
+    The mimetype is used for serialized objects, and tells the 
+    ResXResourceReader how to depersist the object. This is currently not 
+    extensible. For a given mimetype the value must be set accordingly:
+    
+    Note - application/x-microsoft.net.object.binary.base64 is the format 
+    that the ResXResourceWriter will generate, however the reader can 
+    read any of the formats listed below.
+    
+    mimetype: application/x-microsoft.net.object.binary.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+            : and then encoded with base64 encoding.
+    
+    mimetype: application/x-microsoft.net.object.soap.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+            : and then encoded with base64 encoding.
+
+    mimetype: application/x-microsoft.net.object.bytearray.base64
+    value   : The object must be serialized into a byte array 
+            : using a System.ComponentModel.TypeConverter
+            : and then encoded with base64 encoding.
+    -->
+  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+    <xsd:element name="root" msdata:IsDataSet="true">
+      <xsd:complexType>
+        <xsd:choice maxOccurs="unbounded">
+          <xsd:element name="metadata">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" />
+              </xsd:sequence>
+              <xsd:attribute name="name" use="required" type="xsd:string" />
+              <xsd:attribute name="type" type="xsd:string" />
+              <xsd:attribute name="mimetype" type="xsd:string" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="assembly">
+            <xsd:complexType>
+              <xsd:attribute name="alias" type="xsd:string" />
+              <xsd:attribute name="name" type="xsd:string" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="data">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="resheader">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" />
+            </xsd:complexType>
+          </xsd:element>
+        </xsd:choice>
+      </xsd:complexType>
+    </xsd:element>
+  </xsd:schema>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>2.0</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <data name="Bing Visual Search | Page {0} of {1}" xml:space="preserve">
+    <value>Búsqueda Visual de Bing | Página {0} de {1}</value>
+  </data>
+  <data name="Brave image search" xml:space="preserve">
+    <value>Búsqueda de imágenes en Brave</value>
+  </data>
+  <data name="DuckDuckGo image search" xml:space="preserve">
+    <value>Búsqueda de imágenes en DuckDuckGo</value>
+  </data>
+  <data name="Google Images search" xml:space="preserve">
+    <value>Búsqueda de Google Imágenes</value>
+  </data>
+  <data name="Yandex Visual Search | Page {0} of {1}" xml:space="preserve">
+    <value>Búsqueda Visual de Yandex | Página {0} de {1}</value>
+  </data>
+</root>
\ No newline at end of file
diff --git a/src/Resources/Modules.OcrModule.es.resx b/src/Resources/Modules.OcrModule.es.resx
new file mode 100644
index 0000000..547e2d3
--- /dev/null
+++ b/src/Resources/Modules.OcrModule.es.resx
@@ -0,0 +1,144 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+  <!-- 
+    Microsoft ResX Schema 
+    
+    Version 2.0
+    
+    The primary goals of this format is to allow a simple XML format 
+    that is mostly human readable. The generation and parsing of the 
+    various data types are done through the TypeConverter classes 
+    associated with the data types.
+    
+    Example:
+    
+    ... ado.net/XML headers & schema ...
+    <resheader name="resmimetype">text/microsoft-resx</resheader>
+    <resheader name="version">2.0</resheader>
+    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+        <value>[base64 mime encoded serialized .NET Framework object]</value>
+    </data>
+    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+        <comment>This is a comment</comment>
+    </data>
+                
+    There are any number of "resheader" rows that contain simple 
+    name/value pairs.
+    
+    Each data row contains a name, and value. The row also contains a 
+    type or mimetype. Type corresponds to a .NET class that support 
+    text/value conversion through the TypeConverter architecture. 
+    Classes that don't support this are serialized and stored with the 
+    mimetype set.
+    
+    The mimetype is used for serialized objects, and tells the 
+    ResXResourceReader how to depersist the object. This is currently not 
+    extensible. For a given mimetype the value must be set accordingly:
+    
+    Note - application/x-microsoft.net.object.binary.base64 is the format 
+    that the ResXResourceWriter will generate, however the reader can 
+    read any of the formats listed below.
+    
+    mimetype: application/x-microsoft.net.object.binary.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+            : and then encoded with base64 encoding.
+    
+    mimetype: application/x-microsoft.net.object.soap.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+            : and then encoded with base64 encoding.
+
+    mimetype: application/x-microsoft.net.object.bytearray.base64
+    value   : The object must be serialized into a byte array 
+            : using a System.ComponentModel.TypeConverter
+            : and then encoded with base64 encoding.
+    -->
+  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+    <xsd:element name="root" msdata:IsDataSet="true">
+      <xsd:complexType>
+        <xsd:choice maxOccurs="unbounded">
+          <xsd:element name="metadata">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" />
+              </xsd:sequence>
+              <xsd:attribute name="name" use="required" type="xsd:string" />
+              <xsd:attribute name="type" type="xsd:string" />
+              <xsd:attribute name="mimetype" type="xsd:string" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="assembly">
+            <xsd:complexType>
+              <xsd:attribute name="alias" type="xsd:string" />
+              <xsd:attribute name="name" type="xsd:string" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="data">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="resheader">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" />
+            </xsd:complexType>
+          </xsd:element>
+        </xsd:choice>
+      </xsd:complexType>
+    </xsd:element>
+  </xsd:schema>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>2.0</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <data name="Bing Visual Search" xml:space="preserve">
+    <value>Búsqueda Visual de Bing</value>
+  </data>
+  <data name="OCR Results" xml:space="preserve">
+    <value>Resultados de OCR</value>
+  </data>
+  <data name="Select an OCR engine" xml:space="preserve">
+    <value>Selecciona un motor de OCR</value>
+  </data>
+  <data name="The OCR yielded no results." xml:space="preserve">
+    <value>El OCR no dio resultados.</value>
+  </data>
+  <data name="Translate" xml:space="preserve">
+    <value>Traducir</value>
+  </data>
+  <data name="Translate to {0}" xml:space="preserve">
+    <value>Traducir a {0}</value>
+  </data>
+  <data name="Yandex OCR" xml:space="preserve">
+    <value>OCR de Yandex</value>
+  </data>
+  <data name="{0} | Processing time: {1}ms" xml:space="preserve">
+    <value>{0} | Tiempo de procesado: {1}ms</value>
+  </data>
+</root>
\ No newline at end of file
diff --git a/src/Resources/Modules.UrbanModule.es.resx b/src/Resources/Modules.UrbanModule.es.resx
new file mode 100644
index 0000000..be1a94f
--- /dev/null
+++ b/src/Resources/Modules.UrbanModule.es.resx
@@ -0,0 +1,132 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+  <!-- 
+    Microsoft ResX Schema 
+    
+    Version 2.0
+    
+    The primary goals of this format is to allow a simple XML format 
+    that is mostly human readable. The generation and parsing of the 
+    various data types are done through the TypeConverter classes 
+    associated with the data types.
+    
+    Example:
+    
+    ... ado.net/XML headers & schema ...
+    <resheader name="resmimetype">text/microsoft-resx</resheader>
+    <resheader name="version">2.0</resheader>
+    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+        <value>[base64 mime encoded serialized .NET Framework object]</value>
+    </data>
+    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+        <comment>This is a comment</comment>
+    </data>
+                
+    There are any number of "resheader" rows that contain simple 
+    name/value pairs.
+    
+    Each data row contains a name, and value. The row also contains a 
+    type or mimetype. Type corresponds to a .NET class that support 
+    text/value conversion through the TypeConverter architecture. 
+    Classes that don't support this are serialized and stored with the 
+    mimetype set.
+    
+    The mimetype is used for serialized objects, and tells the 
+    ResXResourceReader how to depersist the object. This is currently not 
+    extensible. For a given mimetype the value must be set accordingly:
+    
+    Note - application/x-microsoft.net.object.binary.base64 is the format 
+    that the ResXResourceWriter will generate, however the reader can 
+    read any of the formats listed below.
+    
+    mimetype: application/x-microsoft.net.object.binary.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+            : and then encoded with base64 encoding.
+    
+    mimetype: application/x-microsoft.net.object.soap.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+            : and then encoded with base64 encoding.
+
+    mimetype: application/x-microsoft.net.object.bytearray.base64
+    value   : The object must be serialized into a byte array 
+            : using a System.ComponentModel.TypeConverter
+            : and then encoded with base64 encoding.
+    -->
+  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+    <xsd:element name="root" msdata:IsDataSet="true">
+      <xsd:complexType>
+        <xsd:choice maxOccurs="unbounded">
+          <xsd:element name="metadata">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" />
+              </xsd:sequence>
+              <xsd:attribute name="name" use="required" type="xsd:string" />
+              <xsd:attribute name="type" type="xsd:string" />
+              <xsd:attribute name="mimetype" type="xsd:string" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="assembly">
+            <xsd:complexType>
+              <xsd:attribute name="alias" type="xsd:string" />
+              <xsd:attribute name="name" type="xsd:string" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="data">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="resheader">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" />
+            </xsd:complexType>
+          </xsd:element>
+        </xsd:choice>
+      </xsd:complexType>
+    </xsd:element>
+  </xsd:schema>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>2.0</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <data name="By {0}" xml:space="preserve">
+    <value>Por {0}</value>
+  </data>
+  <data name="Urban Dictionary (Random Definitions) | Page {0} of {1}" xml:space="preserve">
+    <value>Urban Dictionary (Definiciones Aleatorias) | Página {0} de {1}</value>
+  </data>
+  <data name="Urban Dictionary (Words of the day, {0}) | Page {1} of {2}" xml:space="preserve">
+    <value>Urban Dictionary (Palabras del día, {0}) | Página {1} de {2}</value>
+  </data>
+  <data name="Urban Dictionary | Page {0} of {1}" xml:space="preserve">
+    <value>Urban Dictionary | Página {0} de {1}</value>
+  </data>
+</root>
\ No newline at end of file
diff --git a/src/Resources/Modules.UserModule.es.resx b/src/Resources/Modules.UserModule.es.resx
new file mode 100644
index 0000000..f237274
--- /dev/null
+++ b/src/Resources/Modules.UserModule.es.resx
@@ -0,0 +1,144 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+  <!-- 
+    Microsoft ResX Schema 
+    
+    Version 2.0
+    
+    The primary goals of this format is to allow a simple XML format 
+    that is mostly human readable. The generation and parsing of the 
+    various data types are done through the TypeConverter classes 
+    associated with the data types.
+    
+    Example:
+    
+    ... ado.net/XML headers & schema ...
+    <resheader name="resmimetype">text/microsoft-resx</resheader>
+    <resheader name="version">2.0</resheader>
+    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+        <value>[base64 mime encoded serialized .NET Framework object]</value>
+    </data>
+    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+        <comment>This is a comment</comment>
+    </data>
+                
+    There are any number of "resheader" rows that contain simple 
+    name/value pairs.
+    
+    Each data row contains a name, and value. The row also contains a 
+    type or mimetype. Type corresponds to a .NET class that support 
+    text/value conversion through the TypeConverter architecture. 
+    Classes that don't support this are serialized and stored with the 
+    mimetype set.
+    
+    The mimetype is used for serialized objects, and tells the 
+    ResXResourceReader how to depersist the object. This is currently not 
+    extensible. For a given mimetype the value must be set accordingly:
+    
+    Note - application/x-microsoft.net.object.binary.base64 is the format 
+    that the ResXResourceWriter will generate, however the reader can 
+    read any of the formats listed below.
+    
+    mimetype: application/x-microsoft.net.object.binary.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+            : and then encoded with base64 encoding.
+    
+    mimetype: application/x-microsoft.net.object.soap.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+            : and then encoded with base64 encoding.
+
+    mimetype: application/x-microsoft.net.object.bytearray.base64
+    value   : The object must be serialized into a byte array 
+            : using a System.ComponentModel.TypeConverter
+            : and then encoded with base64 encoding.
+    -->
+  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+    <xsd:element name="root" msdata:IsDataSet="true">
+      <xsd:complexType>
+        <xsd:choice maxOccurs="unbounded">
+          <xsd:element name="metadata">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" />
+              </xsd:sequence>
+              <xsd:attribute name="name" use="required" type="xsd:string" />
+              <xsd:attribute name="type" type="xsd:string" />
+              <xsd:attribute name="mimetype" type="xsd:string" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="assembly">
+            <xsd:complexType>
+              <xsd:attribute name="alias" type="xsd:string" />
+              <xsd:attribute name="name" type="xsd:string" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="data">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="resheader">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" />
+            </xsd:complexType>
+          </xsd:element>
+        </xsd:choice>
+      </xsd:complexType>
+    </xsd:element>
+  </xsd:schema>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>2.0</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <data name="Active Clients" xml:space="preserve">
+    <value>Clientes Activos</value>
+  </data>
+  <data name="Activities" xml:space="preserve">
+    <value>Actividades</value>
+  </data>
+  <data name="Boosting Since" xml:space="preserve">
+    <value>Mejorando desde</value>
+  </data>
+  <data name="Created At" xml:space="preserve">
+    <value>Creado En</value>
+  </data>
+  <data name="Is Bot" xml:space="preserve">
+    <value>Es Bot</value>
+  </data>
+  <data name="Name" xml:space="preserve">
+    <value>Nombre</value>
+  </data>
+  <data name="Server Join Date" xml:space="preserve">
+    <value>Fecha de ingreso al servidor</value>
+  </data>
+  <data name="User Info" xml:space="preserve">
+    <value>Información de usuario</value>
+  </data>
+</root>
\ No newline at end of file
diff --git a/src/Resources/Modules.UtilityModule.es.resx b/src/Resources/Modules.UtilityModule.es.resx
new file mode 100644
index 0000000..b2b5de0
--- /dev/null
+++ b/src/Resources/Modules.UtilityModule.es.resx
@@ -0,0 +1,180 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+  <!-- 
+    Microsoft ResX Schema 
+    
+    Version 2.0
+    
+    The primary goals of this format is to allow a simple XML format 
+    that is mostly human readable. The generation and parsing of the 
+    various data types are done through the TypeConverter classes 
+    associated with the data types.
+    
+    Example:
+    
+    ... ado.net/XML headers & schema ...
+    <resheader name="resmimetype">text/microsoft-resx</resheader>
+    <resheader name="version">2.0</resheader>
+    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+        <value>[base64 mime encoded serialized .NET Framework object]</value>
+    </data>
+    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+        <comment>This is a comment</comment>
+    </data>
+                
+    There are any number of "resheader" rows that contain simple 
+    name/value pairs.
+    
+    Each data row contains a name, and value. The row also contains a 
+    type or mimetype. Type corresponds to a .NET class that support 
+    text/value conversion through the TypeConverter architecture. 
+    Classes that don't support this are serialized and stored with the 
+    mimetype set.
+    
+    The mimetype is used for serialized objects, and tells the 
+    ResXResourceReader how to depersist the object. This is currently not 
+    extensible. For a given mimetype the value must be set accordingly:
+    
+    Note - application/x-microsoft.net.object.binary.base64 is the format 
+    that the ResXResourceWriter will generate, however the reader can 
+    read any of the formats listed below.
+    
+    mimetype: application/x-microsoft.net.object.binary.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+            : and then encoded with base64 encoding.
+    
+    mimetype: application/x-microsoft.net.object.soap.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+            : and then encoded with base64 encoding.
+
+    mimetype: application/x-microsoft.net.object.bytearray.base64
+    value   : The object must be serialized into a byte array 
+            : using a System.ComponentModel.TypeConverter
+            : and then encoded with base64 encoding.
+    -->
+  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+    <xsd:element name="root" msdata:IsDataSet="true">
+      <xsd:complexType>
+        <xsd:choice maxOccurs="unbounded">
+          <xsd:element name="metadata">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" />
+              </xsd:sequence>
+              <xsd:attribute name="name" use="required" type="xsd:string" />
+              <xsd:attribute name="type" type="xsd:string" />
+              <xsd:attribute name="mimetype" type="xsd:string" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="assembly">
+            <xsd:complexType>
+              <xsd:attribute name="alias" type="xsd:string" />
+              <xsd:attribute name="name" type="xsd:string" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="data">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="resheader">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" />
+            </xsd:complexType>
+          </xsd:element>
+        </xsd:choice>
+      </xsd:complexType>
+    </xsd:element>
+  </xsd:schema>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>2.0</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <data name="Bot Owner" xml:space="preserve">
+    <value>Dueño del bot</value>
+  </data>
+  <data name="Bot Version" xml:space="preserve">
+    <value>Versión del bot</value>
+  </data>
+  <data name="Command output" xml:space="preserve">
+    <value>Salida de comando</value>
+  </data>
+  <data name="CPU Usage" xml:space="preserve">
+    <value>Uso de CPU</value>
+  </data>
+  <data name="Fergun Stats" xml:space="preserve">
+    <value>Estadísticas de Fergun</value>
+  </data>
+  <data name="Fergun2Info" xml:space="preserve">
+    <value>Esto es Fergun 2. Fergun 2 es una reescritura completa de Fergun 1 y solo tendrá slash commands.
+Fergun 2 esta en estado alfa y sólo los comandos más usados están presentes, pero más comandos serán agregados pronto.
+Fergun 2 será termindo en Mayo 2022 e incluirá nuevas funcionalidades y comandos.
+
+Algunos módulos y comandos estan actualmente en modo de mantenimiento en Fergun 1 y no se migrarán a Fergun 2. Estos comandos son:
+- Módulo de **AI Dungeon**
+- Módulo de **Música**
+- Comandos de **snipe**
+
+Puedes encontrar más información sobre la eliminación de estos módulos/comandos [aquí]({0}).</value>
+  </data>
+  <data name="Language Chain" xml:space="preserve">
+    <value>Cadena de Idiomas</value>
+  </data>
+  <data name="Library" xml:space="preserve">
+    <value>Librería</value>
+  </data>
+  <data name="No output." xml:space="preserve">
+    <value>Sin salida.</value>
+  </data>
+  <data name="Operating System" xml:space="preserve">
+    <value>Sistema Operativo</value>
+  </data>
+  <data name="RAM Usage" xml:space="preserve">
+    <value>Uso de RAM</value>
+  </data>
+  <data name="Shard ID" xml:space="preserve">
+    <value>ID de shard</value>
+  </data>
+  <data name="The chain count must be between 2 and 10 (inclusive)." xml:space="preserve">
+    <value>El número de cadenas debe estar entre 2 y 10 (inclusivo).</value>
+  </data>
+  <data name="Total Servers" xml:space="preserve">
+    <value>Servidores Totales</value>
+  </data>
+  <data name="Total Users" xml:space="preserve">
+    <value>Usuarios Totales</value>
+  </data>
+  <data name="Uptime" xml:space="preserve">
+    <value>Tiempo activo</value>
+  </data>
+  <data name="Wikipedia Search | Page {0} of {1}" xml:space="preserve">
+    <value>Búsqueda de Wikipedia | Página {0} de {1}</value>
+  </data>
+</root>
\ No newline at end of file
diff --git a/src/Resources/Modules.UtilityModule.resx b/src/Resources/Modules.UtilityModule.resx
new file mode 100644
index 0000000..b531106
--- /dev/null
+++ b/src/Resources/Modules.UtilityModule.resx
@@ -0,0 +1,132 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+  <!-- 
+    Microsoft ResX Schema 
+    
+    Version 2.0
+    
+    The primary goals of this format is to allow a simple XML format 
+    that is mostly human readable. The generation and parsing of the 
+    various data types are done through the TypeConverter classes 
+    associated with the data types.
+    
+    Example:
+    
+    ... ado.net/XML headers & schema ...
+    <resheader name="resmimetype">text/microsoft-resx</resheader>
+    <resheader name="version">2.0</resheader>
+    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+        <value>[base64 mime encoded serialized .NET Framework object]</value>
+    </data>
+    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+        <comment>This is a comment</comment>
+    </data>
+                
+    There are any number of "resheader" rows that contain simple 
+    name/value pairs.
+    
+    Each data row contains a name, and value. The row also contains a 
+    type or mimetype. Type corresponds to a .NET class that support 
+    text/value conversion through the TypeConverter architecture. 
+    Classes that don't support this are serialized and stored with the 
+    mimetype set.
+    
+    The mimetype is used for serialized objects, and tells the 
+    ResXResourceReader how to depersist the object. This is currently not 
+    extensible. For a given mimetype the value must be set accordingly:
+    
+    Note - application/x-microsoft.net.object.binary.base64 is the format 
+    that the ResXResourceWriter will generate, however the reader can 
+    read any of the formats listed below.
+    
+    mimetype: application/x-microsoft.net.object.binary.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+            : and then encoded with base64 encoding.
+    
+    mimetype: application/x-microsoft.net.object.soap.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+            : and then encoded with base64 encoding.
+
+    mimetype: application/x-microsoft.net.object.bytearray.base64
+    value   : The object must be serialized into a byte array 
+            : using a System.ComponentModel.TypeConverter
+            : and then encoded with base64 encoding.
+    -->
+  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+    <xsd:element name="root" msdata:IsDataSet="true">
+      <xsd:complexType>
+        <xsd:choice maxOccurs="unbounded">
+          <xsd:element name="metadata">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" />
+              </xsd:sequence>
+              <xsd:attribute name="name" use="required" type="xsd:string" />
+              <xsd:attribute name="type" type="xsd:string" />
+              <xsd:attribute name="mimetype" type="xsd:string" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="assembly">
+            <xsd:complexType>
+              <xsd:attribute name="alias" type="xsd:string" />
+              <xsd:attribute name="name" type="xsd:string" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="data">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="resheader">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" />
+            </xsd:complexType>
+          </xsd:element>
+        </xsd:choice>
+      </xsd:complexType>
+    </xsd:element>
+  </xsd:schema>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>2.0</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <data name="Fergun2Info" xml:space="preserve">
+    <value>This is Fergun 2. Fergun 2 is a complete rewrite of Fergun 1 and it will only have slash commands.
+Fergun 2 is in alpha stages and only the most used commands are present, but more commands will be added soon.
+Fergun 2 will be finished in May 2022 and it will include new features and commands.
+
+Some modules and commands are currently in maintenance mode in Fergun 1 and they won't be migrated to Fergun 2. These modules are:
+- **AI Dungeon** module
+- **Music** module
+- **Snipe** commands
+
+You can find more info about the removals of these modules/commands [here]({0}).</value>
+  </data>
+</root>
\ No newline at end of file
diff --git a/src/Resources/SharedResource.es.resx b/src/Resources/SharedResource.es.resx
new file mode 100644
index 0000000..9608fb9
--- /dev/null
+++ b/src/Resources/SharedResource.es.resx
@@ -0,0 +1,171 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+  <!-- 
+    Microsoft ResX Schema 
+    
+    Version 2.0
+    
+    The primary goals of this format is to allow a simple XML format 
+    that is mostly human readable. The generation and parsing of the 
+    various data types are done through the TypeConverter classes 
+    associated with the data types.
+    
+    Example:
+    
+    ... ado.net/XML headers & schema ...
+    <resheader name="resmimetype">text/microsoft-resx</resheader>
+    <resheader name="version">2.0</resheader>
+    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+        <value>[base64 mime encoded serialized .NET Framework object]</value>
+    </data>
+    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+        <comment>This is a comment</comment>
+    </data>
+                
+    There are any number of "resheader" rows that contain simple 
+    name/value pairs.
+    
+    Each data row contains a name, and value. The row also contains a 
+    type or mimetype. Type corresponds to a .NET class that support 
+    text/value conversion through the TypeConverter architecture. 
+    Classes that don't support this are serialized and stored with the 
+    mimetype set.
+    
+    The mimetype is used for serialized objects, and tells the 
+    ResXResourceReader how to depersist the object. This is currently not 
+    extensible. For a given mimetype the value must be set accordingly:
+    
+    Note - application/x-microsoft.net.object.binary.base64 is the format 
+    that the ResXResourceWriter will generate, however the reader can 
+    read any of the formats listed below.
+    
+    mimetype: application/x-microsoft.net.object.binary.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+            : and then encoded with base64 encoding.
+    
+    mimetype: application/x-microsoft.net.object.soap.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+            : and then encoded with base64 encoding.
+
+    mimetype: application/x-microsoft.net.object.bytearray.base64
+    value   : The object must be serialized into a byte array 
+            : using a System.ComponentModel.TypeConverter
+            : and then encoded with base64 encoding.
+    -->
+  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+    <xsd:element name="root" msdata:IsDataSet="true">
+      <xsd:complexType>
+        <xsd:choice maxOccurs="unbounded">
+          <xsd:element name="metadata">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" />
+              </xsd:sequence>
+              <xsd:attribute name="name" use="required" type="xsd:string" />
+              <xsd:attribute name="type" type="xsd:string" />
+              <xsd:attribute name="mimetype" type="xsd:string" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="assembly">
+            <xsd:complexType>
+              <xsd:attribute name="alias" type="xsd:string" />
+              <xsd:attribute name="name" type="xsd:string" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="data">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="resheader">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" />
+            </xsd:complexType>
+          </xsd:element>
+        </xsd:choice>
+      </xsd:complexType>
+    </xsd:element>
+  </xsd:schema>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>2.0</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <data name="An error occurred." xml:space="preserve">
+    <value>Ocurrió un error.</value>
+  </data>
+  <data name="Error message: {0}" xml:space="preserve">
+    <value>Mensaje de error: {0}</value>
+  </data>
+  <data name="Invalid source language &quot;{0}&quot;." xml:space="preserve">
+    <value>Idioma de origen "{0}" inválido.</value>
+  </data>
+  <data name="Invalid target language &quot;{0}&quot;." xml:space="preserve">
+    <value>Idioma de destino "{0}" inválido.</value>
+  </data>
+  <data name="Language &quot;{0}&quot; not supported." xml:space="preserve">
+    <value>Idioma "{0}" no soportado.</value>
+  </data>
+  <data name="No results." xml:space="preserve">
+    <value>Sin resultados.</value>
+  </data>
+  <data name="None" xml:space="preserve">
+    <value>Nada</value>
+  </data>
+  <data name="Output" xml:space="preserve">
+    <value>Salida</value>
+  </data>
+  <data name="Page {0} of {1}" xml:space="preserve">
+    <value>Página {0} de {1}</value>
+  </data>
+  <data name="Result" xml:space="preserve">
+    <value>Resultado</value>
+  </data>
+  <data name="Source language" xml:space="preserve">
+    <value>Idioma de origen</value>
+  </data>
+  <data name="Source language (Detected)" xml:space="preserve">
+    <value>Idioma de origen (Detectado)</value>
+  </data>
+  <data name="Target language" xml:space="preserve">
+    <value>Idioma de destino</value>
+  </data>
+  <data name="The message must contain text." xml:space="preserve">
+    <value>El mensaje debe contener texto.</value>
+  </data>
+  <data name="The URL is not well formed." xml:space="preserve">
+    <value>La URL no está bien formada.</value>
+  </data>
+  <data name="Translation result" xml:space="preserve">
+    <value>Resultado de traducción</value>
+  </data>
+  <data name="Unable to get an image URL from the message." xml:space="preserve">
+    <value>No se puede obtener una URL de imagen del mensaje.</value>
+  </data>
+</root>
\ No newline at end of file
diff --git a/src/Services/InteractionHandlingService.cs b/src/Services/InteractionHandlingService.cs
index 7ad6353..f5e602d 100644
--- a/src/Services/InteractionHandlingService.cs
+++ b/src/Services/InteractionHandlingService.cs
@@ -1,9 +1,11 @@
-using System.Reflection;
+using System.Globalization;
+using System.Reflection;
 using Discord;
 using Discord.Interactions;
 using Discord.WebSocket;
 using Fergun.Extensions;
 using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Logging;
 
@@ -102,10 +104,13 @@ private async Task ContextMenuCommandExecuted(ContextCommandInfo contextCommand,
         await HandleInteractionErrorAsync(context, result);
     }
 
-    private static async Task HandleInteractionErrorAsync(IInteractionContext context, IResult result)
+    private async Task HandleInteractionErrorAsync(IInteractionContext context, IResult result)
     {
+        var localizer = _services.GetRequiredService<IFergunLocalizer<SharedResource>>();
+        localizer.CurrentCulture = new CultureInfo(context.Interaction.GetLanguageCode());
+
         string message = result.Error == InteractionCommandError.Exception
-            ? $"An error occurred.\n\nError message: ```{((ExecuteResult)result).Exception.Message}```"
+            ? $"{localizer["An error occurred."]}\n\n{localizer["Error message: {0}", $"```{((ExecuteResult)result).Exception.Message}```"]}"
             : result.ErrorReason;
 
         if (context.Interaction.HasResponded)
diff --git a/tests/Fergun.Tests/OcrModuleTests.cs b/tests/Fergun.Tests/OcrModuleTests.cs
index 812446a..195db00 100644
--- a/tests/Fergun.Tests/OcrModuleTests.cs
+++ b/tests/Fergun.Tests/OcrModuleTests.cs
@@ -7,6 +7,7 @@
 using Fergun.Apis.Yandex;
 using Fergun.Interactive;
 using Fergun.Modules;
+using Microsoft.Extensions.Localization;
 using Microsoft.Extensions.Logging;
 using Moq;
 using Xunit;
@@ -38,10 +39,16 @@ public OcrModuleTests()
         _yandexImageSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == _invalidImageUrl))).ThrowsAsync(new YandexException("Invalid image."));
 
         var sharedLogger = Mock.Of<ILogger<SharedModule>>();
-        var shared = new SharedModule(sharedLogger, new(), new());
+        var sharedLocalizer = new Mock<IFergunLocalizer<SharedResource>>();
+        sharedLocalizer.Setup(x => x[It.IsAny<string>()]).Returns<string>(s => new LocalizedString(s, s));
+        sharedLocalizer.Setup(x => x[It.IsAny<string>(), It.IsAny<object[]>()]).Returns<string, object[]>((s, p) => new LocalizedString(s, string.Format(s, p)));
+        var ocrLocalizer = new Mock<IFergunLocalizer<OcrModule>>();
+        ocrLocalizer.Setup(x => x[It.IsAny<string>()]).Returns<string>(s => new LocalizedString(s, s));
+        ocrLocalizer.Setup(x => x[It.IsAny<string>(), It.IsAny<object[]>()]).Returns<string, object[]>((s, p) => new LocalizedString(s, string.Format(s, p)));
+        var shared = new SharedModule(sharedLogger, sharedLocalizer.Object, new(), new());
 
         _interactive = new InteractiveService(_client, _interactiveConfig);
-        _ocrModuleMock = new Mock<OcrModule>(() => new OcrModule(_loggerMock.Object, shared, _interactive, _bingVisualSearchMock.Object, _yandexImageSearchMock.Object));
+        _ocrModuleMock = new Mock<OcrModule>(() => new OcrModule(_loggerMock.Object, ocrLocalizer.Object, shared, _interactive, _bingVisualSearchMock.Object, _yandexImageSearchMock.Object));
         _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
         ((IInteractionModuleBase)_ocrModuleMock.Object).SetContext(_contextMock.Object);
     }
diff --git a/tests/Fergun.Tests/UrbanModuleTests.cs b/tests/Fergun.Tests/UrbanModuleTests.cs
index 2620570..86d5227 100644
--- a/tests/Fergun.Tests/UrbanModuleTests.cs
+++ b/tests/Fergun.Tests/UrbanModuleTests.cs
@@ -11,6 +11,7 @@
 using Fergun.Apis.Urban;
 using Fergun.Interactive;
 using Fergun.Modules;
+using Microsoft.Extensions.Localization;
 using Moq;
 using Moq.Protected;
 using Xunit;
@@ -29,7 +30,11 @@ public class UrbanModuleTests
     public UrbanModuleTests()
     {
         var interactive = new InteractiveService(_client, _interactiveConfig);
-        _urbanModuleMock = new Mock<UrbanModule>(() => new UrbanModule(_urbanDictionaryMock.Object, interactive));
+        var urbanLocalizer = new Mock<IFergunLocalizer<UrbanModule>>();
+        urbanLocalizer.Setup(x => x[It.IsAny<string>()]).Returns<string>(s => new LocalizedString(s, s));
+        urbanLocalizer.Setup(x => x[It.IsAny<string>(), It.IsAny<object[]>()]).Returns<string, object[]>((s, p) => new LocalizedString(s, string.Format(s, p)));
+
+        _urbanModuleMock = new Mock<UrbanModule>(() => new UrbanModule(urbanLocalizer.Object, _urbanDictionaryMock.Object, interactive));
         _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
         _contextMock.SetupGet(x => x.User).Returns(() => AutoFaker.Generate<IUser>(b => b.WithBinder(new MoqBinder())));
         ((IInteractionModuleBase)_urbanModuleMock.Object).SetContext(_contextMock.Object);
diff --git a/tests/Fergun.Tests/UserModuleTests.cs b/tests/Fergun.Tests/UserModuleTests.cs
index 90bf1d8..bad43b8 100644
--- a/tests/Fergun.Tests/UserModuleTests.cs
+++ b/tests/Fergun.Tests/UserModuleTests.cs
@@ -6,6 +6,7 @@
 using Discord;
 using Discord.Interactions;
 using Fergun.Modules;
+using Microsoft.Extensions.Localization;
 using Moq;
 using Moq.Protected;
 using Xunit;
@@ -16,10 +17,15 @@ public class UserModuleTests
 {
     private readonly Mock<IInteractionContext> _contextMock = new();
     private readonly Mock<IDiscordInteraction> _interactionMock = new();
-    private readonly Mock<UserModule> _userModuleMock = new();
+    private readonly Mock<UserModule> _userModuleMock;
 
     public UserModuleTests()
     {
+        var userLocalizer = new Mock<IFergunLocalizer<UserModule>>();
+        userLocalizer.Setup(x => x[It.IsAny<string>()]).Returns<string>(s => new LocalizedString(s, s));
+        userLocalizer.Setup(x => x[It.IsAny<string>(), It.IsAny<object[]>()]).Returns<string, object[]>((s, p) => new LocalizedString(s, string.Format(s, p)));
+
+        _userModuleMock = new Mock<UserModule>(() => new UserModule(userLocalizer.Object));
         _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
         ((IInteractionModuleBase)_userModuleMock.Object).SetContext(_contextMock.Object);
     }

From 9465700ef93d5bb83ee405a8e75c2af2004dec95 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Mon, 11 Apr 2022 16:38:45 -0500
Subject: [PATCH 32/83] Use cached CultureInfo instances

---
 src/Modules/ImageModule.cs                 | 2 +-
 src/Modules/OcrModule.cs                   | 2 +-
 src/Modules/SharedModule.cs                | 4 ++--
 src/Modules/UrbanModule.cs                 | 2 +-
 src/Modules/UserModule.cs                  | 2 +-
 src/Modules/UtilityModule.cs               | 2 +-
 src/Services/InteractionHandlingService.cs | 2 +-
 7 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
index e094488..0b39dd6 100644
--- a/src/Modules/ImageModule.cs
+++ b/src/Modules/ImageModule.cs
@@ -42,7 +42,7 @@ public ImageModule(ILogger<ImageModule> logger, IFergunLocalizer<ImageModule> lo
         _yandexImageSearch = yandexImageSearch;
     }
 
-    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = new CultureInfo(Context.Interaction.GetLanguageCode());
+    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
 
     [SlashCommand("google", "Searches for images from Google Images and displays them in a paginator.")]
     public async Task Google([Autocomplete(typeof(GoogleAutocompleteHandler))][Summary(description: "The query to search.")] string query,
diff --git a/src/Modules/OcrModule.cs b/src/Modules/OcrModule.cs
index 0a15919..c16bdce 100644
--- a/src/Modules/OcrModule.cs
+++ b/src/Modules/OcrModule.cs
@@ -33,7 +33,7 @@ public OcrModule(ILogger<OcrModule> logger, IFergunLocalizer<OcrModule> localize
         _yandexImageSearch = yandexImageSearch;
     }
 
-    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = new CultureInfo(Context.Interaction.GetLanguageCode());
+    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
 
     [MessageCommand("OCR")]
     public async Task Ocr(IMessage message)
diff --git a/src/Modules/SharedModule.cs b/src/Modules/SharedModule.cs
index b0a7c2d..a06790c 100644
--- a/src/Modules/SharedModule.cs
+++ b/src/Modules/SharedModule.cs
@@ -29,7 +29,7 @@ public SharedModule(ILogger<SharedModule> logger, IFergunLocalizer<SharedResourc
 
     public async Task TranslateAsync(IDiscordInteraction interaction, string text, string target, string? source = null, bool ephemeral = false, bool deferLoad = false)
     {
-        _localizer.CurrentCulture = new CultureInfo(interaction.GetLanguageCode());
+        _localizer.CurrentCulture = CultureInfo.GetCultureInfo(interaction.GetLanguageCode());
 
         if (string.IsNullOrWhiteSpace(text))
         {
@@ -98,7 +98,7 @@ public async Task TranslateAsync(IDiscordInteraction interaction, string text, s
 
     public async Task TtsAsync(IDiscordInteraction interaction, string text, string? target = null, bool ephemeral = false, bool deferLoad = false)
     {
-        _localizer.CurrentCulture = new CultureInfo(interaction.GetLanguageCode());
+        _localizer.CurrentCulture = CultureInfo.GetCultureInfo(interaction.GetLanguageCode());
 
         if (string.IsNullOrWhiteSpace(text))
         {
diff --git a/src/Modules/UrbanModule.cs b/src/Modules/UrbanModule.cs
index 9dcfd40..b11006f 100644
--- a/src/Modules/UrbanModule.cs
+++ b/src/Modules/UrbanModule.cs
@@ -24,7 +24,7 @@ public UrbanModule(IFergunLocalizer<UrbanModule> localizer, IUrbanDictionary urb
         _interactive = interactive;
     }
 
-    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = new CultureInfo(Context.Interaction.GetLanguageCode());
+    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
 
     [SlashCommand("search", "Searches for definitions for a term in Urban Dictionary.")]
     public async Task Search([Autocomplete(typeof(UrbanAutocompleteHandler))] [Summary(description: "The term to search.")] string term)
diff --git a/src/Modules/UserModule.cs b/src/Modules/UserModule.cs
index d23fce5..463e25a 100644
--- a/src/Modules/UserModule.cs
+++ b/src/Modules/UserModule.cs
@@ -14,7 +14,7 @@ public UserModule(IFergunLocalizer<UserModule> localizer)
         _localizer = localizer;
     }
 
-    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = new CultureInfo(Context.Interaction.GetLanguageCode());
+    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
 
     [UserCommand("Avatar")]
     public async Task Avatar(IUser user)
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 6003a17..5598e9a 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -54,7 +54,7 @@ public UtilityModule(ILogger<UtilityModule> logger, IFergunLocalizer<UtilityModu
         _wikipediaClient = wikipediaClient;
     }
 
-    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = new CultureInfo(Context.Interaction.GetLanguageCode());
+    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
 
     [MessageCommand("Bad Translator")]
     public async Task BadTranslator(IMessage message)
diff --git a/src/Services/InteractionHandlingService.cs b/src/Services/InteractionHandlingService.cs
index f5e602d..aef5322 100644
--- a/src/Services/InteractionHandlingService.cs
+++ b/src/Services/InteractionHandlingService.cs
@@ -107,7 +107,7 @@ private async Task ContextMenuCommandExecuted(ContextCommandInfo contextCommand,
     private async Task HandleInteractionErrorAsync(IInteractionContext context, IResult result)
     {
         var localizer = _services.GetRequiredService<IFergunLocalizer<SharedResource>>();
-        localizer.CurrentCulture = new CultureInfo(context.Interaction.GetLanguageCode());
+        localizer.CurrentCulture = CultureInfo.GetCultureInfo(context.Interaction.GetLanguageCode());
 
         string message = result.Error == InteractionCommandError.Exception
             ? $"{localizer["An error occurred."]}\n\n{localizer["Error message: {0}", $"```{((ExecuteResult)result).Exception.Message}```"]}"

From 9f967b23e6ec6965f1bbba0351b5b88fe91b901a Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Mon, 11 Apr 2022 18:18:43 -0500
Subject: [PATCH 33/83] Add reverse image search context menu command

---
 src/Modules/ImageModule.cs                | 83 +++++++++++++++++++----
 src/Resources/Modules.ImageModule.es.resx |  3 +
 2 files changed, 72 insertions(+), 14 deletions(-)

diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
index 0b39dd6..e3b2207 100644
--- a/src/Modules/ImageModule.cs
+++ b/src/Modules/ImageModule.cs
@@ -6,6 +6,7 @@
 using Fergun.Extensions;
 using Fergun.Interactive;
 using Fergun.Interactive.Pagination;
+using Fergun.Interactive.Selection;
 using Fergun.Modules.Handlers;
 using GScraper;
 using GScraper.Brave;
@@ -20,7 +21,6 @@ public class ImageModule : InteractionModuleBase
 {
     private readonly ILogger<ImageModule> _logger;
     private readonly IFergunLocalizer<ImageModule> _localizer;
-    private readonly SharedModule _shared;
     private readonly InteractiveService _interactive;
     private readonly GoogleScraper _googleScraper;
     private readonly DuckDuckGoScraper _duckDuckGoScraper;
@@ -28,12 +28,11 @@ public class ImageModule : InteractionModuleBase
     private readonly IBingVisualSearch _bingVisualSearch;
     private readonly IYandexImageSearch _yandexImageSearch;
 
-    public ImageModule(ILogger<ImageModule> logger, IFergunLocalizer<ImageModule> localizer, SharedModule shared, InteractiveService interactive,
-        GoogleScraper googleScraper, DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper, IBingVisualSearch bingVisualSearch, IYandexImageSearch yandexImageSearch)
+    public ImageModule(ILogger<ImageModule> logger, IFergunLocalizer<ImageModule> localizer, InteractiveService interactive, GoogleScraper googleScraper,
+        DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper, IBingVisualSearch bingVisualSearch, IYandexImageSearch yandexImageSearch)
     {
         _logger = logger;
         _localizer = localizer;
-        _shared = shared;
         _interactive = interactive;
         _googleScraper = googleScraper;
         _duckDuckGoScraper = duckDuckGoScraper;
@@ -190,22 +189,70 @@ Task<PageBuilder> GeneratePageAsync(int index)
         }
     }
 
+    [MessageCommand("Reverse Image Search")]
+    public async Task Reverse(IMessage message)
+    {
+        var attachment = message.Attachments.FirstOrDefault();
+        var embed = message.Embeds.FirstOrDefault(x => x.Image is not null || x.Thumbnail is not null);
+
+        string? url = attachment?.Url ?? embed?.Image?.Url ?? embed?.Thumbnail?.Url;
+
+        if (url is null)
+        {
+            await Context.Interaction.RespondWarningAsync(_localizer["Unable to get an image URL from the message."], true);
+            return;
+        }
+
+        var page = new PageBuilder()
+            .WithTitle(_localizer["Select an image search engine"])
+            .WithColor(Color.Orange);
+
+        var selection = new SelectionBuilder<ReverseImageSearchEngine>()
+            .AddUser(Context.User)
+            .WithOptions(Enum.GetValues<ReverseImageSearchEngine>())
+            .WithSelectionPage(page)
+            .Build();
+
+        var result = await _interactive.SendSelectionAsync(selection, Context.Interaction, TimeSpan.FromMinutes(1), ephemeral: true);
+
+        // Attempt to disable the components
+        _ = Context.Interaction.ModifyOriginalResponseAsync(x => x.Components = selection.GetOrAddComponents(true).Build());
+
+        if (result.IsSuccess)
+        {
+            await ReverseAsync(url, result.Value, false, result.StopInteraction!, true);
+        }
+    }
+
     [SlashCommand("reverse", "Reverse image search.")]
     public async Task Reverse([Summary(description: "The url of an image.")] string url,
         [Summary(description: $"The search engine. The default is {nameof(ReverseImageSearchEngine.Yandex)}.")] ReverseImageSearchEngine engine = ReverseImageSearchEngine.Yandex,
         [Summary(description: "Whether to display multiple images in a single page.")] bool multiImages = false)
+    {
+        await ReverseAsync(url, engine, multiImages, Context.Interaction);
+    }
+
+    public async Task ReverseAsync(string url, ReverseImageSearchEngine engine, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false)
     {
         await (engine switch
         {
-            ReverseImageSearchEngine.Yandex => YandexAsync(url, multiImages),
-            ReverseImageSearchEngine.Bing => BingAsync(url, multiImages),
+            ReverseImageSearchEngine.Yandex => YandexAsync(url, multiImages, interaction, ephemeral),
+            ReverseImageSearchEngine.Bing => BingAsync(url, multiImages, interaction, ephemeral),
             _ => throw new ArgumentException("Invalid engine", nameof(engine))
         });
     }
 
-    public async Task YandexAsync(string url, bool multiImages)
+    public async Task YandexAsync(string url, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false)
     {
-        await DeferAsync();
+        if (interaction is IComponentInteraction componentInteraction)
+        {
+            await componentInteraction.DeferLoadingAsync(ephemeral);
+        }
+        else
+        {
+            await interaction.DeferAsync(ephemeral);
+        }
+
         bool isNsfw = Context.Channel.IsNsfw();
 
         var results = (await _yandexImageSearch.ReverseImageSearchAsync(url, isNsfw ? YandexSearchFilterMode.None : YandexSearchFilterMode.Family))
@@ -214,7 +261,7 @@ public async Task YandexAsync(string url, bool multiImages)
 
         if (results.Length == 0)
         {
-            await Context.Interaction.FollowupWarning(_localizer["No results."]);
+            await Context.Interaction.FollowupWarning(_localizer["No results."], ephemeral);
             return;
         }
 
@@ -228,7 +275,7 @@ public async Task YandexAsync(string url, bool multiImages)
             .AddUser(Context.User)
             .Build();
 
-        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+        await _interactive.SendPaginatorAsync(paginator, interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
 
         MultiEmbedPageBuilder GeneratePage(int index)
         {
@@ -245,9 +292,17 @@ MultiEmbedPageBuilder GeneratePage(int index)
         }
     }
 
-    public async Task BingAsync(string url, bool multiImages)
+    public async Task BingAsync(string url, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false)
     {
-        await DeferAsync();
+        if (interaction is IComponentInteraction componentInteraction)
+        {
+            await componentInteraction.DeferLoadingAsync(ephemeral);
+        }
+        else
+        {
+            await interaction.DeferAsync(ephemeral);
+        }
+
         bool isNsfw = Context.Channel.IsNsfw();
 
         var results = (await _bingVisualSearch.ReverseImageSearchAsync(url, !isNsfw))
@@ -256,7 +311,7 @@ public async Task BingAsync(string url, bool multiImages)
 
         if (results.Length == 0)
         {
-            await Context.Interaction.FollowupWarning(_localizer["No results."]);
+            await Context.Interaction.FollowupWarning(_localizer["No results."], ephemeral);
             return;
         }
 
@@ -270,7 +325,7 @@ public async Task BingAsync(string url, bool multiImages)
             .AddUser(Context.User)
             .Build();
 
-        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+        await _interactive.SendPaginatorAsync(paginator, interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
 
         MultiEmbedPageBuilder GeneratePage(int index)
         {
diff --git a/src/Resources/Modules.ImageModule.es.resx b/src/Resources/Modules.ImageModule.es.resx
index 3b48858..bf92aa5 100644
--- a/src/Resources/Modules.ImageModule.es.resx
+++ b/src/Resources/Modules.ImageModule.es.resx
@@ -129,6 +129,9 @@
   <data name="Google Images search" xml:space="preserve">
     <value>Búsqueda de Google Imágenes</value>
   </data>
+  <data name="Select an image search engine" xml:space="preserve">
+    <value>Selecciona un motor de búsqueda de imágenes</value>
+  </data>
   <data name="Yandex Visual Search | Page {0} of {1}" xml:space="preserve">
     <value>Búsqueda Visual de Yandex | Página {0} de {1}</value>
   </data>

From 2bb22bebda247f8a7356355257b287b122e4b275 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Tue, 12 Apr 2022 15:41:44 -0500
Subject: [PATCH 34/83] Add check for invalid images in Bing reverse image
 search

---
 src/Apis/Bing/BingVisualSearch.cs           | 14 ++++++++-
 src/Modules/ImageModule.cs                  | 34 +++++++++++++++++----
 src/Modules/OcrModule.cs                    |  2 +-
 tests/Fergun.Tests/BingVisualSearchTests.cs | 11 +++++++
 4 files changed, 53 insertions(+), 8 deletions(-)

diff --git a/src/Apis/Bing/BingVisualSearch.cs b/src/Apis/Bing/BingVisualSearch.cs
index 1ed7ea4..8091317 100644
--- a/src/Apis/Bing/BingVisualSearch.cs
+++ b/src/Apis/Bing/BingVisualSearch.cs
@@ -10,7 +10,7 @@ public sealed class BingVisualSearch : IBingVisualSearch, IDisposable
 {
     private static readonly Uri _apiEndpoint = new("https://www.bing.com/images/api/custom/knowledge/");
 
-    private static readonly Dictionary<string, string> _imageCategories = new()
+    private static readonly Dictionary<string, string> _imageCategories = new(5)
     {
         ["ImageByteSizeExceedsLimit"] = "Image size exceeds the limit (Max. 20MB)",
         ["ImageDimensionsExceedLimit"] = "Image dimensions exceeds the limit (Max. 4000px)",
@@ -101,6 +101,18 @@ public async Task<IEnumerable<BingReverseImageSearchResult>> ReverseImageSearchA
         await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
         using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
 
+        string? imageCategory = document
+            .RootElement
+            .GetPropertyOrDefault("imageQualityHints")
+            .FirstOrDefault()
+            .GetPropertyOrDefault("category")
+            .GetStringOrDefault();
+
+        if (imageCategory is not null && _imageCategories.TryGetValue(imageCategory, out var message))
+        {
+            throw new BingException(message);
+        }
+
         var root = document.RootElement.Clone();
 
         var rawItems = root
diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
index e3b2207..52e4da1 100644
--- a/src/Modules/ImageModule.cs
+++ b/src/Modules/ImageModule.cs
@@ -255,9 +255,20 @@ public async Task YandexAsync(string url, bool multiImages, IDiscordInteraction
 
         bool isNsfw = Context.Channel.IsNsfw();
 
-        var results = (await _yandexImageSearch.ReverseImageSearchAsync(url, isNsfw ? YandexSearchFilterMode.None : YandexSearchFilterMode.Family))
-            .Chunk(multiImages ? 4 : 1)
-            .ToArray();
+        IYandexReverseImageSearchResult[][] results;
+
+        try
+        {
+            results = (await _yandexImageSearch.ReverseImageSearchAsync(url, isNsfw ? YandexSearchFilterMode.None : YandexSearchFilterMode.Family))
+                .Chunk(multiImages ? 4 : 1)
+                .ToArray();
+        }
+        catch (YandexException e)
+        {
+            _logger.LogWarning(e, "Failed to perform reverse image search to url {url}", url);
+            await interaction.FollowupWarning(e.Message, ephemeral);
+            return;
+        }
 
         if (results.Length == 0)
         {
@@ -305,9 +316,20 @@ public async Task BingAsync(string url, bool multiImages, IDiscordInteraction in
 
         bool isNsfw = Context.Channel.IsNsfw();
 
-        var results = (await _bingVisualSearch.ReverseImageSearchAsync(url, !isNsfw))
-            .Chunk(multiImages ? 4 : 1)
-            .ToArray();
+        IBingReverseImageSearchResult[][] results;
+
+        try
+        {
+            results = (await _bingVisualSearch.ReverseImageSearchAsync(url, !isNsfw))
+                .Chunk(multiImages ? 4 : 1)
+                .ToArray();
+        }
+        catch (BingException e)
+        {
+            _logger.LogWarning(e, "Failed to perform reverse image search to url {url}", url);
+            await interaction.FollowupWarning(e.Message, ephemeral);
+            return;
+        }
 
         if (results.Length == 0)
         {
diff --git a/src/Modules/OcrModule.cs b/src/Modules/OcrModule.cs
index c16bdce..ab922c9 100644
--- a/src/Modules/OcrModule.cs
+++ b/src/Modules/OcrModule.cs
@@ -109,7 +109,7 @@ public async Task OcrAsync(OcrEngine ocrEngine, string url, IDiscordInteraction
         {
             text = await ocrTask;
         }
-        catch (Exception e)
+        catch (Exception e) when (e is BingException or YandexException)
         {
             _logger.LogWarning(e, "Failed to perform OCR to url {url}", url);
             await interaction.FollowupWarning(e.Message, ephemeral);
diff --git a/tests/Fergun.Tests/BingVisualSearchTests.cs b/tests/Fergun.Tests/BingVisualSearchTests.cs
index 55f8aea..a8a3955 100644
--- a/tests/Fergun.Tests/BingVisualSearchTests.cs
+++ b/tests/Fergun.Tests/BingVisualSearchTests.cs
@@ -48,6 +48,17 @@ public async Task ReverseImageSearchAsync_Returns_Results(string url, bool onlyF
         Assert.All(results, x => Assert.NotNull(x.ToString()));
     }
 
+    [Theory]
+    [InlineData("https://cdn.discordapp.com/attachments/838832564583661638/954475252027641886/tts.mp3")] // MP3 file
+    [InlineData("https://upload.wikimedia.org/wikipedia/commons/2/29/Suru_Bog_10000px.jpg")] // 10000px image
+    [InlineData("https://simpl.info/bigimage/bigImage.jpg")] // 91 MB file
+    public async Task ReverseImageSearchAsync_Throws_BingException_If_Image_Is_Invalid(string url)
+    {
+        var task = _bingVisualSearch.ReverseImageSearchAsync(url, true);
+
+        await Assert.ThrowsAsync<BingException>(() => task);
+    }
+
     [Fact]
     public async Task Disposed_UrbanDictionary_Usage_Throws_ObjectDisposedException()
     {

From 6ee41faf0652487c89dd5722876cf8e53bf6b266 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Tue, 12 Apr 2022 23:50:41 -0500
Subject: [PATCH 35/83] Fix safe search in Bing reverse image search

---
 src/Apis/Bing/BingSafeSearchLevel.cs        | 20 ++++++++++++
 src/Apis/Bing/BingVisualSearch.cs           | 35 +++++++--------------
 src/Apis/Bing/IBingVisualSearch.cs          |  4 +--
 src/Modules/ImageModule.cs                  | 11 +++----
 tests/Fergun.Tests/BingVisualSearchTests.cs | 13 ++++----
 5 files changed, 46 insertions(+), 37 deletions(-)
 create mode 100644 src/Apis/Bing/BingSafeSearchLevel.cs

diff --git a/src/Apis/Bing/BingSafeSearchLevel.cs b/src/Apis/Bing/BingSafeSearchLevel.cs
new file mode 100644
index 0000000..f2deecd
--- /dev/null
+++ b/src/Apis/Bing/BingSafeSearchLevel.cs
@@ -0,0 +1,20 @@
+namespace Fergun.Apis.Bing;
+
+/// <summary>
+/// Specifies the levels of safe search in Bing Visual Search.
+/// </summary>
+public enum BingSafeSearchLevel
+{
+    /// <summary>
+    /// Return images with adult content. The thumbnail images are clear (non-fuzzy).
+    /// </summary>
+    Off,
+    /// <summary>
+    /// Do not return images with adult content.
+    /// </summary>
+    Moderate,
+    /// <summary>
+    /// Do not return images with adult content.
+    /// </summary>
+    Strict
+}
\ No newline at end of file
diff --git a/src/Apis/Bing/BingVisualSearch.cs b/src/Apis/Bing/BingVisualSearch.cs
index 8091317..1c0aa06 100644
--- a/src/Apis/Bing/BingVisualSearch.cs
+++ b/src/Apis/Bing/BingVisualSearch.cs
@@ -14,9 +14,9 @@ public sealed class BingVisualSearch : IBingVisualSearch, IDisposable
     {
         ["ImageByteSizeExceedsLimit"] = "Image size exceeds the limit (Max. 20MB)",
         ["ImageDimensionsExceedLimit"] = "Image dimensions exceeds the limit (Max. 4000px)",
-        ["ImageDownloadFailed"] = "Image download failed",
-        ["ServiceUnavailable"] = "Bing Visual search is currently unavailable. Try again later",
-        ["UnknownFormat"] = "Unknown format (Only JPEG, PNG or BMP allowed)."
+        ["ImageDownloadFailed"] = "Image download failed.",
+        ["ServiceUnavailable"] = "Bing Visual search is currently unavailable. Try again later.",
+        ["UnknownFormat"] = "Unknown format. Try using JPEG, PNG, or BMP files."
     };
 
     private const string _sKey = "ZbQI4MYyHrlk2E7L-vIV2VLrieGlbMfV8FcK-WCY3ug";
@@ -89,11 +89,11 @@ public BingVisualSearch(HttpClient httpClient)
         return string.Join("\n\n", textRegions);
     }
 
-    /// <inheritdoc cref="IBingVisualSearch.ReverseImageSearchAsync(string, bool)"/>
-    public async Task<IEnumerable<BingReverseImageSearchResult>> ReverseImageSearchAsync(string url, bool onlyFamilyFriendly)
+    /// <inheritdoc cref="IBingVisualSearch.ReverseImageSearchAsync(string, BingSafeSearchLevel)"/>
+    public async Task<IEnumerable<BingReverseImageSearchResult>> ReverseImageSearchAsync(string url, BingSafeSearchLevel safeSearch = BingSafeSearchLevel.Moderate)
     {
         EnsureNotDisposed();
-        using var request = BuildRequest(url, "SimilarImages");
+        using var request = BuildRequest(url, "SimilarImages", safeSearch);
         using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
 
         response.EnsureSuccessStatusCode();
@@ -125,21 +125,10 @@ public async Task<IEnumerable<BingReverseImageSearchResult>> ReverseImageSearchA
             .GetPropertyOrDefault("value")
             .EnumerateArrayOrEmpty();
 
-        return EnumerateResults(rawItems, onlyFamilyFriendly);
+        return rawItems.Select(item => item.Deserialize<BingReverseImageSearchResult>()!);
     }
-
-    private static IEnumerable<BingReverseImageSearchResult> EnumerateResults(IEnumerable<JsonElement> rawItems, bool onlyFamilyFriendly)
-    {
-        foreach (var item in rawItems)
-        {
-            if (onlyFamilyFriendly && item.GetPropertyOrDefault("isFamilyFriendly").ValueKind == JsonValueKind.False)
-                continue;
-
-            yield return item.Deserialize<BingReverseImageSearchResult>()!;
-        }
-    }
-
-    private static HttpRequestMessage BuildRequest(string url, string invokedSkill)
+    //ImageBrqTag
+    private static HttpRequestMessage BuildRequest(string url, string invokedSkill, BingSafeSearchLevel safeSearch = BingSafeSearchLevel.Moderate)
     {
         string jsonRequest = $"{{\"imageInfo\":{{\"url\":\"{url}\",\"source\":\"Url\"}},\"knowledgeRequest\":{{\"invokedSkills\":[\"{invokedSkill}\"]}}}}";
         var content = new MultipartFormDataContent
@@ -150,7 +139,7 @@ private static HttpRequestMessage BuildRequest(string url, string invokedSkill)
         var request = new HttpRequestMessage
         {
             Method = HttpMethod.Post,
-            RequestUri = new Uri($"?skey={_sKey}", UriKind.Relative),
+            RequestUri = new Uri($"?skey={_sKey}&safeSearch={safeSearch}", UriKind.Relative),
             Content = content
         };
 
@@ -180,6 +169,6 @@ private void EnsureNotDisposed()
     }
 
     /// <inheritdoc/>
-    async Task<IEnumerable<IBingReverseImageSearchResult>> IBingVisualSearch.ReverseImageSearchAsync(string url, bool onlyFamilyFriendly)
-        => await ReverseImageSearchAsync(url, onlyFamilyFriendly).ConfigureAwait(false);
+    async Task<IEnumerable<IBingReverseImageSearchResult>> IBingVisualSearch.ReverseImageSearchAsync(string url, BingSafeSearchLevel safeSearch)
+        => await ReverseImageSearchAsync(url, safeSearch).ConfigureAwait(false);
 }
\ No newline at end of file
diff --git a/src/Apis/Bing/IBingVisualSearch.cs b/src/Apis/Bing/IBingVisualSearch.cs
index e412427..13e6bb7 100644
--- a/src/Apis/Bing/IBingVisualSearch.cs
+++ b/src/Apis/Bing/IBingVisualSearch.cs
@@ -16,7 +16,7 @@ public interface IBingVisualSearch
     /// Performs reverse image search to the specified image URL.
     /// </summary>
     /// <param name="url">The URL of an image.</param>
-    /// <param name="onlyFamilyFriendly">Whether to return only results that considered family friendly by Bing.</param>
+    /// <param name="safeSearch">The safe search level.</param>
     /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous search operation. The result contains an <see cref="IEnumerable{T}"/> of search results.</returns>
-    Task<IEnumerable<IBingReverseImageSearchResult>> ReverseImageSearchAsync(string url, bool onlyFamilyFriendly);
+    Task<IEnumerable<IBingReverseImageSearchResult>> ReverseImageSearchAsync(string url, BingSafeSearchLevel safeSearch);
 }
\ No newline at end of file
diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
index 52e4da1..ed77838 100644
--- a/src/Modules/ImageModule.cs
+++ b/src/Modules/ImageModule.cs
@@ -272,7 +272,7 @@ public async Task YandexAsync(string url, bool multiImages, IDiscordInteraction
 
         if (results.Length == 0)
         {
-            await Context.Interaction.FollowupWarning(_localizer["No results."], ephemeral);
+            await interaction.FollowupWarning(_localizer["No results."], ephemeral);
             return;
         }
 
@@ -283,7 +283,7 @@ public async Task YandexAsync(string url, bool multiImages, IDiscordInteraction
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(results.Length - 1)
             .WithFooter(PaginatorFooter.None)
-            .AddUser(Context.User)
+            .AddUser(interaction.User)
             .Build();
 
         await _interactive.SendPaginatorAsync(paginator, interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
@@ -315,12 +315,11 @@ public async Task BingAsync(string url, bool multiImages, IDiscordInteraction in
         }
 
         bool isNsfw = Context.Channel.IsNsfw();
-
         IBingReverseImageSearchResult[][] results;
 
         try
         {
-            results = (await _bingVisualSearch.ReverseImageSearchAsync(url, !isNsfw))
+            results = (await _bingVisualSearch.ReverseImageSearchAsync(url, isNsfw ? BingSafeSearchLevel.Off : BingSafeSearchLevel.Strict))
                 .Chunk(multiImages ? 4 : 1)
                 .ToArray();
         }
@@ -333,7 +332,7 @@ public async Task BingAsync(string url, bool multiImages, IDiscordInteraction in
 
         if (results.Length == 0)
         {
-            await Context.Interaction.FollowupWarning(_localizer["No results."], ephemeral);
+            await interaction.FollowupWarning(_localizer["No results."], ephemeral);
             return;
         }
 
@@ -344,7 +343,7 @@ public async Task BingAsync(string url, bool multiImages, IDiscordInteraction in
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(results.Length - 1)
             .WithFooter(PaginatorFooter.None)
-            .AddUser(Context.User)
+            .AddUser(interaction.User)
             .Build();
 
         await _interactive.SendPaginatorAsync(paginator, interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
diff --git a/tests/Fergun.Tests/BingVisualSearchTests.cs b/tests/Fergun.Tests/BingVisualSearchTests.cs
index a8a3955..c9b54ad 100644
--- a/tests/Fergun.Tests/BingVisualSearchTests.cs
+++ b/tests/Fergun.Tests/BingVisualSearchTests.cs
@@ -34,11 +34,12 @@ public async Task OcrAsync_Throws_BingException_If_Image_Is_Invalid(string url)
     }
 
     [Theory]
-    [InlineData("https://upload.wikimedia.org/wikipedia/commons/thumb/4/4d/Cat_November_2010-1a.jpg/1200px-Cat_November_2010-1a.jpg", true)]
-    [InlineData("https://upload.wikimedia.org/wikipedia/commons/1/18/Dog_Breeds.jpg", false)]
-    public async Task ReverseImageSearchAsync_Returns_Results(string url, bool onlyFamilyFriendly)
+    [InlineData("https://upload.wikimedia.org/wikipedia/commons/thumb/4/4d/Cat_November_2010-1a.jpg/1200px-Cat_November_2010-1a.jpg", BingSafeSearchLevel.Off)]
+    [InlineData("https://upload.wikimedia.org/wikipedia/commons/1/18/Dog_Breeds.jpg", BingSafeSearchLevel.Moderate)]
+    [InlineData("https://upload.wikimedia.org/wikipedia/commons/thumb/5/51/A_beautiful_landscape_of_nature.jpg/1024px-A_beautiful_landscape_of_nature.jpg", BingSafeSearchLevel.Strict)]
+    public async Task ReverseImageSearchAsync_Returns_Results(string url, BingSafeSearchLevel safeSearch)
     {
-        var results = (await _bingVisualSearch.ReverseImageSearchAsync(url, onlyFamilyFriendly)).ToArray();
+        var results = (await _bingVisualSearch.ReverseImageSearchAsync(url, safeSearch)).ToArray();
 
         Assert.NotNull(results);
         Assert.NotEmpty(results);
@@ -54,7 +55,7 @@ public async Task ReverseImageSearchAsync_Returns_Results(string url, bool onlyF
     [InlineData("https://simpl.info/bigimage/bigImage.jpg")] // 91 MB file
     public async Task ReverseImageSearchAsync_Throws_BingException_If_Image_Is_Invalid(string url)
     {
-        var task = _bingVisualSearch.ReverseImageSearchAsync(url, true);
+        var task = _bingVisualSearch.ReverseImageSearchAsync(url);
 
         await Assert.ThrowsAsync<BingException>(() => task);
     }
@@ -66,6 +67,6 @@ public async Task Disposed_UrbanDictionary_Usage_Throws_ObjectDisposedException(
         _bingVisualSearch.Dispose();
 
         await Assert.ThrowsAsync<ObjectDisposedException>(() => _bingVisualSearch.OcrAsync(It.IsAny<string>()));
-        await Assert.ThrowsAsync<ObjectDisposedException>(() => _bingVisualSearch.ReverseImageSearchAsync(It.IsAny<string>(), It.IsAny<bool>()));
+        await Assert.ThrowsAsync<ObjectDisposedException>(() => _bingVisualSearch.ReverseImageSearchAsync(It.IsAny<string>(), It.IsAny<BingSafeSearchLevel>()));
     }
 }
\ No newline at end of file

From 99d73c6ffb6247e925c456cc51ebab4c93a4ecb2 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Wed, 13 Apr 2022 16:10:56 -0500
Subject: [PATCH 36/83] Add friendly domain name/domain host to Bing reverse
 image search

---
 src/Apis/Bing/BingReverseImageSearchResult.cs  | 7 ++++++-
 src/Apis/Bing/IBingReverseImageSearchResult.cs | 5 +++++
 src/Modules/ImageModule.cs                     | 1 +
 3 files changed, 12 insertions(+), 1 deletion(-)

diff --git a/src/Apis/Bing/BingReverseImageSearchResult.cs b/src/Apis/Bing/BingReverseImageSearchResult.cs
index aee366b..ee90908 100644
--- a/src/Apis/Bing/BingReverseImageSearchResult.cs
+++ b/src/Apis/Bing/BingReverseImageSearchResult.cs
@@ -11,9 +11,10 @@ namespace Fergun.Apis.Bing;
 [DebuggerDisplay($"{{{nameof(DebuggerDisplay)}}}")]
 public class BingReverseImageSearchResult : IBingReverseImageSearchResult
 {
-    public BingReverseImageSearchResult(string url, string sourceUrl, string text, Color accentColor)
+    public BingReverseImageSearchResult(string url, string? friendlyDomainName, string sourceUrl, string text, Color accentColor)
     {
         Url = url;
+        FriendlyDomainName = friendlyDomainName;
         SourceUrl = sourceUrl;
         Text = text;
         AccentColor = accentColor;
@@ -23,6 +24,10 @@ public BingReverseImageSearchResult(string url, string sourceUrl, string text, C
     [JsonPropertyName("contentUrl")]
     public string Url { get; }
 
+    /// <inheritdoc/>
+    [JsonPropertyName("hostPageDomainFriendlyName")]
+    public string? FriendlyDomainName { get; }
+
     /// <inheritdoc/>
     [JsonPropertyName("hostPageUrl")]
     public string SourceUrl { get; }
diff --git a/src/Apis/Bing/IBingReverseImageSearchResult.cs b/src/Apis/Bing/IBingReverseImageSearchResult.cs
index 6f46271..a2b8d22 100644
--- a/src/Apis/Bing/IBingReverseImageSearchResult.cs
+++ b/src/Apis/Bing/IBingReverseImageSearchResult.cs
@@ -12,6 +12,11 @@ public interface IBingReverseImageSearchResult
     /// </summary>
     string Url { get; }
 
+    /// <summary>
+    /// Gets a friendly domain name.
+    /// </summary>
+    string? FriendlyDomainName { get; }
+
     /// <summary>
     /// Gets a URL pointing to the webpage hosting the image.
     /// </summary>
diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
index ed77838..8fbc01e 100644
--- a/src/Modules/ImageModule.cs
+++ b/src/Modules/ImageModule.cs
@@ -354,6 +354,7 @@ MultiEmbedPageBuilder GeneratePage(int index)
                 .WithTitle(result.Text)
                 .WithUrl(multiImages ? "https://www.bing.com/visualsearch" : result.SourceUrl)
                 .WithThumbnailUrl(url)
+                .WithDescription(result.FriendlyDomainName ?? (Uri.TryCreate(result.SourceUrl, UriKind.Absolute, out var uri) ? uri.Host : null))
                 .WithImageUrl(result.Url)
                 .WithFooter(_localizer["Bing Visual Search | Page {0} of {1}", index + 1, results.Length], Constants.BingIconUrl)
                 .WithColor((Color)result.AccentColor));

From b7a049cb42be3fb3063a3444c333a2a30d90d01a Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Fri, 15 Apr 2022 17:04:06 -0500
Subject: [PATCH 37/83] Add more tests and add multi-image parameter to image
 commands

---
 src/Modules/ImageModule.cs             |  86 +++++------
 tests/Fergun.Tests/ImageModuleTests.cs | 206 +++++++++++++++++++++++++
 tests/Fergun.Tests/UserModuleTests.cs  |  58 +------
 tests/Fergun.Tests/Utils.cs            | 133 ++++++++++++++++
 4 files changed, 384 insertions(+), 99 deletions(-)
 create mode 100644 tests/Fergun.Tests/ImageModuleTests.cs

diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
index 8fbc01e..1ed60ff 100644
--- a/src/Modules/ImageModule.cs
+++ b/src/Modules/ImageModule.cs
@@ -47,10 +47,10 @@ public ImageModule(ILogger<ImageModule> logger, IFergunLocalizer<ImageModule> lo
     public async Task Google([Autocomplete(typeof(GoogleAutocompleteHandler))][Summary(description: "The query to search.")] string query,
         [Summary(description: "Whether to display multiple images in a single page.")] bool multiImages = false)
     {
-        await DeferAsync();
+        await Context.Interaction.DeferAsync();
 
         bool isNsfw = Context.Channel.IsNsfw();
-        _logger.LogInformation(new EventId(0, "img"), "Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
+        _logger.LogInformation("Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
 
         var images = await _googleScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict, language: Context.Interaction.GetLanguageCode());
 
@@ -59,7 +59,7 @@ public async Task Google([Autocomplete(typeof(GoogleAutocompleteHandler))][Summa
             .Chunk(multiImages ? 4 : 1)
             .ToArray();
 
-        _logger.LogInformation(new EventId(0, "img"), "Image results: {count}", filteredImages.Length);
+        _logger.LogInformation("Image results: {count}", filteredImages.Length);
 
         if (filteredImages.Length == 0)
         {
@@ -94,98 +94,96 @@ MultiEmbedPageBuilder GeneratePage(int index)
     }
 
     [SlashCommand("duckduckgo", "Searches for images from DuckDuckGo and displays them in a paginator.")]
-    public async Task DuckDuckGo([Autocomplete(typeof(DuckDuckGoAutocompleteHandler))][Summary(description: "The query to search.")] string query)
+    public async Task DuckDuckGo([Autocomplete(typeof(DuckDuckGoAutocompleteHandler))][Summary(description: "The query to search.")] string query,
+        [Summary(description: "Whether to display multiple images in a single page.")] bool multiImages = false)
     {
-        await DeferAsync();
+        await Context.Interaction.DeferAsync();
 
         bool isNsfw = Context.Channel.IsNsfw();
-        _logger.LogInformation(new EventId(0, "img2"), "Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
-
-        var images = await _duckDuckGoScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict);
+        _logger.LogInformation("Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
 
-        var filteredImages = images
-            .Where(x => x.Url.StartsWith("http") && x.SourceUrl.StartsWith("http"))
+        var images = (await _duckDuckGoScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict))
+            .Chunk(multiImages ? 4 : 1)
             .ToArray();
 
-        _logger.LogInformation(new EventId(0, "img2"), "Image results: {count}", filteredImages.Length);
+        _logger.LogInformation("Image results: {count}", images.Length);
 
-        if (filteredImages.Length == 0)
+        if (images.Length == 0)
         {
             await Context.Interaction.FollowupWarning(_localizer["No results."]);
             return;
         }
 
         var paginator = new LazyPaginatorBuilder()
-            .WithPageFactory(GeneratePageAsync)
+            .WithPageFactory(GeneratePage)
             .WithFergunEmotes()
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
-            .WithMaxPageIndex(filteredImages.Length - 1)
+            .WithMaxPageIndex(images.Length - 1)
             .WithFooter(PaginatorFooter.None)
             .AddUser(Context.User)
             .Build();
 
         await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
-        Task<PageBuilder> GeneratePageAsync(int index)
+        MultiEmbedPageBuilder GeneratePage(int index)
         {
-            var pageBuilder = new PageBuilder()
-                .WithTitle(filteredImages[index].Title)
+            var builders = images[index].Select(result => new EmbedBuilder()
+                .WithTitle(result.Title)
                 .WithDescription(_localizer["DuckDuckGo image search"])
-                .WithUrl(filteredImages[index].SourceUrl)
-                .WithImageUrl(filteredImages[index].Url)
-                .WithFooter(_localizer["Page {0} of {1}", index + 1, filteredImages.Length], Constants.DuckDuckGoLogoUrl)
-                .WithColor(Color.Orange);
+                .WithUrl(multiImages ? "https://duckduckgo.com": result.SourceUrl)
+                .WithImageUrl(result.Url)
+                .WithFooter(_localizer["Page {0} of {1}", index + 1, images.Length], Constants.DuckDuckGoLogoUrl)
+                .WithColor(Color.Orange));
 
-            return Task.FromResult(pageBuilder);
+            return new MultiEmbedPageBuilder().WithBuilders(builders);
         }
     }
 
     [SlashCommand("brave", "Searches for images from Brave and displays them in a paginator.")]
-    public async Task Brave([Autocomplete(typeof(BraveAutocompleteHandler))][Summary(description: "The query to search.")] string query)
+    public async Task Brave([Autocomplete(typeof(BraveAutocompleteHandler))][Summary(description: "The query to search.")] string query,
+        [Summary(description: "Whether to display multiple images in a single page.")] bool multiImages = false)
     {
-        await DeferAsync();
+        await Context.Interaction.DeferAsync();
 
         bool isNsfw = Context.Channel.IsNsfw();
-        _logger.LogInformation(new EventId(0, "img3"), "Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
-
-        var images = await _braveScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict);
+        _logger.LogInformation("Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
 
-        var filteredImages = images
-            .Where(x => x.Url.StartsWith("http") && x.SourceUrl.StartsWith("http"))
+        var images = (await _braveScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict))
+            .Chunk(multiImages ? 4 : 1)
             .ToArray();
 
-        _logger.LogInformation(new EventId(0, "img3"), "Image results: {count}", filteredImages.Length);
+        _logger.LogInformation("Image results: {count}", images.Length);
 
-        if (filteredImages.Length == 0)
+        if (images.Length == 0)
         {
             await Context.Interaction.FollowupWarning(_localizer["No results."]);
             return;
         }
 
         var paginator = new LazyPaginatorBuilder()
-            .WithPageFactory(GeneratePageAsync)
+            .WithPageFactory(GeneratePage)
             .WithFergunEmotes()
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
-            .WithMaxPageIndex(filteredImages.Length - 1)
+            .WithMaxPageIndex(images.Length - 1)
             .WithFooter(PaginatorFooter.None)
             .AddUser(Context.User)
             .Build();
 
         await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
-        Task<PageBuilder> GeneratePageAsync(int index)
+        MultiEmbedPageBuilder GeneratePage(int index)
         {
-            var pageBuilder = new PageBuilder()
-                .WithTitle(filteredImages[index].Title)
+            var builders = images[index].Select(result => new EmbedBuilder()
+                .WithTitle(result.Title)
                 .WithDescription(_localizer["Brave image search"])
-                .WithUrl(filteredImages[index].SourceUrl)
-                .WithImageUrl(filteredImages[index].Url)
-                .WithFooter(_localizer["Page {0} of {1}", index + 1, filteredImages.Length], Constants.BraveLogoUrl)
-                .WithColor(Color.Orange);
+                .WithUrl(multiImages ? "https://search.brave.com" : result.SourceUrl)
+                .WithImageUrl(result.Url)
+                .WithFooter(_localizer["Page {0} of {1}", index + 1, images.Length], Constants.BraveLogoUrl)
+                .WithColor(Color.Orange));
 
-            return Task.FromResult(pageBuilder);
+            return new MultiEmbedPageBuilder().WithBuilders(builders);
         }
     }
 
@@ -238,11 +236,11 @@ public async Task ReverseAsync(string url, ReverseImageSearchEngine engine, bool
         {
             ReverseImageSearchEngine.Yandex => YandexAsync(url, multiImages, interaction, ephemeral),
             ReverseImageSearchEngine.Bing => BingAsync(url, multiImages, interaction, ephemeral),
-            _ => throw new ArgumentException("Invalid engine", nameof(engine))
+            _ => throw new ArgumentException("Invalid engine.", nameof(engine))
         });
     }
 
-    public async Task YandexAsync(string url, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false)
+    public virtual async Task YandexAsync(string url, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false)
     {
         if (interaction is IComponentInteraction componentInteraction)
         {
@@ -303,7 +301,7 @@ MultiEmbedPageBuilder GeneratePage(int index)
         }
     }
 
-    public async Task BingAsync(string url, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false)
+    public virtual async Task BingAsync(string url, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false)
     {
         if (interaction is IComponentInteraction componentInteraction)
         {
diff --git a/tests/Fergun.Tests/ImageModuleTests.cs b/tests/Fergun.Tests/ImageModuleTests.cs
new file mode 100644
index 0000000..ef62e2d
--- /dev/null
+++ b/tests/Fergun.Tests/ImageModuleTests.cs
@@ -0,0 +1,206 @@
+using System;
+using System.Threading.Tasks;
+using Discord;
+using Discord.Interactions;
+using Discord.WebSocket;
+using Fergun.Apis.Bing;
+using Fergun.Apis.Yandex;
+using Fergun.Interactive;
+using Fergun.Modules;
+using GScraper.Brave;
+using GScraper.DuckDuckGo;
+using GScraper.Google;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Fergun.Tests;
+
+public class ImageModuleTests
+{
+    private readonly Mock<IInteractionContext> _contextMock = new();
+    private readonly Mock<IDiscordInteraction> _interactionMock = new();
+    private readonly GoogleScraper _googleScraper = new();
+    private readonly DuckDuckGoScraper _duckDuckGoScraper = new();
+    private readonly BraveScraper _braveScraper = new();
+    private readonly IBingVisualSearch _bingVisualSearch = Utils.CreateMockedBingVisualSearchApi();
+    private readonly IYandexImageSearch _yandexImageSearch = Utils.CreateMockedYandexImageSearchApi();
+    private readonly DiscordSocketClient _client = new();
+    private readonly IFergunLocalizer<ImageModule> _localizer = Utils.CreateMockedLocalizer<ImageModule>();
+    private readonly Mock<ImageModule> _moduleMock;
+    private readonly ImageModule _module;
+
+    public ImageModuleTests()
+    {
+        var logger = Mock.Of<ILogger<ImageModule>>();
+        var interactive = new InteractiveService(_client, new InteractiveConfig { DeferStopSelectionInteractions = false, ReturnAfterSendingPaginator = true });
+        _moduleMock = new Mock<ImageModule>(() => new ImageModule(logger, _localizer, interactive, _googleScraper,
+            _duckDuckGoScraper, _braveScraper, _bingVisualSearch, _yandexImageSearch))
+        {
+            CallBase = true
+        };
+
+        _module = _moduleMock.Object;
+        _interactionMock.SetupGet(x => x.User).Returns(() => Utils.CreateMockedUser());
+        _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
+        ((IInteractionModuleBase)_moduleMock.Object).SetContext(_contextMock.Object);
+        _contextMock.SetupGet(x => x.User).Returns(_interactionMock.Object.User);
+    }
+
+    [Fact]
+    public void BeforeExecute_Sets_Language()
+    {
+        _interactionMock.SetupGet(x => x.UserLocale).Returns("en");
+        _module.BeforeExecute(It.IsAny<ICommandInfo>());
+        Assert.Equal("en", _localizer.CurrentCulture.TwoLetterISOLanguageName);
+    }
+
+    [Theory]
+    [InlineData("Discord", false, true)]
+    [InlineData("Google", true, false)]
+    public async Task Google_Sends_Paginator(string query, bool multiImages, bool nsfw)
+    {
+        var channel = new Mock<ITextChannel>();
+        channel.SetupGet(x => x.IsNsfw).Returns(nsfw);
+        _contextMock.SetupGet(x => x.Channel).Returns(channel.Object);
+
+        await _module.Google(query, multiImages);
+
+        _interactionMock.Verify(x => x.DeferAsync(It.IsAny<bool>(), It.IsAny<RequestOptions>()), Times.Once);
+        _contextMock.VerifyGet(x => x.Channel);
+        _interactionMock.VerifyGet(x => x.User);
+        channel.VerifyGet(x => x.IsNsfw);
+
+        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.IsAny<bool>(),
+            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
+    }
+
+    [Fact]
+    public async Task Google_Returns_No_Results()
+    {
+        await _module.Google(" ");
+
+        Mock.Get(_localizer).VerifyGet(x => x[It.Is<string>(y => y == "No results.")]);
+
+        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.IsAny<bool>(),
+            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
+    }
+
+    [Theory]
+    [InlineData("Discord", false, true)]
+    [InlineData("DuckDuckGo", true, false)]
+    public async Task DuckDuckGo_Sends_Paginator(string query, bool multiImages, bool nsfw)
+    {
+        var channel = new Mock<ITextChannel>();
+        channel.SetupGet(x => x.IsNsfw).Returns(nsfw);
+        _contextMock.SetupGet(x => x.Channel).Returns(channel.Object);
+
+        await _module.DuckDuckGo(query, multiImages);
+
+        _interactionMock.Verify(x => x.DeferAsync(It.IsAny<bool>(), It.IsAny<RequestOptions>()), Times.Once);
+        _contextMock.VerifyGet(x => x.Channel);
+        _interactionMock.VerifyGet(x => x.User);
+        channel.VerifyGet(x => x.IsNsfw);
+
+        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.IsAny<bool>(),
+            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
+    }
+
+    [Fact]
+    public async Task DuckDuckGo_Returns_No_Results()
+    {
+        await _module.DuckDuckGo("\u200b");
+
+        Mock.Get(_localizer).VerifyGet(x => x[It.Is<string>(y => y == "No results.")]);
+
+        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.IsAny<bool>(),
+            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
+    }
+
+    [Theory]
+    [InlineData("Discord", false, true)]
+    [InlineData("Brave", true, false)]
+    public async Task Brave_Sends_Paginator(string query, bool multiImages, bool nsfw)
+    {
+        var channel = new Mock<ITextChannel>();
+        channel.SetupGet(x => x.IsNsfw).Returns(nsfw);
+        _contextMock.SetupGet(x => x.Channel).Returns(channel.Object);
+
+        await _module.Brave(query, multiImages);
+
+        _interactionMock.Verify(x => x.DeferAsync(It.IsAny<bool>(), It.IsAny<RequestOptions>()), Times.Once);
+        _contextMock.VerifyGet(x => x.Channel);
+        _interactionMock.VerifyGet(x => x.User);
+        channel.VerifyGet(x => x.IsNsfw);
+
+        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.IsAny<bool>(),
+            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
+    }
+
+    [Fact]
+    public async Task Brave_Returns_No_Results()
+    {
+        await _module.Brave("\u200b");
+
+        Mock.Get(_localizer).VerifyGet(x => x[It.Is<string>(y => y == "No results.")]);
+
+        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.IsAny<bool>(),
+            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
+    }
+
+    [Theory]
+    [InlineData("https://example.com/image.png", ImageModule.ReverseImageSearchEngine.Bing, false, false)]
+    [InlineData("https://example.com/image.png", ImageModule.ReverseImageSearchEngine.Bing, true, true)]
+    [InlineData("", ImageModule.ReverseImageSearchEngine.Bing, true, false)]
+    [InlineData("https://example.com/image.png", ImageModule.ReverseImageSearchEngine.Yandex, false, false)]
+    [InlineData("https://example.com/image.png", ImageModule.ReverseImageSearchEngine.Yandex, true, true)]
+    [InlineData("", ImageModule.ReverseImageSearchEngine.Yandex, true, true)]
+    public async Task Reverse_Sends_Paginator(string url, ImageModule.ReverseImageSearchEngine engine, bool multiImages, bool nsfw)
+    {
+        var channel = new Mock<ITextChannel>();
+        channel.SetupGet(x => x.IsNsfw).Returns(nsfw);
+        _contextMock.SetupGet(x => x.Channel).Returns(channel.Object);
+
+        await _module.Reverse(url, engine, multiImages);
+
+        _contextMock.VerifyGet(x => x.Channel);
+        _interactionMock.VerifyGet(x => x.User);
+        channel.VerifyGet(x => x.IsNsfw);
+        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => !b), It.IsAny<RequestOptions>()), Times.Once);
+        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.IsAny<bool>(),
+            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
+
+        if (engine == ImageModule.ReverseImageSearchEngine.Bing)
+        {
+            _moduleMock.Verify(x => x.BingAsync(It.Is<string>(s => s == url), It.Is<bool>(b => b == multiImages), It.IsAny<IDiscordInteraction>(), It.Is<bool>(b => !b)), Times.Once);
+            Mock.Get(_bingVisualSearch).Verify(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == url), It.Is<BingSafeSearchLevel>(l => l == (nsfw ? BingSafeSearchLevel.Off : BingSafeSearchLevel.Strict))), Times.Once);
+        }
+        else if (engine == ImageModule.ReverseImageSearchEngine.Yandex)
+        {
+            _moduleMock.Verify(x => x.YandexAsync(It.Is<string>(s => s == url), It.Is<bool>(b => b == multiImages), It.IsAny<IDiscordInteraction>(), It.Is<bool>(b => !b)), Times.Once);
+            Mock.Get(_yandexImageSearch).Verify(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == url), It.Is<YandexSearchFilterMode>(l => l == (nsfw ? YandexSearchFilterMode.None : YandexSearchFilterMode.Family))), Times.Once);
+        }
+    }
+
+    [Fact]
+    public async Task Reverse_Throws_Exception_If_Invalid_Engine_Is_Passed()
+    {
+        await Assert.ThrowsAsync<ArgumentException>("engine", () => _module.Reverse(It.IsAny<string>(), (ImageModule.ReverseImageSearchEngine)2, It.IsAny<bool>()));
+    }
+
+    [Theory]
+    [InlineData(ImageModule.ReverseImageSearchEngine.Bing)]
+    [InlineData(ImageModule.ReverseImageSearchEngine.Yandex)]
+    public async Task Reverse_Throws_Exception_If_Invalid_Parameters_Are_Passed(ImageModule.ReverseImageSearchEngine engine)
+    {
+        var channel = new Mock<ITextChannel>();
+        channel.SetupGet(x => x.IsNsfw).Returns(false);
+        _contextMock.SetupGet(x => x.Channel).Returns(channel.Object);
+
+        await _module.Reverse(null!, engine, It.IsAny<bool>());
+
+        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => !b), It.IsAny<RequestOptions>()), Times.Once);
+        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => !b),
+            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.Is<Embed>(e => e.Description.EndsWith("Error message.")), It.IsAny<RequestOptions>()), Times.Once);
+    }
+}
\ No newline at end of file
diff --git a/tests/Fergun.Tests/UserModuleTests.cs b/tests/Fergun.Tests/UserModuleTests.cs
index bad43b8..08622a2 100644
--- a/tests/Fergun.Tests/UserModuleTests.cs
+++ b/tests/Fergun.Tests/UserModuleTests.cs
@@ -1,5 +1,4 @@
-using System;
-using System.Collections.Generic;
+using System.Collections.Generic;
 using System.Linq;
 using System.Threading.Tasks;
 using Bogus;
@@ -115,64 +114,13 @@ private static IEnumerable<object[]> GetFakeUsers()
     {
         var faker = new Faker();
 
-        return faker.MakeLazy(20, () =>
-        {
-            var userMock = new Mock<IUser>();
-
-            userMock.SetupGet(x => x.Username).Returns(faker.Internet.UserName());
-            userMock.SetupGet(x => x.DiscriminatorValue).Returns(faker.Random.UShort(1, 9999));
-            userMock.SetupGet(x => x.Discriminator).Returns(() => userMock.Object.DiscriminatorValue.ToString("D4"));
-            userMock.SetupGet(x => x.Activities).Returns(() => faker.MakeLazy(faker.Random.Number(3), () => new Game(faker.Hacker.IngVerb(), faker.Random.Enum(ActivityType.CustomStatus))
-                .OrDefault(faker, 0.5f, CreateCustomStatusGame(faker))).ToArray());
-            userMock.SetupGet(x => x.ActiveClients).Returns(() => faker.MakeLazy(faker.Random.Number(3),
-                () => faker.PickRandom(Enum.GetValues<ClientType>()).OrDefault(faker, 0.5f, (ClientType)3)).ToArray());
-            userMock.SetupGet(x => x.CreatedAt).Returns(() => faker.Date.PastOffset(5));
-            userMock.SetupGet(x => x.Id).Returns(() => faker.Random.ULong());
-            userMock.SetupGet(x => x.IsBot).Returns(() => faker.Random.Bool());
-            userMock.Setup(x => x.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>())).Returns(faker.Internet.Avatar().OrNull(faker));
-            userMock.Setup(x => x.GetDefaultAvatarUrl()).Returns(faker.Internet.Avatar());
-            userMock.Setup(x => x.ToString()).Returns(() => $"{userMock.Object.Username}#{userMock.Object.Discriminator}");
-
-            return userMock;
-        }).Select(x => new object[] { x });
+        return faker.MakeLazy(20, () => Utils.CreateMockedUser()).Select(x => new object[] { Mock.Get(x) });
     }
 
     private static IEnumerable<object[]> GetFakeGuildUsers()
     {
         var faker = new Faker();
 
-        return faker.MakeLazy(20, () =>
-        {
-            var userMock = new Mock<IGuildUser>();
-
-            userMock.SetupGet(x => x.Username).Returns(faker.Internet.UserName());
-            userMock.SetupGet(x => x.DiscriminatorValue).Returns(faker.Random.UShort(1, 9999));
-            userMock.SetupGet(x => x.Discriminator).Returns(() => userMock.Object.DiscriminatorValue.ToString("D4"));
-            userMock.SetupGet(x => x.Activities).Returns(() => faker.MakeLazy(faker.Random.Number(3), () => new Game(faker.Hacker.IngVerb(), faker.Random.Enum(ActivityType.CustomStatus))
-                .OrDefault(faker, 0.5f, CreateCustomStatusGame(faker))).ToArray());
-            userMock.SetupGet(x => x.ActiveClients).Returns(() => faker.MakeLazy(faker.Random.Number(3),
-                () => faker.PickRandom(Enum.GetValues<ClientType>()).OrDefault(faker, 0.5f, (ClientType)3)).ToArray());
-            userMock.SetupGet(x => x.CreatedAt).Returns(() => faker.Date.PastOffset(5));
-            userMock.SetupGet(x => x.Id).Returns(() => faker.Random.ULong());
-            userMock.SetupGet(x => x.IsBot).Returns(() => faker.Random.Bool());
-            userMock.SetupGet(x => x.Nickname).Returns(() => faker.Internet.UserName().OrNull(faker));
-            userMock.SetupGet(x => x.JoinedAt).Returns(() => faker.Date.PastOffset());
-            userMock.SetupGet(x => x.PremiumSince).Returns(() => faker.Date.PastOffset().OrNull(faker));
-            userMock.Setup(x => x.GetGuildAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>())).Returns(faker.Internet.Avatar());
-            userMock.Setup(x => x.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>())).Returns(faker.Internet.Avatar());
-            userMock.Setup(x => x.GetDefaultAvatarUrl()).Returns(faker.Internet.Avatar());
-            userMock.Setup(x => x.ToString()).Returns(() => $"{userMock.Object.Username}#{userMock.Object.Discriminator}");
-
-            return userMock;
-        }).Select(x => new object[] { x });
-    }
-
-    private static CustomStatusGame CreateCustomStatusGame(Faker faker)
-    {
-        var status = Utils.CreateInstance<CustomStatusGame>();
-        status.SetPropertyValue(x => x.Emote, Emote.Parse($"<:{faker.Random.String2(10)}:{faker.Random.ULong()}>"));
-        status.SetPropertyValue(x => x.State, faker.Hacker.IngVerb());
-        status.SetPropertyValue(x => x.Type, ActivityType.CustomStatus);
-        return status;
+        return faker.MakeLazy(20, () => Utils.CreateMockedGuildUser()).Select(x => new object[] { Mock.Get(x) });
     }
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Utils.cs b/tests/Fergun.Tests/Utils.cs
index e77c192..887c638 100644
--- a/tests/Fergun.Tests/Utils.cs
+++ b/tests/Fergun.Tests/Utils.cs
@@ -1,6 +1,13 @@
 using System;
 using System.Globalization;
+using System.Linq;
 using System.Reflection;
+using Bogus;
+using Discord;
+using Fergun.Apis.Bing;
+using Fergun.Apis.Yandex;
+using Microsoft.Extensions.Localization;
+using Moq;
 
 namespace Fergun.Tests;
 
@@ -8,4 +15,130 @@ internal static class Utils
 {
     public static T CreateInstance<T>(params object?[]? args) where T : class
         => (T)Activator.CreateInstance(typeof(T), BindingFlags.NonPublic | BindingFlags.Instance, null, args, CultureInfo.InvariantCulture)!;
+
+    public static IFergunLocalizer<T> CreateMockedLocalizer<T>()
+    {
+        var localizerMock = new Mock<IFergunLocalizer<T>>();
+        localizerMock.Setup(x => x[It.IsAny<string>()]).Returns<string>(s => new LocalizedString(s, s));
+        localizerMock.Setup(x => x[It.IsAny<string>(), It.IsAny<object[]>()]).Returns<string, object[]>((s, p) => new LocalizedString(s, string.Format(s, p)));
+        localizerMock.SetupAllProperties();
+
+        return localizerMock.Object;
+    }
+
+    public static IUser CreateMockedUser(Faker? faker = null)
+    {
+        faker ??= new Faker();
+        var userMock = new Mock<IUser>();
+
+        userMock.SetupGet(x => x.Username).Returns(faker.Internet.UserName());
+        userMock.SetupGet(x => x.DiscriminatorValue).Returns(faker.Random.UShort(1, 9999));
+        userMock.SetupGet(x => x.Discriminator).Returns(() => userMock.Object.DiscriminatorValue.ToString("D4"));
+        userMock.SetupGet(x => x.Activities).Returns(() => faker.MakeLazy(faker.Random.Number(3), () => new Game(faker.Hacker.IngVerb(), faker.Random.Enum(ActivityType.CustomStatus))
+            .OrDefault(faker, 0.5f, CreateCustomStatusGame(faker))).ToArray());
+        userMock.SetupGet(x => x.ActiveClients).Returns(() => faker.MakeLazy(faker.Random.Number(3),
+            () => faker.PickRandom(Enum.GetValues<ClientType>()).OrDefault(faker, 0.5f, (ClientType)3)).ToArray());
+        userMock.SetupGet(x => x.CreatedAt).Returns(() => faker.Date.PastOffset(5));
+        userMock.SetupGet(x => x.Id).Returns(() => faker.Random.ULong());
+        userMock.SetupGet(x => x.IsBot).Returns(() => faker.Random.Bool());
+        userMock.Setup(x => x.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>())).Returns(faker.Internet.Avatar().OrNull(faker));
+        userMock.Setup(x => x.GetDefaultAvatarUrl()).Returns(faker.Internet.Avatar());
+        userMock.Setup(x => x.ToString()).Returns(() => $"{userMock.Object.Username}#{userMock.Object.Discriminator}");
+
+        return userMock.Object;
+    }
+
+    public static IGuildUser CreateMockedGuildUser(Faker? faker = null)
+    {
+        faker ??= new Faker();
+        var userMock = new Mock<IGuildUser>();
+
+        userMock.SetupGet(x => x.Username).Returns(faker.Internet.UserName());
+        userMock.SetupGet(x => x.DiscriminatorValue).Returns(faker.Random.UShort(1, 9999));
+        userMock.SetupGet(x => x.Discriminator).Returns(() => userMock.Object.DiscriminatorValue.ToString("D4"));
+        userMock.SetupGet(x => x.Activities).Returns(() => faker.MakeLazy(faker.Random.Number(3), () => new Game(faker.Hacker.IngVerb(), faker.Random.Enum(ActivityType.CustomStatus))
+            .OrDefault(faker, 0.5f, CreateCustomStatusGame(faker))).ToArray());
+        userMock.SetupGet(x => x.ActiveClients).Returns(() => faker.MakeLazy(faker.Random.Number(3),
+            () => faker.PickRandom(Enum.GetValues<ClientType>()).OrDefault(faker, 0.5f, (ClientType)3)).ToArray());
+        userMock.SetupGet(x => x.CreatedAt).Returns(() => faker.Date.PastOffset(5));
+        userMock.SetupGet(x => x.Id).Returns(() => faker.Random.ULong());
+        userMock.SetupGet(x => x.IsBot).Returns(() => faker.Random.Bool());
+        userMock.SetupGet(x => x.Nickname).Returns(() => faker.Internet.UserName().OrNull(faker));
+        userMock.SetupGet(x => x.JoinedAt).Returns(() => faker.Date.PastOffset());
+        userMock.SetupGet(x => x.PremiumSince).Returns(() => faker.Date.PastOffset().OrNull(faker));
+        userMock.Setup(x => x.GetGuildAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>())).Returns(faker.Internet.Avatar());
+        userMock.Setup(x => x.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>())).Returns(faker.Internet.Avatar());
+        userMock.Setup(x => x.GetDefaultAvatarUrl()).Returns(faker.Internet.Avatar());
+        userMock.Setup(x => x.ToString()).Returns(() => $"{userMock.Object.Username}#{userMock.Object.Discriminator}");
+
+        return userMock.Object;
+    }
+
+    public static CustomStatusGame CreateCustomStatusGame(Faker? faker = null)
+    {
+        faker ??= new Faker();
+
+        var status = CreateInstance<CustomStatusGame>();
+        status.SetPropertyValue(x => x.Emote, Emote.Parse($"<:{faker.Random.String2(10)}:{faker.Random.ULong()}>"));
+        status.SetPropertyValue(x => x.State, faker.Hacker.IngVerb());
+        status.SetPropertyValue(x => x.Type, ActivityType.CustomStatus);
+        return status;
+    }
+
+    public static IBingVisualSearch CreateMockedBingVisualSearchApi(Faker? faker = null)
+    {
+        var bingMock = new Mock<IBingVisualSearch>();
+        faker ??= new Faker();
+
+        bingMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == null))).ThrowsAsync(new BingException("Error message."));
+        bingMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == string.Empty))).ReturnsAsync(() => string.Empty);
+        bingMock.Setup(x => x.OcrAsync(It.Is<string>(s => !string.IsNullOrEmpty(s)))).ReturnsAsync(() => faker.Lorem.Sentence());
+        bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == null), It.IsAny<BingSafeSearchLevel>())).ThrowsAsync(new BingException("Error message."));
+        bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == string.Empty), It.IsAny<BingSafeSearchLevel>())).ReturnsAsync(Enumerable.Empty<IBingReverseImageSearchResult>);
+        bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => !string.IsNullOrEmpty(s)), It.IsAny<BingSafeSearchLevel>())).ReturnsAsync(() => faker.MakeLazy(50, () => CreateMockedBingReverseImageSearchResult(faker)));
+
+        return bingMock.Object;
+    }
+
+    public static IBingReverseImageSearchResult CreateMockedBingReverseImageSearchResult(Faker? faker = null)
+    {
+        var resultMock = new Mock<IBingReverseImageSearchResult>();
+        faker = new Faker();
+
+        resultMock.SetupGet(x => x.Url).Returns(() => faker.Internet.Url());
+        resultMock.SetupGet(x => x.FriendlyDomainName).Returns(() => faker.Internet.DomainName().OrNull(faker));
+        resultMock.SetupGet(x => x.SourceUrl).Returns(() => faker.Internet.Url());
+        resultMock.SetupGet(x => x.Text).Returns(() => faker.Commerce.ProductDescription());
+        resultMock.SetupGet(x => x.AccentColor).Returns(() => System.Drawing.Color.FromArgb(faker.Random.Number((int)Color.MaxDecimalValue)));
+
+        return resultMock.Object;
+    }
+
+    public static IYandexImageSearch CreateMockedYandexImageSearchApi(Faker? faker = null)
+    {
+        var yandexMock = new Mock<IYandexImageSearch>();
+        faker ??= new Faker();
+
+        yandexMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == null))).ThrowsAsync(new YandexException("Error message."));
+        yandexMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == string.Empty))).ReturnsAsync(() => string.Empty);
+        yandexMock.Setup(x => x.OcrAsync(It.Is<string>(s => !string.IsNullOrEmpty(s)))).ReturnsAsync(() => faker.Lorem.Sentence());
+        yandexMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == null), It.IsAny<YandexSearchFilterMode>())).ThrowsAsync(new YandexException("Error message."));
+        yandexMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == string.Empty), It.IsAny<YandexSearchFilterMode>())).ReturnsAsync(Enumerable.Empty<IYandexReverseImageSearchResult>);
+        yandexMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => !string.IsNullOrEmpty(s)), It.IsAny<YandexSearchFilterMode>())).ReturnsAsync(() => faker.MakeLazy(50, () => CreateMockedYandexReverseImageSearchResult(faker)));
+
+        return yandexMock.Object;
+    }
+
+    public static IYandexReverseImageSearchResult CreateMockedYandexReverseImageSearchResult(Faker? faker = null)
+    {
+        var resultMock = new Mock<IYandexReverseImageSearchResult>();
+        faker = new Faker();
+
+        resultMock.SetupGet(x => x.Url).Returns(() => faker.Internet.Url());
+        resultMock.SetupGet(x => x.SourceUrl).Returns(() => faker.Internet.Url());
+        resultMock.SetupGet(x => x.Title).Returns(() => faker.Commerce.ProductName());
+        resultMock.SetupGet(x => x.Text).Returns(() => faker.Commerce.ProductDescription());
+
+        return resultMock.Object;
+    }
 }
\ No newline at end of file

From 25404c7bb79f8511a11a24b73715efdbcc21baec Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 16 Apr 2022 02:01:40 -0500
Subject: [PATCH 38/83] Add support for language-based results to Bing reverse
 image search

---
 src/Apis/Bing/BingVisualSearch.cs           | 17 +++++++++--------
 src/Apis/Bing/IBingVisualSearch.cs          |  4 +++-
 src/Modules/ImageModule.cs                  |  2 +-
 tests/Fergun.Tests/BingVisualSearchTests.cs | 12 ++++++------
 tests/Fergun.Tests/ImageModuleTests.cs      |  3 ++-
 tests/Fergun.Tests/Utils.cs                 |  6 +++---
 6 files changed, 24 insertions(+), 20 deletions(-)

diff --git a/src/Apis/Bing/BingVisualSearch.cs b/src/Apis/Bing/BingVisualSearch.cs
index 1c0aa06..565c35f 100644
--- a/src/Apis/Bing/BingVisualSearch.cs
+++ b/src/Apis/Bing/BingVisualSearch.cs
@@ -89,11 +89,12 @@ public BingVisualSearch(HttpClient httpClient)
         return string.Join("\n\n", textRegions);
     }
 
-    /// <inheritdoc cref="IBingVisualSearch.ReverseImageSearchAsync(string, BingSafeSearchLevel)"/>
-    public async Task<IEnumerable<BingReverseImageSearchResult>> ReverseImageSearchAsync(string url, BingSafeSearchLevel safeSearch = BingSafeSearchLevel.Moderate)
+    /// <inheritdoc cref="IBingVisualSearch.ReverseImageSearchAsync(string, BingSafeSearchLevel, string)"/>
+    public async Task<IEnumerable<BingReverseImageSearchResult>> ReverseImageSearchAsync(string url,
+        BingSafeSearchLevel safeSearch = BingSafeSearchLevel.Moderate, string? language = null)
     {
         EnsureNotDisposed();
-        using var request = BuildRequest(url, "SimilarImages", safeSearch);
+        using var request = BuildRequest(url, "SimilarImages", safeSearch, language);
         using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
 
         response.EnsureSuccessStatusCode();
@@ -127,8 +128,8 @@ public async Task<IEnumerable<BingReverseImageSearchResult>> ReverseImageSearchA
 
         return rawItems.Select(item => item.Deserialize<BingReverseImageSearchResult>()!);
     }
-    //ImageBrqTag
-    private static HttpRequestMessage BuildRequest(string url, string invokedSkill, BingSafeSearchLevel safeSearch = BingSafeSearchLevel.Moderate)
+
+    private static HttpRequestMessage BuildRequest(string url, string invokedSkill, BingSafeSearchLevel safeSearch = BingSafeSearchLevel.Moderate, string? language = null)
     {
         string jsonRequest = $"{{\"imageInfo\":{{\"url\":\"{url}\",\"source\":\"Url\"}},\"knowledgeRequest\":{{\"invokedSkills\":[\"{invokedSkill}\"]}}}}";
         var content = new MultipartFormDataContent
@@ -139,7 +140,7 @@ private static HttpRequestMessage BuildRequest(string url, string invokedSkill,
         var request = new HttpRequestMessage
         {
             Method = HttpMethod.Post,
-            RequestUri = new Uri($"?skey={_sKey}&safeSearch={safeSearch}", UriKind.Relative),
+            RequestUri = new Uri($"?skey={_sKey}&safeSearch={safeSearch}{(language is null ? string.Empty : $"&setLang={language}")}", UriKind.Relative),
             Content = content
         };
 
@@ -169,6 +170,6 @@ private void EnsureNotDisposed()
     }
 
     /// <inheritdoc/>
-    async Task<IEnumerable<IBingReverseImageSearchResult>> IBingVisualSearch.ReverseImageSearchAsync(string url, BingSafeSearchLevel safeSearch)
-        => await ReverseImageSearchAsync(url, safeSearch).ConfigureAwait(false);
+    async Task<IEnumerable<IBingReverseImageSearchResult>> IBingVisualSearch.ReverseImageSearchAsync(string url, BingSafeSearchLevel safeSearch, string? language)
+        => await ReverseImageSearchAsync(url, safeSearch, language).ConfigureAwait(false);
 }
\ No newline at end of file
diff --git a/src/Apis/Bing/IBingVisualSearch.cs b/src/Apis/Bing/IBingVisualSearch.cs
index 13e6bb7..607d6b0 100644
--- a/src/Apis/Bing/IBingVisualSearch.cs
+++ b/src/Apis/Bing/IBingVisualSearch.cs
@@ -17,6 +17,8 @@ public interface IBingVisualSearch
     /// </summary>
     /// <param name="url">The URL of an image.</param>
     /// <param name="safeSearch">The safe search level.</param>
+    /// <param name="language">The language of the results.</param>
     /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous search operation. The result contains an <see cref="IEnumerable{T}"/> of search results.</returns>
-    Task<IEnumerable<IBingReverseImageSearchResult>> ReverseImageSearchAsync(string url, BingSafeSearchLevel safeSearch);
+    Task<IEnumerable<IBingReverseImageSearchResult>> ReverseImageSearchAsync(string url,
+        BingSafeSearchLevel safeSearch = BingSafeSearchLevel.Moderate, string? language = null);
 }
\ No newline at end of file
diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
index 1ed60ff..4daa701 100644
--- a/src/Modules/ImageModule.cs
+++ b/src/Modules/ImageModule.cs
@@ -317,7 +317,7 @@ public virtual async Task BingAsync(string url, bool multiImages, IDiscordIntera
 
         try
         {
-            results = (await _bingVisualSearch.ReverseImageSearchAsync(url, isNsfw ? BingSafeSearchLevel.Off : BingSafeSearchLevel.Strict))
+            results = (await _bingVisualSearch.ReverseImageSearchAsync(url, isNsfw ? BingSafeSearchLevel.Off : BingSafeSearchLevel.Strict, interaction.GetLanguageCode()))
                 .Chunk(multiImages ? 4 : 1)
                 .ToArray();
         }
diff --git a/tests/Fergun.Tests/BingVisualSearchTests.cs b/tests/Fergun.Tests/BingVisualSearchTests.cs
index c9b54ad..efe3c37 100644
--- a/tests/Fergun.Tests/BingVisualSearchTests.cs
+++ b/tests/Fergun.Tests/BingVisualSearchTests.cs
@@ -34,12 +34,12 @@ public async Task OcrAsync_Throws_BingException_If_Image_Is_Invalid(string url)
     }
 
     [Theory]
-    [InlineData("https://upload.wikimedia.org/wikipedia/commons/thumb/4/4d/Cat_November_2010-1a.jpg/1200px-Cat_November_2010-1a.jpg", BingSafeSearchLevel.Off)]
-    [InlineData("https://upload.wikimedia.org/wikipedia/commons/1/18/Dog_Breeds.jpg", BingSafeSearchLevel.Moderate)]
-    [InlineData("https://upload.wikimedia.org/wikipedia/commons/thumb/5/51/A_beautiful_landscape_of_nature.jpg/1024px-A_beautiful_landscape_of_nature.jpg", BingSafeSearchLevel.Strict)]
-    public async Task ReverseImageSearchAsync_Returns_Results(string url, BingSafeSearchLevel safeSearch)
+    [InlineData("https://bingvsdevportalprodgbl.blob.core.windows.net/demo-images/876bb7a8-e8dd-4e36-ab3a-f0b9aba942e5.jpg", BingSafeSearchLevel.Off, null)]
+    [InlineData("https://bingvsdevportalprodgbl.blob.core.windows.net/demo-images/391126cd-977a-43c7-9937-4f139623cd58.jpeg", BingSafeSearchLevel.Moderate, "en")]
+    [InlineData("https://bingvsdevportalprodgbl.blob.core.windows.net/demo-images/5a5e947c-c248-4e4c-a717-d1f798ddb1ba.jpeg", BingSafeSearchLevel.Strict, "es")]
+    public async Task ReverseImageSearchAsync_Returns_Results(string url, BingSafeSearchLevel safeSearch, string? language)
     {
-        var results = (await _bingVisualSearch.ReverseImageSearchAsync(url, safeSearch)).ToArray();
+        var results = (await _bingVisualSearch.ReverseImageSearchAsync(url, safeSearch, language)).ToArray();
 
         Assert.NotNull(results);
         Assert.NotEmpty(results);
@@ -67,6 +67,6 @@ public async Task Disposed_UrbanDictionary_Usage_Throws_ObjectDisposedException(
         _bingVisualSearch.Dispose();
 
         await Assert.ThrowsAsync<ObjectDisposedException>(() => _bingVisualSearch.OcrAsync(It.IsAny<string>()));
-        await Assert.ThrowsAsync<ObjectDisposedException>(() => _bingVisualSearch.ReverseImageSearchAsync(It.IsAny<string>(), It.IsAny<BingSafeSearchLevel>()));
+        await Assert.ThrowsAsync<ObjectDisposedException>(() => _bingVisualSearch.ReverseImageSearchAsync(It.IsAny<string>(), It.IsAny<BingSafeSearchLevel>(), It.IsAny<string?>()));
     }
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/ImageModuleTests.cs b/tests/Fergun.Tests/ImageModuleTests.cs
index ef62e2d..dcd5f64 100644
--- a/tests/Fergun.Tests/ImageModuleTests.cs
+++ b/tests/Fergun.Tests/ImageModuleTests.cs
@@ -160,6 +160,7 @@ public async Task Reverse_Sends_Paginator(string url, ImageModule.ReverseImageSe
         var channel = new Mock<ITextChannel>();
         channel.SetupGet(x => x.IsNsfw).Returns(nsfw);
         _contextMock.SetupGet(x => x.Channel).Returns(channel.Object);
+        _interactionMock.SetupGet(x => x.UserLocale).Returns("en");
 
         await _module.Reverse(url, engine, multiImages);
 
@@ -173,7 +174,7 @@ public async Task Reverse_Sends_Paginator(string url, ImageModule.ReverseImageSe
         if (engine == ImageModule.ReverseImageSearchEngine.Bing)
         {
             _moduleMock.Verify(x => x.BingAsync(It.Is<string>(s => s == url), It.Is<bool>(b => b == multiImages), It.IsAny<IDiscordInteraction>(), It.Is<bool>(b => !b)), Times.Once);
-            Mock.Get(_bingVisualSearch).Verify(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == url), It.Is<BingSafeSearchLevel>(l => l == (nsfw ? BingSafeSearchLevel.Off : BingSafeSearchLevel.Strict))), Times.Once);
+            Mock.Get(_bingVisualSearch).Verify(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == url), It.Is<BingSafeSearchLevel>(l => l == (nsfw ? BingSafeSearchLevel.Off : BingSafeSearchLevel.Strict)), It.IsAny<string>()), Times.Once);
         }
         else if (engine == ImageModule.ReverseImageSearchEngine.Yandex)
         {
diff --git a/tests/Fergun.Tests/Utils.cs b/tests/Fergun.Tests/Utils.cs
index 887c638..c5b001a 100644
--- a/tests/Fergun.Tests/Utils.cs
+++ b/tests/Fergun.Tests/Utils.cs
@@ -93,9 +93,9 @@ public static IBingVisualSearch CreateMockedBingVisualSearchApi(Faker? faker = n
         bingMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == null))).ThrowsAsync(new BingException("Error message."));
         bingMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == string.Empty))).ReturnsAsync(() => string.Empty);
         bingMock.Setup(x => x.OcrAsync(It.Is<string>(s => !string.IsNullOrEmpty(s)))).ReturnsAsync(() => faker.Lorem.Sentence());
-        bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == null), It.IsAny<BingSafeSearchLevel>())).ThrowsAsync(new BingException("Error message."));
-        bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == string.Empty), It.IsAny<BingSafeSearchLevel>())).ReturnsAsync(Enumerable.Empty<IBingReverseImageSearchResult>);
-        bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => !string.IsNullOrEmpty(s)), It.IsAny<BingSafeSearchLevel>())).ReturnsAsync(() => faker.MakeLazy(50, () => CreateMockedBingReverseImageSearchResult(faker)));
+        bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == null), It.IsAny<BingSafeSearchLevel>(), It.IsAny<string>())).ThrowsAsync(new BingException("Error message."));
+        bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == string.Empty), It.IsAny<BingSafeSearchLevel>(), It.IsAny<string>())).ReturnsAsync(Enumerable.Empty<IBingReverseImageSearchResult>);
+        bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => !string.IsNullOrEmpty(s)), It.IsAny<BingSafeSearchLevel>(), It.IsAny<string>())).ReturnsAsync(() => faker.MakeLazy(50, () => CreateMockedBingReverseImageSearchResult(faker)));
 
         return bingMock.Object;
     }

From 3b1435320cb4e6488a0669146568893d93c4bc56 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 16 Apr 2022 02:19:23 -0500
Subject: [PATCH 39/83] Localize Bing visual search error messages

---
 src/Apis/Bing/BingVisualSearch.cs    |  6 +++---
 src/Modules/ImageModule.cs           |  2 +-
 src/Modules/OcrModule.cs             |  2 +-
 src/Resources/SharedResource.es.resx | 15 +++++++++++++++
 4 files changed, 20 insertions(+), 5 deletions(-)

diff --git a/src/Apis/Bing/BingVisualSearch.cs b/src/Apis/Bing/BingVisualSearch.cs
index 565c35f..42aa20e 100644
--- a/src/Apis/Bing/BingVisualSearch.cs
+++ b/src/Apis/Bing/BingVisualSearch.cs
@@ -12,9 +12,9 @@ public sealed class BingVisualSearch : IBingVisualSearch, IDisposable
 
     private static readonly Dictionary<string, string> _imageCategories = new(5)
     {
-        ["ImageByteSizeExceedsLimit"] = "Image size exceeds the limit (Max. 20MB)",
-        ["ImageDimensionsExceedLimit"] = "Image dimensions exceeds the limit (Max. 4000px)",
-        ["ImageDownloadFailed"] = "Image download failed.",
+        ["ImageByteSizeExceedsLimit"] = "Image size exceeds the limit (Max. 20MB).",
+        ["ImageDimensionsExceedLimit"] = "Image dimensions exceeds the limit (Max. 4000px).",
+        ["ImageDownloadFailed"] = "Bing Visual search failed to download the image.",
         ["ServiceUnavailable"] = "Bing Visual search is currently unavailable. Try again later.",
         ["UnknownFormat"] = "Unknown format. Try using JPEG, PNG, or BMP files."
     };
diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
index 4daa701..614406a 100644
--- a/src/Modules/ImageModule.cs
+++ b/src/Modules/ImageModule.cs
@@ -324,7 +324,7 @@ public virtual async Task BingAsync(string url, bool multiImages, IDiscordIntera
         catch (BingException e)
         {
             _logger.LogWarning(e, "Failed to perform reverse image search to url {url}", url);
-            await interaction.FollowupWarning(e.Message, ephemeral);
+            await interaction.FollowupWarning(_localizer[e.Message], ephemeral);
             return;
         }
 
diff --git a/src/Modules/OcrModule.cs b/src/Modules/OcrModule.cs
index ab922c9..f637667 100644
--- a/src/Modules/OcrModule.cs
+++ b/src/Modules/OcrModule.cs
@@ -112,7 +112,7 @@ public async Task OcrAsync(OcrEngine ocrEngine, string url, IDiscordInteraction
         catch (Exception e) when (e is BingException or YandexException)
         {
             _logger.LogWarning(e, "Failed to perform OCR to url {url}", url);
-            await interaction.FollowupWarning(e.Message, ephemeral);
+            await interaction.FollowupWarning(_localizer[e.Message], ephemeral);
             return;
         }
 
diff --git a/src/Resources/SharedResource.es.resx b/src/Resources/SharedResource.es.resx
index 9608fb9..41f752b 100644
--- a/src/Resources/SharedResource.es.resx
+++ b/src/Resources/SharedResource.es.resx
@@ -120,9 +120,21 @@
   <data name="An error occurred." xml:space="preserve">
     <value>Ocurrió un error.</value>
   </data>
+  <data name="Bing Visual search failed to download the image." xml:space="preserve">
+    <value>La Búsqueda Visual de Bing no pudo descargar la imagen.</value>
+  </data>
+  <data name="Bing Visual search is currently unavailable. Try again later." xml:space="preserve">
+    <value>La Búsqueda Visual de Bing no está disponible actualmente. Vuelva a intentarlo más tarde.</value>
+  </data>
   <data name="Error message: {0}" xml:space="preserve">
     <value>Mensaje de error: {0}</value>
   </data>
+  <data name="Image dimensions exceeds the limit (Max. 4000px)." xml:space="preserve">
+    <value>Las dimensiones de imagen supera el límite (Máx. 4000px).</value>
+  </data>
+  <data name="Image size exceeds the limit (Max. 20MB)." xml:space="preserve">
+    <value>El tamaño de imagen supera el límite (Máx. 20MB).</value>
+  </data>
   <data name="Invalid source language &quot;{0}&quot;." xml:space="preserve">
     <value>Idioma de origen "{0}" inválido.</value>
   </data>
@@ -168,4 +180,7 @@
   <data name="Unable to get an image URL from the message." xml:space="preserve">
     <value>No se puede obtener una URL de imagen del mensaje.</value>
   </data>
+  <data name="Unknown format. Try using JPEG, PNG, or BMP files." xml:space="preserve">
+    <value>Formato desconocido. Intenta usar archivos JPEG, PNG o BMP.</value>
+  </data>
 </root>
\ No newline at end of file

From aca32d9aad7e6a4a5054c429c8fb3f14c1c495da Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 17 Apr 2022 17:02:52 -0500
Subject: [PATCH 40/83] Add unit tests for SharedModule

---
 src/Modules/OcrModule.cs                |   4 +-
 src/Modules/SharedModule.cs             |  28 ++---
 src/Modules/UtilityModule.cs            |   2 +-
 src/Program.cs                          |   2 +-
 tests/Fergun.Tests/OcrModuleTests.cs    |  14 +--
 tests/Fergun.Tests/SharedModuleTests.cs | 141 ++++++++++++++++++++++++
 6 files changed, 159 insertions(+), 32 deletions(-)
 create mode 100644 tests/Fergun.Tests/SharedModuleTests.cs

diff --git a/src/Modules/OcrModule.cs b/src/Modules/OcrModule.cs
index f637667..d0cd74d 100644
--- a/src/Modules/OcrModule.cs
+++ b/src/Modules/OcrModule.cs
@@ -157,7 +157,7 @@ public async Task OcrTranslate()
         int startIndex = text.IndexOf('`', StringComparison.Ordinal) + 3;
         text = text[startIndex..^3];
 
-        await _shared.TranslateAsync(Context.Interaction, text, Context.Interaction.GetLanguageCode(), ephemeral: true, deferLoad: true);
+        await _shared.TranslateAsync(Context.Interaction, text, Context.Interaction.GetLanguageCode(), ephemeral: true);
     }
 
     [ComponentInteraction("ocrtts", true)]
@@ -167,7 +167,7 @@ public async Task OcrTts()
         int startIndex = text.IndexOf('`', StringComparison.Ordinal) + 3;
         text = text[startIndex..^3];
 
-        await _shared.TtsAsync(Context.Interaction, text, ephemeral: true, deferLoad: true);
+        await _shared.TtsAsync(Context.Interaction, text, Context.Interaction.GetLanguageCode(), true);
     }
 
     public enum OcrEngine
diff --git a/src/Modules/SharedModule.cs b/src/Modules/SharedModule.cs
index a06790c..1e1ea56 100644
--- a/src/Modules/SharedModule.cs
+++ b/src/Modules/SharedModule.cs
@@ -16,10 +16,10 @@ public class SharedModule
 {
     private readonly ILogger<SharedModule> _logger;
     private readonly IFergunLocalizer<SharedResource> _localizer;
-    private readonly AggregateTranslator _translator;
+    private readonly ITranslator _translator;
     private readonly GoogleTranslator2 _googleTranslator2;
 
-    public SharedModule(ILogger<SharedModule> logger, IFergunLocalizer<SharedResource> localizer, AggregateTranslator translator, GoogleTranslator2 googleTranslator2)
+    public SharedModule(ILogger<SharedModule> logger, IFergunLocalizer<SharedResource> localizer, ITranslator translator, GoogleTranslator2 googleTranslator2)
     {
         _logger = logger;
         _localizer = localizer;
@@ -27,7 +27,7 @@ public SharedModule(ILogger<SharedModule> logger, IFergunLocalizer<SharedResourc
         _googleTranslator2 = googleTranslator2;
     }
 
-    public async Task TranslateAsync(IDiscordInteraction interaction, string text, string target, string? source = null, bool ephemeral = false, bool deferLoad = false)
+    public async Task TranslateAsync(IDiscordInteraction interaction, string text, string target, string? source = null, bool ephemeral = false)
     {
         _localizer.CurrentCulture = CultureInfo.GetCultureInfo(interaction.GetLanguageCode());
 
@@ -49,7 +49,7 @@ public async Task TranslateAsync(IDiscordInteraction interaction, string text, s
             return;
         }
 
-        if (deferLoad && interaction is IComponentInteraction componentInteraction)
+        if (interaction is IComponentInteraction componentInteraction)
         {
             await componentInteraction.DeferLoadingAsync(ephemeral);
         }
@@ -66,7 +66,7 @@ public async Task TranslateAsync(IDiscordInteraction interaction, string text, s
         }
         catch (Exception e)
         {
-            _logger.LogWarning(new(0, "Translate"), e, "Error translating text {text} ({source} -> {target})", text, source ?? "auto", target);
+            _logger.LogWarning(e, "Error translating text {text} ({source} -> {target})", text, source ?? "auto", target);
             await interaction.FollowupWarning(e.Message, ephemeral);
             return;
         }
@@ -96,7 +96,7 @@ public async Task TranslateAsync(IDiscordInteraction interaction, string text, s
         await interaction.FollowupAsync(embed: builder.Build(), ephemeral: ephemeral);
     }
 
-    public async Task TtsAsync(IDiscordInteraction interaction, string text, string? target = null, bool ephemeral = false, bool deferLoad = false)
+    public async Task TtsAsync(IDiscordInteraction interaction, string text, string target, bool ephemeral = false)
     {
         _localizer.CurrentCulture = CultureInfo.GetCultureInfo(interaction.GetLanguageCode());
 
@@ -106,15 +106,13 @@ public async Task TtsAsync(IDiscordInteraction interaction, string text, string?
             return;
         }
 
-        target ??= interaction.GetLanguageCode();
-
         if (!Language.TryGetLanguage(target, out var language) || !GoogleTranslator2.TextToSpeechLanguages.Contains(language))
         {
             await interaction.RespondWarningAsync(_localizer["Language \"{0}\" not supported.", target], true);
             return;
         }
 
-        if (deferLoad && interaction is IComponentInteraction componentInteraction)
+        if (interaction is IComponentInteraction componentInteraction)
         {
             await componentInteraction.DeferLoadingAsync(ephemeral);
         }
@@ -123,15 +121,7 @@ public async Task TtsAsync(IDiscordInteraction interaction, string text, string?
             await interaction.DeferAsync(ephemeral);
         }
 
-        try
-        {
-            await using var stream = await _googleTranslator2.TextToSpeechAsync(text, language);
-            await interaction.FollowupWithFileAsync(new FileAttachment(stream, "tts.mp3"), ephemeral: ephemeral);
-        }
-        catch (Exception e)
-        {
-            _logger.LogWarning(e, "TTS: Error obtaining TTS from text {text} ({language})", text, language);
-            await interaction.FollowupWarning(e.Message, ephemeral);
-        }
+        await using var stream = await _googleTranslator2.TextToSpeechAsync(text, language);
+        await interaction.FollowupWithFileAsync(new FileAttachment(stream, "tts.mp3"), ephemeral: ephemeral);
     }
 }
\ No newline at end of file
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 5598e9a..350aef2 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -380,7 +380,7 @@ public async Task TTS(IMessage message)
     public async Task TTS([Summary(description: "The text to convert.")] string text,
         [Autocomplete(typeof(TtsAutocompleteHandler))] [Summary(description: "The target language.")] string? target = null,
         [Summary(description: "Whether to respond ephemerally.")] bool ephemeral = false)
-        => await _shared.TtsAsync(Context.Interaction, text, target, ephemeral);
+        => await _shared.TtsAsync(Context.Interaction, text, target ?? Context.Interaction.GetLanguageCode(), ephemeral);
 
     [SlashCommand("wikipedia", "Searches for Wikipedia articles.")]
     public async Task Wikipedia([Autocomplete(typeof(WikipediaAutocompleteHandler))] [Summary(description: "The search query.")] string query)
diff --git a/src/Program.cs b/src/Program.cs
index 517ace7..ea3f79e 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -131,7 +131,7 @@ await Host.CreateDefaultBuilder()
         services.AddHttpClient("autocomplete", client => client.DefaultRequestHeaders.UserAgent.ParseAdd(Constants.ChromeUserAgent))
             .SetHandlerLifetime(TimeSpan.FromMinutes(30));
 
-        services.AddSingleton<AggregateTranslator>();
+        services.AddSingleton<ITranslator, AggregateTranslator>();
         services.AddSingleton(x => new GoogleScraper(x.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(GoogleScraper))));
         services.AddSingleton(x => new DuckDuckGoScraper(x.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(DuckDuckGoScraper))));
         services.AddSingleton(x => new BraveScraper(x.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(BraveScraper))));
diff --git a/tests/Fergun.Tests/OcrModuleTests.cs b/tests/Fergun.Tests/OcrModuleTests.cs
index 195db00..db52aa5 100644
--- a/tests/Fergun.Tests/OcrModuleTests.cs
+++ b/tests/Fergun.Tests/OcrModuleTests.cs
@@ -7,7 +7,7 @@
 using Fergun.Apis.Yandex;
 using Fergun.Interactive;
 using Fergun.Modules;
-using Microsoft.Extensions.Localization;
+using GTranslate.Translators;
 using Microsoft.Extensions.Logging;
 using Moq;
 using Xunit;
@@ -39,16 +39,12 @@ public OcrModuleTests()
         _yandexImageSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == _invalidImageUrl))).ThrowsAsync(new YandexException("Invalid image."));
 
         var sharedLogger = Mock.Of<ILogger<SharedModule>>();
-        var sharedLocalizer = new Mock<IFergunLocalizer<SharedResource>>();
-        sharedLocalizer.Setup(x => x[It.IsAny<string>()]).Returns<string>(s => new LocalizedString(s, s));
-        sharedLocalizer.Setup(x => x[It.IsAny<string>(), It.IsAny<object[]>()]).Returns<string, object[]>((s, p) => new LocalizedString(s, string.Format(s, p)));
-        var ocrLocalizer = new Mock<IFergunLocalizer<OcrModule>>();
-        ocrLocalizer.Setup(x => x[It.IsAny<string>()]).Returns<string>(s => new LocalizedString(s, s));
-        ocrLocalizer.Setup(x => x[It.IsAny<string>(), It.IsAny<object[]>()]).Returns<string, object[]>((s, p) => new LocalizedString(s, string.Format(s, p)));
-        var shared = new SharedModule(sharedLogger, sharedLocalizer.Object, new(), new());
+        var sharedLocalizer = Utils.CreateMockedLocalizer<SharedResource>();
+        var ocrLocalizer = Utils.CreateMockedLocalizer<OcrModule>();
+        var shared = new SharedModule(sharedLogger, sharedLocalizer, new AggregateTranslator(), new());
 
         _interactive = new InteractiveService(_client, _interactiveConfig);
-        _ocrModuleMock = new Mock<OcrModule>(() => new OcrModule(_loggerMock.Object, ocrLocalizer.Object, shared, _interactive, _bingVisualSearchMock.Object, _yandexImageSearchMock.Object));
+        _ocrModuleMock = new Mock<OcrModule>(() => new OcrModule(_loggerMock.Object, ocrLocalizer, shared, _interactive, _bingVisualSearchMock.Object, _yandexImageSearchMock.Object));
         _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
         ((IInteractionModuleBase)_ocrModuleMock.Object).SetContext(_contextMock.Object);
     }
diff --git a/tests/Fergun.Tests/SharedModuleTests.cs b/tests/Fergun.Tests/SharedModuleTests.cs
new file mode 100644
index 0000000..389f984
--- /dev/null
+++ b/tests/Fergun.Tests/SharedModuleTests.cs
@@ -0,0 +1,141 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Discord;
+using Fergun.Modules;
+using GTranslate;
+using GTranslate.Results;
+using GTranslate.Translators;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Fergun.Tests;
+
+public class SharedModuleTests
+{
+    private readonly Mock<ITranslator> _translatorMock = new();
+    private readonly Mock<SharedModule> _sharedModuleMock;
+    private readonly Mock<IDiscordInteraction> _interactionMock = new();
+    private readonly Mock<IComponentInteraction> _componentInteractionMock = new();
+
+    public SharedModuleTests()
+    {
+        var localizer = Utils.CreateMockedLocalizer<SharedResource>();
+        _sharedModuleMock = new Mock<SharedModule>(() => new SharedModule(Mock.Of<ILogger<SharedModule>>(), localizer, _translatorMock.Object, new()));
+        _translatorMock.Setup(x => x.TranslateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
+            .ReturnsAsync<string, string, string?, ITranslator, ITranslationResult>((text, target, source) =>
+            {
+                if (text == "Error")
+                {
+                    throw new ArgumentException("Error.", nameof(text));
+                }
+
+                var targetLanguage = Language.GetLanguage(target);
+                Language.TryGetLanguage(source ?? string.Empty, out var sourceLanguage);
+
+                var mock = new Mock<ITranslationResult>();
+                mock.SetupGet(x => x.Translation).Returns(() => text);
+                mock.SetupGet(x => x.Source).Returns(() => text);
+                mock.SetupGet(x => x.SourceLanguage).Returns(() => sourceLanguage ?? targetLanguage);
+                mock.SetupGet(x => x.TargetLanguage).Returns(() => targetLanguage);
+                mock.SetupGet(x => x.Service).Returns(() => text switch
+                {
+                    "Bing" => "BingTranslator",
+                    "Microsoft" => "MicrosoftTranslator",
+                    "Yandex" => "YandexTranslator",
+                    _ => "GoogleTranslator"
+                });
+
+                return mock.Object;
+            });
+
+        _interactionMock.SetupGet(x => x.UserLocale).Returns("en");
+        _componentInteractionMock.SetupGet(x => x.UserLocale).Returns("en");
+    }
+
+    [Theory]
+    [InlineData(null, "en", null, false)]
+    [InlineData("text", "", "es", false)]
+    [InlineData("text", "en", "foobar", true)]
+    [InlineData("Google", "en", "es", false)]
+    [InlineData("Bing", "en", null, true)]
+    [InlineData("Microsoft", "tr", "es", true)]
+    [InlineData("Yandex", "ru", "en", false)]
+    public async Task TranslateAsync_Returns_Results_Or_Fails_Preconditions(string text, string target, string? source, bool ephemeral)
+    {
+        await _sharedModuleMock.Object.TranslateAsync(_interactionMock.Object, text, target, source, ephemeral);
+
+        _interactionMock.VerifyGet(x => x.UserLocale);
+
+        bool passedPreconditions = !string.IsNullOrEmpty(text) && Language.TryGetLanguage(target, out _) && (source == null || Language.TryGetLanguage(source, out _));
+
+        _interactionMock.Verify(x => x.RespondAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b),
+            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), passedPreconditions ? Times.Never : Times.Once);
+
+        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == ephemeral), It.IsAny<RequestOptions>()), passedPreconditions ? Times.Once : Times.Never);
+
+        _translatorMock.Verify(x => x.TranslateAsync(It.Is<string>(s => s == text), It.Is<string>(s => s == target), It.Is<string>(s => s == source)), passedPreconditions ? Times.Once : Times.Never);
+
+        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == ephemeral),
+            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), passedPreconditions ? Times.Once : Times.Never);
+    }
+
+    [Theory]
+    [InlineData("Microsoft", "tr", "es", true)]
+    [InlineData("Yandex", "ru", "en", false)]
+    [InlineData("Error", "fr", "it", true)]
+    public async Task TranslateAsync_Uses_DeferLoadingAsync(string text, string target, string? source, bool ephemeral)
+    {
+        await _sharedModuleMock.Object.TranslateAsync(_componentInteractionMock.Object, text, target, source, ephemeral);
+
+        _componentInteractionMock.VerifyGet(x => x.UserLocale);
+
+        _componentInteractionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == ephemeral), It.IsAny<RequestOptions>()), Times.Never);
+        _componentInteractionMock.Verify(x => x.DeferLoadingAsync(It.Is<bool>(b => b == ephemeral), It.IsAny<RequestOptions>()), Times.Once);
+
+        _translatorMock.Verify(x => x.TranslateAsync(It.Is<string>(s => s == text), It.Is<string>(s => s == target), It.Is<string>(s => s == source)), Times.Once);
+
+        _componentInteractionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == ephemeral),
+            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
+    }
+
+    [Theory]
+    [InlineData("", "en", true)]
+    [InlineData("test", "foobar", false)]
+    [InlineData("test", "yo", true)]
+    [InlineData("Hello world", "en", true)]
+    [InlineData("Hola mundo", "es", false)]
+    public async Task TtsAsync_Sends_Results_Or_Fails_Preconditions(string text, string target, bool ephemeral)
+    {
+        await _sharedModuleMock.Object.TtsAsync(_interactionMock.Object, text, target, ephemeral);
+
+        _interactionMock.VerifyGet(x => x.UserLocale);
+
+        bool passedPreconditions = !string.IsNullOrWhiteSpace(text) && Language.TryGetLanguage(target, out var language) && GoogleTranslator2.TextToSpeechLanguages.Contains(language);
+
+        _interactionMock.Verify(x => x.RespondAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b),
+            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), passedPreconditions ? Times.Never : Times.Once);
+
+        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == ephemeral), It.IsAny<RequestOptions>()), passedPreconditions ? Times.Once : Times.Never);
+
+        _interactionMock.Verify(x => x.FollowupWithFileAsync(It.Is<FileAttachment>(x => x.FileName == "tts.mp3"), It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == ephemeral),
+            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), passedPreconditions ? Times.Once : Times.Never);
+    }
+
+    [Theory]
+    [InlineData("Привет, мир", "ru", true)]
+    [InlineData("Bonjour le monde", "fr", false)]
+    public async Task TtsAsync_Uses_DeferLoadingAsync(string text, string target, bool ephemeral)
+    {
+        await _sharedModuleMock.Object.TtsAsync(_componentInteractionMock.Object, text, target, ephemeral);
+
+        _componentInteractionMock.VerifyGet(x => x.UserLocale);
+
+        _componentInteractionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == ephemeral), It.IsAny<RequestOptions>()), Times.Never);
+        _componentInteractionMock.Verify(x => x.DeferLoadingAsync(It.Is<bool>(b => b == ephemeral), It.IsAny<RequestOptions>()), Times.Once);
+
+        _componentInteractionMock.Verify(x => x.FollowupWithFileAsync(It.Is<FileAttachment>(x => x.FileName == "tts.mp3"), It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == ephemeral),
+            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
+    }
+}
\ No newline at end of file

From c2b43a6edde43b4e7e33138bb3c7d64666dc49e0 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 17 Apr 2022 22:50:15 -0500
Subject: [PATCH 41/83] Update localization

---
 src/Modules/SharedModule.cs          | 4 ++--
 src/Modules/UtilityModule.cs         | 2 +-
 src/Resources/SharedResource.es.resx | 4 ++--
 3 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/src/Modules/SharedModule.cs b/src/Modules/SharedModule.cs
index 1e1ea56..5a4f825 100644
--- a/src/Modules/SharedModule.cs
+++ b/src/Modules/SharedModule.cs
@@ -33,7 +33,7 @@ public async Task TranslateAsync(IDiscordInteraction interaction, string text, s
 
         if (string.IsNullOrWhiteSpace(text))
         {
-            await interaction.RespondWarningAsync(_localizer["The message must contain text."], true);
+            await interaction.RespondWarningAsync(_localizer["The text must not be empty."], true);
             return;
         }
 
@@ -102,7 +102,7 @@ public async Task TtsAsync(IDiscordInteraction interaction, string text, string
 
         if (string.IsNullOrWhiteSpace(text))
         {
-            await interaction.RespondWarningAsync(_localizer["The message must contain text."], true);
+            await interaction.RespondWarningAsync(_localizer["The text must not be empty."], true);
             return;
         }
 
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 350aef2..a32cee5 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -66,7 +66,7 @@ public async Task BadTranslator([Summary(description: "The text to use.")] strin
     {
         if (string.IsNullOrWhiteSpace(text))
         {
-            await Context.Interaction.RespondWarningAsync(_localizer["The message must contain text."], true);
+            await Context.Interaction.RespondWarningAsync(_localizer["The text must not be empty."], true);
             return;
         }
 
diff --git a/src/Resources/SharedResource.es.resx b/src/Resources/SharedResource.es.resx
index 41f752b..07890c2 100644
--- a/src/Resources/SharedResource.es.resx
+++ b/src/Resources/SharedResource.es.resx
@@ -168,8 +168,8 @@
   <data name="Target language" xml:space="preserve">
     <value>Idioma de destino</value>
   </data>
-  <data name="The message must contain text." xml:space="preserve">
-    <value>El mensaje debe contener texto.</value>
+  <data name="The text must not be empty." xml:space="preserve">
+    <value>El texto no debe estar vacío.</value>
   </data>
   <data name="The URL is not well formed." xml:space="preserve">
     <value>La URL no está bien formada.</value>

From 78eb0dcad030bc445137cfc80347c42561f4ac1a Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Mon, 18 Apr 2022 15:59:06 -0500
Subject: [PATCH 42/83] Restructure tests

---
 src/Modules/UserModule.cs                     |   4 +-
 src/Modules/UtilityModule.cs                  |  38 +--
 .../{ => Apis}/BingVisualSearchTests.cs       |  10 +-
 .../{ => Apis}/UrbanDictionaryTests.cs        |   2 +-
 .../{ => Apis}/WikipediaClientTests.cs        |   4 +-
 .../{ => Apis}/YandexImageSearchTests.cs      |   8 +-
 tests/Fergun.Tests/Extensions.cs              |   2 +-
 .../Extensions/ChannelExtensionsTests.cs      |  37 +++
 .../Extensions/InteractionExtensionsTests.cs  | 115 ++++++++
 .../Extensions/MessageExtensionsTests.cs      |  73 +++++
 .../Extensions/StringExtensionsTests.cs       |  22 ++
 .../Extensions/TimestampExtensionsTests.cs    |  29 ++
 tests/Fergun.Tests/ExtensionsTests.cs         | 259 ------------------
 .../Handlers}/AutocompleteHandlerTests.cs     |   2 +-
 .../{ => Modules}/ImageModuleTests.cs         |   7 +-
 .../{ => Modules}/OcrModuleTests.cs           |  29 +-
 .../{ => Modules}/SharedModuleTests.cs        |   2 +-
 .../{ => Modules}/UrbanModuleTests.cs         |  35 ++-
 .../{ => Modules}/UserModuleTests.cs          |  40 +--
 tests/Fergun.Tests/Utils.cs                   |  92 ++++---
 20 files changed, 437 insertions(+), 373 deletions(-)
 rename tests/Fergun.Tests/{ => Apis}/BingVisualSearchTests.cs (90%)
 rename tests/Fergun.Tests/{ => Apis}/UrbanDictionaryTests.cs (99%)
 rename tests/Fergun.Tests/{ => Apis}/WikipediaClientTests.cs (95%)
 rename tests/Fergun.Tests/{ => Apis}/YandexImageSearchTests.cs (96%)
 create mode 100644 tests/Fergun.Tests/Extensions/ChannelExtensionsTests.cs
 create mode 100644 tests/Fergun.Tests/Extensions/InteractionExtensionsTests.cs
 create mode 100644 tests/Fergun.Tests/Extensions/MessageExtensionsTests.cs
 create mode 100644 tests/Fergun.Tests/Extensions/StringExtensionsTests.cs
 create mode 100644 tests/Fergun.Tests/Extensions/TimestampExtensionsTests.cs
 delete mode 100644 tests/Fergun.Tests/ExtensionsTests.cs
 rename tests/Fergun.Tests/{ => Modules/Handlers}/AutocompleteHandlerTests.cs (99%)
 rename tests/Fergun.Tests/{ => Modules}/ImageModuleTests.cs (99%)
 rename tests/Fergun.Tests/{ => Modules}/OcrModuleTests.cs (84%)
 rename tests/Fergun.Tests/{ => Modules}/SharedModuleTests.cs (99%)
 rename tests/Fergun.Tests/{ => Modules}/UrbanModuleTests.cs (76%)
 rename tests/Fergun.Tests/{ => Modules}/UserModuleTests.cs (73%)

diff --git a/src/Modules/UserModule.cs b/src/Modules/UserModule.cs
index 463e25a..35d7a23 100644
--- a/src/Modules/UserModule.cs
+++ b/src/Modules/UserModule.cs
@@ -28,7 +28,7 @@ public async Task Avatar(IUser user)
             Color = Color.Orange
         };
 
-        await RespondAsync(embed: builder.Build());
+        await Context.Interaction.RespondAsync(embed: builder.Build());
     }
 
     [UserCommand("User Info")]
@@ -79,7 +79,7 @@ public async Task UserInfo(IUser user)
             .WithThumbnailUrl(avatarUrl)
             .WithColor(Color.Orange);
 
-        await RespondAsync(embed: builder.Build());
+        await Context.Interaction.RespondAsync(embed: builder.Build());
 
         static string GetTimestamp(DateTimeOffset? dateTime)
             => dateTime == null ? "N/A" : $"{dateTime.Value.ToDiscordTimestamp()} ({dateTime.Value.ToDiscordTimestamp('R')})";
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index a32cee5..ab02bac 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -4,6 +4,7 @@
 using System.Runtime.InteropServices;
 using Discord;
 using Discord.Interactions;
+using Discord.WebSocket;
 using Fergun.Apis.Wikipedia;
 using Fergun.Extensions;
 using Fergun.Interactive;
@@ -20,7 +21,7 @@
 
 namespace Fergun.Modules;
 
-public class UtilityModule : InteractionModuleBase<ShardedInteractionContext>
+public class UtilityModule : InteractionModuleBase
 {
     private readonly ILogger<UtilityModule> _logger;
     private readonly IFergunLocalizer<UtilityModule> _localizer;
@@ -147,7 +148,7 @@ public async Task BadTranslator([Summary(description: "The text to use.")] strin
 
     [RequireOwner]
     [SlashCommand("cmd", "(Owner only) Executes a command.")]
-    public async Task Cmd([Summary(description: "The command to execute")] string command, [Summary("noembed", "No embed.")] bool noEmbed = false)
+    public async Task Cmd([Summary(description: "The command to execute")] string command, [Summary(description: "No embed.")] bool noEmbed = false)
     {
         await DeferAsync();
 
@@ -315,20 +316,23 @@ public async Task Stats()
             processRamUsage = Process.GetCurrentProcess().PrivateMemorySize64 / 1024 / 1024;
         }
 
-        int totalUsers = 0;
-        foreach (var guild in Context.Client.Guilds)
-        {
-            totalUsers += guild.MemberCount;
-        }
+        var guilds = await Context.Client.GetGuildsAsync(CacheMode.CacheOnly);
+        int? totalUsers = guilds.Sum(x => x.ApproximateMemberCount ?? (x as SocketGuild)?.MemberCount);
+
+        int shards = 1;
+        int shardId = 0;
+        int? totalUsersInShard = null;
+        DiscordSocketClient? shard = null;
 
-        int totalUsersInShard = 0;
-        int shardId = Context.Channel.IsPrivate() ? 0 : Context.Client.GetShardIdFor(Context.Guild);
-        foreach (var guild in Context.Client.GetShard(shardId).Guilds)
+        if (Context.Client is DiscordShardedClient shardedClient)
         {
-            totalUsersInShard += guild.MemberCount;
+            shards = shardedClient.Shards.Count;
+            shardId = Context.Channel.IsPrivate() ? 0 : shardedClient.GetShardIdFor(Context.Guild);
+            shard = shardedClient.GetShard(shardId);
+            totalUsersInShard = shard.Guilds.Sum(x => x.MemberCount);
         }
 
-        string version = $"v{Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion}";
+        string? version = Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
 
         var elapsed = DateTimeOffset.UtcNow - Process.GetCurrentProcess().StartTime;
 
@@ -337,7 +341,7 @@ public async Task Stats()
             .AddField(_localizer["Operating System"], os, true)
             .AddField("\u200b", "\u200b", true)
             .AddField("CPU", cpu, true)
-            .AddField(_localizer["CPU Usage"], cpuUsage + "%", true)
+            .AddField(_localizer["CPU Usage"], $"{cpuUsage}%", true)
             .AddField("\u200b", "\u200b", true)
             .AddField(_localizer["RAM Usage"],
                 $"{processRamUsage}MB ({(totalRam == null ? 0 : Math.Round((double)processRamUsage / totalRam.Value * 100, 2))}%) " +
@@ -345,13 +349,13 @@ public async Task Stats()
                 $"/ {totalRam?.ToString() ?? "?"}MB", true)
             .AddField(_localizer["Library"], $"Discord.Net v{DiscordConfig.Version}", true)
             .AddField("\u200b", "\u200b", true)
-            .AddField(_localizer["Bot Version"], version, true)
-            .AddField(_localizer["Total Servers"], $"{Context.Client.Guilds.Count} (Shard: {Context.Client.GetShard(shardId).Guilds.Count})", true)
+            .AddField(_localizer["Bot Version"], version is null ? "?" : $"v{version}", true)
+            .AddField(_localizer["Total Servers"], $"{guilds.Count} (Shard: {shard?.Guilds?.Count ?? guilds.Count})", true)
             .AddField("\u200b", "\u200b", true)
-            .AddField(_localizer["Total Users"], $"{totalUsers} (Shard: {totalUsersInShard})", true)
+            .AddField(_localizer["Total Users"], $"{totalUsers?.ToString() ?? "?"} (Shard: {totalUsersInShard?.ToString() ?? totalUsers?.ToString() ?? "?"})", true)
             .AddField(_localizer["Shard ID"], shardId, true)
             .AddField("\u200b", "\u200b", true)
-            .AddField("Shards", Context.Client.Shards.Count, true)
+            .AddField("Shards", shards, true)
             .AddField(_localizer["Uptime"], elapsed.Humanize(), true)
             .AddField("\u200b", "\u200b", true)
             .AddField(_localizer["Bot Owner"], owner, true);
diff --git a/tests/Fergun.Tests/BingVisualSearchTests.cs b/tests/Fergun.Tests/Apis/BingVisualSearchTests.cs
similarity index 90%
rename from tests/Fergun.Tests/BingVisualSearchTests.cs
rename to tests/Fergun.Tests/Apis/BingVisualSearchTests.cs
index efe3c37..9964f90 100644
--- a/tests/Fergun.Tests/BingVisualSearchTests.cs
+++ b/tests/Fergun.Tests/Apis/BingVisualSearchTests.cs
@@ -5,11 +5,11 @@
 using Moq;
 using Xunit;
 
-namespace Fergun.Tests;
+namespace Fergun.Tests.Apis;
 
 public class BingVisualSearchTests
 {
-    private readonly BingVisualSearch _bingVisualSearch = new();
+    private readonly IBingVisualSearch _bingVisualSearch = new BingVisualSearch();
 
     [Theory]
     [InlineData("https://cdn.discordapp.com/attachments/838832564583661638/954474328324460544/lorem_ipsum.png")]
@@ -61,10 +61,10 @@ public async Task ReverseImageSearchAsync_Throws_BingException_If_Image_Is_Inval
     }
 
     [Fact]
-    public async Task Disposed_UrbanDictionary_Usage_Throws_ObjectDisposedException()
+    public async Task Disposed_BingVisualSearch_Usage_Throws_ObjectDisposedException()
     {
-        _bingVisualSearch.Dispose();
-        _bingVisualSearch.Dispose();
+        (_bingVisualSearch as IDisposable)?.Dispose();
+        (_bingVisualSearch as IDisposable)?.Dispose();
 
         await Assert.ThrowsAsync<ObjectDisposedException>(() => _bingVisualSearch.OcrAsync(It.IsAny<string>()));
         await Assert.ThrowsAsync<ObjectDisposedException>(() => _bingVisualSearch.ReverseImageSearchAsync(It.IsAny<string>(), It.IsAny<BingSafeSearchLevel>(), It.IsAny<string?>()));
diff --git a/tests/Fergun.Tests/UrbanDictionaryTests.cs b/tests/Fergun.Tests/Apis/UrbanDictionaryTests.cs
similarity index 99%
rename from tests/Fergun.Tests/UrbanDictionaryTests.cs
rename to tests/Fergun.Tests/Apis/UrbanDictionaryTests.cs
index 0ef522e..cc457a4 100644
--- a/tests/Fergun.Tests/UrbanDictionaryTests.cs
+++ b/tests/Fergun.Tests/Apis/UrbanDictionaryTests.cs
@@ -4,7 +4,7 @@
 using Moq;
 using Xunit;
 
-namespace Fergun.Tests;
+namespace Fergun.Tests.Apis;
 
 public class UrbanDictionaryTests
 {
diff --git a/tests/Fergun.Tests/WikipediaClientTests.cs b/tests/Fergun.Tests/Apis/WikipediaClientTests.cs
similarity index 95%
rename from tests/Fergun.Tests/WikipediaClientTests.cs
rename to tests/Fergun.Tests/Apis/WikipediaClientTests.cs
index 951a388..7e9546b 100644
--- a/tests/Fergun.Tests/WikipediaClientTests.cs
+++ b/tests/Fergun.Tests/Apis/WikipediaClientTests.cs
@@ -5,7 +5,7 @@
 using Moq;
 using Xunit;
 
-namespace Fergun.Tests;
+namespace Fergun.Tests.Apis;
 
 public class WikipediaClientTests
 {
@@ -52,7 +52,7 @@ public async Task GetAutocompleteResultsAsync_Returns_Results(string term, strin
     }
 
     [Fact]
-    public async Task Disposed_UrbanDictionary_Usage_Should_Throw_ObjectDisposedException()
+    public async Task Disposed_WikipediaClient_Usage_Should_Throw_ObjectDisposedException()
     {
         (_wikipediaClient as IDisposable)?.Dispose();
         (_wikipediaClient as IDisposable)?.Dispose();
diff --git a/tests/Fergun.Tests/YandexImageSearchTests.cs b/tests/Fergun.Tests/Apis/YandexImageSearchTests.cs
similarity index 96%
rename from tests/Fergun.Tests/YandexImageSearchTests.cs
rename to tests/Fergun.Tests/Apis/YandexImageSearchTests.cs
index 15bcb6f..6587f54 100644
--- a/tests/Fergun.Tests/YandexImageSearchTests.cs
+++ b/tests/Fergun.Tests/Apis/YandexImageSearchTests.cs
@@ -13,11 +13,11 @@
 using Moq.Protected;
 using Xunit;
 
-namespace Fergun.Tests;
+namespace Fergun.Tests.Apis;
 
 public class YandexImageSearchTests
 {
-    private readonly YandexImageSearch _yandexImageSearch = new();
+    private readonly IYandexImageSearch _yandexImageSearch = new YandexImageSearch();
 
     [Theory]
     [InlineData("https://cdn.discordapp.com/attachments/838832564583661638/954474328324460544/lorem_ipsum.png")]
@@ -189,8 +189,8 @@ public async Task ReverseImageSearchAsync_Ignores_Invalid_Results()
     [Fact]
     public async Task Disposed_UrbanDictionary_Usage_Throws_ObjectDisposedException()
     {
-        _yandexImageSearch.Dispose();
-        _yandexImageSearch.Dispose();
+        (_yandexImageSearch as IDisposable)?.Dispose();
+        (_yandexImageSearch as IDisposable)?.Dispose();
 
         await Assert.ThrowsAsync<ObjectDisposedException>(() => _yandexImageSearch.OcrAsync(It.IsAny<string>()));
         await Assert.ThrowsAsync<ObjectDisposedException>(() => _yandexImageSearch.ReverseImageSearchAsync(It.IsAny<string>()));
diff --git a/tests/Fergun.Tests/Extensions.cs b/tests/Fergun.Tests/Extensions.cs
index 9a36c26..9ba6080 100644
--- a/tests/Fergun.Tests/Extensions.cs
+++ b/tests/Fergun.Tests/Extensions.cs
@@ -4,7 +4,7 @@
 
 namespace Fergun.Tests;
 
-internal static class Extensions
+internal static class TestExtensions
 {
     public static void SetPropertyValue<TSource, TProperty>(this TSource obj, Expression<Func<TSource, TProperty>> expression, TProperty newValue)
         => ((PropertyInfo)((MemberExpression)expression.Body).Member).SetValue(obj, newValue);
diff --git a/tests/Fergun.Tests/Extensions/ChannelExtensionsTests.cs b/tests/Fergun.Tests/Extensions/ChannelExtensionsTests.cs
new file mode 100644
index 0000000..074aa7d
--- /dev/null
+++ b/tests/Fergun.Tests/Extensions/ChannelExtensionsTests.cs
@@ -0,0 +1,37 @@
+using Discord;
+using Fergun.Extensions;
+using Moq;
+using Xunit;
+
+namespace Fergun.Tests.Extensions;
+
+public class ChannelExtensionsTests
+{
+    [Fact]
+    public void IMessageChannel_IsNsfw_Should_Return_True_When_Channel_Is_NSFW()
+    {
+        var channelMock1 = new Mock<ITextChannel>();
+        channelMock1.SetupGet(x => x.IsNsfw).Returns(true);
+
+        var channelMock2 = new Mock<ITextChannel>();
+        channelMock2.SetupGet(x => x.IsNsfw).Returns(false);
+
+        var channelMock3 = new Mock<IDMChannel>();
+
+        Assert.True(channelMock1.Object.IsNsfw());
+        Assert.False(channelMock2.Object.IsNsfw());
+        Assert.False(channelMock3.Object.IsNsfw());
+    }
+
+    [Fact]
+    public void IChannel_IsPrivate_Should_Return_True_When_Channel_Is_IPrivateChannel()
+    {
+        var channelMock1 = new Mock<IChannel>();
+        var channelMock2 = new Mock<ITextChannel>();
+        var channelMock3 = new Mock<IDMChannel>();
+
+        Assert.False(channelMock1.Object.IsPrivate());
+        Assert.False(channelMock2.Object.IsPrivate());
+        Assert.True(channelMock3.Object.IsPrivate());
+    }
+}
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Extensions/InteractionExtensionsTests.cs b/tests/Fergun.Tests/Extensions/InteractionExtensionsTests.cs
new file mode 100644
index 0000000..bba970c
--- /dev/null
+++ b/tests/Fergun.Tests/Extensions/InteractionExtensionsTests.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Bogus;
+using Discord;
+using Fergun.Extensions;
+using GTranslate;
+using Moq;
+using Xunit;
+
+namespace Fergun.Tests.Extensions;
+
+public class InteractionExtensionsTests
+{
+    [Fact]
+    public async Task Interaction_RespondWarningAsync_Should_Call_RespondAsync_Once()
+    {
+        var interactionMock = new Mock<IDiscordInteraction>();
+        await interactionMock.Object.RespondWarningAsync(It.IsAny<string>(), It.IsAny<bool>());
+
+        interactionMock.Verify(x => x.RespondAsync(It.IsAny<string>(), It.IsAny<Embed[]>(),
+            It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
+    }
+
+    [Fact]
+    public async Task Interaction_FollowupWarningAsync_Should_Call_FollowupAsync_Once()
+    {
+        var interactionMock = new Mock<IDiscordInteraction>();
+        await interactionMock.Object.FollowupWarning(It.IsAny<string>(), It.IsAny<bool>());
+
+        interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(),
+            It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
+    }
+
+    [Theory]
+    [MemberData(nameof(GetLocales))]
+    [MemberData(nameof(GetRandomStrings))]
+    public void Interaction_GetLanguageCode_Should_Return_Code(string locale)
+    {
+        string expected = locale.Contains('-') ? locale.Split('-')[0] : locale;
+
+        var interactionMock = new Mock<IDiscordInteraction>();
+        interactionMock.SetupGet(x => x.UserLocale).Returns(locale);
+
+        string language = interactionMock.Object.GetLanguageCode();
+        Assert.Equal(expected, language);
+    }
+
+    [Theory]
+    [InlineData("", "en")]
+    [InlineData(null, "es")]
+    public void Interaction_GetLanguageCode_Should_Return_Default(string locale, string defaultLanguage)
+    {
+        var interactionMock = new Mock<IDiscordInteraction>();
+        interactionMock.SetupGet(x => x.UserLocale).Returns(locale);
+        var interactionMock2 = new Mock<IDiscordInteraction>();
+        interactionMock2.SetupGet(x => x.UserLocale).Returns((string)null!);
+        interactionMock2.SetupGet(x => x.GuildLocale).Returns(locale);
+
+        string language = interactionMock.Object.GetLanguageCode(defaultLanguage);
+        string language2 = interactionMock2.Object.GetLanguageCode(defaultLanguage);
+
+        interactionMock.VerifyGet(x => x.UserLocale, Times.Once);
+        interactionMock2.VerifyGet(x => x.UserLocale, Times.Once);
+        interactionMock2.VerifyGet(x => x.GuildLocale, Times.Once);
+        Assert.Equal(defaultLanguage, language);
+        Assert.Equal(defaultLanguage, language2);
+    }
+
+    [Theory]
+    [MemberData(nameof(GetLanguages))]
+    public void Interaction_TryGetLanguage_Should_Return_True_If_Valid(string language)
+    {
+        var interactionMock = new Mock<IDiscordInteraction>();
+        if (Random.Shared.Next(2) == 0)
+        {
+            interactionMock.SetupGet(x => x.UserLocale).Returns(language);
+        }
+        else
+        {
+            interactionMock.SetupGet(x => x.GuildLocale).Returns(language);
+        }
+
+        bool success = interactionMock.Object.TryGetLanguage(out _);
+
+        Assert.True(success);
+    }
+
+    private static IEnumerable<object?[]> GetLanguages()
+    {
+        var faker = new Faker();
+
+        return Language.LanguageDictionary.Values
+            .Select(x => x.ISO6391.Contains('-') ? x.ISO6391 : $"{x.ISO6391}-{faker.Random.String2(2)}")
+            .Append(null)
+            .Select(x => new object?[] { x });
+    }
+
+    private static IEnumerable<object[]> GetLocales()
+    {
+        var faker = new Faker();
+
+        return faker.MakeLazy(10, () => faker.Random.RandomLocale().Replace('_', '-'))
+            .Select(x => new object[] { x });
+    }
+
+    private static IEnumerable<object[]> GetRandomStrings()
+    {
+        var faker = new Faker();
+
+        return faker.MakeLazy(10, () => faker.Random.String2(2))
+            .Select(x => new object[] { x });
+    }
+}
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Extensions/MessageExtensionsTests.cs b/tests/Fergun.Tests/Extensions/MessageExtensionsTests.cs
new file mode 100644
index 0000000..e9d26ef
--- /dev/null
+++ b/tests/Fergun.Tests/Extensions/MessageExtensionsTests.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Bogus;
+using Discord;
+using Fergun.Extensions;
+using Moq;
+using Xunit;
+
+namespace Fergun.Tests.Extensions;
+
+public class MessageExtensionsTests
+{
+    [Theory]
+    [MemberData(nameof(GetRandomStrings))]
+    public void IMessage_GetText_Should_Return_Text_From_Content(string content)
+    {
+        var messageMock = new Mock<IMessage>();
+        messageMock.SetupGet(x => x.Content).Returns(content);
+        messageMock.SetupGet(x => x.Embeds).Returns(Array.Empty<IEmbed>());
+
+        string text = messageMock.Object.GetText();
+
+        messageMock.VerifyGet(x => x.Content);
+        Assert.Equal(content, text);
+    }
+
+    [Theory]
+    [MemberData(nameof(GetContentsAndEmbeds))]
+    public void IMessage_GetText_Should_Return_Text_From_Content_And_Embed(string content, Embed embed)
+    {
+        var messageMock = new Mock<IMessage>();
+        messageMock.SetupGet(x => x.Content).Returns(content);
+        messageMock.SetupGet(x => x.Embeds).Returns(new[] { embed });
+
+        string[] parts = messageMock.Object.GetText().Split('\n', StringSplitOptions.RemoveEmptyEntries);
+
+        messageMock.VerifyGet(x => x.Content);
+        messageMock.VerifyGet(x => x.Embeds);
+
+        int index = 0;
+        Assert.True(parts.Length >= 3);
+        Assert.Equal(content, parts[index++]);
+        if (embed.Author is not null)
+        {
+            Assert.Equal(embed.Author.Value.Name, parts[index++]);
+        }
+        Assert.Equal(embed.Title, parts[index++]);
+        Assert.Equal(embed.Description, parts[index]);
+        if (embed.Footer is not null)
+        {
+            Assert.Equal(embed.Footer.Value.Text, parts[^1]);
+        }
+    }
+
+    private static IEnumerable<object[]> GetEmbeds()
+    {
+        return new Faker().MakeLazy(10, Utils.CreateFakeEmbedBuilder).Select(x => new object[] { x.Build() });
+    }
+
+    private static IEnumerable<object[]> GetContentsAndEmbeds()
+    {
+        return GetRandomStrings().Zip(GetEmbeds()).Select(x => new[] { x.First[0], x.Second[0] });
+    }
+
+    private static IEnumerable<object[]> GetRandomStrings()
+    {
+        var faker = new Faker();
+
+        return faker.MakeLazy(10, () => faker.Random.String2(2))
+            .Select(x => new object[] { x });
+    }
+}
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Extensions/StringExtensionsTests.cs b/tests/Fergun.Tests/Extensions/StringExtensionsTests.cs
new file mode 100644
index 0000000..e76ff63
--- /dev/null
+++ b/tests/Fergun.Tests/Extensions/StringExtensionsTests.cs
@@ -0,0 +1,22 @@
+using Fergun.Extensions;
+using Xunit;
+
+namespace Fergun.Tests.Extensions;
+
+public class StringExtensionsTests
+{
+    [Theory]
+    [InlineData("one two three", "one", "three")]
+    [InlineData("1234", "123", "456")]
+    [InlineData("1234", "012", "234")]
+    [InlineData("abcde", "efg", "hij")]
+    public void String_ContainsAny_Should_Return_Expected(string str, string str0, string str1)
+    {
+        bool containsFirst = str.Contains(str0);
+        bool containsSecond = str.Contains(str1);
+
+        bool containsAny = str.ContainsAny(str0, str1);
+
+        Assert.Equal(containsAny, containsFirst || containsSecond);
+    }
+}
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Extensions/TimestampExtensionsTests.cs b/tests/Fergun.Tests/Extensions/TimestampExtensionsTests.cs
new file mode 100644
index 0000000..0a3d56f
--- /dev/null
+++ b/tests/Fergun.Tests/Extensions/TimestampExtensionsTests.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Bogus;
+using Fergun.Extensions;
+using Xunit;
+
+namespace Fergun.Tests.Extensions;
+
+public class TimestampExtensionsTests
+{
+    [Theory]
+    [MemberData(nameof(GetDatesAndStyles))]
+    public void DateTimeOffset_ToDiscordTimestamp_Should_Return_Expected(DateTimeOffset dateTimeOffset, char style)
+    {
+        var unixSeconds = dateTimeOffset.ToUnixTimeSeconds();
+
+        string timestamp = dateTimeOffset.ToDiscordTimestamp(style);
+
+        Assert.Equal(timestamp, $"<t:{unixSeconds}:{style}>");
+    }
+
+    private static IEnumerable<object[]> GetDatesAndStyles()
+    {
+        var faker = new Faker();
+        return faker.MakeLazy(10, () => (faker.Date.BetweenOffset(DateTimeOffset.MinValue, DateTimeOffset.MaxValue), faker.Random.Char()))
+            .Select(x => new object[] { x.Item1, x.Item2 });
+    }
+}
\ No newline at end of file
diff --git a/tests/Fergun.Tests/ExtensionsTests.cs b/tests/Fergun.Tests/ExtensionsTests.cs
deleted file mode 100644
index e8b0047..0000000
--- a/tests/Fergun.Tests/ExtensionsTests.cs
+++ /dev/null
@@ -1,259 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using Bogus;
-using Discord;
-using Fergun.Extensions;
-using GTranslate;
-using Moq;
-using Xunit;
-
-namespace Fergun.Tests;
-
-public class ExtensionsTests
-{
-    // Channel extensions
-    [Fact]
-    public void IMessageChannel_IsNsfw_Should_Return_True_When_Channel_Is_NSFW()
-    {
-        var channelMock1 = new Mock<ITextChannel>();
-        channelMock1.SetupGet(x => x.IsNsfw).Returns(true);
-
-        var channelMock2 = new Mock<ITextChannel>();
-        channelMock2.SetupGet(x => x.IsNsfw).Returns(false);
-
-        var channelMock3 = new Mock<IDMChannel>();
-
-        Assert.True(channelMock1.Object.IsNsfw());
-        Assert.False(channelMock2.Object.IsNsfw());
-        Assert.False(channelMock3.Object.IsNsfw());
-    }
-
-    [Fact]
-    public void IChannel_IsPrivate_Should_Return_True_When_Channel_Is_IPrivateChannel()
-    {
-        var channelMock1 = new Mock<IChannel>();
-        var channelMock2 = new Mock<ITextChannel>();
-        var channelMock3 = new Mock<IDMChannel>();
-
-        Assert.False(channelMock1.Object.IsPrivate());
-        Assert.False(channelMock2.Object.IsPrivate());
-        Assert.True(channelMock3.Object.IsPrivate());
-    }
-
-    // Interaction extensions
-    [Fact]
-    public async Task Interaction_RespondWarningAsync_Should_Call_RespondAsync_Once()
-    {
-        var interactionMock = new Mock<IDiscordInteraction>();
-        await interactionMock.Object.RespondWarningAsync(It.IsAny<string>(), It.IsAny<bool>());
-
-        interactionMock.Verify(x => x.RespondAsync(It.IsAny<string>(), It.IsAny<Embed[]>(),
-            It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
-    }
-
-    [Fact]
-    public async Task Interaction_FollowupWarningAsync_Should_Call_FollowupAsync_Once()
-    {
-        var interactionMock = new Mock<IDiscordInteraction>();
-        await interactionMock.Object.FollowupWarning(It.IsAny<string>(), It.IsAny<bool>());
-
-        interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(),
-            It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
-    }
-
-    [Theory]
-    [MemberData(nameof(GetLocales))]
-    [MemberData(nameof(GetRandomStrings))]
-    public void Interaction_GetLanguageCode_Should_Return_Code(string locale)
-    {
-        string expected = locale.Contains('-') ? locale.Split('-')[0] : locale;
-
-        var interactionMock = new Mock<IDiscordInteraction>();
-        interactionMock.SetupGet(x => x.UserLocale).Returns(locale);
-
-        string language = interactionMock.Object.GetLanguageCode();
-        Assert.Equal(expected, language);
-    }
-
-    [Theory]
-    [InlineData("", "en")]
-    [InlineData(null, "es")]
-    public void Interaction_GetLanguageCode_Should_Return_Default(string locale, string defaultLanguage)
-    {
-        var interactionMock = new Mock<IDiscordInteraction>();
-        interactionMock.SetupGet(x => x.UserLocale).Returns(locale);
-        var interactionMock2 = new Mock<IDiscordInteraction>();
-        interactionMock2.SetupGet(x => x.UserLocale).Returns((string)null!);
-        interactionMock2.SetupGet(x => x.GuildLocale).Returns(locale);
-
-        string language = interactionMock.Object.GetLanguageCode(defaultLanguage);
-        string language2 = interactionMock2.Object.GetLanguageCode(defaultLanguage);
-
-        interactionMock.VerifyGet(x => x.UserLocale, Times.Once);
-        interactionMock2.VerifyGet(x => x.UserLocale, Times.Once);
-        interactionMock2.VerifyGet(x => x.GuildLocale, Times.Once);
-        Assert.Equal(defaultLanguage, language);
-        Assert.Equal(defaultLanguage, language2);
-    }
-
-    [Theory]
-    [MemberData(nameof(GetLanguages))]
-    public void Interaction_TryGetLanguage_Should_Return_True_If_Valid(string language)
-    {
-        var interactionMock = new Mock<IDiscordInteraction>();
-        if (Random.Shared.Next(2) == 0)
-        {
-            interactionMock.SetupGet(x => x.UserLocale).Returns(language);
-        }
-        else
-        {
-            interactionMock.SetupGet(x => x.GuildLocale).Returns(language);
-        }
-
-        bool success = interactionMock.Object.TryGetLanguage(out _);
-
-        Assert.True(success);
-    }
-
-    // Message extensions
-    [Theory]
-    [MemberData(nameof(GetRandomStrings))]
-    public void IMessage_GetText_Should_Return_Text_From_Content(string content)
-    {
-        var messageMock = new Mock<IMessage>();
-        messageMock.SetupGet(x => x.Content).Returns(content);
-        messageMock.SetupGet(x => x.Embeds).Returns(Array.Empty<IEmbed>());
-
-        string text = messageMock.Object.GetText();
-
-        messageMock.VerifyGet(x => x.Content);
-        Assert.Equal(content, text);
-    }
-
-    [Theory]
-    [MemberData(nameof(GetContentsAndEmbeds))]
-    public void IMessage_GetText_Should_Return_Text_From_Content_And_Embed(string content, Embed embed)
-    {
-        var messageMock = new Mock<IMessage>();
-        messageMock.SetupGet(x => x.Content).Returns(content);
-        messageMock.SetupGet(x => x.Embeds).Returns(new[] { embed });
-
-        string[] parts = messageMock.Object.GetText().Split('\n', StringSplitOptions.RemoveEmptyEntries);
-
-        messageMock.VerifyGet(x => x.Content);
-        messageMock.VerifyGet(x => x.Embeds);
-
-        int index = 0;
-        Assert.True(parts.Length >= 3);
-        Assert.Equal(content, parts[index++]);
-        if (embed.Author is not null)
-        {
-            Assert.Equal(embed.Author.Value.Name, parts[index++]);
-        }
-        Assert.Equal(embed.Title, parts[index++]);
-        Assert.Equal(embed.Description, parts[index]);
-        if (embed.Footer is not null)
-        {
-            Assert.Equal(embed.Footer.Value.Text, parts[^1]);
-        }
-    }
-
-    // String extensions
-    [Theory]
-    [InlineData("one two three", "one", "three")]
-    [InlineData("1234", "123", "456")]
-    [InlineData("1234", "012", "234")]
-    [InlineData("abcde", "efg", "hij")]
-    public void String_ContainsAny_Should_Return_Expected(string str, string str0, string str1)
-    {
-        bool containsFirst = str.Contains(str0);
-        bool containsSecond = str.Contains(str1);
-
-        bool containsAny = str.ContainsAny(str0, str1);
-
-        Assert.Equal(containsAny, containsFirst || containsSecond);
-    }
-
-    // Timestamp extensions
-    [Theory]
-    [MemberData(nameof(GetDatesAndStyles))]
-    public void DateTimeOffset_ToDiscordTimestamp_Should_Return_Expected(DateTimeOffset dateTimeOffset, char style)
-    {
-        var unixSeconds = dateTimeOffset.ToUnixTimeSeconds();
-
-        string timestamp = dateTimeOffset.ToDiscordTimestamp(style);
-
-        Assert.Equal(timestamp, $"<t:{unixSeconds}:{style}>");
-    }
-
-    private static IEnumerable<object[]> GetLocales()
-    {
-        var faker = new Faker();
-
-        return faker.MakeLazy(10, () => faker.Random.RandomLocale().Replace('_', '-'))
-            .Select(x => new object[] { x });
-    }
-
-    private static IEnumerable<object[]> GetRandomStrings()
-    {
-        var faker = new Faker();
-
-        return faker.MakeLazy(10, () => faker.Random.String2(2))
-            .Select(x => new object[] { x });
-    }
-
-    private static IEnumerable<object?[]> GetLanguages()
-    {
-        var faker = new Faker();
-
-        return Language.LanguageDictionary.Values
-            .Select(x => x.ISO6391.Contains('-') ? x.ISO6391 : $"{x.ISO6391}-{faker.Random.String2(2)}")
-            .Append(null)
-            .Select(x => new object?[] { x });
-    }
-
-    private static IEnumerable<object[]> GetEmbeds()
-    {
-        var embedFaker = new Faker<EmbedBuilder>()
-            .StrictMode(false)
-            .RuleFor(x => x.Author, f => new EmbedAuthorBuilder
-            {
-                IconUrl = f.Internet.Avatar(),
-                Name = f.Internet.UserName(),
-                Url = f.Internet.Url()
-            }.OrNull(f))
-            .RuleFor(x => x.Title, f => f.Lorem.Sentence())
-            .RuleFor(x => x.Description, f => f.Lorem.Paragraph())
-            .RuleFor(x => x.Fields, f =>
-            {
-                return f.MakeLazy(f.Random.Number(1, 10), () => new EmbedFieldBuilder
-                {
-                    IsInline = f.Random.Bool(),
-                    Name = f.Random.Word(),
-                    Value = f.Random.Word()
-                }).Append(new EmbedFieldBuilder { Name = "\u200b", Value = "\u200b" }).ToList();
-            })
-            .RuleFor(x => x.Footer, f => new EmbedFooterBuilder
-            {
-                IconUrl = f.Image.PlaceImgUrl(),
-                Text = f.Commerce.ProductName()
-            }.OrNull(f))
-            .RuleFor(x => x.Color, f => new Color(f.Random.UInt(0, 0xFFFFFFU)));
-
-        return embedFaker.GenerateLazy(10).Select(x => new object[] { x.Build() });
-    }
-
-    private static IEnumerable<object[]> GetContentsAndEmbeds()
-    {
-        return GetRandomStrings().Zip(GetEmbeds()).Select(x => new[] { x.First[0], x.Second[0] });
-    }
-
-    private static IEnumerable<object[]> GetDatesAndStyles()
-    {
-        var faker = new Faker();
-        return faker.MakeLazy(10, () => (faker.Date.BetweenOffset(DateTimeOffset.MinValue, DateTimeOffset.MaxValue), faker.Random.Char()))
-            .Select(x => new object[] { x.Item1, x.Item2 });
-    }
-}
\ No newline at end of file
diff --git a/tests/Fergun.Tests/AutocompleteHandlerTests.cs b/tests/Fergun.Tests/Modules/Handlers/AutocompleteHandlerTests.cs
similarity index 99%
rename from tests/Fergun.Tests/AutocompleteHandlerTests.cs
rename to tests/Fergun.Tests/Modules/Handlers/AutocompleteHandlerTests.cs
index 81d6907..48910ed 100644
--- a/tests/Fergun.Tests/AutocompleteHandlerTests.cs
+++ b/tests/Fergun.Tests/Modules/Handlers/AutocompleteHandlerTests.cs
@@ -13,7 +13,7 @@
 using Moq;
 using Xunit;
 
-namespace Fergun.Tests;
+namespace Fergun.Tests.Modules.Handlers;
 
 public class AutocompleteHandlerTests
 {
diff --git a/tests/Fergun.Tests/ImageModuleTests.cs b/tests/Fergun.Tests/Modules/ImageModuleTests.cs
similarity index 99%
rename from tests/Fergun.Tests/ImageModuleTests.cs
rename to tests/Fergun.Tests/Modules/ImageModuleTests.cs
index dcd5f64..61b4c0e 100644
--- a/tests/Fergun.Tests/ImageModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/ImageModuleTests.cs
@@ -14,7 +14,7 @@
 using Moq;
 using Xunit;
 
-namespace Fergun.Tests;
+namespace Fergun.Tests.Modules;
 
 public class ImageModuleTests
 {
@@ -35,10 +35,7 @@ public ImageModuleTests()
         var logger = Mock.Of<ILogger<ImageModule>>();
         var interactive = new InteractiveService(_client, new InteractiveConfig { DeferStopSelectionInteractions = false, ReturnAfterSendingPaginator = true });
         _moduleMock = new Mock<ImageModule>(() => new ImageModule(logger, _localizer, interactive, _googleScraper,
-            _duckDuckGoScraper, _braveScraper, _bingVisualSearch, _yandexImageSearch))
-        {
-            CallBase = true
-        };
+            _duckDuckGoScraper, _braveScraper, _bingVisualSearch, _yandexImageSearch)) { CallBase = true };
 
         _module = _moduleMock.Object;
         _interactionMock.SetupGet(x => x.User).Returns(() => Utils.CreateMockedUser());
diff --git a/tests/Fergun.Tests/OcrModuleTests.cs b/tests/Fergun.Tests/Modules/OcrModuleTests.cs
similarity index 84%
rename from tests/Fergun.Tests/OcrModuleTests.cs
rename to tests/Fergun.Tests/Modules/OcrModuleTests.cs
index db52aa5..8c60493 100644
--- a/tests/Fergun.Tests/OcrModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/OcrModuleTests.cs
@@ -12,7 +12,7 @@
 using Moq;
 using Xunit;
 
-namespace Fergun.Tests;
+namespace Fergun.Tests.Modules;
 
 public class OcrModuleTests
 {
@@ -24,7 +24,8 @@ public class OcrModuleTests
     private readonly DiscordSocketClient _client = new();
     private readonly InteractiveService _interactive;
     private readonly InteractiveConfig _interactiveConfig = new() { DeferStopSelectionInteractions = false };
-    private readonly Mock<OcrModule> _ocrModuleMock;
+    private readonly IFergunLocalizer<OcrModule> _ocrLocalizer = Utils.CreateMockedLocalizer<OcrModule>();
+    private readonly Mock<OcrModule> _moduleMock;
     private const string _textImageUrl = "https://example.com/image.png";
     private const string _emptyImageUrl = "https://example.com/empty.png";
     private const string _invalidImageUrl = "https://example.com/file.bin";
@@ -40,13 +41,21 @@ public OcrModuleTests()
 
         var sharedLogger = Mock.Of<ILogger<SharedModule>>();
         var sharedLocalizer = Utils.CreateMockedLocalizer<SharedResource>();
-        var ocrLocalizer = Utils.CreateMockedLocalizer<OcrModule>();
         var shared = new SharedModule(sharedLogger, sharedLocalizer, new AggregateTranslator(), new());
 
         _interactive = new InteractiveService(_client, _interactiveConfig);
-        _ocrModuleMock = new Mock<OcrModule>(() => new OcrModule(_loggerMock.Object, ocrLocalizer, shared, _interactive, _bingVisualSearchMock.Object, _yandexImageSearchMock.Object));
+        _moduleMock = new Mock<OcrModule>(() => new OcrModule(_loggerMock.Object, _ocrLocalizer, shared, _interactive,
+            _bingVisualSearchMock.Object, _yandexImageSearchMock.Object)) { CallBase = true };
         _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
-        ((IInteractionModuleBase)_ocrModuleMock.Object).SetContext(_contextMock.Object);
+        ((IInteractionModuleBase)_moduleMock.Object).SetContext(_contextMock.Object);
+    }
+
+    [Fact]
+    public void BeforeExecute_Sets_Language()
+    {
+        _interactionMock.SetupGet(x => x.UserLocale).Returns("en");
+        _moduleMock.Object.BeforeExecute(It.IsAny<ICommandInfo>());
+        Assert.Equal("en", _ocrLocalizer.CurrentCulture.TwoLetterISOLanguageName);
     }
 
     [Theory]
@@ -54,7 +63,7 @@ public OcrModuleTests()
     [InlineData(_emptyImageUrl)]
     public async Task Bing_Uses_BingVisualSearch(string url)
     {
-        var module = _ocrModuleMock.Object;
+        var module = _moduleMock.Object;
         const bool isEphemeral = false;
 
         await module.Bing(url);
@@ -72,7 +81,7 @@ public async Task Bing_Uses_BingVisualSearch(string url)
     [InlineData(_emptyImageUrl)]
     public async Task Yandex_Uses_YandexImageSearch(string url)
     {
-        var module = _ocrModuleMock.Object;
+        var module = _moduleMock.Object;
         const bool isEphemeral = false;
 
         await module.Yandex(url);
@@ -88,7 +97,7 @@ public async Task Yandex_Uses_YandexImageSearch(string url)
     [Fact]
     public async Task OcrAsync_Returns_Warning_If_Url_Is_Invalid()
     {
-        var module = _ocrModuleMock.Object;
+        var module = _moduleMock.Object;
         const bool isEphemeral = true;
 
         await module.OcrAsync(It.IsAny<OcrModule.OcrEngine>(), string.Empty, _interactionMock.Object, isEphemeral);
@@ -100,7 +109,7 @@ public async Task OcrAsync_Returns_Warning_If_Url_Is_Invalid()
     [Fact]
     public async Task Invalid_OcrEngine_Throws_ArgumentException()
     {
-        var module = _ocrModuleMock.Object;
+        var module = _moduleMock.Object;
         const bool isEphemeral = true;
 
         var task = module.OcrAsync((OcrModule.OcrEngine)2, _textImageUrl, _interactionMock.Object, isEphemeral);
@@ -111,7 +120,7 @@ public async Task Invalid_OcrEngine_Throws_ArgumentException()
     [Fact]
     public async Task OcrAsync_Returns_Warning_On_Exception()
     {
-        var module = _ocrModuleMock.Object;
+        var module = _moduleMock.Object;
         const bool isEphemeral = true;
 
         await module.OcrAsync(It.IsAny<OcrModule.OcrEngine>(), _invalidImageUrl, _interactionMock.Object, isEphemeral);
diff --git a/tests/Fergun.Tests/SharedModuleTests.cs b/tests/Fergun.Tests/Modules/SharedModuleTests.cs
similarity index 99%
rename from tests/Fergun.Tests/SharedModuleTests.cs
rename to tests/Fergun.Tests/Modules/SharedModuleTests.cs
index 389f984..42f0c12 100644
--- a/tests/Fergun.Tests/SharedModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/SharedModuleTests.cs
@@ -10,7 +10,7 @@
 using Moq;
 using Xunit;
 
-namespace Fergun.Tests;
+namespace Fergun.Tests.Modules;
 
 public class SharedModuleTests
 {
diff --git a/tests/Fergun.Tests/UrbanModuleTests.cs b/tests/Fergun.Tests/Modules/UrbanModuleTests.cs
similarity index 76%
rename from tests/Fergun.Tests/UrbanModuleTests.cs
rename to tests/Fergun.Tests/Modules/UrbanModuleTests.cs
index 86d5227..42857ff 100644
--- a/tests/Fergun.Tests/UrbanModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/UrbanModuleTests.cs
@@ -11,44 +11,49 @@
 using Fergun.Apis.Urban;
 using Fergun.Interactive;
 using Fergun.Modules;
-using Microsoft.Extensions.Localization;
 using Moq;
 using Moq.Protected;
 using Xunit;
 
-namespace Fergun.Tests;
+namespace Fergun.Tests.Modules;
 
 public class UrbanModuleTests
 {
     private readonly Mock<IInteractionContext> _contextMock = new();
     private readonly Mock<IDiscordInteraction> _interactionMock = new();
     private readonly Mock<IUrbanDictionary> _urbanDictionaryMock = CreateMockedUrbanDictionary();
-    private readonly Mock<UrbanModule> _urbanModuleMock;
+    private readonly Mock<UrbanModule> _moduleMock;
     private readonly DiscordSocketClient _client = new();
     private readonly InteractiveConfig _interactiveConfig = new() { ReturnAfterSendingPaginator = true };
+    private readonly IFergunLocalizer<UrbanModule> _localizer = Utils.CreateMockedLocalizer<UrbanModule>();
 
     public UrbanModuleTests()
     {
         var interactive = new InteractiveService(_client, _interactiveConfig);
-        var urbanLocalizer = new Mock<IFergunLocalizer<UrbanModule>>();
-        urbanLocalizer.Setup(x => x[It.IsAny<string>()]).Returns<string>(s => new LocalizedString(s, s));
-        urbanLocalizer.Setup(x => x[It.IsAny<string>(), It.IsAny<object[]>()]).Returns<string, object[]>((s, p) => new LocalizedString(s, string.Format(s, p)));
 
-        _urbanModuleMock = new Mock<UrbanModule>(() => new UrbanModule(urbanLocalizer.Object, _urbanDictionaryMock.Object, interactive));
+        _moduleMock = new Mock<UrbanModule>(() => new UrbanModule(_localizer, _urbanDictionaryMock.Object, interactive)) { CallBase = true };
         _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
         _contextMock.SetupGet(x => x.User).Returns(() => AutoFaker.Generate<IUser>(b => b.WithBinder(new MoqBinder())));
-        ((IInteractionModuleBase)_urbanModuleMock.Object).SetContext(_contextMock.Object);
+        ((IInteractionModuleBase)_moduleMock.Object).SetContext(_contextMock.Object);
+    }
+
+    [Fact]
+    public void BeforeExecute_Sets_Language()
+    {
+        _interactionMock.SetupGet(x => x.UserLocale).Returns("en");
+        _moduleMock.Object.BeforeExecute(It.IsAny<ICommandInfo>());
+        Assert.Equal("en", _localizer.CurrentCulture.TwoLetterISOLanguageName);
     }
 
     [MemberData(nameof(GetRandomWords))]
     [Theory]
     public async Task Search_Calls_GetDefinitionsAsync(string term)
     {
-        var module = _urbanModuleMock.Object;
+        var module = _moduleMock.Object;
 
         await module.Search(term);
 
-        _urbanModuleMock.Protected().Verify<Task>("DeferAsync", Times.Once(), ItExpr.IsAny<bool>(), ItExpr.IsAny<RequestOptions>());
+        _moduleMock.Protected().Verify<Task>("DeferAsync", Times.Once(), ItExpr.IsAny<bool>(), ItExpr.IsAny<RequestOptions>());
         _urbanDictionaryMock.Verify(u => u.GetDefinitionsAsync(It.Is<string>(x => x == term)), Times.Once);
         int count = (await _urbanDictionaryMock.Object.GetDefinitionsAsync(It.IsAny<string>())).Count;
 
@@ -62,29 +67,29 @@ public async Task Search_Calls_GetDefinitionsAsync(string term)
     [Fact]
     public async Task Random_Calls_GetRandomDefinitionsAsync()
     {
-        var module = _urbanModuleMock.Object;
+        var module = _moduleMock.Object;
 
         await module.Random();
 
-        _urbanModuleMock.Protected().Verify<Task>("DeferAsync", Times.Once(), ItExpr.IsAny<bool>(), ItExpr.IsAny<RequestOptions>());
+        _moduleMock.Protected().Verify<Task>("DeferAsync", Times.Once(), ItExpr.IsAny<bool>(), ItExpr.IsAny<RequestOptions>());
         _urbanDictionaryMock.Verify(u => u.GetRandomDefinitionsAsync(), Times.Once);
     }
 
     [Fact]
     public async Task WordsOfTheDay_Calls_GetWordsOfTheDayAsync()
     {
-        var module = _urbanModuleMock.Object;
+        var module = _moduleMock.Object;
 
         await module.WordsOfTheDay();
 
-        _urbanModuleMock.Protected().Verify<Task>("DeferAsync", Times.Once(), ItExpr.IsAny<bool>(), ItExpr.IsAny<RequestOptions>());
+        _moduleMock.Protected().Verify<Task>("DeferAsync", Times.Once(), ItExpr.IsAny<bool>(), ItExpr.IsAny<RequestOptions>());
         _urbanDictionaryMock.Verify(u => u.GetWordsOfTheDayAsync(), Times.Once);
     }
 
     [Fact]
     public async Task Invalid_SearchType_Throws_ArgumentException()
     {
-        var module = _urbanModuleMock.Object;
+        var module = _moduleMock.Object;
 
         var task = module.SearchAndSendAsync((UrbanModule.UrbanSearchType)3);
 
diff --git a/tests/Fergun.Tests/UserModuleTests.cs b/tests/Fergun.Tests/Modules/UserModuleTests.cs
similarity index 73%
rename from tests/Fergun.Tests/UserModuleTests.cs
rename to tests/Fergun.Tests/Modules/UserModuleTests.cs
index 08622a2..0d2998a 100644
--- a/tests/Fergun.Tests/UserModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/UserModuleTests.cs
@@ -5,35 +5,38 @@
 using Discord;
 using Discord.Interactions;
 using Fergun.Modules;
-using Microsoft.Extensions.Localization;
 using Moq;
-using Moq.Protected;
 using Xunit;
 
-namespace Fergun.Tests;
+namespace Fergun.Tests.Modules;
 
 public class UserModuleTests
 {
     private readonly Mock<IInteractionContext> _contextMock = new();
     private readonly Mock<IDiscordInteraction> _interactionMock = new();
-    private readonly Mock<UserModule> _userModuleMock;
+    private readonly IFergunLocalizer<UserModule> _localizer = Utils.CreateMockedLocalizer<UserModule>();
+    private readonly Mock<UserModule> _moduleMock;
 
     public UserModuleTests()
     {
-        var userLocalizer = new Mock<IFergunLocalizer<UserModule>>();
-        userLocalizer.Setup(x => x[It.IsAny<string>()]).Returns<string>(s => new LocalizedString(s, s));
-        userLocalizer.Setup(x => x[It.IsAny<string>(), It.IsAny<object[]>()]).Returns<string, object[]>((s, p) => new LocalizedString(s, string.Format(s, p)));
-
-        _userModuleMock = new Mock<UserModule>(() => new UserModule(userLocalizer.Object));
+        _moduleMock = new Mock<UserModule>(() => new UserModule(_localizer)) { CallBase = true };
         _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
-        ((IInteractionModuleBase)_userModuleMock.Object).SetContext(_contextMock.Object);
+        ((IInteractionModuleBase)_moduleMock.Object).SetContext(_contextMock.Object);
+    }
+
+    [Fact]
+    public void BeforeExecute_Sets_Language()
+    {
+        _interactionMock.SetupGet(x => x.UserLocale).Returns("en");
+        _moduleMock.Object.BeforeExecute(It.IsAny<ICommandInfo>());
+        Assert.Equal("en", _localizer.CurrentCulture.TwoLetterISOLanguageName);
     }
 
     [Theory]
     [MemberData(nameof(GetFakeUsers))]
     public async Task Avatar_Should_Return_Embed_With_Avatar(Mock<IUser> userMock)
     {
-        await _userModuleMock.Object.Avatar(userMock.Object);
+        await _moduleMock.Object.Avatar(userMock.Object);
 
         userMock.Verify(x => x.ToString());
         userMock.Verify(x => x.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>()));
@@ -49,7 +52,7 @@ public async Task Avatar_Should_Return_Embed_With_Avatar(Mock<IUser> userMock)
     [MemberData(nameof(GetFakeGuildUsers))]
     public async Task Avatar_Should_Return_Embed_With_Guild_Avatar(Mock<IGuildUser> guildUserMock)
     {
-        await _userModuleMock.Object.Avatar(guildUserMock.Object);
+        await _moduleMock.Object.Avatar(guildUserMock.Object);
 
         guildUserMock.Verify(x => x.ToString());
         guildUserMock.Verify(x => x.GetGuildAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>()));
@@ -60,7 +63,7 @@ public async Task Avatar_Should_Return_Embed_With_Guild_Avatar(Mock<IGuildUser>
     [MemberData(nameof(GetFakeUsers))]
     public async Task UserInfo_Should_Return_Embed_With_Avatar(Mock<IUser> userMock)
     {
-        await _userModuleMock.Object.UserInfo(userMock.Object);
+        await _moduleMock.Object.UserInfo(userMock.Object);
 
         userMock.Verify(x => x.ToString());
         userMock.Verify(x => x.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>()));
@@ -82,7 +85,7 @@ public async Task UserInfo_Should_Return_Embed_With_Avatar(Mock<IUser> userMock)
     [MemberData(nameof(GetFakeGuildUsers))]
     public async Task UserInfo_Should_Return_Embed_With_Guild_Avatar(Mock<IGuildUser> guildUserMock)
     {
-        await _userModuleMock.Object.UserInfo(guildUserMock.Object);
+        await _moduleMock.Object.UserInfo(guildUserMock.Object);
 
         guildUserMock.Verify(x => x.ToString());
         guildUserMock.Verify(x => x.GetGuildAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>()));
@@ -97,13 +100,12 @@ public async Task UserInfo_Should_Return_Embed_With_Guild_Avatar(Mock<IGuildUser
 
         VerifyRespondAsyncCall(guildUserMock.Object);
     }
-
+    
     private void VerifyRespondAsyncCall(IUser user)
     {
-        _userModuleMock.Protected().Verify<Task>("RespondAsync", Times.Once(), ItExpr.IsAny<string>(),
-            ItExpr.IsAny<Embed[]>(), ItExpr.IsAny<bool>(), ItExpr.IsAny<bool>(), ItExpr.IsAny<AllowedMentions>(),
-            ItExpr.IsAny<RequestOptions>(), ItExpr.IsAny<MessageComponent>(),
-            ItExpr.Is<Embed>(e => EmbedImageUrlIsUserAvatarUrl(user, e)));
+        _interactionMock.Verify(x => x.RespondAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(),
+            It.IsAny<bool>(), It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(),
+            It.Is<Embed>(e => EmbedImageUrlIsUserAvatarUrl(user, e)), It.IsAny<RequestOptions>()), Times.Once);
     }
 
     private static bool EmbedImageUrlIsUserAvatarUrl(IUser user, Embed embed)
diff --git a/tests/Fergun.Tests/Utils.cs b/tests/Fergun.Tests/Utils.cs
index c5b001a..8544db8 100644
--- a/tests/Fergun.Tests/Utils.cs
+++ b/tests/Fergun.Tests/Utils.cs
@@ -34,13 +34,13 @@ public static IUser CreateMockedUser(Faker? faker = null)
         userMock.SetupGet(x => x.Username).Returns(faker.Internet.UserName());
         userMock.SetupGet(x => x.DiscriminatorValue).Returns(faker.Random.UShort(1, 9999));
         userMock.SetupGet(x => x.Discriminator).Returns(() => userMock.Object.DiscriminatorValue.ToString("D4"));
-        userMock.SetupGet(x => x.Activities).Returns(() => faker.MakeLazy(faker.Random.Number(3), () => new Game(faker.Hacker.IngVerb(), faker.Random.Enum(ActivityType.CustomStatus))
-            .OrDefault(faker, 0.5f, CreateCustomStatusGame(faker))).ToArray());
-        userMock.SetupGet(x => x.ActiveClients).Returns(() => faker.MakeLazy(faker.Random.Number(3),
+        userMock.SetupGet(x => x.Activities).Returns(faker.MakeLazy(faker.Random.Number(3), () => new Game(faker.Hacker.IngVerb(), faker.Random.Enum(ActivityType.CustomStatus))
+            .OrDefault(faker, 0.5f, CreateFakeCustomStatusGame(faker))).ToArray());
+        userMock.SetupGet(x => x.ActiveClients).Returns(faker.MakeLazy(faker.Random.Number(3),
             () => faker.PickRandom(Enum.GetValues<ClientType>()).OrDefault(faker, 0.5f, (ClientType)3)).ToArray());
-        userMock.SetupGet(x => x.CreatedAt).Returns(() => faker.Date.PastOffset(5));
-        userMock.SetupGet(x => x.Id).Returns(() => faker.Random.ULong());
-        userMock.SetupGet(x => x.IsBot).Returns(() => faker.Random.Bool());
+        userMock.SetupGet(x => x.CreatedAt).Returns(faker.Date.PastOffset(5));
+        userMock.SetupGet(x => x.Id).Returns(faker.Random.ULong());
+        userMock.SetupGet(x => x.IsBot).Returns(faker.Random.Bool());
         userMock.Setup(x => x.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>())).Returns(faker.Internet.Avatar().OrNull(faker));
         userMock.Setup(x => x.GetDefaultAvatarUrl()).Returns(faker.Internet.Avatar());
         userMock.Setup(x => x.ToString()).Returns(() => $"{userMock.Object.Username}#{userMock.Object.Discriminator}");
@@ -55,26 +55,26 @@ public static IGuildUser CreateMockedGuildUser(Faker? faker = null)
 
         userMock.SetupGet(x => x.Username).Returns(faker.Internet.UserName());
         userMock.SetupGet(x => x.DiscriminatorValue).Returns(faker.Random.UShort(1, 9999));
-        userMock.SetupGet(x => x.Discriminator).Returns(() => userMock.Object.DiscriminatorValue.ToString("D4"));
-        userMock.SetupGet(x => x.Activities).Returns(() => faker.MakeLazy(faker.Random.Number(3), () => new Game(faker.Hacker.IngVerb(), faker.Random.Enum(ActivityType.CustomStatus))
-            .OrDefault(faker, 0.5f, CreateCustomStatusGame(faker))).ToArray());
-        userMock.SetupGet(x => x.ActiveClients).Returns(() => faker.MakeLazy(faker.Random.Number(3),
+        userMock.SetupGet(x => x.Discriminator).Returns(userMock.Object.DiscriminatorValue.ToString("D4"));
+        userMock.SetupGet(x => x.Activities).Returns(faker.MakeLazy(faker.Random.Number(3), () => new Game(faker.Hacker.IngVerb(), faker.Random.Enum(ActivityType.CustomStatus))
+            .OrDefault(faker, 0.5f, CreateFakeCustomStatusGame(faker))).ToArray());
+        userMock.SetupGet(x => x.ActiveClients).Returns(faker.MakeLazy(faker.Random.Number(3),
             () => faker.PickRandom(Enum.GetValues<ClientType>()).OrDefault(faker, 0.5f, (ClientType)3)).ToArray());
-        userMock.SetupGet(x => x.CreatedAt).Returns(() => faker.Date.PastOffset(5));
-        userMock.SetupGet(x => x.Id).Returns(() => faker.Random.ULong());
-        userMock.SetupGet(x => x.IsBot).Returns(() => faker.Random.Bool());
-        userMock.SetupGet(x => x.Nickname).Returns(() => faker.Internet.UserName().OrNull(faker));
-        userMock.SetupGet(x => x.JoinedAt).Returns(() => faker.Date.PastOffset());
-        userMock.SetupGet(x => x.PremiumSince).Returns(() => faker.Date.PastOffset().OrNull(faker));
+        userMock.SetupGet(x => x.CreatedAt).Returns(faker.Date.PastOffset(5));
+        userMock.SetupGet(x => x.Id).Returns(faker.Random.ULong());
+        userMock.SetupGet(x => x.IsBot).Returns(faker.Random.Bool());
+        userMock.SetupGet(x => x.Nickname).Returns(faker.Internet.UserName().OrNull(faker));
+        userMock.SetupGet(x => x.JoinedAt).Returns( faker.Date.PastOffset());
+        userMock.SetupGet(x => x.PremiumSince).Returns(faker.Date.PastOffset().OrNull(faker));
         userMock.Setup(x => x.GetGuildAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>())).Returns(faker.Internet.Avatar());
         userMock.Setup(x => x.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>())).Returns(faker.Internet.Avatar());
         userMock.Setup(x => x.GetDefaultAvatarUrl()).Returns(faker.Internet.Avatar());
-        userMock.Setup(x => x.ToString()).Returns(() => $"{userMock.Object.Username}#{userMock.Object.Discriminator}");
+        userMock.Setup(x => x.ToString()).Returns($"{userMock.Object.Username}#{userMock.Object.Discriminator}");
 
         return userMock.Object;
     }
 
-    public static CustomStatusGame CreateCustomStatusGame(Faker? faker = null)
+    public static CustomStatusGame CreateFakeCustomStatusGame(Faker? faker = null)
     {
         faker ??= new Faker();
 
@@ -85,10 +85,40 @@ public static CustomStatusGame CreateCustomStatusGame(Faker? faker = null)
         return status;
     }
 
+    public static EmbedBuilder CreateFakeEmbedBuilder()
+    {
+        return new Faker<EmbedBuilder>()
+            .StrictMode(false)
+            .RuleFor(x => x.Author, f => new EmbedAuthorBuilder
+            {
+                IconUrl = f.Internet.Avatar(),
+                Name = f.Internet.UserName(),
+                Url = f.Internet.Url()
+            }.OrNull(f))
+            .RuleFor(x => x.Title, f => f.Lorem.Sentence())
+            .RuleFor(x => x.Description, f => f.Lorem.Paragraph())
+            .RuleFor(x => x.Fields, f =>
+            {
+                return f.MakeLazy(f.Random.Number(1, 10), () => new EmbedFieldBuilder
+                {
+                    IsInline = f.Random.Bool(),
+                    Name = f.Random.Word(),
+                    Value = f.Random.Word()
+                }).Append(new EmbedFieldBuilder { Name = "\u200b", Value = "\u200b" }).ToList();
+            })
+            .RuleFor(x => x.Footer, f => new EmbedFooterBuilder
+            {
+                IconUrl = f.Image.PlaceImgUrl(),
+                Text = f.Commerce.ProductName()
+            }.OrNull(f))
+            .RuleFor(x => x.Color, f => new Color(f.Random.UInt(0, 0xFFFFFFU)))
+            .Generate();
+    }
+
     public static IBingVisualSearch CreateMockedBingVisualSearchApi(Faker? faker = null)
     {
-        var bingMock = new Mock<IBingVisualSearch>();
         faker ??= new Faker();
+        var bingMock = new Mock<IBingVisualSearch>();
 
         bingMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == null))).ThrowsAsync(new BingException("Error message."));
         bingMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == string.Empty))).ReturnsAsync(() => string.Empty);
@@ -102,22 +132,22 @@ public static IBingVisualSearch CreateMockedBingVisualSearchApi(Faker? faker = n
 
     public static IBingReverseImageSearchResult CreateMockedBingReverseImageSearchResult(Faker? faker = null)
     {
+        faker ??= new Faker();
         var resultMock = new Mock<IBingReverseImageSearchResult>();
-        faker = new Faker();
 
-        resultMock.SetupGet(x => x.Url).Returns(() => faker.Internet.Url());
-        resultMock.SetupGet(x => x.FriendlyDomainName).Returns(() => faker.Internet.DomainName().OrNull(faker));
-        resultMock.SetupGet(x => x.SourceUrl).Returns(() => faker.Internet.Url());
-        resultMock.SetupGet(x => x.Text).Returns(() => faker.Commerce.ProductDescription());
-        resultMock.SetupGet(x => x.AccentColor).Returns(() => System.Drawing.Color.FromArgb(faker.Random.Number((int)Color.MaxDecimalValue)));
+        resultMock.SetupGet(x => x.Url).Returns(faker.Internet.Url());
+        resultMock.SetupGet(x => x.FriendlyDomainName).Returns(faker.Internet.DomainName().OrNull(faker));
+        resultMock.SetupGet(x => x.SourceUrl).Returns(faker.Internet.Url());
+        resultMock.SetupGet(x => x.Text).Returns(faker.Commerce.ProductDescription());
+        resultMock.SetupGet(x => x.AccentColor).Returns(System.Drawing.Color.FromArgb(faker.Random.Number((int)Color.MaxDecimalValue)));
 
         return resultMock.Object;
     }
 
     public static IYandexImageSearch CreateMockedYandexImageSearchApi(Faker? faker = null)
     {
-        var yandexMock = new Mock<IYandexImageSearch>();
         faker ??= new Faker();
+        var yandexMock = new Mock<IYandexImageSearch>();
 
         yandexMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == null))).ThrowsAsync(new YandexException("Error message."));
         yandexMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == string.Empty))).ReturnsAsync(() => string.Empty);
@@ -131,13 +161,13 @@ public static IYandexImageSearch CreateMockedYandexImageSearchApi(Faker? faker =
 
     public static IYandexReverseImageSearchResult CreateMockedYandexReverseImageSearchResult(Faker? faker = null)
     {
+        faker ??= new Faker();
         var resultMock = new Mock<IYandexReverseImageSearchResult>();
-        faker = new Faker();
 
-        resultMock.SetupGet(x => x.Url).Returns(() => faker.Internet.Url());
-        resultMock.SetupGet(x => x.SourceUrl).Returns(() => faker.Internet.Url());
-        resultMock.SetupGet(x => x.Title).Returns(() => faker.Commerce.ProductName());
-        resultMock.SetupGet(x => x.Text).Returns(() => faker.Commerce.ProductDescription());
+        resultMock.SetupGet(x => x.Url).Returns(faker.Internet.Url());
+        resultMock.SetupGet(x => x.SourceUrl).Returns(faker.Internet.Url());
+        resultMock.SetupGet(x => x.Title).Returns(faker.Commerce.ProductName());
+        resultMock.SetupGet(x => x.Text).Returns(faker.Commerce.ProductDescription());
 
         return resultMock.Object;
     }

From 5fa5529438d42d007d41dcfc1fe97dbd42fb6a7c Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Tue, 19 Apr 2022 02:49:36 -0500
Subject: [PATCH 43/83] Use `FergunResult` and add `Async` suffix to commands

---
 src/Entities/FergunResult.cs                  | 53 +++++++++++
 src/FergunResult.cs                           | 15 ---
 src/Modules/ImageModule.cs                    | 74 ++++++++-------
 src/Modules/OcrModule.cs                      | 38 ++++----
 src/Modules/SharedModule.cs                   | 27 +++---
 src/Modules/UrbanModule.cs                    | 41 ++++----
 src/Modules/UserModule.cs                     |  8 +-
 src/Modules/UtilityModule.cs                  | 93 +++++++++++--------
 src/Resources/Modules.ImageModule.es.resx     |  3 +
 src/Resources/Modules.OcrModule.es.resx       |  3 +
 src/Resources/Modules.UrbanModule.es.resx     |  3 +
 src/Services/InteractionHandlingService.cs    | 20 ++--
 .../Fergun.Tests/Modules/ImageModuleTests.cs  | 74 ++++++++-------
 tests/Fergun.Tests/Modules/OcrModuleTests.cs  | 62 ++++++-------
 .../Fergun.Tests/Modules/SharedModuleTests.cs | 17 ++--
 .../Fergun.Tests/Modules/UrbanModuleTests.cs  | 88 ++++++------------
 tests/Fergun.Tests/Modules/UserModuleTests.cs | 20 ++--
 tests/Fergun.Tests/Utils.cs                   | 34 +++++++
 18 files changed, 381 insertions(+), 292 deletions(-)
 create mode 100644 src/Entities/FergunResult.cs
 delete mode 100644 src/FergunResult.cs

diff --git a/src/Entities/FergunResult.cs b/src/Entities/FergunResult.cs
new file mode 100644
index 0000000..5bdcac2
--- /dev/null
+++ b/src/Entities/FergunResult.cs
@@ -0,0 +1,53 @@
+using Discord;
+using Discord.Interactions;
+
+namespace Fergun;
+
+public class FergunResult : RuntimeResult
+{
+    private FergunResult(InteractionCommandError? error, string reason, bool isEphemeral, bool isSilent, IDiscordInteraction? interaction)
+        : base(error, reason)
+    {
+        IsEphemeral = isEphemeral;
+        IsSilent = isSilent;
+        Interaction = interaction;
+    }
+
+    /// <summary>
+    /// Gets a value indicating whether the response should be ephemeral.
+    /// </summary>
+    public bool IsEphemeral { get; }
+
+    /// <summary>
+    /// Gets a value indicating whether the response should be silent.
+    /// </summary>
+    public bool IsSilent { get; }
+
+    /// <summary>
+    /// Gets the interaction that should be responded to.
+    /// </summary>
+    public IDiscordInteraction? Interaction { get; }
+
+    /// <summary>
+    /// Creates a successful instance of the <see cref="FergunResult"/> class.
+    /// </summary>
+    /// <param name="reason">The reason.</param>
+    /// <returns>A <see cref="FergunResult"/>.</returns>
+    public static FergunResult FromSuccess(string? reason = null) => new(null, reason ?? string.Empty, false, false, null);
+
+    /// <summary>
+    /// Creates a <see cref="FergunResult"/> with error type <see cref="InteractionCommandError.Unsuccessful"/>.
+    /// </summary>
+    /// <param name="reason">The reason of the result.</param>
+    /// <param name="isEphemeral">Whether the response should be ephemeral.</param>
+    /// <param name="interaction">The interaction that should be responded to.</param>
+    /// <returns>A <see cref="FergunResult"/>.</returns>
+    public static FergunResult FromError(string reason, bool isEphemeral = false, IDiscordInteraction? interaction = null)
+        => new(InteractionCommandError.Unsuccessful, reason, isEphemeral, false, interaction);
+
+    /// <summary>
+    /// Creates a silent <see cref="FergunResult"/>.
+    /// </summary>
+    /// <returns>A <see cref="FergunResult"/>.</returns>
+    public static FergunResult FromSilentError() => new(InteractionCommandError.Unsuccessful, string.Empty, false, true, null);
+}
\ No newline at end of file
diff --git a/src/FergunResult.cs b/src/FergunResult.cs
deleted file mode 100644
index 83ba2b5..0000000
--- a/src/FergunResult.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using Discord.Interactions;
-
-namespace Fergun;
-
-public class FergunResult : RuntimeResult
-{
-    /// <inheritdoc />
-    private FergunResult(InteractionCommandError? error, string reason) : base(error, reason)
-    {
-    }
-
-    public static FergunResult FromSuccess(string? reason = null) => new(null, reason ?? "");
-
-    public static FergunResult FromError(string reason) => new(InteractionCommandError.Unsuccessful, reason);
-}
\ No newline at end of file
diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
index 614406a..091f8e3 100644
--- a/src/Modules/ImageModule.cs
+++ b/src/Modules/ImageModule.cs
@@ -44,7 +44,7 @@ public ImageModule(ILogger<ImageModule> logger, IFergunLocalizer<ImageModule> lo
     public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
 
     [SlashCommand("google", "Searches for images from Google Images and displays them in a paginator.")]
-    public async Task Google([Autocomplete(typeof(GoogleAutocompleteHandler))][Summary(description: "The query to search.")] string query,
+    public async Task<RuntimeResult> GoogleAsync([Autocomplete(typeof(GoogleAutocompleteHandler))][Summary(description: "The query to search.")] string query,
         [Summary(description: "Whether to display multiple images in a single page.")] bool multiImages = false)
     {
         await Context.Interaction.DeferAsync();
@@ -52,19 +52,16 @@ public async Task Google([Autocomplete(typeof(GoogleAutocompleteHandler))][Summa
         bool isNsfw = Context.Channel.IsNsfw();
         _logger.LogInformation("Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw);
 
-        var images = await _googleScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict, language: Context.Interaction.GetLanguageCode());
-
-        var filteredImages = images
+        var images = (await _googleScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict, language: Context.Interaction.GetLanguageCode()))
             .Where(x => x.Url.StartsWith("http") && x.SourceUrl.StartsWith("http"))
             .Chunk(multiImages ? 4 : 1)
             .ToArray();
 
-        _logger.LogInformation("Image results: {count}", filteredImages.Length);
+        _logger.LogInformation("Image results: {count}", images.Length);
 
-        if (filteredImages.Length == 0)
+        if (images.Length == 0)
         {
-            await Context.Interaction.FollowupWarning(_localizer["No results."]);
-            return;
+            return FergunResult.FromError(_localizer["No results."]);
         }
 
         var paginator = new LazyPaginatorBuilder()
@@ -72,21 +69,23 @@ public async Task Google([Autocomplete(typeof(GoogleAutocompleteHandler))][Summa
             .WithFergunEmotes()
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
-            .WithMaxPageIndex(filteredImages.Length - 1)
+            .WithMaxPageIndex(images.Length - 1)
             .WithFooter(PaginatorFooter.None)
             .AddUser(Context.User)
             .Build();
 
         await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
+        return FergunResult.FromSuccess();
+
         MultiEmbedPageBuilder GeneratePage(int index)
         {
-            var builders = filteredImages[index].Select(result => new EmbedBuilder()
+            var builders = images[index].Select(result => new EmbedBuilder()
                 .WithTitle(result.Title)
                 .WithDescription(_localizer["Google Images search"])
                 .WithUrl(multiImages ? "https://google.com" : result.SourceUrl)
                 .WithImageUrl(result.Url)
-                .WithFooter(_localizer["Page {0} of {1}", index + 1, filteredImages.Length], Constants.GoogleLogoUrl)
+                .WithFooter(_localizer["Page {0} of {1}", index + 1, images.Length], Constants.GoogleLogoUrl)
                 .WithColor(Color.Orange));
 
             return new MultiEmbedPageBuilder().WithBuilders(builders);
@@ -94,7 +93,7 @@ MultiEmbedPageBuilder GeneratePage(int index)
     }
 
     [SlashCommand("duckduckgo", "Searches for images from DuckDuckGo and displays them in a paginator.")]
-    public async Task DuckDuckGo([Autocomplete(typeof(DuckDuckGoAutocompleteHandler))][Summary(description: "The query to search.")] string query,
+    public async Task<RuntimeResult> DuckDuckGoAsync([Autocomplete(typeof(DuckDuckGoAutocompleteHandler))][Summary(description: "The query to search.")] string query,
         [Summary(description: "Whether to display multiple images in a single page.")] bool multiImages = false)
     {
         await Context.Interaction.DeferAsync();
@@ -110,8 +109,7 @@ public async Task DuckDuckGo([Autocomplete(typeof(DuckDuckGoAutocompleteHandler)
 
         if (images.Length == 0)
         {
-            await Context.Interaction.FollowupWarning(_localizer["No results."]);
-            return;
+            return FergunResult.FromError(_localizer["No results."]);
         }
 
         var paginator = new LazyPaginatorBuilder()
@@ -126,6 +124,8 @@ public async Task DuckDuckGo([Autocomplete(typeof(DuckDuckGoAutocompleteHandler)
 
         await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
+        return FergunResult.FromSuccess();
+
         MultiEmbedPageBuilder GeneratePage(int index)
         {
             var builders = images[index].Select(result => new EmbedBuilder()
@@ -141,7 +141,7 @@ MultiEmbedPageBuilder GeneratePage(int index)
     }
 
     [SlashCommand("brave", "Searches for images from Brave and displays them in a paginator.")]
-    public async Task Brave([Autocomplete(typeof(BraveAutocompleteHandler))][Summary(description: "The query to search.")] string query,
+    public async Task<RuntimeResult> BraveAsync([Autocomplete(typeof(BraveAutocompleteHandler))][Summary(description: "The query to search.")] string query,
         [Summary(description: "Whether to display multiple images in a single page.")] bool multiImages = false)
     {
         await Context.Interaction.DeferAsync();
@@ -157,8 +157,7 @@ public async Task Brave([Autocomplete(typeof(BraveAutocompleteHandler))][Summary
 
         if (images.Length == 0)
         {
-            await Context.Interaction.FollowupWarning(_localizer["No results."]);
-            return;
+            return FergunResult.FromError(_localizer["No results."]);
         }
 
         var paginator = new LazyPaginatorBuilder()
@@ -173,6 +172,8 @@ public async Task Brave([Autocomplete(typeof(BraveAutocompleteHandler))][Summary
 
         await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
+        return FergunResult.FromSuccess();
+
         MultiEmbedPageBuilder GeneratePage(int index)
         {
             var builders = images[index].Select(result => new EmbedBuilder()
@@ -188,7 +189,7 @@ MultiEmbedPageBuilder GeneratePage(int index)
     }
 
     [MessageCommand("Reverse Image Search")]
-    public async Task Reverse(IMessage message)
+    public async Task<RuntimeResult> ReverseAsync(IMessage message)
     {
         var attachment = message.Attachments.FirstOrDefault();
         var embed = message.Embeds.FirstOrDefault(x => x.Image is not null || x.Thumbnail is not null);
@@ -197,8 +198,7 @@ public async Task Reverse(IMessage message)
 
         if (url is null)
         {
-            await Context.Interaction.RespondWarningAsync(_localizer["Unable to get an image URL from the message."], true);
-            return;
+            return FergunResult.FromError(_localizer["Unable to get an image URL from the message."], true);
         }
 
         var page = new PageBuilder()
@@ -218,29 +218,31 @@ public async Task Reverse(IMessage message)
 
         if (result.IsSuccess)
         {
-            await ReverseAsync(url, result.Value, false, result.StopInteraction!, true);
+            return await ReverseAsync(url, result.Value, false, result.StopInteraction!, true);
         }
+
+        return FergunResult.FromSilentError();
     }
 
     [SlashCommand("reverse", "Reverse image search.")]
-    public async Task Reverse([Summary(description: "The url of an image.")] string url,
+    public async Task<RuntimeResult> ReverseAsync([Summary(description: "The url of an image.")] string url,
         [Summary(description: $"The search engine. The default is {nameof(ReverseImageSearchEngine.Yandex)}.")] ReverseImageSearchEngine engine = ReverseImageSearchEngine.Yandex,
         [Summary(description: "Whether to display multiple images in a single page.")] bool multiImages = false)
     {
-        await ReverseAsync(url, engine, multiImages, Context.Interaction);
+        return await ReverseAsync(url, engine, multiImages, Context.Interaction);
     }
 
-    public async Task ReverseAsync(string url, ReverseImageSearchEngine engine, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false)
+    public async Task<RuntimeResult> ReverseAsync(string url, ReverseImageSearchEngine engine, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false)
     {
-        await (engine switch
+        return await (engine switch
         {
             ReverseImageSearchEngine.Yandex => YandexAsync(url, multiImages, interaction, ephemeral),
             ReverseImageSearchEngine.Bing => BingAsync(url, multiImages, interaction, ephemeral),
-            _ => throw new ArgumentException("Invalid engine.", nameof(engine))
+            _ => throw new ArgumentException(_localizer["Invalid image search engine."], nameof(engine))
         });
     }
 
-    public virtual async Task YandexAsync(string url, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false)
+    public virtual async Task<RuntimeResult> YandexAsync(string url, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false)
     {
         if (interaction is IComponentInteraction componentInteraction)
         {
@@ -264,14 +266,12 @@ public virtual async Task YandexAsync(string url, bool multiImages, IDiscordInte
         catch (YandexException e)
         {
             _logger.LogWarning(e, "Failed to perform reverse image search to url {url}", url);
-            await interaction.FollowupWarning(e.Message, ephemeral);
-            return;
+            return FergunResult.FromError(e.Message, ephemeral, interaction);
         }
 
         if (results.Length == 0)
         {
-            await interaction.FollowupWarning(_localizer["No results."], ephemeral);
-            return;
+            return FergunResult.FromError(_localizer["No results."], ephemeral, interaction);
         }
 
         var paginator = new LazyPaginatorBuilder()
@@ -286,6 +286,8 @@ public virtual async Task YandexAsync(string url, bool multiImages, IDiscordInte
 
         await _interactive.SendPaginatorAsync(paginator, interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
 
+        return FergunResult.FromSuccess();
+
         MultiEmbedPageBuilder GeneratePage(int index)
         {
             var builders = results[index].Select(result => new EmbedBuilder()
@@ -301,7 +303,7 @@ MultiEmbedPageBuilder GeneratePage(int index)
         }
     }
 
-    public virtual async Task BingAsync(string url, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false)
+    public virtual async Task<RuntimeResult> BingAsync(string url, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false)
     {
         if (interaction is IComponentInteraction componentInteraction)
         {
@@ -324,14 +326,12 @@ public virtual async Task BingAsync(string url, bool multiImages, IDiscordIntera
         catch (BingException e)
         {
             _logger.LogWarning(e, "Failed to perform reverse image search to url {url}", url);
-            await interaction.FollowupWarning(_localizer[e.Message], ephemeral);
-            return;
+            return FergunResult.FromError(_localizer[e.Message], ephemeral, interaction);
         }
 
         if (results.Length == 0)
         {
-            await interaction.FollowupWarning(_localizer["No results."], ephemeral);
-            return;
+            return FergunResult.FromError(_localizer["No results."], ephemeral, interaction);
         }
 
         var paginator = new LazyPaginatorBuilder()
@@ -346,6 +346,8 @@ public virtual async Task BingAsync(string url, bool multiImages, IDiscordIntera
 
         await _interactive.SendPaginatorAsync(paginator, interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
 
+        return FergunResult.FromSuccess();
+
         MultiEmbedPageBuilder GeneratePage(int index)
         {
             var builders = results[index].Select(result => new EmbedBuilder()
diff --git a/src/Modules/OcrModule.cs b/src/Modules/OcrModule.cs
index d0cd74d..b1cdf25 100644
--- a/src/Modules/OcrModule.cs
+++ b/src/Modules/OcrModule.cs
@@ -36,7 +36,7 @@ public OcrModule(ILogger<OcrModule> logger, IFergunLocalizer<OcrModule> localize
     public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
 
     [MessageCommand("OCR")]
-    public async Task Ocr(IMessage message)
+    public async Task<RuntimeResult> OcrAsync(IMessage message)
     {
         var attachment = message.Attachments.FirstOrDefault();
         var embed = message.Embeds.FirstOrDefault(x => x.Image is not null || x.Thumbnail is not null);
@@ -45,8 +45,7 @@ public async Task Ocr(IMessage message)
 
         if (url is null)
         {
-            await Context.Interaction.RespondWarningAsync(_localizer["Unable to get an image URL from the message."], true);
-            return;
+            return FergunResult.FromError(_localizer["Unable to get an image URL from the message."], true);
         }
 
         var page = new PageBuilder()
@@ -66,31 +65,32 @@ public async Task Ocr(IMessage message)
 
         if (result.IsSuccess)
         {
-            await OcrAsync(result.Value, url, result.StopInteraction!, true);
+            return await OcrAsync(result.Value, url, result.StopInteraction!, true);
         }
+
+        return FergunResult.FromSilentError();
     }
 
     [SlashCommand("bing", "Performs OCR to an image using Bing Visual Search.")]
-    public async Task Bing([Summary(description: "An image URL.")] string url)
+    public async Task<RuntimeResult> BingAsync([Summary(description: "An image URL.")] string url)
         => await OcrAsync(OcrEngine.Bing, url, Context.Interaction);
 
     [SlashCommand("yandex", "Performs OCR to an image using Yandex.")]
-    public async Task Yandex([Summary(description: "An image URL.")] string url)
+    public async Task<RuntimeResult> YandexAsync([Summary(description: "An image URL.")] string url)
         => await OcrAsync(OcrEngine.Yandex, url, Context.Interaction);
 
-    public async Task OcrAsync(OcrEngine ocrEngine, string url, IDiscordInteraction interaction, bool ephemeral = false)
+    public async Task<RuntimeResult> OcrAsync(OcrEngine ocrEngine, string url, IDiscordInteraction interaction, bool ephemeral = false)
     {
         if (!Uri.IsWellFormedUriString(url, UriKind.Absolute))
         {
-            await interaction.RespondWarningAsync(_localizer["The URL is not well formed."], true);
-            return;
+            return FergunResult.FromError(_localizer["The URL is not well formed."], true, interaction);
         }
 
         var ocrTask = ocrEngine switch
         {
             OcrEngine.Bing => _bingVisualSearch.OcrAsync(url),
             OcrEngine.Yandex => _yandexImageSearch.OcrAsync(url),
-            _ => throw new ArgumentException("Invalid OCR engine.", nameof(ocrEngine))
+            _ => throw new ArgumentException(_localizer["Invalid OCR engine."], nameof(ocrEngine))
         };
 
         if (interaction is IComponentInteraction componentInteraction)
@@ -112,14 +112,12 @@ public async Task OcrAsync(OcrEngine ocrEngine, string url, IDiscordInteraction
         catch (Exception e) when (e is BingException or YandexException)
         {
             _logger.LogWarning(e, "Failed to perform OCR to url {url}", url);
-            await interaction.FollowupWarning(_localizer[e.Message], ephemeral);
-            return;
+            return FergunResult.FromError(_localizer[e.Message], ephemeral, interaction);
         }
 
         if (string.IsNullOrWhiteSpace(text))
         {
-            await interaction.FollowupWarning(_localizer["The OCR yielded no results."], ephemeral);
-            return;
+            return FergunResult.FromError(_localizer["The OCR yielded no results."], ephemeral, interaction);
         }
 
         stopwatch.Stop();
@@ -130,7 +128,7 @@ public async Task OcrAsync(OcrEngine ocrEngine, string url, IDiscordInteraction
         {
             OcrEngine.Bing => (_localizer["Bing Visual Search"], Constants.BingIconUrl),
             OcrEngine.Yandex => (_localizer["Yandex OCR"], Constants.YandexIconUrl),
-            _ => throw new ArgumentException("Invalid OCR engine.", nameof(ocrEngine))
+            _ => throw new ArgumentException(_localizer["Invalid OCR engine."], nameof(ocrEngine))
         };
 
         string embedText = $"**{_localizer["Output"]}**\n";
@@ -148,26 +146,28 @@ public async Task OcrAsync(OcrEngine ocrEngine, string url, IDiscordInteraction
             .Build();
 
         await interaction.FollowupAsync(embed: builder.Build(), components: components, ephemeral: ephemeral);
+
+        return FergunResult.FromSuccess();
     }
 
     [ComponentInteraction("ocrtranslate", true)]
-    public async Task OcrTranslate()
+    public async Task<RuntimeResult> OcrTranslateAsync()
     {
         string text = ((IComponentInteraction)Context.Interaction).Message.Embeds.First().Description;
         int startIndex = text.IndexOf('`', StringComparison.Ordinal) + 3;
         text = text[startIndex..^3];
 
-        await _shared.TranslateAsync(Context.Interaction, text, Context.Interaction.GetLanguageCode(), ephemeral: true);
+        return await _shared.TranslateAsync(Context.Interaction, text, Context.Interaction.GetLanguageCode(), ephemeral: true);
     }
 
     [ComponentInteraction("ocrtts", true)]
-    public async Task OcrTts()
+    public async Task<RuntimeResult> OcrTtsAsync()
     {
         string text = ((IComponentInteraction)Context.Interaction).Message.Embeds.First().Description;
         int startIndex = text.IndexOf('`', StringComparison.Ordinal) + 3;
         text = text[startIndex..^3];
 
-        await _shared.TtsAsync(Context.Interaction, text, Context.Interaction.GetLanguageCode(), true);
+        return await _shared.TtsAsync(Context.Interaction, text, Context.Interaction.GetLanguageCode(), true);
     }
 
     public enum OcrEngine
diff --git a/src/Modules/SharedModule.cs b/src/Modules/SharedModule.cs
index 5a4f825..d77a4a9 100644
--- a/src/Modules/SharedModule.cs
+++ b/src/Modules/SharedModule.cs
@@ -1,5 +1,6 @@
 using System.Globalization;
 using Discord;
+using Discord.Interactions;
 using Fergun.Extensions;
 using GTranslate;
 using GTranslate.Results;
@@ -27,26 +28,23 @@ public SharedModule(ILogger<SharedModule> logger, IFergunLocalizer<SharedResourc
         _googleTranslator2 = googleTranslator2;
     }
 
-    public async Task TranslateAsync(IDiscordInteraction interaction, string text, string target, string? source = null, bool ephemeral = false)
+    public async Task<RuntimeResult> TranslateAsync(IDiscordInteraction interaction, string text, string target, string? source = null, bool ephemeral = false)
     {
         _localizer.CurrentCulture = CultureInfo.GetCultureInfo(interaction.GetLanguageCode());
 
         if (string.IsNullOrWhiteSpace(text))
         {
-            await interaction.RespondWarningAsync(_localizer["The text must not be empty."], true);
-            return;
+            return FergunResult.FromError(_localizer["The text must not be empty."], true, interaction);
         }
 
         if (!Language.TryGetLanguage(target, out _))
         {
-            await interaction.RespondWarningAsync(_localizer["Invalid target language \"{0}\".", target], true);
-            return;
+            return FergunResult.FromError(_localizer["Invalid target language \"{0}\".", target], true, interaction);
         }
 
         if (source != null && !Language.TryGetLanguage(source, out _))
         {
-            await interaction.RespondWarningAsync(_localizer["Invalid source language \"{0}\".", source], true);
-            return;
+            return FergunResult.FromError(_localizer["Invalid source language \"{0}\".", source], true, interaction);
         }
 
         if (interaction is IComponentInteraction componentInteraction)
@@ -67,8 +65,7 @@ public async Task TranslateAsync(IDiscordInteraction interaction, string text, s
         catch (Exception e)
         {
             _logger.LogWarning(e, "Error translating text {text} ({source} -> {target})", text, source ?? "auto", target);
-            await interaction.FollowupWarning(e.Message, ephemeral);
-            return;
+            return FergunResult.FromError(e.Message, ephemeral, interaction);
         }
 
         string thumbnailUrl = result.Service switch
@@ -94,22 +91,22 @@ public async Task TranslateAsync(IDiscordInteraction interaction, string text, s
             .WithColor(Color.Orange);
 
         await interaction.FollowupAsync(embed: builder.Build(), ephemeral: ephemeral);
+
+        return FergunResult.FromSuccess();
     }
 
-    public async Task TtsAsync(IDiscordInteraction interaction, string text, string target, bool ephemeral = false)
+    public async Task<RuntimeResult> TtsAsync(IDiscordInteraction interaction, string text, string target, bool ephemeral = false)
     {
         _localizer.CurrentCulture = CultureInfo.GetCultureInfo(interaction.GetLanguageCode());
 
         if (string.IsNullOrWhiteSpace(text))
         {
-            await interaction.RespondWarningAsync(_localizer["The text must not be empty."], true);
-            return;
+            return FergunResult.FromError(_localizer["The text must not be empty."], true, interaction);
         }
 
         if (!Language.TryGetLanguage(target, out var language) || !GoogleTranslator2.TextToSpeechLanguages.Contains(language))
         {
-            await interaction.RespondWarningAsync(_localizer["Language \"{0}\" not supported.", target], true);
-            return;
+            return FergunResult.FromError(_localizer["Language \"{0}\" not supported.", target], true, interaction);
         }
 
         if (interaction is IComponentInteraction componentInteraction)
@@ -123,5 +120,7 @@ public async Task TtsAsync(IDiscordInteraction interaction, string text, string
 
         await using var stream = await _googleTranslator2.TextToSpeechAsync(text, language);
         await interaction.FollowupWithFileAsync(new FileAttachment(stream, "tts.mp3"), ephemeral: ephemeral);
+
+        return FergunResult.FromSuccess();
     }
 }
\ No newline at end of file
diff --git a/src/Modules/UrbanModule.cs b/src/Modules/UrbanModule.cs
index b11006f..85119bf 100644
--- a/src/Modules/UrbanModule.cs
+++ b/src/Modules/UrbanModule.cs
@@ -27,31 +27,30 @@ public UrbanModule(IFergunLocalizer<UrbanModule> localizer, IUrbanDictionary urb
     public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
 
     [SlashCommand("search", "Searches for definitions for a term in Urban Dictionary.")]
-    public async Task Search([Autocomplete(typeof(UrbanAutocompleteHandler))] [Summary(description: "The term to search.")] string term)
+    public async Task<RuntimeResult> SearchAsync([Autocomplete(typeof(UrbanAutocompleteHandler))] [Summary(description: "The term to search.")] string term)
         => await SearchAndSendAsync(UrbanSearchType.Search, term);
 
     [SlashCommand("random", "Gets random definitions from Urban Dictionary.")]
-    public async Task Random() => await SearchAndSendAsync(UrbanSearchType.Random);
+    public async Task<RuntimeResult> RandomAsync() => await SearchAndSendAsync(UrbanSearchType.Random);
 
     [SlashCommand("words-of-the-day", "Gets the words of the day in Urban Dictionary.")]
-    public async Task WordsOfTheDay() => await SearchAndSendAsync(UrbanSearchType.WordsOfTheDay);
+    public async Task<RuntimeResult> WordsOfTheDayAsync() => await SearchAndSendAsync(UrbanSearchType.WordsOfTheDay);
 
-    public async Task SearchAndSendAsync(UrbanSearchType searchType, string? term = null)
+    public async Task<RuntimeResult> SearchAndSendAsync(UrbanSearchType searchType, string? term = null)
     {
-        await DeferAsync();
+        await Context.Interaction.DeferAsync();
 
         var definitions = searchType switch
         {
             UrbanSearchType.Search => await _urbanDictionary.GetDefinitionsAsync(term!),
             UrbanSearchType.Random => await _urbanDictionary.GetRandomDefinitionsAsync(),
             UrbanSearchType.WordsOfTheDay => await _urbanDictionary.GetWordsOfTheDayAsync(),
-            _ => throw new ArgumentException("Invalid search type.", nameof(searchType))
+            _ => throw new ArgumentException(_localizer["Invalid search type."], nameof(searchType))
         };
 
         if (definitions.Count == 0)
         {
-            await Context.Interaction.FollowupWarning(_localizer["No results."]);
-            return;
+            return FergunResult.FromError(_localizer["No results."]);
         }
 
         var paginator = new LazyPaginatorBuilder()
@@ -66,32 +65,36 @@ public async Task SearchAndSendAsync(UrbanSearchType searchType, string? term =
 
         await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
+        return FergunResult.FromSuccess();
+
         PageBuilder GeneratePage(int i)
         {
-            var description = new StringBuilder(definitions[i].Definition.Length + definitions[i].Example.Length);
-            description.Append(Format.Sanitize(definitions[i].Definition));
-            if (!string.IsNullOrEmpty(definitions[i].Example))
+            var definition = definitions[i];
+
+            var description = new StringBuilder(definition.Definition.Length + definition.Example.Length);
+            description.Append(Format.Sanitize(definition.Definition));
+            if (!string.IsNullOrEmpty(definition.Example))
             {
                 description.Append("\n\n");
-                description.Append(Format.Italics(Format.Sanitize(definitions[i].Example.Trim())));
+                description.Append(Format.Italics(Format.Sanitize(definition.Example.Trim())));
             }
 
             string footer = searchType switch
             {
                 UrbanSearchType.Random => _localizer["Urban Dictionary (Random Definitions) | Page {0} of {1}", i + 1, definitions.Count],
-                UrbanSearchType.WordsOfTheDay => _localizer["Urban Dictionary (Words of the day, {0}) | Page {1} of {2}", definitions[i].Date!, i + 1, definitions.Count],
+                UrbanSearchType.WordsOfTheDay => _localizer["Urban Dictionary (Words of the day, {0}) | Page {1} of {2}", definition.Date!, i + 1, definitions.Count],
                 _ => _localizer["Urban Dictionary | Page {0} of {1}", i + 1, definitions.Count]
             };
 
             return new PageBuilder()
-                .WithTitle(definitions[i].Word)
-                .WithUrl(definitions[i].Permalink)
-                .WithAuthor(_localizer["By {0}", definitions[i].Author], url: $"https://www.urbandictionary.com/author.php?author={Uri.EscapeDataString(definitions[i].Author)}")
+                .WithTitle(definition.Word)
+                .WithUrl(definition.Permalink)
+                .WithAuthor(_localizer["By {0}", definition.Author], url: $"https://www.urbandictionary.com/author.php?author={Uri.EscapeDataString(definition.Author)}")
                 .WithDescription(description.ToString())
-                .AddField("👍", definitions[i].ThumbsUp, true)
-                .AddField("👎", definitions[i].ThumbsDown, true)
+                .AddField("👍", definition.ThumbsUp, true)
+                .AddField("👎", definition.ThumbsDown, true)
                 .WithFooter(footer, Constants.UrbanDictionaryIconUrl)
-                .WithTimestamp(definitions[i].WrittenOn)
+                .WithTimestamp(definition.WrittenOn)
                 .WithColor(Color.Orange); // 0x10151BU 0x1B2936U
         }
     }
diff --git a/src/Modules/UserModule.cs b/src/Modules/UserModule.cs
index 35d7a23..9d93ed0 100644
--- a/src/Modules/UserModule.cs
+++ b/src/Modules/UserModule.cs
@@ -17,7 +17,7 @@ public UserModule(IFergunLocalizer<UserModule> localizer)
     public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
 
     [UserCommand("Avatar")]
-    public async Task Avatar(IUser user)
+    public async Task<RuntimeResult> AvatarAsync(IUser user)
     {
         string url = (user as IGuildUser)?.GetGuildAvatarUrl(size: 2048) ?? user.GetAvatarUrl(size: 2048) ?? user.GetDefaultAvatarUrl();
 
@@ -29,10 +29,12 @@ public async Task Avatar(IUser user)
         };
 
         await Context.Interaction.RespondAsync(embed: builder.Build());
+
+        return FergunResult.FromSuccess();
     }
 
     [UserCommand("User Info")]
-    public async Task UserInfo(IUser user)
+    public async Task<RuntimeResult> UserInfoAsync(IUser user)
     {
         string activities = "";
         if (user.Activities.Count > 0)
@@ -81,6 +83,8 @@ public async Task UserInfo(IUser user)
 
         await Context.Interaction.RespondAsync(embed: builder.Build());
 
+        return FergunResult.FromSuccess();
+
         static string GetTimestamp(DateTimeOffset? dateTime)
             => dateTime == null ? "N/A" : $"{dateTime.Value.ToDiscordTimestamp()} ({dateTime.Value.ToDiscordTimestamp('R')})";
     }
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index ab02bac..4a9fd2c 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -58,26 +58,24 @@ public UtilityModule(ILogger<UtilityModule> logger, IFergunLocalizer<UtilityModu
     public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
 
     [MessageCommand("Bad Translator")]
-    public async Task BadTranslator(IMessage message)
-        => await BadTranslator(message.GetText());
+    public async Task<RuntimeResult> BadTranslatorAsync(IMessage message)
+        => await BadTranslatorAsync(message.GetText());
 
     [SlashCommand("badtranslator", "Passes a text through multiple, different translators.")]
-    public async Task BadTranslator([Summary(description: "The text to use.")] string text,
+    public async Task<RuntimeResult> BadTranslatorAsync([Summary(description: "The text to use.")] string text,
         [Summary(description: "The amount of times to translate the text (2-10).")] [MinValue(2)] [MaxValue(10)] int chainCount = 8)
     {
         if (string.IsNullOrWhiteSpace(text))
         {
-            await Context.Interaction.RespondWarningAsync(_localizer["The text must not be empty."], true);
-            return;
+            return FergunResult.FromError(_localizer["The text must not be empty."], true);
         }
 
         if (chainCount is < 2 or > 10)
         {
-            await Context.Interaction.RespondWarningAsync(_localizer["The chain count must be between 2 and 10 (inclusive)."], true);
-            return;
+            return FergunResult.FromError(_localizer["The chain count must be between 2 and 10 (inclusive)."], true);
         }
-
-        await DeferAsync();
+        
+        await Context.Interaction.DeferAsync();
 
         // Create an aggregated translator manually so we can randomize the initial order of the translators and shift them.
         // Bing Translator is not included because it only allows max. 1000 chars per translation
@@ -117,8 +115,7 @@ public async Task BadTranslator([Summary(description: "The text to use.")] strin
             catch (Exception e)
             {
                 _logger.LogWarning(e, "Error translating text {text} ({source} -> {target})", text, source?.ISO6391 ?? "auto", target.ISO6391);
-                await Context.Interaction.FollowupWarning(e.Message);
-                return;
+                return FergunResult.FromError(e.Message);
             }
 
             if (i == 0)
@@ -143,20 +140,22 @@ public async Task BadTranslator([Summary(description: "The text to use.")] strin
             .WithColor(Color.Orange)
             .Build();
 
-        await FollowupAsync(embed: embed);
+        await Context.Interaction.FollowupAsync(embed: embed);
+
+        return FergunResult.FromSuccess();
     }
 
     [RequireOwner]
     [SlashCommand("cmd", "(Owner only) Executes a command.")]
-    public async Task Cmd([Summary(description: "The command to execute")] string command, [Summary(description: "No embed.")] bool noEmbed = false)
+    public async Task<RuntimeResult> CmdAsync([Summary(description: "The command to execute.")] string command, [Summary(description: "No embed.")] bool noEmbed = false)
     {
-        await DeferAsync();
+        await Context.Interaction.DeferAsync();
 
-        var result = CommandUtils.RunCommand(command);
+        string? result = CommandUtils.RunCommand(command);
 
         if (string.IsNullOrWhiteSpace(result))
         {
-            await FollowupAsync(_localizer["No output."]);
+            await Context.Interaction.FollowupAsync(_localizer["No output."]);
         }
         else
         {
@@ -178,12 +177,14 @@ public async Task Cmd([Summary(description: "The command to execute")] string co
                     .Build();
             }
 
-            await FollowupAsync(text, embed: embed);
+            await Context.Interaction.FollowupAsync(text, embed: embed);
         }
+
+        return FergunResult.FromSuccess();
     }
 
     [SlashCommand("help", "Information about Fergun 2")]
-    public async Task Help()
+    public async Task<RuntimeResult> HelpAsync()
     {
         var embed = new EmbedBuilder()
             .WithTitle("Fergun 2")
@@ -192,10 +193,12 @@ public async Task Help()
             .Build();
 
         await RespondAsync(embed: embed);
+
+        return FergunResult.FromSuccess();
     }
 
     [SlashCommand("ping", "Sends the response time of the bot.")]
-    public async Task Ping()
+    public async Task<RuntimeResult> PingAsync()
     {
         var embed = new EmbedBuilder()
             .WithDescription("Pong!")
@@ -203,7 +206,7 @@ public async Task Ping()
             .Build();
 
         var sw = Stopwatch.StartNew();
-        await RespondAsync(embed: embed);
+        await Context.Interaction.RespondAsync(embed: embed);
         sw.Stop();
 
         embed = new EmbedBuilder()
@@ -212,18 +215,22 @@ public async Task Ping()
             .Build();
 
         await Context.Interaction.ModifyOriginalResponseAsync(x => x.Embed = embed);
-    }
 
+        return FergunResult.FromSuccess();
+    }
+    
     [SlashCommand("say", "Says something.")]
-    public async Task Say([Summary(description: "The text to send.")] string text)
+    public async Task<RuntimeResult> SayAsync([Summary(description: "The text to send.")] string text)
     {
-        await RespondAsync(text.Truncate(DiscordConfig.MaxMessageSize), allowedMentions: AllowedMentions.None);
+        await Context.Interaction.RespondAsync(text.Truncate(DiscordConfig.MaxMessageSize), allowedMentions: AllowedMentions.None);
+
+        return FergunResult.FromSuccess();
     }
 
     [SlashCommand("stats", "Sends the stats of the bot.")]
-    public async Task Stats()
+    public async Task<RuntimeResult> StatsAsync()
     {
-        await DeferAsync();
+        await Context.Interaction.DeferAsync();
 
         long temp;
         var owner = (await Context.Client.GetApplicationInfoAsync()).Owner;
@@ -361,42 +368,43 @@ public async Task Stats()
             .AddField(_localizer["Bot Owner"], owner, true);
 
         builder.WithColor(Color.Orange);
+        
+        await Context.Interaction.FollowupAsync(embed: builder.Build());
 
-        await FollowupAsync(embed: builder.Build());
+        return FergunResult.FromSuccess();
     }
 
     [MessageCommand("Translate")]
-    public async Task Translate(IMessage message)
-        => await Translate(message.GetText(), Context.Interaction.GetLanguageCode());
+    public async Task<RuntimeResult> TranslateAsync(IMessage message)
+        => await TranslateAsync(message.GetText(), Context.Interaction.GetLanguageCode());
 
     [SlashCommand("translate", "Translates a text.")]
-    public async Task Translate([Summary(description: "The text to translate.")] string text,
+    public async Task<RuntimeResult> TranslateAsync([Summary(description: "The text to translate.")] string text,
         [Autocomplete(typeof(TranslateAutocompleteHandler))] [Summary(description: "Target language (name, code or alias).")] string target,
         [Autocomplete(typeof(TranslateAutocompleteHandler))] [Summary(description: "Source language (name, code or alias).")] string? source = null,
         [Summary(description: "Whether to respond ephemerally.")] bool ephemeral = false)
         => await _shared.TranslateAsync(Context.Interaction, text, target, source, ephemeral);
-
+    
     [MessageCommand("TTS")]
-    public async Task TTS(IMessage message)
-        => await TTS(message.GetText());
+    public async Task<RuntimeResult> TtsAsync(IMessage message)
+        => await TtsAsync(message.GetText());
 
     [SlashCommand("tts", "Converts text into synthesized speech.")]
-    public async Task TTS([Summary(description: "The text to convert.")] string text,
+    public async Task<RuntimeResult> TtsAsync([Summary(description: "The text to convert.")] string text,
         [Autocomplete(typeof(TtsAutocompleteHandler))] [Summary(description: "The target language.")] string? target = null,
         [Summary(description: "Whether to respond ephemerally.")] bool ephemeral = false)
         => await _shared.TtsAsync(Context.Interaction, text, target ?? Context.Interaction.GetLanguageCode(), ephemeral);
 
     [SlashCommand("wikipedia", "Searches for Wikipedia articles.")]
-    public async Task Wikipedia([Autocomplete(typeof(WikipediaAutocompleteHandler))] [Summary(description: "The search query.")] string query)
+    public async Task<RuntimeResult> WikipediaAsync([Autocomplete(typeof(WikipediaAutocompleteHandler))] [Summary(description: "The search query.")] string query)
     {
-        await DeferAsync();
+        await Context.Interaction.DeferAsync();
 
         var articles = (await _wikipediaClient.GetArticlesAsync(query, Context.Interaction.GetLanguageCode())).ToArray();
 
         if (articles.Length == 0)
         {
-            await Context.Interaction.FollowupWarning(_localizer["No results."]);
-            return;
+            return FergunResult.FromError(_localizer["No results."]);
         }
 
         var paginator = new LazyPaginatorBuilder()
@@ -411,6 +419,8 @@ public async Task Wikipedia([Autocomplete(typeof(WikipediaAutocompleteHandler))]
 
         await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
 
+        return FergunResult.FromSuccess();
+
         PageBuilder GeneratePage(int index)
         {
             var article = articles[index];
@@ -440,17 +450,16 @@ PageBuilder GeneratePage(int index)
     }
 
     [SlashCommand("youtube", "Sends a paginator containing YouTube videos.")]
-    public async Task YouTube([Autocomplete(typeof(YouTubeAutocompleteHandler))] [Summary(description: "The query.")] string query)
+    public async Task<RuntimeResult> YouTubeAsync([Autocomplete(typeof(YouTubeAutocompleteHandler))] [Summary(description: "The query.")] string query)
     {
-        await DeferAsync();
+        await Context.Interaction.DeferAsync();
 
         var videos = await _searchClient.GetVideosAsync(query).Take(10);
 
         switch (videos.Count)
         {
             case 0:
-                await Context.Interaction.FollowupWarning(_localizer["No results."]);
-                break;
+                return FergunResult.FromError(_localizer["No results."]);
 
             case 1:
                 await Context.Interaction.FollowupAsync(videos[0].Url);
@@ -469,5 +478,7 @@ public async Task YouTube([Autocomplete(typeof(YouTubeAutocompleteHandler))] [Su
                 await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
                 break;
         }
+
+        return FergunResult.FromSuccess();
     }
 }
\ No newline at end of file
diff --git a/src/Resources/Modules.ImageModule.es.resx b/src/Resources/Modules.ImageModule.es.resx
index bf92aa5..147c502 100644
--- a/src/Resources/Modules.ImageModule.es.resx
+++ b/src/Resources/Modules.ImageModule.es.resx
@@ -129,6 +129,9 @@
   <data name="Google Images search" xml:space="preserve">
     <value>Búsqueda de Google Imágenes</value>
   </data>
+  <data name="Invalid image search engine." xml:space="preserve">
+    <value>Motor de búsqueda de imágenes inválido.</value>
+  </data>
   <data name="Select an image search engine" xml:space="preserve">
     <value>Selecciona un motor de búsqueda de imágenes</value>
   </data>
diff --git a/src/Resources/Modules.OcrModule.es.resx b/src/Resources/Modules.OcrModule.es.resx
index 547e2d3..cb687d8 100644
--- a/src/Resources/Modules.OcrModule.es.resx
+++ b/src/Resources/Modules.OcrModule.es.resx
@@ -120,6 +120,9 @@
   <data name="Bing Visual Search" xml:space="preserve">
     <value>Búsqueda Visual de Bing</value>
   </data>
+  <data name="Invalid OCR engine." xml:space="preserve">
+    <value>Motor de OCR inválido.</value>
+  </data>
   <data name="OCR Results" xml:space="preserve">
     <value>Resultados de OCR</value>
   </data>
diff --git a/src/Resources/Modules.UrbanModule.es.resx b/src/Resources/Modules.UrbanModule.es.resx
index be1a94f..6b77b9c 100644
--- a/src/Resources/Modules.UrbanModule.es.resx
+++ b/src/Resources/Modules.UrbanModule.es.resx
@@ -120,6 +120,9 @@
   <data name="By {0}" xml:space="preserve">
     <value>Por {0}</value>
   </data>
+  <data name="Invalid search type." xml:space="preserve">
+    <value>Tipo de búsqueda inválido.</value>
+  </data>
   <data name="Urban Dictionary (Random Definitions) | Page {0} of {1}" xml:space="preserve">
     <value>Urban Dictionary (Definiciones Aleatorias) | Página {0} de {1}</value>
   </data>
diff --git a/src/Services/InteractionHandlingService.cs b/src/Services/InteractionHandlingService.cs
index aef5322..f7828bf 100644
--- a/src/Services/InteractionHandlingService.cs
+++ b/src/Services/InteractionHandlingService.cs
@@ -98,7 +98,7 @@ private async Task ContextMenuCommandExecuted(ContextCommandInfo contextCommand,
         _logger.LogInformation("Executed context menu command \"{name}\" for {username}#{discriminator} ({id}) in {context}",
             contextCommand.Name, context.User.Username, context.User.Discriminator, context.User.Id, context.Display());
 
-        if (result.IsSuccess)
+        if (result.IsSuccess || result is FergunResult { IsSilent: true })
             return;
 
         await HandleInteractionErrorAsync(context, result);
@@ -106,20 +106,24 @@ private async Task ContextMenuCommandExecuted(ContextCommandInfo contextCommand,
 
     private async Task HandleInteractionErrorAsync(IInteractionContext context, IResult result)
     {
-        var localizer = _services.GetRequiredService<IFergunLocalizer<SharedResource>>();
-        localizer.CurrentCulture = CultureInfo.GetCultureInfo(context.Interaction.GetLanguageCode());
+        string message = result.ErrorReason;
+        bool ephemeral = (result as FergunResult)?.IsEphemeral ?? true;
+        var interaction = (result as FergunResult)?.Interaction ?? context.Interaction;
 
-        string message = result.Error == InteractionCommandError.Exception
-            ? $"{localizer["An error occurred."]}\n\n{localizer["Error message: {0}", $"```{((ExecuteResult)result).Exception.Message}```"]}"
-            : result.ErrorReason;
+        if (result.Error == InteractionCommandError.Exception)
+        {
+            var localizer = _services.GetRequiredService<IFergunLocalizer<SharedResource>>();
+            localizer.CurrentCulture = CultureInfo.GetCultureInfo(context.Interaction.GetLanguageCode());
+            message = $"{localizer["An error occurred."]}\n\n{localizer["Error message: {0}", $"```{((ExecuteResult)result).Exception.Message}```"]}";
+        }
 
         if (context.Interaction.HasResponded)
         {
-            await context.Interaction.FollowupWarning(message, true);
+            await interaction.FollowupWarning($"âš  {message}", ephemeral);
         }
         else
         {
-            await context.Interaction.RespondWarningAsync(message, true);
+            await interaction.RespondWarningAsync($"âš  {message}", ephemeral);
         }
     }
 
diff --git a/tests/Fergun.Tests/Modules/ImageModuleTests.cs b/tests/Fergun.Tests/Modules/ImageModuleTests.cs
index 61b4c0e..35a7983 100644
--- a/tests/Fergun.Tests/Modules/ImageModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/ImageModuleTests.cs
@@ -55,13 +55,14 @@ public void BeforeExecute_Sets_Language()
     [Theory]
     [InlineData("Discord", false, true)]
     [InlineData("Google", true, false)]
-    public async Task Google_Sends_Paginator(string query, bool multiImages, bool nsfw)
+    public async Task GoogleAsync_Sends_Paginator(string query, bool multiImages, bool nsfw)
     {
         var channel = new Mock<ITextChannel>();
         channel.SetupGet(x => x.IsNsfw).Returns(nsfw);
         _contextMock.SetupGet(x => x.Channel).Returns(channel.Object);
 
-        await _module.Google(query, multiImages);
+        var result = await _module.GoogleAsync(query, multiImages);
+        Assert.True(result.IsSuccess);
 
         _interactionMock.Verify(x => x.DeferAsync(It.IsAny<bool>(), It.IsAny<RequestOptions>()), Times.Once);
         _contextMock.VerifyGet(x => x.Channel);
@@ -73,26 +74,25 @@ public async Task Google_Sends_Paginator(string query, bool multiImages, bool ns
     }
 
     [Fact]
-    public async Task Google_Returns_No_Results()
+    public async Task GoogleAsync_Returns_No_Results()
     {
-        await _module.Google(" ");
+        var result = await _module.GoogleAsync(" ");
+        Assert.False(result.IsSuccess);
 
         Mock.Get(_localizer).VerifyGet(x => x[It.Is<string>(y => y == "No results.")]);
-
-        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.IsAny<bool>(),
-            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
     }
 
     [Theory]
     [InlineData("Discord", false, true)]
     [InlineData("DuckDuckGo", true, false)]
-    public async Task DuckDuckGo_Sends_Paginator(string query, bool multiImages, bool nsfw)
+    public async Task DuckDuckGoAsync_Sends_Paginator(string query, bool multiImages, bool nsfw)
     {
         var channel = new Mock<ITextChannel>();
         channel.SetupGet(x => x.IsNsfw).Returns(nsfw);
         _contextMock.SetupGet(x => x.Channel).Returns(channel.Object);
 
-        await _module.DuckDuckGo(query, multiImages);
+        var result = await _module.DuckDuckGoAsync(query, multiImages);
+        Assert.True(result.IsSuccess);
 
         _interactionMock.Verify(x => x.DeferAsync(It.IsAny<bool>(), It.IsAny<RequestOptions>()), Times.Once);
         _contextMock.VerifyGet(x => x.Channel);
@@ -104,26 +104,25 @@ public async Task DuckDuckGo_Sends_Paginator(string query, bool multiImages, boo
     }
 
     [Fact]
-    public async Task DuckDuckGo_Returns_No_Results()
+    public async Task DuckDuckGoAsync_Returns_No_Results()
     {
-        await _module.DuckDuckGo("\u200b");
+        var result = await _module.DuckDuckGoAsync("\u200b");
+        Assert.False(result.IsSuccess);
 
         Mock.Get(_localizer).VerifyGet(x => x[It.Is<string>(y => y == "No results.")]);
-
-        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.IsAny<bool>(),
-            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
     }
 
     [Theory]
     [InlineData("Discord", false, true)]
     [InlineData("Brave", true, false)]
-    public async Task Brave_Sends_Paginator(string query, bool multiImages, bool nsfw)
+    public async Task BraveAsync_Sends_Paginator(string query, bool multiImages, bool nsfw)
     {
         var channel = new Mock<ITextChannel>();
         channel.SetupGet(x => x.IsNsfw).Returns(nsfw);
         _contextMock.SetupGet(x => x.Channel).Returns(channel.Object);
 
-        await _module.Brave(query, multiImages);
+        var result = await _module.BraveAsync(query, multiImages);
+        Assert.True(result.IsSuccess);
 
         _interactionMock.Verify(x => x.DeferAsync(It.IsAny<bool>(), It.IsAny<RequestOptions>()), Times.Once);
         _contextMock.VerifyGet(x => x.Channel);
@@ -135,31 +134,28 @@ public async Task Brave_Sends_Paginator(string query, bool multiImages, bool nsf
     }
 
     [Fact]
-    public async Task Brave_Returns_No_Results()
+    public async Task BraveAsync_Returns_No_Results()
     {
-        await _module.Brave("\u200b");
+        var result = await _module.BraveAsync("\u200b");
+        Assert.False(result.IsSuccess);
 
         Mock.Get(_localizer).VerifyGet(x => x[It.Is<string>(y => y == "No results.")]);
-
-        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.IsAny<bool>(),
-            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
     }
 
     [Theory]
     [InlineData("https://example.com/image.png", ImageModule.ReverseImageSearchEngine.Bing, false, false)]
     [InlineData("https://example.com/image.png", ImageModule.ReverseImageSearchEngine.Bing, true, true)]
-    [InlineData("", ImageModule.ReverseImageSearchEngine.Bing, true, false)]
     [InlineData("https://example.com/image.png", ImageModule.ReverseImageSearchEngine.Yandex, false, false)]
     [InlineData("https://example.com/image.png", ImageModule.ReverseImageSearchEngine.Yandex, true, true)]
-    [InlineData("", ImageModule.ReverseImageSearchEngine.Yandex, true, true)]
-    public async Task Reverse_Sends_Paginator(string url, ImageModule.ReverseImageSearchEngine engine, bool multiImages, bool nsfw)
+    public async Task ReverseAsync_Sends_Paginator(string url, ImageModule.ReverseImageSearchEngine engine, bool multiImages, bool nsfw)
     {
         var channel = new Mock<ITextChannel>();
         channel.SetupGet(x => x.IsNsfw).Returns(nsfw);
         _contextMock.SetupGet(x => x.Channel).Returns(channel.Object);
         _interactionMock.SetupGet(x => x.UserLocale).Returns("en");
 
-        await _module.Reverse(url, engine, multiImages);
+        var result = await _module.ReverseAsync(url, engine, multiImages);
+        Assert.True(result.IsSuccess);
 
         _contextMock.VerifyGet(x => x.Channel);
         _interactionMock.VerifyGet(x => x.User);
@@ -180,25 +176,41 @@ public async Task Reverse_Sends_Paginator(string url, ImageModule.ReverseImageSe
         }
     }
 
+    [Theory]
+    [InlineData("", ImageModule.ReverseImageSearchEngine.Bing, true, false)]
+    [InlineData("", ImageModule.ReverseImageSearchEngine.Yandex, true, true)]
+    public async Task ReverseAsync_Returns_No_Results(string url, ImageModule.ReverseImageSearchEngine engine, bool multiImages, bool nsfw)
+    {
+        var channel = new Mock<ITextChannel>();
+        channel.SetupGet(x => x.IsNsfw).Returns(nsfw);
+        _contextMock.SetupGet(x => x.Channel).Returns(channel.Object);
+        _interactionMock.SetupGet(x => x.UserLocale).Returns("en");
+
+        var result = await _module.ReverseAsync(url, engine, multiImages);
+        Assert.False(result.IsSuccess);
+
+        Mock.Get(_localizer).VerifyGet(x => x[It.Is<string>(y => y == "No results.")]);
+    }
+
     [Fact]
-    public async Task Reverse_Throws_Exception_If_Invalid_Engine_Is_Passed()
+    public async Task ReverseAsync_Throws_Exception_If_Invalid_Engine_Is_Passed()
     {
-        await Assert.ThrowsAsync<ArgumentException>("engine", () => _module.Reverse(It.IsAny<string>(), (ImageModule.ReverseImageSearchEngine)2, It.IsAny<bool>()));
+        await Assert.ThrowsAsync<ArgumentException>("engine", () => _module.ReverseAsync(It.IsAny<string>(), (ImageModule.ReverseImageSearchEngine)2, It.IsAny<bool>()));
     }
 
     [Theory]
     [InlineData(ImageModule.ReverseImageSearchEngine.Bing)]
     [InlineData(ImageModule.ReverseImageSearchEngine.Yandex)]
-    public async Task Reverse_Throws_Exception_If_Invalid_Parameters_Are_Passed(ImageModule.ReverseImageSearchEngine engine)
+    public async Task ReverseAsync_Throws_Exception_If_Invalid_Parameters_Are_Passed(ImageModule.ReverseImageSearchEngine engine)
     {
         var channel = new Mock<ITextChannel>();
         channel.SetupGet(x => x.IsNsfw).Returns(false);
         _contextMock.SetupGet(x => x.Channel).Returns(channel.Object);
 
-        await _module.Reverse(null!, engine, It.IsAny<bool>());
+        var result = await _module.ReverseAsync(null!, engine, It.IsAny<bool>());
+        Assert.False(result.IsSuccess);
+        Assert.Equal("Error message.", result.ErrorReason);
 
         _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => !b), It.IsAny<RequestOptions>()), Times.Once);
-        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => !b),
-            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.Is<Embed>(e => e.Description.EndsWith("Error message.")), It.IsAny<RequestOptions>()), Times.Once);
     }
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Modules/OcrModuleTests.cs b/tests/Fergun.Tests/Modules/OcrModuleTests.cs
index 8c60493..7a36ac4 100644
--- a/tests/Fergun.Tests/Modules/OcrModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/OcrModuleTests.cs
@@ -26,18 +26,18 @@ public class OcrModuleTests
     private readonly InteractiveConfig _interactiveConfig = new() { DeferStopSelectionInteractions = false };
     private readonly IFergunLocalizer<OcrModule> _ocrLocalizer = Utils.CreateMockedLocalizer<OcrModule>();
     private readonly Mock<OcrModule> _moduleMock;
-    private const string _textImageUrl = "https://example.com/image.png";
-    private const string _emptyImageUrl = "https://example.com/empty.png";
-    private const string _invalidImageUrl = "https://example.com/file.bin";
+    private const string TextImageUrl = "https://example.com/image.png";
+    private const string EmptyImageUrl = "https://example.com/empty.png";
+    private const string InvalidImageUrl = "https://example.com/file.bin";
 
     public OcrModuleTests()
     {
-        _bingVisualSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == _textImageUrl))).ReturnsAsync("test");
-        _bingVisualSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == _emptyImageUrl))).ReturnsAsync(string.Empty);
-        _bingVisualSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == _invalidImageUrl))).ThrowsAsync(new BingException("Invalid image."));
-        _yandexImageSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == _textImageUrl))).ReturnsAsync("test");
-        _yandexImageSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == _emptyImageUrl))).ReturnsAsync(string.Empty);
-        _yandexImageSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == _invalidImageUrl))).ThrowsAsync(new YandexException("Invalid image."));
+        _bingVisualSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == TextImageUrl))).ReturnsAsync("test");
+        _bingVisualSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == EmptyImageUrl))).ReturnsAsync(string.Empty);
+        _bingVisualSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == InvalidImageUrl))).ThrowsAsync(new BingException("Invalid image."));
+        _yandexImageSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == TextImageUrl))).ReturnsAsync("test");
+        _yandexImageSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == EmptyImageUrl))).ReturnsAsync(string.Empty);
+        _yandexImageSearchMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == InvalidImageUrl))).ThrowsAsync(new YandexException("Invalid image."));
 
         var sharedLogger = Mock.Of<ILogger<SharedModule>>();
         var sharedLocalizer = Utils.CreateMockedLocalizer<SharedResource>();
@@ -57,41 +57,43 @@ public void BeforeExecute_Sets_Language()
         _moduleMock.Object.BeforeExecute(It.IsAny<ICommandInfo>());
         Assert.Equal("en", _ocrLocalizer.CurrentCulture.TwoLetterISOLanguageName);
     }
-
+    
     [Theory]
-    [InlineData(_textImageUrl)]
-    [InlineData(_emptyImageUrl)]
-    public async Task Bing_Uses_BingVisualSearch(string url)
+    [InlineData(TextImageUrl, true)]
+    [InlineData(EmptyImageUrl, false)]
+    public async Task BingAsync_Uses_BingVisualSearch(string url, bool success)
     {
         var module = _moduleMock.Object;
         const bool isEphemeral = false;
 
-        await module.Bing(url);
+        var result = await module.BingAsync(url);
+        Assert.Equal(success, result.IsSuccess);
 
-        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == isEphemeral), It.IsAny<RequestOptions>()), Times.Once());
+        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == isEphemeral), It.IsAny<RequestOptions>()), Times.Once);
 
         _bingVisualSearchMock.Verify(x => x.OcrAsync(It.Is<string>(s => s == url)), Times.Once);
 
         _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == isEphemeral),
-                It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once());
+                It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), success ? Times.Once : Times.Never);
     }
 
     [Theory]
-    [InlineData(_textImageUrl)]
-    [InlineData(_emptyImageUrl)]
-    public async Task Yandex_Uses_YandexImageSearch(string url)
+    [InlineData(TextImageUrl, true)]
+    [InlineData(EmptyImageUrl, false)]
+    public async Task YandexAsync_Uses_YandexImageSearch(string url, bool success)
     {
         var module = _moduleMock.Object;
         const bool isEphemeral = false;
 
-        await module.Yandex(url);
+        var result = await module.YandexAsync(url);
+        Assert.Equal(success, result.IsSuccess);
 
-        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == isEphemeral), It.IsAny<RequestOptions>()), Times.Once());
+        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == isEphemeral), It.IsAny<RequestOptions>()), Times.Once);
 
         _yandexImageSearchMock.Verify(x => x.OcrAsync(It.Is<string>(s => s == url)), Times.Once);
 
         _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == isEphemeral),
-                It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once());
+                It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), success ? Times.Once : Times.Never);
     }
 
     [Fact]
@@ -100,10 +102,8 @@ public async Task OcrAsync_Returns_Warning_If_Url_Is_Invalid()
         var module = _moduleMock.Object;
         const bool isEphemeral = true;
 
-        await module.OcrAsync(It.IsAny<OcrModule.OcrEngine>(), string.Empty, _interactionMock.Object, isEphemeral);
-
-        _interactionMock.Verify(x => x.RespondAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == isEphemeral),
-                It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once());
+        var result = await module.OcrAsync(It.IsAny<OcrModule.OcrEngine>(), string.Empty, _interactionMock.Object, isEphemeral);
+        Assert.False(result.IsSuccess);
     }
 
     [Fact]
@@ -112,7 +112,7 @@ public async Task Invalid_OcrEngine_Throws_ArgumentException()
         var module = _moduleMock.Object;
         const bool isEphemeral = true;
 
-        var task = module.OcrAsync((OcrModule.OcrEngine)2, _textImageUrl, _interactionMock.Object, isEphemeral);
+        var task = module.OcrAsync((OcrModule.OcrEngine)2, TextImageUrl, _interactionMock.Object, isEphemeral);
 
         await Assert.ThrowsAsync<ArgumentException>(() => task);
     }
@@ -123,11 +123,9 @@ public async Task OcrAsync_Returns_Warning_On_Exception()
         var module = _moduleMock.Object;
         const bool isEphemeral = true;
 
-        await module.OcrAsync(It.IsAny<OcrModule.OcrEngine>(), _invalidImageUrl, _interactionMock.Object, isEphemeral);
+        var result = await module.OcrAsync(It.IsAny<OcrModule.OcrEngine>(), InvalidImageUrl, _interactionMock.Object, isEphemeral);
+        Assert.False(result.IsSuccess);
 
-        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == isEphemeral), It.IsAny<RequestOptions>()), Times.Once());
-
-        _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == isEphemeral),
-                It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once());
+        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == isEphemeral), It.IsAny<RequestOptions>()), Times.Once);
     }
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Modules/SharedModuleTests.cs b/tests/Fergun.Tests/Modules/SharedModuleTests.cs
index 42f0c12..7e022d5 100644
--- a/tests/Fergun.Tests/Modules/SharedModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/SharedModuleTests.cs
@@ -28,7 +28,7 @@ public SharedModuleTests()
             {
                 if (text == "Error")
                 {
-                    throw new ArgumentException("Error.", nameof(text));
+                    throw new ArgumentException(null, nameof(text));
                 }
 
                 var targetLanguage = Language.GetLanguage(target);
@@ -62,16 +62,16 @@ public SharedModuleTests()
     [InlineData("Bing", "en", null, true)]
     [InlineData("Microsoft", "tr", "es", true)]
     [InlineData("Yandex", "ru", "en", false)]
+    [InlineData("Error", "fr", "it", true)]
     public async Task TranslateAsync_Returns_Results_Or_Fails_Preconditions(string text, string target, string? source, bool ephemeral)
     {
-        await _sharedModuleMock.Object.TranslateAsync(_interactionMock.Object, text, target, source, ephemeral);
+        var result = await _sharedModuleMock.Object.TranslateAsync(_interactionMock.Object, text, target, source, ephemeral);
 
         _interactionMock.VerifyGet(x => x.UserLocale);
 
         bool passedPreconditions = !string.IsNullOrEmpty(text) && Language.TryGetLanguage(target, out _) && (source == null || Language.TryGetLanguage(source, out _));
 
-        _interactionMock.Verify(x => x.RespondAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b),
-            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), passedPreconditions ? Times.Never : Times.Once);
+        Assert.Equal(result.IsSuccess, passedPreconditions);
 
         _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == ephemeral), It.IsAny<RequestOptions>()), passedPreconditions ? Times.Once : Times.Never);
 
@@ -84,10 +84,11 @@ public async Task TranslateAsync_Returns_Results_Or_Fails_Preconditions(string t
     [Theory]
     [InlineData("Microsoft", "tr", "es", true)]
     [InlineData("Yandex", "ru", "en", false)]
-    [InlineData("Error", "fr", "it", true)]
     public async Task TranslateAsync_Uses_DeferLoadingAsync(string text, string target, string? source, bool ephemeral)
     {
-        await _sharedModuleMock.Object.TranslateAsync(_componentInteractionMock.Object, text, target, source, ephemeral);
+        var result = await _sharedModuleMock.Object.TranslateAsync(_componentInteractionMock.Object, text, target, source, ephemeral);
+
+        Assert.True(result.IsSuccess);
 
         _componentInteractionMock.VerifyGet(x => x.UserLocale);
 
@@ -119,7 +120,7 @@ public async Task TtsAsync_Sends_Results_Or_Fails_Preconditions(string text, str
 
         _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == ephemeral), It.IsAny<RequestOptions>()), passedPreconditions ? Times.Once : Times.Never);
 
-        _interactionMock.Verify(x => x.FollowupWithFileAsync(It.Is<FileAttachment>(x => x.FileName == "tts.mp3"), It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == ephemeral),
+        _interactionMock.Verify(x => x.FollowupWithFileAsync(It.Is<FileAttachment>(f => f.FileName == "tts.mp3"), It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == ephemeral),
             It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), passedPreconditions ? Times.Once : Times.Never);
     }
 
@@ -135,7 +136,7 @@ public async Task TtsAsync_Uses_DeferLoadingAsync(string text, string target, bo
         _componentInteractionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == ephemeral), It.IsAny<RequestOptions>()), Times.Never);
         _componentInteractionMock.Verify(x => x.DeferLoadingAsync(It.Is<bool>(b => b == ephemeral), It.IsAny<RequestOptions>()), Times.Once);
 
-        _componentInteractionMock.Verify(x => x.FollowupWithFileAsync(It.Is<FileAttachment>(x => x.FileName == "tts.mp3"), It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == ephemeral),
+        _componentInteractionMock.Verify(x => x.FollowupWithFileAsync(It.Is<FileAttachment>(f => f.FileName == "tts.mp3"), It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == ephemeral),
             It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
     }
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Modules/UrbanModuleTests.cs b/tests/Fergun.Tests/Modules/UrbanModuleTests.cs
index 42857ff..106eba3 100644
--- a/tests/Fergun.Tests/Modules/UrbanModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/UrbanModuleTests.cs
@@ -4,7 +4,6 @@
 using System.Threading.Tasks;
 using AutoBogus;
 using AutoBogus.Moq;
-using Bogus;
 using Discord;
 using Discord.Interactions;
 using Discord.WebSocket;
@@ -12,7 +11,6 @@
 using Fergun.Interactive;
 using Fergun.Modules;
 using Moq;
-using Moq.Protected;
 using Xunit;
 
 namespace Fergun.Tests.Modules;
@@ -21,7 +19,7 @@ public class UrbanModuleTests
 {
     private readonly Mock<IInteractionContext> _contextMock = new();
     private readonly Mock<IDiscordInteraction> _interactionMock = new();
-    private readonly Mock<IUrbanDictionary> _urbanDictionaryMock = CreateMockedUrbanDictionary();
+    private readonly IUrbanDictionary _urbanDictionary = Utils.CreateMockedUrbanDictionaryApi();
     private readonly Mock<UrbanModule> _moduleMock;
     private readonly DiscordSocketClient _client = new();
     private readonly InteractiveConfig _interactiveConfig = new() { ReturnAfterSendingPaginator = true };
@@ -31,7 +29,7 @@ public UrbanModuleTests()
     {
         var interactive = new InteractiveService(_client, _interactiveConfig);
 
-        _moduleMock = new Mock<UrbanModule>(() => new UrbanModule(_localizer, _urbanDictionaryMock.Object, interactive)) { CallBase = true };
+        _moduleMock = new Mock<UrbanModule>(() => new UrbanModule(_localizer, _urbanDictionary, interactive)) { CallBase = true };
         _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
         _contextMock.SetupGet(x => x.User).Returns(() => AutoFaker.Generate<IUser>(b => b.WithBinder(new MoqBinder())));
         ((IInteractionModuleBase)_moduleMock.Object).SetContext(_contextMock.Object);
@@ -45,83 +43,55 @@ public void BeforeExecute_Sets_Language()
         Assert.Equal("en", _localizer.CurrentCulture.TwoLetterISOLanguageName);
     }
 
-    [MemberData(nameof(GetRandomWords))]
     [Theory]
-    public async Task Search_Calls_GetDefinitionsAsync(string term)
+    [MemberData(nameof(GetRandomWords))]
+    public async Task SearchAsync_Returns_Definitions(string term)
     {
-        var module = _moduleMock.Object;
+        var result = await _moduleMock.Object.SearchAsync(term);
+        Assert.True(result.IsSuccess);
 
-        await module.Search(term);
+        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => !b), It.IsAny<RequestOptions>()), Times.Once);
+        Mock.Get(_urbanDictionary).Verify(u => u.GetDefinitionsAsync(It.Is<string>(x => x == term)), Times.Once);
+    }
 
-        _moduleMock.Protected().Verify<Task>("DeferAsync", Times.Once(), ItExpr.IsAny<bool>(), ItExpr.IsAny<RequestOptions>());
-        _urbanDictionaryMock.Verify(u => u.GetDefinitionsAsync(It.Is<string>(x => x == term)), Times.Once);
-        int count = (await _urbanDictionaryMock.Object.GetDefinitionsAsync(It.IsAny<string>())).Count;
+    [Theory]
+    [InlineData(null)]
+    public async Task SearchAsync_Returns_No_Definitions(string term)
+    {
+        var result = await _moduleMock.Object.SearchAsync(term);
+        Assert.False(result.IsSuccess);
 
-        if (count == 0)
-        {
-            _interactionMock.Verify(i => i.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<AllowedMentions>(),
-                It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
-        }
+        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => !b), It.IsAny<RequestOptions>()), Times.Once);
+        Mock.Get(_urbanDictionary).Verify(u => u.GetDefinitionsAsync(It.Is<string>(x => x == term)), Times.Once);
     }
 
     [Fact]
-    public async Task Random_Calls_GetRandomDefinitionsAsync()
+    public async Task RandomAsync_Calls_GetRandomDefinitionsAsync()
     {
-        var module = _moduleMock.Object;
-
-        await module.Random();
+        var result = await _moduleMock.Object.RandomAsync();
+        Assert.True(result.IsSuccess);
 
-        _moduleMock.Protected().Verify<Task>("DeferAsync", Times.Once(), ItExpr.IsAny<bool>(), ItExpr.IsAny<RequestOptions>());
-        _urbanDictionaryMock.Verify(u => u.GetRandomDefinitionsAsync(), Times.Once);
+        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => !b), It.IsAny<RequestOptions>()), Times.Once);
+        Mock.Get(_urbanDictionary).Verify(u => u.GetRandomDefinitionsAsync(), Times.Once);
     }
 
     [Fact]
-    public async Task WordsOfTheDay_Calls_GetWordsOfTheDayAsync()
+    public async Task WordsOfTheDayAsync_Calls_GetWordsOfTheDayAsync()
     {
-        var module = _moduleMock.Object;
+        var result = await _moduleMock.Object.WordsOfTheDayAsync();
+        Assert.True(result.IsSuccess);
 
-        await module.WordsOfTheDay();
-
-        _moduleMock.Protected().Verify<Task>("DeferAsync", Times.Once(), ItExpr.IsAny<bool>(), ItExpr.IsAny<RequestOptions>());
-        _urbanDictionaryMock.Verify(u => u.GetWordsOfTheDayAsync(), Times.Once);
+        _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => !b), It.IsAny<RequestOptions>()), Times.Once);
+        Mock.Get(_urbanDictionary).Verify(u => u.GetWordsOfTheDayAsync(), Times.Once);
     }
 
     [Fact]
     public async Task Invalid_SearchType_Throws_ArgumentException()
     {
-        var module = _moduleMock.Object;
-
-        var task = module.SearchAndSendAsync((UrbanModule.UrbanSearchType)3);
+        var task = _moduleMock.Object.SearchAndSendAsync((UrbanModule.UrbanSearchType)3);
 
         await Assert.ThrowsAsync<ArgumentException>(() => task);
     }
 
-    private static IEnumerable<object?[]> GetRandomWords() => AutoFaker.Generate<string>(20).Select(x => new object[] { x });
-
-    private static Mock<IUrbanDictionary> CreateMockedUrbanDictionary()
-    {
-        var faker = new Faker();
-        var mock = new Mock<IUrbanDictionary>();
-
-        var definitionFaker = new AutoFaker<UrbanDefinition>()
-            .RuleFor(x => x.Definition, f => f.Lorem.Sentence())
-            .RuleFor(x => x.Date, f => f.Date.Weekday().OrNull(f))
-            .RuleFor(x => x.Permalink, f => f.Internet.Url())
-            .RuleFor(x => x.ThumbsUp, f => f.Random.Int())
-            .RuleFor(x => x.SoundUrls, Array.Empty<string>())
-            .RuleFor(x => x.Author, f => f.Internet.UserName())
-            .RuleFor(x => x.Word, f => f.Lorem.Word())
-            .RuleFor(x => x.Id, f => f.Random.Int())
-            .RuleFor(x => x.WrittenOn, f => f.Date.PastOffset())
-            .RuleFor(x => x.Example, f => f.Lorem.Sentence());
-
-        mock.Setup(u => u.GetDefinitionsAsync(It.IsAny<string>())).ReturnsAsync(definitionFaker.Generate(10).OrDefault(faker, defaultValue: new()));
-        mock.Setup(u => u.GetRandomDefinitionsAsync()).ReturnsAsync(definitionFaker.Generate(10));
-        mock.Setup(u => u.GetDefinitionAsync(It.IsAny<int>())).ReturnsAsync(definitionFaker.Generate());
-        mock.Setup(u => u.GetWordsOfTheDayAsync()).ReturnsAsync(definitionFaker.Generate(10));
-        mock.Setup(u => u.GetAutocompleteResultsAsync(It.IsAny<string>())).ReturnsAsync(AutoFaker.Generate<string>(20));
-        mock.Setup(u => u.GetAutocompleteResultsExtraAsync(It.IsAny<string>())).ReturnsAsync(AutoFaker.Generate<UrbanAutocompleteResult>(20));
-
-        return mock;
-    }
+    private static IEnumerable<object?[]> GetRandomWords() => AutoFaker.Generate<string>(10).Select(x => new object[] { x });
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Modules/UserModuleTests.cs b/tests/Fergun.Tests/Modules/UserModuleTests.cs
index 0d2998a..823b82a 100644
--- a/tests/Fergun.Tests/Modules/UserModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/UserModuleTests.cs
@@ -34,9 +34,10 @@ public void BeforeExecute_Sets_Language()
 
     [Theory]
     [MemberData(nameof(GetFakeUsers))]
-    public async Task Avatar_Should_Return_Embed_With_Avatar(Mock<IUser> userMock)
+    public async Task AvatarAsync_Should_Return_Embed_With_Avatar(Mock<IUser> userMock)
     {
-        await _moduleMock.Object.Avatar(userMock.Object);
+        var result = await _moduleMock.Object.AvatarAsync(userMock.Object);
+        Assert.True(result.IsSuccess);
 
         userMock.Verify(x => x.ToString());
         userMock.Verify(x => x.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>()));
@@ -50,9 +51,10 @@ public async Task Avatar_Should_Return_Embed_With_Avatar(Mock<IUser> userMock)
 
     [Theory]
     [MemberData(nameof(GetFakeGuildUsers))]
-    public async Task Avatar_Should_Return_Embed_With_Guild_Avatar(Mock<IGuildUser> guildUserMock)
+    public async Task AvatarAsync_Should_Return_Embed_With_Guild_Avatar(Mock<IGuildUser> guildUserMock)
     {
-        await _moduleMock.Object.Avatar(guildUserMock.Object);
+        var result = await _moduleMock.Object.AvatarAsync(guildUserMock.Object);
+        Assert.True(result.IsSuccess);
 
         guildUserMock.Verify(x => x.ToString());
         guildUserMock.Verify(x => x.GetGuildAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>()));
@@ -61,9 +63,10 @@ public async Task Avatar_Should_Return_Embed_With_Guild_Avatar(Mock<IGuildUser>
 
     [Theory]
     [MemberData(nameof(GetFakeUsers))]
-    public async Task UserInfo_Should_Return_Embed_With_Avatar(Mock<IUser> userMock)
+    public async Task UserInfoAsync_Should_Return_Embed_With_Avatar(Mock<IUser> userMock)
     {
-        await _moduleMock.Object.UserInfo(userMock.Object);
+        var result = await _moduleMock.Object.UserInfoAsync(userMock.Object);
+        Assert.True(result.IsSuccess);
 
         userMock.Verify(x => x.ToString());
         userMock.Verify(x => x.GetAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>()));
@@ -83,9 +86,10 @@ public async Task UserInfo_Should_Return_Embed_With_Avatar(Mock<IUser> userMock)
 
     [Theory]
     [MemberData(nameof(GetFakeGuildUsers))]
-    public async Task UserInfo_Should_Return_Embed_With_Guild_Avatar(Mock<IGuildUser> guildUserMock)
+    public async Task UserInfoAsync_Should_Return_Embed_With_Guild_Avatar(Mock<IGuildUser> guildUserMock)
     {
-        await _moduleMock.Object.UserInfo(guildUserMock.Object);
+        var result = await _moduleMock.Object.UserInfoAsync(guildUserMock.Object);
+        Assert.True(result.IsSuccess);
 
         guildUserMock.Verify(x => x.ToString());
         guildUserMock.Verify(x => x.GetGuildAvatarUrl(It.IsAny<ImageFormat>(), It.IsAny<ushort>()));
diff --git a/tests/Fergun.Tests/Utils.cs b/tests/Fergun.Tests/Utils.cs
index 8544db8..1199751 100644
--- a/tests/Fergun.Tests/Utils.cs
+++ b/tests/Fergun.Tests/Utils.cs
@@ -2,9 +2,11 @@
 using System.Globalization;
 using System.Linq;
 using System.Reflection;
+using AutoBogus;
 using Bogus;
 using Discord;
 using Fergun.Apis.Bing;
+using Fergun.Apis.Urban;
 using Fergun.Apis.Yandex;
 using Microsoft.Extensions.Localization;
 using Moq;
@@ -171,4 +173,36 @@ public static IYandexReverseImageSearchResult CreateMockedYandexReverseImageSear
 
         return resultMock.Object;
     }
+
+    public static IUrbanDictionary CreateMockedUrbanDictionaryApi(Faker? faker = null)
+    {
+        faker ??= new Faker();
+        var mock = new Mock<IUrbanDictionary>();
+
+        mock.Setup(u => u.GetDefinitionsAsync(It.IsNotNull<string>())).ReturnsAsync(() => faker.MakeLazy(10, CreateFakeUrbanDefinition).ToList());
+        mock.Setup(u => u.GetDefinitionsAsync(It.Is<string>(s => s == null))).ReturnsAsync(Array.Empty<UrbanDefinition>());
+        mock.Setup(u => u.GetRandomDefinitionsAsync()).ReturnsAsync(() => faker.MakeLazy(10, CreateFakeUrbanDefinition).ToList());
+        mock.Setup(u => u.GetDefinitionAsync(It.IsAny<int>())).ReturnsAsync(CreateFakeUrbanDefinition);
+        mock.Setup(u => u.GetWordsOfTheDayAsync()).ReturnsAsync(() => faker.MakeLazy(10, CreateFakeUrbanDefinition).ToList());
+        mock.Setup(u => u.GetAutocompleteResultsAsync(It.IsAny<string>())).ReturnsAsync(AutoFaker.Generate<string>(20));
+        mock.Setup(u => u.GetAutocompleteResultsExtraAsync(It.IsAny<string>())).ReturnsAsync(AutoFaker.Generate<UrbanAutocompleteResult>(20));
+
+        return mock.Object;
+    }
+
+    public static UrbanDefinition CreateFakeUrbanDefinition()
+    {
+        return new AutoFaker<UrbanDefinition>()
+            .RuleFor(x => x.Definition, f => f.Lorem.Sentence())
+            .RuleFor(x => x.Date, f => f.Date.Weekday().OrNull(f))
+            .RuleFor(x => x.Permalink, f => f.Internet.Url())
+            .RuleFor(x => x.ThumbsUp, f => f.Random.Int())
+            .RuleFor(x => x.SoundUrls, Array.Empty<string>())
+            .RuleFor(x => x.Author, f => f.Internet.UserName())
+            .RuleFor(x => x.Word, f => f.Lorem.Word())
+            .RuleFor(x => x.Id, f => f.Random.Int())
+            .RuleFor(x => x.WrittenOn, f => f.Date.PastOffset())
+            .RuleFor(x => x.Example, f => f.Lorem.Sentence())
+            .Generate();
+    }
 }
\ No newline at end of file

From ce93518aa7c1790864fc6b25b59097423bcb9f97 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 24 Apr 2022 01:27:15 -0500
Subject: [PATCH 44/83] Update GTranslate and improve `translate`/`tts`
 commands/autocomplete handlers

---
 src/Fergun.csproj                              |  4 ++--
 .../Handlers/TranslateAutocompleteHandler.cs   | 15 ++++++++++-----
 src/Modules/Handlers/TtsAutocompleteHandler.cs | 16 +++++++++++-----
 src/Modules/OcrModule.cs                       | 18 +++++++++++++++++-
 src/Modules/SharedModule.cs                    |  9 ++++++---
 src/Resources/Modules.OcrModule.es.resx        |  3 +++
 6 files changed, 49 insertions(+), 16 deletions(-)

diff --git a/src/Fergun.csproj b/src/Fergun.csproj
index 5bcca1c..e75ddc0 100644
--- a/src/Fergun.csproj
+++ b/src/Fergun.csproj
@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
     <OutputType>Exe</OutputType>
@@ -13,7 +13,7 @@
     <PackageReference Include="Discord.Net.Interactions" Version="3.5.0" />
     <PackageReference Include="Fergun.Interactive" Version="1.5.4" />
     <PackageReference Include="GScraper" Version="1.0.2" />
-    <PackageReference Include="GTranslate" Version="2.0.2" />
+    <PackageReference Include="GTranslate" Version="2.1.0" />
     <PackageReference Include="Humanizer.Core" Version="2.14.1" />
     <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.3" />
diff --git a/src/Modules/Handlers/TranslateAutocompleteHandler.cs b/src/Modules/Handlers/TranslateAutocompleteHandler.cs
index a3f615f..d3643e7 100644
--- a/src/Modules/Handlers/TranslateAutocompleteHandler.cs
+++ b/src/Modules/Handlers/TranslateAutocompleteHandler.cs
@@ -11,12 +11,13 @@ public class TranslateAutocompleteHandler : AutocompleteHandler
     public override Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context,
         IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
     {
-        var text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
+        string text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
 
         IEnumerable<Language> languages = Language
             .LanguageDictionary
             .Values
             .Where(x => x.Name.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
+                        x.NativeName.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
                         x.ISO6391.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
                         x.ISO6393.StartsWith(text, StringComparison.OrdinalIgnoreCase))
             .OrderBy(x => x.Name);
@@ -34,14 +35,18 @@ public override Task<AutocompletionResult> GenerateSuggestionsAsync(IInteraction
         }
         else
         {
-            if (context.Interaction.TryGetLanguage(out var language))
+            if (context.Interaction.TryGetLanguage(out var userLanguage) && (string.IsNullOrEmpty(text) ||
+                userLanguage.Name.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
+                userLanguage.NativeName.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
+                userLanguage.ISO6391.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
+                userLanguage.ISO6393.StartsWith(text, StringComparison.OrdinalIgnoreCase)))
             {
-                languages = languages.Where(x => !x.Equals(language)).Prepend(language);
+                languages = languages.Where(x => !x.Equals(userLanguage)).Prepend(userLanguage);
             }
         }
-
+        
         var results = languages
-            .Select(x => new AutocompleteResult($"{x.Name} ({x.ISO6391})", x.ISO6391))
+            .Select(x => new AutocompleteResult($"{x.Name}{(x.Name == x.NativeName ? "" : $" ({x.NativeName})")} ({x.ISO6391})", x.ISO6391))
             .Take(25);
 
         return Task.FromResult(AutocompletionResult.FromSuccess(results));
diff --git a/src/Modules/Handlers/TtsAutocompleteHandler.cs b/src/Modules/Handlers/TtsAutocompleteHandler.cs
index 4fc8603..5e90f24 100644
--- a/src/Modules/Handlers/TtsAutocompleteHandler.cs
+++ b/src/Modules/Handlers/TtsAutocompleteHandler.cs
@@ -10,22 +10,28 @@ public class TtsAutocompleteHandler : AutocompleteHandler
 {
     public override Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
     {
-        var text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
+        string text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
 
-        IEnumerable<ILanguage> languages = GoogleTranslator2
+        IEnumerable<Language> languages = GoogleTranslator2
             .TextToSpeechLanguages
+            .Cast<Language>()
             .Where(x => x.Name.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
+                        x.NativeName.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
                         x.ISO6391.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
                         x.ISO6393.StartsWith(text, StringComparison.OrdinalIgnoreCase))
             .OrderBy(x => x.Name);
 
-        if (context.Interaction.TryGetLanguage(out var language))
+        if (context.Interaction.TryGetLanguage(out var userLanguage) && (string.IsNullOrEmpty(text) ||
+                userLanguage.Name.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
+                userLanguage.NativeName.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
+                userLanguage.ISO6391.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
+                userLanguage.ISO6393.StartsWith(text, StringComparison.OrdinalIgnoreCase)))
         {
-            languages = languages.Where(x => !x.Equals(language)).Prepend(language);
+            languages = languages.Where(x => !x.Equals(userLanguage)).Prepend(userLanguage);
         }
 
         var results = languages
-            .Select(x => new AutocompleteResult($"{x.Name} ({x.ISO6391})", x.ISO6391))
+            .Select(x => new AutocompleteResult($"{x.Name}{(x.Name == x.NativeName ? "" : $" ({x.NativeName})")} ({x.ISO6391})", x.ISO6391))
             .Take(25);
 
         return Task.FromResult(AutocompletionResult.FromSuccess(results));
diff --git a/src/Modules/OcrModule.cs b/src/Modules/OcrModule.cs
index b1cdf25..73b9416 100644
--- a/src/Modules/OcrModule.cs
+++ b/src/Modules/OcrModule.cs
@@ -140,8 +140,24 @@ public async Task<RuntimeResult> OcrAsync(OcrEngine ocrEngine, string url, IDisc
             .WithFooter(_localizer["{0} | Processing time: {1}ms", name, stopwatch.ElapsedMilliseconds], iconUrl)
             .WithColor(Color.Orange);
 
+        string buttonText;
+        if (language is null)
+        {
+            buttonText = _localizer["Translate"];
+        }
+        else
+        {
+            var localizedString = _localizer["Translate to {0}", language.NativeName];
+            if (localizedString.ResourceNotFound && language.ISO6391 != "en")
+            {
+                localizedString = _localizer["Translate to {0} ({1})", language.Name, language.NativeName];
+            }
+
+            buttonText = localizedString.Value;
+        }
+
         var components = new ComponentBuilder()
-            .WithButton(language is null ? _localizer["Translate"] : _localizer["Translate to {0}", language.Name], "ocrtranslate", ButtonStyle.Secondary)
+            .WithButton(buttonText, "ocrtranslate", ButtonStyle.Secondary)
             .WithButton("TTS", "ocrtts", ButtonStyle.Secondary)
             .Build();
 
diff --git a/src/Modules/SharedModule.cs b/src/Modules/SharedModule.cs
index d77a4a9..94bdb1f 100644
--- a/src/Modules/SharedModule.cs
+++ b/src/Modules/SharedModule.cs
@@ -77,10 +77,10 @@ public async Task<RuntimeResult> TranslateAsync(IDiscordInteraction interaction,
         };
 
         string embedText = $"**{_localizer[source is null ? "Source language (Detected)" : "Source language"]}**\n" +
-                            $"{result.SourceLanguage.Name}\n\n" +
+                            $"{DisplayName(result.SourceLanguage)}\n\n" +
                             $"**{_localizer["Target language"]}**\n" +
-                            $"{result.TargetLanguage.Name}" +
-                            $"\n\n**{_localizer["Result"]}**\n";
+                            $"{DisplayName(result.TargetLanguage)}\n\n" +
+                            $"**{_localizer["Result"]}**\n";
 
         string translation = result.Translation.Replace('`', '´').Truncate(EmbedBuilder.MaxDescriptionLength - embedText.Length - 6);
 
@@ -93,6 +93,9 @@ public async Task<RuntimeResult> TranslateAsync(IDiscordInteraction interaction,
         await interaction.FollowupAsync(embed: builder.Build(), ephemeral: ephemeral);
 
         return FergunResult.FromSuccess();
+        
+        static string DisplayName(ILanguage language)
+            => $"{language.Name}{(language is not Language lang || lang.NativeName == language.Name ? "" : $" ({lang.NativeName})")}";
     }
 
     public async Task<RuntimeResult> TtsAsync(IDiscordInteraction interaction, string text, string target, bool ephemeral = false)
diff --git a/src/Resources/Modules.OcrModule.es.resx b/src/Resources/Modules.OcrModule.es.resx
index cb687d8..193aeb6 100644
--- a/src/Resources/Modules.OcrModule.es.resx
+++ b/src/Resources/Modules.OcrModule.es.resx
@@ -138,6 +138,9 @@
   <data name="Translate to {0}" xml:space="preserve">
     <value>Traducir a {0}</value>
   </data>
+  <data name="Translate to {0} ({1})" xml:space="preserve">
+    <value>Traducir a {0} ({1})</value>
+  </data>
   <data name="Yandex OCR" xml:space="preserve">
     <value>OCR de Yandex</value>
   </data>

From 6565185fc1257e7b9b3626f159f9b81c4106af94 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 24 Apr 2022 01:35:33 -0500
Subject: [PATCH 45/83] Fix warning messages having duplicated warning emoji

---
 src/Services/InteractionHandlingService.cs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/Services/InteractionHandlingService.cs b/src/Services/InteractionHandlingService.cs
index f7828bf..8af8b51 100644
--- a/src/Services/InteractionHandlingService.cs
+++ b/src/Services/InteractionHandlingService.cs
@@ -119,11 +119,11 @@ private async Task HandleInteractionErrorAsync(IInteractionContext context, IRes
 
         if (context.Interaction.HasResponded)
         {
-            await interaction.FollowupWarning($"âš  {message}", ephemeral);
+            await interaction.FollowupWarning(message, ephemeral);
         }
         else
         {
-            await interaction.RespondWarningAsync($"âš  {message}", ephemeral);
+            await interaction.RespondWarningAsync(message, ephemeral);
         }
     }
 

From 81f3217a08a953a250abccad4ce4c5a2eee4b072 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 24 Apr 2022 17:03:44 -0500
Subject: [PATCH 46/83] Add optional attachment parameter to /ocr and /img
 reverse

---
 src/Modules/ImageModule.cs                    | 10 +++-
 src/Modules/OcrModule.cs                      | 17 +++++--
 src/Resources/Modules.OcrModule.es.resx       |  3 ++
 .../Fergun.Tests/Modules/ImageModuleTests.cs  | 48 ++++++++++++-------
 tests/Fergun.Tests/Utils.cs                   | 10 ++--
 5 files changed, 61 insertions(+), 27 deletions(-)

diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
index 091f8e3..58270c8 100644
--- a/src/Modules/ImageModule.cs
+++ b/src/Modules/ImageModule.cs
@@ -225,10 +225,18 @@ public async Task<RuntimeResult> ReverseAsync(IMessage message)
     }
 
     [SlashCommand("reverse", "Reverse image search.")]
-    public async Task<RuntimeResult> ReverseAsync([Summary(description: "The url of an image.")] string url,
+    public async Task<RuntimeResult> ReverseAsync([Summary(description: "The url of an image.")] string? url = null,
+        [Summary(description: "An image file.")] IAttachment? file = null,
         [Summary(description: $"The search engine. The default is {nameof(ReverseImageSearchEngine.Yandex)}.")] ReverseImageSearchEngine engine = ReverseImageSearchEngine.Yandex,
         [Summary(description: "Whether to display multiple images in a single page.")] bool multiImages = false)
     {
+        url = file?.Url ?? url;
+
+        if (url is null)
+        {
+            return FergunResult.FromError(_localizer["A URL or attachment is required."], true);
+        }
+
         return await ReverseAsync(url, engine, multiImages, Context.Interaction);
     }
 
diff --git a/src/Modules/OcrModule.cs b/src/Modules/OcrModule.cs
index 73b9416..4e7dbba 100644
--- a/src/Modules/OcrModule.cs
+++ b/src/Modules/OcrModule.cs
@@ -72,15 +72,22 @@ public async Task<RuntimeResult> OcrAsync(IMessage message)
     }
 
     [SlashCommand("bing", "Performs OCR to an image using Bing Visual Search.")]
-    public async Task<RuntimeResult> BingAsync([Summary(description: "An image URL.")] string url)
-        => await OcrAsync(OcrEngine.Bing, url, Context.Interaction);
+    public async Task<RuntimeResult> BingAsync([Summary(description: "An image URL.")] string? url = null,
+        [Summary(description: "An image file.")] IAttachment? file = null)
+        => await OcrAsync(OcrEngine.Bing, file?.Url ?? url, Context.Interaction);
 
     [SlashCommand("yandex", "Performs OCR to an image using Yandex.")]
-    public async Task<RuntimeResult> YandexAsync([Summary(description: "An image URL.")] string url)
-        => await OcrAsync(OcrEngine.Yandex, url, Context.Interaction);
+    public async Task<RuntimeResult> YandexAsync([Summary(description: "An image URL.")] string? url = null,
+        [Summary(description: "An image file.")] IAttachment? file = null)
+        => await OcrAsync(OcrEngine.Yandex, file?.Url ?? url, Context.Interaction);
 
-    public async Task<RuntimeResult> OcrAsync(OcrEngine ocrEngine, string url, IDiscordInteraction interaction, bool ephemeral = false)
+    public async Task<RuntimeResult> OcrAsync(OcrEngine ocrEngine, string? url, IDiscordInteraction interaction, bool ephemeral = false)
     {
+        if (url is null)
+        {
+            return FergunResult.FromError(_localizer["A URL or attachment is required."], true, interaction);
+        }
+
         if (!Uri.IsWellFormedUriString(url, UriKind.Absolute))
         {
             return FergunResult.FromError(_localizer["The URL is not well formed."], true, interaction);
diff --git a/src/Resources/Modules.OcrModule.es.resx b/src/Resources/Modules.OcrModule.es.resx
index 193aeb6..5272275 100644
--- a/src/Resources/Modules.OcrModule.es.resx
+++ b/src/Resources/Modules.OcrModule.es.resx
@@ -117,6 +117,9 @@
   <resheader name="writer">
     <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
   </resheader>
+  <data name="A URL or attachment is required." xml:space="preserve">
+    <value>Se requiere una URL o un archivo adjunto.</value>
+  </data>
   <data name="Bing Visual Search" xml:space="preserve">
     <value>Búsqueda Visual de Bing</value>
   </data>
diff --git a/tests/Fergun.Tests/Modules/ImageModuleTests.cs b/tests/Fergun.Tests/Modules/ImageModuleTests.cs
index 35a7983..0888b7a 100644
--- a/tests/Fergun.Tests/Modules/ImageModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/ImageModuleTests.cs
@@ -1,4 +1,5 @@
 using System;
+using System.Collections.Generic;
 using System.Threading.Tasks;
 using Discord;
 using Discord.Interactions;
@@ -143,18 +144,15 @@ public async Task BraveAsync_Returns_No_Results()
     }
 
     [Theory]
-    [InlineData("https://example.com/image.png", ImageModule.ReverseImageSearchEngine.Bing, false, false)]
-    [InlineData("https://example.com/image.png", ImageModule.ReverseImageSearchEngine.Bing, true, true)]
-    [InlineData("https://example.com/image.png", ImageModule.ReverseImageSearchEngine.Yandex, false, false)]
-    [InlineData("https://example.com/image.png", ImageModule.ReverseImageSearchEngine.Yandex, true, true)]
-    public async Task ReverseAsync_Sends_Paginator(string url, ImageModule.ReverseImageSearchEngine engine, bool multiImages, bool nsfw)
+    [MemberData(nameof(GetReverseImageSearchData))]
+    public async Task ReverseAsync_Sends_Paginator(string? url, IAttachment? file, ImageModule.ReverseImageSearchEngine engine, bool multiImages, bool nsfw)
     {
         var channel = new Mock<ITextChannel>();
         channel.SetupGet(x => x.IsNsfw).Returns(nsfw);
         _contextMock.SetupGet(x => x.Channel).Returns(channel.Object);
         _interactionMock.SetupGet(x => x.UserLocale).Returns("en");
 
-        var result = await _module.ReverseAsync(url, engine, multiImages);
+        var result = await _module.ReverseAsync(url, file, engine, multiImages);
         Assert.True(result.IsSuccess);
 
         _contextMock.VerifyGet(x => x.Channel);
@@ -166,36 +164,38 @@ public async Task ReverseAsync_Sends_Paginator(string url, ImageModule.ReverseIm
 
         if (engine == ImageModule.ReverseImageSearchEngine.Bing)
         {
-            _moduleMock.Verify(x => x.BingAsync(It.Is<string>(s => s == url), It.Is<bool>(b => b == multiImages), It.IsAny<IDiscordInteraction>(), It.Is<bool>(b => !b)), Times.Once);
-            Mock.Get(_bingVisualSearch).Verify(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == url), It.Is<BingSafeSearchLevel>(l => l == (nsfw ? BingSafeSearchLevel.Off : BingSafeSearchLevel.Strict)), It.IsAny<string>()), Times.Once);
+            _moduleMock.Verify(x => x.BingAsync(It.Is<string>(s => s == (file == null ? url : file.Url)), It.Is<bool>(b => b == multiImages), It.IsAny<IDiscordInteraction>(), It.Is<bool>(b => !b)), Times.Once);
+            Mock.Get(_bingVisualSearch).Verify(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == (file == null ? url : file.Url)), It.Is<BingSafeSearchLevel>(l => l == (nsfw ? BingSafeSearchLevel.Off : BingSafeSearchLevel.Strict)), It.IsAny<string>()), Times.Once);
         }
         else if (engine == ImageModule.ReverseImageSearchEngine.Yandex)
         {
-            _moduleMock.Verify(x => x.YandexAsync(It.Is<string>(s => s == url), It.Is<bool>(b => b == multiImages), It.IsAny<IDiscordInteraction>(), It.Is<bool>(b => !b)), Times.Once);
-            Mock.Get(_yandexImageSearch).Verify(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == url), It.Is<YandexSearchFilterMode>(l => l == (nsfw ? YandexSearchFilterMode.None : YandexSearchFilterMode.Family))), Times.Once);
+            _moduleMock.Verify(x => x.YandexAsync(It.Is<string>(s => s == (file == null ? url : file.Url)), It.Is<bool>(b => b == multiImages), It.IsAny<IDiscordInteraction>(), It.Is<bool>(b => !b)), Times.Once);
+            Mock.Get(_yandexImageSearch).Verify(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == (file == null ? url : file.Url)), It.Is<YandexSearchFilterMode>(l => l == (nsfw ? YandexSearchFilterMode.None : YandexSearchFilterMode.Family))), Times.Once);
         }
     }
 
     [Theory]
-    [InlineData("", ImageModule.ReverseImageSearchEngine.Bing, true, false)]
-    [InlineData("", ImageModule.ReverseImageSearchEngine.Yandex, true, true)]
-    public async Task ReverseAsync_Returns_No_Results(string url, ImageModule.ReverseImageSearchEngine engine, bool multiImages, bool nsfw)
+    [InlineData("", null, ImageModule.ReverseImageSearchEngine.Bing, true, false, "No results.")]
+    [InlineData("", null, ImageModule.ReverseImageSearchEngine.Yandex, true, true, "No results.")]
+    [InlineData(null, null, ImageModule.ReverseImageSearchEngine.Bing, false, true, "A URL or attachment is required.")]
+    [InlineData(null, null, ImageModule.ReverseImageSearchEngine.Yandex, false, true, "A URL or attachment is required.")]
+    public async Task ReverseAsync_Returns_No_Results(string? url, IAttachment? file, ImageModule.ReverseImageSearchEngine engine, bool multiImages, bool nsfw, string message)
     {
         var channel = new Mock<ITextChannel>();
         channel.SetupGet(x => x.IsNsfw).Returns(nsfw);
         _contextMock.SetupGet(x => x.Channel).Returns(channel.Object);
         _interactionMock.SetupGet(x => x.UserLocale).Returns("en");
 
-        var result = await _module.ReverseAsync(url, engine, multiImages);
+        var result = await _module.ReverseAsync(url, file, engine, multiImages);
         Assert.False(result.IsSuccess);
 
-        Mock.Get(_localizer).VerifyGet(x => x[It.Is<string>(y => y == "No results.")]);
+        Mock.Get(_localizer).VerifyGet(x => x[It.Is<string>(y => y == message)]);
     }
 
     [Fact]
     public async Task ReverseAsync_Throws_Exception_If_Invalid_Engine_Is_Passed()
     {
-        await Assert.ThrowsAsync<ArgumentException>("engine", () => _module.ReverseAsync(It.IsAny<string>(), (ImageModule.ReverseImageSearchEngine)2, It.IsAny<bool>()));
+        await Assert.ThrowsAsync<ArgumentException>("engine", () => _module.ReverseAsync("", It.IsAny<IAttachment>(), (ImageModule.ReverseImageSearchEngine)2, It.IsAny<bool>()));
     }
 
     [Theory]
@@ -207,10 +207,24 @@ public async Task ReverseAsync_Throws_Exception_If_Invalid_Parameters_Are_Passed
         channel.SetupGet(x => x.IsNsfw).Returns(false);
         _contextMock.SetupGet(x => x.Channel).Returns(channel.Object);
 
-        var result = await _module.ReverseAsync(null!, engine, It.IsAny<bool>());
+        var result = await _module.ReverseAsync("https://example.com/error", null, engine, It.IsAny<bool>());
         Assert.False(result.IsSuccess);
         Assert.Equal("Error message.", result.ErrorReason);
 
         _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => !b), It.IsAny<RequestOptions>()), Times.Once);
     }
+
+    private static IEnumerable<object?[]> GetReverseImageSearchData()
+    {
+        var attachmentMock = new Mock<IAttachment>();
+        attachmentMock.SetupGet(x => x.Url).Returns("https://example.com/image.png");
+
+        return new[]
+        {
+            new object?[] { "https://example.com/image.png", null, ImageModule.ReverseImageSearchEngine.Bing, false, false },
+            new object?[] { null, attachmentMock.Object, ImageModule.ReverseImageSearchEngine.Bing, true, true },
+            new object?[] { "https://example.com/image.png", null, ImageModule.ReverseImageSearchEngine.Yandex, false, false },
+            new object?[] { null, attachmentMock.Object, ImageModule.ReverseImageSearchEngine.Yandex, true, true }
+        };
+    }
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Utils.cs b/tests/Fergun.Tests/Utils.cs
index 1199751..b02ab31 100644
--- a/tests/Fergun.Tests/Utils.cs
+++ b/tests/Fergun.Tests/Utils.cs
@@ -122,12 +122,13 @@ public static IBingVisualSearch CreateMockedBingVisualSearchApi(Faker? faker = n
         faker ??= new Faker();
         var bingMock = new Mock<IBingVisualSearch>();
 
-        bingMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == null))).ThrowsAsync(new BingException("Error message."));
         bingMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == string.Empty))).ReturnsAsync(() => string.Empty);
         bingMock.Setup(x => x.OcrAsync(It.Is<string>(s => !string.IsNullOrEmpty(s)))).ReturnsAsync(() => faker.Lorem.Sentence());
-        bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == null), It.IsAny<BingSafeSearchLevel>(), It.IsAny<string>())).ThrowsAsync(new BingException("Error message."));
+        bingMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == "https://example.com/error"))).ThrowsAsync(new BingException("Error message."));
+
         bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == string.Empty), It.IsAny<BingSafeSearchLevel>(), It.IsAny<string>())).ReturnsAsync(Enumerable.Empty<IBingReverseImageSearchResult>);
         bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => !string.IsNullOrEmpty(s)), It.IsAny<BingSafeSearchLevel>(), It.IsAny<string>())).ReturnsAsync(() => faker.MakeLazy(50, () => CreateMockedBingReverseImageSearchResult(faker)));
+        bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == "https://example.com/error"), It.IsAny<BingSafeSearchLevel>(), It.IsAny<string>())).ThrowsAsync(new BingException("Error message."));
 
         return bingMock.Object;
     }
@@ -151,12 +152,13 @@ public static IYandexImageSearch CreateMockedYandexImageSearchApi(Faker? faker =
         faker ??= new Faker();
         var yandexMock = new Mock<IYandexImageSearch>();
 
-        yandexMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == null))).ThrowsAsync(new YandexException("Error message."));
         yandexMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == string.Empty))).ReturnsAsync(() => string.Empty);
         yandexMock.Setup(x => x.OcrAsync(It.Is<string>(s => !string.IsNullOrEmpty(s)))).ReturnsAsync(() => faker.Lorem.Sentence());
-        yandexMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == null), It.IsAny<YandexSearchFilterMode>())).ThrowsAsync(new YandexException("Error message."));
+        yandexMock.Setup(x => x.OcrAsync(It.Is<string>(s => s == "https://example.com/error"))).ThrowsAsync(new YandexException("Error message."));
+        
         yandexMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == string.Empty), It.IsAny<YandexSearchFilterMode>())).ReturnsAsync(Enumerable.Empty<IYandexReverseImageSearchResult>);
         yandexMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => !string.IsNullOrEmpty(s)), It.IsAny<YandexSearchFilterMode>())).ReturnsAsync(() => faker.MakeLazy(50, () => CreateMockedYandexReverseImageSearchResult(faker)));
+        yandexMock.Setup(x => x.ReverseImageSearchAsync(It.Is<string>(s => s == "https://example.com/error"), It.IsAny<YandexSearchFilterMode>())).ThrowsAsync(new YandexException("Error message."));
 
         return yandexMock.Object;
     }

From 0dfb8e32ca5c64392c0aaf9089f74c656557c344 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 24 Apr 2022 23:30:03 -0500
Subject: [PATCH 47/83] Move /stats to OtherModule, merge UserModule into
 UtilityModule, add /user and /avatar

---
 src/Modules/OtherModule.cs                    | 171 +++++++++++++
 src/Modules/UserModule.cs                     |  91 -------
 src/Modules/UtilityModule.cs                  | 225 ++++++------------
 ...le.es.resx => Modules.OtherModule.es.resx} |  41 ++--
 src/Resources/Modules.UtilityModule.es.resx   |  53 ++---
 ...erModuleTests.cs => UtilityModuleTests.cs} |  27 ++-
 6 files changed, 314 insertions(+), 294 deletions(-)
 create mode 100644 src/Modules/OtherModule.cs
 delete mode 100644 src/Modules/UserModule.cs
 rename src/Resources/{Modules.UserModule.es.resx => Modules.OtherModule.es.resx} (86%)
 rename tests/Fergun.Tests/Modules/{UserModuleTests.cs => UtilityModuleTests.cs} (77%)

diff --git a/src/Modules/OtherModule.cs b/src/Modules/OtherModule.cs
new file mode 100644
index 0000000..9fdba1f
--- /dev/null
+++ b/src/Modules/OtherModule.cs
@@ -0,0 +1,171 @@
+using System.Diagnostics;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using Discord;
+using Discord.Interactions;
+using Discord.WebSocket;
+using Fergun.Extensions;
+using Fergun.Utils;
+using Humanizer;
+using Microsoft.Extensions.Logging;
+
+namespace Fergun.Modules;
+
+public class OtherModule : InteractionModuleBase
+{
+    private readonly ILogger<OtherModule> _logger;
+    private readonly IFergunLocalizer<OtherModule> _localizer;
+
+    public OtherModule(ILogger<OtherModule> logger, IFergunLocalizer<OtherModule> localizer)
+    {
+        _logger = logger;
+        _localizer = localizer;
+    }
+
+    [SlashCommand("stats", "Sends the stats of the bot.")]
+    public async Task<RuntimeResult> StatsAsync()
+    {
+        await Context.Interaction.DeferAsync();
+
+        long temp;
+        var owner = (await Context.Client.GetApplicationInfoAsync()).Owner;
+        var cpuUsage = (int)await CommandUtils.GetCpuUsageForProcessAsync();
+        string? cpu = null;
+        long? totalRamUsage = null;
+        long processRamUsage = 0;
+        long? totalRam = null;
+        string? os = RuntimeInformation.OSDescription;
+
+        if (OperatingSystem.IsLinux())
+        {
+            // CPU Name
+            if (File.Exists("/proc/cpuinfo"))
+            {
+                cpu = File.ReadAllLines("/proc/cpuinfo")
+                    .FirstOrDefault(x => x.StartsWith("model name", StringComparison.OrdinalIgnoreCase))?
+                    .Split(':')
+                    .ElementAtOrDefault(1)?
+                    .Trim();
+            }
+
+            if (string.IsNullOrWhiteSpace(cpu))
+            {
+                cpu = CommandUtils.RunCommand("lscpu")?
+                    .Split('\n')
+                    .FirstOrDefault(x => x.StartsWith("model name", StringComparison.OrdinalIgnoreCase))?
+                    .Split(':')
+                    .ElementAtOrDefault(1)?
+                    .Trim();
+
+                if (string.IsNullOrWhiteSpace(cpu))
+                {
+                    cpu = "?";
+                }
+            }
+
+            // OS Name
+            if (File.Exists("/etc/lsb-release"))
+            {
+                var distroInfo = File.ReadAllLines("/etc/lsb-release");
+                os = distroInfo.ElementAtOrDefault(3)?.Split('=').ElementAtOrDefault(1)?.Trim('\"');
+            }
+
+            // Total RAM & total RAM usage
+            var output = CommandUtils.RunCommand("free -m")?.Split(Environment.NewLine);
+            var memory = output?.ElementAtOrDefault(1)?.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+
+            if (long.TryParse(memory?.ElementAtOrDefault(1), out temp)) totalRam = temp;
+            if (long.TryParse(memory?.ElementAtOrDefault(2), out temp)) totalRamUsage = temp;
+
+            // Process RAM usage
+            processRamUsage = Process.GetCurrentProcess().WorkingSet64 / 1024 / 1024;
+        }
+        else if (OperatingSystem.IsWindows())
+        {
+            // CPU Name
+            cpu = CommandUtils.RunCommand("wmic cpu get name")
+                ?.Trim()
+                .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
+                .ElementAtOrDefault(1);
+
+            // Total RAM & total RAM usage
+            var output = CommandUtils.RunCommand("wmic OS get FreePhysicalMemory,TotalVisibleMemorySize /Value")
+                ?.Trim()
+                .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
+
+            if (output?.Length > 1)
+            {
+                long freeRam = 0;
+                var split = output[0].Split('=', StringSplitOptions.RemoveEmptyEntries);
+                if (split.Length > 1 && long.TryParse(split[1], out temp))
+                {
+                    freeRam = temp / 1024;
+                }
+
+                split = output[1].Split('=', StringSplitOptions.RemoveEmptyEntries);
+                if (split.Length > 1 && long.TryParse(split[1], out temp))
+                {
+                    totalRam = temp / 1024;
+                }
+
+                if (totalRam != null && freeRam != 0)
+                {
+                    totalRamUsage = totalRam - freeRam;
+                }
+            }
+
+            // Process RAM usage
+            processRamUsage = Process.GetCurrentProcess().PrivateMemorySize64 / 1024 / 1024;
+        }
+
+        var guilds = await Context.Client.GetGuildsAsync(CacheMode.CacheOnly);
+        int? totalUsers = guilds.Sum(x => x.ApproximateMemberCount ?? (x as SocketGuild)?.MemberCount);
+
+        int shards = 1;
+        int shardId = 0;
+        int? totalUsersInShard = null;
+        DiscordSocketClient? shard = null;
+
+        if (Context.Client is DiscordShardedClient shardedClient)
+        {
+            shards = shardedClient.Shards.Count;
+            shardId = Context.Channel.IsPrivate() ? 0 : shardedClient.GetShardIdFor(Context.Guild);
+            shard = shardedClient.GetShard(shardId);
+            totalUsersInShard = shard.Guilds.Sum(x => x.MemberCount);
+        }
+
+        string? version = Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
+
+        var elapsed = DateTimeOffset.UtcNow - Process.GetCurrentProcess().StartTime;
+
+        var builder = new EmbedBuilder()
+            .WithTitle(_localizer["Fergun Stats"])
+            .AddField(_localizer["Operating System"], os, true)
+            .AddField("\u200b", "\u200b", true)
+            .AddField("CPU", cpu, true)
+            .AddField(_localizer["CPU Usage"], $"{cpuUsage}%", true)
+            .AddField("\u200b", "\u200b", true)
+            .AddField(_localizer["RAM Usage"],
+                $"{processRamUsage}MB ({(totalRam == null ? 0 : Math.Round((double)processRamUsage / totalRam.Value * 100, 2))}%) " +
+                $"/ {(totalRamUsage == null || totalRam == null ? "?MB" : $"{totalRamUsage}MB ({Math.Round((double)totalRamUsage.Value / totalRam.Value * 100, 2)}%)")} " +
+                $"/ {totalRam?.ToString() ?? "?"}MB", true)
+            .AddField(_localizer["Library"], $"Discord.Net v{DiscordConfig.Version}", true)
+            .AddField("\u200b", "\u200b", true)
+            .AddField(_localizer["Bot Version"], version is null ? "?" : $"v{version}", true)
+            .AddField(_localizer["Total Servers"], $"{guilds.Count} (Shard: {shard?.Guilds?.Count ?? guilds.Count})", true)
+            .AddField("\u200b", "\u200b", true)
+            .AddField(_localizer["Total Users"], $"{totalUsers?.ToString() ?? "?"} (Shard: {totalUsersInShard?.ToString() ?? totalUsers?.ToString() ?? "?"})", true)
+            .AddField(_localizer["Shard ID"], shardId, true)
+            .AddField("\u200b", "\u200b", true)
+            .AddField("Shards", shards, true)
+            .AddField(_localizer["Uptime"], elapsed.Humanize(), true)
+            .AddField("\u200b", "\u200b", true)
+            .AddField(_localizer["Bot Owner"], owner, true);
+
+        builder.WithColor(Color.Orange);
+
+        await Context.Interaction.FollowupAsync(embed: builder.Build());
+
+        return FergunResult.FromSuccess();
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/UserModule.cs b/src/Modules/UserModule.cs
deleted file mode 100644
index 9d93ed0..0000000
--- a/src/Modules/UserModule.cs
+++ /dev/null
@@ -1,91 +0,0 @@
-using System.Globalization;
-using Discord;
-using Discord.Interactions;
-using Fergun.Extensions;
-
-namespace Fergun.Modules;
-
-public class UserModule : InteractionModuleBase
-{
-    private readonly IFergunLocalizer<UserModule> _localizer;
-
-    public UserModule(IFergunLocalizer<UserModule> localizer)
-    {
-        _localizer = localizer;
-    }
-
-    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
-
-    [UserCommand("Avatar")]
-    public async Task<RuntimeResult> AvatarAsync(IUser user)
-    {
-        string url = (user as IGuildUser)?.GetGuildAvatarUrl(size: 2048) ?? user.GetAvatarUrl(size: 2048) ?? user.GetDefaultAvatarUrl();
-
-        var builder = new EmbedBuilder
-        {
-            Title = user.ToString(),
-            ImageUrl = url,
-            Color = Color.Orange
-        };
-
-        await Context.Interaction.RespondAsync(embed: builder.Build());
-
-        return FergunResult.FromSuccess();
-    }
-
-    [UserCommand("User Info")]
-    public async Task<RuntimeResult> UserInfoAsync(IUser user)
-    {
-        string activities = "";
-        if (user.Activities.Count > 0)
-        {
-            activities = string.Join('\n', user.Activities.Select(x =>
-                x.Type == ActivityType.CustomStatus
-                    ? ((CustomStatusGame)x).ToString()
-                    : $"{x.Type} {x.Name}"));
-        }
-
-        if (string.IsNullOrWhiteSpace(activities))
-            activities = $"({_localizer["None"]})";
-
-        string clients = "?";
-        if (user.ActiveClients.Count > 0)
-        {
-            clients = string.Join(' ', user.ActiveClients.Select(x =>
-                x switch
-                {
-                    ClientType.Desktop => "🖥",
-                    ClientType.Mobile => "📱",
-                    ClientType.Web => "🌐",
-                    _ => ""
-                }));
-        }
-
-        if (string.IsNullOrWhiteSpace(clients))
-            clients = "?";
-
-        var guildUser = user as IGuildUser;
-        string avatarUrl = guildUser?.GetGuildAvatarUrl(size: 2048) ?? user.GetAvatarUrl(ImageFormat.Auto, 2048) ?? user.GetDefaultAvatarUrl();
-
-        var builder = new EmbedBuilder()
-            .WithTitle(_localizer["User Info"])
-            .AddField(_localizer["Name"], user.ToString())
-            .AddField("Nickname", guildUser?.Nickname ?? $"({_localizer["None"]})")
-            .AddField("ID", user.Id)
-            .AddField(_localizer["Activities"], activities, true)
-            .AddField("Active Clients", clients, true)
-            .AddField(_localizer["Is Bot"], user.IsBot)
-            .AddField(_localizer["Created At"], GetTimestamp(user.CreatedAt))
-            .AddField(_localizer["Server Join Date"], GetTimestamp(guildUser?.JoinedAt))
-            .AddField(_localizer["Boosting Since"], GetTimestamp(guildUser?.PremiumSince))
-            .WithThumbnailUrl(avatarUrl)
-            .WithColor(Color.Orange);
-
-        await Context.Interaction.RespondAsync(embed: builder.Build());
-
-        return FergunResult.FromSuccess();
-
-        static string GetTimestamp(DateTimeOffset? dateTime)
-            => dateTime == null ? "N/A" : $"{dateTime.Value.ToDiscordTimestamp()} ({dateTime.Value.ToDiscordTimestamp('R')})";
-    }
-}
\ No newline at end of file
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 4a9fd2c..77cec31 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -1,10 +1,7 @@
 using System.Diagnostics;
 using System.Globalization;
-using System.Reflection;
-using System.Runtime.InteropServices;
 using Discord;
 using Discord.Interactions;
-using Discord.WebSocket;
 using Fergun.Apis.Wikipedia;
 using Fergun.Extensions;
 using Fergun.Interactive;
@@ -57,6 +54,24 @@ public UtilityModule(ILogger<UtilityModule> logger, IFergunLocalizer<UtilityModu
 
     public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
 
+    [UserCommand("Avatar")]
+    [SlashCommand("avatar", "Displays the avatar of a user.")]
+    public async Task<RuntimeResult> AvatarAsync(IUser user)
+    {
+        string url = (user as IGuildUser)?.GetGuildAvatarUrl(size: 2048) ?? user.GetAvatarUrl(size: 2048) ?? user.GetDefaultAvatarUrl();
+
+        var builder = new EmbedBuilder
+        {
+            Title = user.ToString(),
+            ImageUrl = url,
+            Color = Color.Orange
+        };
+
+        await Context.Interaction.RespondAsync(embed: builder.Build());
+
+        return FergunResult.FromSuccess();
+    }
+
     [MessageCommand("Bad Translator")]
     public async Task<RuntimeResult> BadTranslatorAsync(IMessage message)
         => await BadTranslatorAsync(message.GetText());
@@ -227,153 +242,6 @@ public async Task<RuntimeResult> SayAsync([Summary(description: "The text to sen
         return FergunResult.FromSuccess();
     }
 
-    [SlashCommand("stats", "Sends the stats of the bot.")]
-    public async Task<RuntimeResult> StatsAsync()
-    {
-        await Context.Interaction.DeferAsync();
-
-        long temp;
-        var owner = (await Context.Client.GetApplicationInfoAsync()).Owner;
-        var cpuUsage = (int)await CommandUtils.GetCpuUsageForProcessAsync();
-        string? cpu = null;
-        long? totalRamUsage = null;
-        long processRamUsage = 0;
-        long? totalRam = null;
-        string? os = RuntimeInformation.OSDescription;
-
-        if (OperatingSystem.IsLinux())
-        {
-            // CPU Name
-            if (File.Exists("/proc/cpuinfo"))
-            {
-                cpu = File.ReadAllLines("/proc/cpuinfo")
-                    .FirstOrDefault(x => x.StartsWith("model name", StringComparison.OrdinalIgnoreCase))?
-                    .Split(':')
-                    .ElementAtOrDefault(1)?
-                    .Trim();
-            }
-
-            if (string.IsNullOrWhiteSpace(cpu))
-            {
-                cpu = CommandUtils.RunCommand("lscpu")?
-                    .Split('\n')
-                    .FirstOrDefault(x => x.StartsWith("model name", StringComparison.OrdinalIgnoreCase))?
-                    .Split(':')
-                    .ElementAtOrDefault(1)?
-                    .Trim();
-
-                if (string.IsNullOrWhiteSpace(cpu))
-                {
-                    cpu = "?";
-                }
-            }
-
-            // OS Name
-            if (File.Exists("/etc/lsb-release"))
-            {
-                var distroInfo = File.ReadAllLines("/etc/lsb-release");
-                os = distroInfo.ElementAtOrDefault(3)?.Split('=').ElementAtOrDefault(1)?.Trim('\"');
-            }
-
-            // Total RAM & total RAM usage
-            var output = CommandUtils.RunCommand("free -m")?.Split(Environment.NewLine);
-            var memory = output?.ElementAtOrDefault(1)?.Split(' ', StringSplitOptions.RemoveEmptyEntries);
-
-            if (long.TryParse(memory?.ElementAtOrDefault(1), out temp)) totalRam = temp;
-            if (long.TryParse(memory?.ElementAtOrDefault(2), out temp)) totalRamUsage = temp;
-
-            // Process RAM usage
-            processRamUsage = Process.GetCurrentProcess().WorkingSet64 / 1024 / 1024;
-        }
-        else if (OperatingSystem.IsWindows())
-        {
-            // CPU Name
-            cpu = CommandUtils.RunCommand("wmic cpu get name")
-                ?.Trim()
-                .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
-                .ElementAtOrDefault(1);
-
-            // Total RAM & total RAM usage
-            var output = CommandUtils.RunCommand("wmic OS get FreePhysicalMemory,TotalVisibleMemorySize /Value")
-                ?.Trim()
-                .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
-
-            if (output?.Length > 1)
-            {
-                long freeRam = 0;
-                var split = output[0].Split('=', StringSplitOptions.RemoveEmptyEntries);
-                if (split.Length > 1 && long.TryParse(split[1], out temp))
-                {
-                    freeRam = temp / 1024;
-                }
-
-                split = output[1].Split('=', StringSplitOptions.RemoveEmptyEntries);
-                if (split.Length > 1 && long.TryParse(split[1], out temp))
-                {
-                    totalRam = temp / 1024;
-                }
-
-                if (totalRam != null && freeRam != 0)
-                {
-                    totalRamUsage = totalRam - freeRam;
-                }
-            }
-
-            // Process RAM usage
-            processRamUsage = Process.GetCurrentProcess().PrivateMemorySize64 / 1024 / 1024;
-        }
-
-        var guilds = await Context.Client.GetGuildsAsync(CacheMode.CacheOnly);
-        int? totalUsers = guilds.Sum(x => x.ApproximateMemberCount ?? (x as SocketGuild)?.MemberCount);
-
-        int shards = 1;
-        int shardId = 0;
-        int? totalUsersInShard = null;
-        DiscordSocketClient? shard = null;
-
-        if (Context.Client is DiscordShardedClient shardedClient)
-        {
-            shards = shardedClient.Shards.Count;
-            shardId = Context.Channel.IsPrivate() ? 0 : shardedClient.GetShardIdFor(Context.Guild);
-            shard = shardedClient.GetShard(shardId);
-            totalUsersInShard = shard.Guilds.Sum(x => x.MemberCount);
-        }
-
-        string? version = Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
-
-        var elapsed = DateTimeOffset.UtcNow - Process.GetCurrentProcess().StartTime;
-
-        var builder = new EmbedBuilder()
-            .WithTitle(_localizer["Fergun Stats"])
-            .AddField(_localizer["Operating System"], os, true)
-            .AddField("\u200b", "\u200b", true)
-            .AddField("CPU", cpu, true)
-            .AddField(_localizer["CPU Usage"], $"{cpuUsage}%", true)
-            .AddField("\u200b", "\u200b", true)
-            .AddField(_localizer["RAM Usage"],
-                $"{processRamUsage}MB ({(totalRam == null ? 0 : Math.Round((double)processRamUsage / totalRam.Value * 100, 2))}%) " +
-                $"/ {(totalRamUsage == null || totalRam == null ? "?MB" : $"{totalRamUsage}MB ({Math.Round((double)totalRamUsage.Value / totalRam.Value * 100, 2)}%)")} " +
-                $"/ {totalRam?.ToString() ?? "?"}MB", true)
-            .AddField(_localizer["Library"], $"Discord.Net v{DiscordConfig.Version}", true)
-            .AddField("\u200b", "\u200b", true)
-            .AddField(_localizer["Bot Version"], version is null ? "?" : $"v{version}", true)
-            .AddField(_localizer["Total Servers"], $"{guilds.Count} (Shard: {shard?.Guilds?.Count ?? guilds.Count})", true)
-            .AddField("\u200b", "\u200b", true)
-            .AddField(_localizer["Total Users"], $"{totalUsers?.ToString() ?? "?"} (Shard: {totalUsersInShard?.ToString() ?? totalUsers?.ToString() ?? "?"})", true)
-            .AddField(_localizer["Shard ID"], shardId, true)
-            .AddField("\u200b", "\u200b", true)
-            .AddField("Shards", shards, true)
-            .AddField(_localizer["Uptime"], elapsed.Humanize(), true)
-            .AddField("\u200b", "\u200b", true)
-            .AddField(_localizer["Bot Owner"], owner, true);
-
-        builder.WithColor(Color.Orange);
-        
-        await Context.Interaction.FollowupAsync(embed: builder.Build());
-
-        return FergunResult.FromSuccess();
-    }
-
     [MessageCommand("Translate")]
     public async Task<RuntimeResult> TranslateAsync(IMessage message)
         => await TranslateAsync(message.GetText(), Context.Interaction.GetLanguageCode());
@@ -395,6 +263,63 @@ public async Task<RuntimeResult> TtsAsync([Summary(description: "The text to con
         [Summary(description: "Whether to respond ephemerally.")] bool ephemeral = false)
         => await _shared.TtsAsync(Context.Interaction, text, target ?? Context.Interaction.GetLanguageCode(), ephemeral);
 
+    [UserCommand("User Info")]
+    [SlashCommand("user", "Gets information about a user.")]
+    public async Task<RuntimeResult> UserInfoAsync([Summary(description: "The user.")] IUser user)
+    {
+        string activities = "";
+        if (user.Activities.Count > 0)
+        {
+            activities = string.Join('\n', user.Activities.Select(x =>
+                x.Type == ActivityType.CustomStatus
+                    ? ((CustomStatusGame)x).ToString()
+                    : $"{x.Type} {x.Name}"));
+        }
+
+        if (string.IsNullOrWhiteSpace(activities))
+            activities = $"({_localizer["None"]})";
+
+        string clients = "?";
+        if (user.ActiveClients.Count > 0)
+        {
+            clients = string.Join(' ', user.ActiveClients.Select(x =>
+                x switch
+                {
+                    ClientType.Desktop => "🖥",
+                    ClientType.Mobile => "📱",
+                    ClientType.Web => "🌐",
+                    _ => ""
+                }));
+        }
+
+        if (string.IsNullOrWhiteSpace(clients))
+            clients = "?";
+
+        var guildUser = user as IGuildUser;
+        string avatarUrl = guildUser?.GetGuildAvatarUrl(size: 2048) ?? user.GetAvatarUrl(ImageFormat.Auto, 2048) ?? user.GetDefaultAvatarUrl();
+
+        var builder = new EmbedBuilder()
+            .WithTitle(_localizer["User Info"])
+            .AddField(_localizer["Name"], user.ToString())
+            .AddField("Nickname", guildUser?.Nickname ?? $"({_localizer["None"]})")
+            .AddField("ID", user.Id)
+            .AddField(_localizer["Activities"], activities, true)
+            .AddField(_localizer["Active Clients"], clients, true)
+            .AddField(_localizer["Is Bot"], user.IsBot)
+            .AddField(_localizer["Created At"], GetTimestamp(user.CreatedAt))
+            .AddField(_localizer["Server Join Date"], GetTimestamp(guildUser?.JoinedAt))
+            .AddField(_localizer["Boosting Since"], GetTimestamp(guildUser?.PremiumSince))
+            .WithThumbnailUrl(avatarUrl)
+            .WithColor(Color.Orange);
+
+        await Context.Interaction.RespondAsync(embed: builder.Build());
+
+        return FergunResult.FromSuccess();
+
+        static string GetTimestamp(DateTimeOffset? dateTime)
+            => dateTime == null ? "N/A" : $"{dateTime.Value.ToDiscordTimestamp()} ({dateTime.Value.ToDiscordTimestamp('R')})";
+    }
+
     [SlashCommand("wikipedia", "Searches for Wikipedia articles.")]
     public async Task<RuntimeResult> WikipediaAsync([Autocomplete(typeof(WikipediaAutocompleteHandler))] [Summary(description: "The search query.")] string query)
     {
diff --git a/src/Resources/Modules.UserModule.es.resx b/src/Resources/Modules.OtherModule.es.resx
similarity index 86%
rename from src/Resources/Modules.UserModule.es.resx
rename to src/Resources/Modules.OtherModule.es.resx
index f237274..de4bf68 100644
--- a/src/Resources/Modules.UserModule.es.resx
+++ b/src/Resources/Modules.OtherModule.es.resx
@@ -117,28 +117,37 @@
   <resheader name="writer">
     <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
   </resheader>
-  <data name="Active Clients" xml:space="preserve">
-    <value>Clientes Activos</value>
+  <data name="Bot Owner" xml:space="preserve">
+    <value>Dueño del bot</value>
   </data>
-  <data name="Activities" xml:space="preserve">
-    <value>Actividades</value>
+  <data name="Bot Version" xml:space="preserve">
+    <value>Versión del bot</value>
   </data>
-  <data name="Boosting Since" xml:space="preserve">
-    <value>Mejorando desde</value>
+  <data name="CPU Usage" xml:space="preserve">
+    <value>Uso de CPU</value>
   </data>
-  <data name="Created At" xml:space="preserve">
-    <value>Creado En</value>
+  <data name="Fergun Stats" xml:space="preserve">
+    <value>Estadísticas de Fergun</value>
   </data>
-  <data name="Is Bot" xml:space="preserve">
-    <value>Es Bot</value>
+  <data name="Library" xml:space="preserve">
+    <value>Librería</value>
   </data>
-  <data name="Name" xml:space="preserve">
-    <value>Nombre</value>
+  <data name="Operating System" xml:space="preserve">
+    <value>Sistema Operativo</value>
   </data>
-  <data name="Server Join Date" xml:space="preserve">
-    <value>Fecha de ingreso al servidor</value>
+  <data name="RAM Usage" xml:space="preserve">
+    <value>Uso de RAM</value>
   </data>
-  <data name="User Info" xml:space="preserve">
-    <value>Información de usuario</value>
+  <data name="Shard ID" xml:space="preserve">
+    <value>ID de shard</value>
+  </data>
+  <data name="Total Servers" xml:space="preserve">
+    <value>Servidores Totales</value>
+  </data>
+  <data name="Total Users" xml:space="preserve">
+    <value>Usuarios Totales</value>
+  </data>
+  <data name="Uptime" xml:space="preserve">
+    <value>Tiempo activo</value>
   </data>
 </root>
\ No newline at end of file
diff --git a/src/Resources/Modules.UtilityModule.es.resx b/src/Resources/Modules.UtilityModule.es.resx
index b2b5de0..3aeebf5 100644
--- a/src/Resources/Modules.UtilityModule.es.resx
+++ b/src/Resources/Modules.UtilityModule.es.resx
@@ -117,21 +117,9 @@
   <resheader name="writer">
     <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
   </resheader>
-  <data name="Bot Owner" xml:space="preserve">
-    <value>Dueño del bot</value>
-  </data>
-  <data name="Bot Version" xml:space="preserve">
-    <value>Versión del bot</value>
-  </data>
   <data name="Command output" xml:space="preserve">
     <value>Salida de comando</value>
   </data>
-  <data name="CPU Usage" xml:space="preserve">
-    <value>Uso de CPU</value>
-  </data>
-  <data name="Fergun Stats" xml:space="preserve">
-    <value>Estadísticas de Fergun</value>
-  </data>
   <data name="Fergun2Info" xml:space="preserve">
     <value>Esto es Fergun 2. Fergun 2 es una reescritura completa de Fergun 1 y solo tendrá slash commands.
 Fergun 2 esta en estado alfa y sólo los comandos más usados están presentes, pero más comandos serán agregados pronto.
@@ -147,34 +135,37 @@ Puedes encontrar más información sobre la eliminación de estos módulos/coman
   <data name="Language Chain" xml:space="preserve">
     <value>Cadena de Idiomas</value>
   </data>
-  <data name="Library" xml:space="preserve">
-    <value>Librería</value>
-  </data>
   <data name="No output." xml:space="preserve">
     <value>Sin salida.</value>
   </data>
-  <data name="Operating System" xml:space="preserve">
-    <value>Sistema Operativo</value>
+  <data name="The chain count must be between 2 and 10 (inclusive)." xml:space="preserve">
+    <value>El número de cadenas debe estar entre 2 y 10 (inclusivo).</value>
   </data>
-  <data name="RAM Usage" xml:space="preserve">
-    <value>Uso de RAM</value>
+  <data name="Wikipedia Search | Page {0} of {1}" xml:space="preserve">
+    <value>Búsqueda de Wikipedia | Página {0} de {1}</value>
   </data>
-  <data name="Shard ID" xml:space="preserve">
-    <value>ID de shard</value>
+  <data name="Active Clients" xml:space="preserve">
+    <value>Clientes Activos</value>
   </data>
-  <data name="The chain count must be between 2 and 10 (inclusive)." xml:space="preserve">
-    <value>El número de cadenas debe estar entre 2 y 10 (inclusivo).</value>
+  <data name="Activities" xml:space="preserve">
+    <value>Actividades</value>
   </data>
-  <data name="Total Servers" xml:space="preserve">
-    <value>Servidores Totales</value>
+  <data name="Boosting Since" xml:space="preserve">
+    <value>Mejorando desde</value>
   </data>
-  <data name="Total Users" xml:space="preserve">
-    <value>Usuarios Totales</value>
+  <data name="Created At" xml:space="preserve">
+    <value>Creado En</value>
   </data>
-  <data name="Uptime" xml:space="preserve">
-    <value>Tiempo activo</value>
+  <data name="Is Bot" xml:space="preserve">
+    <value>Es Bot</value>
   </data>
-  <data name="Wikipedia Search | Page {0} of {1}" xml:space="preserve">
-    <value>Búsqueda de Wikipedia | Página {0} de {1}</value>
+  <data name="Name" xml:space="preserve">
+    <value>Nombre</value>
+  </data>
+  <data name="Server Join Date" xml:space="preserve">
+    <value>Fecha de ingreso al servidor</value>
+  </data>
+  <data name="User Info" xml:space="preserve">
+    <value>Información de usuario</value>
   </data>
 </root>
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Modules/UserModuleTests.cs b/tests/Fergun.Tests/Modules/UtilityModuleTests.cs
similarity index 77%
rename from tests/Fergun.Tests/Modules/UserModuleTests.cs
rename to tests/Fergun.Tests/Modules/UtilityModuleTests.cs
index 823b82a..a475142 100644
--- a/tests/Fergun.Tests/Modules/UserModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/UtilityModuleTests.cs
@@ -4,22 +4,37 @@
 using Bogus;
 using Discord;
 using Discord.Interactions;
+using Discord.WebSocket;
+using Fergun.Apis.Wikipedia;
+using Fergun.Interactive;
 using Fergun.Modules;
+using GTranslate.Translators;
+using Microsoft.Extensions.Logging;
 using Moq;
 using Xunit;
+using YoutubeExplode.Search;
 
 namespace Fergun.Tests.Modules;
 
-public class UserModuleTests
+public class UtilityModuleTests
 {
     private readonly Mock<IInteractionContext> _contextMock = new();
     private readonly Mock<IDiscordInteraction> _interactionMock = new();
-    private readonly IFergunLocalizer<UserModule> _localizer = Utils.CreateMockedLocalizer<UserModule>();
-    private readonly Mock<UserModule> _moduleMock;
-
-    public UserModuleTests()
+    private readonly IFergunLocalizer<UtilityModule> _localizer = Utils.CreateMockedLocalizer<UtilityModule>();
+    private readonly GoogleTranslator _googleTranslator = new();
+    private readonly GoogleTranslator2 _googleTranslator2 = new();
+    private readonly MicrosoftTranslator _microsoftTranslator = new();
+    private readonly YandexTranslator _yandexTranslator = new();
+    private readonly SearchClient _searchClient = new(new());
+    private readonly IWikipediaClient _wikipediaClient = null!;
+    private readonly Mock<UtilityModule> _moduleMock;
+
+    public UtilityModuleTests()
     {
-        _moduleMock = new Mock<UserModule>(() => new UserModule(_localizer)) { CallBase = true };
+        SharedModule shared = new(Mock.Of<ILogger<SharedModule>>(), Utils.CreateMockedLocalizer<SharedResource>(), new AggregateTranslator(), _googleTranslator2);
+        var interactive = new InteractiveService(new DiscordSocketClient(), new InteractiveConfig { ReturnAfterSendingPaginator = true });
+        _moduleMock = new Mock<UtilityModule>(() => new UtilityModule(Mock.Of<ILogger<UtilityModule>>(), _localizer, shared,
+            interactive, _googleTranslator, _googleTranslator2, _microsoftTranslator, _yandexTranslator, _searchClient, _wikipediaClient)) { CallBase = true };
         _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
         ((IInteractionModuleBase)_moduleMock.Object).SetContext(_contextMock.Object);
     }

From 8186ad794ce8def72a99d611acb732549f851b2b Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 24 Apr 2022 23:59:43 -0500
Subject: [PATCH 48/83] Add /inspirobot

---
 src/Modules/OtherModule.cs | 21 ++++++++++++++++++++-
 src/Program.cs             |  2 +-
 2 files changed, 21 insertions(+), 2 deletions(-)

diff --git a/src/Modules/OtherModule.cs b/src/Modules/OtherModule.cs
index 9fdba1f..beb11af 100644
--- a/src/Modules/OtherModule.cs
+++ b/src/Modules/OtherModule.cs
@@ -15,11 +15,30 @@ public class OtherModule : InteractionModuleBase
 {
     private readonly ILogger<OtherModule> _logger;
     private readonly IFergunLocalizer<OtherModule> _localizer;
+    private readonly HttpClient _httpClient;
 
-    public OtherModule(ILogger<OtherModule> logger, IFergunLocalizer<OtherModule> localizer)
+    public OtherModule(ILogger<OtherModule> logger, IFergunLocalizer<OtherModule> localizer, HttpClient httpClient)
     {
         _logger = logger;
         _localizer = localizer;
+        _httpClient = httpClient;
+    }
+
+    [SlashCommand("inspirobot", "Sends an inspirational quote.")]
+    public async Task<RuntimeResult> InspiroBotAsync()
+    {
+        await Context.Interaction.DeferAsync();
+
+        string url = await _httpClient.GetStringAsync(new Uri("https://inspirobot.me/api?generate=true"));
+
+        var builder = new EmbedBuilder()
+            .WithTitle("InspiroBot")
+            .WithImageUrl(url)
+            .WithColor(Color.Orange);
+
+        await Context.Interaction.FollowupAsync(embed: builder.Build());
+
+        return FergunResult.FromSuccess();
     }
 
     [SlashCommand("stats", "Sends the stats of the bot.")]
diff --git a/src/Program.cs b/src/Program.cs
index ea3f79e..5f8f9ee 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -111,7 +111,7 @@ await Host.CreateDefaultBuilder()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
 
-        services.AddHttpClient<UtilityModule>()
+        services.AddHttpClient<OtherModule>()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
 

From 34998609df63c86f207af1717cb0ff83eca02a61 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Mon, 25 Apr 2022 17:35:19 -0500
Subject: [PATCH 49/83] Add /color

---
 src/Converters/ColorConverter.cs           | 63 ++++++++++++++++++++++
 src/Fergun.csproj                          |  2 +
 src/Modules/UtilityModule.cs               | 37 +++++++++++++
 src/Program.cs                             |  2 +-
 src/Resources/SharedResource.es.resx       |  3 ++
 src/Services/InteractionHandlingService.cs |  2 +
 6 files changed, 108 insertions(+), 1 deletion(-)
 create mode 100644 src/Converters/ColorConverter.cs

diff --git a/src/Converters/ColorConverter.cs b/src/Converters/ColorConverter.cs
new file mode 100644
index 0000000..9bbda04
--- /dev/null
+++ b/src/Converters/ColorConverter.cs
@@ -0,0 +1,63 @@
+using System.Globalization;
+using Discord;
+using Discord.Interactions;
+using Fergun.Extensions;
+using Humanizer;
+using Microsoft.Extensions.DependencyInjection;
+using Color = System.Drawing.Color;
+
+namespace Fergun.Converters;
+
+/// <summary>
+/// Represents a converter of <see cref="Color"/>.
+/// </summary>
+public class ColorConverter : TypeConverter<Color>
+{
+    /// <inheritdoc/>
+    public override ApplicationCommandOptionType GetDiscordType() => ApplicationCommandOptionType.String;
+
+    /// <inheritdoc/>
+    public override Task<TypeConverterResult> ReadAsync(IInteractionContext context, IApplicationCommandInteractionDataOption option, IServiceProvider services)
+    {
+        string value = option.Value as string ?? string.Empty;
+        var color = Color.FromName(value);
+        if (color.ToArgb() == 0)
+        {
+            var span = value.AsSpan().Trim();
+            int index = span.IndexOf('#');
+            if (index != -1)
+            {
+                span = span[++index..];
+            }
+            else
+            {
+                index = span.IndexOf("0x");
+            }
+            if (index != -1)
+            {
+                span = span[(index + 2)..];
+            }
+
+            if ((uint.TryParse(span, NumberStyles.Integer, CultureInfo.InvariantCulture, out uint rawColor)
+                || uint.TryParse(span, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out rawColor))
+                && rawColor <= Discord.Color.MaxDecimalValue)
+            {
+                color = Color.FromArgb((int)rawColor);
+            }
+        }
+
+        if (color.ToArgb() == 0)
+        {
+            var localizer = services.GetRequiredService<IFergunLocalizer<SharedResource>>();
+            localizer.CurrentCulture = CultureInfo.GetCultureInfo(context.Interaction.GetLanguageCode());
+            return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, localizer["Could not convert \"{0}\" to a color.", value.Truncate(20)]));
+        }
+
+        return Task.FromResult(TypeConverterResult.FromSuccess(color));
+    }
+
+    /// <inheritdoc/>
+    public override void Write(ApplicationCommandOptionProperties properties, IParameterInfo parameterInfo)
+    {
+    }
+}
\ No newline at end of file
diff --git a/src/Fergun.csproj b/src/Fergun.csproj
index e75ddc0..0da8050 100644
--- a/src/Fergun.csproj
+++ b/src/Fergun.csproj
@@ -23,6 +23,8 @@
     <PackageReference Include="Serilog.Extensions.Logging.File" Version="2.0.0" />
     <PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
     <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
+    <PackageReference Include="SixLabors.ImageSharp" Version="2.1.1" />
+    <PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta14" />
     <PackageReference Include="YoutubeExplode" Version="6.1.0" />
   </ItemGroup>
 
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 77cec31..e20ec82 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -13,8 +13,14 @@
 using GTranslate.Translators;
 using Humanizer;
 using Microsoft.Extensions.Logging;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Drawing.Processing;
+using SixLabors.ImageSharp.Formats.Png;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
 using YoutubeExplode.Common;
 using YoutubeExplode.Search;
+using Color = Discord.Color;
 
 namespace Fergun.Modules;
 
@@ -31,6 +37,8 @@ public class UtilityModule : InteractionModuleBase
     private readonly SearchClient _searchClient;
     private readonly IWikipediaClient _wikipediaClient;
 
+    private static readonly DrawingOptions _cachedDrawingOptions = new();
+    private static readonly PngEncoder _cachedPngEncoder = new() { CompressionLevel = PngCompressionLevel.BestCompression, IgnoreMetadata = true };
     private static readonly Lazy<Language[]> _lazyFilteredLanguages = new(() => Language.LanguageDictionary
         .Values
         .Where(x => x.SupportedServices == (TranslationServices.Google | TranslationServices.Bing | TranslationServices.Yandex | TranslationServices.Microsoft))
@@ -198,6 +206,35 @@ public async Task<RuntimeResult> CmdAsync([Summary(description: "The command to
         return FergunResult.FromSuccess();
     }
 
+    [SlashCommand("color", "Displays a color.")]
+    public async Task<RuntimeResult> ColorAsync([Summary(description: "A color name, hex string or raw value. Leave empty to get a random color.")]
+        System.Drawing.Color color = default)
+    {
+        if (color.IsEmpty)
+        {
+            color = System.Drawing.Color.FromArgb(Random.Shared.Next((int)(Color.MaxDecimalValue + 1)));
+        }
+
+        using var image = new Image<Rgba32>(500, 500);
+
+        image.Mutate(x => x.Fill(_cachedDrawingOptions, SixLabors.ImageSharp.Color.FromRgb(color.R, color.G, color.B)));
+        await using var stream = new MemoryStream();
+        await image.SaveAsPngAsync(stream, _cachedPngEncoder);
+        stream.Seek(0, SeekOrigin.Begin);
+
+        string hex = $"{color.R:X2}{color.G:X2}{color.B:X2}";
+
+        var builder = new EmbedBuilder()
+            .WithTitle($"#{hex}{(color.IsNamedColor ? $" ({color.Name})" : "")}")
+            .WithImageUrl($"attachment://{hex}.png")
+            .WithFooter($"R: {color.R}, G: {color.G}, B: {color.B}")
+            .WithColor((Color)color);
+
+        await Context.Interaction.RespondWithFileAsync(new FileAttachment(stream, $"{hex}.png"), embed: builder.Build());
+
+        return FergunResult.FromSuccess();
+    }
+
     [SlashCommand("help", "Information about Fergun 2")]
     public async Task<RuntimeResult> HelpAsync()
     {
diff --git a/src/Program.cs b/src/Program.cs
index 5f8f9ee..088b343 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -43,7 +43,7 @@ await Host.CreateDefaultBuilder()
     {
         config.LogLevel = LogSeverity.Verbose;
         config.DefaultRunMode = RunMode.Async;
-        config.UseCompiledLambda = true;
+        config.UseCompiledLambda = false;
     })
     .ConfigureLogging(logging => logging.ClearProviders())
     .UseSerilog((_, config) =>
diff --git a/src/Resources/SharedResource.es.resx b/src/Resources/SharedResource.es.resx
index 07890c2..47ef9ef 100644
--- a/src/Resources/SharedResource.es.resx
+++ b/src/Resources/SharedResource.es.resx
@@ -126,6 +126,9 @@
   <data name="Bing Visual search is currently unavailable. Try again later." xml:space="preserve">
     <value>La Búsqueda Visual de Bing no está disponible actualmente. Vuelva a intentarlo más tarde.</value>
   </data>
+  <data name="Could not convert &quot;{0}&quot; to a color." xml:space="preserve">
+    <value>No se pudo convertir "{0}" a un color.</value>
+  </data>
   <data name="Error message: {0}" xml:space="preserve">
     <value>Mensaje de error: {0}</value>
   </data>
diff --git a/src/Services/InteractionHandlingService.cs b/src/Services/InteractionHandlingService.cs
index 8af8b51..ad8e46a 100644
--- a/src/Services/InteractionHandlingService.cs
+++ b/src/Services/InteractionHandlingService.cs
@@ -3,6 +3,7 @@
 using Discord;
 using Discord.Interactions;
 using Discord.WebSocket;
+using Fergun.Converters;
 using Fergun.Extensions;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
@@ -37,6 +38,7 @@ public async Task StartAsync(CancellationToken cancellationToken)
         _interactionService.ContextCommandExecuted += ContextMenuCommandExecuted;
         _shardedClient.InteractionCreated += HandleInteractionAsync;
 
+        _interactionService.AddTypeConverter<System.Drawing.Color>(new ColorConverter());
         var modules = await _interactionService.AddModulesAsync(Assembly.GetEntryAssembly(), _services);
         _logger.LogDebug("Added {moduleCount} command modules", modules.Count());
 

From 9fad94856de757cdd435804a7035297c4f166e4c Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Mon, 25 Apr 2022 20:39:46 -0500
Subject: [PATCH 50/83] Improve ColorConverter

---
 src/Converters/ColorConverter.cs | 23 ++++++++---------------
 1 file changed, 8 insertions(+), 15 deletions(-)

diff --git a/src/Converters/ColorConverter.cs b/src/Converters/ColorConverter.cs
index 9bbda04..26ae142 100644
--- a/src/Converters/ColorConverter.cs
+++ b/src/Converters/ColorConverter.cs
@@ -23,23 +23,16 @@ public override Task<TypeConverterResult> ReadAsync(IInteractionContext context,
         var color = Color.FromName(value);
         if (color.ToArgb() == 0)
         {
-            var span = value.AsSpan().Trim();
-            int index = span.IndexOf('#');
-            if (index != -1)
-            {
-                span = span[++index..];
-            }
-            else
-            {
-                index = span.IndexOf("0x");
-            }
-            if (index != -1)
-            {
-                span = span[(index + 2)..];
-            }
+            var span = value.AsSpan()
+                .TrimStart()
+                .TrimStart('#')
+                .TrimStart("0x")
+                .TrimStart("0X")
+                .TrimStart("&h")
+                .TrimStart("&H");
 
             if ((uint.TryParse(span, NumberStyles.Integer, CultureInfo.InvariantCulture, out uint rawColor)
-                || uint.TryParse(span, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out rawColor))
+                 || uint.TryParse(span, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out rawColor))
                 && rawColor <= Discord.Color.MaxDecimalValue)
             {
                 color = Color.FromArgb((int)rawColor);

From 2dade97c735e7b08c7c3878cee594e167fb3786c Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Mon, 25 Apr 2022 21:40:45 -0500
Subject: [PATCH 51/83] Localize paginator prompts

---
 src/Extensions/Extensions.cs          | 20 -----------
 src/Extensions/PaginatorExtensions.cs | 50 +++++++++++++++++++++++++++
 src/Modules/ImageModule.cs            |  5 +++
 src/Modules/UrbanModule.cs            |  1 +
 src/Modules/UtilityModule.cs          | 11 ++++--
 src/Resources/SharedResource.es.resx  | 15 ++++++++
 6 files changed, 79 insertions(+), 23 deletions(-)
 create mode 100644 src/Extensions/PaginatorExtensions.cs

diff --git a/src/Extensions/Extensions.cs b/src/Extensions/Extensions.cs
index 6224302..c43847d 100644
--- a/src/Extensions/Extensions.cs
+++ b/src/Extensions/Extensions.cs
@@ -1,5 +1,4 @@
 using Discord;
-using Fergun.Interactive.Pagination;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Polly;
@@ -60,23 +59,4 @@ public static string Display(this IInteractionContext context)
 
         return displayMessage;
     }
-
-    /// <summary>
-    /// Adds Fergun emotes.
-    /// </summary>
-    /// <param name="builder">A paginator builder.</param>
-    /// <returns>This builder.</returns>
-    public static TBuilder WithFergunEmotes<TPaginator, TBuilder>(this PaginatorBuilder<TPaginator, TBuilder> builder)
-        where TPaginator : Paginator
-        where TBuilder : PaginatorBuilder<TPaginator, TBuilder>
-    {
-        builder.Options.Clear();
-
-        builder.AddOption(Emoji.Parse("◀️"), PaginatorAction.Backward);
-        builder.AddOption(Emoji.Parse("▶️"), PaginatorAction.Forward);
-        builder.AddOption(Emoji.Parse("🔢"), PaginatorAction.Jump);
-        builder.AddOption(Emoji.Parse("🛑"), PaginatorAction.Exit);
-
-        return (TBuilder)builder;
-    }
 }
\ No newline at end of file
diff --git a/src/Extensions/PaginatorExtensions.cs b/src/Extensions/PaginatorExtensions.cs
new file mode 100644
index 0000000..772632d
--- /dev/null
+++ b/src/Extensions/PaginatorExtensions.cs
@@ -0,0 +1,50 @@
+using Discord;
+using Fergun.Interactive.Pagination;
+using Microsoft.Extensions.Localization;
+
+namespace Fergun.Extensions;
+
+public static class PaginatorExtensions
+{
+    /// <summary>
+    /// Adds Fergun emotes.
+    /// </summary>
+    /// <typeparam name="TPaginator">The type of the paginator.</typeparam>
+    /// <typeparam name="TBuilder">The type of the paginator builder.</typeparam>
+    /// <param name="builder">A paginator builder.</param>
+    /// <returns>This builder.</returns>
+    public static TBuilder WithFergunEmotes<TPaginator, TBuilder>(this PaginatorBuilder<TPaginator, TBuilder> builder)
+        where TPaginator : Paginator
+        where TBuilder : PaginatorBuilder<TPaginator, TBuilder>
+    {
+        builder.Options.Clear();
+
+        builder.AddOption(Emoji.Parse("◀️"), PaginatorAction.Backward);
+        builder.AddOption(Emoji.Parse("▶️"), PaginatorAction.Forward);
+        builder.AddOption(Emoji.Parse("🔢"), PaginatorAction.Jump);
+        builder.AddOption(Emoji.Parse("🛑"), PaginatorAction.Exit);
+
+        return (TBuilder)builder;
+    }
+
+    /// <summary>
+    /// Sets the localized prompts.
+    /// </summary>
+    /// <typeparam name="TPaginator">The type of the paginator.</typeparam>
+    /// <typeparam name="TBuilder">The type of the paginator builder.</typeparam>
+    /// <param name="builder">The paginator builder.</param>
+    /// <param name="localizer">The localizer.</param>
+    /// <returns>This builder.</returns>
+    public static TBuilder WithLocalizedPrompts<TPaginator, TBuilder>(this BaseLazyPaginatorBuilder<TPaginator, TBuilder> builder, IStringLocalizer localizer)
+        where TPaginator : BaseLazyPaginator
+        where TBuilder : BaseLazyPaginatorBuilder<TPaginator, TBuilder>
+    {
+        builder.WithJumpInputPrompt(localizer["Enter a page number"]);
+        builder.WithJumpInputTextLabel(localizer["Page number ({0}-{1})", 1, builder.MaxPageIndex + 1]);
+        builder.WithInvalidJumpInputMessage(localizer["Invalid input. The number must be in the range of {0} to {1}, excluding the current page.", 1, builder.MaxPageIndex + 1]);
+        builder.WithJumpInputInUseMessage(localizer["Another user is currently using this action. Try again later."]);
+        builder.WithExpiredJumpInputMessage(localizer["Expired modal interaction. You must respond within {0} seconds.", builder.JumpInputTimeout.TotalSeconds]);
+
+        return (TBuilder)builder;
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
index 58270c8..30c0dd0 100644
--- a/src/Modules/ImageModule.cs
+++ b/src/Modules/ImageModule.cs
@@ -72,6 +72,7 @@ public async Task<RuntimeResult> GoogleAsync([Autocomplete(typeof(GoogleAutocomp
             .WithMaxPageIndex(images.Length - 1)
             .WithFooter(PaginatorFooter.None)
             .AddUser(Context.User)
+            .WithLocalizedPrompts(_localizer)
             .Build();
 
         await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
@@ -120,6 +121,7 @@ public async Task<RuntimeResult> DuckDuckGoAsync([Autocomplete(typeof(DuckDuckGo
             .WithMaxPageIndex(images.Length - 1)
             .WithFooter(PaginatorFooter.None)
             .AddUser(Context.User)
+            .WithLocalizedPrompts(_localizer)
             .Build();
 
         await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
@@ -168,6 +170,7 @@ public async Task<RuntimeResult> BraveAsync([Autocomplete(typeof(BraveAutocomple
             .WithMaxPageIndex(images.Length - 1)
             .WithFooter(PaginatorFooter.None)
             .AddUser(Context.User)
+            .WithLocalizedPrompts(_localizer)
             .Build();
 
         await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
@@ -290,6 +293,7 @@ public virtual async Task<RuntimeResult> YandexAsync(string url, bool multiImage
             .WithMaxPageIndex(results.Length - 1)
             .WithFooter(PaginatorFooter.None)
             .AddUser(interaction.User)
+            .WithLocalizedPrompts(_localizer)
             .Build();
 
         await _interactive.SendPaginatorAsync(paginator, interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
@@ -350,6 +354,7 @@ public virtual async Task<RuntimeResult> BingAsync(string url, bool multiImages,
             .WithMaxPageIndex(results.Length - 1)
             .WithFooter(PaginatorFooter.None)
             .AddUser(interaction.User)
+            .WithLocalizedPrompts(_localizer)
             .Build();
 
         await _interactive.SendPaginatorAsync(paginator, interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
diff --git a/src/Modules/UrbanModule.cs b/src/Modules/UrbanModule.cs
index 85119bf..37befad 100644
--- a/src/Modules/UrbanModule.cs
+++ b/src/Modules/UrbanModule.cs
@@ -61,6 +61,7 @@ public async Task<RuntimeResult> SearchAndSendAsync(UrbanSearchType searchType,
             .WithMaxPageIndex(definitions.Count - 1)
             .WithFooter(PaginatorFooter.None)
             .AddUser(Context.User)
+            .WithLocalizedPrompts(_localizer)
             .Build();
 
         await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index e20ec82..dd61b7b 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -377,6 +377,7 @@ public async Task<RuntimeResult> WikipediaAsync([Autocomplete(typeof(WikipediaAu
             .WithMaxPageIndex(articles.Length - 1)
             .WithFooter(PaginatorFooter.None)
             .WithFergunEmotes()
+            .WithLocalizedPrompts(_localizer)
             .Build();
 
         await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
@@ -426,15 +427,17 @@ public async Task<RuntimeResult> YouTubeAsync([Autocomplete(typeof(YouTubeAutoco
             case 1:
                 await Context.Interaction.FollowupAsync(videos[0].Url);
                 break;
-
+                
             default:
-                var paginator = new StaticPaginatorBuilder()
+                var paginator = new LazyPaginatorBuilder()
                     .AddUser(Context.User)
-                    .WithPages(videos.Select((x, i) => new PageBuilder { Text = $"{x.Url}\n{_localizer["Page {0} of {1}", i + 1, videos.Count]}" } as IPageBuilder).ToArray())
+                    .WithPageFactory(GeneratePage)
                     .WithActionOnCancellation(ActionOnStop.DisableInput)
                     .WithActionOnTimeout(ActionOnStop.DisableInput)
+                    .WithMaxPageIndex(videos.Count - 1)
                     .WithFooter(PaginatorFooter.None)
                     .WithFergunEmotes()
+                    .WithLocalizedPrompts(_localizer)
                     .Build();
 
                 await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
@@ -442,5 +445,7 @@ public async Task<RuntimeResult> YouTubeAsync([Autocomplete(typeof(YouTubeAutoco
         }
 
         return FergunResult.FromSuccess();
+        
+        PageBuilder GeneratePage(int index) => new PageBuilder().WithText($"{videos[index].Url}\n{_localizer["Page {0} of {1}", index + 1, videos.Count]}");
     }
 }
\ No newline at end of file
diff --git a/src/Resources/SharedResource.es.resx b/src/Resources/SharedResource.es.resx
index 47ef9ef..1781178 100644
--- a/src/Resources/SharedResource.es.resx
+++ b/src/Resources/SharedResource.es.resx
@@ -120,6 +120,9 @@
   <data name="An error occurred." xml:space="preserve">
     <value>Ocurrió un error.</value>
   </data>
+  <data name="Another user is currently using this action. Try again later." xml:space="preserve">
+    <value>Otro usuario está usando esta acción actualmente. Vuelva a intentarlo más tarde.</value>
+  </data>
   <data name="Bing Visual search failed to download the image." xml:space="preserve">
     <value>La Búsqueda Visual de Bing no pudo descargar la imagen.</value>
   </data>
@@ -129,15 +132,24 @@
   <data name="Could not convert &quot;{0}&quot; to a color." xml:space="preserve">
     <value>No se pudo convertir "{0}" a un color.</value>
   </data>
+  <data name="Enter a page number" xml:space="preserve">
+    <value>Ingresa un número de página</value>
+  </data>
   <data name="Error message: {0}" xml:space="preserve">
     <value>Mensaje de error: {0}</value>
   </data>
+  <data name="Expired modal interaction. You must respond within {0} seconds." xml:space="preserve">
+    <value>Interacción de modal expirada. Debes responder en {0} segundos.</value>
+  </data>
   <data name="Image dimensions exceeds the limit (Max. 4000px)." xml:space="preserve">
     <value>Las dimensiones de imagen supera el límite (Máx. 4000px).</value>
   </data>
   <data name="Image size exceeds the limit (Max. 20MB)." xml:space="preserve">
     <value>El tamaño de imagen supera el límite (Máx. 20MB).</value>
   </data>
+  <data name="Invalid input. The number must be in the range of {0} to {1}, excluding the current page." xml:space="preserve">
+    <value>Entrada inválida. El número debe estar en rango de {0} a {1}, excluyendo la página actual.</value>
+  </data>
   <data name="Invalid source language &quot;{0}&quot;." xml:space="preserve">
     <value>Idioma de origen "{0}" inválido.</value>
   </data>
@@ -156,6 +168,9 @@
   <data name="Output" xml:space="preserve">
     <value>Salida</value>
   </data>
+  <data name="Page number ({0}-{1})" xml:space="preserve">
+    <value>Número de página ({0}-{1})</value>
+  </data>
   <data name="Page {0} of {1}" xml:space="preserve">
     <value>Página {0} de {1}</value>
   </data>

From ba2711643318447eb1f174ef46189157638e9b58 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Mon, 25 Apr 2022 21:46:04 -0500
Subject: [PATCH 52/83] Ignore serializer error warnings temporarily

---
 src/Program.cs | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/Program.cs b/src/Program.cs
index 088b343..23170c0 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -51,6 +51,7 @@ await Host.CreateDefaultBuilder()
         config.MinimumLevel.Debug()
             .Filter.ByExcluding(e => e.Level == LogEventLevel.Debug && Matching.FromSource("Discord.WebSocket.DiscordShardedClient").Invoke(e) && e.MessageTemplate.Render(e.Properties).ContainsAny("Connected to", "Disconnected from"))
             .Filter.ByExcluding(e => e.Level <= LogEventLevel.Debug && (Matching.FromSource("Microsoft.Extensions.Http").Invoke(e) || Matching.FromSource("Microsoft.Extensions.Localization").Invoke(e)))
+            .Filter.ByExcluding(e => e.Level == LogEventLevel.Warning && Matching.FromSource("Discord.WebSocket.DiscordShardedClient").Invoke(e) && e.MessageTemplate.Render(e.Properties).Contains("Serializer Error"))
             .WriteTo.Console(LogEventLevel.Debug, theme: AnsiConsoleTheme.Literate)
             .WriteTo.Async(logger => logger.File("logs/log-.txt", LogEventLevel.Debug, rollingInterval: RollingInterval.Day));
     })

From d7eaf867f96ccf6ca4f4f800e97acf9b6c6eb17c Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Mon, 25 Apr 2022 23:58:41 -0500
Subject: [PATCH 53/83] Add avatar type option to /avatar

---
 src/Entities/AvatarType.cs                  | 33 +++++++++++++++
 src/Modules/UtilityModule.cs                | 45 +++++++++++++++++++--
 src/Resources/Modules.UtilityModule.es.resx | 15 +++++++
 3 files changed, 90 insertions(+), 3 deletions(-)
 create mode 100644 src/Entities/AvatarType.cs

diff --git a/src/Entities/AvatarType.cs b/src/Entities/AvatarType.cs
new file mode 100644
index 0000000..cc01fe6
--- /dev/null
+++ b/src/Entities/AvatarType.cs
@@ -0,0 +1,33 @@
+using Discord.Interactions;
+
+namespace Fergun;
+
+/// <summary>
+/// Specifies the types of avatars.
+/// </summary>
+public enum AvatarType
+{
+    /// <summary>
+    /// The first available avatar (Server, then Global, then Default).
+    /// </summary>
+    [Hide]
+    FirstAvailable,
+
+    /// <summary>
+    /// Server avatar.
+    /// </summary>
+    [ChoiceDisplay("Server avatar")]
+    Server,
+
+    /// <summary>
+    /// Global (main) avatar.
+    /// </summary>
+    [ChoiceDisplay("Global (main) avatar")]
+    Global,
+
+    /// <summary>
+    /// Default avatar.
+    /// </summary>
+    [ChoiceDisplay("Default avatar")]
+    Default
+}
\ No newline at end of file
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index dd61b7b..9eaad7c 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -63,14 +63,53 @@ public UtilityModule(ILogger<UtilityModule> logger, IFergunLocalizer<UtilityModu
     public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
 
     [UserCommand("Avatar")]
+    public async Task<RuntimeResult> AvatarUserCommandAsync(IUser user)
+        => await AvatarAsync(user);
+
     [SlashCommand("avatar", "Displays the avatar of a user.")]
-    public async Task<RuntimeResult> AvatarAsync(IUser user)
+    public async Task<RuntimeResult> AvatarAsync([Summary(description: "The user.")] IUser user,
+        [Summary(description: "An specific avatar type.")] AvatarType type = AvatarType.FirstAvailable)
     {
-        string url = (user as IGuildUser)?.GetGuildAvatarUrl(size: 2048) ?? user.GetAvatarUrl(size: 2048) ?? user.GetDefaultAvatarUrl();
+        string? url;
+        string title;
+
+        switch (type)
+        {
+            case AvatarType.FirstAvailable:
+                url = (user as IGuildUser)?.GetGuildAvatarUrl(size: 2048) ?? user.GetAvatarUrl(size: 2048) ?? user.GetDefaultAvatarUrl();
+                title = user.ToString()!;
+                break;
+
+            case AvatarType.Server:
+                url = (user as IGuildUser)?.GetGuildAvatarUrl(size: 2048);
+                if (url is null)
+                {
+                    return FergunResult.FromError(_localizer["{0} doesn't have a server avatar.", user]);
+                }
+
+                title = $"{user} ({_localizer["Server"]})";
+                break;
+
+            case AvatarType.Global:
+                url = user.GetAvatarUrl(size: 2048);
+                if (url is null)
+                {
+                    return FergunResult.FromError(_localizer["{0} doesn't have a global (main) avatar.", user]);
+                }
+
+                title = $"{user} ({_localizer["Global"]})";
+                break;
+
+            case AvatarType.Default:
+            default:
+                url = user.GetDefaultAvatarUrl();
+                title = $"{user} ({_localizer["Default"]})";
+                break;
+        }
 
         var builder = new EmbedBuilder
         {
-            Title = user.ToString(),
+            Title = title,
             ImageUrl = url,
             Color = Color.Orange
         };
diff --git a/src/Resources/Modules.UtilityModule.es.resx b/src/Resources/Modules.UtilityModule.es.resx
index 3aeebf5..e749c9a 100644
--- a/src/Resources/Modules.UtilityModule.es.resx
+++ b/src/Resources/Modules.UtilityModule.es.resx
@@ -168,4 +168,19 @@ Puedes encontrar más información sobre la eliminación de estos módulos/coman
   <data name="User Info" xml:space="preserve">
     <value>Información de usuario</value>
   </data>
+  <data name="Default" xml:space="preserve">
+    <value>Predeterminado</value>
+  </data>
+  <data name="Global" xml:space="preserve">
+    <value>Global</value>
+  </data>
+  <data name="Server" xml:space="preserve">
+    <value>Servidor</value>
+  </data>
+  <data name="{0} doesn't have a global (main) avatar." xml:space="preserve">
+    <value>{0} no tiene un avatar global (principal).</value>
+  </data>
+  <data name="{0} doesn't have a server avatar." xml:space="preserve">
+    <value>{0} no tiene un avatar de servidor.</value>
+  </data>
 </root>
\ No newline at end of file

From 8607c7800b33d01f91bc702af2f4d73810ba6f6b Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Tue, 26 Apr 2022 14:53:02 -0500
Subject: [PATCH 54/83] Revert "Ignore serializer error warnings temporarily"

This reverts commit ba2711643318447eb1f174ef46189157638e9b58.
---
 src/Program.cs | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/Program.cs b/src/Program.cs
index 23170c0..088b343 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -51,7 +51,6 @@ await Host.CreateDefaultBuilder()
         config.MinimumLevel.Debug()
             .Filter.ByExcluding(e => e.Level == LogEventLevel.Debug && Matching.FromSource("Discord.WebSocket.DiscordShardedClient").Invoke(e) && e.MessageTemplate.Render(e.Properties).ContainsAny("Connected to", "Disconnected from"))
             .Filter.ByExcluding(e => e.Level <= LogEventLevel.Debug && (Matching.FromSource("Microsoft.Extensions.Http").Invoke(e) || Matching.FromSource("Microsoft.Extensions.Localization").Invoke(e)))
-            .Filter.ByExcluding(e => e.Level == LogEventLevel.Warning && Matching.FromSource("Discord.WebSocket.DiscordShardedClient").Invoke(e) && e.MessageTemplate.Render(e.Properties).Contains("Serializer Error"))
             .WriteTo.Console(LogEventLevel.Debug, theme: AnsiConsoleTheme.Literate)
             .WriteTo.Async(logger => logger.File("logs/log-.txt", LogEventLevel.Debug, rollingInterval: RollingInterval.Day));
     })

From 533ea68d49a7efc7321ddf2999fd894ab362e07e Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Tue, 26 Apr 2022 15:12:06 -0500
Subject: [PATCH 55/83] Fix total servers and users in /stats

---
 src/Modules/OtherModule.cs | 12 +++++++++---
 1 file changed, 9 insertions(+), 3 deletions(-)

diff --git a/src/Modules/OtherModule.cs b/src/Modules/OtherModule.cs
index beb11af..3271641 100644
--- a/src/Modules/OtherModule.cs
+++ b/src/Modules/OtherModule.cs
@@ -137,9 +137,7 @@ public async Task<RuntimeResult> StatsAsync()
             processRamUsage = Process.GetCurrentProcess().PrivateMemorySize64 / 1024 / 1024;
         }
 
-        var guilds = await Context.Client.GetGuildsAsync(CacheMode.CacheOnly);
-        int? totalUsers = guilds.Sum(x => x.ApproximateMemberCount ?? (x as SocketGuild)?.MemberCount);
-
+        IReadOnlyCollection<IGuild> guilds;
         int shards = 1;
         int shardId = 0;
         int? totalUsersInShard = null;
@@ -147,11 +145,19 @@ public async Task<RuntimeResult> StatsAsync()
 
         if (Context.Client is DiscordShardedClient shardedClient)
         {
+            guilds = shardedClient.Guilds;
             shards = shardedClient.Shards.Count;
             shardId = Context.Channel.IsPrivate() ? 0 : shardedClient.GetShardIdFor(Context.Guild);
             shard = shardedClient.GetShard(shardId);
             totalUsersInShard = shard.Guilds.Sum(x => x.MemberCount);
         }
+        else
+        {
+            // Context.Client returns the current socket client instead of the shared client
+            guilds = await Context.Client.GetGuildsAsync(CacheMode.CacheOnly);
+        }
+
+        int? totalUsers = guilds.Sum(x => x.ApproximateMemberCount ?? (x as SocketGuild)?.MemberCount);
 
         string? version = Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
 

From 511e237567825700eb8e76000a76e41343c54697 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Tue, 26 Apr 2022 15:15:31 -0500
Subject: [PATCH 56/83] Fix /stats again

---
 src/Modules/OtherModule.cs | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/src/Modules/OtherModule.cs b/src/Modules/OtherModule.cs
index 3271641..0f49db6 100644
--- a/src/Modules/OtherModule.cs
+++ b/src/Modules/OtherModule.cs
@@ -143,12 +143,12 @@ public async Task<RuntimeResult> StatsAsync()
         int? totalUsersInShard = null;
         DiscordSocketClient? shard = null;
 
-        if (Context.Client is DiscordShardedClient shardedClient)
+        if (Context is ShardedInteractionContext shardedContext)
         {
-            guilds = shardedClient.Guilds;
-            shards = shardedClient.Shards.Count;
-            shardId = Context.Channel.IsPrivate() ? 0 : shardedClient.GetShardIdFor(Context.Guild);
-            shard = shardedClient.GetShard(shardId);
+            guilds = shardedContext.Client.Guilds;
+            shards = shardedContext.Client.Shards.Count;
+            shardId = Context.Channel.IsPrivate() ? 0 : shardedContext.Client.GetShardIdFor(Context.Guild);
+            shard = shardedContext.Client.GetShard(shardId);
             totalUsersInShard = shard.Guilds.Sum(x => x.MemberCount);
         }
         else

From 773636aed7d017d42da2ec71fde0b48f31fb4adf Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Tue, 26 Apr 2022 19:02:44 -0500
Subject: [PATCH 57/83] Add FergunTranslator

---
 src/Entities/FergunTranslator.cs              | 257 ++++++++++++++++++
 src/Entities/IFergunTranslator.cs             |  19 ++
 src/Entities/WrapBackEnumerable.cs            |  70 +++++
 src/Modules/SharedModule.cs                   |   4 +-
 src/Modules/UtilityModule.cs                  |  32 +--
 src/Program.cs                                |  12 +-
 tests/Fergun.Tests/Modules/OcrModuleTests.cs  |   2 +-
 .../Fergun.Tests/Modules/SharedModuleTests.cs |   4 +-
 .../Modules/UtilityModuleTests.cs             |   9 +-
 9 files changed, 366 insertions(+), 43 deletions(-)
 create mode 100644 src/Entities/FergunTranslator.cs
 create mode 100644 src/Entities/IFergunTranslator.cs
 create mode 100644 src/Entities/WrapBackEnumerable.cs

diff --git a/src/Entities/FergunTranslator.cs b/src/Entities/FergunTranslator.cs
new file mode 100644
index 0000000..2e00301
--- /dev/null
+++ b/src/Entities/FergunTranslator.cs
@@ -0,0 +1,257 @@
+using System.Runtime.CompilerServices;
+using Fergun.Extensions;
+using GTranslate;
+using GTranslate.Results;
+using GTranslate.Translators;
+
+namespace Fergun;
+
+/// <summary>
+/// Represents an aggregation of translators where the order of the translators can be modified.
+/// </summary>
+public class FergunTranslator : IFergunTranslator
+{
+    /// <inheritdoc/>
+    public string Name => nameof(FergunTranslator);
+
+    private readonly ITranslator[] _translators;
+    private WrapBackEnumerable<ITranslator> _wrappedTranslators;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="FergunTranslator"/> class.
+    /// </summary>
+    /// <param name="googleTranslator">The Google Translator.</param>
+    /// <param name="googleTranslator2">The new Google Translator.</param>
+    /// <param name="microsoftTranslator">The Microsoft translator.</param>
+    /// <param name="yandexTranslator">The Yandex Translator.</param>
+    public FergunTranslator(GoogleTranslator googleTranslator, GoogleTranslator2 googleTranslator2,
+        MicrosoftTranslator microsoftTranslator, YandexTranslator yandexTranslator)
+    {
+        _translators = new ITranslator[] {googleTranslator, googleTranslator2, microsoftTranslator, yandexTranslator};
+        _wrappedTranslators = new WrapBackEnumerable<ITranslator>(_translators);
+    }
+
+    /// <inheritdoc/>
+    public void Next()
+    {
+        _wrappedTranslators.Index = _wrappedTranslators.Index == _translators.Length - 1 ? 0 : _wrappedTranslators.Index + 1;
+    }
+
+    /// <inheritdoc/>
+    public void Randomize()
+    {
+        _translators.Shuffle();
+        _wrappedTranslators.Index = Random.Shared.Next(0, _translators.Length);
+    }
+
+    /// <summary>
+    /// Translates a text using the available translation services.
+    /// </summary>
+    /// <param name="text">The text.</param>
+    /// <param name="toLanguage">The target language.</param>
+    /// <param name="fromLanguage">The source language.</param>
+    /// <returns>A task containing the translation result.</returns>
+    /// <remarks>This method will attempt to use all the translation services passed in the constructor, in the order they were provided.</remarks>
+    /// <exception cref="ObjectDisposedException">Thrown when this translator has been disposed.</exception>
+    /// <exception cref="ArgumentNullException">Thrown when <paramref name="text"/> or <paramref name="toLanguage"/> are null.</exception>
+    /// <exception cref="TranslatorException">Thrown when no translator supports <paramref name="toLanguage"/> or <paramref name="fromLanguage"/>.</exception>
+    /// <exception cref="AggregateException">Thrown when all translators fail to provide a valid result.</exception>
+    public async Task<ITranslationResult> TranslateAsync(string text, string toLanguage, string? fromLanguage = null)
+    {
+        LanguageSupported(this, toLanguage, fromLanguage);
+
+        List<Exception> exceptions = null!;
+        foreach (var translator in _wrappedTranslators)
+        {
+            if (!translator.IsLanguageSupported(toLanguage) || fromLanguage != null && !translator.IsLanguageSupported(fromLanguage))
+            {
+                continue;
+            }
+
+            try
+            {
+                return await translator.TranslateAsync(text, toLanguage, fromLanguage).ConfigureAwait(false);
+            }
+            catch (Exception e)
+            {
+                exceptions ??= new List<Exception>();
+                exceptions.Add(e);
+            }
+        }
+
+        throw new AggregateException("No translator provided a valid result.", exceptions);
+    }
+
+    /// <inheritdoc cref="TranslateAsync(string, string, string)"/>
+    public async Task<ITranslationResult> TranslateAsync(string text, ILanguage toLanguage, ILanguage? fromLanguage = null)
+    {
+        LanguageSupported(this, toLanguage, fromLanguage);
+
+        List<Exception> exceptions = null!;
+        foreach (var translator in _wrappedTranslators)
+        {
+            if (!translator.IsLanguageSupported(toLanguage) || fromLanguage != null && !translator.IsLanguageSupported(fromLanguage))
+            {
+                continue;
+            }
+
+            try
+            {
+                return await translator.TranslateAsync(text, toLanguage, fromLanguage).ConfigureAwait(false);
+            }
+            catch (Exception e)
+            {
+                exceptions ??= new List<Exception>();
+                exceptions.Add(e);
+            }
+        }
+
+        throw new AggregateException("No translator provided a valid result.", exceptions);
+    }
+
+    /// <summary>
+    /// Transliterates a text using the available translation services.
+    /// </summary>
+    /// <param name="text">The text.</param>
+    /// <param name="toLanguage">The target language.</param>
+    /// <param name="fromLanguage">The source language.</param>
+    /// <returns>A task containing the transliteration result.</returns>
+    /// <remarks>This method will attempt to use all the translation services passed in the constructor, in the order they were provided.</remarks>
+    /// <exception cref="ObjectDisposedException">Thrown when this translator has been disposed.</exception>
+    /// <exception cref="ArgumentNullException">Thrown when <paramref name="text"/> or <paramref name="toLanguage"/> are null.</exception>
+    /// <exception cref="ArgumentException">Thrown when a <see cref="Language"/> could not be obtained from <paramref name="toLanguage"/> or <paramref name="fromLanguage"/>.</exception>
+    /// <exception cref="TranslatorException">Thrown when no translator supports <paramref name="toLanguage"/> or <paramref name="fromLanguage"/>.</exception>
+    /// <exception cref="AggregateException">Thrown when all translators fail to provide a valid result.</exception>
+    public async Task<ITransliterationResult> TransliterateAsync(string text, string toLanguage, string? fromLanguage = null)
+    {
+        LanguageFound(toLanguage, out var toLang, "Unknown target language.");
+        LanguageFound(fromLanguage, out var fromLang, "Unknown source language.");
+
+        return await TransliterateAsync(text, toLang, fromLang).ConfigureAwait(false);
+    }
+
+    /// <inheritdoc cref="TransliterateAsync(string, string, string)"/>
+    public async Task<ITransliterationResult> TransliterateAsync(string text, ILanguage toLanguage, ILanguage? fromLanguage = null)
+    {
+        LanguageSupported(this, toLanguage, fromLanguage);
+
+        List<Exception> exceptions = null!;
+        foreach (var translator in _wrappedTranslators)
+        {
+            if (!translator.IsLanguageSupported(toLanguage) || fromLanguage != null && !translator.IsLanguageSupported(fromLanguage))
+            {
+                continue;
+            }
+
+            try
+            {
+                return await translator.TransliterateAsync(text, toLanguage, fromLanguage).ConfigureAwait(false);
+            }
+            catch (Exception e)
+            {
+                exceptions ??= new List<Exception>();
+                exceptions.Add(e);
+            }
+        }
+
+        throw new AggregateException("No translator provided a valid result.", exceptions);
+    }
+
+    /// <summary>
+    /// Detects the language of a text using the available translation services.
+    /// </summary>
+    /// <param name="text">The text to detect its language.</param>
+    /// <returns>A task that represents the asynchronous language detection operation. The task contains the detected language.</returns>
+    /// <remarks>This method will attempt to use all the translation services passed in the constructor, in the order they were provided.</remarks>
+    /// <exception cref="ObjectDisposedException">Thrown when this translator has been disposed.</exception>
+    /// <exception cref="ArgumentNullException">Thrown when <paramref name="text"/> is null.</exception>
+    /// <exception cref="AggregateException">Thrown when all translators fail to provide a valid result.</exception>
+    public async Task<ILanguage> DetectLanguageAsync(string text)
+    {
+        List<Exception> exceptions = null!;
+        foreach (var translator in _wrappedTranslators)
+        {
+            try
+            {
+                return await translator.DetectLanguageAsync(text).ConfigureAwait(false);
+            }
+            catch (Exception e)
+            {
+                exceptions ??= new List<Exception>();
+                exceptions.Add(e);
+            }
+        }
+
+        throw new AggregateException("No translator provided a valid result.", exceptions);
+    }
+
+    /// <summary>
+    /// Returns whether at least one translator supports the specified language.
+    /// </summary>
+    /// <param name="language">The language.</param>
+    /// <returns><see langword="true"/> if the language is supported by at least one translator, otherwise <see langword="false"/>.</returns>
+    public bool IsLanguageSupported(string language)
+    {
+        foreach (var translator in _wrappedTranslators)
+        {
+            if (translator.IsLanguageSupported(language))
+            {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /// <inheritdoc cref="IsLanguageSupported(string)"/>
+    public bool IsLanguageSupported(ILanguage language)
+    {
+        foreach (var translator in _wrappedTranslators)
+        {
+            if (translator.IsLanguageSupported(language))
+            {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private static void LanguageFound(string? language, out Language lang, string message = "Unknown language.",
+        [CallerArgumentExpression("language")] string? parameterName = null)
+    {
+        Language temp = null!;
+        if (language is not null && !Language.TryGetLanguage(language, out temp!))
+        {
+            throw new ArgumentException(message, parameterName);
+        }
+
+        lang = temp;
+    }
+
+    private static void LanguageSupported(ITranslator translator, string toLanguage, string? fromLanguage)
+    {
+        if (!translator.IsLanguageSupported(toLanguage))
+        {
+            throw new TranslatorException($"No available translator supports the target language \"{toLanguage}\".", translator.Name);
+        }
+
+        if (!string.IsNullOrEmpty(fromLanguage) && !translator.IsLanguageSupported(fromLanguage))
+        {
+            throw new TranslatorException($"No available translator supports the source language \"{fromLanguage}\".", translator.Name);
+        }
+    }
+
+    private static void LanguageSupported(ITranslator translator, ILanguage toLanguage, ILanguage? fromLanguage)
+    {
+        if (!translator.IsLanguageSupported(toLanguage))
+        {
+            throw new TranslatorException($"No available translator supports the target language \"{toLanguage.ISO6391}\".", translator.Name);
+        }
+
+        if (fromLanguage != null && !translator.IsLanguageSupported(fromLanguage))
+        {
+            throw new TranslatorException($"No available translator supports the source language \"{fromLanguage.ISO6391}\".", translator.Name);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Entities/IFergunTranslator.cs b/src/Entities/IFergunTranslator.cs
new file mode 100644
index 0000000..b172ea9
--- /dev/null
+++ b/src/Entities/IFergunTranslator.cs
@@ -0,0 +1,19 @@
+using GTranslate.Translators;
+
+namespace Fergun;
+
+/// <summary>
+/// Provides methods to modify of the order of translators.
+/// </summary>
+public interface IFergunTranslator : ITranslator
+{
+    /// <summary>
+    /// Switches to the next translator.
+    /// </summary>
+    void Next();
+
+    /// <summary>
+    /// Randomizes the order of the translators.
+    /// </summary>
+    void Randomize();
+}
\ No newline at end of file
diff --git a/src/Entities/WrapBackEnumerable.cs b/src/Entities/WrapBackEnumerable.cs
new file mode 100644
index 0000000..5a6b755
--- /dev/null
+++ b/src/Entities/WrapBackEnumerable.cs
@@ -0,0 +1,70 @@
+namespace Fergun;
+
+/// <summary>
+/// Provides an enumerator that wraps back to the start after reaching the last element.
+/// </summary>
+/// <typeparam name="T">The type of the elements to enumerate.</typeparam>
+public struct WrapBackEnumerable<T>
+{
+    private readonly T[] _items;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="WrapBackEnumerable{T}"/> struct.
+    /// </summary>
+    /// <param name="items">The items.</param>
+    public WrapBackEnumerable(T[] items)
+    {
+        _items = items;
+        Index = 0;
+    }
+
+    /// <summary>
+    /// Gets or sets the index.
+    /// </summary>
+    public int Index { get; set; }
+
+    /// <summary>
+    /// Returns the enumerator.
+    /// </summary>
+    /// <returns>The enumerator.</returns>
+    public readonly Enumerator GetEnumerator() => new(_items,  Index);
+
+    /// <summary>
+    /// Enumerates the elements of a <see cref="WrapBackEnumerable{T}"/>.
+    /// </summary>
+    public struct Enumerator
+    {
+        private readonly IReadOnlyList<T> _items;
+        private int _index;
+        private int _increment;
+
+        
+        internal Enumerator(IReadOnlyList<T> items, int index)
+        {
+            _items = items;
+            _index = index;
+            _increment = 0;
+        }
+
+        /// <summary>
+        /// Advances the enumerator to the next element.
+        /// </summary>
+        public bool MoveNext()
+        {
+            if (_increment >= _items.Count)
+            {
+                return false;
+            }
+
+            _index = _index == _items.Count - 1 ? 0 : _index + 1;
+            _increment++;
+
+            return true;
+        }
+
+        /// <summary>
+        /// Gets the current element.
+        /// </summary>
+        public readonly T Current => _items[_index];
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/SharedModule.cs b/src/Modules/SharedModule.cs
index 94bdb1f..3a68c62 100644
--- a/src/Modules/SharedModule.cs
+++ b/src/Modules/SharedModule.cs
@@ -17,10 +17,10 @@ public class SharedModule
 {
     private readonly ILogger<SharedModule> _logger;
     private readonly IFergunLocalizer<SharedResource> _localizer;
-    private readonly ITranslator _translator;
+    private readonly IFergunTranslator _translator;
     private readonly GoogleTranslator2 _googleTranslator2;
 
-    public SharedModule(ILogger<SharedModule> logger, IFergunLocalizer<SharedResource> localizer, ITranslator translator, GoogleTranslator2 googleTranslator2)
+    public SharedModule(ILogger<SharedModule> logger, IFergunLocalizer<SharedResource> localizer, IFergunTranslator translator, GoogleTranslator2 googleTranslator2)
     {
         _logger = logger;
         _localizer = localizer;
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 9eaad7c..48fe708 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -10,7 +10,6 @@
 using Fergun.Utils;
 using GTranslate;
 using GTranslate.Results;
-using GTranslate.Translators;
 using Humanizer;
 using Microsoft.Extensions.Logging;
 using SixLabors.ImageSharp;
@@ -30,10 +29,7 @@ public class UtilityModule : InteractionModuleBase
     private readonly IFergunLocalizer<UtilityModule> _localizer;
     private readonly SharedModule _shared;
     private readonly InteractiveService _interactive;
-    private readonly GoogleTranslator _googleTranslator;
-    private readonly GoogleTranslator2 _googleTranslator2;
-    private readonly MicrosoftTranslator _microsoftTranslator;
-    private readonly YandexTranslator _yandexTranslator;
+    private readonly IFergunTranslator _translator;
     private readonly SearchClient _searchClient;
     private readonly IWikipediaClient _wikipediaClient;
 
@@ -44,18 +40,14 @@ public class UtilityModule : InteractionModuleBase
         .Where(x => x.SupportedServices == (TranslationServices.Google | TranslationServices.Bing | TranslationServices.Yandex | TranslationServices.Microsoft))
         .ToArray());
 
-    public UtilityModule(ILogger<UtilityModule> logger, IFergunLocalizer<UtilityModule> localizer,
-        SharedModule shared, InteractiveService interactive, GoogleTranslator googleTranslator, GoogleTranslator2 googleTranslator2,
-        MicrosoftTranslator microsoftTranslator, YandexTranslator yandexTranslator, SearchClient searchClient, IWikipediaClient wikipediaClient)
+    public UtilityModule(ILogger<UtilityModule> logger, IFergunLocalizer<UtilityModule> localizer, SharedModule shared,
+        InteractiveService interactive, IFergunTranslator translator, SearchClient searchClient, IWikipediaClient wikipediaClient)
     {
         _logger = logger;
         _localizer = localizer;
         _shared = shared;
         _interactive = interactive;
-        _googleTranslator = googleTranslator;
-        _googleTranslator2 = googleTranslator2;
-        _microsoftTranslator = microsoftTranslator;
-        _yandexTranslator = yandexTranslator;
+        _translator = translator;
         _searchClient = searchClient;
         _wikipediaClient = wikipediaClient;
     }
@@ -139,11 +131,7 @@ public async Task<RuntimeResult> BadTranslatorAsync([Summary(description: "The t
         
         await Context.Interaction.DeferAsync();
 
-        // Create an aggregated translator manually so we can randomize the initial order of the translators and shift them.
-        // Bing Translator is not included because it only allows max. 1000 chars per translation
-        var translators = new ITranslator[] { _googleTranslator, _googleTranslator2, _microsoftTranslator, _yandexTranslator };
-        translators.Shuffle();
-        var badTranslator = new AggregateTranslator(translators);
+        _translator.Randomize();
 
         var languageChain = new List<ILanguage>(chainCount + 1);
         ILanguage? source = null;
@@ -163,16 +151,11 @@ public async Task<RuntimeResult> BadTranslatorAsync([Summary(description: "The t
                 } while (languageChain.Contains(target));
             }
 
-            // Shift the translators to avoid spamming them and get more variety
-            var last = translators[^1];
-            Array.Copy(translators, 0, translators, 1, translators.Length - 1);
-            translators[0] = last;
-
             ITranslationResult result;
             try
             {
                 _logger.LogInformation("Translating to: {target}", target.ISO6391);
-                result = await badTranslator.TranslateAsync(text, target);
+                result = await _translator.TranslateAsync(text, target);
             }
             catch (Exception e)
             {
@@ -180,6 +163,9 @@ public async Task<RuntimeResult> BadTranslatorAsync([Summary(description: "The t
                 return FergunResult.FromError(e.Message);
             }
 
+            // Switch the translators to avoid spamming them and get more variety
+            _translator.Next();
+
             if (i == 0)
             {
                 source = result.SourceLanguage;
diff --git a/src/Program.cs b/src/Program.cs
index 088b343..ace100d 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -92,8 +92,8 @@ await Host.CreateDefaultBuilder()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
 
-        // We have to register the named client and service separately because Bing Translator and Microsoft Translator aren't stateless,
-        // They store a token required to make API calls that is obtained once and updated occasionally, since AddHttpClient<TClient>
+        // We have to register the named client and service separately because Microsoft Translator isn't stateless,
+        // It stores a token required to make API calls that is obtained once and updated occasionally, since AddHttpClient<TClient>
         // adds the services with a transient scope, this means that the token would be obtained every time the services are used.
         services.AddHttpClient(nameof(MicrosoftTranslator))
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
@@ -101,12 +101,6 @@ await Host.CreateDefaultBuilder()
 
         services.AddSingleton(s => new MicrosoftTranslator(s.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(MicrosoftTranslator))));
 
-        services.AddHttpClient(nameof(BingTranslator))
-            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
-            .AddRetryPolicy();
-
-        services.AddSingleton(s => new BingTranslator(s.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(BingTranslator))));
-
         services.AddHttpClient<SearchClient>()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
@@ -131,7 +125,7 @@ await Host.CreateDefaultBuilder()
         services.AddHttpClient("autocomplete", client => client.DefaultRequestHeaders.UserAgent.ParseAdd(Constants.ChromeUserAgent))
             .SetHandlerLifetime(TimeSpan.FromMinutes(30));
 
-        services.AddSingleton<ITranslator, AggregateTranslator>();
+        services.AddTransient<IFergunTranslator, FergunTranslator>();
         services.AddSingleton(x => new GoogleScraper(x.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(GoogleScraper))));
         services.AddSingleton(x => new DuckDuckGoScraper(x.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(DuckDuckGoScraper))));
         services.AddSingleton(x => new BraveScraper(x.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(BraveScraper))));
diff --git a/tests/Fergun.Tests/Modules/OcrModuleTests.cs b/tests/Fergun.Tests/Modules/OcrModuleTests.cs
index 7a36ac4..c00293d 100644
--- a/tests/Fergun.Tests/Modules/OcrModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/OcrModuleTests.cs
@@ -41,7 +41,7 @@ public OcrModuleTests()
 
         var sharedLogger = Mock.Of<ILogger<SharedModule>>();
         var sharedLocalizer = Utils.CreateMockedLocalizer<SharedResource>();
-        var shared = new SharedModule(sharedLogger, sharedLocalizer, new AggregateTranslator(), new());
+        var shared = new SharedModule(sharedLogger, sharedLocalizer, Mock.Of<IFergunTranslator>(), new());
 
         _interactive = new InteractiveService(_client, _interactiveConfig);
         _moduleMock = new Mock<OcrModule>(() => new OcrModule(_loggerMock.Object, _ocrLocalizer, shared, _interactive,
diff --git a/tests/Fergun.Tests/Modules/SharedModuleTests.cs b/tests/Fergun.Tests/Modules/SharedModuleTests.cs
index 7e022d5..22381e3 100644
--- a/tests/Fergun.Tests/Modules/SharedModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/SharedModuleTests.cs
@@ -14,7 +14,7 @@ namespace Fergun.Tests.Modules;
 
 public class SharedModuleTests
 {
-    private readonly Mock<ITranslator> _translatorMock = new();
+    private readonly Mock<IFergunTranslator> _translatorMock = new();
     private readonly Mock<SharedModule> _sharedModuleMock;
     private readonly Mock<IDiscordInteraction> _interactionMock = new();
     private readonly Mock<IComponentInteraction> _componentInteractionMock = new();
@@ -24,7 +24,7 @@ public SharedModuleTests()
         var localizer = Utils.CreateMockedLocalizer<SharedResource>();
         _sharedModuleMock = new Mock<SharedModule>(() => new SharedModule(Mock.Of<ILogger<SharedModule>>(), localizer, _translatorMock.Object, new()));
         _translatorMock.Setup(x => x.TranslateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
-            .ReturnsAsync<string, string, string?, ITranslator, ITranslationResult>((text, target, source) =>
+            .ReturnsAsync<string, string, string?, IFergunTranslator, ITranslationResult>((text, target, source) =>
             {
                 if (text == "Error")
                 {
diff --git a/tests/Fergun.Tests/Modules/UtilityModuleTests.cs b/tests/Fergun.Tests/Modules/UtilityModuleTests.cs
index a475142..3f55ce3 100644
--- a/tests/Fergun.Tests/Modules/UtilityModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/UtilityModuleTests.cs
@@ -21,20 +21,17 @@ public class UtilityModuleTests
     private readonly Mock<IInteractionContext> _contextMock = new();
     private readonly Mock<IDiscordInteraction> _interactionMock = new();
     private readonly IFergunLocalizer<UtilityModule> _localizer = Utils.CreateMockedLocalizer<UtilityModule>();
-    private readonly GoogleTranslator _googleTranslator = new();
     private readonly GoogleTranslator2 _googleTranslator2 = new();
-    private readonly MicrosoftTranslator _microsoftTranslator = new();
-    private readonly YandexTranslator _yandexTranslator = new();
     private readonly SearchClient _searchClient = new(new());
     private readonly IWikipediaClient _wikipediaClient = null!;
     private readonly Mock<UtilityModule> _moduleMock;
-
+    
     public UtilityModuleTests()
     {
-        SharedModule shared = new(Mock.Of<ILogger<SharedModule>>(), Utils.CreateMockedLocalizer<SharedResource>(), new AggregateTranslator(), _googleTranslator2);
+        SharedModule shared = new(Mock.Of<ILogger<SharedModule>>(), Utils.CreateMockedLocalizer<SharedResource>(), Mock.Of<IFergunTranslator>(), _googleTranslator2);
         var interactive = new InteractiveService(new DiscordSocketClient(), new InteractiveConfig { ReturnAfterSendingPaginator = true });
         _moduleMock = new Mock<UtilityModule>(() => new UtilityModule(Mock.Of<ILogger<UtilityModule>>(), _localizer, shared,
-            interactive, _googleTranslator, _googleTranslator2, _microsoftTranslator, _yandexTranslator, _searchClient, _wikipediaClient)) { CallBase = true };
+            interactive, Mock.Of<IFergunTranslator>(), _searchClient, _wikipediaClient)) { CallBase = true };
         _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
         ((IInteractionModuleBase)_moduleMock.Object).SetContext(_contextMock.Object);
     }

From 91da90375ca514f343e301cee376b0d5b5b66892 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 30 Apr 2022 23:36:32 -0500
Subject: [PATCH 58/83] Add bot list service

---
 src/Entities/BotList.cs        |  16 ++++
 src/Entities/BotListOptions.cs |  19 ++++
 src/Program.cs                 |   4 +-
 src/Services/BotListService.cs | 158 +++++++++++++++++++++++++++++++++
 src/appsettings.json           |  11 ++-
 5 files changed, 206 insertions(+), 2 deletions(-)
 create mode 100644 src/Entities/BotList.cs
 create mode 100644 src/Entities/BotListOptions.cs
 create mode 100644 src/Services/BotListService.cs

diff --git a/src/Entities/BotList.cs b/src/Entities/BotList.cs
new file mode 100644
index 0000000..b5847b5
--- /dev/null
+++ b/src/Entities/BotList.cs
@@ -0,0 +1,16 @@
+namespace Fergun;
+
+/// <summary>
+/// Specifies the bot lists.
+/// </summary>
+public enum BotList
+{
+    /// <summary>
+    /// Top.gg.
+    /// </summary>
+    TopGg,
+    /// <summary>
+    /// Discord Bots.
+    /// </summary>
+    DiscordBots
+}
\ No newline at end of file
diff --git a/src/Entities/BotListOptions.cs b/src/Entities/BotListOptions.cs
new file mode 100644
index 0000000..8b36cde
--- /dev/null
+++ b/src/Entities/BotListOptions.cs
@@ -0,0 +1,19 @@
+namespace Fergun;
+
+/// <summary>
+/// Represents the settings related to bot lists.
+/// </summary>
+public class BotListOptions
+{
+    public const string BotList = nameof(BotList);
+
+    /// <summary>
+    /// Gets or sets the update period in minutes.
+    /// </summary>
+    public int UpdatePeriodInMinutes { get; set; } = 30;
+
+    /// <summary>
+    /// Gets or sets the dictionary of tokens.
+    /// </summary>
+    public IDictionary<BotList, string> Tokens { get; set; } = new Dictionary<BotList, string>();
+}
\ No newline at end of file
diff --git a/src/Program.cs b/src/Program.cs
index ace100d..5119625 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -54,14 +54,16 @@ await Host.CreateDefaultBuilder()
             .WriteTo.Console(LogEventLevel.Debug, theme: AnsiConsoleTheme.Literate)
             .WriteTo.Async(logger => logger.File("logs/log-.txt", LogEventLevel.Debug, rollingInterval: RollingInterval.Day));
     })
-    .ConfigureServices(services =>
+    .ConfigureServices((context, services) =>
     {
         services.AddLocalization(options => options.ResourcesPath = "Resources");
         services.AddTransient(typeof(IFergunLocalizer<>), typeof(FergunLocalizer<>));
         services.AddHostedService<InteractionHandlingService>();
+        services.AddHostedService<BotListService>();
         services.AddSingleton(new InteractiveConfig { ReturnAfterSendingPaginator = true, DeferStopSelectionInteractions = false });
         services.AddSingleton<InteractiveService>();
         services.AddFergunPolicies();
+        services.Configure<BotListOptions>(context.Configuration.GetSection(BotListOptions.BotList));
 
         services.AddHttpClient<IBingVisualSearch, BingVisualSearch>()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
diff --git a/src/Services/BotListService.cs b/src/Services/BotListService.cs
new file mode 100644
index 0000000..f93da77
--- /dev/null
+++ b/src/Services/BotListService.cs
@@ -0,0 +1,158 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using Discord.WebSocket;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Fergun.Services;
+
+/// <summary>
+/// Represents a service that updates the bot stats periodically (currently Top.gg and Discord Bots).
+/// </summary>
+public sealed class BotListService : BackgroundService
+{
+    private readonly DiscordShardedClient _discordClient;
+    private readonly HttpClient _httpClient;
+    private readonly ILogger<BotListService> _logger;
+    private readonly IOptions<BotListOptions> _options;
+    private int _lastServerCount = -1;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="BotListService"/> class.
+    /// </summary>
+    /// <param name="discordClient">The Discord client.</param>
+    /// <param name="httpClient">The HTTP client.</param>
+    /// <param name="logger">The logger.</param>
+    /// <param name="options">The bot list options.</param>
+    public BotListService(DiscordShardedClient discordClient, HttpClient httpClient, ILogger<BotListService> logger, IOptions<BotListOptions> options)
+    {
+        _discordClient = discordClient;
+        _httpClient = httpClient;
+        _logger = logger;
+        _options = options;
+    }
+    
+    /// <inheritdoc/>
+    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+    {
+        var botLists = _options.Value.Tokens.Where(x => string.IsNullOrWhiteSpace(x.Value)).Select(x => x.Key).ToArray();
+        foreach (var botList in botLists)
+        {
+            _options.Value.Tokens.Remove(botList);
+        }
+
+        if (_options.Value.Tokens.Count == 0)
+        {
+            _logger.LogInformation("Bot list service started. No bot stats will be updated because no tokens were provided.");
+            return;
+        }
+        
+        _logger.LogInformation("Bot list service started. Updating stats for {BotLists} every {UpdatePeriod} minute(s).",
+            string.Join(", ", _options.Value.Tokens.Keys), _options.Value.UpdatePeriodInMinutes);
+
+        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(_options.Value.UpdatePeriodInMinutes));
+        while (await timer.WaitForNextTickAsync(stoppingToken))
+        {
+            await UpdateStatsAsync();
+        }
+    }
+
+    /// <summary>
+    /// Updates the bot list server count.
+    /// </summary>
+    public async ValueTask UpdateStatsAsync()
+    {
+        int serverCount = _discordClient.Guilds.Count;
+        if (_lastServerCount == -1) _lastServerCount = serverCount;
+        else if (_lastServerCount == serverCount) return;
+
+        foreach ((var botList, string token) in _options.Value.Tokens)
+        {
+            if (!string.IsNullOrWhiteSpace(token))
+            {
+                await UpdateStatsAsync(botList, serverCount, _discordClient.Shards.Count, token);
+            }
+        }
+
+        _lastServerCount = serverCount;
+    }
+
+    /// <summary>
+    /// Updates the bot stats of a specific bot list using the specified server count and shard count.
+    /// </summary>
+    /// <param name="botList">The bot list.</param>
+    /// <param name="serverCount">The server count.</param>
+    /// <param name="shardCount">The shard count.</param>
+    /// <param name="token">The API token.</param>
+    public async Task UpdateStatsAsync(BotList botList, int serverCount, int shardCount, string token)
+    {
+        _logger.LogDebug("Updating {BotList} bot stats...", botList);
+
+        using var request = CreateRequest(botList, serverCount, shardCount, token);
+        try
+        {
+            using var response = await _httpClient.SendAsync(request);
+            response.EnsureSuccessStatusCode();
+            _logger.LogDebug("Successfully updated {BotList} bot stats (server count: {ServerCount}, shard count: {ShardCount}).", botList, serverCount, shardCount);
+        }
+        catch (Exception e)
+        {
+            _logger.LogWarning(e, "Failed to update {BotList} bot stats (server count: {ServerCount}, shard count: {ShardCount}).", botList, serverCount, shardCount);
+
+            if (e is HttpRequestException { StatusCode: HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.NotFound } requestException)
+            {
+                var statusCode = requestException.StatusCode.Value;
+
+                if (statusCode == HttpStatusCode.NotFound)
+                {
+                    _logger.LogInformation("Got status code {StatusCode} ({StatusCodeName}), make sure the bot is listed on {BotList}.", statusCode.ToString("D"), statusCode, botList);
+                }
+                else
+                {
+                    _logger.LogInformation("Got status code {StatusCode} ({StatusCodeName}), make sure the token is valid.", statusCode.ToString("D"), statusCode);
+                }
+
+                _logger.LogInformation("Bot stats will not be sent to {BotList} API.", botList);
+                _options.Value.Tokens.Remove(botList);
+            }
+        }
+    }
+
+    /// <inheritdoc/>
+    public override void Dispose()
+    {
+        _httpClient.Dispose();
+        base.Dispose();
+    }
+
+    private HttpRequestMessage CreateRequest(BotList botList, int serverCount, int shardCount, string token) => botList switch
+    {
+        BotList.TopGg => CreateTopGgRequest(serverCount, shardCount, token),
+        BotList.DiscordBots => CreateDiscordBotsRequest(serverCount, shardCount, token),
+        _ => throw new ArgumentException($"Unknown bot list {botList}.")
+    };
+
+    private HttpRequestMessage CreateTopGgRequest(int serverCount, int shardCount, string token) => new()
+    {
+        Method = HttpMethod.Post,
+        RequestUri = new Uri($"https://top.gg/api/bots/{_discordClient.CurrentUser.Id}/stats"),
+        Content = new StringContent($"{{\"server_count\": {serverCount},\"shard_count\":{shardCount}}}", Encoding.UTF8, "application/json"),
+        Headers =
+        {
+            Authorization = new AuthenticationHeaderValue(token)
+        }
+    };
+
+    private HttpRequestMessage CreateDiscordBotsRequest(int serverCount, int shardCount, string token) => new()
+    {
+        Method = HttpMethod.Post,
+        RequestUri = new Uri($"https://discord.bots.gg/api/bots/{_discordClient.CurrentUser.Id}/stats"),
+        Content = new StringContent($"{{\"guildCount\": {serverCount},\"shardCount\":{shardCount}}}", Encoding.UTF8, "application/json"),
+        Headers =
+        {
+            Authorization = new AuthenticationHeaderValue(token)
+        }
+    };
+}
\ No newline at end of file
diff --git a/src/appsettings.json b/src/appsettings.json
index 65ddbd5..48c1809 100644
--- a/src/appsettings.json
+++ b/src/appsettings.json
@@ -1,4 +1,13 @@
 {
   "TargetGuildId": 0,
-  "Token": ""
+  "Token": "",
+  "BotList":
+  {
+	"UpdatePeriodInMinutes": 30,
+	"Tokens":
+	{
+		"TopGg": "",
+		"DiscordBots": ""
+	}
+  }
 }
\ No newline at end of file

From 9378138ceccba05fb64a84fa759093b1297271a0 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 1 May 2022 01:55:41 -0500
Subject: [PATCH 59/83] Refactoring

---
 src/Converters/ColorConverter.cs |  5 -----
 src/Modules/UtilityModule.cs     |  1 -
 src/Services/BotListService.cs   | 18 +++++++++---------
 3 files changed, 9 insertions(+), 15 deletions(-)

diff --git a/src/Converters/ColorConverter.cs b/src/Converters/ColorConverter.cs
index 26ae142..020210d 100644
--- a/src/Converters/ColorConverter.cs
+++ b/src/Converters/ColorConverter.cs
@@ -48,9 +48,4 @@ public override Task<TypeConverterResult> ReadAsync(IInteractionContext context,
 
         return Task.FromResult(TypeConverterResult.FromSuccess(color));
     }
-
-    /// <inheritdoc/>
-    public override void Write(ApplicationCommandOptionProperties properties, IParameterInfo parameterInfo)
-    {
-    }
 }
\ No newline at end of file
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 48fe708..316aab2 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -92,7 +92,6 @@ public async Task<RuntimeResult> AvatarAsync([Summary(description: "The user.")]
                 title = $"{user} ({_localizer["Global"]})";
                 break;
 
-            case AvatarType.Default:
             default:
                 url = user.GetDefaultAvatarUrl();
                 title = $"{user} ({_localizer["Default"]})";
diff --git a/src/Services/BotListService.cs b/src/Services/BotListService.cs
index f93da77..d44c157 100644
--- a/src/Services/BotListService.cs
+++ b/src/Services/BotListService.cs
@@ -16,7 +16,7 @@ public sealed class BotListService : BackgroundService
     private readonly DiscordShardedClient _discordClient;
     private readonly HttpClient _httpClient;
     private readonly ILogger<BotListService> _logger;
-    private readonly IOptions<BotListOptions> _options;
+    private readonly BotListOptions _options;
     private int _lastServerCount = -1;
 
     /// <summary>
@@ -31,28 +31,28 @@ public BotListService(DiscordShardedClient discordClient, HttpClient httpClient,
         _discordClient = discordClient;
         _httpClient = httpClient;
         _logger = logger;
-        _options = options;
+        _options = options.Value;
     }
     
     /// <inheritdoc/>
     protected override async Task ExecuteAsync(CancellationToken stoppingToken)
     {
-        var botLists = _options.Value.Tokens.Where(x => string.IsNullOrWhiteSpace(x.Value)).Select(x => x.Key).ToArray();
+        var botLists = _options.Tokens.Where(x => string.IsNullOrWhiteSpace(x.Value)).Select(x => x.Key).ToArray();
         foreach (var botList in botLists)
         {
-            _options.Value.Tokens.Remove(botList);
+            _options.Tokens.Remove(botList);
         }
 
-        if (_options.Value.Tokens.Count == 0)
+        if (_options.Tokens.Count == 0)
         {
             _logger.LogInformation("Bot list service started. No bot stats will be updated because no tokens were provided.");
             return;
         }
         
         _logger.LogInformation("Bot list service started. Updating stats for {BotLists} every {UpdatePeriod} minute(s).",
-            string.Join(", ", _options.Value.Tokens.Keys), _options.Value.UpdatePeriodInMinutes);
+            string.Join(", ", _options.Tokens.Keys), _options.UpdatePeriodInMinutes);
 
-        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(_options.Value.UpdatePeriodInMinutes));
+        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(_options.UpdatePeriodInMinutes));
         while (await timer.WaitForNextTickAsync(stoppingToken))
         {
             await UpdateStatsAsync();
@@ -68,7 +68,7 @@ public async ValueTask UpdateStatsAsync()
         if (_lastServerCount == -1) _lastServerCount = serverCount;
         else if (_lastServerCount == serverCount) return;
 
-        foreach ((var botList, string token) in _options.Value.Tokens)
+        foreach ((var botList, string token) in _options.Tokens)
         {
             if (!string.IsNullOrWhiteSpace(token))
             {
@@ -115,7 +115,7 @@ public async Task UpdateStatsAsync(BotList botList, int serverCount, int shardCo
                 }
 
                 _logger.LogInformation("Bot stats will not be sent to {BotList} API.", botList);
-                _options.Value.Tokens.Remove(botList);
+                _options.Tokens.Remove(botList);
             }
         }
     }

From fc8cbc90787dd2620afc87318e7dd25465ce847f Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 1 May 2022 19:17:30 -0500
Subject: [PATCH 60/83] Add `/tts microsoft` and move `/tts` to `/tts google`

---
 src/Converters/MicrosoftVoiceConverter.cs     | 45 ++++++++++++++
 .../MicrosoftTtsAutocompleteHandler.cs        | 51 ++++++++++++++++
 src/Modules/OcrModule.cs                      |  2 +-
 src/Modules/SharedModule.cs                   |  2 +-
 src/Modules/TtsModule.cs                      | 59 +++++++++++++++++++
 src/Modules/UtilityModule.cs                  | 10 ----
 src/Resources/SharedResource.es.resx          |  3 +
 src/Services/InteractionHandlingService.cs    |  2 +
 .../Fergun.Tests/Modules/SharedModuleTests.cs |  4 +-
 9 files changed, 164 insertions(+), 14 deletions(-)
 create mode 100644 src/Converters/MicrosoftVoiceConverter.cs
 create mode 100644 src/Modules/Handlers/MicrosoftTtsAutocompleteHandler.cs
 create mode 100644 src/Modules/TtsModule.cs

diff --git a/src/Converters/MicrosoftVoiceConverter.cs b/src/Converters/MicrosoftVoiceConverter.cs
new file mode 100644
index 0000000..951cb8e
--- /dev/null
+++ b/src/Converters/MicrosoftVoiceConverter.cs
@@ -0,0 +1,45 @@
+using System.Globalization;
+using Discord;
+using Discord.Interactions;
+using Fergun.Extensions;
+using GTranslate;
+using GTranslate.Translators;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Fergun.Converters;
+
+/// <summary>
+/// Represent a converter of <see cref="MicrosoftVoice"/>.
+/// </summary>
+public class MicrosoftVoiceConverter : TypeConverter<MicrosoftVoice>
+{
+    /// <inheritdoc/>
+    public override ApplicationCommandOptionType GetDiscordType() => ApplicationCommandOptionType.String;
+
+    /// <inheritdoc/>
+    public override Task<TypeConverterResult> ReadAsync(IInteractionContext context, IApplicationCommandInteractionDataOption option, IServiceProvider services)
+    {
+        string value = option.Value as string ?? string.Empty;
+
+        var translator = services
+            .GetRequiredService<MicrosoftTranslator>();
+
+        var voices = MicrosoftTranslator.DefaultVoices.Values;
+        var task = translator.GetTTSVoicesAsync();
+        if (task.IsCompletedSuccessfully)
+        {
+            voices = task.GetAwaiter().GetResult();
+        }
+        
+        var voice = voices.FirstOrDefault(x => x.ShortName == value);
+
+        if (voice is null)
+        {
+            var localizer = services.GetRequiredService<IFergunLocalizer<SharedResource>>();
+            localizer.CurrentCulture = CultureInfo.GetCultureInfo(context.Interaction.GetLanguageCode());
+            return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, localizer["Unable to get the specified voice. Use the autocomplete results."]));
+        }
+
+        return Task.FromResult(TypeConverterResult.FromSuccess(voice));
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/Handlers/MicrosoftTtsAutocompleteHandler.cs b/src/Modules/Handlers/MicrosoftTtsAutocompleteHandler.cs
new file mode 100644
index 0000000..78365a2
--- /dev/null
+++ b/src/Modules/Handlers/MicrosoftTtsAutocompleteHandler.cs
@@ -0,0 +1,51 @@
+using Discord;
+using Discord.Interactions;
+using Fergun.Extensions;
+using GTranslate;
+using GTranslate.Translators;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Fergun.Modules.Handlers;
+
+public class MicrosoftTtsAutocompleteHandler : AutocompleteHandler
+{
+    public override Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
+    {
+        string text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
+
+        var translator = services
+            .GetRequiredService<MicrosoftTranslator>();
+
+        var voices = MicrosoftTranslator.DefaultVoices.Values;
+        var task = translator.GetTTSVoicesAsync();
+        if (task.IsCompletedSuccessfully)
+        {
+            voices = task.GetAwaiter().GetResult();
+        }
+
+        voices = voices
+            .Where(x => x.DisplayName.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
+                        x.Locale.StartsWith(text, StringComparison.OrdinalIgnoreCase) ||
+                        x.Gender.StartsWith(text, StringComparison.OrdinalIgnoreCase))
+            .OrderBy(x => x.DisplayName);
+
+        if (context.Interaction.TryGetLanguage(out var userLanguage) && string.IsNullOrEmpty(text))
+        {
+            var matchingVoices = voices
+                .Where(x => x.Locale.StartsWith(userLanguage.ISO6391, StringComparison.OrdinalIgnoreCase))
+                .ToHashSet();
+
+            matchingVoices.UnionWith(voices);
+            voices = matchingVoices;
+            
+        }
+
+        var results = voices
+            .Take(25)
+            .Select(x => new AutocompleteResult($"{x.DisplayName} ({x.Gender}, {x.Locale})", x.ShortName));
+
+        return Task.FromResult(AutocompletionResult.FromSuccess(results));
+    }
+
+    
+}
\ No newline at end of file
diff --git a/src/Modules/OcrModule.cs b/src/Modules/OcrModule.cs
index 4e7dbba..e9ad1ac 100644
--- a/src/Modules/OcrModule.cs
+++ b/src/Modules/OcrModule.cs
@@ -190,7 +190,7 @@ public async Task<RuntimeResult> OcrTtsAsync()
         int startIndex = text.IndexOf('`', StringComparison.Ordinal) + 3;
         text = text[startIndex..^3];
 
-        return await _shared.TtsAsync(Context.Interaction, text, Context.Interaction.GetLanguageCode(), true);
+        return await _shared.GoogleTtsAsync(Context.Interaction, text, Context.Interaction.GetLanguageCode(), true);
     }
 
     public enum OcrEngine
diff --git a/src/Modules/SharedModule.cs b/src/Modules/SharedModule.cs
index 3a68c62..8a96845 100644
--- a/src/Modules/SharedModule.cs
+++ b/src/Modules/SharedModule.cs
@@ -98,7 +98,7 @@ static string DisplayName(ILanguage language)
             => $"{language.Name}{(language is not Language lang || lang.NativeName == language.Name ? "" : $" ({lang.NativeName})")}";
     }
 
-    public async Task<RuntimeResult> TtsAsync(IDiscordInteraction interaction, string text, string target, bool ephemeral = false)
+    public async Task<RuntimeResult> GoogleTtsAsync(IDiscordInteraction interaction, string text, string target, bool ephemeral = false)
     {
         _localizer.CurrentCulture = CultureInfo.GetCultureInfo(interaction.GetLanguageCode());
 
diff --git a/src/Modules/TtsModule.cs b/src/Modules/TtsModule.cs
new file mode 100644
index 0000000..ac91d8e
--- /dev/null
+++ b/src/Modules/TtsModule.cs
@@ -0,0 +1,59 @@
+using System.Globalization;
+using Discord;
+using Discord.Interactions;
+using Fergun.Extensions;
+using Fergun.Modules.Handlers;
+using GTranslate;
+using GTranslate.Translators;
+using Microsoft.Extensions.Logging;
+
+namespace Fergun.Modules;
+
+[Group("tts", "TTS commands.")]
+public class TtsModule : InteractionModuleBase
+{
+    private readonly ILogger<TtsModule> _logger;
+    private readonly IFergunLocalizer<TtsModule> _localizer;
+    private readonly SharedModule _shared;
+    private readonly MicrosoftTranslator _microsoftTranslator;
+
+    public TtsModule(ILogger<TtsModule> logger, IFergunLocalizer<TtsModule> localizer, SharedModule shared, MicrosoftTranslator microsoftTranslator)
+    {
+        _logger = logger;
+        _localizer = localizer;
+        _shared = shared;
+        _microsoftTranslator = microsoftTranslator;
+    }
+
+    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
+
+    [MessageCommand("TTS")]
+    public async Task<RuntimeResult> TtsAsync(IMessage message)
+        => await GoogleAsync(message.GetText()); // NOTE: Should we allow the user to specify a TTS engine/language?
+
+    [SlashCommand("google", "Converts text into synthesized speech using Google.")]
+    public async Task<RuntimeResult> GoogleAsync([Summary(description: "The text to convert.")] string text,
+        [Autocomplete(typeof(TtsAutocompleteHandler))][Summary(description: "The target language.")] string? target = null,
+        [Summary(description: "Whether to respond ephemerally.")] bool ephemeral = false)
+        => await _shared.GoogleTtsAsync(Context.Interaction, text, target ?? Context.Interaction.GetLanguageCode(), ephemeral);
+
+    [SlashCommand("microsoft", "Converts text into synthesized speech using Microsoft Azure.")]
+    public async Task<RuntimeResult> MicrosoftAsync([Summary(description: "The text to convert.")] string text,
+        [Autocomplete(typeof(MicrosoftTtsAutocompleteHandler))][Summary(description: "The target voice.")] MicrosoftVoice voice,
+        [Summary(description: "Whether to respond ephemerally.")] bool ephemeral = false)
+    {
+        if (string.IsNullOrWhiteSpace(text))
+        {
+            return FergunResult.FromError(_localizer["The text must not be empty."], true);
+        }
+
+        await Context.Interaction.DeferAsync(ephemeral);
+
+        _logger.LogInformation("Sending Microsoft TTS request (text: {text}, voice: {voice})", text, voice.ShortName);
+        await using var stream = await _microsoftTranslator.TextToSpeechAsync(text, voice);
+
+        await Context.Interaction.FollowupWithFileAsync(new FileAttachment(stream, $"{voice.ShortName}.mp3"), ephemeral: ephemeral);
+
+        return FergunResult.FromSuccess();
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 316aab2..e909f93 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -313,16 +313,6 @@ public async Task<RuntimeResult> TranslateAsync([Summary(description: "The text
         [Autocomplete(typeof(TranslateAutocompleteHandler))] [Summary(description: "Source language (name, code or alias).")] string? source = null,
         [Summary(description: "Whether to respond ephemerally.")] bool ephemeral = false)
         => await _shared.TranslateAsync(Context.Interaction, text, target, source, ephemeral);
-    
-    [MessageCommand("TTS")]
-    public async Task<RuntimeResult> TtsAsync(IMessage message)
-        => await TtsAsync(message.GetText());
-
-    [SlashCommand("tts", "Converts text into synthesized speech.")]
-    public async Task<RuntimeResult> TtsAsync([Summary(description: "The text to convert.")] string text,
-        [Autocomplete(typeof(TtsAutocompleteHandler))] [Summary(description: "The target language.")] string? target = null,
-        [Summary(description: "Whether to respond ephemerally.")] bool ephemeral = false)
-        => await _shared.TtsAsync(Context.Interaction, text, target ?? Context.Interaction.GetLanguageCode(), ephemeral);
 
     [UserCommand("User Info")]
     [SlashCommand("user", "Gets information about a user.")]
diff --git a/src/Resources/SharedResource.es.resx b/src/Resources/SharedResource.es.resx
index 1781178..335d50e 100644
--- a/src/Resources/SharedResource.es.resx
+++ b/src/Resources/SharedResource.es.resx
@@ -198,6 +198,9 @@
   <data name="Unable to get an image URL from the message." xml:space="preserve">
     <value>No se puede obtener una URL de imagen del mensaje.</value>
   </data>
+  <data name="Unable to get the specified voice. Use the autocomplete results." xml:space="preserve">
+    <value>No se puede obtener la voz especificada. Utilice los resultados de autocompletado.</value>
+  </data>
   <data name="Unknown format. Try using JPEG, PNG, or BMP files." xml:space="preserve">
     <value>Formato desconocido. Intenta usar archivos JPEG, PNG o BMP.</value>
   </data>
diff --git a/src/Services/InteractionHandlingService.cs b/src/Services/InteractionHandlingService.cs
index ad8e46a..5e165da 100644
--- a/src/Services/InteractionHandlingService.cs
+++ b/src/Services/InteractionHandlingService.cs
@@ -5,6 +5,7 @@
 using Discord.WebSocket;
 using Fergun.Converters;
 using Fergun.Extensions;
+using GTranslate;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
@@ -39,6 +40,7 @@ public async Task StartAsync(CancellationToken cancellationToken)
         _shardedClient.InteractionCreated += HandleInteractionAsync;
 
         _interactionService.AddTypeConverter<System.Drawing.Color>(new ColorConverter());
+        _interactionService.AddTypeConverter<MicrosoftVoice>(new MicrosoftVoiceConverter());
         var modules = await _interactionService.AddModulesAsync(Assembly.GetEntryAssembly(), _services);
         _logger.LogDebug("Added {moduleCount} command modules", modules.Count());
 
diff --git a/tests/Fergun.Tests/Modules/SharedModuleTests.cs b/tests/Fergun.Tests/Modules/SharedModuleTests.cs
index 22381e3..b3b44ca 100644
--- a/tests/Fergun.Tests/Modules/SharedModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/SharedModuleTests.cs
@@ -109,7 +109,7 @@ public async Task TranslateAsync_Uses_DeferLoadingAsync(string text, string targ
     [InlineData("Hola mundo", "es", false)]
     public async Task TtsAsync_Sends_Results_Or_Fails_Preconditions(string text, string target, bool ephemeral)
     {
-        await _sharedModuleMock.Object.TtsAsync(_interactionMock.Object, text, target, ephemeral);
+        await _sharedModuleMock.Object.GoogleTtsAsync(_interactionMock.Object, text, target, ephemeral);
 
         _interactionMock.VerifyGet(x => x.UserLocale);
 
@@ -129,7 +129,7 @@ public async Task TtsAsync_Sends_Results_Or_Fails_Preconditions(string text, str
     [InlineData("Bonjour le monde", "fr", false)]
     public async Task TtsAsync_Uses_DeferLoadingAsync(string text, string target, bool ephemeral)
     {
-        await _sharedModuleMock.Object.TtsAsync(_componentInteractionMock.Object, text, target, ephemeral);
+        await _sharedModuleMock.Object.GoogleTtsAsync(_componentInteractionMock.Object, text, target, ephemeral);
 
         _componentInteractionMock.VerifyGet(x => x.UserLocale);
 

From 74a2b15b11259d56307686889277d43fcf54e06b Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 1 May 2022 20:39:11 -0500
Subject: [PATCH 61/83] Fix appsettings.json being read from project folder
 instead of build folder

---
 src/Program.cs | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/src/Program.cs b/src/Program.cs
index 5119625..b198319 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -26,6 +26,7 @@
 using YoutubeExplode.Search;
 
 await Host.CreateDefaultBuilder()
+    .UseContentRoot(AppDomain.CurrentDomain.BaseDirectory)
     .ConfigureDiscordShardedHost((context, config) =>
     {
         config.SocketConfig = new DiscordSocketConfig
@@ -46,13 +47,13 @@ await Host.CreateDefaultBuilder()
         config.UseCompiledLambda = false;
     })
     .ConfigureLogging(logging => logging.ClearProviders())
-    .UseSerilog((_, config) =>
+    .UseSerilog((context, config) =>
     {
         config.MinimumLevel.Debug()
             .Filter.ByExcluding(e => e.Level == LogEventLevel.Debug && Matching.FromSource("Discord.WebSocket.DiscordShardedClient").Invoke(e) && e.MessageTemplate.Render(e.Properties).ContainsAny("Connected to", "Disconnected from"))
             .Filter.ByExcluding(e => e.Level <= LogEventLevel.Debug && (Matching.FromSource("Microsoft.Extensions.Http").Invoke(e) || Matching.FromSource("Microsoft.Extensions.Localization").Invoke(e)))
             .WriteTo.Console(LogEventLevel.Debug, theme: AnsiConsoleTheme.Literate)
-            .WriteTo.Async(logger => logger.File("logs/log-.txt", LogEventLevel.Debug, rollingInterval: RollingInterval.Day));
+            .WriteTo.Async(logger => logger.File($"{context.HostingEnvironment.ContentRootPath}logs/log-.txt", LogEventLevel.Debug, rollingInterval: RollingInterval.Day));
     })
     .ConfigureServices((context, services) =>
     {

From ddb31128817c0b776296e83ea229a4eac7b02862 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Tue, 3 May 2022 23:13:31 -0500
Subject: [PATCH 62/83] Add /lyrics

---
 src/Apis/Genius/GeniusClient.cs               | 210 ++++++++++++++++++
 src/Apis/Genius/GeniusException.cs            |  46 ++++
 src/Apis/Genius/GeniusSong.cs                 |  69 ++++++
 src/Apis/Genius/IGeniusClient.cs              |  21 ++
 src/Apis/Genius/IGeniusSong.cs                |  59 +++++
 src/Extensions/StringExtensions.cs            |  32 +++
 .../Handlers/GeniusAutocompleteHandler.cs     |  35 +++
 src/Modules/OtherModule.cs                    |  64 +++++-
 src/Program.cs                                |   5 +
 src/Resources/Modules.OtherModule.es.resx     |  18 ++
 10 files changed, 558 insertions(+), 1 deletion(-)
 create mode 100644 src/Apis/Genius/GeniusClient.cs
 create mode 100644 src/Apis/Genius/GeniusException.cs
 create mode 100644 src/Apis/Genius/GeniusSong.cs
 create mode 100644 src/Apis/Genius/IGeniusClient.cs
 create mode 100644 src/Apis/Genius/IGeniusSong.cs
 create mode 100644 src/Modules/Handlers/GeniusAutocompleteHandler.cs

diff --git a/src/Apis/Genius/GeniusClient.cs b/src/Apis/Genius/GeniusClient.cs
new file mode 100644
index 0000000..3d256db
--- /dev/null
+++ b/src/Apis/Genius/GeniusClient.cs
@@ -0,0 +1,210 @@
+using System.Drawing;
+using System.Globalization;
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace Fergun.Apis.Genius;
+
+/// <summary>
+/// Represents an API wrapper for Genius.
+/// </summary>
+public sealed class GeniusClient : IGeniusClient, IDisposable
+{
+    private static readonly Uri _apiEndpoint = new("https://genius.com/");
+
+    private const string _defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36";
+    private readonly HttpClient _httpClient;
+    private bool _disposed;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="GeniusClient"/> class.
+    /// </summary>
+    public GeniusClient()
+        : this(new HttpClient())
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="GeniusClient"/> class using the specified <see cref="HttpClient"/>.
+    /// </summary>
+    /// <param name="httpClient">An instance of <see cref="HttpClient"/>.</param>
+    public GeniusClient(HttpClient httpClient)
+    {
+        _httpClient = httpClient;
+
+        _httpClient.BaseAddress ??= _apiEndpoint;
+
+        if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0)
+        {
+            _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_defaultUserAgent);
+        }
+    }
+
+    /// <inheritdoc cref="IGeniusClient.SearchSongsAsync(string)"/>
+    public async Task<IEnumerable<GeniusSong>> SearchSongsAsync(string query)
+    {
+        EnsureNotDisposed();
+
+        var response = await _httpClient.GetAsync(new Uri($"api/search?q={Uri.EscapeDataString(query)}", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
+        response.EnsureSuccessStatusCode();
+
+        var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+        var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+
+        return document.RootElement
+            .GetProperty("response")
+            .GetProperty("hits")
+            .EnumerateArray()
+            .Select(x => x.GetProperty("result").Deserialize<GeniusSong>()!);
+    }
+
+    /// <inheritdoc cref="IGeniusClient.GetSongAsync(int)"/>
+    public async Task<GeniusSong?> GetSongAsync(int id)
+    {
+        EnsureNotDisposed();
+        const string startString = "window.__PRELOADED_STATE__ = JSON.parse('";
+        const string endString = "');\n";
+
+        // The API doesn't provide the lyrics, so we scrape the lyrics page and extract the embedded JSON which contains the lyrics.
+        using var response = await _httpClient.GetAsync(new Uri($"songs/{id}", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
+
+        if (response.StatusCode == HttpStatusCode.NotFound)
+        {
+            return null;
+        }
+
+        response.EnsureSuccessStatusCode();
+        string rawHtml = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+
+        int start = rawHtml.IndexOf(startString, StringComparison.Ordinal);
+        if (start == -1)
+        {
+            throw new GeniusException("Failed the extract the embedded JSON from the lyrics page.");
+        }
+
+        start += startString.Length;
+
+        int end = rawHtml.IndexOf(endString, start, StringComparison.Ordinal);
+        if (end == -1)
+        {
+            throw new GeniusException("Failed the extract the embedded JSON from the lyrics page.");
+        }
+
+        var document = JsonDocument.Parse(Regex.Unescape(rawHtml[start..end]));
+
+        var song = document
+            .RootElement
+            .GetProperty("entities")
+            .GetProperty("songs")
+            .GetProperty(id.ToString());
+
+        string artistNames = song.GetProperty("artistNames").GetString() ?? throw new GeniusException("Unable to get the artist names.");
+        string headerImageUrl = song.GetProperty("headerImageUrl").GetString() ?? throw new GeniusException("Unable to get the song header image URL.");
+        bool isInstrumental = song.GetProperty("instrumental").GetBoolean();
+        string songArtImageUrl = song.GetProperty("songArtImageUrl").GetString() ?? throw new GeniusException("Unable to get the song art image URL.");
+        string title = song.GetProperty("title").GetString() ?? throw new GeniusException("Unable to get the song title.");
+        string url = song.GetProperty("url").GetString() ?? throw new GeniusException("Unable to get the lyrics page URL.");
+        string songArtPrimaryColor = song.GetProperty("songArtPrimaryColor").GetString() ?? throw new GeniusException("Unable to get the primary art color.");
+        Color? primaryColor = int.TryParse(songArtPrimaryColor.AsSpan().TrimStart('#'), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int color)
+            ? Color.FromArgb(color)
+            : default;
+
+        int primaryArtistId = song.GetProperty("primaryArtist").GetInt32();
+        string? primaryArtistUrl = document
+            .RootElement
+            .GetProperty("entities")
+            .GetProperty("artists")
+            .GetProperty(primaryArtistId.ToString())
+            .GetProperty("url")
+            .GetString();
+
+        var chunks = document
+            .RootElement
+            .GetProperty("songPage")
+            .GetProperty("lyricsData")
+            .GetProperty("body")
+            .GetProperty("children");
+
+        var lyricsBuilder = new StringBuilder();
+
+        IterateChunks(chunks, lyricsBuilder);
+
+        return new GeniusSong(artistNames, headerImageUrl, id, isInstrumental, songArtImageUrl, title, url, primaryArtistUrl, primaryColor, lyricsBuilder.ToString());
+    }
+
+    /// <inheritdoc/>
+    public void Dispose()
+    {
+        if (_disposed)
+        {
+            return;
+        }
+
+        _httpClient.Dispose();
+        _disposed = true;
+    }
+
+    private void EnsureNotDisposed()
+    {
+        if (_disposed)
+        {
+            throw new ObjectDisposedException(nameof(GeniusClient));
+        }
+    }
+
+    private static void IterateChunks(in JsonElement element, in StringBuilder builder, bool appendStrings = true)
+    {
+        if (element.ValueKind == JsonValueKind.Array)
+        {
+            foreach (var child in element.EnumerateArray())
+            {
+                IterateChunks(child, builder);
+            }
+        }
+        else if (element.ValueKind == JsonValueKind.String)
+        {
+            if (appendStrings)
+            {
+                builder.Append(element.GetString());
+            }
+        }
+        else if (element.ValueKind == JsonValueKind.Object)
+        {
+            if (element.TryGetProperty("tag", out var tag))
+            {
+                string markDownEq = tag.GetString() switch
+                {
+                    "a" => "",
+                    "i" => "*",
+                    "b" => "**",
+                    _ => "\n"
+                };
+                builder.Append(markDownEq);
+            }
+
+            foreach (var property in element.EnumerateObject())
+            {
+                IterateChunks(property.Value, builder, false);
+
+                if (property.NameEquals("tag"))
+                {
+                    string markDownEq = tag.GetString() switch
+                    {
+                        "i" => "*",
+                        "b" => "**",
+                        _ => ""
+                    };
+                    builder.Append(markDownEq);
+                }
+            }
+        }
+    }
+
+    /// <inheritdoc/>
+    async Task<IEnumerable<IGeniusSong>> IGeniusClient.SearchSongsAsync(string query) => await SearchSongsAsync(query).ConfigureAwait(false);
+
+    /// <inheritdoc/>
+    async Task<IGeniusSong?> IGeniusClient.GetSongAsync(int id) => await GetSongAsync(id).ConfigureAwait(false);
+}
\ No newline at end of file
diff --git a/src/Apis/Genius/GeniusException.cs b/src/Apis/Genius/GeniusException.cs
new file mode 100644
index 0000000..297d380
--- /dev/null
+++ b/src/Apis/Genius/GeniusException.cs
@@ -0,0 +1,46 @@
+using System.Runtime.Serialization;
+
+namespace Fergun.Apis.Genius;
+
+/// <summary>
+/// The exception that is thrown when <see cref="GeniusClient"/> fails to scrape a song.
+/// </summary>
+[Serializable]
+public class GeniusException : Exception
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="GeniusException"/> class.
+    /// </summary>
+    public GeniusException()
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="GeniusException"/> class with a specified error message.
+    /// </summary>
+    /// <param name="message">The message that describes the error.</param>
+    public GeniusException(string? message)
+        : base(message)
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="GeniusException"/> class with a specified error message and a reference to the inner exception that is the cause of this exception.
+    /// </summary>
+    /// <param name="message">The error message that explains the reason for the exception.</param>
+    /// <param name="innerException">The exception that is the cause of the current exception, or a null reference if no inner exception is specified.</param>
+    public GeniusException(string? message, Exception? innerException)
+        : base(message, innerException)
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="GeniusException"/> class with serialized data.
+    /// </summary>
+    /// <param name="serializationInfo">The <see cref="SerializationInfo"/> that holds the serialized object data about the exception being thrown.</param>
+    /// <param name="streamingContext">The <see cref="StreamingContext"/> that contains contextual information about the source or destination.</param>
+    protected GeniusException(SerializationInfo serializationInfo, StreamingContext streamingContext)
+        : base(serializationInfo, streamingContext)
+    {
+    }
+}
\ No newline at end of file
diff --git a/src/Apis/Genius/GeniusSong.cs b/src/Apis/Genius/GeniusSong.cs
new file mode 100644
index 0000000..d74f608
--- /dev/null
+++ b/src/Apis/Genius/GeniusSong.cs
@@ -0,0 +1,69 @@
+using System.Drawing;
+using System.Text.Json.Serialization;
+
+namespace Fergun.Apis.Genius;
+
+/// <summary>
+/// Represents a Genius song.
+/// </summary>
+public class GeniusSong : IGeniusSong
+{
+    public GeniusSong(string artistNames, string headerImageUrl, int id, bool isInstrumental,
+        string songArtImageUrl, string title, string url, string? primaryArtistUrl, Color? primaryArtColor, string? lyrics)
+    {
+        ArtistNames = artistNames;
+        HeaderImageUrl = headerImageUrl;
+        Id = id;
+        IsInstrumental = isInstrumental;
+        SongArtImageUrl = songArtImageUrl;
+        Title = title;
+        Url = url;
+        Id = id;
+        PrimaryArtistUrl = primaryArtistUrl;
+        PrimaryArtColor = primaryArtColor;
+        Lyrics = lyrics;
+    }
+
+    /// <inheritdoc/>
+    [JsonPropertyName("artist_names")]
+    public string ArtistNames { get; }
+
+    /// <inheritdoc/>
+    [JsonPropertyName("header_image_url")]
+    public string HeaderImageUrl { get; }
+
+    /// <inheritdoc/>
+    [JsonPropertyName("id")]
+    public int Id { get; }
+
+    /// <inheritdoc/>
+    [JsonPropertyName("instrumental")]
+    public bool IsInstrumental { get; }
+
+    /// <inheritdoc/>
+    [JsonPropertyName("song_art_image_url")]
+    public string SongArtImageUrl { get; }
+
+    /// <inheritdoc/>
+    [JsonPropertyName("title")]
+    public string Title { get; }
+
+    /// <inheritdoc/>
+    [JsonPropertyName("url")]
+    public string Url { get; }
+
+    /// <inheritdoc/>
+    public string? PrimaryArtistUrl { get; }
+
+    /// <inheritdoc/>
+    public Color? PrimaryArtColor { get; }
+
+    /// <inheritdoc/>
+    public string? Lyrics { get; }
+
+    /// <summary>
+    /// Returns the full title of this song.
+    /// </summary>
+    /// <returns>The full title of this song.</returns>
+    public override string ToString() => $"{ArtistNames} - {Title}";
+}
\ No newline at end of file
diff --git a/src/Apis/Genius/IGeniusClient.cs b/src/Apis/Genius/IGeniusClient.cs
new file mode 100644
index 0000000..4a48036
--- /dev/null
+++ b/src/Apis/Genius/IGeniusClient.cs
@@ -0,0 +1,21 @@
+namespace Fergun.Apis.Genius;
+
+/// <summary>
+/// Represents a Genius API client.
+/// </summary>
+public interface IGeniusClient
+{
+    /// <summary>
+    /// Searches for Genius songs that matches <paramref name="query"/>.
+    /// </summary>
+    /// <param name="query">The search term.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains an <see cref="IEnumerable{T}"/> of matching songs.</returns>
+    Task<IEnumerable<IGeniusSong>> SearchSongsAsync(string query);
+
+    /// <summary>
+    /// Gets a Genius song by its ID.
+    /// </summary>
+    /// <param name="id">The ID of the song.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation. The result contains the song.</returns>
+    Task<IGeniusSong?> GetSongAsync(int id);
+}
\ No newline at end of file
diff --git a/src/Apis/Genius/IGeniusSong.cs b/src/Apis/Genius/IGeniusSong.cs
new file mode 100644
index 0000000..11f2cbf
--- /dev/null
+++ b/src/Apis/Genius/IGeniusSong.cs
@@ -0,0 +1,59 @@
+using System.Drawing;
+
+namespace Fergun.Apis.Genius;
+
+/// <summary>
+/// Represents a Genius song.
+/// </summary>
+public interface IGeniusSong
+{
+    /// <summary>
+    /// Gets the artist names.
+    /// </summary>
+    string ArtistNames { get; }
+
+    /// <summary>
+    /// Gets the header image URL.
+    /// </summary>
+    string HeaderImageUrl { get; }
+
+    /// <summary>
+    /// Gets the ID of this song.
+    /// </summary>
+    int Id { get; }
+
+    /// <summary>
+    /// Gets a value indicating whether this song is instrumental.
+    /// </summary>
+    bool IsInstrumental { get; }
+
+    /// <summary>
+    /// Gets the song art image URL.
+    /// </summary>
+    string SongArtImageUrl { get; }
+
+    /// <summary>
+    /// Gets the title of this song.
+    /// </summary>
+    string Title { get; }
+
+    /// <summary>
+    /// Gets a URL pointing to the lyrics page.
+    /// </summary>
+    string Url { get; }
+
+    /// <summary>
+    /// Gets a URL pointing to the primary artist page.
+    /// </summary>
+    public string? PrimaryArtistUrl { get; }
+
+    /// <summary>
+    /// Gets the primary art color.
+    /// </summary>
+    public Color? PrimaryArtColor { get; }
+
+    /// <summary>
+    /// Gets the lyrics of this song.
+    /// </summary>
+    string? Lyrics { get; }
+}
\ No newline at end of file
diff --git a/src/Extensions/StringExtensions.cs b/src/Extensions/StringExtensions.cs
index 11cc02a..8baa95b 100644
--- a/src/Extensions/StringExtensions.cs
+++ b/src/Extensions/StringExtensions.cs
@@ -3,4 +3,36 @@
 public static class StringExtensions
 {
     public static bool ContainsAny(this string str, string str0, string str1) => str.Contains(str0) || str.Contains(str1);
+
+    // From GTranslate
+    public static IEnumerable<ReadOnlyMemory<char>> SplitWithoutWordBreaking(this string text, int maxLength)
+    {
+        var current = text.AsMemory();
+
+        while (!current.IsEmpty)
+        {
+            int index = -1;
+            int length;
+
+            if (current.Length <= maxLength)
+            {
+                length = current.Length;
+            }
+            else
+            {
+                index = current[..maxLength].Span.LastIndexOf(' ');
+                length = index == -1 ? maxLength : index;
+            }
+
+            var line = current[..length];
+            // skip a single space if there's one
+            if (index != -1)
+            {
+                length++;
+            }
+
+            current = current[length..];
+            yield return line;
+        }
+    }
 }
\ No newline at end of file
diff --git a/src/Modules/Handlers/GeniusAutocompleteHandler.cs b/src/Modules/Handlers/GeniusAutocompleteHandler.cs
new file mode 100644
index 0000000..cdf6bb1
--- /dev/null
+++ b/src/Modules/Handlers/GeniusAutocompleteHandler.cs
@@ -0,0 +1,35 @@
+using Discord;
+using Discord.Interactions;
+using Fergun.Apis.Genius;
+using Humanizer;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Fergun.Modules.Handlers;
+
+public class GeniusAutocompleteHandler : AutocompleteHandler
+{
+    /// <inheritdoc />
+    public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services)
+    {
+        string text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim();
+        
+        if (string.IsNullOrWhiteSpace(text))
+        {
+            return AutocompletionResult.FromSuccess();
+        }
+
+        var geniusClient = services
+            .GetRequiredService<IGeniusClient>();
+
+        var songs = await geniusClient.SearchSongsAsync(text);
+
+        var results = songs
+            .Where(x => !x.IsInstrumental)
+            .Take(25)
+            .Select(x => new AutocompleteResult($"{x.ArtistNames} - {x.Title}".Truncate(100), x.Id));
+
+        return AutocompletionResult.FromSuccess(results);
+    }
+
+
+}
\ No newline at end of file
diff --git a/src/Modules/OtherModule.cs b/src/Modules/OtherModule.cs
index 0f49db6..364de90 100644
--- a/src/Modules/OtherModule.cs
+++ b/src/Modules/OtherModule.cs
@@ -4,7 +4,11 @@
 using Discord;
 using Discord.Interactions;
 using Discord.WebSocket;
+using Fergun.Apis.Genius;
 using Fergun.Extensions;
+using Fergun.Interactive;
+using Fergun.Interactive.Pagination;
+using Fergun.Modules.Handlers;
 using Fergun.Utils;
 using Humanizer;
 using Microsoft.Extensions.Logging;
@@ -15,13 +19,18 @@ public class OtherModule : InteractionModuleBase
 {
     private readonly ILogger<OtherModule> _logger;
     private readonly IFergunLocalizer<OtherModule> _localizer;
+    private readonly InteractiveService _interactive;
+    private readonly IGeniusClient _geniusClient;
     private readonly HttpClient _httpClient;
 
-    public OtherModule(ILogger<OtherModule> logger, IFergunLocalizer<OtherModule> localizer, HttpClient httpClient)
+    public OtherModule(ILogger<OtherModule> logger, IFergunLocalizer<OtherModule> localizer,
+        InteractiveService interactive, IGeniusClient geniusClient, HttpClient httpClient)
     {
         _logger = logger;
         _localizer = localizer;
+        _geniusClient = geniusClient;
         _httpClient = httpClient;
+        _interactive = interactive;
     }
 
     [SlashCommand("inspirobot", "Sends an inspirational quote.")]
@@ -40,6 +49,59 @@ public async Task<RuntimeResult> InspiroBotAsync()
 
         return FergunResult.FromSuccess();
     }
+    
+    [SlashCommand("lyrics", "Gets the lyrics of a song.")]
+    public async Task<RuntimeResult> LyricsAsync([Autocomplete(typeof(GeniusAutocompleteHandler))] [Summary(name: "name", description: "The name of the song.")] int id)
+    {
+        await Context.Interaction.DeferAsync();
+        var song = await _geniusClient.GetSongAsync(id);
+
+        if (song is null)
+        {
+            return FergunResult.FromError(_localizer["Unable to find a song with ID {0}. Use the autocomplete results.", id]);
+        }
+        
+        if (song.IsInstrumental)
+        {
+            // This shouldn't be reachable unless someone manually passes an instrumental ID.
+            return FergunResult.FromError(_localizer["\"{0}\" is instrumental.", $"{song.ArtistNames} - {song.Title}"]);
+        }
+
+        if (song.Lyrics is null)
+        {
+            return FergunResult.FromError(_localizer["Unable to get the lyrics of \"{0}\".", $"{song.ArtistNames} - {song.Title}"]);
+        }
+
+        var chunks = song.Lyrics.SplitWithoutWordBreaking(EmbedBuilder.MaxDescriptionLength).ToArray();
+
+        var paginator = new LazyPaginatorBuilder()
+            .AddUser(Context.User)
+            .WithPageFactory(GeneratePage)
+            .WithActionOnCancellation(ActionOnStop.DisableInput)
+            .WithActionOnTimeout(ActionOnStop.DisableInput)
+            .WithMaxPageIndex(chunks.Length - 1)
+            .WithFooter(PaginatorFooter.None)
+            .WithFergunEmotes()
+            .WithLocalizedPrompts(_localizer)
+            .Build();
+
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(20), InteractionResponseType.DeferredChannelMessageWithSource);
+
+        return FergunResult.FromSuccess();
+
+        PageBuilder GeneratePage(int index)
+        {
+            var chunk = chunks[index];
+            
+            return new PageBuilder()
+                .WithTitle($"{song.ArtistNames} - {song.Title}".Truncate(EmbedBuilder.MaxTitleLength))
+                .WithThumbnailUrl(song.SongArtImageUrl)
+                .WithDescription(chunk.ToString())
+                .AddField("Links", $"{Format.Url(_localizer["View on Genius"], song.Url)}{(song.PrimaryArtistUrl is null ? "" : $" | {Format.Url(_localizer["View Artist"], song.PrimaryArtistUrl)}")}")
+                .WithFooter(_localizer["Lyrics by Genius | Page {0} of {1}", index + 1, chunks.Length])
+                .WithColor((Color)(song.PrimaryArtColor ?? Color.Orange));
+        }
+    }
 
     [SlashCommand("stats", "Sends the stats of the bot.")]
     public async Task<RuntimeResult> StatsAsync()
diff --git a/src/Program.cs b/src/Program.cs
index b198319..49239dd 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -4,6 +4,7 @@
 using Discord.WebSocket;
 using Fergun;
 using Fergun.Apis.Bing;
+using Fergun.Apis.Genius;
 using Fergun.Apis.Urban;
 using Fergun.Apis.Wikipedia;
 using Fergun.Apis.Yandex;
@@ -83,6 +84,10 @@ await Host.CreateDefaultBuilder()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
 
+        services.AddHttpClient<IGeniusClient, GeniusClient>()
+            .SetHandlerLifetime(TimeSpan.FromMinutes(30))
+            .AddRetryPolicy();
+
         services.AddHttpClient<GoogleTranslator>()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
diff --git a/src/Resources/Modules.OtherModule.es.resx b/src/Resources/Modules.OtherModule.es.resx
index de4bf68..a9d35a1 100644
--- a/src/Resources/Modules.OtherModule.es.resx
+++ b/src/Resources/Modules.OtherModule.es.resx
@@ -117,6 +117,9 @@
   <resheader name="writer">
     <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
   </resheader>
+  <data name="&quot;{0}&quot; is instrumental." xml:space="preserve">
+    <value>"{0}" es instrumental.</value>
+  </data>
   <data name="Bot Owner" xml:space="preserve">
     <value>Dueño del bot</value>
   </data>
@@ -132,6 +135,9 @@
   <data name="Library" xml:space="preserve">
     <value>Librería</value>
   </data>
+  <data name="Lyrics by Genius | Page {0} of {1}" xml:space="preserve">
+    <value>Letra por Genius | Página {0} de {1}</value>
+  </data>
   <data name="Operating System" xml:space="preserve">
     <value>Sistema Operativo</value>
   </data>
@@ -147,7 +153,19 @@
   <data name="Total Users" xml:space="preserve">
     <value>Usuarios Totales</value>
   </data>
+  <data name="Unable to find a song with ID {0}. Use the autocomplete results." xml:space="preserve">
+    <value>No se pudo encontrar una canción con ID {0}. Usa los resultados de autocompletado.</value>
+  </data>
+  <data name="Unable to get the lyrics of &quot;{0}&quot;." xml:space="preserve">
+    <value>No es posible obtener la letra de "{0}".</value>
+  </data>
   <data name="Uptime" xml:space="preserve">
     <value>Tiempo activo</value>
   </data>
+  <data name="View Artist" xml:space="preserve">
+    <value>Ver Artista</value>
+  </data>
+  <data name="View on Genius" xml:space="preserve">
+    <value>Ver en Genius</value>
+  </data>
 </root>
\ No newline at end of file

From bf187b1fa35351786879c336eac5af7afe3b19f7 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Wed, 4 May 2022 20:32:17 -0500
Subject: [PATCH 63/83] Fix incorrect localized texts in `OtherModule`

---
 src/Modules/OtherModule.cs | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/src/Modules/OtherModule.cs b/src/Modules/OtherModule.cs
index 364de90..411a413 100644
--- a/src/Modules/OtherModule.cs
+++ b/src/Modules/OtherModule.cs
@@ -1,4 +1,5 @@
 using System.Diagnostics;
+using System.Globalization;
 using System.Reflection;
 using System.Runtime.InteropServices;
 using Discord;
@@ -33,6 +34,8 @@ public OtherModule(ILogger<OtherModule> logger, IFergunLocalizer<OtherModule> lo
         _interactive = interactive;
     }
 
+    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
+
     [SlashCommand("inspirobot", "Sends an inspirational quote.")]
     public async Task<RuntimeResult> InspiroBotAsync()
     {

From 78bddc6889e0bedad540b69dea5cc55924e61068 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Wed, 4 May 2022 20:44:07 -0500
Subject: [PATCH 64/83] Move /cmd to `OwnerModule`

---
 src/Modules/OwnerModule.cs   | 57 ++++++++++++++++++++++++++++++++++++
 src/Modules/UtilityModule.cs | 39 ------------------------
 2 files changed, 57 insertions(+), 39 deletions(-)
 create mode 100644 src/Modules/OwnerModule.cs

diff --git a/src/Modules/OwnerModule.cs b/src/Modules/OwnerModule.cs
new file mode 100644
index 0000000..fd21ae3
--- /dev/null
+++ b/src/Modules/OwnerModule.cs
@@ -0,0 +1,57 @@
+using Discord;
+using Discord.Interactions;
+using Fergun.Utils;
+using Humanizer;
+using Microsoft.Extensions.Logging;
+
+namespace Fergun.Modules;
+
+public class OwnerModule : InteractionModuleBase
+{
+    private readonly ILogger<UtilityModule> _logger;
+    private readonly IFergunLocalizer<UtilityModule> _localizer;
+
+    public OwnerModule(ILogger<UtilityModule> logger, IFergunLocalizer<UtilityModule> localizer)
+    {
+        _logger = logger;
+        _localizer = localizer;
+    }
+
+    [RequireOwner]
+    [SlashCommand("cmd", "(Owner only) Executes a command.")]
+    public async Task<RuntimeResult> CmdAsync([Summary(description: "The command to execute.")] string command, [Summary(description: "No embed.")] bool noEmbed = false)
+    {
+        await Context.Interaction.DeferAsync();
+
+        string? result = CommandUtils.RunCommand(command);
+
+        if (string.IsNullOrWhiteSpace(result))
+        {
+            await Context.Interaction.FollowupAsync(_localizer["No output."]);
+        }
+        else
+        {
+            int limit = noEmbed ? DiscordConfig.MaxMessageSize : EmbedBuilder.MaxDescriptionLength;
+            string sanitized = Format.Code(result.Replace('`', '´').Truncate(limit - 12), "ansi");
+            string? text = null;
+            Embed? embed = null;
+
+            if (noEmbed)
+            {
+                text = sanitized;
+            }
+            else
+            {
+                embed = new EmbedBuilder()
+                    .WithTitle(_localizer["Command output"])
+                    .WithDescription(sanitized)
+                    .WithColor(Color.Orange)
+                    .Build();
+            }
+
+            await Context.Interaction.FollowupAsync(text, embed: embed);
+        }
+
+        return FergunResult.FromSuccess();
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index e909f93..49095a8 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -7,7 +7,6 @@
 using Fergun.Interactive;
 using Fergun.Interactive.Pagination;
 using Fergun.Modules.Handlers;
-using Fergun.Utils;
 using GTranslate;
 using GTranslate.Results;
 using Humanizer;
@@ -192,44 +191,6 @@ public async Task<RuntimeResult> BadTranslatorAsync([Summary(description: "The t
         return FergunResult.FromSuccess();
     }
 
-    [RequireOwner]
-    [SlashCommand("cmd", "(Owner only) Executes a command.")]
-    public async Task<RuntimeResult> CmdAsync([Summary(description: "The command to execute.")] string command, [Summary(description: "No embed.")] bool noEmbed = false)
-    {
-        await Context.Interaction.DeferAsync();
-
-        string? result = CommandUtils.RunCommand(command);
-
-        if (string.IsNullOrWhiteSpace(result))
-        {
-            await Context.Interaction.FollowupAsync(_localizer["No output."]);
-        }
-        else
-        {
-            int limit = noEmbed ? DiscordConfig.MaxMessageSize : EmbedBuilder.MaxDescriptionLength;
-            string sanitized = Format.Code(result.Replace('`', '´').Truncate(limit - 12), "ansi");
-            string? text = null;
-            Embed? embed = null;
-
-            if (noEmbed)
-            {
-                text = sanitized;
-            }
-            else
-            {
-                embed = new EmbedBuilder()
-                    .WithTitle(_localizer["Command output"])
-                    .WithDescription(sanitized)
-                    .WithColor(Color.Orange)
-                    .Build();
-            }
-
-            await Context.Interaction.FollowupAsync(text, embed: embed);
-        }
-
-        return FergunResult.FromSuccess();
-    }
-
     [SlashCommand("color", "Displays a color.")]
     public async Task<RuntimeResult> ColorAsync([Summary(description: "A color name, hex string or raw value. Leave empty to get a random color.")]
         System.Drawing.Color color = default)

From db0f207e76e0f48a96296beddfaa449abad84ae5 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Thu, 5 May 2022 01:44:12 -0500
Subject: [PATCH 65/83] Fix tests

---
 tests/Fergun.Tests/Modules/SharedModuleTests.cs | 13 +++++++------
 1 file changed, 7 insertions(+), 6 deletions(-)

diff --git a/tests/Fergun.Tests/Modules/SharedModuleTests.cs b/tests/Fergun.Tests/Modules/SharedModuleTests.cs
index b3b44ca..074f70b 100644
--- a/tests/Fergun.Tests/Modules/SharedModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/SharedModuleTests.cs
@@ -71,14 +71,17 @@ public async Task TranslateAsync_Returns_Results_Or_Fails_Preconditions(string t
 
         bool passedPreconditions = !string.IsNullOrEmpty(text) && Language.TryGetLanguage(target, out _) && (source == null || Language.TryGetLanguage(source, out _));
 
-        Assert.Equal(result.IsSuccess, passedPreconditions);
+        if (text != "Error")
+        {
+            Assert.Equal(result.IsSuccess, passedPreconditions);
+        }
 
         _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == ephemeral), It.IsAny<RequestOptions>()), passedPreconditions ? Times.Once : Times.Never);
 
         _translatorMock.Verify(x => x.TranslateAsync(It.Is<string>(s => s == text), It.Is<string>(s => s == target), It.Is<string>(s => s == source)), passedPreconditions ? Times.Once : Times.Never);
 
         _interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b == ephemeral),
-            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), passedPreconditions ? Times.Once : Times.Never);
+            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), result.IsSuccess && passedPreconditions ? Times.Once : Times.Never);
     }
 
     [Theory]
@@ -109,14 +112,12 @@ public async Task TranslateAsync_Uses_DeferLoadingAsync(string text, string targ
     [InlineData("Hola mundo", "es", false)]
     public async Task TtsAsync_Sends_Results_Or_Fails_Preconditions(string text, string target, bool ephemeral)
     {
-        await _sharedModuleMock.Object.GoogleTtsAsync(_interactionMock.Object, text, target, ephemeral);
+        var result = await _sharedModuleMock.Object.GoogleTtsAsync(_interactionMock.Object, text, target, ephemeral);
 
         _interactionMock.VerifyGet(x => x.UserLocale);
 
         bool passedPreconditions = !string.IsNullOrWhiteSpace(text) && Language.TryGetLanguage(target, out var language) && GoogleTranslator2.TextToSpeechLanguages.Contains(language);
-
-        _interactionMock.Verify(x => x.RespondAsync(It.IsAny<string>(), It.IsAny<Embed[]>(), It.IsAny<bool>(), It.Is<bool>(b => b),
-            It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), passedPreconditions ? Times.Never : Times.Once);
+        Assert.Equal(passedPreconditions, result.IsSuccess);
 
         _interactionMock.Verify(x => x.DeferAsync(It.Is<bool>(b => b == ephemeral), It.IsAny<RequestOptions>()), passedPreconditions ? Times.Once : Times.Never);
 

From 7bd6d2ce30ca36ddf1ee4ba0b05424dd827b3246 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Fri, 6 May 2022 01:56:58 -0500
Subject: [PATCH 66/83] Update packages

---
 src/Fergun.csproj                      |  8 ++++----
 tests/Fergun.Tests/Fergun.Tests.csproj | 12 +++---------
 2 files changed, 7 insertions(+), 13 deletions(-)

diff --git a/src/Fergun.csproj b/src/Fergun.csproj
index 0da8050..9335699 100644
--- a/src/Fergun.csproj
+++ b/src/Fergun.csproj
@@ -10,14 +10,14 @@
 
   <ItemGroup>
     <PackageReference Include="Discord.Addons.Hosting" Version="5.1.0" />
-    <PackageReference Include="Discord.Net.Interactions" Version="3.5.0" />
+    <PackageReference Include="Discord.Net.Interactions" Version="3.6.1" />
     <PackageReference Include="Fergun.Interactive" Version="1.5.4" />
     <PackageReference Include="GScraper" Version="1.0.2" />
     <PackageReference Include="GTranslate" Version="2.1.0" />
     <PackageReference Include="Humanizer.Core" Version="2.14.1" />
     <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
-    <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.3" />
-    <PackageReference Include="Microsoft.Extensions.Localization" Version="6.0.3" />
+    <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.4" />
+    <PackageReference Include="Microsoft.Extensions.Localization" Version="6.0.4" />
     <PackageReference Include="Polly.Caching.Memory" Version="3.0.2" />
     <PackageReference Include="Serilog.Extensions.Hosting" Version="4.2.0" />
     <PackageReference Include="Serilog.Extensions.Logging.File" Version="2.0.0" />
@@ -25,7 +25,7 @@
     <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
     <PackageReference Include="SixLabors.ImageSharp" Version="2.1.1" />
     <PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta14" />
-    <PackageReference Include="YoutubeExplode" Version="6.1.0" />
+    <PackageReference Include="YoutubeExplode" Version="6.1.2" />
   </ItemGroup>
 
 </Project>
diff --git a/tests/Fergun.Tests/Fergun.Tests.csproj b/tests/Fergun.Tests/Fergun.Tests.csproj
index 27882a3..155f65a 100644
--- a/tests/Fergun.Tests/Fergun.Tests.csproj
+++ b/tests/Fergun.Tests/Fergun.Tests.csproj
@@ -3,22 +3,16 @@
   <PropertyGroup>
     <TargetFramework>net6.0</TargetFramework>
     <Nullable>enable</Nullable>
-
-    <IsPackable>false</IsPackable>
   </PropertyGroup>
 
   <ItemGroup>
     <PackageReference Include="AutoBogus" Version="2.13.1" />
     <PackageReference Include="AutoBogus.Moq" Version="2.13.1" />
-    <PackageReference Include="Bogus" Version="34.0.1" />
+    <PackageReference Include="Bogus" Version="34.0.2" />
     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
-    <PackageReference Include="Moq" Version="4.17.1" />
+    <PackageReference Include="Moq" Version="4.17.2" />
     <PackageReference Include="xunit" Version="2.4.1" />
-    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
-      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
-      <PrivateAssets>all</PrivateAssets>
-    </PackageReference>
-    <PackageReference Include="coverlet.collector" Version="3.1.2">
+    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.4">
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
       <PrivateAssets>all</PrivateAssets>
     </PackageReference>

From d22746939640e2afdfcc256aad97e705ae7a7eb2 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Fri, 6 May 2022 17:20:00 -0500
Subject: [PATCH 67/83] Add `InteractiveOptions`

---
 src/Entities/InteractiveOptions.cs            | 27 ++++++++++++
 src/Extensions/PaginatorExtensions.cs         | 43 +++++++++++++++----
 src/Modules/ImageModule.cs                    | 28 ++++++------
 src/Modules/OtherModule.cs                    |  7 ++-
 src/Modules/UrbanModule.cs                    |  8 +++-
 src/Modules/UtilityModule.cs                  | 11 +++--
 src/Program.cs                                |  1 +
 src/appsettings.json                          | 36 ++++++++++------
 .../Fergun.Tests/Modules/ImageModuleTests.cs  |  4 +-
 .../Fergun.Tests/Modules/UrbanModuleTests.cs  |  4 +-
 .../Modules/UtilityModuleTests.cs             |  4 +-
 11 files changed, 130 insertions(+), 43 deletions(-)
 create mode 100644 src/Entities/InteractiveOptions.cs

diff --git a/src/Entities/InteractiveOptions.cs b/src/Entities/InteractiveOptions.cs
new file mode 100644
index 0000000..3891ab7
--- /dev/null
+++ b/src/Entities/InteractiveOptions.cs
@@ -0,0 +1,27 @@
+using Fergun.Interactive;
+using Fergun.Interactive.Pagination;
+
+namespace Fergun;
+
+/// <summary>
+/// Represents the settings related to <see cref="InteractiveService"/>.
+/// </summary>
+public class InteractiveOptions
+{
+    public const string Interactive = nameof(Interactive);
+    
+    /// <summary>
+    /// Gets or sets the default paginator timeout.
+    /// </summary>
+    public TimeSpan PaginatorTimeout { get; set; }
+
+    /// <summary>
+    /// Gets or sets the default selection timeout.
+    /// </summary>
+    public TimeSpan SelectionTimeout { get; set; }
+
+    /// <summary>
+    /// Gets or sets the dictionary of paginator emotes.
+    /// </summary>
+    public IDictionary<PaginatorAction, string> PaginatorEmotes { get; set; } = new Dictionary<PaginatorAction, string>();
+}
\ No newline at end of file
diff --git a/src/Extensions/PaginatorExtensions.cs b/src/Extensions/PaginatorExtensions.cs
index 772632d..a6ffb17 100644
--- a/src/Extensions/PaginatorExtensions.cs
+++ b/src/Extensions/PaginatorExtensions.cs
@@ -1,30 +1,43 @@
 using Discord;
 using Fergun.Interactive.Pagination;
 using Microsoft.Extensions.Localization;
+using System.Diagnostics.CodeAnalysis;
 
 namespace Fergun.Extensions;
 
 public static class PaginatorExtensions
 {
+    // We use this because the dictionary of emotes is not deserialized in the order it is provided.
+    private static readonly PaginatorAction[] _orderedActions =
+    {
+        PaginatorAction.SkipToStart,
+        PaginatorAction.Backward,
+        PaginatorAction.Forward,
+        PaginatorAction.SkipToEnd,
+        PaginatorAction.Jump,
+        PaginatorAction.Exit
+    };
+
     /// <summary>
     /// Adds Fergun emotes.
     /// </summary>
     /// <typeparam name="TPaginator">The type of the paginator.</typeparam>
     /// <typeparam name="TBuilder">The type of the paginator builder.</typeparam>
     /// <param name="builder">A paginator builder.</param>
+    /// <param name="options">The interactive options.</param>
     /// <returns>This builder.</returns>
-    public static TBuilder WithFergunEmotes<TPaginator, TBuilder>(this PaginatorBuilder<TPaginator, TBuilder> builder)
+    public static TBuilder WithFergunEmotes<TPaginator, TBuilder>(this PaginatorBuilder<TPaginator, TBuilder> builder, InteractiveOptions options)
         where TPaginator : Paginator
         where TBuilder : PaginatorBuilder<TPaginator, TBuilder>
     {
-        builder.Options.Clear();
-
-        builder.AddOption(Emoji.Parse("◀️"), PaginatorAction.Backward);
-        builder.AddOption(Emoji.Parse("▶️"), PaginatorAction.Forward);
-        builder.AddOption(Emoji.Parse("🔢"), PaginatorAction.Jump);
-        builder.AddOption(Emoji.Parse("🛑"), PaginatorAction.Exit);
+        var emotes = options
+            .PaginatorEmotes
+            .OrderBy(x => Array.IndexOf(_orderedActions, x.Key))
+            .Select(pair => (Success: pair.Value.TryParseEmote(out var emote), Emote: emote, Action: pair.Key))
+            .Where(x => x.Success)
+            .ToDictionary(x => x.Emote!, x => x.Action);
 
-        return (TBuilder)builder;
+        return builder.WithOptions(emotes);
     }
 
     /// <summary>
@@ -47,4 +60,18 @@ public static TBuilder WithLocalizedPrompts<TPaginator, TBuilder>(this BaseLazyP
 
         return (TBuilder)builder;
     }
+
+    private static bool TryParseEmote(this string rawEmote, [MaybeNullWhen(false)] out IEmote emote)
+    {
+        bool success = Emote.TryParse(rawEmote, out var temp);
+        emote = temp;
+
+        if (!success)
+        {
+            success = Emoji.TryParse(rawEmote, out var temp2);
+            emote = temp2;
+        }
+
+        return success;
+    }
 }
\ No newline at end of file
diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
index 30c0dd0..f483199 100644
--- a/src/Modules/ImageModule.cs
+++ b/src/Modules/ImageModule.cs
@@ -13,6 +13,7 @@
 using GScraper.DuckDuckGo;
 using GScraper.Google;
 using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
 
 namespace Fergun.Modules;
 
@@ -21,6 +22,7 @@ public class ImageModule : InteractionModuleBase
 {
     private readonly ILogger<ImageModule> _logger;
     private readonly IFergunLocalizer<ImageModule> _localizer;
+    private readonly InteractiveOptions _interactiveOptions;
     private readonly InteractiveService _interactive;
     private readonly GoogleScraper _googleScraper;
     private readonly DuckDuckGoScraper _duckDuckGoScraper;
@@ -28,11 +30,13 @@ public class ImageModule : InteractionModuleBase
     private readonly IBingVisualSearch _bingVisualSearch;
     private readonly IYandexImageSearch _yandexImageSearch;
 
-    public ImageModule(ILogger<ImageModule> logger, IFergunLocalizer<ImageModule> localizer, InteractiveService interactive, GoogleScraper googleScraper,
-        DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper, IBingVisualSearch bingVisualSearch, IYandexImageSearch yandexImageSearch)
+    public ImageModule(ILogger<ImageModule> logger, IFergunLocalizer<ImageModule> localizer, IOptionsSnapshot<InteractiveOptions> interactiveOptions,
+        InteractiveService interactive, GoogleScraper googleScraper, DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper,
+        IBingVisualSearch bingVisualSearch, IYandexImageSearch yandexImageSearch)
     {
         _logger = logger;
         _localizer = localizer;
+        _interactiveOptions = interactiveOptions.Value;
         _interactive = interactive;
         _googleScraper = googleScraper;
         _duckDuckGoScraper = duckDuckGoScraper;
@@ -66,7 +70,7 @@ public async Task<RuntimeResult> GoogleAsync([Autocomplete(typeof(GoogleAutocomp
 
         var paginator = new LazyPaginatorBuilder()
             .WithPageFactory(GeneratePage)
-            .WithFergunEmotes()
+            .WithFergunEmotes(_interactiveOptions)
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(images.Length - 1)
@@ -75,7 +79,7 @@ public async Task<RuntimeResult> GoogleAsync([Autocomplete(typeof(GoogleAutocomp
             .WithLocalizedPrompts(_localizer)
             .Build();
 
-        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _interactiveOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource);
 
         return FergunResult.FromSuccess();
 
@@ -115,7 +119,7 @@ public async Task<RuntimeResult> DuckDuckGoAsync([Autocomplete(typeof(DuckDuckGo
 
         var paginator = new LazyPaginatorBuilder()
             .WithPageFactory(GeneratePage)
-            .WithFergunEmotes()
+            .WithFergunEmotes(_interactiveOptions)
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(images.Length - 1)
@@ -124,7 +128,7 @@ public async Task<RuntimeResult> DuckDuckGoAsync([Autocomplete(typeof(DuckDuckGo
             .WithLocalizedPrompts(_localizer)
             .Build();
 
-        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _interactiveOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource);
 
         return FergunResult.FromSuccess();
 
@@ -164,7 +168,7 @@ public async Task<RuntimeResult> BraveAsync([Autocomplete(typeof(BraveAutocomple
 
         var paginator = new LazyPaginatorBuilder()
             .WithPageFactory(GeneratePage)
-            .WithFergunEmotes()
+            .WithFergunEmotes(_interactiveOptions)
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(images.Length - 1)
@@ -173,7 +177,7 @@ public async Task<RuntimeResult> BraveAsync([Autocomplete(typeof(BraveAutocomple
             .WithLocalizedPrompts(_localizer)
             .Build();
 
-        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource);
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _interactiveOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource);
 
         return FergunResult.FromSuccess();
 
@@ -287,7 +291,7 @@ public virtual async Task<RuntimeResult> YandexAsync(string url, bool multiImage
 
         var paginator = new LazyPaginatorBuilder()
             .WithPageFactory(GeneratePage)
-            .WithFergunEmotes()
+            .WithFergunEmotes(_interactiveOptions)
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(results.Length - 1)
@@ -296,7 +300,7 @@ public virtual async Task<RuntimeResult> YandexAsync(string url, bool multiImage
             .WithLocalizedPrompts(_localizer)
             .Build();
 
-        await _interactive.SendPaginatorAsync(paginator, interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
+        await _interactive.SendPaginatorAsync(paginator, interaction, _interactiveOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
 
         return FergunResult.FromSuccess();
 
@@ -348,7 +352,7 @@ public virtual async Task<RuntimeResult> BingAsync(string url, bool multiImages,
 
         var paginator = new LazyPaginatorBuilder()
             .WithPageFactory(GeneratePage)
-            .WithFergunEmotes()
+            .WithFergunEmotes(_interactiveOptions)
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(results.Length - 1)
@@ -357,7 +361,7 @@ public virtual async Task<RuntimeResult> BingAsync(string url, bool multiImages,
             .WithLocalizedPrompts(_localizer)
             .Build();
 
-        await _interactive.SendPaginatorAsync(paginator, interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
+        await _interactive.SendPaginatorAsync(paginator, interaction, _interactiveOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
 
         return FergunResult.FromSuccess();
 
diff --git a/src/Modules/OtherModule.cs b/src/Modules/OtherModule.cs
index 411a413..c5350df 100644
--- a/src/Modules/OtherModule.cs
+++ b/src/Modules/OtherModule.cs
@@ -13,6 +13,7 @@
 using Fergun.Utils;
 using Humanizer;
 using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
 
 namespace Fergun.Modules;
 
@@ -20,15 +21,17 @@ public class OtherModule : InteractionModuleBase
 {
     private readonly ILogger<OtherModule> _logger;
     private readonly IFergunLocalizer<OtherModule> _localizer;
+    private readonly InteractiveOptions _interactiveOptions;
     private readonly InteractiveService _interactive;
     private readonly IGeniusClient _geniusClient;
     private readonly HttpClient _httpClient;
 
-    public OtherModule(ILogger<OtherModule> logger, IFergunLocalizer<OtherModule> localizer,
+    public OtherModule(ILogger<OtherModule> logger, IFergunLocalizer<OtherModule> localizer, IOptionsSnapshot<InteractiveOptions> interactiveOptions,
         InteractiveService interactive, IGeniusClient geniusClient, HttpClient httpClient)
     {
         _logger = logger;
         _localizer = localizer;
+        _interactiveOptions = interactiveOptions.Value;
         _geniusClient = geniusClient;
         _httpClient = httpClient;
         _interactive = interactive;
@@ -84,7 +87,7 @@ public async Task<RuntimeResult> LyricsAsync([Autocomplete(typeof(GeniusAutocomp
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(chunks.Length - 1)
             .WithFooter(PaginatorFooter.None)
-            .WithFergunEmotes()
+            .WithFergunEmotes(_interactiveOptions)
             .WithLocalizedPrompts(_localizer)
             .Build();
 
diff --git a/src/Modules/UrbanModule.cs b/src/Modules/UrbanModule.cs
index 37befad..a9fc740 100644
--- a/src/Modules/UrbanModule.cs
+++ b/src/Modules/UrbanModule.cs
@@ -7,6 +7,7 @@
 using Fergun.Interactive;
 using Fergun.Interactive.Pagination;
 using Fergun.Modules.Handlers;
+using Microsoft.Extensions.Options;
 
 namespace Fergun.Modules;
 
@@ -14,12 +15,15 @@ namespace Fergun.Modules;
 public class UrbanModule : InteractionModuleBase
 {
     private readonly IFergunLocalizer<UrbanModule> _localizer;
+    private readonly InteractiveOptions _interactiveOptions;
     private readonly IUrbanDictionary _urbanDictionary;
     private readonly InteractiveService _interactive;
 
-    public UrbanModule(IFergunLocalizer<UrbanModule> localizer, IUrbanDictionary urbanDictionary, InteractiveService interactive)
+    public UrbanModule(IFergunLocalizer<UrbanModule> localizer, IOptionsSnapshot<InteractiveOptions> interactiveOptions,
+        IUrbanDictionary urbanDictionary, InteractiveService interactive)
     {
         _localizer = localizer;
+        _interactiveOptions = interactiveOptions.Value;
         _urbanDictionary = urbanDictionary;
         _interactive = interactive;
     }
@@ -55,7 +59,7 @@ public async Task<RuntimeResult> SearchAndSendAsync(UrbanSearchType searchType,
 
         var paginator = new LazyPaginatorBuilder()
             .WithPageFactory(GeneratePage)
-            .WithFergunEmotes()
+            .WithFergunEmotes(_interactiveOptions)
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(definitions.Count - 1)
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 49095a8..d3dddf7 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -11,6 +11,7 @@
 using GTranslate.Results;
 using Humanizer;
 using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
 using SixLabors.ImageSharp;
 using SixLabors.ImageSharp.Drawing.Processing;
 using SixLabors.ImageSharp.Formats.Png;
@@ -26,6 +27,7 @@ public class UtilityModule : InteractionModuleBase
 {
     private readonly ILogger<UtilityModule> _logger;
     private readonly IFergunLocalizer<UtilityModule> _localizer;
+    private readonly InteractiveOptions _interactiveOptions;
     private readonly SharedModule _shared;
     private readonly InteractiveService _interactive;
     private readonly IFergunTranslator _translator;
@@ -39,11 +41,12 @@ public class UtilityModule : InteractionModuleBase
         .Where(x => x.SupportedServices == (TranslationServices.Google | TranslationServices.Bing | TranslationServices.Yandex | TranslationServices.Microsoft))
         .ToArray());
 
-    public UtilityModule(ILogger<UtilityModule> logger, IFergunLocalizer<UtilityModule> localizer, SharedModule shared,
-        InteractiveService interactive, IFergunTranslator translator, SearchClient searchClient, IWikipediaClient wikipediaClient)
+    public UtilityModule(ILogger<UtilityModule> logger, IFergunLocalizer<UtilityModule> localizer, IOptionsSnapshot<InteractiveOptions> interactiveOptions, 
+        SharedModule shared, InteractiveService interactive, IFergunTranslator translator, SearchClient searchClient, IWikipediaClient wikipediaClient)
     {
         _logger = logger;
         _localizer = localizer;
+        _interactiveOptions = interactiveOptions.Value;
         _shared = shared;
         _interactive = interactive;
         _translator = translator;
@@ -351,7 +354,7 @@ public async Task<RuntimeResult> WikipediaAsync([Autocomplete(typeof(WikipediaAu
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(articles.Length - 1)
             .WithFooter(PaginatorFooter.None)
-            .WithFergunEmotes()
+            .WithFergunEmotes(_interactiveOptions)
             .WithLocalizedPrompts(_localizer)
             .Build();
 
@@ -411,7 +414,7 @@ public async Task<RuntimeResult> YouTubeAsync([Autocomplete(typeof(YouTubeAutoco
                     .WithActionOnTimeout(ActionOnStop.DisableInput)
                     .WithMaxPageIndex(videos.Count - 1)
                     .WithFooter(PaginatorFooter.None)
-                    .WithFergunEmotes()
+                    .WithFergunEmotes(_interactiveOptions)
                     .WithLocalizedPrompts(_localizer)
                     .Build();
 
diff --git a/src/Program.cs b/src/Program.cs
index 49239dd..17c89f1 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -66,6 +66,7 @@ await Host.CreateDefaultBuilder()
         services.AddSingleton<InteractiveService>();
         services.AddFergunPolicies();
         services.Configure<BotListOptions>(context.Configuration.GetSection(BotListOptions.BotList));
+        services.Configure<InteractiveOptions>(context.Configuration.GetSection(InteractiveOptions.Interactive));
 
         services.AddHttpClient<IBingVisualSearch, BingVisualSearch>()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
diff --git a/src/appsettings.json b/src/appsettings.json
index 48c1809..f92b6b5 100644
--- a/src/appsettings.json
+++ b/src/appsettings.json
@@ -1,13 +1,25 @@
-{
-  "TargetGuildId": 0,
-  "Token": "",
-  "BotList":
-  {
-	"UpdatePeriodInMinutes": 30,
-	"Tokens":
-	{
-		"TopGg": "",
-		"DiscordBots": ""
-	}
-  }
+{
+    "TargetGuildId": 0,
+    "Token": "",
+    "Interactive": 
+    {
+        "PaginatorTimeout": "00:10:00",
+        "SelectionTimeout": "00:10:00",
+        "PaginatorEmotes":
+        {
+            "Backward": "◀️",
+            "Forward":  "▶️",
+            "Jump":  "🔢",
+            "Exit": "🛑"
+        }
+    },
+    "BotList":
+    {
+        "UpdatePeriodInMinutes": 30,
+        "Tokens":
+        {
+            "TopGg": "",
+            "DiscordBots": ""
+        }
+    }
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Modules/ImageModuleTests.cs b/tests/Fergun.Tests/Modules/ImageModuleTests.cs
index 0888b7a..c5bad55 100644
--- a/tests/Fergun.Tests/Modules/ImageModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/ImageModuleTests.cs
@@ -12,6 +12,7 @@
 using GScraper.DuckDuckGo;
 using GScraper.Google;
 using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
 using Moq;
 using Xunit;
 
@@ -34,8 +35,9 @@ public class ImageModuleTests
     public ImageModuleTests()
     {
         var logger = Mock.Of<ILogger<ImageModule>>();
+        var options = Mock.Of<IOptionsSnapshot<InteractiveOptions>>();
         var interactive = new InteractiveService(_client, new InteractiveConfig { DeferStopSelectionInteractions = false, ReturnAfterSendingPaginator = true });
-        _moduleMock = new Mock<ImageModule>(() => new ImageModule(logger, _localizer, interactive, _googleScraper,
+        _moduleMock = new Mock<ImageModule>(() => new ImageModule(logger, _localizer, options, interactive, _googleScraper,
             _duckDuckGoScraper, _braveScraper, _bingVisualSearch, _yandexImageSearch)) { CallBase = true };
 
         _module = _moduleMock.Object;
diff --git a/tests/Fergun.Tests/Modules/UrbanModuleTests.cs b/tests/Fergun.Tests/Modules/UrbanModuleTests.cs
index 106eba3..e921adc 100644
--- a/tests/Fergun.Tests/Modules/UrbanModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/UrbanModuleTests.cs
@@ -10,6 +10,7 @@
 using Fergun.Apis.Urban;
 using Fergun.Interactive;
 using Fergun.Modules;
+using Microsoft.Extensions.Options;
 using Moq;
 using Xunit;
 
@@ -27,9 +28,10 @@ public class UrbanModuleTests
 
     public UrbanModuleTests()
     {
+        var options = Mock.Of<IOptionsSnapshot<InteractiveOptions>>();
         var interactive = new InteractiveService(_client, _interactiveConfig);
 
-        _moduleMock = new Mock<UrbanModule>(() => new UrbanModule(_localizer, _urbanDictionary, interactive)) { CallBase = true };
+        _moduleMock = new Mock<UrbanModule>(() => new UrbanModule(_localizer, options, _urbanDictionary, interactive)) { CallBase = true };
         _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
         _contextMock.SetupGet(x => x.User).Returns(() => AutoFaker.Generate<IUser>(b => b.WithBinder(new MoqBinder())));
         ((IInteractionModuleBase)_moduleMock.Object).SetContext(_contextMock.Object);
diff --git a/tests/Fergun.Tests/Modules/UtilityModuleTests.cs b/tests/Fergun.Tests/Modules/UtilityModuleTests.cs
index 3f55ce3..97ce166 100644
--- a/tests/Fergun.Tests/Modules/UtilityModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/UtilityModuleTests.cs
@@ -10,6 +10,7 @@
 using Fergun.Modules;
 using GTranslate.Translators;
 using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
 using Moq;
 using Xunit;
 using YoutubeExplode.Search;
@@ -28,9 +29,10 @@ public class UtilityModuleTests
     
     public UtilityModuleTests()
     {
+        var options = Mock.Of<IOptionsSnapshot<InteractiveOptions>>();
         SharedModule shared = new(Mock.Of<ILogger<SharedModule>>(), Utils.CreateMockedLocalizer<SharedResource>(), Mock.Of<IFergunTranslator>(), _googleTranslator2);
         var interactive = new InteractiveService(new DiscordSocketClient(), new InteractiveConfig { ReturnAfterSendingPaginator = true });
-        _moduleMock = new Mock<UtilityModule>(() => new UtilityModule(Mock.Of<ILogger<UtilityModule>>(), _localizer, shared,
+        _moduleMock = new Mock<UtilityModule>(() => new UtilityModule(Mock.Of<ILogger<UtilityModule>>(), _localizer, options, shared,
             interactive, Mock.Of<IFergunTranslator>(), _searchClient, _wikipediaClient)) { CallBase = true };
         _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object);
         ((IInteractionModuleBase)_moduleMock.Object).SetContext(_contextMock.Object);

From 6918f45c180cf53c1b2a936f5efc26a927a17672 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 8 May 2022 15:53:17 -0500
Subject: [PATCH 68/83] Add database logic and entities

---
 src/Data/FergunContext.cs                     | 29 +++++++++++
 .../20220508195749_InitialCreate.Designer.cs  | 52 +++++++++++++++++++
 .../20220508195749_InitialCreate.cs           | 46 ++++++++++++++++
 .../Migrations/FergunContextModelSnapshot.cs  | 50 ++++++++++++++++++
 src/Data/Models/BlacklistStatus.cs            | 22 ++++++++
 src/Data/Models/Command.cs                    | 21 ++++++++
 src/Data/Models/User.cs                       | 27 ++++++++++
 src/Fergun.csproj                             |  9 ++++
 src/Program.cs                                | 34 ++++++++++--
 src/appsettings.json                          |  4 ++
 10 files changed, 290 insertions(+), 4 deletions(-)
 create mode 100644 src/Data/FergunContext.cs
 create mode 100644 src/Data/Migrations/20220508195749_InitialCreate.Designer.cs
 create mode 100644 src/Data/Migrations/20220508195749_InitialCreate.cs
 create mode 100644 src/Data/Migrations/FergunContextModelSnapshot.cs
 create mode 100644 src/Data/Models/BlacklistStatus.cs
 create mode 100644 src/Data/Models/Command.cs
 create mode 100644 src/Data/Models/User.cs

diff --git a/src/Data/FergunContext.cs b/src/Data/FergunContext.cs
new file mode 100644
index 0000000..71f13d6
--- /dev/null
+++ b/src/Data/FergunContext.cs
@@ -0,0 +1,29 @@
+using Fergun.Data.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace Fergun.Data;
+
+/// <summary>
+/// Represents the Fergun database context.
+/// </summary>
+public class FergunContext : DbContext
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="FergunContext"/> class with the specified options.
+    /// </summary>
+    /// <param name="options">The options.</param>
+    public FergunContext(DbContextOptions<FergunContext> options)
+        : base(options)
+    {
+    }
+
+    /// <summary>
+    /// Gets or sets the users.
+    /// </summary>
+    public DbSet<User> Users { get; set; } = null!;
+
+    /// <summary>
+    /// Gets or sets the command stats.
+    /// </summary>
+    public DbSet<Command> CommandStats { get; set; } = null!;
+}
\ No newline at end of file
diff --git a/src/Data/Migrations/20220508195749_InitialCreate.Designer.cs b/src/Data/Migrations/20220508195749_InitialCreate.Designer.cs
new file mode 100644
index 0000000..da23740
--- /dev/null
+++ b/src/Data/Migrations/20220508195749_InitialCreate.Designer.cs
@@ -0,0 +1,52 @@
+// <auto-generated />
+using Fergun.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Fergun.Data.Migrations
+{
+    [DbContext(typeof(FergunContext))]
+    [Migration("20220508195749_InitialCreate")]
+    partial class InitialCreate
+    {
+        protected override void BuildTargetModel(ModelBuilder modelBuilder)
+        {
+#pragma warning disable 612, 618
+            modelBuilder.HasAnnotation("ProductVersion", "6.0.4");
+
+            modelBuilder.Entity("Fergun.Data.Models.Command", b =>
+                {
+                    b.Property<string>("Name")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("UsageCount")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Name");
+
+                    b.ToTable("CommandStats");
+                });
+
+            modelBuilder.Entity("Fergun.Data.Models.User", b =>
+                {
+                    b.Property<ulong>("Id")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("BlacklistReason")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("BlacklistStatus")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.ToTable("Users");
+                });
+#pragma warning restore 612, 618
+        }
+    }
+}
diff --git a/src/Data/Migrations/20220508195749_InitialCreate.cs b/src/Data/Migrations/20220508195749_InitialCreate.cs
new file mode 100644
index 0000000..ec8425e
--- /dev/null
+++ b/src/Data/Migrations/20220508195749_InitialCreate.cs
@@ -0,0 +1,46 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Fergun.Data.Migrations
+{
+    public partial class InitialCreate : Migration
+    {
+        protected override void Up(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.CreateTable(
+                name: "CommandStats",
+                columns: table => new
+                {
+                    Name = table.Column<string>(type: "TEXT", nullable: false),
+                    UsageCount = table.Column<int>(type: "INTEGER", nullable: false)
+                },
+                constraints: table =>
+                {
+                    table.PrimaryKey("PK_CommandStats", x => x.Name);
+                });
+
+            migrationBuilder.CreateTable(
+                name: "Users",
+                columns: table => new
+                {
+                    Id = table.Column<ulong>(type: "INTEGER", nullable: false),
+                    BlacklistStatus = table.Column<int>(type: "INTEGER", nullable: false),
+                    BlacklistReason = table.Column<string>(type: "TEXT", nullable: true)
+                },
+                constraints: table =>
+                {
+                    table.PrimaryKey("PK_Users", x => x.Id);
+                });
+        }
+
+        protected override void Down(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.DropTable(
+                name: "CommandStats");
+
+            migrationBuilder.DropTable(
+                name: "Users");
+        }
+    }
+}
diff --git a/src/Data/Migrations/FergunContextModelSnapshot.cs b/src/Data/Migrations/FergunContextModelSnapshot.cs
new file mode 100644
index 0000000..afdf6e7
--- /dev/null
+++ b/src/Data/Migrations/FergunContextModelSnapshot.cs
@@ -0,0 +1,50 @@
+// <auto-generated />
+using Fergun.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Fergun.Data.Migrations
+{
+    [DbContext(typeof(FergunContext))]
+    partial class FergunContextModelSnapshot : ModelSnapshot
+    {
+        protected override void BuildModel(ModelBuilder modelBuilder)
+        {
+#pragma warning disable 612, 618
+            modelBuilder.HasAnnotation("ProductVersion", "6.0.4");
+
+            modelBuilder.Entity("Fergun.Data.Models.Command", b =>
+                {
+                    b.Property<string>("Name")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("UsageCount")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Name");
+
+                    b.ToTable("CommandStats");
+                });
+
+            modelBuilder.Entity("Fergun.Data.Models.User", b =>
+                {
+                    b.Property<ulong>("Id")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("BlacklistReason")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("BlacklistStatus")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.ToTable("Users");
+                });
+#pragma warning restore 612, 618
+        }
+    }
+}
diff --git a/src/Data/Models/BlacklistStatus.cs b/src/Data/Models/BlacklistStatus.cs
new file mode 100644
index 0000000..e3d3531
--- /dev/null
+++ b/src/Data/Models/BlacklistStatus.cs
@@ -0,0 +1,22 @@
+namespace Fergun.Data.Models;
+
+/// <summary>
+/// Specifies the possible blacklist status of a user.
+/// </summary>
+public enum BlacklistStatus
+{
+    /// <summary>
+    /// The user is not blacklisted.
+    /// </summary>
+    None,
+    
+    /// <summary>
+    /// The user is blacklisted.
+    /// </summary>
+    Blacklisted,
+
+    /// <summary>
+    /// The user is "shadow"-blacklisted. The user shouldn't be notified that they're blacklisted.
+    /// </summary>
+    ShadowBlacklisted
+}
\ No newline at end of file
diff --git a/src/Data/Models/Command.cs b/src/Data/Models/Command.cs
new file mode 100644
index 0000000..b5cea39
--- /dev/null
+++ b/src/Data/Models/Command.cs
@@ -0,0 +1,21 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Fergun.Data.Models;
+
+/// <summary>
+/// Represents a bot command.
+/// </summary>
+public class Command
+{
+    /// <summary>
+    /// Gets or sets the name of this command.
+    /// </summary>
+    [Key]
+    public string Name { get; set; } = null!;
+
+    /// <summary>
+    /// Gets or sets the usage count.
+    /// </summary>
+    [Required]
+    public int UsageCount { get; set; }
+}
\ No newline at end of file
diff --git a/src/Data/Models/User.cs b/src/Data/Models/User.cs
new file mode 100644
index 0000000..7507515
--- /dev/null
+++ b/src/Data/Models/User.cs
@@ -0,0 +1,27 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Discord;
+
+namespace Fergun.Data.Models;
+
+/// <summary>
+/// Represents a database user.
+/// </summary>
+public class User : IEntity<ulong>
+{
+    /// <inheritdoc/>
+    [Key]
+    [DatabaseGenerated(DatabaseGeneratedOption.None)]
+    public ulong Id { get; set; }
+
+    /// <summary>
+    /// Gets or sets the blacklist status.
+    /// </summary>
+    [Required]
+    public BlacklistStatus BlacklistStatus { get; set; }
+
+    /// <summary>
+    /// Gets or sets the blacklist reason.
+    /// </summary>
+    public string? BlacklistReason { get; set; }
+}
\ No newline at end of file
diff --git a/src/Fergun.csproj b/src/Fergun.csproj
index 9335699..4808013 100644
--- a/src/Fergun.csproj
+++ b/src/Fergun.csproj
@@ -15,6 +15,11 @@
     <PackageReference Include="GScraper" Version="1.0.2" />
     <PackageReference Include="GTranslate" Version="2.1.0" />
     <PackageReference Include="Humanizer.Core" Version="2.14.1" />
+    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.4">
+      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+      <PrivateAssets>all</PrivateAssets>
+    </PackageReference>
+    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.4" />
     <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.4" />
     <PackageReference Include="Microsoft.Extensions.Localization" Version="6.0.4" />
@@ -28,4 +33,8 @@
     <PackageReference Include="YoutubeExplode" Version="6.1.2" />
   </ItemGroup>
 
+  <ItemGroup>
+    <Folder Include="Data\Models\" />
+  </ItemGroup>
+
 </Project>
diff --git a/src/Program.cs b/src/Program.cs
index 17c89f1..830aa0a 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -8,6 +8,7 @@
 using Fergun.Apis.Urban;
 using Fergun.Apis.Wikipedia;
 using Fergun.Apis.Yandex;
+using Fergun.Data;
 using Fergun.Extensions;
 using Fergun.Interactive;
 using Fergun.Modules;
@@ -16,6 +17,7 @@
 using GScraper.DuckDuckGo;
 using GScraper.Google;
 using GTranslate.Translators;
+using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
@@ -26,7 +28,12 @@
 using Serilog.Sinks.SystemConsole.Themes;
 using YoutubeExplode.Search;
 
-await Host.CreateDefaultBuilder()
+// The current directory is changed so the SQLite database is stored in the current folder
+// instead of the project folder (if the data source path is relative).
+Directory.SetCurrentDirectory(AppDomain.CurrentDomain.BaseDirectory);
+
+var host = Host.CreateDefaultBuilder()
+    .UseConsoleLifetime()
     .UseContentRoot(AppDomain.CurrentDomain.BaseDirectory)
     .ConfigureDiscordShardedHost((context, config) =>
     {
@@ -53,6 +60,7 @@ await Host.CreateDefaultBuilder()
         config.MinimumLevel.Debug()
             .Filter.ByExcluding(e => e.Level == LogEventLevel.Debug && Matching.FromSource("Discord.WebSocket.DiscordShardedClient").Invoke(e) && e.MessageTemplate.Render(e.Properties).ContainsAny("Connected to", "Disconnected from"))
             .Filter.ByExcluding(e => e.Level <= LogEventLevel.Debug && (Matching.FromSource("Microsoft.Extensions.Http").Invoke(e) || Matching.FromSource("Microsoft.Extensions.Localization").Invoke(e)))
+            .Filter.ByExcluding(e => e.Level <= LogEventLevel.Information && Matching.FromSource("Microsoft.EntityFrameworkCore").Invoke(e))
             .WriteTo.Console(LogEventLevel.Debug, theme: AnsiConsoleTheme.Literate)
             .WriteTo.Async(logger => logger.File($"{context.HostingEnvironment.ContentRootPath}logs/log-.txt", LogEventLevel.Debug, rollingInterval: RollingInterval.Day));
     })
@@ -67,7 +75,8 @@ await Host.CreateDefaultBuilder()
         services.AddFergunPolicies();
         services.Configure<BotListOptions>(context.Configuration.GetSection(BotListOptions.BotList));
         services.Configure<InteractiveOptions>(context.Configuration.GetSection(InteractiveOptions.Interactive));
-
+        services.AddSqlite<FergunContext>(context.Configuration.GetConnectionString("FergunDatabase"));
+        
         services.AddHttpClient<IBingVisualSearch, BingVisualSearch>()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
@@ -76,7 +85,7 @@ await Host.CreateDefaultBuilder()
             .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { UseCookies = false })
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
-
+        
         services.AddHttpClient<IUrbanDictionary, UrbanDictionary>()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
@@ -139,4 +148,21 @@ await Host.CreateDefaultBuilder()
         services.AddSingleton(x => new DuckDuckGoScraper(x.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(DuckDuckGoScraper))));
         services.AddSingleton(x => new BraveScraper(x.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(BraveScraper))));
         services.AddTransient<SharedModule>();
-    }).RunConsoleAsync();
\ No newline at end of file
+    })
+    .Build();
+
+// Semi-automatic migration
+await using (var scope = host.Services.CreateAsyncScope())
+{
+    var db = scope.ServiceProvider.GetRequiredService<FergunContext>();
+    int pendingMigrations = (await db.Database.GetPendingMigrationsAsync()).Count();
+
+    if (pendingMigrations > 0)
+    {
+        var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
+        await db.Database.MigrateAsync();
+        logger.LogInformation("Applied {Count} pending database migration(s).", pendingMigrations);
+    }
+}
+
+await host.RunAsync();
\ No newline at end of file
diff --git a/src/appsettings.json b/src/appsettings.json
index f92b6b5..ff958e3 100644
--- a/src/appsettings.json
+++ b/src/appsettings.json
@@ -1,4 +1,8 @@
 {
+    "ConnectionStrings":
+    {
+        "FergunDatabase": "Data Source=Fergun.db"
+    },
     "TargetGuildId": 0,
     "Token": "",
     "Interactive": 

From 4f317419c38bd93d4f1df73baf076edaf9878277 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Tue, 10 May 2022 02:18:52 -0500
Subject: [PATCH 69/83] Add /blacklist commands and improve command log
 messages

---
 src/Extensions/Extensions.cs                  |   6 +-
 src/Extensions/InteractionExtensions.cs       |  20 --
 src/Modules/BlacklistModule.cs                |  81 ++++++++
 src/Modules/OwnerModule.cs                    |   2 +-
 src/Program.cs                                |   7 +-
 src/Resources/Modules.BlacklistModule.es.resx | 132 +++++++++++++
 src/Resources/SharedResource.es.resx          |   6 +
 src/Services/InteractionHandlingService.cs    | 179 ++++++++++++++----
 .../Extensions/InteractionExtensionsTests.cs  |  21 --
 9 files changed, 375 insertions(+), 79 deletions(-)
 create mode 100644 src/Modules/BlacklistModule.cs
 create mode 100644 src/Resources/Modules.BlacklistModule.es.resx

diff --git a/src/Extensions/Extensions.cs b/src/Extensions/Extensions.cs
index c43847d..b8a316d 100644
--- a/src/Extensions/Extensions.cs
+++ b/src/Extensions/Extensions.cs
@@ -52,10 +52,12 @@ public static LogLevel ToLogLevel(this LogSeverity logSeverity)
 
     public static string Display(this IInteractionContext context)
     {
-        string displayMessage = context.Channel.Name;
+        string displayMessage = string.Empty;
 
         if (context.Channel is IGuildChannel guildChannel)
-            displayMessage += $"/{guildChannel.Guild.Name}";
+            displayMessage = $"{guildChannel.Guild.Name}/";
+
+        displayMessage += context.Channel.Name;
 
         return displayMessage;
     }
diff --git a/src/Extensions/InteractionExtensions.cs b/src/Extensions/InteractionExtensions.cs
index 82ed490..27d6d2e 100644
--- a/src/Extensions/InteractionExtensions.cs
+++ b/src/Extensions/InteractionExtensions.cs
@@ -6,26 +6,6 @@ namespace Fergun.Extensions;
 
 public static class InteractionExtensions
 {
-    public static async Task RespondWarningAsync(this IDiscordInteraction interaction, string message, bool ephemeral = false)
-    {
-        var embed = new EmbedBuilder()
-            .WithDescription($"âš  {message}")
-            .WithColor(Color.Orange)
-            .Build();
-
-        await interaction.RespondAsync(embed: embed, ephemeral: ephemeral);
-    }
-
-    public static async Task FollowupWarning(this IDiscordInteraction interaction, string message, bool ephemeral = false)
-    {
-        var embed = new EmbedBuilder()
-            .WithDescription($"âš  {message}")
-            .WithColor(Color.Orange)
-            .Build();
-
-        await interaction.FollowupAsync(embed: embed, ephemeral: ephemeral);
-    }
-
     public static string GetLanguageCode(this IDiscordInteraction interaction, string defaultLanguage = "en")
     {
         string language = interaction.UserLocale ?? interaction.GuildLocale;
diff --git a/src/Modules/BlacklistModule.cs b/src/Modules/BlacklistModule.cs
new file mode 100644
index 0000000..e74d0bd
--- /dev/null
+++ b/src/Modules/BlacklistModule.cs
@@ -0,0 +1,81 @@
+using System.Globalization;
+using Discord;
+using Discord.Interactions;
+using Fergun.Data;
+using Fergun.Data.Models;
+using Fergun.Extensions;
+using Microsoft.Extensions.Logging;
+
+namespace Fergun.Modules;
+
+[RequireOwner]
+[Group("blacklist", "Blacklist commands.")]
+public class BlacklistModule : InteractionModuleBase
+{
+    private readonly ILogger<OtherModule> _logger;
+    private readonly IFergunLocalizer<OtherModule> _localizer;
+    private readonly FergunContext _db;
+
+    public BlacklistModule(ILogger<OtherModule> logger, IFergunLocalizer<OtherModule> localizer, FergunContext db)
+    {
+        _logger = logger;
+        _localizer = localizer;
+        _db = db;
+    }
+
+    public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
+
+    [SlashCommand("add", "Adds a user to the blacklist.")]
+    public async Task<RuntimeResult> AddAsync([Summary(description: "The user to add.")] IUser user,
+        [Summary(description: "The blacklist reason.")] string? reason = null,
+        [Summary(description: "Whether the user should be \"shadow\"-blacklisted.")] bool shadow = false)
+    {
+        var dbUser = await _db.Users.FindAsync(user.Id);
+        if (dbUser?.BlacklistStatus is BlacklistStatus.Blacklisted or BlacklistStatus.ShadowBlacklisted)
+        {
+            return FergunResult.FromError(_localizer["{0} is already blacklisted.", user]);
+        }
+
+        if (dbUser is null)
+        {
+            dbUser = new User { Id = user.Id };
+            await _db.AddAsync(dbUser);
+        }
+
+        dbUser.BlacklistStatus = shadow ? BlacklistStatus.ShadowBlacklisted : BlacklistStatus.Blacklisted;
+        dbUser.BlacklistReason = reason;
+
+        await _db.SaveChangesAsync();
+
+        var builder = new EmbedBuilder()
+            .WithDescription(_localizer["{0} has been blacklisted.", user])
+            .WithColor(Color.Orange);
+
+        await Context.Interaction.RespondAsync(embed: builder.Build());
+
+        return FergunResult.FromSuccess();
+    }
+    
+    [SlashCommand("remove", "Removes a user from the blacklist.")]
+    public async Task<RuntimeResult> RemoveAsync([Summary(description: "The user to remove.")] IUser user)
+    {
+        var dbUser = await _db.Users.FindAsync(user.Id);
+        if (dbUser is null or { BlacklistStatus: BlacklistStatus.None })
+        {
+            return FergunResult.FromError(_localizer["{0} is not blacklisted.", user]);
+        }
+
+        dbUser.BlacklistStatus = BlacklistStatus.None;
+        dbUser.BlacklistReason = null;
+
+        await _db.SaveChangesAsync();
+
+        var builder = new EmbedBuilder()
+            .WithDescription(_localizer["{0} has been removed from the blacklist.", user])
+            .WithColor(Color.Orange);
+
+        await Context.Interaction.RespondAsync(embed: builder.Build());
+
+        return FergunResult.FromSuccess();
+    }
+}
\ No newline at end of file
diff --git a/src/Modules/OwnerModule.cs b/src/Modules/OwnerModule.cs
index fd21ae3..a09323d 100644
--- a/src/Modules/OwnerModule.cs
+++ b/src/Modules/OwnerModule.cs
@@ -6,6 +6,7 @@
 
 namespace Fergun.Modules;
 
+[RequireOwner]
 public class OwnerModule : InteractionModuleBase
 {
     private readonly ILogger<UtilityModule> _logger;
@@ -17,7 +18,6 @@ public OwnerModule(ILogger<UtilityModule> logger, IFergunLocalizer<UtilityModule
         _localizer = localizer;
     }
 
-    [RequireOwner]
     [SlashCommand("cmd", "(Owner only) Executes a command.")]
     public async Task<RuntimeResult> CmdAsync([Summary(description: "The command to execute.")] string command, [Summary(description: "No embed.")] bool noEmbed = false)
     {
diff --git a/src/Program.cs b/src/Program.cs
index 830aa0a..bf863f0 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -43,14 +43,15 @@
             GatewayIntents = GatewayIntents.Guilds,
             UseInteractionSnowflakeDate = false,
             LogGatewayIntentWarnings = false,
-            SuppressUnknownDispatchWarnings = true
+            SuppressUnknownDispatchWarnings = true,
+            FormatUsersInBidirectionalUnicode = false
         };
 
         config.Token = context.Configuration.Get<FergunConfig>().Token;
     })
     .UseInteractionService((_, config) =>
     {
-        config.LogLevel = LogSeverity.Verbose;
+        config.LogLevel = LogSeverity.Critical;
         config.DefaultRunMode = RunMode.Async;
         config.UseCompiledLambda = false;
     })
@@ -60,7 +61,7 @@
         config.MinimumLevel.Debug()
             .Filter.ByExcluding(e => e.Level == LogEventLevel.Debug && Matching.FromSource("Discord.WebSocket.DiscordShardedClient").Invoke(e) && e.MessageTemplate.Render(e.Properties).ContainsAny("Connected to", "Disconnected from"))
             .Filter.ByExcluding(e => e.Level <= LogEventLevel.Debug && (Matching.FromSource("Microsoft.Extensions.Http").Invoke(e) || Matching.FromSource("Microsoft.Extensions.Localization").Invoke(e)))
-            .Filter.ByExcluding(e => e.Level <= LogEventLevel.Information && Matching.FromSource("Microsoft.EntityFrameworkCore").Invoke(e))
+            //.Filter.ByExcluding(e => e.Level <= LogEventLevel.Information && Matching.FromSource("Microsoft.EntityFrameworkCore").Invoke(e))
             .WriteTo.Console(LogEventLevel.Debug, theme: AnsiConsoleTheme.Literate)
             .WriteTo.Async(logger => logger.File($"{context.HostingEnvironment.ContentRootPath}logs/log-.txt", LogEventLevel.Debug, rollingInterval: RollingInterval.Day));
     })
diff --git a/src/Resources/Modules.BlacklistModule.es.resx b/src/Resources/Modules.BlacklistModule.es.resx
new file mode 100644
index 0000000..44627a7
--- /dev/null
+++ b/src/Resources/Modules.BlacklistModule.es.resx
@@ -0,0 +1,132 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+  <!-- 
+    Microsoft ResX Schema 
+    
+    Version 2.0
+    
+    The primary goals of this format is to allow a simple XML format 
+    that is mostly human readable. The generation and parsing of the 
+    various data types are done through the TypeConverter classes 
+    associated with the data types.
+    
+    Example:
+    
+    ... ado.net/XML headers & schema ...
+    <resheader name="resmimetype">text/microsoft-resx</resheader>
+    <resheader name="version">2.0</resheader>
+    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+        <value>[base64 mime encoded serialized .NET Framework object]</value>
+    </data>
+    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+        <comment>This is a comment</comment>
+    </data>
+                
+    There are any number of "resheader" rows that contain simple 
+    name/value pairs.
+    
+    Each data row contains a name, and value. The row also contains a 
+    type or mimetype. Type corresponds to a .NET class that support 
+    text/value conversion through the TypeConverter architecture. 
+    Classes that don't support this are serialized and stored with the 
+    mimetype set.
+    
+    The mimetype is used for serialized objects, and tells the 
+    ResXResourceReader how to depersist the object. This is currently not 
+    extensible. For a given mimetype the value must be set accordingly:
+    
+    Note - application/x-microsoft.net.object.binary.base64 is the format 
+    that the ResXResourceWriter will generate, however the reader can 
+    read any of the formats listed below.
+    
+    mimetype: application/x-microsoft.net.object.binary.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+            : and then encoded with base64 encoding.
+    
+    mimetype: application/x-microsoft.net.object.soap.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+            : and then encoded with base64 encoding.
+
+    mimetype: application/x-microsoft.net.object.bytearray.base64
+    value   : The object must be serialized into a byte array 
+            : using a System.ComponentModel.TypeConverter
+            : and then encoded with base64 encoding.
+    -->
+  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+    <xsd:element name="root" msdata:IsDataSet="true">
+      <xsd:complexType>
+        <xsd:choice maxOccurs="unbounded">
+          <xsd:element name="metadata">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" />
+              </xsd:sequence>
+              <xsd:attribute name="name" use="required" type="xsd:string" />
+              <xsd:attribute name="type" type="xsd:string" />
+              <xsd:attribute name="mimetype" type="xsd:string" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="assembly">
+            <xsd:complexType>
+              <xsd:attribute name="alias" type="xsd:string" />
+              <xsd:attribute name="name" type="xsd:string" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="data">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="resheader">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" />
+            </xsd:complexType>
+          </xsd:element>
+        </xsd:choice>
+      </xsd:complexType>
+    </xsd:element>
+  </xsd:schema>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>2.0</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <data name="{0} has been blacklisted" xml:space="preserve">
+    <value>{0} ahora está en la lista negra.</value>
+  </data>
+  <data name="{0} has been removed from the blacklist." xml:space="preserve">
+    <value>{0} ya no está en la lista negra.</value>
+  </data>
+  <data name="{0} is already blacklisted." xml:space="preserve">
+    <value>{0} ya está en la lista negra.</value>
+  </data>
+  <data name="{0} is not blacklisted." xml:space="preserve">
+    <value>{0} no está en la lista negra.</value>
+  </data>
+</root>
\ No newline at end of file
diff --git a/src/Resources/SharedResource.es.resx b/src/Resources/SharedResource.es.resx
index 335d50e..ec154f1 100644
--- a/src/Resources/SharedResource.es.resx
+++ b/src/Resources/SharedResource.es.resx
@@ -204,4 +204,10 @@
   <data name="Unknown format. Try using JPEG, PNG, or BMP files." xml:space="preserve">
     <value>Formato desconocido. Intenta usar archivos JPEG, PNG o BMP.</value>
   </data>
+  <data name="You're blacklisted with reason: {0}" xml:space="preserve">
+    <value>Estás en la lista negra con el motivo: {0}</value>
+  </data>
+  <data name="You're blacklisted." xml:space="preserve">
+    <value>Estás en la lista negra.</value>
+  </data>
 </root>
\ No newline at end of file
diff --git a/src/Services/InteractionHandlingService.cs b/src/Services/InteractionHandlingService.cs
index 5e165da..a691591 100644
--- a/src/Services/InteractionHandlingService.cs
+++ b/src/Services/InteractionHandlingService.cs
@@ -4,8 +4,11 @@
 using Discord.Interactions;
 using Discord.WebSocket;
 using Fergun.Converters;
+using Fergun.Data;
+using Fergun.Data.Models;
 using Fergun.Extensions;
 using GTranslate;
+using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
@@ -34,15 +37,16 @@ public InteractionHandlingService(DiscordShardedClient client, InteractionServic
     /// <inheritdoc />
     public async Task StartAsync(CancellationToken cancellationToken)
     {
-        _interactionService.Log += LogInteraction;
         _interactionService.SlashCommandExecuted += SlashCommandExecuted;
-        _interactionService.ContextCommandExecuted += ContextMenuCommandExecuted;
-        _shardedClient.InteractionCreated += HandleInteractionAsync;
+        _interactionService.ContextCommandExecuted += ContextCommandExecuted;
+        _interactionService.AutocompleteHandlerExecuted += AutocompleteHandlerExecuted;
+        _shardedClient.InteractionCreated += InteractionCreated;
 
         _interactionService.AddTypeConverter<System.Drawing.Color>(new ColorConverter());
         _interactionService.AddTypeConverter<MicrosoftVoice>(new MicrosoftVoiceConverter());
-        var modules = await _interactionService.AddModulesAsync(Assembly.GetEntryAssembly(), _services);
-        _logger.LogDebug("Added {moduleCount} command modules", modules.Count());
+        var modules = (await _interactionService.AddModulesAsync(Assembly.GetEntryAssembly(), _services)).ToArray();
+        _logger.LogDebug("Added {Modules} command modules ({Commands} commands)", modules.Length,
+            modules.Sum(x => x.ContextCommands.Count) + modules.Sum(x => x.SlashCommands.Count));
 
         _shardedClient.ShardReady += ReadyAsync;
     }
@@ -50,8 +54,7 @@ public async Task StartAsync(CancellationToken cancellationToken)
     /// <inheritdoc />
     public Task StopAsync(CancellationToken cancellationToken)
     {
-        _interactionService.Log -= LogInteraction;
-        _shardedClient.InteractionCreated -= HandleInteractionAsync;
+        _shardedClient.InteractionCreated -= InteractionCreated;
         _shardedClient.ShardReady -= ReadyAsync;
 
         return Task.CompletedTask;
@@ -80,62 +83,174 @@ public async Task ReadyAsync()
         }
     }
 
-    public async Task HandleInteractionAsync(SocketInteraction interaction)
+    private Task InteractionCreated(SocketInteraction interaction)
     {
+        _ = Task.Run(async () =>
+        {
+            try
+            {
+                await HandleInteractionAsync(interaction);
+            }
+            catch (Exception e)
+            {
+                _logger.LogWarning(e, "The interaction handler has thrown an exception.");
+            }
+        });
+
+        return Task.CompletedTask;
+    }
+
+    private async Task HandleInteractionAsync(SocketInteraction interaction)
+    {
+        var db = _services.GetRequiredService<FergunContext>();
+        
+        var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(x => x.Id == interaction.User.Id);
+        
+        switch (user?.BlacklistStatus)
+        {
+            case BlacklistStatus.Blacklisted when interaction.Type is InteractionType.ApplicationCommand or InteractionType.MessageComponent:
+            {
+                _logger.LogInformation("Blacklisted user {User} ({UserId}) tried to execute an interaction.", interaction.User, interaction.User.Id);
+                
+                var localizer = _services.GetRequiredService<IFergunLocalizer<SharedResource>>();
+                localizer.CurrentCulture = CultureInfo.GetCultureInfo(interaction.GetLanguageCode());
+
+                string description = string.IsNullOrWhiteSpace(user.BlacklistReason)
+                    ? localizer["You're blacklisted."]
+                    : localizer["You're blacklisted with reason: {0}", user.BlacklistReason];
+                    
+                var builder = new EmbedBuilder()
+                    .WithDescription($"❌ {description}")
+                    .WithColor(Color.Orange);
+
+                await interaction.RespondAsync(embed: builder.Build(), ephemeral: true);
+
+                return;
+            }
+            case BlacklistStatus.ShadowBlacklisted:
+                _logger.LogInformation("Shadow-blacklisted user {User} ({UserId}) tried to execute an interaction.", interaction.User, interaction.User.Id);
+                return;
+        }
+
         var context = new ShardedInteractionContext(_shardedClient, interaction);
         await _interactionService.ExecuteCommandAsync(context, _services);
     }
 
-    private async Task SlashCommandExecuted(SlashCommandInfo slashCommand, IInteractionContext context, IResult result)
+    private Task SlashCommandExecuted(SlashCommandInfo slashCommand, IInteractionContext context, IResult result)
     {
-        _logger.LogInformation("Executed slash command \"{name}\" for {username}#{discriminator} ({id}) in {context}",
-            slashCommand.Name, context.User.Username, context.User.Discriminator, context.User.Id, context.Display());
+        _ = Task.Run(async () =>
+        {
+            try
+            {
+                await HandleCommandExecutedAsync(slashCommand, context, result);
+            }
+            catch (Exception e)
+            {
+                _logger.LogWarning(e, "The command-executed handler has thrown an exception.");
+            }
+        });
 
-        if (result.IsSuccess)
-            return;
+        return Task.CompletedTask;
+    }
 
-        await HandleInteractionErrorAsync(context, result);
+    private Task ContextCommandExecuted(ContextCommandInfo contextCommand, IInteractionContext context, IResult result)
+    {
+        _ = Task.Run(async () =>
+        {
+            try
+            {
+                await HandleCommandExecutedAsync(contextCommand, context, result);
+            }
+            catch (Exception e)
+            {
+                _logger.LogWarning(e, "The command-executed handler has thrown an exception.");
+            }
+        });
+        
+        return Task.CompletedTask;
     }
 
-    private async Task ContextMenuCommandExecuted(ContextCommandInfo contextCommand, IInteractionContext context, IResult result)
+    private Task AutocompleteHandlerExecuted(IAutocompleteHandler autocompleteCommand, IInteractionContext context, IResult result)
     {
-        _logger.LogInformation("Executed context menu command \"{name}\" for {username}#{discriminator} ({id}) in {context}",
-            contextCommand.Name, context.User.Username, context.User.Discriminator, context.User.Id, context.Display());
+        var interaction = (IAutocompleteInteraction)context.Interaction;
+        var subCommands = string.Join(" ", interaction.Data.Options
+            .Where(x => x.Type == ApplicationCommandOptionType.SubCommand).Select(x => x.Name));
 
-        if (result.IsSuccess || result is FergunResult { IsSilent: true })
-            return;
+        if (!string.IsNullOrEmpty(subCommands))
+        {
+            subCommands = $" {subCommands}";
+        }
+
+        string name = $"{interaction.Data.CommandName}{subCommands} ({interaction.Data.Current.Name})";
+
+        if (result.IsSuccess)
+        {
+            _logger.LogDebug("Executed Autocomplete handler of command \"{Name}\" for {User} ({Id}) in {Context}",
+                name, context.User, context.User.Id, context.Display());
+        }
+        else if (result.Error == InteractionCommandError.Exception)
+        {
+            var exception = ((ExecuteResult)result).Exception;
 
-        await HandleInteractionErrorAsync(context, result);
+            _logger.LogError(exception, "Failed to execute Autocomplete handler of command \"{Name}\" for {User} ({Id}) in {Context} due to an exception.",
+                name, context.User, context.User.Id, context.Display());
+        }
+        else
+        {
+            _logger.LogWarning("Failed to execute Autocomplete handler of command  \"{Name}\" for {User} ({Id}) in {Context}. Reason: {Reason}",
+                name, context.User, context.User.Id, context.Display(), result.ErrorReason);
+        }
+        
+
+        return Task.CompletedTask;
     }
 
-    private async Task HandleInteractionErrorAsync(IInteractionContext context, IResult result)
+    private async Task HandleCommandExecutedAsync(IApplicationCommandInfo command, IInteractionContext context, IResult result)
     {
+        if (result.IsSuccess)
+        {
+            _logger.LogInformation("Executed {Type} Command \"{Command}\" for {User} ({Id}) in {Context}",
+                command.CommandType, command, context.User, context.User.Id, context.Display());
+
+            return;
+        }
+        
+        if (result is FergunResult { IsSilent: true })
+            return;
+
         string message = result.ErrorReason;
         bool ephemeral = (result as FergunResult)?.IsEphemeral ?? true;
         var interaction = (result as FergunResult)?.Interaction ?? context.Interaction;
 
         if (result.Error == InteractionCommandError.Exception)
         {
+            var exception = ((ExecuteResult)result).Exception;
+
+            _logger.LogError(exception, "Failed to execute {Type} Command \"{Command}\" for {User} ({Id}) in {Context} due to an exception.",
+                command.CommandType, command, context.User, context.User.Id, context.Display());
+
             var localizer = _services.GetRequiredService<IFergunLocalizer<SharedResource>>();
             localizer.CurrentCulture = CultureInfo.GetCultureInfo(context.Interaction.GetLanguageCode());
-            message = $"{localizer["An error occurred."]}\n\n{localizer["Error message: {0}", $"```{((ExecuteResult)result).Exception.Message}```"]}";
+            message = $"{localizer["An error occurred."]}\n\n{localizer["Error message: {0}", $"```{exception.Message}```"]}";
         }
+        else
+        {
+            _logger.LogWarning("Failed to execute {Type} Command \"{Command}\" for {User} ({Id}) in {Context}. Reason: {Reason}",
+                command.CommandType, command, context.User, context.User.Id, context.Display(), message);
+        }
+
+        var embed = new EmbedBuilder()
+            .WithDescription($"âš  {message}")
+            .WithColor(Color.Orange)
+            .Build();
 
         if (context.Interaction.HasResponded)
         {
-            await interaction.FollowupWarning(message, ephemeral);
+            await interaction.FollowupAsync(embed: embed, ephemeral: ephemeral);
         }
         else
         {
-            await interaction.RespondWarningAsync(message, ephemeral);
+            await interaction.RespondAsync(embed: embed, ephemeral: ephemeral);
         }
     }
-
-    private Task LogInteraction(LogMessage log)
-    {
-#pragma warning disable CA2254 // Template should be a static expression
-        _logger.Log(log.Severity.ToLogLevel(), new EventId(0, log.Source), log.Exception, log.Message);
-#pragma warning restore CA2254 // Template should be a static expression
-        return Task.CompletedTask;
-    }
 }
\ No newline at end of file
diff --git a/tests/Fergun.Tests/Extensions/InteractionExtensionsTests.cs b/tests/Fergun.Tests/Extensions/InteractionExtensionsTests.cs
index bba970c..176bfbb 100644
--- a/tests/Fergun.Tests/Extensions/InteractionExtensionsTests.cs
+++ b/tests/Fergun.Tests/Extensions/InteractionExtensionsTests.cs
@@ -1,7 +1,6 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
-using System.Threading.Tasks;
 using Bogus;
 using Discord;
 using Fergun.Extensions;
@@ -13,26 +12,6 @@ namespace Fergun.Tests.Extensions;
 
 public class InteractionExtensionsTests
 {
-    [Fact]
-    public async Task Interaction_RespondWarningAsync_Should_Call_RespondAsync_Once()
-    {
-        var interactionMock = new Mock<IDiscordInteraction>();
-        await interactionMock.Object.RespondWarningAsync(It.IsAny<string>(), It.IsAny<bool>());
-
-        interactionMock.Verify(x => x.RespondAsync(It.IsAny<string>(), It.IsAny<Embed[]>(),
-            It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
-    }
-
-    [Fact]
-    public async Task Interaction_FollowupWarningAsync_Should_Call_FollowupAsync_Once()
-    {
-        var interactionMock = new Mock<IDiscordInteraction>();
-        await interactionMock.Object.FollowupWarning(It.IsAny<string>(), It.IsAny<bool>());
-
-        interactionMock.Verify(x => x.FollowupAsync(It.IsAny<string>(), It.IsAny<Embed[]>(),
-            It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<AllowedMentions>(), It.IsAny<MessageComponent>(), It.IsAny<Embed>(), It.IsAny<RequestOptions>()), Times.Once);
-    }
-
     [Theory]
     [MemberData(nameof(GetLocales))]
     [MemberData(nameof(GetRandomStrings))]

From 6fe2749501cf89000da06d08756c69a4d9038ea1 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Tue, 10 May 2022 02:35:44 -0500
Subject: [PATCH 70/83] Filter EF Core logs

---
 src/Program.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Program.cs b/src/Program.cs
index bf863f0..c6db069 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -61,7 +61,7 @@
         config.MinimumLevel.Debug()
             .Filter.ByExcluding(e => e.Level == LogEventLevel.Debug && Matching.FromSource("Discord.WebSocket.DiscordShardedClient").Invoke(e) && e.MessageTemplate.Render(e.Properties).ContainsAny("Connected to", "Disconnected from"))
             .Filter.ByExcluding(e => e.Level <= LogEventLevel.Debug && (Matching.FromSource("Microsoft.Extensions.Http").Invoke(e) || Matching.FromSource("Microsoft.Extensions.Localization").Invoke(e)))
-            //.Filter.ByExcluding(e => e.Level <= LogEventLevel.Information && Matching.FromSource("Microsoft.EntityFrameworkCore").Invoke(e))
+            .Filter.ByExcluding(e => e.Level <= LogEventLevel.Information && Matching.FromSource("Microsoft.EntityFrameworkCore").Invoke(e))
             .WriteTo.Console(LogEventLevel.Debug, theme: AnsiConsoleTheme.Literate)
             .WriteTo.Async(logger => logger.File($"{context.HostingEnvironment.ContentRootPath}logs/log-.txt", LogEventLevel.Debug, rollingInterval: RollingInterval.Day));
     })

From 0028fe0b3b9f47d2e5d510b6917fded54ee9f3a2 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Thu, 12 May 2022 02:10:26 -0500
Subject: [PATCH 71/83] Add /cmdstats and command stats tracking

---
 src/Modules/OtherModule.cs                 | 45 +++++++++++++++++++++-
 src/Resources/Modules.OtherModule.es.resx  |  6 +++
 src/Services/InteractionHandlingService.cs | 28 +++++++++++++-
 3 files changed, 77 insertions(+), 2 deletions(-)

diff --git a/src/Modules/OtherModule.cs b/src/Modules/OtherModule.cs
index c5350df..8fc7598 100644
--- a/src/Modules/OtherModule.cs
+++ b/src/Modules/OtherModule.cs
@@ -6,12 +6,14 @@
 using Discord.Interactions;
 using Discord.WebSocket;
 using Fergun.Apis.Genius;
+using Fergun.Data;
 using Fergun.Extensions;
 using Fergun.Interactive;
 using Fergun.Interactive.Pagination;
 using Fergun.Modules.Handlers;
 using Fergun.Utils;
 using Humanizer;
+using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Options;
 
@@ -25,9 +27,10 @@ public class OtherModule : InteractionModuleBase
     private readonly InteractiveService _interactive;
     private readonly IGeniusClient _geniusClient;
     private readonly HttpClient _httpClient;
+    private readonly FergunContext _db;
 
     public OtherModule(ILogger<OtherModule> logger, IFergunLocalizer<OtherModule> localizer, IOptionsSnapshot<InteractiveOptions> interactiveOptions,
-        InteractiveService interactive, IGeniusClient geniusClient, HttpClient httpClient)
+        InteractiveService interactive, IGeniusClient geniusClient, HttpClient httpClient, FergunContext db)
     {
         _logger = logger;
         _localizer = localizer;
@@ -35,10 +38,50 @@ public OtherModule(ILogger<OtherModule> logger, IFergunLocalizer<OtherModule> lo
         _geniusClient = geniusClient;
         _httpClient = httpClient;
         _interactive = interactive;
+        _db = db;
     }
 
     public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode());
 
+    [SlashCommand("cmdstats", "Displays the command usage stats.")]
+    public async Task<RuntimeResult> CommandStatsAsync()
+    {
+        await Context.Interaction.DeferAsync();
+
+        var commandStats = (await _db.CommandStats
+            .AsNoTracking()
+            .OrderByDescending(x => x.UsageCount)
+            .ToListAsync())
+            .Chunk(25)
+            .ToArray();
+
+        if (commandStats.Length == 0)
+        {
+            return FergunResult.FromError(_localizer["No stats to display."]);
+        }
+
+        var paginator = new LazyPaginatorBuilder()
+            .AddUser(Context.User)
+            .WithPageFactory(GeneratePage)
+            .WithActionOnCancellation(ActionOnStop.DisableInput)
+            .WithActionOnTimeout(ActionOnStop.DisableInput)
+            .WithMaxPageIndex(commandStats.Length - 1)
+            .WithFooter(PaginatorFooter.None)
+            .WithFergunEmotes(_interactiveOptions)
+            .WithLocalizedPrompts(_localizer)
+            .Build();
+
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _interactiveOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource);
+
+        return FergunResult.FromSuccess();
+
+        PageBuilder GeneratePage(int index) => new PageBuilder()
+                .WithTitle(_localizer["Command Stats"])
+                .WithDescription(string.Join('\n', commandStats[index].Select((x, i) => $"{index * 25 + i + 1}. `{x.Name}`: {x.UsageCount}")))
+                .WithFooter(_localizer["Page {0} of {1}", index + 1, commandStats.Length])
+                .WithColor(Color.Orange);
+    }
+
     [SlashCommand("inspirobot", "Sends an inspirational quote.")]
     public async Task<RuntimeResult> InspiroBotAsync()
     {
diff --git a/src/Resources/Modules.OtherModule.es.resx b/src/Resources/Modules.OtherModule.es.resx
index a9d35a1..9256f38 100644
--- a/src/Resources/Modules.OtherModule.es.resx
+++ b/src/Resources/Modules.OtherModule.es.resx
@@ -126,6 +126,9 @@
   <data name="Bot Version" xml:space="preserve">
     <value>Versión del bot</value>
   </data>
+  <data name="Command Stats" xml:space="preserve">
+    <value>Estadísticas de Comandos</value>
+  </data>
   <data name="CPU Usage" xml:space="preserve">
     <value>Uso de CPU</value>
   </data>
@@ -138,6 +141,9 @@
   <data name="Lyrics by Genius | Page {0} of {1}" xml:space="preserve">
     <value>Letra por Genius | Página {0} de {1}</value>
   </data>
+  <data name="No stats to display." xml:space="preserve">
+    <value>Sin estadísticas que mostrar.</value>
+  </data>
   <data name="Operating System" xml:space="preserve">
     <value>Sistema Operativo</value>
   </data>
diff --git a/src/Services/InteractionHandlingService.cs b/src/Services/InteractionHandlingService.cs
index a691591..ff88840 100644
--- a/src/Services/InteractionHandlingService.cs
+++ b/src/Services/InteractionHandlingService.cs
@@ -23,6 +23,7 @@ public class InteractionHandlingService : IHostedService
     private readonly ILogger<InteractionHandlingService> _logger;
     private readonly IServiceProvider _services;
     private readonly ulong _targetGuildId;
+    private readonly SemaphoreSlim _cmdStatsSemaphore = new SemaphoreSlim(1, 1);
 
     public InteractionHandlingService(DiscordShardedClient client, InteractionService interactionService,
         ILogger<InteractionHandlingService> logger, IServiceProvider services, IConfiguration configuration)
@@ -102,7 +103,8 @@ private Task InteractionCreated(SocketInteraction interaction)
 
     private async Task HandleInteractionAsync(SocketInteraction interaction)
     {
-        var db = _services.GetRequiredService<FergunContext>();
+        await using var scope = _services.CreateAsyncScope();
+        var db = scope.ServiceProvider.GetRequiredService<FergunContext>();
         
         var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(x => x.Id == interaction.User.Id);
         
@@ -207,6 +209,30 @@ private Task AutocompleteHandlerExecuted(IAutocompleteHandler autocompleteComman
 
     private async Task HandleCommandExecutedAsync(IApplicationCommandInfo command, IInteractionContext context, IResult result)
     {
+        string commandName = command.ToString()!.ToLowerInvariant();
+        await _cmdStatsSemaphore.WaitAsync();
+
+        try
+        {
+            await using var scope = _services.CreateAsyncScope();
+            var db = scope.ServiceProvider.GetRequiredService<FergunContext>();
+            var dbCommand = await db.CommandStats.FirstOrDefaultAsync(x => x.Name == commandName);
+
+            if (dbCommand is null)
+            {
+                dbCommand = new Command { Name = commandName };
+                await db.AddAsync(dbCommand);
+            }
+
+            dbCommand.UsageCount++;
+
+            await db.SaveChangesAsync();
+        }
+        finally
+        {
+            _cmdStatsSemaphore.Release();
+        }
+        
         if (result.IsSuccess)
         {
             _logger.LogInformation("Executed {Type} Command \"{Command}\" for {User} ({Id}) in {Context}",

From 450b9214e0afa9aa90d1c1963605ac189fee7bfd Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Fri, 13 May 2022 17:16:09 -0500
Subject: [PATCH 72/83] Add current option to autocomplete suggestions where
 applicable

---
 src/Extensions/Extensions.cs                          | 4 ++++
 src/Modules/Handlers/BraveAutocompleteHandler.cs      | 2 ++
 src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs | 1 +
 src/Modules/Handlers/GoogleAutocompleteHandler.cs     | 1 +
 src/Modules/Handlers/UrbanAutocompleteHandler.cs      | 4 +++-
 src/Modules/Handlers/WikipediaAutocompleteHandler.cs  | 3 ++-
 src/Modules/Handlers/YouTubeAutocompleteHandler.cs    | 1 +
 7 files changed, 14 insertions(+), 2 deletions(-)

diff --git a/src/Extensions/Extensions.cs b/src/Extensions/Extensions.cs
index b8a316d..124e29b 100644
--- a/src/Extensions/Extensions.cs
+++ b/src/Extensions/Extensions.cs
@@ -61,4 +61,8 @@ public static string Display(this IInteractionContext context)
 
         return displayMessage;
     }
+
+    public static IEnumerable<AutocompleteResult> PrependCurrentIfNotPresent(this IEnumerable<AutocompleteResult> source, string option)
+        => source.Any(x => string.Equals(x.Name, option, StringComparison.OrdinalIgnoreCase))
+            ? source : source.Prepend(new AutocompleteResult { Name = option, Value = option });
 }
\ No newline at end of file
diff --git a/src/Modules/Handlers/BraveAutocompleteHandler.cs b/src/Modules/Handlers/BraveAutocompleteHandler.cs
index fb1da1d..13e7130 100644
--- a/src/Modules/Handlers/BraveAutocompleteHandler.cs
+++ b/src/Modules/Handlers/BraveAutocompleteHandler.cs
@@ -1,6 +1,7 @@
 using System.Text.Json;
 using Discord;
 using Discord.Interactions;
+using Fergun.Extensions;
 using Microsoft.Extensions.DependencyInjection;
 using Polly;
 using Polly.Registry;
@@ -38,6 +39,7 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             .RootElement[1]
             .EnumerateArray()
             .Select(x => new AutocompleteResult(x.GetString(), x.GetString()))
+            .PrependCurrentIfNotPresent(text)
             .Take(25);
 
         return AutocompletionResult.FromSuccess(results);
diff --git a/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs b/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs
index e119e0c..26b040f 100644
--- a/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs
+++ b/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs
@@ -49,6 +49,7 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             .RootElement
             .EnumerateArray()
             .Select(x => new AutocompleteResult(x.GetProperty("phrase").GetString(), x.GetProperty("phrase").GetString()))
+            .PrependCurrentIfNotPresent(text)
             .Take(25);
 
         return AutocompletionResult.FromSuccess(results);
diff --git a/src/Modules/Handlers/GoogleAutocompleteHandler.cs b/src/Modules/Handlers/GoogleAutocompleteHandler.cs
index 65759cb..9422e1e 100644
--- a/src/Modules/Handlers/GoogleAutocompleteHandler.cs
+++ b/src/Modules/Handlers/GoogleAutocompleteHandler.cs
@@ -40,6 +40,7 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             .RootElement[1]
             .EnumerateArray()
             .Select(x => new AutocompleteResult(x.GetString(), x.GetString()))
+            .PrependCurrentIfNotPresent(text)
             .Take(25);
 
         return AutocompletionResult.FromSuccess(results);
diff --git a/src/Modules/Handlers/UrbanAutocompleteHandler.cs b/src/Modules/Handlers/UrbanAutocompleteHandler.cs
index 626ab96..9c69912 100644
--- a/src/Modules/Handlers/UrbanAutocompleteHandler.cs
+++ b/src/Modules/Handlers/UrbanAutocompleteHandler.cs
@@ -1,6 +1,7 @@
 using Discord;
 using Discord.Interactions;
 using Fergun.Apis.Urban;
+using Fergun.Extensions;
 using Humanizer;
 using Microsoft.Extensions.DependencyInjection;
 
@@ -21,7 +22,8 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             .GetRequiredService<IUrbanDictionary>();
 
         var results = (await urbanDictionary.GetAutocompleteResultsAsync(text))
-            .Select(x => new AutocompleteResult(x, x))
+            .Select(x => new AutocompleteResult(x.Truncate(100), x.Truncate(100)))
+            .PrependCurrentIfNotPresent(text)
             .Take(25);
 
         return AutocompletionResult.FromSuccess(results);
diff --git a/src/Modules/Handlers/WikipediaAutocompleteHandler.cs b/src/Modules/Handlers/WikipediaAutocompleteHandler.cs
index 11b22e8..a21279a 100644
--- a/src/Modules/Handlers/WikipediaAutocompleteHandler.cs
+++ b/src/Modules/Handlers/WikipediaAutocompleteHandler.cs
@@ -22,7 +22,8 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             .GetRequiredService<IWikipediaClient>();
 
         var results = (await urbanDictionary.GetAutocompleteResultsAsync(text, autocompleteInteraction.GetLanguageCode()))
-            .Select(x => new AutocompleteResult(x, x))
+            .Select(x => new AutocompleteResult(x.Truncate(100), x.Truncate(100)))
+            .PrependCurrentIfNotPresent(text)
             .Take(25);
 
         return AutocompletionResult.FromSuccess(results);
diff --git a/src/Modules/Handlers/YouTubeAutocompleteHandler.cs b/src/Modules/Handlers/YouTubeAutocompleteHandler.cs
index d17faea..0248be0 100644
--- a/src/Modules/Handlers/YouTubeAutocompleteHandler.cs
+++ b/src/Modules/Handlers/YouTubeAutocompleteHandler.cs
@@ -41,6 +41,7 @@ public override async Task<AutocompletionResult> GenerateSuggestionsAsync(IInter
             .RootElement[1]
             .EnumerateArray()
             .Select(x => new AutocompleteResult(x[0].GetString(), x[0].GetString()))
+            .PrependCurrentIfNotPresent(text)
             .Take(25);
 
         return AutocompletionResult.FromSuccess(results);

From 8df82a0786454ff8d7133fe6cfddd29a855760fe Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Fri, 13 May 2022 18:25:16 -0500
Subject: [PATCH 73/83] Downgrade Discord.Net.Interactions

---
 src/Fergun.csproj | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Fergun.csproj b/src/Fergun.csproj
index 4808013..5991c7c 100644
--- a/src/Fergun.csproj
+++ b/src/Fergun.csproj
@@ -10,7 +10,7 @@
 
   <ItemGroup>
     <PackageReference Include="Discord.Addons.Hosting" Version="5.1.0" />
-    <PackageReference Include="Discord.Net.Interactions" Version="3.6.1" />
+    <PackageReference Include="Discord.Net.Interactions" Version="3.5.0" />
     <PackageReference Include="Fergun.Interactive" Version="1.5.4" />
     <PackageReference Include="GScraper" Version="1.0.2" />
     <PackageReference Include="GTranslate" Version="2.1.0" />

From 1962c0299168b7a0c5d4fc06dd4af1d36e827aa8 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 14 May 2022 02:59:11 -0500
Subject: [PATCH 74/83] Update appsettings.json and register owner commands to
 config-specific guild

---
 src/Entities/FergunConfig.cs               | 17 -------
 src/Entities/FergunOptions.cs              | 24 ++++++++++
 src/Modules/OwnerModule.cs                 |  2 +-
 src/Program.cs                             | 16 ++++---
 src/Services/InteractionHandlingService.cs | 52 +++++++++++++++++-----
 src/appsettings.json                       |  8 +++-
 6 files changed, 83 insertions(+), 36 deletions(-)
 delete mode 100644 src/Entities/FergunConfig.cs
 create mode 100644 src/Entities/FergunOptions.cs

diff --git a/src/Entities/FergunConfig.cs b/src/Entities/FergunConfig.cs
deleted file mode 100644
index 8eb7efa..0000000
--- a/src/Entities/FergunConfig.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace Fergun;
-
-/// <summary>
-/// Represents the configuration of Fergun.
-/// </summary>
-public class FergunConfig
-{
-    /// <summary>
-    /// Gets or sets the token of the bot.
-    /// </summary>
-    public string Token { get; set; } = string.Empty;
-
-    /// <summary>
-    /// Gets or sets the ID of the guild to register the guild commands.
-    /// </summary>
-    public ulong TargetGuildId { get; set; }
-}
\ No newline at end of file
diff --git a/src/Entities/FergunOptions.cs b/src/Entities/FergunOptions.cs
new file mode 100644
index 0000000..bf031bf
--- /dev/null
+++ b/src/Entities/FergunOptions.cs
@@ -0,0 +1,24 @@
+namespace Fergun;
+
+/// <summary>
+/// Represents the settings related to Fergun.
+/// </summary>
+public class FergunOptions
+{
+    public const string Fergun = nameof(Fergun);
+
+    /// <summary>
+    /// Gets or sets the token of the bot.
+    /// </summary>
+    public string Token { get; set; } = string.Empty;
+
+    /// <summary>
+    /// Gets or sets the ID of the guild to register the commands for testing.
+    /// </summary>
+    public ulong TestingGuildId { get; set; }
+
+    /// <summary>
+    /// Gets or sets the ID of the guild to register owner commands.
+    /// </summary>
+    public ulong OwnerCommandsGuildId { get; set; }
+}
\ No newline at end of file
diff --git a/src/Modules/OwnerModule.cs b/src/Modules/OwnerModule.cs
index a09323d..8a208a8 100644
--- a/src/Modules/OwnerModule.cs
+++ b/src/Modules/OwnerModule.cs
@@ -18,7 +18,7 @@ public OwnerModule(ILogger<UtilityModule> logger, IFergunLocalizer<UtilityModule
         _localizer = localizer;
     }
 
-    [SlashCommand("cmd", "(Owner only) Executes a command.")]
+    [SlashCommand("cmd", "Executes a command.")]
     public async Task<RuntimeResult> CmdAsync([Summary(description: "The command to execute.")] string command, [Summary(description: "No embed.")] bool noEmbed = false)
     {
         await Context.Interaction.DeferAsync();
diff --git a/src/Program.cs b/src/Program.cs
index c6db069..daf9576 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -35,6 +35,13 @@
 var host = Host.CreateDefaultBuilder()
     .UseConsoleLifetime()
     .UseContentRoot(AppDomain.CurrentDomain.BaseDirectory)
+    .ConfigureServices((context, services) =>
+    {
+        services.Configure<FergunOptions>(context.Configuration.GetSection(FergunOptions.Fergun));
+        services.Configure<BotListOptions>(context.Configuration.GetSection(BotListOptions.BotList));
+        services.Configure<InteractiveOptions>(context.Configuration.GetSection(InteractiveOptions.Interactive));
+        services.AddSqlite<FergunContext>(context.Configuration.GetConnectionString("FergunDatabase"));
+    })
     .ConfigureDiscordShardedHost((context, config) =>
     {
         config.SocketConfig = new DiscordSocketConfig
@@ -47,7 +54,7 @@
             FormatUsersInBidirectionalUnicode = false
         };
 
-        config.Token = context.Configuration.Get<FergunConfig>().Token;
+        config.Token = context.Configuration.GetSection(FergunOptions.Fergun).Get<FergunOptions>().Token;
     })
     .UseInteractionService((_, config) =>
     {
@@ -65,7 +72,7 @@
             .WriteTo.Console(LogEventLevel.Debug, theme: AnsiConsoleTheme.Literate)
             .WriteTo.Async(logger => logger.File($"{context.HostingEnvironment.ContentRootPath}logs/log-.txt", LogEventLevel.Debug, rollingInterval: RollingInterval.Day));
     })
-    .ConfigureServices((context, services) =>
+    .ConfigureServices(services =>
     {
         services.AddLocalization(options => options.ResourcesPath = "Resources");
         services.AddTransient(typeof(IFergunLocalizer<>), typeof(FergunLocalizer<>));
@@ -74,10 +81,7 @@
         services.AddSingleton(new InteractiveConfig { ReturnAfterSendingPaginator = true, DeferStopSelectionInteractions = false });
         services.AddSingleton<InteractiveService>();
         services.AddFergunPolicies();
-        services.Configure<BotListOptions>(context.Configuration.GetSection(BotListOptions.BotList));
-        services.Configure<InteractiveOptions>(context.Configuration.GetSection(InteractiveOptions.Interactive));
-        services.AddSqlite<FergunContext>(context.Configuration.GetConnectionString("FergunDatabase"));
-        
+
         services.AddHttpClient<IBingVisualSearch, BingVisualSearch>()
             .SetHandlerLifetime(TimeSpan.FromMinutes(30))
             .AddRetryPolicy();
diff --git a/src/Services/InteractionHandlingService.cs b/src/Services/InteractionHandlingService.cs
index ff88840..0163ce3 100644
--- a/src/Services/InteractionHandlingService.cs
+++ b/src/Services/InteractionHandlingService.cs
@@ -7,12 +7,13 @@
 using Fergun.Data;
 using Fergun.Data.Models;
 using Fergun.Extensions;
+using Fergun.Modules;
 using GTranslate;
 using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
 
 namespace Fergun.Services;
 
@@ -22,17 +23,19 @@ public class InteractionHandlingService : IHostedService
     private readonly InteractionService _interactionService;
     private readonly ILogger<InteractionHandlingService> _logger;
     private readonly IServiceProvider _services;
-    private readonly ulong _targetGuildId;
-    private readonly SemaphoreSlim _cmdStatsSemaphore = new SemaphoreSlim(1, 1);
+    private readonly ulong _testingGuildId;
+    private readonly ulong _ownerCommandsGuildId;
+    private readonly SemaphoreSlim _cmdStatsSemaphore = new(1, 1);
 
     public InteractionHandlingService(DiscordShardedClient client, InteractionService interactionService,
-        ILogger<InteractionHandlingService> logger, IServiceProvider services, IConfiguration configuration)
+        ILogger<InteractionHandlingService> logger, IServiceProvider services, IOptions<FergunOptions> options)
     {
         _shardedClient = client;
         _interactionService = interactionService;
         _logger = logger;
         _services = services;
-        _targetGuildId = configuration.Get<FergunConfig>().TargetGuildId;
+        _testingGuildId = options.Value.TestingGuildId;
+        _ownerCommandsGuildId = options.Value.OwnerCommandsGuildId;
     }
 
     /// <inheritdoc />
@@ -45,6 +48,7 @@ public async Task StartAsync(CancellationToken cancellationToken)
 
         _interactionService.AddTypeConverter<System.Drawing.Color>(new ColorConverter());
         _interactionService.AddTypeConverter<MicrosoftVoice>(new MicrosoftVoiceConverter());
+
         var modules = (await _interactionService.AddModulesAsync(Assembly.GetEntryAssembly(), _services)).ToArray();
         _logger.LogDebug("Added {Modules} command modules ({Commands} commands)", modules.Length,
             modules.Sum(x => x.ContextCommands.Count) + modules.Sum(x => x.SlashCommands.Count));
@@ -69,18 +73,46 @@ public async Task ReadyAsync(DiscordSocketClient client)
             await ReadyAsync();
         }
     }
-
+    
     public async Task ReadyAsync()
     {
-        if (_targetGuildId == 0)
+        var modules = _interactionService.Modules.Where(x => x.Name is not nameof(OwnerModule) and not nameof(BlacklistModule));
+        var ownerModules = _interactionService.Modules
+            .Where(x => x.Name is nameof(OwnerModule) or nameof(BlacklistModule))
+            .ToArray();
+
+        if (_testingGuildId == 0)
         {
             _logger.LogInformation("Registering commands globally");
-            await _interactionService.RegisterCommandsGloballyAsync();
+            await _interactionService.AddModulesGloballyAsync(true, modules.ToArray());
+            
+            if (_ownerCommandsGuildId != 0)
+            {
+                _logger.LogInformation("Registering owner commands to guild {GuildId}", _ownerCommandsGuildId);
+                var ownerCommandsGuild = _shardedClient.GetGuild(_ownerCommandsGuildId) ?? (IGuild)await _shardedClient.Rest.GetGuildAsync(_ownerCommandsGuildId);
+                await _interactionService.AddModulesToGuildAsync(ownerCommandsGuild, true, ownerModules);
+            }
         }
         else
         {
-            _logger.LogInformation("Registering commands to guild {guildId}", _targetGuildId);
-            await _interactionService.RegisterCommandsToGuildAsync(_targetGuildId);
+            _logger.LogInformation("Registering commands to guild {GuildId}", _testingGuildId);
+
+            if (_testingGuildId == _ownerCommandsGuildId)
+            {
+                await _interactionService.RegisterCommandsToGuildAsync(_testingGuildId);
+            }
+            else
+            {
+                var testingGuild = _shardedClient.GetGuild(_testingGuildId) ?? (IGuild)await _shardedClient.Rest.GetGuildAsync(_testingGuildId);
+                await _interactionService.AddModulesToGuildAsync(testingGuild, true, modules.ToArray());
+
+                if (_ownerCommandsGuildId != 0)
+                {
+                    _logger.LogInformation("Registering owner commands to guild {GuildId}", _ownerCommandsGuildId);
+                    var ownerCommandsGuild = _shardedClient.GetGuild(_ownerCommandsGuildId) ?? (IGuild)await _shardedClient.Rest.GetGuildAsync(_ownerCommandsGuildId);
+                    await _interactionService.AddModulesToGuildAsync(ownerCommandsGuild, true, ownerModules);
+                }
+            }
         }
     }
 
diff --git a/src/appsettings.json b/src/appsettings.json
index ff958e3..ed03006 100644
--- a/src/appsettings.json
+++ b/src/appsettings.json
@@ -3,8 +3,12 @@
     {
         "FergunDatabase": "Data Source=Fergun.db"
     },
-    "TargetGuildId": 0,
-    "Token": "",
+    "Fergun":
+    {
+        "Token": "",
+        "TestingGuildId": 0,
+        "OwnerCommandsGuildId": 0
+    },
     "Interactive": 
     {
         "PaginatorTimeout": "00:10:00",

From 90073ae154056c01db74c56c556be57b95c7f3ce Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 14 May 2022 14:49:42 -0500
Subject: [PATCH 75/83] Fix tests

---
 .../Fergun.Tests/Modules/ImageModuleTests.cs  |  3 +--
 .../Fergun.Tests/Modules/UrbanModuleTests.cs  |  3 +--
 tests/Fergun.Tests/Utils.cs                   | 22 +++++++++++++++++++
 3 files changed, 24 insertions(+), 4 deletions(-)

diff --git a/tests/Fergun.Tests/Modules/ImageModuleTests.cs b/tests/Fergun.Tests/Modules/ImageModuleTests.cs
index c5bad55..1e530a1 100644
--- a/tests/Fergun.Tests/Modules/ImageModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/ImageModuleTests.cs
@@ -12,7 +12,6 @@
 using GScraper.DuckDuckGo;
 using GScraper.Google;
 using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
 using Moq;
 using Xunit;
 
@@ -35,7 +34,7 @@ public class ImageModuleTests
     public ImageModuleTests()
     {
         var logger = Mock.Of<ILogger<ImageModule>>();
-        var options = Mock.Of<IOptionsSnapshot<InteractiveOptions>>();
+        var options = Utils.CreateMockedInteractiveOptions();
         var interactive = new InteractiveService(_client, new InteractiveConfig { DeferStopSelectionInteractions = false, ReturnAfterSendingPaginator = true });
         _moduleMock = new Mock<ImageModule>(() => new ImageModule(logger, _localizer, options, interactive, _googleScraper,
             _duckDuckGoScraper, _braveScraper, _bingVisualSearch, _yandexImageSearch)) { CallBase = true };
diff --git a/tests/Fergun.Tests/Modules/UrbanModuleTests.cs b/tests/Fergun.Tests/Modules/UrbanModuleTests.cs
index e921adc..377da9c 100644
--- a/tests/Fergun.Tests/Modules/UrbanModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/UrbanModuleTests.cs
@@ -10,7 +10,6 @@
 using Fergun.Apis.Urban;
 using Fergun.Interactive;
 using Fergun.Modules;
-using Microsoft.Extensions.Options;
 using Moq;
 using Xunit;
 
@@ -28,7 +27,7 @@ public class UrbanModuleTests
 
     public UrbanModuleTests()
     {
-        var options = Mock.Of<IOptionsSnapshot<InteractiveOptions>>();
+        var options = Utils.CreateMockedInteractiveOptions();
         var interactive = new InteractiveService(_client, _interactiveConfig);
 
         _moduleMock = new Mock<UrbanModule>(() => new UrbanModule(_localizer, options, _urbanDictionary, interactive)) { CallBase = true };
diff --git a/tests/Fergun.Tests/Utils.cs b/tests/Fergun.Tests/Utils.cs
index b02ab31..c68c372 100644
--- a/tests/Fergun.Tests/Utils.cs
+++ b/tests/Fergun.Tests/Utils.cs
@@ -1,4 +1,5 @@
 using System;
+using System.Collections.Generic;
 using System.Globalization;
 using System.Linq;
 using System.Reflection;
@@ -8,7 +9,9 @@
 using Fergun.Apis.Bing;
 using Fergun.Apis.Urban;
 using Fergun.Apis.Yandex;
+using Fergun.Interactive.Pagination;
 using Microsoft.Extensions.Localization;
+using Microsoft.Extensions.Options;
 using Moq;
 
 namespace Fergun.Tests;
@@ -207,4 +210,23 @@ public static UrbanDefinition CreateFakeUrbanDefinition()
             .RuleFor(x => x.Example, f => f.Lorem.Sentence())
             .Generate();
     }
+
+    public static IOptionsSnapshot<InteractiveOptions> CreateMockedInteractiveOptions()
+    {
+        var mock = new Mock<IOptionsSnapshot<InteractiveOptions>>();
+        var faker = new Faker<InteractiveOptions>()
+            .RuleFor(x => x.PaginatorTimeout, f => f.Date.Timespan())
+            .RuleFor(x => x.SelectionTimeout, f => f.Date.Timespan())
+            .RuleFor(x => x.PaginatorEmotes, f => new Dictionary<PaginatorAction, string>
+            {
+                { PaginatorAction.Backward, "◀️" }, // Valid emoji
+                { PaginatorAction.Forward, $"<:forward:{f.Random.ULong()}>" }, // Valid emote
+                { PaginatorAction.Jump, "123" }, // Invalid emote
+                { PaginatorAction.Exit, "🛑" }
+            });
+
+        mock.Setup(x => x.Value).Returns(() => faker.Generate());
+
+        return mock.Object;
+    }
 }
\ No newline at end of file

From 039dfe1dfd4eaed7deff57e92373830263141125 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sat, 14 May 2022 17:46:34 -0500
Subject: [PATCH 76/83] Add SupportServerUrl to config

---
 src/Entities/FergunOptions.cs               |  5 +++++
 src/Modules/UtilityModule.cs                | 25 ++++++++++++++++-----
 src/Resources/Modules.UtilityModule.es.resx | 13 +++++++----
 src/Resources/Modules.UtilityModule.resx    | 10 +++++----
 src/appsettings.json                        |  3 ++-
 5 files changed, 42 insertions(+), 14 deletions(-)

diff --git a/src/Entities/FergunOptions.cs b/src/Entities/FergunOptions.cs
index bf031bf..97ce56d 100644
--- a/src/Entities/FergunOptions.cs
+++ b/src/Entities/FergunOptions.cs
@@ -21,4 +21,9 @@ public class FergunOptions
     /// Gets or sets the ID of the guild to register owner commands.
     /// </summary>
     public ulong OwnerCommandsGuildId { get; set; }
+
+    /// <summary>
+    /// Gets or sets the support server URL.
+    /// </summary>
+    public Uri? SupportServerUrl { get; set; }
 }
\ No newline at end of file
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index d3dddf7..4caccad 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -27,6 +27,7 @@ public class UtilityModule : InteractionModuleBase
 {
     private readonly ILogger<UtilityModule> _logger;
     private readonly IFergunLocalizer<UtilityModule> _localizer;
+    private readonly FergunOptions _fergunOptions;
     private readonly InteractiveOptions _interactiveOptions;
     private readonly SharedModule _shared;
     private readonly InteractiveService _interactive;
@@ -41,11 +42,13 @@ public class UtilityModule : InteractionModuleBase
         .Where(x => x.SupportedServices == (TranslationServices.Google | TranslationServices.Bing | TranslationServices.Yandex | TranslationServices.Microsoft))
         .ToArray());
 
-    public UtilityModule(ILogger<UtilityModule> logger, IFergunLocalizer<UtilityModule> localizer, IOptionsSnapshot<InteractiveOptions> interactiveOptions, 
-        SharedModule shared, InteractiveService interactive, IFergunTranslator translator, SearchClient searchClient, IWikipediaClient wikipediaClient)
+    public UtilityModule(ILogger<UtilityModule> logger, IFergunLocalizer<UtilityModule> localizer, IOptionsSnapshot<FergunOptions> fergunOptions,
+        IOptionsSnapshot<InteractiveOptions> interactiveOptions, SharedModule shared, InteractiveService interactive,
+        IFergunTranslator translator, SearchClient searchClient, IWikipediaClient wikipediaClient)
     {
         _logger = logger;
         _localizer = localizer;
+        _fergunOptions = fergunOptions.Value;
         _interactiveOptions = interactiveOptions.Value;
         _shared = shared;
         _interactive = interactive;
@@ -223,16 +226,28 @@ public async Task<RuntimeResult> ColorAsync([Summary(description: "A color name,
         return FergunResult.FromSuccess();
     }
 
-    [SlashCommand("help", "Information about Fergun 2")]
+    [SlashCommand("help", "Information about Fergun 2.")]
     public async Task<RuntimeResult> HelpAsync()
     {
+        MessageComponent? components = null;
+        string description = _localizer["Fergun2Info", "https://github.com/d4n3436/Fergun/wiki/Command-removal-notice"];
+        var url = _fergunOptions.SupportServerUrl;
+
+        if (url is not null && (url.Scheme == Uri.UriSchemeHttp || url.Scheme == Uri.UriSchemeHttps))
+        {
+            description += $"\n\n{_localizer["Fergun2SupportInfo"]}";
+            components = new ComponentBuilder()
+                .WithButton(_localizer["Support Server"], style: ButtonStyle.Link, url: url.AbsoluteUri)
+                .Build();
+        }
+
         var embed = new EmbedBuilder()
             .WithTitle("Fergun 2")
-            .WithDescription(_localizer["Fergun2Info", "https://github.com/d4n3436/Fergun/wiki/Command-removal-notice"])
+            .WithDescription(description)
             .WithColor(Color.Orange)
             .Build();
 
-        await RespondAsync(embed: embed);
+        await Context.Interaction.RespondAsync(embed: embed, components: components);
 
         return FergunResult.FromSuccess();
     }
diff --git a/src/Resources/Modules.UtilityModule.es.resx b/src/Resources/Modules.UtilityModule.es.resx
index e749c9a..2852b93 100644
--- a/src/Resources/Modules.UtilityModule.es.resx
+++ b/src/Resources/Modules.UtilityModule.es.resx
@@ -121,11 +121,10 @@
     <value>Salida de comando</value>
   </data>
   <data name="Fergun2Info" xml:space="preserve">
-    <value>Esto es Fergun 2. Fergun 2 es una reescritura completa de Fergun 1 y solo tendrá slash commands.
-Fergun 2 esta en estado alfa y sólo los comandos más usados están presentes, pero más comandos serán agregados pronto.
-Fergun 2 será termindo en Mayo 2022 e incluirá nuevas funcionalidades y comandos.
+    <value>Bienvenido(a) a **Fergun** 2, una reescritura completa de Fergun 1 que usa slash commands.
+Fergun 2 se encuentra actualmente en beta abierta. Los comandos más utilizados se han reescrito para usar slash commands y más comandos serán agregados pronto.
 
-Algunos módulos y comandos estan actualmente en modo de mantenimiento en Fergun 1 y no se migrarán a Fergun 2. Estos comandos son:
+Los siguientes módulos y comandos no se migrarán a Fergun 2 debido a varias razones:
 - Módulo de **AI Dungeon**
 - Módulo de **Música**
 - Comandos de **snipe**
@@ -183,4 +182,10 @@ Puedes encontrar más información sobre la eliminación de estos módulos/coman
   <data name="{0} doesn't have a server avatar." xml:space="preserve">
     <value>{0} no tiene un avatar de servidor.</value>
   </data>
+  <data name="Fergun2SupportInfo" xml:space="preserve">
+    <value>Si tiene alguna pregunta sobre la reescritura o tiene alguna sugerencia, no dude en unirse al servidor de soporte.</value>
+  </data>
+  <data name="Support Server" xml:space="preserve">
+    <value>Servidor de soporte</value>
+  </data>
 </root>
\ No newline at end of file
diff --git a/src/Resources/Modules.UtilityModule.resx b/src/Resources/Modules.UtilityModule.resx
index b531106..751872e 100644
--- a/src/Resources/Modules.UtilityModule.resx
+++ b/src/Resources/Modules.UtilityModule.resx
@@ -118,15 +118,17 @@
     <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
   </resheader>
   <data name="Fergun2Info" xml:space="preserve">
-    <value>This is Fergun 2. Fergun 2 is a complete rewrite of Fergun 1 and it will only have slash commands.
-Fergun 2 is in alpha stages and only the most used commands are present, but more commands will be added soon.
-Fergun 2 will be finished in May 2022 and it will include new features and commands.
+    <value>Welcome to **Fergun** 2, a complete rewrite of Fergun 1 that uses slash commands.
+Fergun 2 is currently in open beta. The most used commands have been rewritten to use slash commands and more commands will be added soon.
 
-Some modules and commands are currently in maintenance mode in Fergun 1 and they won't be migrated to Fergun 2. These modules are:
+The following modules and commands won't be migrated to Fergun 2 due to various reasons:
 - **AI Dungeon** module
 - **Music** module
 - **Snipe** commands
 
 You can find more info about the removals of these modules/commands [here]({0}).</value>
   </data>
+  <data name="Fergun2SupportInfo" xml:space="preserve">
+    <value>If you have any questions about the rewrite or have any suggestions, feel free to join the support server.</value>
+  </data>
 </root>
\ No newline at end of file
diff --git a/src/appsettings.json b/src/appsettings.json
index ed03006..3f0560a 100644
--- a/src/appsettings.json
+++ b/src/appsettings.json
@@ -7,7 +7,8 @@
     {
         "Token": "",
         "TestingGuildId": 0,
-        "OwnerCommandsGuildId": 0
+        "OwnerCommandsGuildId": 0,
+        "SupportServerUrl": null
     },
     "Interactive": 
     {

From e3f821ad902322469d2a095f9f82979c0e0ad927 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 15 May 2022 01:09:37 -0500
Subject: [PATCH 77/83] Change `BotList.UpdatePeriodInMinutes` format

---
 src/Entities/BotListOptions.cs | 4 ++--
 src/Services/BotListService.cs | 6 +++---
 src/appsettings.json           | 2 +-
 3 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/src/Entities/BotListOptions.cs b/src/Entities/BotListOptions.cs
index 8b36cde..f8cd455 100644
--- a/src/Entities/BotListOptions.cs
+++ b/src/Entities/BotListOptions.cs
@@ -8,9 +8,9 @@ public class BotListOptions
     public const string BotList = nameof(BotList);
 
     /// <summary>
-    /// Gets or sets the update period in minutes.
+    /// Gets or sets the update period.
     /// </summary>
-    public int UpdatePeriodInMinutes { get; set; } = 30;
+    public TimeSpan UpdatePeriod { get; set; }
 
     /// <summary>
     /// Gets or sets the dictionary of tokens.
diff --git a/src/Services/BotListService.cs b/src/Services/BotListService.cs
index d44c157..fb320ba 100644
--- a/src/Services/BotListService.cs
+++ b/src/Services/BotListService.cs
@@ -49,10 +49,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
             return;
         }
         
-        _logger.LogInformation("Bot list service started. Updating stats for {BotLists} every {UpdatePeriod} minute(s).",
-            string.Join(", ", _options.Tokens.Keys), _options.UpdatePeriodInMinutes);
+        _logger.LogInformation("Bot list service started. Updating stats for {BotLists} every {UpdatePeriod}.",
+            string.Join(", ", _options.Tokens.Keys), _options.UpdatePeriod.ToString("h'h 'm'm 's's'"));
 
-        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(_options.UpdatePeriodInMinutes));
+        using var timer = new PeriodicTimer(_options.UpdatePeriod);
         while (await timer.WaitForNextTickAsync(stoppingToken))
         {
             await UpdateStatsAsync();
diff --git a/src/appsettings.json b/src/appsettings.json
index 3f0560a..8bd5b13 100644
--- a/src/appsettings.json
+++ b/src/appsettings.json
@@ -24,7 +24,7 @@
     },
     "BotList":
     {
-        "UpdatePeriodInMinutes": 30,
+        "UpdatePeriod": "00:30:00",
         "Tokens":
         {
             "TopGg": "",

From 86c7305a8e01bb4e244646c689c66482c3984eee Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Sun, 15 May 2022 01:43:01 -0500
Subject: [PATCH 78/83] Update appsettings.json schema

---
 src/Entities/FergunOptions.cs                 | 22 ++++++++-------
 src/Entities/InteractiveOptions.cs            | 27 -------------------
 src/Entities/StartupOptions.cs                | 24 +++++++++++++++++
 src/Extensions/PaginatorExtensions.cs         |  2 +-
 src/Modules/ImageModule.cs                    | 26 +++++++++---------
 src/Modules/OtherModule.cs                    | 12 ++++-----
 src/Modules/UrbanModule.cs                    |  8 +++---
 src/Modules/UtilityModule.cs                  |  9 +++----
 src/Program.cs                                |  6 ++---
 src/Services/InteractionHandlingService.cs    |  2 +-
 src/appsettings.json                          |  8 +++---
 .../Fergun.Tests/Modules/ImageModuleTests.cs  |  2 +-
 .../Fergun.Tests/Modules/UrbanModuleTests.cs  |  2 +-
 .../Modules/UtilityModuleTests.cs             |  3 +--
 tests/Fergun.Tests/Utils.cs                   |  6 ++---
 15 files changed, 77 insertions(+), 82 deletions(-)
 delete mode 100644 src/Entities/InteractiveOptions.cs
 create mode 100644 src/Entities/StartupOptions.cs

diff --git a/src/Entities/FergunOptions.cs b/src/Entities/FergunOptions.cs
index 97ce56d..e98a616 100644
--- a/src/Entities/FergunOptions.cs
+++ b/src/Entities/FergunOptions.cs
@@ -1,29 +1,31 @@
-namespace Fergun;
+using Fergun.Interactive.Pagination;
+
+namespace Fergun;
 
 /// <summary>
-/// Represents the settings related to Fergun.
+/// Represents general Fergun settings.
 /// </summary>
 public class FergunOptions
 {
     public const string Fergun = nameof(Fergun);
 
     /// <summary>
-    /// Gets or sets the token of the bot.
+    /// Gets or sets the support server URL.
     /// </summary>
-    public string Token { get; set; } = string.Empty;
+    public Uri? SupportServerUrl { get; set; }
 
     /// <summary>
-    /// Gets or sets the ID of the guild to register the commands for testing.
+    /// Gets or sets the default paginator timeout.
     /// </summary>
-    public ulong TestingGuildId { get; set; }
+    public TimeSpan PaginatorTimeout { get; set; }
 
     /// <summary>
-    /// Gets or sets the ID of the guild to register owner commands.
+    /// Gets or sets the default selection timeout.
     /// </summary>
-    public ulong OwnerCommandsGuildId { get; set; }
+    public TimeSpan SelectionTimeout { get; set; }
 
     /// <summary>
-    /// Gets or sets the support server URL.
+    /// Gets or sets the dictionary of paginator emotes.
     /// </summary>
-    public Uri? SupportServerUrl { get; set; }
+    public IDictionary<PaginatorAction, string> PaginatorEmotes { get; set; } = new Dictionary<PaginatorAction, string>();
 }
\ No newline at end of file
diff --git a/src/Entities/InteractiveOptions.cs b/src/Entities/InteractiveOptions.cs
deleted file mode 100644
index 3891ab7..0000000
--- a/src/Entities/InteractiveOptions.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-using Fergun.Interactive;
-using Fergun.Interactive.Pagination;
-
-namespace Fergun;
-
-/// <summary>
-/// Represents the settings related to <see cref="InteractiveService"/>.
-/// </summary>
-public class InteractiveOptions
-{
-    public const string Interactive = nameof(Interactive);
-    
-    /// <summary>
-    /// Gets or sets the default paginator timeout.
-    /// </summary>
-    public TimeSpan PaginatorTimeout { get; set; }
-
-    /// <summary>
-    /// Gets or sets the default selection timeout.
-    /// </summary>
-    public TimeSpan SelectionTimeout { get; set; }
-
-    /// <summary>
-    /// Gets or sets the dictionary of paginator emotes.
-    /// </summary>
-    public IDictionary<PaginatorAction, string> PaginatorEmotes { get; set; } = new Dictionary<PaginatorAction, string>();
-}
\ No newline at end of file
diff --git a/src/Entities/StartupOptions.cs b/src/Entities/StartupOptions.cs
new file mode 100644
index 0000000..f7ba1a9
--- /dev/null
+++ b/src/Entities/StartupOptions.cs
@@ -0,0 +1,24 @@
+namespace Fergun;
+
+/// <summary>
+/// Represents startup settings.
+/// </summary>
+public class StartupOptions
+{
+    public const string Startup = nameof(Startup);
+
+    /// <summary>
+    /// Gets or sets the token of the bot.
+    /// </summary>
+    public string Token { get; set; } = string.Empty;
+
+    /// <summary>
+    /// Gets or sets the ID of the guild to register the commands for testing.
+    /// </summary>
+    public ulong TestingGuildId { get; set; }
+
+    /// <summary>
+    /// Gets or sets the ID of the guild to register owner commands.
+    /// </summary>
+    public ulong OwnerCommandsGuildId { get; set; }
+}
\ No newline at end of file
diff --git a/src/Extensions/PaginatorExtensions.cs b/src/Extensions/PaginatorExtensions.cs
index a6ffb17..81080aa 100644
--- a/src/Extensions/PaginatorExtensions.cs
+++ b/src/Extensions/PaginatorExtensions.cs
@@ -26,7 +26,7 @@ public static class PaginatorExtensions
     /// <param name="builder">A paginator builder.</param>
     /// <param name="options">The interactive options.</param>
     /// <returns>This builder.</returns>
-    public static TBuilder WithFergunEmotes<TPaginator, TBuilder>(this PaginatorBuilder<TPaginator, TBuilder> builder, InteractiveOptions options)
+    public static TBuilder WithFergunEmotes<TPaginator, TBuilder>(this PaginatorBuilder<TPaginator, TBuilder> builder, FergunOptions options)
         where TPaginator : Paginator
         where TBuilder : PaginatorBuilder<TPaginator, TBuilder>
     {
diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs
index f483199..b73b536 100644
--- a/src/Modules/ImageModule.cs
+++ b/src/Modules/ImageModule.cs
@@ -22,7 +22,7 @@ public class ImageModule : InteractionModuleBase
 {
     private readonly ILogger<ImageModule> _logger;
     private readonly IFergunLocalizer<ImageModule> _localizer;
-    private readonly InteractiveOptions _interactiveOptions;
+    private readonly FergunOptions _fergunOptions;
     private readonly InteractiveService _interactive;
     private readonly GoogleScraper _googleScraper;
     private readonly DuckDuckGoScraper _duckDuckGoScraper;
@@ -30,13 +30,13 @@ public class ImageModule : InteractionModuleBase
     private readonly IBingVisualSearch _bingVisualSearch;
     private readonly IYandexImageSearch _yandexImageSearch;
 
-    public ImageModule(ILogger<ImageModule> logger, IFergunLocalizer<ImageModule> localizer, IOptionsSnapshot<InteractiveOptions> interactiveOptions,
+    public ImageModule(ILogger<ImageModule> logger, IFergunLocalizer<ImageModule> localizer, IOptionsSnapshot<FergunOptions> fergunOptions,
         InteractiveService interactive, GoogleScraper googleScraper, DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper,
         IBingVisualSearch bingVisualSearch, IYandexImageSearch yandexImageSearch)
     {
         _logger = logger;
         _localizer = localizer;
-        _interactiveOptions = interactiveOptions.Value;
+        _fergunOptions = fergunOptions.Value;
         _interactive = interactive;
         _googleScraper = googleScraper;
         _duckDuckGoScraper = duckDuckGoScraper;
@@ -70,7 +70,7 @@ public async Task<RuntimeResult> GoogleAsync([Autocomplete(typeof(GoogleAutocomp
 
         var paginator = new LazyPaginatorBuilder()
             .WithPageFactory(GeneratePage)
-            .WithFergunEmotes(_interactiveOptions)
+            .WithFergunEmotes(_fergunOptions)
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(images.Length - 1)
@@ -79,7 +79,7 @@ public async Task<RuntimeResult> GoogleAsync([Autocomplete(typeof(GoogleAutocomp
             .WithLocalizedPrompts(_localizer)
             .Build();
 
-        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _interactiveOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource);
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _fergunOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource);
 
         return FergunResult.FromSuccess();
 
@@ -119,7 +119,7 @@ public async Task<RuntimeResult> DuckDuckGoAsync([Autocomplete(typeof(DuckDuckGo
 
         var paginator = new LazyPaginatorBuilder()
             .WithPageFactory(GeneratePage)
-            .WithFergunEmotes(_interactiveOptions)
+            .WithFergunEmotes(_fergunOptions)
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(images.Length - 1)
@@ -128,7 +128,7 @@ public async Task<RuntimeResult> DuckDuckGoAsync([Autocomplete(typeof(DuckDuckGo
             .WithLocalizedPrompts(_localizer)
             .Build();
 
-        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _interactiveOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource);
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _fergunOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource);
 
         return FergunResult.FromSuccess();
 
@@ -168,7 +168,7 @@ public async Task<RuntimeResult> BraveAsync([Autocomplete(typeof(BraveAutocomple
 
         var paginator = new LazyPaginatorBuilder()
             .WithPageFactory(GeneratePage)
-            .WithFergunEmotes(_interactiveOptions)
+            .WithFergunEmotes(_fergunOptions)
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(images.Length - 1)
@@ -177,7 +177,7 @@ public async Task<RuntimeResult> BraveAsync([Autocomplete(typeof(BraveAutocomple
             .WithLocalizedPrompts(_localizer)
             .Build();
 
-        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _interactiveOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource);
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _fergunOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource);
 
         return FergunResult.FromSuccess();
 
@@ -291,7 +291,7 @@ public virtual async Task<RuntimeResult> YandexAsync(string url, bool multiImage
 
         var paginator = new LazyPaginatorBuilder()
             .WithPageFactory(GeneratePage)
-            .WithFergunEmotes(_interactiveOptions)
+            .WithFergunEmotes(_fergunOptions)
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(results.Length - 1)
@@ -300,7 +300,7 @@ public virtual async Task<RuntimeResult> YandexAsync(string url, bool multiImage
             .WithLocalizedPrompts(_localizer)
             .Build();
 
-        await _interactive.SendPaginatorAsync(paginator, interaction, _interactiveOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
+        await _interactive.SendPaginatorAsync(paginator, interaction, _fergunOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
 
         return FergunResult.FromSuccess();
 
@@ -352,7 +352,7 @@ public virtual async Task<RuntimeResult> BingAsync(string url, bool multiImages,
 
         var paginator = new LazyPaginatorBuilder()
             .WithPageFactory(GeneratePage)
-            .WithFergunEmotes(_interactiveOptions)
+            .WithFergunEmotes(_fergunOptions)
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(results.Length - 1)
@@ -361,7 +361,7 @@ public virtual async Task<RuntimeResult> BingAsync(string url, bool multiImages,
             .WithLocalizedPrompts(_localizer)
             .Build();
 
-        await _interactive.SendPaginatorAsync(paginator, interaction, _interactiveOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
+        await _interactive.SendPaginatorAsync(paginator, interaction, _fergunOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource, ephemeral);
 
         return FergunResult.FromSuccess();
 
diff --git a/src/Modules/OtherModule.cs b/src/Modules/OtherModule.cs
index 8fc7598..6fc811b 100644
--- a/src/Modules/OtherModule.cs
+++ b/src/Modules/OtherModule.cs
@@ -23,18 +23,18 @@ public class OtherModule : InteractionModuleBase
 {
     private readonly ILogger<OtherModule> _logger;
     private readonly IFergunLocalizer<OtherModule> _localizer;
-    private readonly InteractiveOptions _interactiveOptions;
+    private readonly FergunOptions _fergunOptions;
     private readonly InteractiveService _interactive;
     private readonly IGeniusClient _geniusClient;
     private readonly HttpClient _httpClient;
     private readonly FergunContext _db;
 
-    public OtherModule(ILogger<OtherModule> logger, IFergunLocalizer<OtherModule> localizer, IOptionsSnapshot<InteractiveOptions> interactiveOptions,
+    public OtherModule(ILogger<OtherModule> logger, IFergunLocalizer<OtherModule> localizer, IOptionsSnapshot<FergunOptions> fergunOptions,
         InteractiveService interactive, IGeniusClient geniusClient, HttpClient httpClient, FergunContext db)
     {
         _logger = logger;
         _localizer = localizer;
-        _interactiveOptions = interactiveOptions.Value;
+        _fergunOptions = fergunOptions.Value;
         _geniusClient = geniusClient;
         _httpClient = httpClient;
         _interactive = interactive;
@@ -67,11 +67,11 @@ public async Task<RuntimeResult> CommandStatsAsync()
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(commandStats.Length - 1)
             .WithFooter(PaginatorFooter.None)
-            .WithFergunEmotes(_interactiveOptions)
+            .WithFergunEmotes(_fergunOptions)
             .WithLocalizedPrompts(_localizer)
             .Build();
 
-        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _interactiveOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource);
+        await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _fergunOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource);
 
         return FergunResult.FromSuccess();
 
@@ -130,7 +130,7 @@ public async Task<RuntimeResult> LyricsAsync([Autocomplete(typeof(GeniusAutocomp
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(chunks.Length - 1)
             .WithFooter(PaginatorFooter.None)
-            .WithFergunEmotes(_interactiveOptions)
+            .WithFergunEmotes(_fergunOptions)
             .WithLocalizedPrompts(_localizer)
             .Build();
 
diff --git a/src/Modules/UrbanModule.cs b/src/Modules/UrbanModule.cs
index a9fc740..c241a1b 100644
--- a/src/Modules/UrbanModule.cs
+++ b/src/Modules/UrbanModule.cs
@@ -15,15 +15,15 @@ namespace Fergun.Modules;
 public class UrbanModule : InteractionModuleBase
 {
     private readonly IFergunLocalizer<UrbanModule> _localizer;
-    private readonly InteractiveOptions _interactiveOptions;
+    private readonly FergunOptions _fergunOptions;
     private readonly IUrbanDictionary _urbanDictionary;
     private readonly InteractiveService _interactive;
 
-    public UrbanModule(IFergunLocalizer<UrbanModule> localizer, IOptionsSnapshot<InteractiveOptions> interactiveOptions,
+    public UrbanModule(IFergunLocalizer<UrbanModule> localizer, IOptionsSnapshot<FergunOptions> fergunOptions,
         IUrbanDictionary urbanDictionary, InteractiveService interactive)
     {
         _localizer = localizer;
-        _interactiveOptions = interactiveOptions.Value;
+        _fergunOptions = fergunOptions.Value;
         _urbanDictionary = urbanDictionary;
         _interactive = interactive;
     }
@@ -59,7 +59,7 @@ public async Task<RuntimeResult> SearchAndSendAsync(UrbanSearchType searchType,
 
         var paginator = new LazyPaginatorBuilder()
             .WithPageFactory(GeneratePage)
-            .WithFergunEmotes(_interactiveOptions)
+            .WithFergunEmotes(_fergunOptions)
             .WithActionOnCancellation(ActionOnStop.DisableInput)
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(definitions.Count - 1)
diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs
index 4caccad..9ced794 100644
--- a/src/Modules/UtilityModule.cs
+++ b/src/Modules/UtilityModule.cs
@@ -28,7 +28,6 @@ public class UtilityModule : InteractionModuleBase
     private readonly ILogger<UtilityModule> _logger;
     private readonly IFergunLocalizer<UtilityModule> _localizer;
     private readonly FergunOptions _fergunOptions;
-    private readonly InteractiveOptions _interactiveOptions;
     private readonly SharedModule _shared;
     private readonly InteractiveService _interactive;
     private readonly IFergunTranslator _translator;
@@ -43,13 +42,11 @@ public class UtilityModule : InteractionModuleBase
         .ToArray());
 
     public UtilityModule(ILogger<UtilityModule> logger, IFergunLocalizer<UtilityModule> localizer, IOptionsSnapshot<FergunOptions> fergunOptions,
-        IOptionsSnapshot<InteractiveOptions> interactiveOptions, SharedModule shared, InteractiveService interactive,
-        IFergunTranslator translator, SearchClient searchClient, IWikipediaClient wikipediaClient)
+        SharedModule shared, InteractiveService interactive, IFergunTranslator translator, SearchClient searchClient, IWikipediaClient wikipediaClient)
     {
         _logger = logger;
         _localizer = localizer;
         _fergunOptions = fergunOptions.Value;
-        _interactiveOptions = interactiveOptions.Value;
         _shared = shared;
         _interactive = interactive;
         _translator = translator;
@@ -369,7 +366,7 @@ public async Task<RuntimeResult> WikipediaAsync([Autocomplete(typeof(WikipediaAu
             .WithActionOnTimeout(ActionOnStop.DisableInput)
             .WithMaxPageIndex(articles.Length - 1)
             .WithFooter(PaginatorFooter.None)
-            .WithFergunEmotes(_interactiveOptions)
+            .WithFergunEmotes(_fergunOptions)
             .WithLocalizedPrompts(_localizer)
             .Build();
 
@@ -429,7 +426,7 @@ public async Task<RuntimeResult> YouTubeAsync([Autocomplete(typeof(YouTubeAutoco
                     .WithActionOnTimeout(ActionOnStop.DisableInput)
                     .WithMaxPageIndex(videos.Count - 1)
                     .WithFooter(PaginatorFooter.None)
-                    .WithFergunEmotes(_interactiveOptions)
+                    .WithFergunEmotes(_fergunOptions)
                     .WithLocalizedPrompts(_localizer)
                     .Build();
 
diff --git a/src/Program.cs b/src/Program.cs
index daf9576..3d0d233 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -37,9 +37,9 @@
     .UseContentRoot(AppDomain.CurrentDomain.BaseDirectory)
     .ConfigureServices((context, services) =>
     {
-        services.Configure<FergunOptions>(context.Configuration.GetSection(FergunOptions.Fergun));
+        services.Configure<StartupOptions>(context.Configuration.GetSection(StartupOptions.Startup));
         services.Configure<BotListOptions>(context.Configuration.GetSection(BotListOptions.BotList));
-        services.Configure<InteractiveOptions>(context.Configuration.GetSection(InteractiveOptions.Interactive));
+        services.Configure<FergunOptions>(context.Configuration.GetSection(FergunOptions.Fergun));
         services.AddSqlite<FergunContext>(context.Configuration.GetConnectionString("FergunDatabase"));
     })
     .ConfigureDiscordShardedHost((context, config) =>
@@ -54,7 +54,7 @@
             FormatUsersInBidirectionalUnicode = false
         };
 
-        config.Token = context.Configuration.GetSection(FergunOptions.Fergun).Get<FergunOptions>().Token;
+        config.Token = context.Configuration.GetSection(StartupOptions.Startup).Get<StartupOptions>().Token;
     })
     .UseInteractionService((_, config) =>
     {
diff --git a/src/Services/InteractionHandlingService.cs b/src/Services/InteractionHandlingService.cs
index 0163ce3..a14b974 100644
--- a/src/Services/InteractionHandlingService.cs
+++ b/src/Services/InteractionHandlingService.cs
@@ -28,7 +28,7 @@ public class InteractionHandlingService : IHostedService
     private readonly SemaphoreSlim _cmdStatsSemaphore = new(1, 1);
 
     public InteractionHandlingService(DiscordShardedClient client, InteractionService interactionService,
-        ILogger<InteractionHandlingService> logger, IServiceProvider services, IOptions<FergunOptions> options)
+        ILogger<InteractionHandlingService> logger, IServiceProvider services, IOptions<StartupOptions> options)
     {
         _shardedClient = client;
         _interactionService = interactionService;
diff --git a/src/appsettings.json b/src/appsettings.json
index 8bd5b13..6522f78 100644
--- a/src/appsettings.json
+++ b/src/appsettings.json
@@ -3,15 +3,15 @@
     {
         "FergunDatabase": "Data Source=Fergun.db"
     },
-    "Fergun":
+    "Startup":
     {
         "Token": "",
         "TestingGuildId": 0,
-        "OwnerCommandsGuildId": 0,
-        "SupportServerUrl": null
+        "OwnerCommandsGuildId": 0
     },
-    "Interactive": 
+    "Fergun":
     {
+        "SupportServerUrl": "",
         "PaginatorTimeout": "00:10:00",
         "SelectionTimeout": "00:10:00",
         "PaginatorEmotes":
diff --git a/tests/Fergun.Tests/Modules/ImageModuleTests.cs b/tests/Fergun.Tests/Modules/ImageModuleTests.cs
index 1e530a1..1d7c088 100644
--- a/tests/Fergun.Tests/Modules/ImageModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/ImageModuleTests.cs
@@ -34,7 +34,7 @@ public class ImageModuleTests
     public ImageModuleTests()
     {
         var logger = Mock.Of<ILogger<ImageModule>>();
-        var options = Utils.CreateMockedInteractiveOptions();
+        var options = Utils.CreateMockedFergunOptions();
         var interactive = new InteractiveService(_client, new InteractiveConfig { DeferStopSelectionInteractions = false, ReturnAfterSendingPaginator = true });
         _moduleMock = new Mock<ImageModule>(() => new ImageModule(logger, _localizer, options, interactive, _googleScraper,
             _duckDuckGoScraper, _braveScraper, _bingVisualSearch, _yandexImageSearch)) { CallBase = true };
diff --git a/tests/Fergun.Tests/Modules/UrbanModuleTests.cs b/tests/Fergun.Tests/Modules/UrbanModuleTests.cs
index 377da9c..fa38936 100644
--- a/tests/Fergun.Tests/Modules/UrbanModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/UrbanModuleTests.cs
@@ -27,7 +27,7 @@ public class UrbanModuleTests
 
     public UrbanModuleTests()
     {
-        var options = Utils.CreateMockedInteractiveOptions();
+        var options = Utils.CreateMockedFergunOptions();
         var interactive = new InteractiveService(_client, _interactiveConfig);
 
         _moduleMock = new Mock<UrbanModule>(() => new UrbanModule(_localizer, options, _urbanDictionary, interactive)) { CallBase = true };
diff --git a/tests/Fergun.Tests/Modules/UtilityModuleTests.cs b/tests/Fergun.Tests/Modules/UtilityModuleTests.cs
index 97ce166..de0088e 100644
--- a/tests/Fergun.Tests/Modules/UtilityModuleTests.cs
+++ b/tests/Fergun.Tests/Modules/UtilityModuleTests.cs
@@ -10,7 +10,6 @@
 using Fergun.Modules;
 using GTranslate.Translators;
 using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
 using Moq;
 using Xunit;
 using YoutubeExplode.Search;
@@ -29,7 +28,7 @@ public class UtilityModuleTests
     
     public UtilityModuleTests()
     {
-        var options = Mock.Of<IOptionsSnapshot<InteractiveOptions>>();
+        var options = Utils.CreateMockedFergunOptions();
         SharedModule shared = new(Mock.Of<ILogger<SharedModule>>(), Utils.CreateMockedLocalizer<SharedResource>(), Mock.Of<IFergunTranslator>(), _googleTranslator2);
         var interactive = new InteractiveService(new DiscordSocketClient(), new InteractiveConfig { ReturnAfterSendingPaginator = true });
         _moduleMock = new Mock<UtilityModule>(() => new UtilityModule(Mock.Of<ILogger<UtilityModule>>(), _localizer, options, shared,
diff --git a/tests/Fergun.Tests/Utils.cs b/tests/Fergun.Tests/Utils.cs
index c68c372..6d5e0e0 100644
--- a/tests/Fergun.Tests/Utils.cs
+++ b/tests/Fergun.Tests/Utils.cs
@@ -211,10 +211,10 @@ public static UrbanDefinition CreateFakeUrbanDefinition()
             .Generate();
     }
 
-    public static IOptionsSnapshot<InteractiveOptions> CreateMockedInteractiveOptions()
+    public static IOptionsSnapshot<FergunOptions> CreateMockedFergunOptions()
     {
-        var mock = new Mock<IOptionsSnapshot<InteractiveOptions>>();
-        var faker = new Faker<InteractiveOptions>()
+        var mock = new Mock<IOptionsSnapshot<FergunOptions>>();
+        var faker = new Faker<FergunOptions>()
             .RuleFor(x => x.PaginatorTimeout, f => f.Date.Timespan())
             .RuleFor(x => x.SelectionTimeout, f => f.Date.Timespan())
             .RuleFor(x => x.PaginatorEmotes, f => new Dictionary<PaginatorAction, string>

From 21eb1d23850958e03ff03ced3100a1c8e6a07012 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Mon, 16 May 2022 16:13:49 -0500
Subject: [PATCH 79/83] Add /invite

---
 src/Modules/OtherModule.cs                | 17 +++++++++++++++++
 src/Resources/Modules.OtherModule.es.resx |  6 ++++++
 2 files changed, 23 insertions(+)

diff --git a/src/Modules/OtherModule.cs b/src/Modules/OtherModule.cs
index 6fc811b..acb618c 100644
--- a/src/Modules/OtherModule.cs
+++ b/src/Modules/OtherModule.cs
@@ -98,6 +98,23 @@ public async Task<RuntimeResult> InspiroBotAsync()
 
         return FergunResult.FromSuccess();
     }
+
+    [SlashCommand("invite", "Invite Fergun to your server.")]
+    public async Task<RuntimeResult> InviteAsync()
+    {
+        var builder = new EmbedBuilder()
+            .WithDescription(_localizer["Click the button below to invite Fergun to your server."])
+            .WithColor(Color.Orange);
+
+        ulong applicationId = (await Context.Client.GetApplicationInfoAsync()).Id;
+
+        var button = new ComponentBuilder()
+            .WithButton(_localizer["Invite Fergun"], style: ButtonStyle.Link, url: $"https://discord.com/oauth2/authorize?client_id={applicationId}&scope=bot%20applications.commands");
+
+        await Context.Interaction.RespondAsync(embed: builder.Build(), components: button.Build());
+
+        return FergunResult.FromSuccess();
+    }
     
     [SlashCommand("lyrics", "Gets the lyrics of a song.")]
     public async Task<RuntimeResult> LyricsAsync([Autocomplete(typeof(GeniusAutocompleteHandler))] [Summary(name: "name", description: "The name of the song.")] int id)
diff --git a/src/Resources/Modules.OtherModule.es.resx b/src/Resources/Modules.OtherModule.es.resx
index 9256f38..8f04214 100644
--- a/src/Resources/Modules.OtherModule.es.resx
+++ b/src/Resources/Modules.OtherModule.es.resx
@@ -126,6 +126,9 @@
   <data name="Bot Version" xml:space="preserve">
     <value>Versión del bot</value>
   </data>
+  <data name="Click the button below to invite Fergun to your server." xml:space="preserve">
+    <value>Haga click en el botón de abajo para invitar a Fergun a tu servidor.</value>
+  </data>
   <data name="Command Stats" xml:space="preserve">
     <value>Estadísticas de Comandos</value>
   </data>
@@ -135,6 +138,9 @@
   <data name="Fergun Stats" xml:space="preserve">
     <value>Estadísticas de Fergun</value>
   </data>
+  <data name="Invite Fergun" xml:space="preserve">
+    <value>Invita a Fergun</value>
+  </data>
   <data name="Library" xml:space="preserve">
     <value>Librería</value>
   </data>

From 86c4d20c39d41143b9b9b99dd0476d48bf8caee8 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Tue, 17 May 2022 14:37:29 -0500
Subject: [PATCH 80/83] Update version label to beta

---
 src/Fergun.csproj | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Fergun.csproj b/src/Fergun.csproj
index 5991c7c..f76db50 100644
--- a/src/Fergun.csproj
+++ b/src/Fergun.csproj
@@ -5,7 +5,7 @@
     <TargetFramework>net6.0</TargetFramework>
     <ImplicitUsings>enable</ImplicitUsings>
     <Nullable>enable</Nullable>
-    <Version>2.0-alpha</Version>
+    <Version>2.0-beta</Version>
   </PropertyGroup>
 
   <ItemGroup>

From 0e8a52b45f244515f1333b214d44530c24f53de0 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Tue, 17 May 2022 17:28:57 -0500
Subject: [PATCH 81/83] Update README.md

---
 README.md         | 68 ++++++++++++++++++++++++++++++++++++++++++++++-
 src/Fergun.csproj |  4 ++-
 2 files changed, 70 insertions(+), 2 deletions(-)

diff --git a/README.md b/README.md
index e706c34..a362f36 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,69 @@
 # Fergun
+[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Discord](https://discord.com/api/guilds/460627183501574144/widget.png)](https://discord.gg/V3TgaZRUPX)
 
-Rewrite WIP
\ No newline at end of file
+Fergun is an utility bot/application with lots of useful commands.
+
+You can invite Fergun to your Discord server clicking [here](https://discord.com/oauth2/authorize?client_id=680507783359365121&scope=bot%20applications.commands).
+
+Have any questions or need help with the bot? Join the [support server](https://discord.gg/V3TgaZRUPX).
+
+## Features
+- Translate text from and to more than 140 languages using a robust translation module that is powered by popular translators from Google Translate, Microsoft Translator and Yandex Translate
+- Image search from Google, DuckDuckGo and Brave
+- Reverse image search from Bing and Yandex
+- Perform OCR to images using Bing and Yandex
+- Perform text-to-speech using Google and Microsoft Azure
+- Translate a text multiple times using different translators for bad translations
+- Get inspirational quotes from InspiroBot
+- Search or get random definitions from Urban Dictionary
+- Search and get Wikipedia articles
+- Search and get music lyrics from Genius
+- Search YouTube videos
+- Get user information and server/global/default avatar
+- Generate images of colors
+- 2 supported languages (English and Spanish)
+- Support for localized output (like localized results) in most commands
+- And more coming soon™
+
+## Setup
+
+### 0. Prerequisites
+* A Discord bot application (You can create one [here](https://discord.com/developers/applications)).
+* [.NET 6 SDK](https://dotnet.microsoft.com/download)
+
+### 1. Build and run the bot
+* Clone the repository:
+  `git clone https://github.com/d4n3436/Fergun.git`
+  
+* Build the bot:
+  ```
+  cd Fergun
+  dotnet build -c Release
+  ```
+  
+* Go to the build output folder: 
+  ```
+  cd src/bin/Release/net6.0
+  ```
+  
+* Open `appsettings.json` with a text editor and set the application token:
+  ```json
+  {
+      "Startup":
+      {
+          "Token": "put your token here"
+      }
+  }
+  ```
+  
+* Start the bot by double clicking `Fergun.exe` or with the command `dotnet Fergun.dll`.
+
+  The application should create the SQLite database automatically and start the bot.
+
+* To start using Fergun, simply type `/` in a server with the bot and use its commands.
+
+## Contributing
+Feel free to report bugs or request new features via issues or pull requests. Requesting new commands may or may not be accepted depending on the utility and usability of that command.
+
+## License
+Fergun is licensed under the [MIT license](LICENSE).
\ No newline at end of file
diff --git a/src/Fergun.csproj b/src/Fergun.csproj
index f76db50..e5d8862 100644
--- a/src/Fergun.csproj
+++ b/src/Fergun.csproj
@@ -34,7 +34,9 @@
   </ItemGroup>
 
   <ItemGroup>
-    <Folder Include="Data\Models\" />
+    <None Update="appsettings.json">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
 </Project>

From 75ac2784594514d338f28fc3fbca068d2b0b8383 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Wed, 18 May 2022 18:27:17 -0500
Subject: [PATCH 82/83] Add mobile patcher

---
 src/Entities/MobilePatcher.cs  | 46 ++++++++++++++++++++++++++++++++++
 src/Entities/StartupOptions.cs |  5 ++++
 src/Fergun.csproj              |  3 ++-
 src/Program.cs                 |  5 ++++
 src/appsettings.json           |  3 ++-
 5 files changed, 60 insertions(+), 2 deletions(-)
 create mode 100644 src/Entities/MobilePatcher.cs

diff --git a/src/Entities/MobilePatcher.cs b/src/Entities/MobilePatcher.cs
new file mode 100644
index 0000000..43af602
--- /dev/null
+++ b/src/Entities/MobilePatcher.cs
@@ -0,0 +1,46 @@
+using System.Reflection;
+using Discord.WebSocket;
+using HarmonyLib;
+
+namespace Fergun;
+
+/// <summary>
+/// Represents the mobile patcher.
+/// </summary>
+public static class MobilePatcher
+{
+    /// <summary>
+    /// Patches Discord.Net to display the mobile status.
+    /// </summary>
+    public static void Patch()
+    {
+        var harmony = new Harmony(nameof(MobilePatcher));
+
+        var original = AccessTools.Method("Discord.API.DiscordSocketApiClient:SendGatewayAsync");
+        var prefix = typeof(MobilePatcher).GetMethod(nameof(Prefix));
+
+        harmony.Patch(original, new HarmonyMethod(prefix));
+    }
+
+    private static readonly Type _identifyParams =
+        typeof(BaseSocketClient).Assembly.GetType("Discord.API.Gateway.IdentifyParams", true)!;
+
+    private static readonly PropertyInfo? _property = _identifyParams.GetProperty("Properties");
+
+    public static void Prefix(in byte opCode, in object payload)
+    {
+        if (opCode != 2) // Identify
+            return;
+
+        if (payload.GetType() != _identifyParams)
+            return;
+
+        if (_property?.GetValue(payload) is not IDictionary<string, string> props
+            || !props.TryGetValue("$device", out string? device)
+            || device != "Discord.Net")
+            return;
+
+        props["$os"] = "android";
+        props["$browser"] = "Discord Android";
+    }
+}
\ No newline at end of file
diff --git a/src/Entities/StartupOptions.cs b/src/Entities/StartupOptions.cs
index f7ba1a9..2c70926 100644
--- a/src/Entities/StartupOptions.cs
+++ b/src/Entities/StartupOptions.cs
@@ -21,4 +21,9 @@ public class StartupOptions
     /// Gets or sets the ID of the guild to register owner commands.
     /// </summary>
     public ulong OwnerCommandsGuildId { get; set; }
+
+    /// <summary>
+    /// Gets or sets a value indicating whether the mobile status should be used.
+    /// </summary>
+    public bool MobileStatus { get; set; }
 }
\ No newline at end of file
diff --git a/src/Fergun.csproj b/src/Fergun.csproj
index e5d8862..9545145 100644
--- a/src/Fergun.csproj
+++ b/src/Fergun.csproj
@@ -8,13 +8,14 @@
     <Version>2.0-beta</Version>
   </PropertyGroup>
 
-  <ItemGroup>
+	<ItemGroup>
     <PackageReference Include="Discord.Addons.Hosting" Version="5.1.0" />
     <PackageReference Include="Discord.Net.Interactions" Version="3.5.0" />
     <PackageReference Include="Fergun.Interactive" Version="1.5.4" />
     <PackageReference Include="GScraper" Version="1.0.2" />
     <PackageReference Include="GTranslate" Version="2.1.0" />
     <PackageReference Include="Humanizer.Core" Version="2.14.1" />
+    <PackageReference Include="Lib.Harmony" Version="2.2.1" />
     <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.4">
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
       <PrivateAssets>all</PrivateAssets>
diff --git a/src/Program.cs b/src/Program.cs
index 3d0d233..a475d20 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -41,6 +41,11 @@
         services.Configure<BotListOptions>(context.Configuration.GetSection(BotListOptions.BotList));
         services.Configure<FergunOptions>(context.Configuration.GetSection(FergunOptions.Fergun));
         services.AddSqlite<FergunContext>(context.Configuration.GetConnectionString("FergunDatabase"));
+
+        if (context.Configuration.GetSection(StartupOptions.Startup).Get<StartupOptions>().MobileStatus)
+        {
+            MobilePatcher.Patch();
+        }
     })
     .ConfigureDiscordShardedHost((context, config) =>
     {
diff --git a/src/appsettings.json b/src/appsettings.json
index 6522f78..d7e6d39 100644
--- a/src/appsettings.json
+++ b/src/appsettings.json
@@ -7,7 +7,8 @@
     {
         "Token": "",
         "TestingGuildId": 0,
-        "OwnerCommandsGuildId": 0
+        "OwnerCommandsGuildId": 0,
+        "MobileStatus": false
     },
     "Fergun":
     {

From 32fd445e1d29273a3a03e5bc0d4d1f46c83abb47 Mon Sep 17 00:00:00 2001
From: d4n <dan3436@hotmail.com>
Date: Fri, 20 May 2022 14:55:58 -0500
Subject: [PATCH 83/83] Fix displayed command names in logs and database

---
 src/Services/InteractionHandlingService.cs | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/Services/InteractionHandlingService.cs b/src/Services/InteractionHandlingService.cs
index a14b974..5133758 100644
--- a/src/Services/InteractionHandlingService.cs
+++ b/src/Services/InteractionHandlingService.cs
@@ -241,9 +241,9 @@ private Task AutocompleteHandlerExecuted(IAutocompleteHandler autocompleteComman
 
     private async Task HandleCommandExecutedAsync(IApplicationCommandInfo command, IInteractionContext context, IResult result)
     {
-        string commandName = command.ToString()!.ToLowerInvariant();
+        string commandName = command.CommandType == ApplicationCommandType.Slash ? command.ToString()! : command.Name;
         await _cmdStatsSemaphore.WaitAsync();
-
+        
         try
         {
             await using var scope = _services.CreateAsyncScope();
@@ -268,7 +268,7 @@ private async Task HandleCommandExecutedAsync(IApplicationCommandInfo command, I
         if (result.IsSuccess)
         {
             _logger.LogInformation("Executed {Type} Command \"{Command}\" for {User} ({Id}) in {Context}",
-                command.CommandType, command, context.User, context.User.Id, context.Display());
+                command.CommandType, commandName, context.User, context.User.Id, context.Display());
 
             return;
         }