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 index b2b0109..6e20ba0 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -2,9 +2,9 @@ name: .NET on: push: - branches: [ master ] pull_request: - branches: [ master ] + branches: + - '*' jobs: build: @@ -20,6 +20,6 @@ jobs: - name: Restore dependencies run: dotnet restore - name: Build - run: dotnet build --no-restore + run: dotnet build --configuration Release --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 e5b30d2..5a49e6d 100644 --- a/Fergun.sln +++ b/Fergun.sln @@ -1,17 +1,11 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.2.32210.308 +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}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Fergun", "src\Fergun.csproj", "{2084CFF0-83BC-46FA-A969-479DE6282984}" 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.Tests", "tests\Fergun.Tests\Fergun.Tests.csproj", "{A80CD8CE-6020-47B7-B01D-827FB90C2F1C}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -19,19 +13,19 @@ Global Release|Any CPU = Release|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}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FF19917E-6D1C-4EBD-A553-F73456BE0138}.Release|Any CPU.Build.0 = Release|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}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5CC63A1D-B041-458D-96F7-BAA0FC33434C}.Release|Any CPU.Build.0 = Release|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 + {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 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 3339470..a362f36 100644 --- a/README.md +++ b/README.md @@ -1,152 +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) -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 | ❌ | - +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) -* 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: +### 1. Build and run 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` to `Debug` in a debug build): + +* Build the bot: ``` 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` to `Debug` in a debug build): + +* Go to the build output folder: ``` 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` to `Debug` in a debug build): - - `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`. -* 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 `: `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 `. - -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.
The service requires that the bot is being run by a daemon that handles Exit Code 1 as a restart.
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.
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.
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.
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.
`MongoDbEmote` and `WebSocketEmote` are used in `ping`.
`BoosterEmote` and `UserFlagsEmotes` are used in `userinfo`.
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.) + The application should create the SQLite database automatically and start the bot. -* 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. +* 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). +Fergun is licensed under the [MIT license](LICENSE). \ 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 - { - } - - // 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 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 9e1e105..0000000 --- a/Tests/Fergun.Tests/DictionaryTests.cs +++ /dev/null @@ -1,23 +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("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/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(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(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 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 OrderTestCases(IEnumerable testCases) where TTestCase : ITestCase - { - var sortedMethods = new SortedDictionary>(); - - foreach (var testCase in testCases) - { - int priority = 0; - - foreach (var attr in testCase.TestMethod.Method.GetCustomAttributes(typeof(TestPriorityAttribute).AssemblyQualifiedName)) - priority = attr.GetNamedArgument("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(IDictionary 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 0001f36..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", null, "po")] - public async Task TranslationInvalidParamsTest(string text, string toLanguage, string fromLanguage = null) - { - // Arrange - using var translator = new AggregateTranslator(); - - // Act and Assert - await Assert.ThrowsAnyAsync(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(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(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(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(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 @@ - - - - - - - \ 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 SendRequestAsync(IAiDungeonRequest request, bool requireToken = true) - => await SendRequestAsync(JsonSerializer.Serialize(request, _defaultSerializerOptions), requireToken); - - private async Task 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 CreateAnonymousAccountAsync() - { - var request = new AiDungeonAnonymousAccountRequest(); - var response = await SendRequestAsync(request, false); - - return DeserializeToEntity(response, "createAnonymousAccount"); - } - - public async Task GetAccountInfoAsync() - { - var request = new AiDungeonAccountInfoRequest(); - var response = await SendRequestAsync(request); - - return DeserializeToEntity(response, "user"); - } - - public async Task DisableSafeModeAsync(string id) - { - var request = new AiDungeonAccountGameSettingsRequest(id, true); - var response = await SendRequestAsync(request); - - return DeserializeToEntity(response, "saveGameSettings"); - } - - public async Task GetScenarioAsync(string scenarioId) - { - var request = new AiDungeonRequest(scenarioId, RequestType.GetScenario); - var response = await SendRequestAsync(request); - - return DeserializeToEntity(response, "scenario"); - } - - // aka add adventure - public async Task CreateAdventureAsync(string scenarioId, string? prompt = null) - { - var request = new AiDungeonRequest(scenarioId, RequestType.CreateAdventure, prompt); - var response = await SendRequestAsync(request); - - return DeserializeToEntity(response, "addAdventure"); - } - - public async Task GetAdventureAsync(string publicId) - { - var request = new AiDungeonRequest(publicId, RequestType.GetAdventure); - var response = await SendRequestAsync(request); - - return DeserializeToEntity(response, "adventure"); - } - - // aka add action - public async Task 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 DeleteAdventureAsync(string publicId) - { - var request = new AiDungeonRequest(publicId, RequestType.DeleteAdventure); - var response = await SendRequestAsync(request); - - return DeserializeToEntity(response, "deleteAdventure"); - } - - private static TEntity DeserializeToEntity(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(_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 - { - /// - /// Do an action. - /// - Do, - - /// - /// Say something. - /// - Say, - - /// - /// Describe the place. - /// - Story, - - /// - /// Generate more story (no input). - /// - Continue, - - /// - /// Undo the last action. - /// - Undo, - - /// - /// Redo the last action. - /// - Redo, - - /// - /// Edit the last action. - /// - Alter, - - /// - /// Edit the memory context. - /// - Remember, - - /// - /// Retry the last action and generate a new response. - /// - 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 - { - 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 - { - 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 - { - 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 - { - 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 - { - 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 Options { get; set; } = Array.Empty(); - } - - 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 Actions { get; set; } = Array.Empty(); - - public IReadOnlyList UndoneWindow { get; set; } = Array.Empty(); - - 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(); - - /// - /// Takes a screenshot from an Url. - /// - /// A valid access key allowing you to make API calls. - /// 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. - /// The image format of the captured screenshot. Either jpeg or png. - /// 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. - /// Number of seconds the screenshot is cached. From 0 seconds to 2592000 seconds (30 days). - /// Force the API to capture a fresh new screenshot instead of returning a screenshot from the cache. - /// Set this parameter to true to capture the entire page of the target website. - /// 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. - /// A task containing a ApiFlashResponse object with the Url of the image or the error message. - public static async Task 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(json); - } - - public static async Task 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(json); - } - - public enum FormatType - { - Jpeg, - Png - } - - public enum ResponseType - { - Image, - Json - } - } - - public class ApiFlashResponse - { - /// - /// Url of the screenshot. - /// - [JsonProperty("url", NullValueHandling = NullValueHandling.Ignore)] - public string Url { get; set; } - - /// - /// The error message on fail. - /// - [JsonProperty("error_message", NullValueHandling = NullValueHandling.Ignore)] - public string ErrorMessage { get; set; } - } - - public class ApiFlashQuotaResponse - { - /// - /// The maximum number of API calls you can make per billing period. - /// - [JsonProperty("limit")] - public int Limit { get; set; } - - /// - /// The number of API calls remaining for the current billing period. - /// - [JsonProperty("remaining")] - public int Remaining { get; set; } - - /// - /// The time, in UTC epoch seconds, at which the current billing period ends and the remaining number of API calls resets. - /// - [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 -{ - /// - /// Represents a dictionary API. - /// - public static class DictionaryApi - { - /// - /// Returns the API endpoint. - /// - public const string ApiEndpoint = "https://api.dictionaryapi.dev/api/v2/entries/"; - - /// - /// Returns the default language. - /// - public const string DefaultLanguage = "en"; - - private static readonly HttpClient _httpClient = new HttpClient { BaseAddress = new Uri(ApiEndpoint) }; - - /// - /// Gets word definitions using the provided word and language. - /// - /// The word to get its definitions. - /// A language in . - /// Whether to fallback to if there are no results in . - /// A task representing the asynchronous operation. The result contains a read-only list of objects. - public static async Task> 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(); - } - - // Fallback to the default language. - language = DefaultLanguage; - fallback = false; - continue; - } - - break; - } - - string json = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject>(json); - } - - /// - /// Gets a read-only list containing the supported languages. - /// - public static IReadOnlyList 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 Phonetics { get; set; } = new List(); - - [JsonProperty("origin")] - public string Origin { get; set; } - - [JsonProperty("meanings")] - public IReadOnlyList Meanings { get; set; } = new List(); - } - - public class Meaning - { - [JsonProperty("partOfSpeech")] - public string PartOfSpeech { get; set; } - - [JsonProperty("definitions")] - public IReadOnlyList Definitions { get; set; } = new List(); - } - - public class DefinitionInfo - { - [JsonProperty("definition")] - public string Definition { get; set; } - - [JsonProperty("example")] - public string Example { get; set; } - - [JsonProperty("synonyms")] - public IReadOnlyList Synonyms { get; set; } = new List(); - - [JsonProperty("antonyms")] - public IReadOnlyList Antonyms { get; set; } = new List(); - } - - 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 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(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 Hits { get; set; } - } - - public class Hit - { - [JsonProperty("highlights")] - public List 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 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 - { - { "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(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 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 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 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 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(); - - /// - /// Requests questions from the API. - /// - /// The amount of questions to request. - /// The category of the questions. If left empty the questions will have mixed categories. - /// The difficulty of the questions. Easy, Medium, or Hard. If left empty the questions received will have mixed difficulty levels. - /// The type of questions. Multiple or Boolean. If left empty the questions will have mixed types. - /// The type of encoding used in the response. Default, urlLegacy, url3986, or base64. If left empty it will use the default encoding (HTML Codes). - /// 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. - /// A object. - /// Thrown when is out of range. - public static async Task 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(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}"; - } - - /// - /// Sends a command to the Token API endpoint. - /// - /// The command to send. It can be "Request" (Requests a session token) or "Reset" (Resets the provided session token) - /// Resets the provided session token, only if one is passed and command is set to "Reset". - /// A object. - public static async Task 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(json); - } - - /// - /// Requests the list of categories and IDs in the database. - /// - /// A object. - public static async Task RequestCategoryListAsync() - { - string json = await _httpClient.GetStringAsync(ApiCategoryEndpoint); - return JsonConvert.DeserializeObject(json); - } - - /// - /// Requests the number of questions in the database, in a specific category. - /// - /// The category to request. - /// A object. - /// Thrown when is . - public static async Task 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(json); - } - - /// - /// Requests the total number of questions in the database. - /// - /// A object. - public static async Task RequestGlobalQuestionCountAsync() - { - string json = await _httpClient.GetStringAsync(ApiGlobalCountEndpoint); - return JsonConvert.DeserializeObject(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 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 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 SearchWordAsync(string word) - { - string response = await _httpClient.GetStringAsync($"{ApiEndpoint}/define?term={Uri.EscapeDataString(word)}"); - return JsonConvert.DeserializeObject(response); - } - - public static async Task GetRandomWordsAsync() - { - string response = await _httpClient.GetStringAsync($"{ApiEndpoint}/random"); - return JsonConvert.DeserializeObject(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 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}×tamp={timestamp}", UriKind.Relative)); - return JsonConvert.DeserializeObject(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/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; + +/// +/// The exception that is thrown when Bing Visual Search fails to retrieve the results of an operation. +/// +[Serializable] +public class BingException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public BingException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public BingException(string? message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public BingException(string? message, Exception? innerException) + : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class with serialized data. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected BingException(SerializationInfo serializationInfo, StreamingContext streamingContext) + : base(serializationInfo, streamingContext) + { + } +} \ No newline at end of file diff --git a/src/Apis/Bing/BingReverseImageSearchResult.cs b/src/Apis/Bing/BingReverseImageSearchResult.cs new file mode 100644 index 0000000..ee90908 --- /dev/null +++ b/src/Apis/Bing/BingReverseImageSearchResult.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.Text.Json.Serialization; + +namespace Fergun.Apis.Bing; + +/// +/// Represents a Bing reverse image search result. +/// +[DebuggerDisplay($"{{{nameof(DebuggerDisplay)}}}")] +public class BingReverseImageSearchResult : IBingReverseImageSearchResult +{ + public BingReverseImageSearchResult(string url, string? friendlyDomainName, string sourceUrl, string text, Color accentColor) + { + Url = url; + FriendlyDomainName = friendlyDomainName; + SourceUrl = sourceUrl; + Text = text; + AccentColor = accentColor; + } + + /// + [JsonPropertyName("contentUrl")] + public string Url { get; } + + /// + [JsonPropertyName("hostPageDomainFriendlyName")] + public string? FriendlyDomainName { get; } + + /// + [JsonPropertyName("hostPageUrl")] + public string SourceUrl { get; } + + /// + [JsonPropertyName("name")] + public string Text { get; } + + /// + [JsonPropertyName("accentColor")] + [JsonConverter(typeof(ColorJsonConverter))] + public Color AccentColor { get; } + + /// + public override string ToString() => $"{nameof(Text)} = {Text}"; + + [ExcludeFromCodeCoverage] + private string DebuggerDisplay => ToString(); +} \ No newline at end of file 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; + +/// +/// Specifies the levels of safe search in Bing Visual Search. +/// +public enum BingSafeSearchLevel +{ + /// + /// Return images with adult content. The thumbnail images are clear (non-fuzzy). + /// + Off, + /// + /// Do not return images with adult content. + /// + Moderate, + /// + /// Do not return images with adult content. + /// + Strict +} \ 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..42aa20e --- /dev/null +++ b/src/Apis/Bing/BingVisualSearch.cs @@ -0,0 +1,175 @@ +using System.Text.Json; +using Fergun.Extensions; + +namespace Fergun.Apis.Bing; + +/// +/// Represents a wrapper over Bing Visual Search internal API. +/// +public sealed class BingVisualSearch : IBingVisualSearch, IDisposable +{ + private static readonly Uri _apiEndpoint = new("https://www.bing.com/images/api/custom/knowledge/"); + + private static readonly Dictionary _imageCategories = new(5) + { + ["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." + }; + + 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; + + /// + /// Initializes a new instance of the class. + /// + public BingVisualSearch() + : this(new HttpClient()) + { + } + + /// + /// Initializes a new instance of the class using the specified . + /// + /// An instance of . + public BingVisualSearch(HttpClient httpClient) + { + _httpClient = httpClient; + + _httpClient.BaseAddress ??= _apiEndpoint; + + if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0) + { + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_defaultUserAgent); + } + } + + /// + public async Task OcrAsync(string url) + { + EnsureNotDisposed(); + 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 + .GetProperty("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); + } + + /// + public async Task> ReverseImageSearchAsync(string url, + BingSafeSearchLevel safeSearch = BingSafeSearchLevel.Moderate, string? language = null) + { + EnsureNotDisposed(); + using var request = BuildRequest(url, "SimilarImages", safeSearch, language); + 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 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 rawItems.Select(item => item.Deserialize()!); + } + + 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 + { + { new StringContent(jsonRequest), "knowledgeRequest" } + }; + + var request = new HttpRequestMessage + { + Method = HttpMethod.Post, + RequestUri = new Uri($"?skey={_sKey}&safeSearch={safeSearch}{(language is null ? string.Empty : $"&setLang={language}")}", UriKind.Relative), + Content = content + }; + + request.Headers.Referrer = new Uri($"https://www.bing.com/images/search?view=detailv2&iss=sbi&q=imgurl:{url}"); + + return request; + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _httpClient.Dispose(); + _disposed = true; + } + + private void EnsureNotDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(BingVisualSearch)); + } + } + + /// + async Task> 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/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; + +/// +/// Converts a string to a . +/// +public class ColorJsonConverter : JsonConverter +{ + /// + 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; + + /// + 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/Bing/IBingReverseImageSearchResult.cs b/src/Apis/Bing/IBingReverseImageSearchResult.cs new file mode 100644 index 0000000..a2b8d22 --- /dev/null +++ b/src/Apis/Bing/IBingReverseImageSearchResult.cs @@ -0,0 +1,34 @@ +using System.Drawing; + +namespace Fergun.Apis.Bing; + +/// +/// Represents a Bing reverse image search result. +/// +public interface IBingReverseImageSearchResult +{ + /// + /// Gets a URL pointing to the image. + /// + string Url { get; } + + /// + /// Gets a friendly domain name. + /// + string? FriendlyDomainName { get; } + + /// + /// Gets a URL pointing to the webpage hosting the image. + /// + string SourceUrl { get; } + + /// + /// Gets the description of the image result. + /// + string Text { get; } + + /// + /// Gets the accent color of this result. + /// + Color AccentColor { get; } +} \ 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..607d6b0 --- /dev/null +++ b/src/Apis/Bing/IBingVisualSearch.cs @@ -0,0 +1,24 @@ +namespace Fergun.Apis.Bing; + +/// +/// Represents a Bing Visual Search API. +/// +public interface IBingVisualSearch +{ + /// + /// Performs OCR to the specified image URL. + /// + /// The URL of an image. + /// A representing the asynchronous OCR operation. The result contains the recognized text. + Task OcrAsync(string url); + + /// + /// Performs reverse image search to the specified image URL. + /// + /// The URL of an image. + /// The safe search level. + /// The language of the results. + /// A representing the asynchronous search operation. The result contains an of search results. + Task> ReverseImageSearchAsync(string url, + BingSafeSearchLevel safeSearch = BingSafeSearchLevel.Moderate, string? language = null); +} \ No newline at end of file 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; + +/// +/// Represents an API wrapper for Genius. +/// +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; + + /// + /// Initializes a new instance of the class. + /// + public GeniusClient() + : this(new HttpClient()) + { + } + + /// + /// Initializes a new instance of the class using the specified . + /// + /// An instance of . + public GeniusClient(HttpClient httpClient) + { + _httpClient = httpClient; + + _httpClient.BaseAddress ??= _apiEndpoint; + + if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0) + { + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_defaultUserAgent); + } + } + + /// + public async Task> 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()!); + } + + /// + public async Task 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()); + } + + /// + 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); + } + } + } + } + + /// + async Task> IGeniusClient.SearchSongsAsync(string query) => await SearchSongsAsync(query).ConfigureAwait(false); + + /// + async Task 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; + +/// +/// The exception that is thrown when fails to scrape a song. +/// +[Serializable] +public class GeniusException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public GeniusException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public GeniusException(string? message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public GeniusException(string? message, Exception? innerException) + : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class with serialized data. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + 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; + +/// +/// Represents a Genius song. +/// +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; + } + + /// + [JsonPropertyName("artist_names")] + public string ArtistNames { get; } + + /// + [JsonPropertyName("header_image_url")] + public string HeaderImageUrl { get; } + + /// + [JsonPropertyName("id")] + public int Id { get; } + + /// + [JsonPropertyName("instrumental")] + public bool IsInstrumental { get; } + + /// + [JsonPropertyName("song_art_image_url")] + public string SongArtImageUrl { get; } + + /// + [JsonPropertyName("title")] + public string Title { get; } + + /// + [JsonPropertyName("url")] + public string Url { get; } + + /// + public string? PrimaryArtistUrl { get; } + + /// + public Color? PrimaryArtColor { get; } + + /// + public string? Lyrics { get; } + + /// + /// Returns the full title of this song. + /// + /// The full title of this song. + 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; + +/// +/// Represents a Genius API client. +/// +public interface IGeniusClient +{ + /// + /// Searches for Genius songs that matches . + /// + /// The search term. + /// A representing the asynchronous operation. The result contains an of matching songs. + Task> SearchSongsAsync(string query); + + /// + /// Gets a Genius song by its ID. + /// + /// The ID of the song. + /// A representing the asynchronous operation. The result contains the song. + Task 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; + +/// +/// Represents a Genius song. +/// +public interface IGeniusSong +{ + /// + /// Gets the artist names. + /// + string ArtistNames { get; } + + /// + /// Gets the header image URL. + /// + string HeaderImageUrl { get; } + + /// + /// Gets the ID of this song. + /// + int Id { get; } + + /// + /// Gets a value indicating whether this song is instrumental. + /// + bool IsInstrumental { get; } + + /// + /// Gets the song art image URL. + /// + string SongArtImageUrl { get; } + + /// + /// Gets the title of this song. + /// + string Title { get; } + + /// + /// Gets a URL pointing to the lyrics page. + /// + string Url { get; } + + /// + /// Gets a URL pointing to the primary artist page. + /// + public string? PrimaryArtistUrl { get; } + + /// + /// Gets the primary art color. + /// + public Color? PrimaryArtColor { get; } + + /// + /// Gets the lyrics of this song. + /// + string? Lyrics { get; } +} \ No newline at end of file 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; + +/// +/// Represents an Urban Dictionary API. +/// +public interface IUrbanDictionary +{ + /// + /// Gets definitions for a term. + /// + /// The term to search. + /// A representing the asynchronous operation. The result contains a read-only collection of definitions. + Task> GetDefinitionsAsync(string term); + + /// + /// Gets random definitions. + /// + /// A representing the asynchronous operation. The result contains a read-only collection of random definitions. + Task> GetRandomDefinitionsAsync(); + + /// + /// Gets a definition by its ID. + /// + /// The ID of the definition. + /// A representing the asynchronous operation. The result contains the definition, or null if not found. + Task GetDefinitionAsync(int id); + + /// + /// Gets the words of the day. + /// + /// A representing the asynchronous operation. The result contains a read-only collection of definitions. + Task> GetWordsOfTheDayAsync(); + + /// + /// Gets autocomplete results for a term. + /// + /// The term to search. + /// A representing the asynchronous operation. The result contains a read-only collection of suggested terms. + Task> GetAutocompleteResultsAsync(string term); + + /// + /// Gets autocomplete results for a term. The results contain the term and a preview definition. + /// + /// The term to search. + /// A representing the asynchronous operation. The result contains a read-only collection of suggested terms. + Task> 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; + +/// +/// Represent an Urban Dictionary autocomplete result. +/// +[DebuggerDisplay($"{{{nameof(DebuggerDisplay)}}}")] +public class UrbanAutocompleteResult +{ + [JsonConstructor] + public UrbanAutocompleteResult(string term, string preview) + { + Term = term; + Preview = preview; + } + + /// + /// Gets the term of this result. + /// + [JsonPropertyName("term")] + public string Term { get; } + + /// + /// Gets a preview definition of the term. + /// + [JsonPropertyName("preview")] + public string Preview { get; } + + /// + 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..e2fed9b --- /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; + +/// +/// Represents an Urban Dictionary definition. +/// +[DebuggerDisplay($"{{{nameof(DebuggerDisplay)}}}")] +public class UrbanDefinition +{ + [JsonConstructor] + public UrbanDefinition(string definition, string? date, string permalink, int thumbsUp, IReadOnlyCollection 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; + } + + /// + /// Gets the definition. + /// + [JsonPropertyName("definition")] + public string Definition { get; init; } + + /// + /// Gets the date this definition was posted on the front page as a word of the day. + /// + [JsonPropertyName("date")] + public string? Date { get; init; } + + /// + /// Gets a permalink to the page containing this definition. + /// + [JsonPropertyName("permalink")] + public string Permalink { get; init; } + + /// + /// Gets the number of thumps-up. + /// + [JsonPropertyName("thumbs_up")] + public int ThumbsUp { get; init; } + + /// + /// Gets a collection of sound URLs. + /// + [JsonPropertyName("sound_urls")] + public IReadOnlyCollection SoundUrls { get; init; } + + /// + /// Gets the author of this definition. + /// + [JsonPropertyName("author")] + public string Author { get; init; } + + /// + /// Gets the word (term) being defined. + /// + [JsonPropertyName("word")] + public string Word { get; init; } + + /// + /// Gets the ID of this definition. + /// + [JsonPropertyName("defid")] + public int Id { get; init; } + + /// + /// Gets the date this definition was written. + /// + [JsonPropertyName("written_on")] + public DateTimeOffset WrittenOn { get; init; } + + /// + /// Gets an example usage of the definition. + /// + [JsonPropertyName("example")] + public string Example { get; init; } + + /// + /// Gets the number of thumps-down. + /// + [JsonPropertyName("thumbs_down")] + public int ThumbsDown { get; init; } + + /// + 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; + +/// +/// Represents an API wrapper for Urban Dictionary. +/// +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; + + /// + /// Initializes a new instance of the class. + /// + public UrbanDictionary() + : this(new HttpClient()) + { + } + + /// + /// Initializes a new instance of the class using the specified . + /// + /// An instance of . + public UrbanDictionary(HttpClient httpClient) + { + _httpClient = httpClient; + + _httpClient.BaseAddress ??= _apiEndpoint; + + if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0) + { + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_defaultUserAgent); + } + } + + /// + public async Task> 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>()!; + } + + /// + public async Task> 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>()!; + } + + /// + public async Task 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()!; + } + + /// + public async Task> 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>()!; + } + + /// + public async Task> 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>(stream).ConfigureAwait(false))!; + } + + /// + public async Task> 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>()!; + } + + /// + 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/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; + +/// +/// Represent a Wikipedia article. +/// +public interface IWikipediaArticle +{ + /// + /// Gets the title of this article. + /// + string Title { get; } + + /// + /// Gets the description of this article. + /// + string? Description { get; } + + /// + /// Gets the extract text of this article. + /// + string Extract { get; } + + /// + /// Gets the image of this article. + /// + IWikipediaImage? Image { get; } + + /// + /// Gets the ID of this article. + /// + 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; + +/// +/// Represents a Wikipedia API client. +/// +public interface IWikipediaClient +{ + /// + /// Gets a collection of articles that matches . + /// + /// The search string. + /// The search language. + /// A that represents the asynchronous operation. The result contains an ordered of articles. + Task> GetArticlesAsync(string query, string language); + + /// + /// Gets autocomplete results. + /// + /// The search string. + /// The search language. + /// A that represents the asynchronous operation. The result contains a read-only list of autocomplete results. + Task> 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; + +/// +/// Represents a Wikipedia image. +/// +public interface IWikipediaImage +{ + /// + /// Gets a URL to the image. + /// + string Url { get; } + + /// + /// Gets the width of this image. + /// + int Width { get; } + + /// + /// Gets the height of this image. + /// + 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; + +/// +/// Represent a Wikipedia article. +/// +public class WikipediaArticle : IWikipediaArticle +{ + /// + /// Initializes a new instance of the . + /// + /// The title. + /// The description. + /// The extract text. + /// The image. + /// The ID. + public WikipediaArticle(string title, string? description, string extract, WikipediaImage? image, int id) + { + Title = title; + Description = description; + Extract = extract; + Image = image; + Id = id; + } + + /// + [JsonPropertyName("title")] + public string Title { get; } + + /// + [JsonPropertyName("description")] + public string? Description { get; } + + /// + [JsonPropertyName("extract")] + public string Extract { get; } + + /// + [JsonPropertyName("original")] + public WikipediaImage? Image { get; } + + /// + [JsonPropertyName("pageid")] + public int Id { get; } + + /// + IWikipediaImage? IWikipediaArticle.Image => Image; + + /// + /// Returns the title and description of this article. + /// + /// The title and description of this article. + 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; + +/// +/// Represents a Wikipedia API client. +/// +public sealed class WikipediaClient : IWikipediaClient, IDisposable +{ + private readonly HttpClient _httpClient; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + public WikipediaClient() + : this(new HttpClient()) + { + } + + /// + /// Initializes a new instance of the class using the specified . + /// + /// An instance of . + public WikipediaClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + /// + public async Task> 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()!); + } + + /// + /// Gets autocomplete results. + /// + /// The search string. + /// The search language. + /// A that represents the asynchronous operation. The result contains a read-only list of autocomplete results. + public async Task> 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>()!; + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _httpClient.Dispose(); + _disposed = true; + } + + private void EnsureNotDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(WikipediaClient)); + } + } + + /// + async Task> 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; + +/// +/// Represents a Wikipedia image. +/// +public class WikipediaImage : IWikipediaImage +{ + /// + /// Initializes a new instance of the class. + /// + /// The URL of the image. + /// The width. + /// The height. + public WikipediaImage(string url, int width, int height) + { + Url = url; + Width = width; + Height = height; + } + + /// + [JsonPropertyName("source")] + public string Url { get; } + + /// + [JsonPropertyName("width")] + public int Width { get; } + + /// + [JsonPropertyName("height")] + public int Height { get; } + + /// + /// Returns . + /// + /// + public override string ToString() => 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..cee3c5b --- /dev/null +++ b/src/Apis/Yandex/IYandexImageSearch.cs @@ -0,0 +1,22 @@ +namespace Fergun.Apis.Yandex; + +/// +/// Represents a Yandex Image Search API. +/// +public interface IYandexImageSearch +{ + /// + /// Performs OCR to the specified image URL. + /// + /// The URL of an image. + /// A representing the asynchronous OCR operation. The result contains the recognized text. + Task OcrAsync(string url); + + /// + /// Performs reverse image search to the specified image URL. + /// + /// The URL of an image. + /// The search filter mode. + /// A representing the asynchronous search operation. The result contains an of search results. + Task> 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; + +/// +/// Represents a Yandex reverse image search result. +/// +public interface IYandexReverseImageSearchResult +{ + /// + /// Gets a URL pointing to the image. + /// + string Url { get; } + + /// + /// Gets a URL pointing to the webpage hosting the image. + /// + string SourceUrl { get; } + + /// + /// Gets the title of the image result. + /// + string? Title { get; } + + /// + /// Gets the description of the image result. + /// + string Text { get; } +} \ 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; + +/// +/// The exception that is thrown when Yandex Image search fails to retrieve the results of an operation. +/// +[Serializable] +public class YandexException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public YandexException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public YandexException(string? message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public YandexException(string? message, Exception? innerException) + : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class with serialized data. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + 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..9a30491 --- /dev/null +++ b/src/Apis/Yandex/YandexImageSearch.cs @@ -0,0 +1,218 @@ +using System.Net; +using System.Text.Json; +using AngleSharp.Html.Parser; +using Fergun.Extensions; + +namespace Fergun.Apis.Yandex; + +/// +/// Represents a wrapper over Yandex Image Search internal API. +/// +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; + + /// + /// Initializes a new instance of the class. + /// + public YandexImageSearch() + : this(new HttpClient(new HttpClientHandler { UseCookies = false })) + { + } + + /// + /// Initializes a new instance of the class using the specified . + /// + /// An instance of . + public YandexImageSearch(HttpClient httpClient) + { + _httpClient = httpClient; + + if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0) + { + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_defaultUserAgent); + } + } + + /// + public async Task OcrAsync(string url) + { + EnsureNotDisposed(); + + // 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); + + 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 + .RootElement + .GetProperty("image_id") + .GetString(); + + 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(); + + // 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); + + 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(); + } + + /// + public async Task> ReverseImageSearchAsync(string url, YandexSearchFilterMode mode = YandexSearchFilterMode.Moderate) + { + EnsureNotDisposed(); + + 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(); + + return EnumerateResults(rawItems); + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _httpClient.Dispose(); + _disposed = true; + } + + private static IEnumerable EnumerateResults(IEnumerable 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("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), WebUtility.HtmlDecode(text)); + } + } + + private void EnsureNotDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(YandexImageSearch)); + } + } + + /// + async Task> IYandexImageSearch.ReverseImageSearchAsync(string url, YandexSearchFilterMode mode) + => await ReverseImageSearchAsync(url, mode).ConfigureAwait(false); +} \ No newline at end of file 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; + +/// +/// Represents a Yandex reverse image search result. +/// +[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; + } + + /// + public string Url { get; } + + /// + public string SourceUrl { get; } + + /// + public string? Title { get; } + + /// + public string Text { get; } + + /// + 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; + +/// +/// Specifies the filter modes in Yandex.Search. +/// +public enum YandexSearchFilterMode +{ + /// + /// Search results include all the documents found for the query, including internet resources “for adults”. + /// + None, + /// + /// Sites “for adults” are excluded from search results if the query does not explicitly search for such resources. + /// + Moderate, + /// + /// Adult content and sites containing obscene language are completely excluded from search results (even if the query is clearly directed at finding such resources). + /// + Family +} \ 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 -{ - /// - /// Marks a command or module to be always enabled. - /// - [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 -{ - /// - /// Attaches an example to your command. - /// - [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 -{ - /// - /// An attribute that sends the typing state to the current channel (useful for long-running commands). - /// - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)] - public sealed class LongRunningAttribute : PreconditionAttribute - { - public override async Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) - { - IUserMessage response; - ulong messageId = 0; - bool? found = services.GetService()?.TryGetValue(context.Message.Id, out messageId); - var cache = services.GetService(); - - 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 -{ - /// - /// Marks the order of a module, a lower value equals higher order. - /// - [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 -{ - /// - /// Disables the command or module globally or on a specific guild. - /// - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] - public sealed class DisabledAttribute : RequireContextAttribute - { - public DisabledAttribute() : base(ContextType.Guild) - { - } - - public DisabledAttribute(params ulong[] guildIds) : this() - { - _guildIds = guildIds; - } - - /// - public override string ErrorMessage { get; set; } - - private readonly ulong[] _guildIds = Array.Empty(); - - /// - public override Task 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 -{ - /// - /// Sets how often a user is allowed to use this command - /// or any command in this module. - /// - /// - /// - /// This is backed by an in-memory collection - /// and will not persist with restarts. - /// - /// - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)] - public sealed class RatelimitAttribute : PreconditionAttribute - { - /// - 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>(); - - /// - /// Sets how often a user is allowed to use this command. - /// - /// The number of times a user may use the command within a certain period. - /// - /// - /// The amount of time since first invoke a user has until the limit is lifted. - /// - /// - /// The scale in which the parameter should be measured. - /// - /// - /// Flags to set behavior of the ratelimit. - /// - 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.") - }; - } - - /// - /// Sets how often a user is allowed to use this command. - /// - /// - /// The number of times a user may use the command within a certain period. - /// - /// - /// The amount of time since first invoke a user has until the limit is lifted. - /// - /// - /// Flags to set the behavior of the ratelimit. - /// - /// - /// - /// This is a convenient constructor overload for use with the dynamic - /// command builders, but not with the Class & Method-style commands. - /// - /// - 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; - } - - /// - public override async Task 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; - } - } - } - - /// - /// Determines the scale of the period parameter. - /// - public enum Measure - { - /// - /// Period is measured in days. - /// - Days, - - /// - /// Period is measured in hours. - /// - Hours, - - /// - /// Period is measured in minutes. - /// - Minutes - } - - /// - /// Determines the behavior of the . - /// - [Flags] - public enum RatelimitOptions - { - /// - /// Set none of the flags. - /// - None = 0, - - /// - /// Set whether or not there is no limit to the command in DMs. - /// - NoLimitInDMs = 1 << 0, - - /// - /// Set whether or not there is no limit to the command for guild admins. - /// - NoLimitForAdmins = 1 << 1, - - /// - /// Set whether or not to apply a limit per guild. - /// - 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 -{ - /// - /// Indicates this parameter must be a - /// whose Hierarchy value must be - /// lower than that of the Bot. - /// - [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; - } - - /// - public override async Task 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 -{ - /// - /// 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 . - /// - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)] - public sealed class UserMustBeInVoiceAttribute : RequireContextAttribute - { - /// - public override string ErrorMessage { get; set; } - - public UserMustBeInVoiceAttribute() - : base(ContextType.Guild) - { - } - - public UserMustBeInVoiceAttribute(params string[] exceptions) - : this() - { - _exceptions = exceptions; - } - - private readonly string[] _exceptions; - - /// - public override async Task 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(); - 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 -{ - /// - /// Represents an AI Dungeon adventure. - /// - [BsonIgnoreExtraElements] - public class AidAdventure : IIdentity - { - /// - /// Initializes a new instance of the class with the provided values. - /// - /// The Id of the adventure. - /// The public Id of the adventure. - /// The owner Id of the adventure. - /// Whether the adventure is public. - public AidAdventure(long id, string publicId, ulong ownerId, bool isPublic) - { - Id = id; - PublicId = publicId; - OwnerId = ownerId; - IsPublic = isPublic; - } - - /// - [BsonId] - public ObjectId ObjectId { get; set; } - - /// - /// Gets or sets the Id of this adventure. - /// - public long Id { get; set; } - - /// - /// Gets or sets the public Id of this adventure. - /// - public string PublicId { get; set; } - - /// - /// Gets or sets the owner Id of this adventure. - /// - public ulong OwnerId { get; set; } - - /// - /// Gets or sets whether this adventure is public. - /// - 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 -{ - /// - /// Represents the bot configuration in the database. - /// - public static class DatabaseConfig - { - /// - /// Gets the bot global prefix. - /// - public static string GlobalPrefix => FergunClient.IsDebugMode ? GetConfig().DevGlobalPrefix : GetConfig().GlobalPrefix; - - /// - /// Gets the bot language. - /// - public static string Language => GetConfig().Language; - - /// - /// Gets a dictionary containing the command stats. - /// - public static IDictionary CommandStats => GetConfig().CommandStats ?? new Dictionary(); - - /// - /// Gets a dictionary containing the commands that have been disabled globally. - /// - public static IDictionary GloballyDisabledCommands => GetConfig().GloballyDisabledCommands ?? new Dictionary(); - - /// - /// Modifies the database config with the specified properties. - /// - /// A delegate containing the properties to modify the config with. - public static void Update(Action action) - { - var cfg = GetConfig(); - action(cfg); - FergunClient.Database.InsertOrUpdateDocument(Constants.ConfigCollection, cfg); - } - - private static BaseDatabaseConfig GetConfig() - { - return FergunClient.Database.GetFirstDocument(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 CommandStats { get; set; } = new Dictionary(); - public IDictionary GloballyDisabledCommands { get; set; } = new Dictionary(); - } -} \ 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 -{ - /// - /// Represents the bot configuration. - /// - public class FergunConfig - { - /// - /// Gets the bot token. - /// - [JsonProperty] - public string Token { get; private set; } - - /// - /// Gets the development bot token. - /// - /// - /// This token will be automatically used in debug builds. - /// - [JsonProperty] - public string DevToken { get; private set; } - - /// - /// Gets the Top.gg API token. - /// - [JsonProperty] - public string TopGgApiToken { get; private set; } - - /// - /// Gets the Discord Bots API token. - /// - [JsonProperty] - public string DiscordBotsApiToken { get; private set; } - - /// - /// Gets the Genius API token. - /// - [JsonProperty] - public string GeniusApiToken { get; private set; } - - /// - /// Gets the AI Dungeon user token. - /// - [JsonProperty] - public string AiDungeonToken { get; private set; } - - /// - /// Gets the DeepAI API key. - /// - [JsonProperty] - public string DeepAiApiKey { get; private set; } - - /// - /// Gets the ApiFlash access key. - /// - [JsonProperty] - public string ApiFlashAccessKey { get; private set; } - - /// - /// Gets the WolframAlpha App ID. - /// - [JsonProperty] - public string WolframAlphaAppId { get; private set; } - - /// - /// Gets the raw value of the color the bot will use in its embeds. - /// - [JsonProperty] - public uint EmbedColor { get; private set; } = Constants.DefaultEmbedColor; - - /// - /// Gets the support server invite. - /// - [JsonProperty] - public string SupportServer { get; private set; } - - /// - /// Gets the ID of the log channel. - /// - [JsonProperty] - public ulong LogChannel { get; private set; } - - /// - /// Gets whether the intent should be used. - /// - [JsonProperty] - public bool PresenceIntent { get; private set; } - - /// - /// Gets whether the intent should be used. - /// - [JsonProperty] - public bool ServerMembersIntent { get; private set; } - - /// - /// Gets the message cache size. If is set to , this property will be used in the optimized cache service instead. - /// - [JsonProperty] - public int MessageCacheSize { get; private set; } = 100; - - /// - /// Gets the number of messages to search in a channel. - /// - /// - /// This property is used in commands that searches for a Url in the messages of a channel. - /// - [JsonProperty] - public int MessagesToSearchLimit { get; private set; } = 100; - - /// - /// Gets whether all users should be downloaded to the cache. - /// - [JsonProperty] - public bool AlwaysDownloadUsers { get; private set; } - - /// - /// Gets whether the reliability service should be used. - /// - [JsonProperty] - public bool UseReliabilityService { get; private set; } - - /// - /// Gets whether the command cache service should be used. - /// - [JsonProperty] - public bool UseCommandCacheService { get; private set; } = true; - - /// - /// Gets whether the optimized message cache service should be used. - /// - [JsonProperty] - public bool UseMessageCacheService { get; private set; } = true; - - /// - /// 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. - /// - /// - /// This is used in the optimized message cache. - /// - [JsonProperty] - public int MinimumCommandTime { get; private set; } = Constants.MinCommandTime; - - /// - /// Gets the donation Url. - /// - [JsonProperty] - public string DonationUrl { get; private set; } - - /// - /// Gets the total number of shards to use. - /// - [JsonProperty] - public int? TotalShards { get; private set; } - - /// - /// Gets the minimum log level. - /// - [JsonProperty] - public LogSeverity LogLevel { get; private set; } = LogSeverity.Verbose; - - /// - /// Gets the MongoDB server authentication info. - /// - [JsonProperty] - public MongoConfig DatabaseConfig { get; private set; } = MongoConfig.Default; - - /// - /// Gets the Lavalink server configuration. - /// - [JsonProperty] - public LavaConfig LavaConfig { get; private set; } = new LavaConfig(); - - /// - /// Gets the loading emote. - /// - [JsonProperty] - public string LoadingEmote { get; private set; } - - /// - /// Gets the online emote. - /// - [JsonProperty] - public string OnlineEmote { get; private set; } - - /// - /// Gets the idle emote. - /// - [JsonProperty] - public string IdleEmote { get; private set; } - - /// - /// Gets the do not disturb emote. - /// - [JsonProperty] - public string DndEmote { get; private set; } - - /// - /// Gets the streaming emote. - /// - [JsonProperty] - public string StreamingEmote { get; private set; } - - /// - /// Gets the offline emote. - /// - [JsonProperty] - public string OfflineEmote { get; private set; } - - /// - /// Gets the text channel emote. - /// - [JsonProperty] - public string TextEmote { get; private set; } - - /// - /// Gets the voice channel emote. - /// - [JsonProperty] - public string VoiceEmote { get; private set; } - - /// - /// Gets the MongoDB emote. - /// - [JsonProperty] - public string MongoDbEmote { get; private set; } - - /// - /// Gets the websocket emote. - /// - [JsonProperty] - public string WebSocketEmote { get; private set; } - - /// - /// Gets the Nitro booster emote. - /// - [JsonProperty] - public string BoosterEmote { get; internal set; } - - /// - /// Gets the first page emote. Used in paginators. - /// - [JsonProperty] - public string FirstPageEmote { get; private set; } - - /// - /// Gets the previous page emote. Used in paginators. - /// - [JsonProperty] - public string PreviousPageEmote { get; private set; } - - /// - /// Gets the next page emote. Used in paginators. - /// - [JsonProperty] - public string NextPageEmote { get; private set; } - - /// - /// Gets the last page emote. Used in paginators. - /// - [JsonProperty] - public string LastPageEmote { get; private set; } - - /// - /// Gets the stop paginator emote. Used in paginators. - /// - [JsonProperty] - public string StopPaginatorEmote { get; private set; } - - /// - /// Gets user flags emotes. - /// - [JsonProperty] - public IReadOnlyDictionary UserFlagsEmotes { get; private set; } - = new ReadOnlyDictionary(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 -{ - /// - /// Represents the bot database. - /// - public class FergunDatabase - { - private readonly MongoClient _client; - private readonly IMongoDatabase _database; - - /// - /// Initializes a new instance of the class with the provided database name. - /// - /// The name of the database. - public FergunDatabase(string database) - { - _client = new MongoClient(); - _database = _client.GetDatabase(database); - } - - /// - /// Initializes a new instance of the class with the provided database name and connection string. - /// - /// The name of the database. - /// The connection string. - public FergunDatabase(string database, string url) - { - _client = new MongoClient(new MongoUrlBuilder(url).ToMongoUrl()); - _database = _client.GetDatabase(database); - } - - /// - /// Gets whether the bot is connected to the database. - /// - public bool IsConnected - { - get - { - try - { - _client.ListDatabaseNames(); - return _client.Cluster.Description.State == ClusterState.Connected; - } - catch (MongoException) { return false; } - } - } - - /// - /// Inserts a document. - /// - /// The collection. - /// The document. - public void InsertDocument(string collection, T document) - { - var c = _database.GetCollection(collection); - c.InsertOne(document); - } - - /// - /// Inserts a document asynchronously. - /// - /// The collection. - /// The document. - public async Task InsertDocumentAsync(string collection, T document) - { - var c = _database.GetCollection(collection); - await c.InsertOneAsync(document); - } - - /// - /// Inserts multiple documents. - /// - /// The collection. - /// The documents. - public void InsertDocuments(string collection, IEnumerable documents) - { - var c = _database.GetCollection(collection); - c.InsertMany(documents); - } - - /// - /// Inserts multiple documents asynchronously. - /// - /// The collection. - /// The documents. - public async Task InsertDocumentsAsync(string collection, IEnumerable documents) - { - var c = _database.GetCollection(collection); - await c.InsertManyAsync(documents); - } - - /// - /// Inserts or updates a document. - /// - /// A type that inherits from . - /// The collection. - /// The document. - public void InsertOrUpdateDocument(string collection, T document) where T : IIdentity - { - var c = _database.GetCollection(collection); - if (document.ObjectId == ObjectId.Empty) - { - c.InsertOne(document); // driver creates ObjectId under the hood - } - else - { - c.ReplaceOne(x => x.ObjectId == document.ObjectId, document); - } - } - - /// - /// Inserts or updates a document asynchronously. - /// - /// A type that inherits from . - /// The collection. - /// The document. - public async Task InsertOrUpdateDocumentAsync(string collection, T document) where T : IIdentity - { - var c = _database.GetCollection(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); - } - } - - /// - /// Gets the first document in the collection. - /// - /// The collection. - public T GetFirstDocument(string collection) - { - var c = _database.GetCollection(collection); - return c.Find(new BsonDocument()).FirstOrDefault(); - } - - /// - /// Gets the first document in the collection asynchronously. - /// - /// The collection. - public async Task GetFirstDocumentAsync(string collection) - { - var c = _database.GetCollection(collection); - return await (await c.FindAsync(new BsonDocument())).FirstOrDefaultAsync(); - } - - /// - /// Gets all the documents in the collection. - /// - /// The collection. - public IEnumerable GetAllDocuments(string collection) - { - var c = _database.GetCollection(collection); - return c.Find(new BsonDocument()).ToEnumerable(); - } - - /// - /// Gets all the documents in the collection asynchronously. - /// - /// The collection. - public async Task> GetAllDocumentsAsync(string collection) - { - var c = _database.GetCollection(collection); - return (await c.FindAsync(new BsonDocument())).ToEnumerable().ToAsyncEnumerable(); - } - - /// - /// Deletes a document. - /// - /// A type that inherits from . - /// The collection. - /// The document. - public bool DeleteDocument(string collection, T document) where T : IIdentity - { - var c = _database.GetCollection(collection); - var result = c.DeleteOne(x => x.ObjectId == document.ObjectId); - return result.IsAcknowledged; - } - - /// - /// Deletes a document asynchronously. - /// - /// A type that inherits from . - /// The collection. - /// The document. - public async Task DeleteDocumentAsync(string collection, T document) where T : IIdentity - { - var c = _database.GetCollection(collection); - var result = await c.DeleteOneAsync(x => x.ObjectId == document.ObjectId); - return result.IsAcknowledged; - } - - /// - /// Finds a document. - /// - /// The collection. - /// The filter. - public T FindDocument(string collection, Expression> filter) where T : class - { - var c = _database.GetCollection(collection); - return c.Find(filter).Limit(1).FirstOrDefault(); - } - - /// - /// Finds a document asynchronously. - /// - /// The collection. - /// The filter. - public async Task FindDocumentAsync(string collection, Expression> filter) where T : class - { - var c = _database.GetCollection(collection); - return await (await c.FindAsync(filter)).FirstOrDefaultAsync(); - } - - /// - /// Finds multiple documents. - /// - /// The collection. - /// The filter. - public IEnumerable FindManyDocuments(string collection, Expression> filter) where T : class - { - var c = _database.GetCollection(collection); - return c.Find(filter).ToEnumerable(); - } - - /// - /// Finds multiple documents asynchronously. - /// - /// The collection. - /// The filter. - public async Task> FindManyDocumentsAsync(string collection, Expression> filter) where T : class - { - var c = _database.GetCollection(collection); - return (await c.FindAsync(filter)).ToEnumerable().ToAsyncEnumerable(); - } - - /// - /// Renames a collection. - /// - /// The old name. - /// The new name. - public void RenameCollection(string oldName, string newName) - { - _database.RenameCollection(oldName, newName); - } - - /// - /// Renames a collection asynchronously. - /// - /// The old name. - /// The new name. - public async Task RenameCollectionAsync(string oldName, string newName) - { - await _database.RenameCollectionAsync(oldName, newName); - } - - /// - /// Runs a command. - /// - /// The command. - /// The result of the commands. - public string RunCommand(string command) - { - try - { - var result = _database.RunCommand(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 -{ - /// - /// Represents a Discord server configuration in the database. - /// - [BsonIgnoreExtraElements] - public class GuildConfig : IBlacklistEntity, IIdentity - { - /// - /// Initializes a new instance of the class with the provided Id. - /// - /// The server Id. - public GuildConfig(ulong id) - { - Id = id; - } - - /// - /// Initializes a new instance of the class with the provided values. - /// - /// The server Id. - /// Whether the server is blacklisted. - /// The reason of the blacklist. - /// The prefix of the server. - /// The language of the server. - /// A list of disabled commands for the server. - /// Whether the response of the AI Dungeon API should be translated to the server's language. - /// Whether the track selection message should be sent instead of playing the first result automatically in the server. - public GuildConfig(ulong id, bool isBlacklisted = false, string blacklistReason = null, - string prefix = null, string language = null, ICollection 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 _disabledCommands; - - /// - [BsonId] - public ObjectId ObjectId { get; set; } - - /// - public ulong Id { get; set; } - - /// - public bool IsBlacklisted { get; set; } - - /// - public string BlacklistReason { get; set; } - - /// - /// Gets or sets the prefix of this server. - /// - public string Prefix { get; set; } - - /// - /// Gets or sets the language of this server. - /// - public string Language { get; set; } = DatabaseConfig.Language ?? Constants.DefaultLanguage; - - /// - /// Gets or sets a collection of disabled commands for this server. - /// - public ICollection DisabledCommands - { - get => _disabledCommands ??= new List(); - set => _disabledCommands = value; - } - - /// - /// Gets or sets whether the response of the AI Dungeon API should be translated to this server's language. - /// - public bool AidAutoTranslate { get; set; } = Constants.AidAutoTranslateDefault; - - /// - /// Gets or sets whether the track selection message should be sent instead of playing the first result automatically in this server. - /// - 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 -{ - /// - /// Represents an entity that can be blacklisted. - /// - public interface IBlacklistEntity : IEntity - { - /// - /// Gets or sets whether this entity is blacklisted. - /// - public bool IsBlacklisted { get; set; } - - /// - /// Gets or sets the reason of the blacklist. - /// - 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 -{ - /// - /// Represents an object that can be identified with an ObjectId. - /// - public interface IIdentity - { - /// - /// Gets or sets the ObjectId. - /// - 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 -{ - /// - /// Represents a simple MongoDB auth config. - /// - public class MongoConfig - { - /// - /// Gets the user. - /// - [JsonProperty] - public string User { get; private set; } - - /// - /// Gets the password. - /// - [JsonProperty] - public string Password { get; private set; } - - /// - /// Gets the host where the MongoDB server is running. The default is localhost. - /// - [JsonProperty] - public string Host { get; private set; } = "localhost"; - - /// - /// Gets the port where the MongoDB server is running. The default is 27017. - /// - [JsonProperty] - public int Port { get; private set; } = 27017; - - /// - /// Gets the authentication database to use if an user and password is passed. The default is admin. - /// - [JsonProperty] - public string AuthDatabase { get; private set; } = "admin"; - - /// - /// Gets whether the hostname corresponds to a DNS SRV record (+srv). - /// - [JsonProperty] - public bool IsSrv { get; private set; } - - /// - /// Gets a instance with the default values. - /// - public static MongoConfig Default => new MongoConfig(); - - /// - /// Gets the connection string. - /// - [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 -{ - /// - /// Represents a user configuration in the database. - /// - [BsonIgnoreExtraElements] - public class UserConfig : IBlacklistEntity, IIdentity - { - /// - /// Initializes a new instance of the class with the provided user Id. - /// - /// The user Id. - public UserConfig(ulong id) - { - Id = id; - } - - /// - /// Initializes a new instance of the class with the provided values. - /// - /// The user Id. - /// Whether the user is blacklisted. - /// The reason of the blacklist. - /// The Trivia points of the user. - public UserConfig(ulong id, bool isBlacklisted = false, string blacklistReason = null, int triviaPoints = 0) : this(id) - { - IsBlacklisted = isBlacklisted; - BlacklistReason = blacklistReason; - TriviaPoints = triviaPoints; - } - - /// - [BsonId] - public ObjectId ObjectId { get; set; } - - /// - public ulong Id { get; set; } - - /// - public bool IsBlacklisted { get; set; } - - /// - public string BlacklistReason { get; set; } - - /// - /// Gets or sets the Trivia points of this user. - /// - public int TriviaPoints { get; set; } - - /// - /// Gets or sets whether the user has opted out the temporary collection of deleted/edited messages in the "snipe" commands. - /// - public bool IsOptedOutSnipe { get; set; } - } -} \ No newline at end of file diff --git a/src/Constants.cs b/src/Constants.cs index 33ef063..af904a7 100644 --- a/src/Constants.cs +++ b/src/Constants.cs @@ -1,176 +1,30 @@ -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() - .InformationalVersion; + public const string SpotifyLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/838833381298143334/spotify.png"; - public const string Changelog = @"**v1.9** -Additions: -- Added Yandex OCR as a fallback OCR API. + public const string GoogleLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/890326437268168704/unknown.png"; -Changes: -- Fixed a bug in AI Dungeon module that caused an exception when attempting to get the error message. -- Fixed a bug that caused an exception when modifying a command message in some cases. -- Fixed a bug in play that caused an exception due to a bug in the music library. -- Fixed a bug in `translate`, `badtranslator` and `ocrtranslate` that caused the translated text to not be complete in some cases. -- Updated `badtranslator` to be more diverse and use all 4 available translation services. -- Updated `new` and `alter` to use modals (forms). -- Reduced the cooldown of `dump`."; + public const string GoogleTranslateLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/838833843917029446/googletranslate.png"; - public static string GitHash { get; } = Assembly.GetExecutingAssembly() - .GetCustomAttribute()? - .GitHash; + public const string BingTranslatorLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/944755269034991666/BingTranslator.png"; - public static DiscordSocketConfig ClientConfig { get; } = new DiscordSocketConfig - { - LogLevel = LogSeverity.Verbose, - UseSystemClock = false, - GatewayIntents = - GatewayIntents.Guilds | + public const string MicrosoftAzureLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/944745954605686864/Microsoft_Azure.png"; - // Moderation commands - GatewayIntents.GuildBans | + public const string YandexTranslateLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/857013120358416394/YandexTranslate.png"; - // General + Moderation commands - GatewayIntents.GuildMessages | + public const string DuckDuckGoLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/890323046286651402/unknown.png"; - // Music commands - GatewayIntents.GuildVoiceStates | + public const string BraveLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/890323194504937522/unknown.png"; - // DM support - GatewayIntents.DirectMessages - }; + public const string BadTranslatorLogoUrl = "https://cdn.discordapp.com/attachments/838832564583661638/944755022816763914/unknown.png"; - public static CommandServiceConfig CommandServiceConfig { get; } = new CommandServiceConfig - { - LogLevel = LogSeverity.Verbose, - CaseSensitiveCommands = false, - IgnoreExtraArgs = true - }; + public const string BingIconUrl = "https://cdn.discordapp.com/attachments/838832564583661638/949767220232339507/Bing_Icon.png"; - public static TimeSpan HttpClientTimeout => TimeSpan.FromSeconds(60); + public const string YandexIconUrl = "https://cdn.discordapp.com/attachments/838832564583661638/954428306533523476/Yandex_Icon.png"; - public static TimeSpan PaginatorTimeout => TimeSpan.FromMinutes(10); + public const string UrbanDictionaryIconUrl = "https://cdn.discordapp.com/attachments/838832564583661638/951936600273715300/UrbanDictionary.png"; - 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/Converters/ColorConverter.cs b/src/Converters/ColorConverter.cs new file mode 100644 index 0000000..020210d --- /dev/null +++ b/src/Converters/ColorConverter.cs @@ -0,0 +1,51 @@ +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; + +/// +/// Represents a converter of . +/// +public class ColorConverter : TypeConverter +{ + /// + public override ApplicationCommandOptionType GetDiscordType() => ApplicationCommandOptionType.String; + + /// + public override Task 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() + .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)) + && rawColor <= Discord.Color.MaxDecimalValue) + { + color = Color.FromArgb((int)rawColor); + } + } + + if (color.ToArgb() == 0) + { + var localizer = services.GetRequiredService>(); + 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)); + } +} \ No newline at end of file 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; + +/// +/// Represent a converter of . +/// +public class MicrosoftVoiceConverter : TypeConverter +{ + /// + public override ApplicationCommandOptionType GetDiscordType() => ApplicationCommandOptionType.String; + + /// + public override Task ReadAsync(IInteractionContext context, IApplicationCommandInteractionDataOption option, IServiceProvider services) + { + string value = option.Value as string ?? string.Empty; + + var translator = services + .GetRequiredService(); + + 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>(); + 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/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; + +/// +/// Represents the Fergun database context. +/// +public class FergunContext : DbContext +{ + /// + /// Initializes a new instance of the class with the specified options. + /// + /// The options. + public FergunContext(DbContextOptions options) + : base(options) + { + } + + /// + /// Gets or sets the users. + /// + public DbSet Users { get; set; } = null!; + + /// + /// Gets or sets the command stats. + /// + public DbSet 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 @@ +// +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("Name") + .HasColumnType("TEXT"); + + b.Property("UsageCount") + .HasColumnType("INTEGER"); + + b.HasKey("Name"); + + b.ToTable("CommandStats"); + }); + + modelBuilder.Entity("Fergun.Data.Models.User", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("BlacklistReason") + .HasColumnType("TEXT"); + + b.Property("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(type: "TEXT", nullable: false), + UsageCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CommandStats", x => x.Name); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false), + BlacklistStatus = table.Column(type: "INTEGER", nullable: false), + BlacklistReason = table.Column(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 @@ +// +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("Name") + .HasColumnType("TEXT"); + + b.Property("UsageCount") + .HasColumnType("INTEGER"); + + b.HasKey("Name"); + + b.ToTable("CommandStats"); + }); + + modelBuilder.Entity("Fergun.Data.Models.User", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("BlacklistReason") + .HasColumnType("TEXT"); + + b.Property("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; + +/// +/// Specifies the possible blacklist status of a user. +/// +public enum BlacklistStatus +{ + /// + /// The user is not blacklisted. + /// + None, + + /// + /// The user is blacklisted. + /// + Blacklisted, + + /// + /// The user is "shadow"-blacklisted. The user shouldn't be notified that they're blacklisted. + /// + 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; + +/// +/// Represents a bot command. +/// +public class Command +{ + /// + /// Gets or sets the name of this command. + /// + [Key] + public string Name { get; set; } = null!; + + /// + /// Gets or sets the usage count. + /// + [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; + +/// +/// Represents a database user. +/// +public class User : IEntity +{ + /// + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.None)] + public ulong Id { get; set; } + + /// + /// Gets or sets the blacklist status. + /// + [Required] + public BlacklistStatus BlacklistStatus { get; set; } + + /// + /// Gets or sets the blacklist reason. + /// + public string? BlacklistReason { get; set; } +} \ No newline at end of file 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; + +/// +/// Specifies the types of avatars. +/// +public enum AvatarType +{ + /// + /// The first available avatar (Server, then Global, then Default). + /// + [Hide] + FirstAvailable, + + /// + /// Server avatar. + /// + [ChoiceDisplay("Server avatar")] + Server, + + /// + /// Global (main) avatar. + /// + [ChoiceDisplay("Global (main) avatar")] + Global, + + /// + /// Default avatar. + /// + [ChoiceDisplay("Default avatar")] + Default +} \ No newline at end of file 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; + +/// +/// Specifies the bot lists. +/// +public enum BotList +{ + /// + /// Top.gg. + /// + TopGg, + /// + /// Discord Bots. + /// + 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..f8cd455 --- /dev/null +++ b/src/Entities/BotListOptions.cs @@ -0,0 +1,19 @@ +namespace Fergun; + +/// +/// Represents the settings related to bot lists. +/// +public class BotListOptions +{ + public const string BotList = nameof(BotList); + + /// + /// Gets or sets the update period. + /// + public TimeSpan UpdatePeriod { get; set; } + + /// + /// Gets or sets the dictionary of tokens. + /// + public IDictionary Tokens { get; set; } = new Dictionary(); +} \ No newline at end of file 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; + +/// +/// Represents the default implementation of . +/// +/// By default, this localizer has a dependency in the target localizer (), and a shared localizer , +/// both of which are used to get a localized string. +/// The to provide strings for. +public class FergunLocalizer : IFergunLocalizer +{ + private readonly IStringLocalizer _localizer; + private readonly IStringLocalizer _sharedLocalizer; + + /// + /// Initializes a new instance of the class. + /// + /// The target localizer. + /// The shared localizer. + public FergunLocalizer(IStringLocalizer localizer, IStringLocalizer sharedLocalizer) + { + _localizer = localizer; + _sharedLocalizer = sharedLocalizer; + } + + /// + public CultureInfo CurrentCulture { get; set; } = CultureInfo.CurrentCulture; + + /// + public IEnumerable GetAllStrings(bool includeParentCultures) + => _localizer.GetAllStrings(includeParentCultures).Concat(_sharedLocalizer.GetAllStrings(includeParentCultures)); + + /// + public LocalizedString this[string name] + { + get + { + Thread.CurrentThread.CurrentUICulture = CurrentCulture; + var localized = _localizer[name]; + return localized.ResourceNotFound ? _sharedLocalizer[name] : localized; + } + } + + /// + 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/FergunOptions.cs b/src/Entities/FergunOptions.cs new file mode 100644 index 0000000..e98a616 --- /dev/null +++ b/src/Entities/FergunOptions.cs @@ -0,0 +1,31 @@ +using Fergun.Interactive.Pagination; + +namespace Fergun; + +/// +/// Represents general Fergun settings. +/// +public class FergunOptions +{ + public const string Fergun = nameof(Fergun); + + /// + /// Gets or sets the support server URL. + /// + public Uri? SupportServerUrl { get; set; } + + /// + /// Gets or sets the default paginator timeout. + /// + public TimeSpan PaginatorTimeout { get; set; } + + /// + /// Gets or sets the default selection timeout. + /// + public TimeSpan SelectionTimeout { get; set; } + + /// + /// Gets or sets the dictionary of paginator emotes. + /// + public IDictionary PaginatorEmotes { get; set; } = new Dictionary(); +} \ No newline at end of file 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; + } + + /// + /// Gets a value indicating whether the response should be ephemeral. + /// + public bool IsEphemeral { get; } + + /// + /// Gets a value indicating whether the response should be silent. + /// + public bool IsSilent { get; } + + /// + /// Gets the interaction that should be responded to. + /// + public IDiscordInteraction? Interaction { get; } + + /// + /// Creates a successful instance of the class. + /// + /// The reason. + /// A . + public static FergunResult FromSuccess(string? reason = null) => new(null, reason ?? string.Empty, false, false, null); + + /// + /// Creates a with error type . + /// + /// The reason of the result. + /// Whether the response should be ephemeral. + /// The interaction that should be responded to. + /// A . + public static FergunResult FromError(string reason, bool isEphemeral = false, IDiscordInteraction? interaction = null) + => new(InteractionCommandError.Unsuccessful, reason, isEphemeral, false, interaction); + + /// + /// Creates a silent . + /// + /// A . + public static FergunResult FromSilentError() => new(InteractionCommandError.Unsuccessful, string.Empty, false, true, null); +} \ No newline at end of file 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; + +/// +/// Represents an aggregation of translators where the order of the translators can be modified. +/// +public class FergunTranslator : IFergunTranslator +{ + /// + public string Name => nameof(FergunTranslator); + + private readonly ITranslator[] _translators; + private WrapBackEnumerable _wrappedTranslators; + + /// + /// Initializes a new instance of the class. + /// + /// The Google Translator. + /// The new Google Translator. + /// The Microsoft translator. + /// The Yandex Translator. + public FergunTranslator(GoogleTranslator googleTranslator, GoogleTranslator2 googleTranslator2, + MicrosoftTranslator microsoftTranslator, YandexTranslator yandexTranslator) + { + _translators = new ITranslator[] {googleTranslator, googleTranslator2, microsoftTranslator, yandexTranslator}; + _wrappedTranslators = new WrapBackEnumerable(_translators); + } + + /// + public void Next() + { + _wrappedTranslators.Index = _wrappedTranslators.Index == _translators.Length - 1 ? 0 : _wrappedTranslators.Index + 1; + } + + /// + public void Randomize() + { + _translators.Shuffle(); + _wrappedTranslators.Index = Random.Shared.Next(0, _translators.Length); + } + + /// + /// Translates a text using the available translation services. + /// + /// The text. + /// The target language. + /// The source language. + /// A task containing the translation result. + /// This method will attempt to use all the translation services passed in the constructor, in the order they were provided. + /// Thrown when this translator has been disposed. + /// Thrown when or are null. + /// Thrown when no translator supports or . + /// Thrown when all translators fail to provide a valid result. + public async Task TranslateAsync(string text, string toLanguage, string? fromLanguage = null) + { + LanguageSupported(this, toLanguage, fromLanguage); + + List 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(); + exceptions.Add(e); + } + } + + throw new AggregateException("No translator provided a valid result.", exceptions); + } + + /// + public async Task TranslateAsync(string text, ILanguage toLanguage, ILanguage? fromLanguage = null) + { + LanguageSupported(this, toLanguage, fromLanguage); + + List 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(); + exceptions.Add(e); + } + } + + throw new AggregateException("No translator provided a valid result.", exceptions); + } + + /// + /// Transliterates a text using the available translation services. + /// + /// The text. + /// The target language. + /// The source language. + /// A task containing the transliteration result. + /// This method will attempt to use all the translation services passed in the constructor, in the order they were provided. + /// Thrown when this translator has been disposed. + /// Thrown when or are null. + /// Thrown when a could not be obtained from or . + /// Thrown when no translator supports or . + /// Thrown when all translators fail to provide a valid result. + public async Task 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); + } + + /// + public async Task TransliterateAsync(string text, ILanguage toLanguage, ILanguage? fromLanguage = null) + { + LanguageSupported(this, toLanguage, fromLanguage); + + List 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(); + exceptions.Add(e); + } + } + + throw new AggregateException("No translator provided a valid result.", exceptions); + } + + /// + /// Detects the language of a text using the available translation services. + /// + /// The text to detect its language. + /// A task that represents the asynchronous language detection operation. The task contains the detected language. + /// This method will attempt to use all the translation services passed in the constructor, in the order they were provided. + /// Thrown when this translator has been disposed. + /// Thrown when is null. + /// Thrown when all translators fail to provide a valid result. + public async Task DetectLanguageAsync(string text) + { + List exceptions = null!; + foreach (var translator in _wrappedTranslators) + { + try + { + return await translator.DetectLanguageAsync(text).ConfigureAwait(false); + } + catch (Exception e) + { + exceptions ??= new List(); + exceptions.Add(e); + } + } + + throw new AggregateException("No translator provided a valid result.", exceptions); + } + + /// + /// Returns whether at least one translator supports the specified language. + /// + /// The language. + /// if the language is supported by at least one translator, otherwise . + public bool IsLanguageSupported(string language) + { + foreach (var translator in _wrappedTranslators) + { + if (translator.IsLanguageSupported(language)) + { + return true; + } + } + + return false; + } + + /// + 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/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; + +/// +/// Represents a with a current culture which can be changed. +/// +/// The to provide strings for. +public interface IFergunLocalizer : IStringLocalizer +{ + /// + /// Gets or sets the current culture. + /// + CultureInfo CurrentCulture { get; set; } +} \ 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; + +/// +/// Provides methods to modify of the order of translators. +/// +public interface IFergunTranslator : ITranslator +{ + /// + /// Switches to the next translator. + /// + void Next(); + + /// + /// Randomizes the order of the translators. + /// + void Randomize(); +} \ No newline at end of file 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; + +/// +/// Represents the mobile patcher. +/// +public static class MobilePatcher +{ + /// + /// Patches Discord.Net to display the mobile status. + /// + 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 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/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; + +/// +/// Used for shared localization. +/// +public class SharedResource +{ +} \ No newline at end of file diff --git a/src/Entities/StartupOptions.cs b/src/Entities/StartupOptions.cs new file mode 100644 index 0000000..2c70926 --- /dev/null +++ b/src/Entities/StartupOptions.cs @@ -0,0 +1,29 @@ +namespace Fergun; + +/// +/// Represents startup settings. +/// +public class StartupOptions +{ + public const string Startup = nameof(Startup); + + /// + /// Gets or sets the token of the bot. + /// + public string Token { get; set; } = string.Empty; + + /// + /// Gets or sets the ID of the guild to register the commands for testing. + /// + public ulong TestingGuildId { get; set; } + + /// + /// Gets or sets the ID of the guild to register owner commands. + /// + public ulong OwnerCommandsGuildId { get; set; } + + /// + /// Gets or sets a value indicating whether the mobile status should be used. + /// + public bool MobileStatus { get; set; } +} \ 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; + +/// +/// Provides an enumerator that wraps back to the start after reaching the last element. +/// +/// The type of the elements to enumerate. +public struct WrapBackEnumerable +{ + private readonly T[] _items; + + /// + /// Initializes a new instance of the struct. + /// + /// The items. + public WrapBackEnumerable(T[] items) + { + _items = items; + Index = 0; + } + + /// + /// Gets or sets the index. + /// + public int Index { get; set; } + + /// + /// Returns the enumerator. + /// + /// The enumerator. + public readonly Enumerator GetEnumerator() => new(_items, Index); + + /// + /// Enumerates the elements of a . + /// + public struct Enumerator + { + private readonly IReadOnlyList _items; + private int _index; + private int _increment; + + + internal Enumerator(IReadOnlyList items, int index) + { + _items = items; + _index = index; + _increment = 0; + } + + /// + /// Advances the enumerator to the next element. + /// + public bool MoveNext() + { + if (_increment >= _items.Count) + { + return false; + } + + _index = _index == _items.Count - 1 ? 0 : _index + 1; + _increment++; + + return true; + } + + /// + /// Gets the current element. + /// + public readonly T Current => _items[_index]; + } +} \ 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); - - /// - /// Tries to delete a message. - /// - /// The source channel. - /// The message. - /// The message cache service. - public static async Task TryDeleteMessageAsync(this IMessageChannel channel, IMessage message, MessageCacheService cache = null) - => await channel.TryDeleteMessageAsync(message.Id, cache); - - /// - /// Tries to delete a message. - /// - /// The source channel. - /// The message Id. - /// The message cache service. - public static async Task 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; - - /// - /// Gets the last url in the last messages. - /// - /// The channel to search. - /// The number of messages to search. - /// The message cache service. - /// Get only urls of images. - /// An optional message to search first before searching in the channel. - /// An optional url to use before searching in the channel. - /// The maximum file size in bytes, by default. - /// A task that represents an asynchronous search operation. - 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..124e29b 100644 --- a/src/Extensions/Extensions.cs +++ b/src/Extensions/Extensions.cs @@ -1,397 +1,68 @@ -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 Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; -using Victoria; -using Victoria.Responses.Search; +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Caching; +using Polly.Caching.Memory; +using Polly.Extensions.Http; +using Polly.Registry; -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(this IList 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 SearchAsync(this LavaNode 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 [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().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().FirstOrDefault(); - - if (ratelimit != null) - { - builder.AddField("Ratelimit", string.Format(GuildUtils.Locate("RatelimitUses", language), ratelimit.InvokeLimit, ratelimit.InvokeLimitPeriod.ToShortForm2())); - } +namespace Fergun.Extensions; - // 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()); - } - - // Add aliases if present - if (command.Aliases.Count > 1) - { - builder.AddField(GuildUtils.Locate("Alias", language), string.Join(", ", command.Aliases.Skip(1))); - } +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)))); - // Add footer with info about required and optional parameters - if (command.Parameters.Count > 0) + public static IServiceCollection AddFergunPolicies(this IServiceCollection services) + { + return services.AddMemoryCache() + .AddSingleton() + .AddSingleton, PolicyRegistry>(provider => { - builder.WithFooter(GuildUtils.Locate("HelpFooter2", language)); - } - - return builder.Build(); - } + var cacheProvider = provider.GetRequiredService().AsyncFor(); + var cachePolicy = Policy.CacheAsync(cacheProvider, new SlidingTtl(TimeSpan.FromHours(2))); - /// - /// Gets the last url in the last messages. - /// - /// The channel to search. - /// The number of messages to search. - /// The message cache service. - /// Get only urls of images. - /// An optional url to use before searching in the channel. - /// The maximum file size in bytes, by default. - /// A task that represents an asynchronous search operation. - 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}" : ""); - } + var retryPolicy = HttpPolicyExtensions.HandleTransientHttpError() + .OrTransientHttpStatusCode() + .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); - 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}"; - } + var timeoutPolicy = Policy.TimeoutAsync(TimeSpan.FromSeconds(3)); - 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 $""; - } - - public static IServiceCollection AddSingletonIf(this IServiceCollection services, bool condition, TService implementationInstance) where TService : class - { - return condition ? services.AddSingleton(implementationInstance) : services; - } - - public static IServiceCollection AddSingletonIf(this IServiceCollection services, bool condition, - Func implementationFactory) where TService : class - { - return condition ? services.AddSingleton(implementationFactory) : services; - } - - public static string Dump(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 + return new PolicyRegistry { - ContractResolver = resolver, - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented + { "GeneralPolicy", Policy.WrapAsync(cachePolicy, retryPolicy) }, + { "AutocompletePolicy", Policy.WrapAsync(cachePolicy, retryPolicy, timeoutPolicy) } }; - serializer.Serialize(jsonWriter, obj); - return strWriter.ToString(); - } - catch (JsonSerializationException) - { - return null; - } - } + }); } - public class CustomJsonTextWriter : JsonTextWriter - { - public CustomJsonTextWriter(TextWriter textWriter) : base(textWriter) - { - } - - public int CurrentDepth { get; private set; } - - public override void WriteStartObject() + public static LogLevel ToLogLevel(this LogSeverity logSeverity) + => logSeverity switch { - CurrentDepth++; - base.WriteStartObject(); - } + 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 override void WriteEndObject() - { - CurrentDepth--; - base.WriteEndObject(); - } - } - - public class CustomContractResolver : DefaultContractResolver + public static string Display(this IInteractionContext context) { - private readonly Func _includeProperty; + string displayMessage = string.Empty; - public CustomContractResolver(Func includeProperty) - { - _includeProperty = includeProperty; - } + if (context.Channel is IGuildChannel guildChannel) + displayMessage = $"{guildChannel.Guild.Name}/"; - 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; - } + displayMessage += context.Channel.Name; + + return displayMessage; } + + public static IEnumerable PrependCurrentIfNotPresent(this IEnumerable 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/Extensions/ImageExtensions.cs b/src/Extensions/ImageExtensions.cs deleted file mode 100644 index a8ddd04..0000000 --- a/src/Extensions/ImageExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; - -namespace Fergun.Extensions -{ - public static class ImageExtensions - { - public static Color GetAverageColor(this Image image) - { - var average = new Rgba32(); - - image.ProcessPixelRows(accessor => - { - int r = 0; - int g = 0; - int b = 0; - - for (int y = 0; y < accessor.Height; y++) - { - var pixelRow = accessor.GetRowSpan(y); - for (int x = 0; x < pixelRow.Length; x++) - { - ref var pixel = ref pixelRow[x]; - r += pixel.R; - g += pixel.G; - b += pixel.B; - } - } - - int total = image.Width * image.Height; - - average.R = (byte)(r / total); - average.G = (byte)(g / total); - average.B = (byte)(b / total); - }); - - return Color.FromPixel(average); - } - } -} \ No newline at end of file diff --git a/src/Extensions/InteractionExtensions.cs b/src/Extensions/InteractionExtensions.cs new file mode 100644 index 0000000..27d6d2e --- /dev/null +++ b/src/Extensions/InteractionExtensions.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using Discord; +using GTranslate; + +namespace Fergun.Extensions; + +public static class InteractionExtensions +{ + public static string GetLanguageCode(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) + { + return Language.TryGetLanguage(interaction.GetLocale(), out language) || + Language.TryGetLanguage(interaction.GetLanguageCode(), 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 index 39c0928..28c3c84 100644 --- a/src/Extensions/JsonExtensions.cs +++ b/src/Extensions/JsonExtensions.cs @@ -1,28 +1,21 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; +using System.Text.Json; -namespace Fergun.Extensions -{ - public static class JsonExtensions - { - public static IEnumerable EnumerateArrayOrEmpty(this JsonElement element) - => element.ValueKind == JsonValueKind.Array ? element.EnumerateArray(): Enumerable.Empty(); +namespace Fergun.Extensions; - public static JsonElement FirstOrDefault(this JsonElement element) - => element.ValueKind == JsonValueKind.Array ? element.EnumerateArray().FirstOrDefault() : default; +public static class JsonExtensions +{ + public static IEnumerable EnumerateArrayOrEmpty(this JsonElement element) + => element.ValueKind == JsonValueKind.Array ? element.EnumerateArray() : Enumerable.Empty(); - public static JsonElement FirstOrDefault(this JsonElement element, Func predicate) - => element.ValueKind == JsonValueKind.Array ? element.EnumerateArray().FirstOrDefault(predicate) : default; + public static JsonElement FirstOrDefault(this JsonElement element) + => element.ValueKind == JsonValueKind.Array ? element.EnumerateArray().FirstOrDefault() : default; - public static JsonElement GetPropertyOrDefault(this JsonElement element, string propertyName) - => element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var value) ? value : default; + public static JsonElement FirstOrDefault(this JsonElement element, Func predicate) + => element.ValueKind == JsonValueKind.Array ? element.EnumerateArray().FirstOrDefault(predicate) : default; - public static string GetStringOrDefault(this JsonElement element) - => element.ValueKind == JsonValueKind.String ? element.GetString() : default; + public static JsonElement GetPropertyOrDefault(this JsonElement element, string propertyName) + => element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var value) ? value : default; - public static int GetInt32OrDefault(this JsonElement element) - => element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out int value) ? value : default; - } + public static string? GetStringOrDefault(this JsonElement element) + => element.ValueKind == JsonValueKind.String ? element.GetString() : 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(this IList 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..2bea5ff 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) { - /// - /// Tries to delete this message. - /// - /// The message. - /// The message cache service. - public static async Task TryDeleteAsync(this IMessage message, MessageCacheService cache = null) - { - if (message == null) return false; - - message = await message.Channel.GetMessageAsync(cache, message.Id); - - if (message == null) return false; + var builder = new StringBuilder(message.Content, message.Content.Length); - if (message.Channel is SocketGuildChannel guildChannel) + if (message.Embeds.Count > 0) + { + builder.Append('\n'); + 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; - } + builder.Append(embed.Footer?.Text); } - /// - /// Tries to remove all reactions from this message. - /// - public static async Task 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; - } - - /// - /// Modifies this message or re-sends it if no longer exists. - /// - /// A new message or a modified one. - public static async Task 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(); - }); - - return await message.Channel.GetMessageAsync(cache, message.Id) as IUserMessage; - } + return builder.ToString(); } } \ No newline at end of file diff --git a/src/Extensions/PaginatorExtensions.cs b/src/Extensions/PaginatorExtensions.cs new file mode 100644 index 0000000..81080aa --- /dev/null +++ b/src/Extensions/PaginatorExtensions.cs @@ -0,0 +1,77 @@ +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 + }; + + /// + /// Adds Fergun emotes. + /// + /// The type of the paginator. + /// The type of the paginator builder. + /// A paginator builder. + /// The interactive options. + /// This builder. + public static TBuilder WithFergunEmotes(this PaginatorBuilder builder, FergunOptions options) + where TPaginator : Paginator + where TBuilder : PaginatorBuilder + { + 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 builder.WithOptions(emotes); + } + + /// + /// Sets the localized prompts. + /// + /// The type of the paginator. + /// The type of the paginator builder. + /// The paginator builder. + /// The localizer. + /// This builder. + public static TBuilder WithLocalizedPrompts(this BaseLazyPaginatorBuilder builder, IStringLocalizer localizer) + where TPaginator : BaseLazyPaginator + where TBuilder : BaseLazyPaginatorBuilder + { + 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; + } + + 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/Extensions/StringExtensions.cs b/src/Extensions/StringExtensions.cs index c5214f7..8baa95b 100644 --- a/src/Extensions/StringExtensions.cs +++ b/src/Extensions/StringExtensions.cs @@ -1,143 +1,38 @@ -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]); - } - }); - } - - /// - /// Converts a string to its full width form. - /// - /// The string to convert. - 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; - } - }); - } - - /// - /// Truncates a string to the specified length. - /// - /// The string to truncate. - /// The maximum length of the string. - /// The truncated string. - 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 containsKeywords, StringComparison comparisonType) - { - return containsKeywords.Any(keyword => text.Contains(keyword, comparisonType)); - } + public static bool ContainsAny(this string str, string str0, string str1) => str.Contains(str0) || str.Contains(str1); - public static bool TryBase64Decode(this string text, out string decoded) - { - var buffer = new Span(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; - } + // From GTranslate + public static IEnumerable> SplitWithoutWordBreaking(this string text, int maxLength) + { + var current = text.AsMemory(); - public static string ToTitleCase(this string text) + while (!current.IsEmpty) { - return string.Create(text.Length, text, (chars, state) => - { - state.AsSpan().ToLowerInvariant(chars); - chars[0] = char.ToUpperInvariant(state[0]); - }); - } + int index = -1; + int length; - public static IEnumerable SplitBySeparatorWithLimit(this string text, char separator, int maxLength) - { - var sb = new StringBuilder(); - foreach (var part in text.Split(separator)) + if (current.Length <= maxLength) { - if (part.Length + sb.Length >= maxLength) - { - yield return sb.ToString(); - sb.Clear(); - } - - sb.Append(part); - sb.Append(separator); + length = current.Length; } - if (sb.Length != 0) + else { - yield return sb.ToString(); + index = current[..maxLength].Span.LastIndexOf(' '); + length = index == -1 ? maxLength : index; } - } - public static int ToColor(this string str) - { - int hash = 0; - foreach (char ch in str) + var line = current[..length]; + // skip a single space if there's one + if (index != -1) { - hash = ch + ((hash << 5) - hash); + length++; } - return hash; - //string c = (hash & 0x00FFFFFF).ToString("X4").ToUpperInvariant(); - //return "00000".Substring(0, 6 - c.Length) + c; + current = current[length..]; + yield return line; } } } \ 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 - { - /// - /// Formats a TimeSpan to mm:ss or hh:mm:ss. - /// - public static string ToShortForm(this TimeSpan t) => t.ToString((t.Hours > 0 ? @"hh\:" : "") + @"mm\:ss"); - - /// - /// Formats a TimeSpan to ss, mm ss, hh mm ss or dd hh mm ss. - /// - 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..36cdc5f --- /dev/null +++ b/src/Extensions/TimestampExtensions.cs @@ -0,0 +1,10 @@ +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 long timestamp, char style = 'f') + => $""; +} \ No newline at end of file diff --git a/src/Fergun.csproj b/src/Fergun.csproj index e21e135..9545145 100644 --- a/src/Fergun.csproj +++ b/src/Fergun.csproj @@ -1,53 +1,43 @@ - + Exe net6.0 - 1.9 - 10 - en - en - d4n3436 - https://github.com/d4n3436/Fergun - https://github.com/d4n3436/Fergun + enable + enable + 2.0-beta - - - - - - - $(IntermediateOutputPath)GitHashInfo.cs - - - - - - - - - <_Parameter1>$(GitHash) - - - - - + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + - - - - - - - - - - - - - + + PreserveNewest + 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 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(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(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(); - - // Resolve dependencies - services.GetService(); - services.GetService(); - - await services.GetRequiredService().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 LoadConfigAsync(string path) where T : class, new() - { - T config = null; - - if (File.Exists(path)) - { - try - { - config = JsonConvert.DeserializeObject(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() - .AddSingleton(s => new LogService(_client, s.GetRequiredService(), Config.LogLevel)) - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton(s => Config.UseMessageCacheService && Config.MessageCacheSize > 0 - ? new MessageCacheService(s.GetRequiredService(), Config.MessageCacheSize, - s.GetRequiredService().LogAsync, - Constants.MessageCacheClearInterval, - Constants.MaxMessageCacheLongevity, Config.MinimumCommandTime) - : MessageCacheService.Disabled) - .AddSingleton(s => Config.UseCommandCacheService - ? new CommandCacheService(s.GetRequiredService(), Constants.MessageCacheCapacity, - s.GetRequiredService().HandleCommandAsync, - s.GetRequiredService().LogAsync, Constants.CommandCacheClearInterval, - Constants.MaxCommandCacheLongevity, s.GetRequiredService()) - : CommandCacheService.Disabled) - .AddSingletonIf(Config.UseReliabilityService, - s => new ReliabilityService(s.GetRequiredService(), s.GetRequiredService().LogAsync)) - .AddSingletonIf(!IsDebugMode, - s => new BotListService(s.GetRequiredService(), Config.TopGgApiToken, Config.DiscordBotsApiToken, - logger: s.GetRequiredService().LogAsync)) - .BuildServiceProvider(); - } - - private static IEnumerable GetAvailableCultures() - { - var result = new List(); - - 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(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(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 deleted file mode 100644 index 09dc9ee..0000000 --- a/src/FergunResult.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Discord; -using Discord.Commands; - -namespace Fergun -{ - public class FergunResult : RuntimeResult - { - /// - /// Gets whether this result is silent. - /// - public bool IsSilent { get; } - - /// - /// 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. - /// - 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, bool isSilent = false, IUserMessage responseMessage = null) - => new FergunResult(null, reason, isSilent, responseMessage); - } -} \ No newline at end of file diff --git a/src/Modules/AIDungeon.cs b/src/Modules/AIDungeon.cs deleted file mode 100644 index 61eb6c0..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 _queue = new ConcurrentDictionary(); - private static IReadOnlyDictionary _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 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 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() - .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 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 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 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(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 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 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 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 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 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(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 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 MakePublic([Summary("makepublicParam1")] uint adventureId) - { - var adventure = FergunClient.Database.FindDocument(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 MakePrivate([Summary("makeprivateParam1")] uint adventureId) - { - var adventure = FergunClient.Database.FindDocument(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 IdList([Summary("idlistParam1")] IUser user = null) - { - user ??= Context.User; - var adventures = FergunClient.Database.FindManyDocuments(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 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(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 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(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(3, 1, Measure.Minutes)] - [Summary("dumpSummary")] - [Alias("export")] - [Example("2582734")] - public async Task 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(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 Give([Summary("giveParam1")] uint adventureId, [Remainder, Summary("giveParam2")] IUser user) - { - var adventure = FergunClient.Database.FindDocument(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 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 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/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 _logger; + private readonly IFergunLocalizer _localizer; + private readonly FergunContext _db; + + public BlacklistModule(ILogger logger, IFergunLocalizer 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 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 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/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 -{ - /// - public abstract class FergunBase : FergunBase - { - } - - /// - /// The command module base that Fergun uses in its modules. - /// - public abstract class FergunBase : CommandCacheModuleBase - where T : ShardedCommandContext - { - /// - /// Gets or sets the interactive service. - /// - public InteractiveService Interactive { get; set; } - - /// - /// Gets or sets the message cache service. - /// - public MessageCacheService MessageCache { get; set; } - - /// - public async Task 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); - } - } - } - - /// - public async Task> SendSelectionAsync(BaseSelection 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); - } - } - } - - /// - protected override async Task 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(); - x.AllowedMentions = allowedMentions ?? Optional.Create(); - 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; - } - - /// - /// Returns the prefix of the source channel. - /// - /// The prefix of the channel. - public string GetPrefix() => GuildUtils.GetPrefix(Context.Channel); - - /// - /// Returns the configuration of a guild using the source channel. - /// - /// The configuration of the guild, or null if the guild cannot be found in the database. - public GuildConfig GetGuildConfig() => GuildUtils.GetGuildConfig(Context.Channel); - - /// - /// Returns the language of the source channel. - /// - /// The language of the source channel. - public string GetLanguage() => GuildUtils.GetLanguage(Context.Channel); - - /// - /// Returns the localized value of a resource key. - /// - /// The resource key to localize. - /// The localized text, or if the value cannot be found. - public string Locate(string key) => GuildUtils.Locate(key, Context.Channel); - - /// - /// Returns the localized value of a boolean. - /// - /// The boolean to localize. - /// The localized boolean. - public string Locate(bool boolean) => GuildUtils.Locate(boolean ? "Yes" : "No", Context.Channel); - - /// - /// Returns the localized value of a resource key in the specified language. - /// - /// The resource key to localize. - /// The language to localize the resource key. - /// The localized text, or if the value cannot be found. - public string Locate(string key, string language) => GuildUtils.Locate(key, language); - - /// - /// Sends or edits an embed to the source channel, and adds the response to the cache if the message is new. - /// - /// The embed description. - /// A task that represents the send or edit operation. The task contains the sent or edited message. - public async Task 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..13e7130 --- /dev/null +++ b/src/Modules/Handlers/BraveAutocompleteHandler.cs @@ -0,0 +1,47 @@ +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 BraveAutocompleteHandler : AutocompleteHandler +{ + /// + public override async Task GenerateSuggestionsAsync(IInteractionContext context, + IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services) + { + var text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim(); + + if (string.IsNullOrEmpty(text)) + return AutocompletionResult.FromSuccess(); + + var client = services + .GetRequiredService() + .CreateClient("autocomplete"); + + var policy = services + .GetRequiredService>() + .Get>("AutocompletePolicy"); + + 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 + .RootElement[1] + .EnumerateArray() + .Select(x => new AutocompleteResult(x.GetString(), x.GetString())) + .PrependCurrentIfNotPresent(text) + .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..26b040f --- /dev/null +++ b/src/Modules/Handlers/DuckDuckGoAutocompleteHandler.cs @@ -0,0 +1,57 @@ +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 +{ + /// + public override async Task GenerateSuggestionsAsync(IInteractionContext context, + IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services) + { + var text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim(); + + if (string.IsNullOrEmpty(text)) + return AutocompletionResult.FromSuccess(); + + var client = services + .GetRequiredService() + .CreateClient("autocomplete"); + + var policy = services + .GetRequiredService>() + .Get>("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}"; + + 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())) + .PrependCurrentIfNotPresent(text) + .Take(25); + + return AutocompletionResult.FromSuccess(results); + } +} \ 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 +{ + /// + public override async Task 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(); + + 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/Handlers/GoogleAutocompleteHandler.cs b/src/Modules/Handlers/GoogleAutocompleteHandler.cs new file mode 100644 index 0000000..9422e1e --- /dev/null +++ b/src/Modules/Handlers/GoogleAutocompleteHandler.cs @@ -0,0 +1,48 @@ +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 +{ + /// + public override async Task 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 client = services + .GetRequiredService() + .CreateClient("autocomplete"); + + var policy = services + .GetRequiredService>() + .Get>("AutocompletePolicy"); + + 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); + + var results = document + .RootElement[1] + .EnumerateArray() + .Select(x => new AutocompleteResult(x.GetString(), x.GetString())) + .PrependCurrentIfNotPresent(text) + .Take(25); + + return AutocompletionResult.FromSuccess(results); + } +} \ 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 GenerateSuggestionsAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services) + { + string text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim(); + + var translator = services + .GetRequiredService(); + + 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/Handlers/TranslateAutocompleteHandler.cs b/src/Modules/Handlers/TranslateAutocompleteHandler.cs new file mode 100644 index 0000000..d3643e7 --- /dev/null +++ b/src/Modules/Handlers/TranslateAutocompleteHandler.cs @@ -0,0 +1,54 @@ +using Discord; +using Discord.Interactions; +using Fergun.Extensions; +using GTranslate; + +namespace Fergun.Modules.Handlers; + +public class TranslateAutocompleteHandler : AutocompleteHandler +{ + /// + public override Task GenerateSuggestionsAsync(IInteractionContext context, + IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services) + { + string text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim(); + + IEnumerable 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); + + 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 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(userLanguage)).Prepend(userLanguage); + } + } + + var results = languages + .Select(x => new AutocompleteResult($"{x.Name}{(x.Name == x.NativeName ? "" : $" ({x.NativeName})")} ({x.ISO6391})", x.ISO6391)) + .Take(25); + + return Task.FromResult(AutocompletionResult.FromSuccess(results)); + } +} \ No newline at end of file diff --git a/src/Modules/Handlers/TtsAutocompleteHandler.cs b/src/Modules/Handlers/TtsAutocompleteHandler.cs new file mode 100644 index 0000000..5e90f24 --- /dev/null +++ b/src/Modules/Handlers/TtsAutocompleteHandler.cs @@ -0,0 +1,39 @@ +using Discord; +using Discord.Interactions; +using Fergun.Extensions; +using GTranslate; +using GTranslate.Translators; + +namespace Fergun.Modules.Handlers; + +public class TtsAutocompleteHandler : AutocompleteHandler +{ + public override Task GenerateSuggestionsAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services) + { + string text = (autocompleteInteraction.Data.Current.Value as string ?? "").Trim(); + + IEnumerable languages = GoogleTranslator2 + .TextToSpeechLanguages + .Cast() + .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 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(userLanguage)).Prepend(userLanguage); + } + + var results = languages + .Select(x => new AutocompleteResult($"{x.Name}{(x.Name == x.NativeName ? "" : $" ({x.NativeName})")} ({x.ISO6391})", x.ISO6391)) + .Take(25); + + return Task.FromResult(AutocompletionResult.FromSuccess(results)); + } +} \ 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..9c69912 --- /dev/null +++ b/src/Modules/Handlers/UrbanAutocompleteHandler.cs @@ -0,0 +1,31 @@ +using Discord; +using Discord.Interactions; +using Fergun.Apis.Urban; +using Fergun.Extensions; +using Humanizer; +using Microsoft.Extensions.DependencyInjection; + +namespace Fergun.Modules.Handlers; + +public class UrbanAutocompleteHandler : AutocompleteHandler +{ + /// + public override async Task 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(); + + var results = (await urbanDictionary.GetAutocompleteResultsAsync(text)) + .Select(x => new AutocompleteResult(x.Truncate(100), x.Truncate(100))) + .PrependCurrentIfNotPresent(text) + .Take(25); + + return AutocompletionResult.FromSuccess(results); + } +} \ 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..a21279a --- /dev/null +++ b/src/Modules/Handlers/WikipediaAutocompleteHandler.cs @@ -0,0 +1,31 @@ +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 +{ + /// + public override async Task 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(); + + var results = (await urbanDictionary.GetAutocompleteResultsAsync(text, autocompleteInteraction.GetLanguageCode())) + .Select(x => new AutocompleteResult(x.Truncate(100), x.Truncate(100))) + .PrependCurrentIfNotPresent(text) + .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 new file mode 100644 index 0000000..0248be0 --- /dev/null +++ b/src/Modules/Handlers/YouTubeAutocompleteHandler.cs @@ -0,0 +1,49 @@ +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 +{ + /// + public override async Task 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 client = services + .GetRequiredService() + .CreateClient("autocomplete"); + + var policy = services + .GetRequiredService>() + .Get>("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)); + 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())) + .PrependCurrentIfNotPresent(text) + .Take(25); + + return AutocompletionResult.FromSuccess(results); + } +} \ No newline at end of file diff --git a/src/Modules/ImageModule.cs b/src/Modules/ImageModule.cs new file mode 100644 index 0000000..b73b536 --- /dev/null +++ b/src/Modules/ImageModule.cs @@ -0,0 +1,388 @@ +using System.Globalization; +using Discord; +using Discord.Interactions; +using Fergun.Apis.Bing; +using Fergun.Apis.Yandex; +using Fergun.Extensions; +using Fergun.Interactive; +using Fergun.Interactive.Pagination; +using Fergun.Interactive.Selection; +using Fergun.Modules.Handlers; +using GScraper; +using GScraper.Brave; +using GScraper.DuckDuckGo; +using GScraper.Google; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Fergun.Modules; + +[Group("img", "Image search commands.")] +public class ImageModule : InteractionModuleBase +{ + private readonly ILogger _logger; + private readonly IFergunLocalizer _localizer; + private readonly FergunOptions _fergunOptions; + 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 logger, IFergunLocalizer localizer, IOptionsSnapshot fergunOptions, + InteractiveService interactive, GoogleScraper googleScraper, DuckDuckGoScraper duckDuckGoScraper, BraveScraper braveScraper, + IBingVisualSearch bingVisualSearch, IYandexImageSearch yandexImageSearch) + { + _logger = logger; + _localizer = localizer; + _fergunOptions = fergunOptions.Value; + _interactive = interactive; + _googleScraper = googleScraper; + _duckDuckGoScraper = duckDuckGoScraper; + _braveScraper = braveScraper; + _bingVisualSearch = bingVisualSearch; + _yandexImageSearch = yandexImageSearch; + } + + 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 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(); + + 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())) + .Where(x => x.Url.StartsWith("http") && x.SourceUrl.StartsWith("http")) + .Chunk(multiImages ? 4 : 1) + .ToArray(); + + _logger.LogInformation("Image results: {count}", images.Length); + + if (images.Length == 0) + { + return FergunResult.FromError(_localizer["No results."]); + } + + var paginator = new LazyPaginatorBuilder() + .WithPageFactory(GeneratePage) + .WithFergunEmotes(_fergunOptions) + .WithActionOnCancellation(ActionOnStop.DisableInput) + .WithActionOnTimeout(ActionOnStop.DisableInput) + .WithMaxPageIndex(images.Length - 1) + .WithFooter(PaginatorFooter.None) + .AddUser(Context.User) + .WithLocalizedPrompts(_localizer) + .Build(); + + await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _fergunOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource); + + return FergunResult.FromSuccess(); + + MultiEmbedPageBuilder GeneratePage(int index) + { + 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, images.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 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(); + + bool isNsfw = Context.Channel.IsNsfw(); + _logger.LogInformation("Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw); + + var images = (await _duckDuckGoScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict)) + .Chunk(multiImages ? 4 : 1) + .ToArray(); + + _logger.LogInformation("Image results: {count}", images.Length); + + if (images.Length == 0) + { + return FergunResult.FromError(_localizer["No results."]); + } + + var paginator = new LazyPaginatorBuilder() + .WithPageFactory(GeneratePage) + .WithFergunEmotes(_fergunOptions) + .WithActionOnCancellation(ActionOnStop.DisableInput) + .WithActionOnTimeout(ActionOnStop.DisableInput) + .WithMaxPageIndex(images.Length - 1) + .WithFooter(PaginatorFooter.None) + .AddUser(Context.User) + .WithLocalizedPrompts(_localizer) + .Build(); + + await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _fergunOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource); + + return FergunResult.FromSuccess(); + + MultiEmbedPageBuilder GeneratePage(int index) + { + var builders = images[index].Select(result => new EmbedBuilder() + .WithTitle(result.Title) + .WithDescription(_localizer["DuckDuckGo image search"]) + .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 new MultiEmbedPageBuilder().WithBuilders(builders); + } + } + + [SlashCommand("brave", "Searches for images from Brave and displays them in a paginator.")] + public async Task 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(); + + bool isNsfw = Context.Channel.IsNsfw(); + _logger.LogInformation("Query: \"{query}\", is NSFW: {isNsfw}", query, isNsfw); + + var images = (await _braveScraper.GetImagesAsync(query, isNsfw ? SafeSearchLevel.Off : SafeSearchLevel.Strict)) + .Chunk(multiImages ? 4 : 1) + .ToArray(); + + _logger.LogInformation("Image results: {count}", images.Length); + + if (images.Length == 0) + { + return FergunResult.FromError(_localizer["No results."]); + } + + var paginator = new LazyPaginatorBuilder() + .WithPageFactory(GeneratePage) + .WithFergunEmotes(_fergunOptions) + .WithActionOnCancellation(ActionOnStop.DisableInput) + .WithActionOnTimeout(ActionOnStop.DisableInput) + .WithMaxPageIndex(images.Length - 1) + .WithFooter(PaginatorFooter.None) + .AddUser(Context.User) + .WithLocalizedPrompts(_localizer) + .Build(); + + await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _fergunOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource); + + return FergunResult.FromSuccess(); + + MultiEmbedPageBuilder GeneratePage(int index) + { + var builders = images[index].Select(result => new EmbedBuilder() + .WithTitle(result.Title) + .WithDescription(_localizer["Brave image search"]) + .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 new MultiEmbedPageBuilder().WithBuilders(builders); + } + } + + [MessageCommand("Reverse Image Search")] + public async Task ReverseAsync(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) + { + return FergunResult.FromError(_localizer["Unable to get an image URL from the message."], true); + } + + var page = new PageBuilder() + .WithTitle(_localizer["Select an image search engine"]) + .WithColor(Color.Orange); + + var selection = new SelectionBuilder() + .AddUser(Context.User) + .WithOptions(Enum.GetValues()) + .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) + { + return await ReverseAsync(url, result.Value, false, result.StopInteraction!, true); + } + + return FergunResult.FromSilentError(); + } + + [SlashCommand("reverse", "Reverse image search.")] + public async Task 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); + } + + public async Task ReverseAsync(string url, ReverseImageSearchEngine engine, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false) + { + return await (engine switch + { + ReverseImageSearchEngine.Yandex => YandexAsync(url, multiImages, interaction, ephemeral), + ReverseImageSearchEngine.Bing => BingAsync(url, multiImages, interaction, ephemeral), + _ => throw new ArgumentException(_localizer["Invalid image search engine."], nameof(engine)) + }); + } + + public virtual async Task YandexAsync(string url, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false) + { + if (interaction is IComponentInteraction componentInteraction) + { + await componentInteraction.DeferLoadingAsync(ephemeral); + } + else + { + await interaction.DeferAsync(ephemeral); + } + + bool isNsfw = Context.Channel.IsNsfw(); + + 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); + return FergunResult.FromError(e.Message, ephemeral, interaction); + } + + if (results.Length == 0) + { + return FergunResult.FromError(_localizer["No results."], ephemeral, interaction); + } + + var paginator = new LazyPaginatorBuilder() + .WithPageFactory(GeneratePage) + .WithFergunEmotes(_fergunOptions) + .WithActionOnCancellation(ActionOnStop.DisableInput) + .WithActionOnTimeout(ActionOnStop.DisableInput) + .WithMaxPageIndex(results.Length - 1) + .WithFooter(PaginatorFooter.None) + .AddUser(interaction.User) + .WithLocalizedPrompts(_localizer) + .Build(); + + await _interactive.SendPaginatorAsync(paginator, interaction, _fergunOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource, ephemeral); + + return FergunResult.FromSuccess(); + + 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(_localizer["Yandex Visual Search | Page {0} of {1}", index + 1, results.Length], Constants.YandexIconUrl) + .WithColor(Color.Orange)); + + return new MultiEmbedPageBuilder().WithBuilders(builders); + } + } + + public virtual async Task BingAsync(string url, bool multiImages, IDiscordInteraction interaction, bool ephemeral = false) + { + if (interaction is IComponentInteraction componentInteraction) + { + await componentInteraction.DeferLoadingAsync(ephemeral); + } + else + { + await interaction.DeferAsync(ephemeral); + } + + bool isNsfw = Context.Channel.IsNsfw(); + IBingReverseImageSearchResult[][] results; + + try + { + results = (await _bingVisualSearch.ReverseImageSearchAsync(url, isNsfw ? BingSafeSearchLevel.Off : BingSafeSearchLevel.Strict, interaction.GetLanguageCode())) + .Chunk(multiImages ? 4 : 1) + .ToArray(); + } + catch (BingException e) + { + _logger.LogWarning(e, "Failed to perform reverse image search to url {url}", url); + return FergunResult.FromError(_localizer[e.Message], ephemeral, interaction); + } + + if (results.Length == 0) + { + return FergunResult.FromError(_localizer["No results."], ephemeral, interaction); + } + + var paginator = new LazyPaginatorBuilder() + .WithPageFactory(GeneratePage) + .WithFergunEmotes(_fergunOptions) + .WithActionOnCancellation(ActionOnStop.DisableInput) + .WithActionOnTimeout(ActionOnStop.DisableInput) + .WithMaxPageIndex(results.Length - 1) + .WithFooter(PaginatorFooter.None) + .AddUser(interaction.User) + .WithLocalizedPrompts(_localizer) + .Build(); + + await _interactive.SendPaginatorAsync(paginator, interaction, _fergunOptions.PaginatorTimeout, InteractionResponseType.DeferredChannelMessageWithSource, ephemeral); + + return FergunResult.FromSuccess(); + + 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) + .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)); + + return new MultiEmbedPageBuilder().WithBuilders(builders); + } + } + + public enum ReverseImageSearchEngine + { + Bing, + Yandex + } +} \ 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 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 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 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 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 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 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 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 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 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().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 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 Play([Remainder, Summary("playParam1")] string query) - { - var user = (SocketGuildUser)Context.User; - string response; - IReadOnlyList 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() - .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 Spotify([Remainder, Summary("spotifyParam1")] IUser user = null) - { - if (!FergunClient.Config.PresenceIntent) - { - return FergunResult.FromError(Locate("NoPresenceIntent")); - } - - user ??= Context.User; - var spotify = user.Activities?.OfType().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/OcrModule.cs b/src/Modules/OcrModule.cs new file mode 100644 index 0000000..e9ad1ac --- /dev/null +++ b/src/Modules/OcrModule.cs @@ -0,0 +1,201 @@ +using System.Diagnostics; +using System.Globalization; +using Discord; +using Discord.Interactions; +using Fergun.Apis.Bing; +using Fergun.Apis.Yandex; +using Fergun.Extensions; +using Fergun.Interactive; +using Fergun.Interactive.Selection; +using Humanizer; +using Microsoft.Extensions.Logging; + +namespace Fergun.Modules; + +[Group("ocr", "OCR commands.")] +public class OcrModule : InteractionModuleBase +{ + private readonly ILogger _logger; + private readonly IFergunLocalizer _localizer; + private readonly SharedModule _shared; + private readonly InteractiveService _interactive; + private readonly IBingVisualSearch _bingVisualSearch; + private readonly IYandexImageSearch _yandexImageSearch; + + public OcrModule(ILogger logger, IFergunLocalizer 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 = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode()); + + [MessageCommand("OCR")] + public async Task OcrAsync(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) + { + return FergunResult.FromError(_localizer["Unable to get an image URL from the message."], true); + } + + var page = new PageBuilder() + .WithTitle(_localizer["Select an OCR engine"]) + .WithColor(Color.Orange); + + var selection = new SelectionBuilder() + .AddUser(Context.User) + .WithOptions(Enum.GetValues()) + .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) + { + 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 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 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 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); + } + + var ocrTask = ocrEngine switch + { + OcrEngine.Bing => _bingVisualSearch.OcrAsync(url), + OcrEngine.Yandex => _yandexImageSearch.OcrAsync(url), + _ => throw new ArgumentException(_localizer["Invalid OCR engine."], nameof(ocrEngine)) + }; + + if (interaction is IComponentInteraction componentInteraction) + { + await componentInteraction.DeferLoadingAsync(ephemeral); + } + else + { + await interaction.DeferAsync(ephemeral); + } + + var stopwatch = Stopwatch.StartNew(); + string? text; + + try + { + text = await ocrTask; + } + catch (Exception e) when (e is BingException or YandexException) + { + _logger.LogWarning(e, "Failed to perform OCR to url {url}", url); + return FergunResult.FromError(_localizer[e.Message], ephemeral, interaction); + } + + if (string.IsNullOrWhiteSpace(text)) + { + return FergunResult.FromError(_localizer["The OCR yielded no results."], ephemeral, interaction); + } + + stopwatch.Stop(); + + interaction.TryGetLanguage(out var language); + + var (name, iconUrl) = ocrEngine switch + { + OcrEngine.Bing => (_localizer["Bing Visual Search"], Constants.BingIconUrl), + OcrEngine.Yandex => (_localizer["Yandex OCR"], Constants.YandexIconUrl), + _ => throw new ArgumentException(_localizer["Invalid OCR engine."], nameof(ocrEngine)) + }; + + string embedText = $"**{_localizer["Output"]}**\n"; + + var builder = new EmbedBuilder() + .WithTitle(_localizer["OCR Results"]) + .WithDescription($"{embedText}```{text.Replace('`', '´').Truncate(EmbedBuilder.MaxDescriptionLength - embedText.Length - 6)}```") + .WithThumbnailUrl(url) + .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(buttonText, "ocrtranslate", ButtonStyle.Secondary) + .WithButton("TTS", "ocrtts", ButtonStyle.Secondary) + .Build(); + + await interaction.FollowupAsync(embed: builder.Build(), components: components, ephemeral: ephemeral); + + return FergunResult.FromSuccess(); + } + + [ComponentInteraction("ocrtranslate", true)] + public async Task OcrTranslateAsync() + { + string text = ((IComponentInteraction)Context.Interaction).Message.Embeds.First().Description; + int startIndex = text.IndexOf('`', StringComparison.Ordinal) + 3; + text = text[startIndex..^3]; + + return await _shared.TranslateAsync(Context.Interaction, text, Context.Interaction.GetLanguageCode(), ephemeral: true); + } + + [ComponentInteraction("ocrtts", true)] + public async Task OcrTtsAsync() + { + string text = ((IComponentInteraction)Context.Interaction).Message.Embeds.First().Description; + int startIndex = text.IndexOf('`', StringComparison.Ordinal) + 3; + text = text[startIndex..^3]; + + return await _shared.GoogleTtsAsync(Context.Interaction, text, Context.Interaction.GetLanguageCode(), true); + } + + public enum OcrEngine + { + Bing, + Yandex + } +} \ No newline at end of file diff --git a/src/Modules/Other.cs b/src/Modules/Other.cs deleted file mode 100644 index f6a9ffc..0000000 --- a/src/Modules/Other.cs +++ /dev/null @@ -1,957 +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 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 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 CmdStats() - { - var stats = DatabaseConfig.CommandStats.OrderByDescending(x => x.Value); - int i = 1; - string current = ""; - var splitStats = new List(); - - 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 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 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 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 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 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 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 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() - .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 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}")})"; - } - - 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"), $"Discord.Net v{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 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(question.IncorrectAnswers) - { - question.CorrectAnswer - }; - - options.Shuffle(); - - string optionsText = ""; - var selections = new Dictionary(); - - 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() - .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 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, LanguageSelectionBuilder> - { - public new IDictionary Options { get; set; } = new Dictionary(); - - public override InputType InputType { get; set; } = InputType.SelectMenus; - - public override Func, 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 options) - { - Options = options; - base.Options = Options; - return this; - } - - public LanguageSelectionBuilder WithOptions(IReadOnlyDictionary options) - { - Options = new Dictionary(options); - base.Options = Options; - return this; - } - - public override LanguageSelection Build() => new(this); - } - - /// - /// Custom selection for - /// - internal class LanguageSelection : BaseSelection> - { - 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(); - - 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/OtherModule.cs b/src/Modules/OtherModule.cs new file mode 100644 index 0000000..acb618c --- /dev/null +++ b/src/Modules/OtherModule.cs @@ -0,0 +1,324 @@ +using System.Diagnostics; +using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; +using Discord; +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; + +namespace Fergun.Modules; + +public class OtherModule : InteractionModuleBase +{ + private readonly ILogger _logger; + private readonly IFergunLocalizer _localizer; + private readonly FergunOptions _fergunOptions; + private readonly InteractiveService _interactive; + private readonly IGeniusClient _geniusClient; + private readonly HttpClient _httpClient; + private readonly FergunContext _db; + + public OtherModule(ILogger logger, IFergunLocalizer localizer, IOptionsSnapshot fergunOptions, + InteractiveService interactive, IGeniusClient geniusClient, HttpClient httpClient, FergunContext db) + { + _logger = logger; + _localizer = localizer; + _fergunOptions = fergunOptions.Value; + _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 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(_fergunOptions) + .WithLocalizedPrompts(_localizer) + .Build(); + + await _interactive.SendPaginatorAsync(paginator, Context.Interaction, _fergunOptions.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 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("invite", "Invite Fergun to your server.")] + public async Task 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 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(_fergunOptions) + .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 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; + } + + IReadOnlyCollection guilds; + int shards = 1; + int shardId = 0; + int? totalUsersInShard = null; + DiscordSocketClient? shard = null; + + if (Context is ShardedInteractionContext shardedContext) + { + 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 + { + // 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()?.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/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 _scriptAssemblies; - private static IEnumerable _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(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 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 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 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 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 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 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 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/OwnerModule.cs b/src/Modules/OwnerModule.cs new file mode 100644 index 0000000..8a208a8 --- /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; + +[RequireOwner] +public class OwnerModule : InteractionModuleBase +{ + private readonly ILogger _logger; + private readonly IFergunLocalizer _localizer; + + public OwnerModule(ILogger logger, IFergunLocalizer localizer) + { + _logger = logger; + _localizer = localizer; + } + + [SlashCommand("cmd", "Executes a command.")] + public async Task 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/SharedModule.cs b/src/Modules/SharedModule.cs new file mode 100644 index 0000000..8a96845 --- /dev/null +++ b/src/Modules/SharedModule.cs @@ -0,0 +1,129 @@ +using System.Globalization; +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; + +/// +/// Module that contains shared methods used across modules. +/// +public class SharedModule +{ + private readonly ILogger _logger; + private readonly IFergunLocalizer _localizer; + private readonly IFergunTranslator _translator; + private readonly GoogleTranslator2 _googleTranslator2; + + public SharedModule(ILogger logger, IFergunLocalizer localizer, IFergunTranslator 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) + { + _localizer.CurrentCulture = CultureInfo.GetCultureInfo(interaction.GetLanguageCode()); + + if (string.IsNullOrWhiteSpace(text)) + { + return FergunResult.FromError(_localizer["The text must not be empty."], true, interaction); + } + + if (!Language.TryGetLanguage(target, out _)) + { + return FergunResult.FromError(_localizer["Invalid target language \"{0}\".", target], true, interaction); + } + + if (source != null && !Language.TryGetLanguage(source, out _)) + { + return FergunResult.FromError(_localizer["Invalid source language \"{0}\".", source], true, interaction); + } + + if (interaction is IComponentInteraction 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(e, "Error translating text {text} ({source} -> {target})", text, source ?? "auto", target); + return FergunResult.FromError(e.Message, ephemeral, interaction); + } + + string thumbnailUrl = result.Service switch + { + "BingTranslator" => Constants.BingTranslatorLogoUrl, + "MicrosoftTranslator" => Constants.MicrosoftAzureLogoUrl, + "YandexTranslator" => Constants.YandexTranslateLogoUrl, + _ => Constants.GoogleTranslateLogoUrl + }; + + string embedText = $"**{_localizer[source is null ? "Source language (Detected)" : "Source language"]}**\n" + + $"{DisplayName(result.SourceLanguage)}\n\n" + + $"**{_localizer["Target language"]}**\n" + + $"{DisplayName(result.TargetLanguage)}\n\n" + + $"**{_localizer["Result"]}**\n"; + + string translation = result.Translation.Replace('`', '´').Truncate(EmbedBuilder.MaxDescriptionLength - embedText.Length - 6); + + var builder = new EmbedBuilder() + .WithTitle(_localizer["Translation result"]) + .WithDescription($"{embedText}```{translation}```") + .WithThumbnailUrl(thumbnailUrl) + .WithColor(Color.Orange); + + 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 GoogleTtsAsync(IDiscordInteraction interaction, string text, string target, bool ephemeral = false) + { + _localizer.CurrentCulture = CultureInfo.GetCultureInfo(interaction.GetLanguageCode()); + + if (string.IsNullOrWhiteSpace(text)) + { + return FergunResult.FromError(_localizer["The text must not be empty."], true, interaction); + } + + if (!Language.TryGetLanguage(target, out var language) || !GoogleTranslator2.TextToSpeechLanguages.Contains(language)) + { + return FergunResult.FromError(_localizer["Language \"{0}\" not supported.", target], true, interaction); + } + + if (interaction is IComponentInteraction componentInteraction) + { + await componentInteraction.DeferLoadingAsync(ephemeral); + } + else + { + await interaction.DeferAsync(ephemeral); + } + + 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/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/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 _logger; + private readonly IFergunLocalizer _localizer; + private readonly SharedModule _shared; + private readonly MicrosoftTranslator _microsoftTranslator; + + public TtsModule(ILogger logger, IFergunLocalizer 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 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 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 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/UrbanModule.cs b/src/Modules/UrbanModule.cs new file mode 100644 index 0000000..c241a1b --- /dev/null +++ b/src/Modules/UrbanModule.cs @@ -0,0 +1,113 @@ +using System.Globalization; +using System.Text; +using Discord; +using Discord.Interactions; +using Fergun.Apis.Urban; +using Fergun.Extensions; +using Fergun.Interactive; +using Fergun.Interactive.Pagination; +using Fergun.Modules.Handlers; +using Microsoft.Extensions.Options; + +namespace Fergun.Modules; + +[Group("urban", "Urban Dictionary commands")] +public class UrbanModule : InteractionModuleBase +{ + private readonly IFergunLocalizer _localizer; + private readonly FergunOptions _fergunOptions; + private readonly IUrbanDictionary _urbanDictionary; + private readonly InteractiveService _interactive; + + public UrbanModule(IFergunLocalizer localizer, IOptionsSnapshot fergunOptions, + IUrbanDictionary urbanDictionary, InteractiveService interactive) + { + _localizer = localizer; + _fergunOptions = fergunOptions.Value; + _urbanDictionary = urbanDictionary; + _interactive = interactive; + } + + 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 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 RandomAsync() => await SearchAndSendAsync(UrbanSearchType.Random); + + [SlashCommand("words-of-the-day", "Gets the words of the day in Urban Dictionary.")] + public async Task WordsOfTheDayAsync() => await SearchAndSendAsync(UrbanSearchType.WordsOfTheDay); + + public async Task SearchAndSendAsync(UrbanSearchType searchType, string? term = null) + { + 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(_localizer["Invalid search type."], nameof(searchType)) + }; + + if (definitions.Count == 0) + { + return FergunResult.FromError(_localizer["No results."]); + } + + var paginator = new LazyPaginatorBuilder() + .WithPageFactory(GeneratePage) + .WithFergunEmotes(_fergunOptions) + .WithActionOnCancellation(ActionOnStop.DisableInput) + .WithActionOnTimeout(ActionOnStop.DisableInput) + .WithMaxPageIndex(definitions.Count - 1) + .WithFooter(PaginatorFooter.None) + .AddUser(Context.User) + .WithLocalizedPrompts(_localizer) + .Build(); + + await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource); + + return FergunResult.FromSuccess(); + + PageBuilder GeneratePage(int i) + { + 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(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}", definition.Date!, i + 1, definitions.Count], + _ => _localizer["Urban Dictionary | Page {0} of {1}", i + 1, definitions.Count] + }; + + return new PageBuilder() + .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("👍", definition.ThumbsUp, true) + .AddField("👎", definition.ThumbsDown, true) + .WithFooter(footer, Constants.UrbanDictionaryIconUrl) + .WithTimestamp(definition.WrittenOn) + .WithColor(Color.Orange); // 0x10151BU 0x1B2936U + } + } + + public enum UrbanSearchType + { + Search, + Random, + WordsOfTheDay + } +} \ 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 _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 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 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(response); - var average = img.GetAverageColor().ToPixel(); - 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 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(); - 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 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 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 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 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 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 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 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(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 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> result = null; - IUserMessage message = null; - - while (result == null || result.Status == InteractiveStatus.Success) - { - var selection = new EmoteSelectionBuilder() - .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 Define([Remainder, Summary("defineParam1")] string word) - { - IReadOnlyList 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(); - foreach (var result in results ?? Enumerable.Empty()) - { - 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(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 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("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 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 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 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 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 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 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 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 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 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() - .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(response); - var average = img.GetAverageColor().ToPixel(); - 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 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 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 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 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(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 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 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 YtRandom() - { - for (int i = 0; i < 10; i++) - { - string randStr = StringUtils.RandomString(Random.Shared.Next(5, 7)); - IReadOnlyList 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(response); - _timeToCheckComic = DateTimeOffset.UtcNow.AddDays(1); - } - - private void InitializeCmdListCache() - { - if (_commandListCache == null) - { - _commandListCache = new Dictionary(); - - var modules = _cmdService.Modules - .Where(x => x.Name != Constants.DevelopmentModuleName && x.Commands.Count > 0) - .OrderBy(x => x.Attributes.OfType().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..9ced794 --- /dev/null +++ b/src/Modules/UtilityModule.cs @@ -0,0 +1,441 @@ +using System.Diagnostics; +using System.Globalization; +using Discord; +using Discord.Interactions; +using Fergun.Apis.Wikipedia; +using Fergun.Extensions; +using Fergun.Interactive; +using Fergun.Interactive.Pagination; +using Fergun.Modules.Handlers; +using GTranslate; +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; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using YoutubeExplode.Common; +using YoutubeExplode.Search; +using Color = Discord.Color; + +namespace Fergun.Modules; + +public class UtilityModule : InteractionModuleBase +{ + private readonly ILogger _logger; + private readonly IFergunLocalizer _localizer; + private readonly FergunOptions _fergunOptions; + private readonly SharedModule _shared; + private readonly InteractiveService _interactive; + private readonly IFergunTranslator _translator; + 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 _lazyFilteredLanguages = new(() => Language.LanguageDictionary + .Values + .Where(x => x.SupportedServices == (TranslationServices.Google | TranslationServices.Bing | TranslationServices.Yandex | TranslationServices.Microsoft)) + .ToArray()); + + public UtilityModule(ILogger logger, IFergunLocalizer localizer, IOptionsSnapshot fergunOptions, + SharedModule shared, InteractiveService interactive, IFergunTranslator translator, SearchClient searchClient, IWikipediaClient wikipediaClient) + { + _logger = logger; + _localizer = localizer; + _fergunOptions = fergunOptions.Value; + _shared = shared; + _interactive = interactive; + _translator = translator; + _searchClient = searchClient; + _wikipediaClient = wikipediaClient; + } + + public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode()); + + [UserCommand("Avatar")] + public async Task AvatarUserCommandAsync(IUser user) + => await AvatarAsync(user); + + [SlashCommand("avatar", "Displays the avatar of a user.")] + public async Task AvatarAsync([Summary(description: "The user.")] IUser user, + [Summary(description: "An specific avatar type.")] AvatarType type = AvatarType.FirstAvailable) + { + 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; + + default: + url = user.GetDefaultAvatarUrl(); + title = $"{user} ({_localizer["Default"]})"; + break; + } + + var builder = new EmbedBuilder + { + Title = title, + ImageUrl = url, + Color = Color.Orange + }; + + await Context.Interaction.RespondAsync(embed: builder.Build()); + + return FergunResult.FromSuccess(); + } + + [MessageCommand("Bad Translator")] + public async Task BadTranslatorAsync(IMessage message) + => await BadTranslatorAsync(message.GetText()); + + [SlashCommand("badtranslator", "Passes a text through multiple, different translators.")] + public async Task 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)) + { + return FergunResult.FromError(_localizer["The text must not be empty."], true); + } + + if (chainCount is < 2 or > 10) + { + return FergunResult.FromError(_localizer["The chain count must be between 2 and 10 (inclusive)."], true); + } + + await Context.Interaction.DeferAsync(); + + _translator.Randomize(); + + var languageChain = new List(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)); + } + + ITranslationResult result; + try + { + _logger.LogInformation("Translating to: {target}", target.ISO6391); + result = await _translator.TranslateAsync(text, target); + } + catch (Exception e) + { + _logger.LogWarning(e, "Error translating text {text} ({source} -> {target})", text, source?.ISO6391 ?? "auto", target.ISO6391); + return FergunResult.FromError(e.Message); + } + + // Switch the translators to avoid spamming them and get more variety + _translator.Next(); + + 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 = $"**{_localizer["Language Chain"]}**\n{string.Join(" -> ", languageChain.Select(x => x.ISO6391))}\n\n**{_localizer["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 Context.Interaction.FollowupAsync(embed: embed); + + return FergunResult.FromSuccess(); + } + + [SlashCommand("color", "Displays a color.")] + public async Task 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(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 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(description) + .WithColor(Color.Orange) + .Build(); + + await Context.Interaction.RespondAsync(embed: embed, components: components); + + return FergunResult.FromSuccess(); + } + + [SlashCommand("ping", "Sends the response time of the bot.")] + public async Task PingAsync() + { + var embed = new EmbedBuilder() + .WithDescription("Pong!") + .WithColor(Color.Orange) + .Build(); + + var sw = Stopwatch.StartNew(); + await Context.Interaction.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); + + return FergunResult.FromSuccess(); + } + + [SlashCommand("say", "Says something.")] + public async Task SayAsync([Summary(description: "The text to send.")] string text) + { + await Context.Interaction.RespondAsync(text.Truncate(DiscordConfig.MaxMessageSize), allowedMentions: AllowedMentions.None); + + return FergunResult.FromSuccess(); + } + + [MessageCommand("Translate")] + public async Task TranslateAsync(IMessage message) + => await TranslateAsync(message.GetText(), Context.Interaction.GetLanguageCode()); + + [SlashCommand("translate", "Translates a text.")] + public async Task 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); + + [UserCommand("User Info")] + [SlashCommand("user", "Gets information about a user.")] + public async Task 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 WikipediaAsync([Autocomplete(typeof(WikipediaAutocompleteHandler))] [Summary(description: "The search query.")] string query) + { + await Context.Interaction.DeferAsync(); + + var articles = (await _wikipediaClient.GetArticlesAsync(query, Context.Interaction.GetLanguageCode())).ToArray(); + + if (articles.Length == 0) + { + return FergunResult.FromError(_localizer["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(_fergunOptions) + .WithLocalizedPrompts(_localizer) + .Build(); + + await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource); + + return FergunResult.FromSuccess(); + + 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(_localizer["Wikipedia Search | Page {0} of {1}", index + 1, 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 YouTubeAsync([Autocomplete(typeof(YouTubeAutocompleteHandler))] [Summary(description: "The query.")] string query) + { + await Context.Interaction.DeferAsync(); + + var videos = await _searchClient.GetVideosAsync(query).Take(10); + + switch (videos.Count) + { + case 0: + return FergunResult.FromError(_localizer["No results."]); + + case 1: + await Context.Interaction.FollowupAsync(videos[0].Url); + break; + + default: + var paginator = new LazyPaginatorBuilder() + .AddUser(Context.User) + .WithPageFactory(GeneratePage) + .WithActionOnCancellation(ActionOnStop.DisableInput) + .WithActionOnTimeout(ActionOnStop.DisableInput) + .WithMaxPageIndex(videos.Count - 1) + .WithFooter(PaginatorFooter.None) + .WithFergunEmotes(_fergunOptions) + .WithLocalizedPrompts(_localizer) + .Build(); + + await _interactive.SendPaginatorAsync(paginator, Context.Interaction, TimeSpan.FromMinutes(10), InteractionResponseType.DeferredChannelMessageWithSource); + break; + } + + 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/Program.cs b/src/Program.cs index 39c3e60..a475d20 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -1,19 +1,178 @@ -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.Apis.Bing; +using Fergun.Apis.Genius; +using Fergun.Apis.Urban; +using Fergun.Apis.Wikipedia; +using Fergun.Apis.Yandex; +using Fergun.Data; +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.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Events; +using Serilog.Filters; +using Serilog.Sinks.SystemConsole.Themes; +using YoutubeExplode.Search; -namespace Fergun -{ - internal static class Program +// 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) + .ConfigureServices((context, services) => + { + services.Configure(context.Configuration.GetSection(StartupOptions.Startup)); + services.Configure(context.Configuration.GetSection(BotListOptions.BotList)); + services.Configure(context.Configuration.GetSection(FergunOptions.Fergun)); + services.AddSqlite(context.Configuration.GetConnectionString("FergunDatabase")); + + if (context.Configuration.GetSection(StartupOptions.Startup).Get().MobileStatus) + { + MobilePatcher.Patch(); + } + }) + .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, + SuppressUnknownDispatchWarnings = true, + FormatUsersInBidirectionalUnicode = false + }; - Console.OutputEncoding = System.Text.Encoding.UTF8; + config.Token = context.Configuration.GetSection(StartupOptions.Startup).Get().Token; + }) + .UseInteractionService((_, config) => + { + config.LogLevel = LogSeverity.Critical; + config.DefaultRunMode = RunMode.Async; + config.UseCompiledLambda = false; + }) + .ConfigureLogging(logging => logging.ClearProviders()) + .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))) + .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)); + }) + .ConfigureServices(services => + { + services.AddLocalization(options => options.ResourcesPath = "Resources"); + services.AddTransient(typeof(IFergunLocalizer<>), typeof(FergunLocalizer<>)); + services.AddHostedService(); + services.AddHostedService(); + services.AddSingleton(new InteractiveConfig { ReturnAfterSendingPaginator = true, DeferStopSelectionInteractions = false }); + services.AddSingleton(); + services.AddFergunPolicies(); - await new FergunClient().InitializeAsync(); - } + services.AddHttpClient() + .SetHandlerLifetime(TimeSpan.FromMinutes(30)) + .AddRetryPolicy(); + + services.AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { UseCookies = false }) + .SetHandlerLifetime(TimeSpan.FromMinutes(30)) + .AddRetryPolicy(); + + services.AddHttpClient() + .SetHandlerLifetime(TimeSpan.FromMinutes(30)) + .AddRetryPolicy(); + + services.AddHttpClient() + .SetHandlerLifetime(TimeSpan.FromMinutes(30)) + .AddRetryPolicy(); + + services.AddHttpClient() + .SetHandlerLifetime(TimeSpan.FromMinutes(30)) + .AddRetryPolicy(); + + services.AddHttpClient() + .SetHandlerLifetime(TimeSpan.FromMinutes(30)) + .AddRetryPolicy(); + + services.AddHttpClient() + .SetHandlerLifetime(TimeSpan.FromMinutes(30)) + .AddRetryPolicy(); + + services.AddHttpClient() + .SetHandlerLifetime(TimeSpan.FromMinutes(30)) + .AddRetryPolicy(); + + // 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 + // 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().CreateClient(nameof(MicrosoftTranslator)))); + + services.AddHttpClient() + .SetHandlerLifetime(TimeSpan.FromMinutes(30)) + .AddRetryPolicy(); + + services.AddHttpClient() + .SetHandlerLifetime(TimeSpan.FromMinutes(30)) + .AddRetryPolicy(); + + services.AddHttpClient(nameof(GoogleScraper)) + .SetHandlerLifetime(TimeSpan.FromMinutes(30)) + .AddRetryPolicy(); + + services.AddHttpClient(nameof(DuckDuckGoScraper)) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { UseCookies = false }) + .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)); + + services.AddTransient(); + services.AddSingleton(x => new GoogleScraper(x.GetRequiredService().CreateClient(nameof(GoogleScraper)))); + services.AddSingleton(x => new DuckDuckGoScraper(x.GetRequiredService().CreateClient(nameof(DuckDuckGoScraper)))); + services.AddSingleton(x => new BraveScraper(x.GetRequiredService().CreateClient(nameof(BraveScraper)))); + services.AddTransient(); + }) + .Build(); + +// Semi-automatic migration +await using (var scope = host.Services.CreateAsyncScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + int pendingMigrations = (await db.Database.GetPendingMigrationsAsync()).Count(); + + if (pendingMigrations > 0) + { + var logger = scope.ServiceProvider.GetRequiredService>(); + await db.Database.MigrateAsync(); + logger.LogInformation("Applied {Count} pending database migration(s).", pendingMigrations); } -} \ No newline at end of file +} + +await host.RunAsync(); \ 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 -{ - /// - /// A for parsing objects implementing . - /// Modified from the original to get user by mention/id from REST and use the "Search Guild Members" endpoint. - /// - /// The type to be checked; must implement . - public class UserTypeReader : TypeReader - where T : class, IUser - { - /// - public override async Task 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(); - - var usersFromGuildSearch = context.Guild != null - ? await context.Guild.SearchUsersAsync(input.Substring(0, Math.Min(input.Length, 100)), 10).ConfigureAwait(false) - : Array.Empty(); - - var guildUsers = context.Guild != null - ? await context.Guild.GetUsersAsync(CacheMode.CacheOnly).ConfigureAwait(false) - : Array.Empty(); - - 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 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 results, T user, float score) - { - results.TryAdd(user.Id, new TypeReaderValue(user, score)); - } - - private class UserEqualityComparer : IEqualityComparer - { - 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/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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + {0} ahora está en la lista negra. + + + {0} ya no está en la lista negra. + + + {0} ya está en la lista negra. + + + {0} no está en la lista negra. + + \ No newline at end of file diff --git a/src/Resources/Modules.ImageModule.es.resx b/src/Resources/Modules.ImageModule.es.resx new file mode 100644 index 0000000..147c502 --- /dev/null +++ b/src/Resources/Modules.ImageModule.es.resx @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Búsqueda Visual de Bing | Página {0} de {1} + + + Búsqueda de imágenes en Brave + + + Búsqueda de imágenes en DuckDuckGo + + + Búsqueda de Google Imágenes + + + Motor de búsqueda de imágenes inválido. + + + Selecciona un motor de búsqueda de imágenes + + + Búsqueda Visual de Yandex | Página {0} de {1} + + \ 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..5272275 --- /dev/null +++ b/src/Resources/Modules.OcrModule.es.resx @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Se requiere una URL o un archivo adjunto. + + + Búsqueda Visual de Bing + + + Motor de OCR inválido. + + + Resultados de OCR + + + Selecciona un motor de OCR + + + El OCR no dio resultados. + + + Traducir + + + Traducir a {0} + + + Traducir a {0} ({1}) + + + OCR de Yandex + + + {0} | Tiempo de procesado: {1}ms + + \ No newline at end of file diff --git a/src/Resources/Modules.OtherModule.es.resx b/src/Resources/Modules.OtherModule.es.resx new file mode 100644 index 0000000..8f04214 --- /dev/null +++ b/src/Resources/Modules.OtherModule.es.resx @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + "{0}" es instrumental. + + + Dueño del bot + + + Versión del bot + + + Haga click en el botón de abajo para invitar a Fergun a tu servidor. + + + Estadísticas de Comandos + + + Uso de CPU + + + Estadísticas de Fergun + + + Invita a Fergun + + + Librería + + + Letra por Genius | Página {0} de {1} + + + Sin estadísticas que mostrar. + + + Sistema Operativo + + + Uso de RAM + + + ID de shard + + + Servidores Totales + + + Usuarios Totales + + + No se pudo encontrar una canción con ID {0}. Usa los resultados de autocompletado. + + + No es posible obtener la letra de "{0}". + + + Tiempo activo + + + Ver Artista + + + Ver en Genius + + \ 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..6b77b9c --- /dev/null +++ b/src/Resources/Modules.UrbanModule.es.resx @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Por {0} + + + Tipo de búsqueda inválido. + + + Urban Dictionary (Definiciones Aleatorias) | Página {0} de {1} + + + Urban Dictionary (Palabras del día, {0}) | Página {1} de {2} + + + Urban Dictionary | Página {0} de {1} + + \ 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..2852b93 --- /dev/null +++ b/src/Resources/Modules.UtilityModule.es.resx @@ -0,0 +1,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Salida de comando + + + 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. + +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** + +Puedes encontrar más información sobre la eliminación de estos módulos/comandos [aquí]({0}). + + + Cadena de Idiomas + + + Sin salida. + + + El número de cadenas debe estar entre 2 y 10 (inclusivo). + + + Búsqueda de Wikipedia | Página {0} de {1} + + + Clientes Activos + + + Actividades + + + Mejorando desde + + + Creado En + + + Es Bot + + + Nombre + + + Fecha de ingreso al servidor + + + Información de usuario + + + Predeterminado + + + Global + + + Servidor + + + {0} no tiene un avatar global (principal). + + + {0} no tiene un avatar de servidor. + + + Si tiene alguna pregunta sobre la reescritura o tiene alguna sugerencia, no dude en unirse al servidor de soporte. + + + Servidor de soporte + + \ 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..751872e --- /dev/null +++ b/src/Resources/Modules.UtilityModule.resx @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 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. + +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}). + + + If you have any questions about the rewrite or have any suggestions, feel free to join the support server. + + \ 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..ec154f1 --- /dev/null +++ b/src/Resources/SharedResource.es.resx @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ocurrió un error. + + + Otro usuario está usando esta acción actualmente. Vuelva a intentarlo más tarde. + + + La Búsqueda Visual de Bing no pudo descargar la imagen. + + + La Búsqueda Visual de Bing no está disponible actualmente. Vuelva a intentarlo más tarde. + + + No se pudo convertir "{0}" a un color. + + + Ingresa un número de página + + + Mensaje de error: {0} + + + Interacción de modal expirada. Debes responder en {0} segundos. + + + Las dimensiones de imagen supera el límite (Máx. 4000px). + + + El tamaño de imagen supera el límite (Máx. 20MB). + + + Entrada inválida. El número debe estar en rango de {0} a {1}, excluyendo la página actual. + + + Idioma de origen "{0}" inválido. + + + Idioma de destino "{0}" inválido. + + + Idioma "{0}" no soportado. + + + Sin resultados. + + + Nada + + + Salida + + + Número de página ({0}-{1}) + + + Página {0} de {1} + + + Resultado + + + Idioma de origen + + + Idioma de origen (Detectado) + + + Idioma de destino + + + El texto no debe estar vacío. + + + La URL no está bien formada. + + + Resultado de traducción + + + No se puede obtener una URL de imagen del mensaje. + + + No se puede obtener la voz especificada. Utilice los resultados de autocompletado. + + + Formato desconocido. Intenta usar archivos JPEG, PNG o BMP. + + + Estás en la lista negra con el motivo: {0} + + + Estás en la lista negra. + + \ 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 index 73e948c..fb320ba 100644 --- a/src/Services/BotListService.cs +++ b/src/Services/BotListService.cs @@ -1,190 +1,158 @@ -using System; -using System.Net.Http; +using System.Net; using System.Net.Http.Headers; using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Discord; using Discord.WebSocket; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; -namespace Fergun.Services +namespace Fergun.Services; + +/// +/// Represents a service that updates the bot stats periodically (currently Top.gg and Discord Bots). +/// +public sealed class BotListService : BackgroundService { + private readonly DiscordShardedClient _discordClient; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly BotListOptions _options; + private int _lastServerCount = -1; + /// - /// Represents a service that updates the bot server count periodically (currently Top.gg and DiscordBots) + /// Initializes a new instance of the class. /// - public class BotListService : IDisposable + /// The Discord client. + /// The HTTP client. + /// The logger. + /// The bot list options. + public BotListService(DiscordShardedClient discordClient, HttpClient httpClient, ILogger logger, IOptions options) + { + _discordClient = discordClient; + _httpClient = httpClient; + _logger = logger; + _options = options.Value; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - private readonly BaseSocketClient _client; - private readonly Timer _updateTimer; - private readonly Func _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 logger = null) - : this((BaseSocketClient)client, topGgToken, discordBotsToken, updatePeriod, logger) + var botLists = _options.Tokens.Where(x => string.IsNullOrWhiteSpace(x.Value)).Select(x => x.Key).ToArray(); + foreach (var botList in botLists) { + _options.Tokens.Remove(botList); } - public BotListService(DiscordShardedClient client, string topGgToken = null, string discordBotsToken = null, - TimeSpan? updatePeriod = null, Func logger = null) - : this((BaseSocketClient)client, topGgToken, discordBotsToken, updatePeriod, logger) + 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}.", + string.Join(", ", _options.Tokens.Keys), _options.UpdatePeriod.ToString("h'h 'm'm 's's'")); - public BotListService(BaseSocketClient client, string topGgToken = null, string discordBotsToken = null, - TimeSpan? updatePeriod = null, Func logger = null) + using var timer = new PeriodicTimer(_options.UpdatePeriod); + while (await timer.WaitForNextTickAsync(stoppingToken)) { - _client = client ?? throw new ArgumentNullException(nameof(client)); - _logger = logger ?? Task.FromResult; + await UpdateStatsAsync(); + } + } - 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); - } + /// + /// Updates the bot list server count. + /// + public async ValueTask UpdateStatsAsync() + { + int serverCount = _discordClient.Guilds.Count; + if (_lastServerCount == -1) _lastServerCount = serverCount; + else if (_lastServerCount == serverCount) return; - 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) + foreach ((var botList, string token) in _options.Tokens) + { + if (!string.IsNullOrWhiteSpace(token)) { - return; + await UpdateStatsAsync(botList, serverCount, _discordClient.Shards.Count, token); } - - updatePeriod ??= TimeSpan.FromMinutes(30); - _updateTimer = new Timer(OnTimerFired, null, updatePeriod.Value, updatePeriod.Value); } - private void OnTimerFired(object state) - { - _ = UpdateStatsAsync(); - } + _lastServerCount = serverCount; + } - /// - /// Manually updates the bot list server count using the client's guild count. - /// - public async Task UpdateStatsAsync() => await UpdateStatsAsync(_client.Guilds.Count); + /// + /// Updates the bot stats of a specific bot list using the specified server count and shard count. + /// + /// The bot list. + /// The server count. + /// The shard count. + /// The API token. + public async Task UpdateStatsAsync(BotList botList, int serverCount, int shardCount, string token) + { + _logger.LogDebug("Updating {BotList} bot stats...", botList); - /// - /// Manually updates the bot list server count using the specified server count. - /// - /// The server count. - public async Task UpdateStatsAsync(int serverCount) + using var request = CreateRequest(botList, serverCount, shardCount, token); + try { - 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(); - } + 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); } - - /// - /// Manually updates a specific bot list server count using the specified server count. - /// - /// The server count. - /// The bot list. - public async Task UpdateStatsAsync(int serverCount, BotList botList) + catch (Exception e) { - switch (botList) - { - case BotList.TopGg when _topGgClientDisposed: - case BotList.DiscordBots when _discordBotsClientDisposed: - return; - } + _logger.LogWarning(e, "Failed to update {BotList} bot stats (server count: {ServerCount}, shard count: {ShardCount}).", botList, serverCount, shardCount); - 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 + if (e is HttpRequestException { StatusCode: HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.NotFound } requestException) { - string serverCountString = botList == BotList.TopGg ? "server_count" : "guildCount"; - var content = new StringContent($"{{\"{serverCountString}\": {serverCount}}}", Encoding.UTF8, "application/json"); + var statusCode = requestException.StatusCode.Value; - 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) + if (statusCode == HttpStatusCode.NotFound) { - 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; - } - } + _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.Tokens.Remove(botList); } } + } - public enum BotList - { - TopGg, - DiscordBots - } + /// + public override void Dispose() + { + _httpClient.Dispose(); + base.Dispose(); + } - protected virtual void Dispose(bool disposing) - { - if (_disposed) return; - if (disposing) - { - _updateTimer?.Dispose(); - _topGgClient?.Dispose(); - _discordBotsClient?.Dispose(); - _topGgClientDisposed = true; - _discordBotsClientDisposed = true; - } + 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}.") + }; - _disposed = true; + 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) } + }; - /// - public void Dispose() + 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 = { - GC.SuppressFinalize(this); - Dispose(true); + Authorization = new AuthenticationHeaderValue(token) } - } + }; } \ 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 -{ - /// - /// A thread-safe class used to automatically modify or delete response messages when the command message is modified or deleted. - /// - public class CommandCacheService : IDisposable - { - private readonly ConcurrentDictionary _cache = new ConcurrentDictionary(); - private readonly int _max; - private Timer _autoClear; - private readonly Func _logger; - private int _count; - private bool _disposed; - private readonly BaseSocketClient _client; - private readonly Func _cmdHandler; - private readonly double _maxMessageTime; - private readonly MessageCacheService _messageCache; - - private CommandCacheService() - { - IsDisabled = true; - _disposed = true; - } - - /// - public CommandCacheService(DiscordSocketClient client, int capacity = 200, Func cmdHandler = null, - Func logger = null, int period = 1800000, double maxMessageTime = 2.0, MessageCacheService messageCache = null) - : this((BaseSocketClient)client, capacity, cmdHandler, logger, period, maxMessageTime, messageCache) - { - } - - /// - public CommandCacheService(DiscordShardedClient client, int capacity = 200, Func cmdHandler = null, - Func logger = null, int period = 1800000, double maxMessageTime = 2.0, MessageCacheService messageCache = null) - : this((BaseSocketClient)client, capacity, cmdHandler, logger, period, maxMessageTime, messageCache) - { - } - - /// - /// Initializes the cache with a maximum capacity, tracking the client's message deleted event, and optionally the client's message modified event. - /// - /// The client that the MessageDeleted handler should be hooked up to. - /// The maximum capacity of the cache. - /// An optional method that gets called when the modified message event is fired. - /// An optional method to use for logging. - /// The interval between invocations of the cache clearing, in milliseconds. - /// The max. message longevity, in hours. - /// The message cache. - /// Thrown if capacity is less than 1. - public CommandCacheService(BaseSocketClient client, int capacity = 200, Func cmdHandler = null, - Func 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.")); - } - - /// - /// Returns a disabled instance of . - /// - public static CommandCacheService Disabled => new CommandCacheService(); - - /// - /// Gets all the keys in the cache. Will claim all locks until the operation is complete. - /// - public ICollection Keys => _cache.Keys; - - /// - /// Gets all the values in the cache. Will claim all locks until the operation is complete. - /// - public ICollection Values => _cache.Values; - - /// - /// Gets the number of command/response pairs in the cache. - /// - public int Count => _count; - - public bool IsDisabled { get; } - - /// - /// Adds a key and a value to the cache, or update the value if the key already exists. - /// - /// The id of the command message. - /// The ids of the response messages. - 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; - } - - /// - /// Adds a new command/response pair to the cache, or updates the value if the key already exists. - /// - /// The command/response pair. - public void Add(KeyValuePair pair) => Add(pair.Key, pair.Value); - - /// - /// Adds a command message and response to the cache. - /// - /// The command message. - /// The response message. - public void Add(IUserMessage command, IUserMessage response) => Add(command.Id, response.Id); - - /// - /// Clears all items from the cache. Will claim all locks until the operation is complete. - /// - public void Clear() - { - _cache.Clear(); - Interlocked.Exchange(ref _count, 0); - } - - /// - /// Checks whether the cache contains a set with a certain key. - /// - /// The key to search for. - /// Whether or not the key was found. - public bool ContainsKey(ulong key) => _cache.ContainsKey(key); - - /// - /// Returns an enumerator that iterates through the cache. - /// - /// An enumerator for the cache. - public IEnumerator> GetEnumerator() => _cache.GetEnumerator(); - - /// - /// Tries to remove a value from the cache by key. - /// - /// The key to search for. - /// Whether or not the removal operation was successful. - public bool TryRemove(ulong key) - { - var success = _cache.TryRemove(key, out _); - if (success) Interlocked.Decrement(ref _count); - return success; - } - - /// - /// Tries to get a value from the cache by key. - /// - /// The key to search for. - /// The value if found. - /// Whether or not key was found in the cache. - public bool TryGetValue(ulong key, out ulong value) => _cache.TryGetValue(key, out value); - - /// - /// Safely disposes of the auto-clear timer - /// and unsubscribes from the and events. - /// - 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 cacheable, Cacheable 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 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; - } - } - - /// - /// The command cache module base. - /// - /// The implementation. - public abstract class CommandCacheModuleBase : ModuleBase - where TCommandContext : class, ICommandContext - { - /// - /// Gets or sets the command cache service. - /// - public CommandCacheService Cache { get; set; } - - /// - /// Sends or edits a message to the source channel, and adds the response to the cache if the message is new. - /// - /// The message to be sent or edited. - /// Whether the message should be read aloud by Discord or not. - /// The to be sent or edited. - /// The options to be used when sending the request. - /// - /// Specifies if notifications are sent for mentioned users and roles in the message . If null, all mentioned roles and users will be notified. - /// - /// The message references to be included. Used to reply to specific messages. - /// The message components to be included with this message. Used for interactions - /// A collection of stickers to send. - /// A array of s to send with this response. Max 10. - /// A task that represents an asynchronous operation for sending or editing the message. The task contains the sent or edited message. - protected override async Task 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(); - x.AllowedMentions = allowedMentions ?? Optional.Create(); - 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 - { - /// - /// Sends a file to this message channel with an optional caption, then adds it to the command cache. - /// - /// The source channel. - /// The command cache that the messages should be added to. - /// The ID of the command message. - /// The of the file to be sent. - /// The name of the attachment. - /// The message to be sent. - /// Whether the message should be read aloud by Discord or not. - /// The to be sent. - /// The options to be used when sending the request. - /// Whether the message attachment should be hidden as a spoiler. - /// - /// Specifies if notifications are sent for mentioned users and roles in the message . If null, all mentioned roles and users will be notified. - /// - /// The message references to be included. Used to reply to specific messages. - /// A task that represents an asynchronous send operation for delivering the message. The task result contains the sent message. - public static async Task 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(); - }).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 _ignoredUsers = new HashSet(); - 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()); - - 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 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()?.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(); - if (responseMessage == null) - { - var component = new ComponentBuilder().Build(); // remove message components - var cache = _services.GetService(); - - 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(); - 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(); - }); - } - - 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..5133758 --- /dev/null +++ b/src/Services/InteractionHandlingService.cs @@ -0,0 +1,314 @@ +using System.Globalization; +using System.Reflection; +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using Fergun.Converters; +using Fergun.Data; +using Fergun.Data.Models; +using Fergun.Extensions; +using Fergun.Modules; +using GTranslate; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Fergun.Services; + +public class InteractionHandlingService : IHostedService +{ + private readonly DiscordShardedClient _shardedClient; + private readonly InteractionService _interactionService; + private readonly ILogger _logger; + private readonly IServiceProvider _services; + private readonly ulong _testingGuildId; + private readonly ulong _ownerCommandsGuildId; + private readonly SemaphoreSlim _cmdStatsSemaphore = new(1, 1); + + public InteractionHandlingService(DiscordShardedClient client, InteractionService interactionService, + ILogger logger, IServiceProvider services, IOptions options) + { + _shardedClient = client; + _interactionService = interactionService; + _logger = logger; + _services = services; + _testingGuildId = options.Value.TestingGuildId; + _ownerCommandsGuildId = options.Value.OwnerCommandsGuildId; + } + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + _interactionService.SlashCommandExecuted += SlashCommandExecuted; + _interactionService.ContextCommandExecuted += ContextCommandExecuted; + _interactionService.AutocompleteHandlerExecuted += AutocompleteHandlerExecuted; + _shardedClient.InteractionCreated += InteractionCreated; + + _interactionService.AddTypeConverter(new ColorConverter()); + _interactionService.AddTypeConverter(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)); + + _shardedClient.ShardReady += ReadyAsync; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _shardedClient.InteractionCreated -= InteractionCreated; + _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() + { + 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.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}", _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); + } + } + } + } + + 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) + { + await using var scope = _services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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>(); + 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 Task SlashCommandExecuted(SlashCommandInfo slashCommand, IInteractionContext context, IResult result) + { + _ = Task.Run(async () => + { + try + { + await HandleCommandExecutedAsync(slashCommand, context, result); + } + catch (Exception e) + { + _logger.LogWarning(e, "The command-executed handler has thrown an exception."); + } + }); + + return Task.CompletedTask; + } + + 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 Task AutocompleteHandlerExecuted(IAutocompleteHandler autocompleteCommand, IInteractionContext context, IResult result) + { + var interaction = (IAutocompleteInteraction)context.Interaction; + var subCommands = string.Join(" ", interaction.Data.Options + .Where(x => x.Type == ApplicationCommandOptionType.SubCommand).Select(x => x.Name)); + + 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; + + _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 HandleCommandExecutedAsync(IApplicationCommandInfo command, IInteractionContext context, IResult result) + { + string commandName = command.CommandType == ApplicationCommandType.Slash ? command.ToString()! : command.Name; + await _cmdStatsSemaphore.WaitAsync(); + + try + { + await using var scope = _services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + 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}", + command.CommandType, commandName, 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>(); + localizer.CurrentCulture = CultureInfo.GetCultureInfo(context.Interaction.GetLanguageCode()); + 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.FollowupAsync(embed: embed, ephemeral: ephemeral); + } + else + { + await interaction.RespondAsync(embed: embed, ephemeral: ephemeral); + } + } +} \ 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 -{ - /// - /// Represents an optimized cache of sent, deleted and edited messages. - /// - public class MessageCacheService : IDisposable - { - // short term cache - private readonly ConcurrentDictionary> _cache - = new ConcurrentDictionary>(); - - private readonly ConcurrentDictionary> _orderedCache - = new ConcurrentDictionary>(); - - // long term cache - private readonly ConcurrentDictionary> _editedCache - = new ConcurrentDictionary>(); - - private readonly ConcurrentDictionary> _deletedCache - = new ConcurrentDictionary>(); - - private readonly ConcurrentDictionary _lastCommandUsageTimes = new ConcurrentDictionary(); - private readonly bool _onlyCacheUserDeletedEditedMessages; - private readonly Func _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; - } - - /// - public MessageCacheService(DiscordSocketClient client, int messageCacheSize, Func logger = null, - int period = 3600000, double maxMessageTime = 6, int minCommandTime = 12, bool onlyCacheUserDeletedEditedMessages = true) - : this((BaseSocketClient)client, messageCacheSize, logger, period, maxMessageTime, minCommandTime, onlyCacheUserDeletedEditedMessages) - { - } - - /// - public MessageCacheService(DiscordShardedClient client, int messageCacheSize, Func logger = null, - int period = 3600000, double maxMessageTime = 6, int minCommandTime = 12, bool onlyCacheUserDeletedEditedMessages = true) - : this((BaseSocketClient)client, messageCacheSize, logger, period, maxMessageTime, minCommandTime, onlyCacheUserDeletedEditedMessages) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The client. - /// The message cache size. This only applies to sent messages and not edited/deleted messages. - /// The logger. - /// The period between cleanings. This only applies to edited/deleted messages. - /// The max. time the messages can be kept in the cache. This only applies to edited/deleted messages. - /// 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.
- /// Use in your command handler to update the last time a command was used. - /// Whether to only save messages from users in the edited/deleted messages cache. - public MessageCacheService(BaseSocketClient client, int messageCacheSize, Func 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; - } - - /// - /// Returns a disabled instance of . - /// - public static MessageCacheService Disabled => new MessageCacheService(); - - /// - /// Gets whether the cache is disabled. - /// - public bool IsDisabled { get; } - - /// - /// Gets a cache of messages for a channel. - /// - /// The channel. - /// The source event. - /// A cache of messages. - public IReadOnlyDictionary GetCacheForChannel(IMessageChannel channel, - MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived) - => GetCacheForChannel(channel.Id, sourceEvent); - - /// - /// Gets a cache of messages for a channel. - /// - /// The channel id. - /// The source event. - /// A cache of messages. - public IReadOnlyDictionary GetCacheForChannel(ulong channelId, - MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived) - { - GetCacheForEvent(sourceEvent).TryGetValue(channelId, out var channel); - - return channel as IReadOnlyDictionary ?? ImmutableDictionary.Empty; - } - - /// - /// Clears all channels and messages from the cache. - /// - public void Clear() - { - _cache.Clear(); - _orderedCache.Clear(); - _editedCache.Clear(); - _deletedCache.Clear(); - _lastCommandUsageTimes.Clear(); - } - - /// - /// Attempts to clear the specified channel and all its messages from the cache. - /// - /// The channel. - /// Whether the channel has been removed from at least one cache. - public bool TryClear(IMessageChannel channel) => TryClear(channel.Id); - - /// - /// Attempts to clear the specified channel and all its messages from the cache. - /// - /// The channel id. - /// Whether the channel has been removed from at least one cache. - public bool TryClear(ulong channelId) - => _cache.TryRemove(channelId, out _) - | _orderedCache.TryRemove(channelId, out _) - | _editedCache.TryRemove(channelId, out _) - | _deletedCache.TryRemove(channelId, out _); - - /// - /// Attempts to get a cached message from the provided channel and message id. - /// - /// The channel. - /// The id of the cached message. - /// The cached message, or null if the message could not be found. - /// The source event. - /// Whether the message was found. - public bool TryGetCachedMessage(IMessageChannel channel, ulong messageId, out ICachedMessage message, - MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived) - => TryGetCachedMessage(channel.Id, messageId, out message, sourceEvent); - - /// - /// Attempts to get a cached message from the provided channel id and message id. - /// - /// The id of the channel. - /// The id of the cached message. - /// The cached message, or null if the message could not be found. - /// The source event. - /// Whether the message was found. - 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); - } - - /// - /// Attempts to get a cached message from the provided message id, searching in every channel cache. - /// - /// The id of the cached message. - /// The cached message, or null if the message could not be found. - /// The source event. - /// Whether the message was found. - 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; - } - - /// - /// Attempts to remove and return a cached message from the provided channel and message id. - /// - /// The channel. - /// The id of the cached message. - /// The cached message, or null if the message could not be found. - /// The source event. - /// Whether the message was found. - public bool TryRemoveCachedMessage(IMessageChannel channel, ulong messageId, out ICachedMessage message, - MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived) - => TryRemoveCachedMessage(channel.Id, messageId, out message, sourceEvent); - - /// - /// Attempts to remove and return a cached message from the provided channel id and message id. - /// - /// The id of the channel. - /// The id of the cached message. - /// The cached message, or null if the message could not be found. - /// The source event. - /// Whether the message was found. - 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); - } - - /// - /// Updates the last time a command was executed in the specified guild to . - /// - /// The id of the guild. - public void UpdateLastCommandUsageTime(ulong guildId) - => UpdateLastCommandUsageTime(guildId, DateTimeOffset.UtcNow); - - /// - /// Updates the last time a command was executed in the specified guild to . - /// - /// The id of the guild. - /// The to set. - public void UpdateLastCommandUsageTime(ulong guildId, DateTimeOffset dateTime) - { - _lastCommandUsageTimes[guildId] = dateTime; - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - internal IEnumerable GetMessageQueue(ulong channelId) => _orderedCache.GetValueOrDefault(channelId) ?? Enumerable.Empty(); - - private ConcurrentDictionary> 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 embeds) - => CreateCachedMessage(message, cachedAt, sourceEvent, originalMessage, embeds, null); - - private static ICachedMessage CreateCachedMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage, - IReadOnlyCollection embeds, List 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(Environment.ProcessorCount, (int)(_messageCacheSize * 1.05))); - - var channelQueue = _orderedCache.GetOrAdd(message.Channel.Id, new ConcurrentQueue()); - - 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 cachedMessage, Cacheable 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()); - cachedChannel[messageId] = message; - } - if (TryRemoveCachedMessage(channelId, messageId, out message, MessageSourceEvent.MessageUpdated)) // An updated message gets deleted - { - var cachedChannel = _deletedCache.GetOrAdd(channelId, new ConcurrentDictionary()); - - // 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 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()); - - 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()); - - 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> cachedMessages, Cacheable 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 cachedMessage, Cacheable 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 cachedMessage, Cacheable 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 cachedMessage, Cacheable 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 cachedMessage, Cacheable 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; - } - } - - /// - /// Represents an edited message. - /// - public interface IEditedMessage : IMessage - { - /// - /// Gets the original message prior to being edited. This property is only present if the message has been edited once. - /// - public IMessage OriginalMessage { get; } - - internal void Update(IMessage originalMessage); - } - - /// - /// Represents a generic cached message. - /// - public interface ICachedMessage : IEditedMessage - { - /// - /// Gets when this message was cached. - /// - public DateTimeOffset CachedAt { get; } - - /// - /// Gets the source event of this message. - /// - 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); - } - - /// - /// Represents a cached user message. - /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] - public class CachedUserMessage : CachedMessage, IUserMessage - { - /// - /// Initializes a new instance of the class. - /// - /// The message. - /// When the message was cached. - /// The source event of the message. - internal CachedUserMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent) - : base(message, cachedAt, sourceEvent) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The message. - /// When the message was cached. - /// The source event of the message. - /// The original message (prior to being edited). - internal CachedUserMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage) - : base(message, cachedAt, sourceEvent, originalMessage) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The message. - /// When the message was cached. - /// The source event of the message. - /// The original message (prior to being edited). - /// A collection of embeds. - internal CachedUserMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage, - IReadOnlyCollection embeds) - : base(message, cachedAt, sourceEvent, originalMessage, embeds) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The message. - /// When the message was cached. - /// The source event of the message. - /// The original message (prior to being edited). - /// A collection of embeds. - /// A collection of reactions. - internal CachedUserMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage, - IReadOnlyCollection embeds, List reactions) - : base(message, cachedAt, sourceEvent, originalMessage, embeds, reactions) - { - } - - /// - public Task ModifyAsync(Action func, RequestOptions options = null) - => ((IUserMessage)_message).ModifyAsync(func, options); - - /// - public Task PinAsync(RequestOptions options = null) => ((IUserMessage)_message).PinAsync(options); - - /// - public Task UnpinAsync(RequestOptions options = null) => ((IUserMessage)_message).UnpinAsync(options); - - /// - public Task CrosspostAsync(RequestOptions options = null) => ((IUserMessage)_message).CrosspostAsync(options); - - /// - 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); - - /// - public IUserMessage ReferencedMessage => ((IUserMessage)_message).ReferencedMessage; - } - - /// - /// Represents a cached message. - /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] - public class CachedMessage : ICachedMessage - { - /// - /// Initializes a new instance of the class. - /// - /// The message. - /// When the message was cached. - /// The source event of the message. - internal CachedMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent) - { - _message = message; - CachedAt = cachedAt; - SourceEvent = sourceEvent; - } - - /// - /// Initializes a new instance of the class. - /// - /// The message. - /// When the message was cached. - /// The source event of the message. - /// The original message (prior to being edited). - internal CachedMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage) - : this(message, cachedAt, sourceEvent) - { - OriginalMessage = originalMessage; - } - - /// - /// Initializes a new instance of the class. - /// - /// The message. - /// When the message was cached. - /// The source event of the message. - /// The original message (prior to being edited). - /// A collection of embeds. - internal CachedMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage, - IReadOnlyCollection embeds) - : this(message, cachedAt, sourceEvent, originalMessage) - { - _embeds = embeds; - } - - /// - /// Initializes a new instance of the class. - /// - /// The message. - /// When the message was cached. - /// The source event of the message. - /// The original message (prior to being edited). - /// A collection of embeds. - /// A collection of reactions. - internal CachedMessage(IMessage message, DateTimeOffset cachedAt, MessageSourceEvent sourceEvent, IMessage originalMessage, - IReadOnlyCollection embeds, List reactions) - : this(message, cachedAt, sourceEvent, originalMessage, embeds) - { - _reactions = reactions; - } - - private protected readonly IMessage _message; - private protected readonly IReadOnlyCollection _embeds; - internal List _reactions; - - /// - public IMessage OriginalMessage { get; private set; } - - /// - public DateTimeOffset CachedAt { get; private set; } - - /// - public MessageSourceEvent SourceEvent { get; private set; } - - /// - public ulong Id => _message.Id; - - /// - public DateTimeOffset CreatedAt => _message.CreatedAt; - - /// - public MessageType Type => _message.Type; - - /// - public MessageSource Source => _message.Source; - - /// - public bool IsTTS => _message.IsTTS; - - /// - public bool IsPinned => _message.IsPinned; - - /// - public bool IsSuppressed => _message.IsSuppressed; - - /// - public bool MentionedEveryone => _message.MentionedEveryone; - - /// - public string Content => _message.Content; - - /// - public DateTimeOffset Timestamp => _message.Timestamp; - - /// - public DateTimeOffset? EditedTimestamp => _message.EditedTimestamp; - - /// - public IMessageChannel Channel => _message.Channel; - - /// - public IUser Author => _message.Author; - - /// - public IReadOnlyCollection Attachments => _message.Attachments; - - /// - public IReadOnlyCollection Embeds => _embeds ?? _message.Embeds; - - /// - public IReadOnlyCollection Tags => _message.Tags; - - /// - public IReadOnlyCollection MentionedChannelIds => _message.MentionedChannelIds; - - /// - public IReadOnlyCollection MentionedRoleIds => _message.MentionedRoleIds; - - /// - public IReadOnlyCollection MentionedUserIds => _message.MentionedUserIds; - - /// - public MessageActivity Activity => _message.Activity; - - /// - public MessageApplication Application => _message.Application; - - /// - public MessageReference Reference => _message.Reference; - - /// - public IReadOnlyDictionary 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; - - /// - public MessageFlags? Flags => _message.Flags; - - /// - public IReadOnlyCollection Components => _message.Components; - - /// - public IReadOnlyCollection Stickers => _message.Stickers; - - public string CleanContent => _message.CleanContent; - - public IMessageInteraction Interaction => _message.Interaction; - - /// - public Task DeleteAsync(RequestOptions options = null) => _message.DeleteAsync(options); - - /// - public Task AddReactionAsync(IEmote emote, RequestOptions options = null) => _message.AddReactionAsync(emote, options); - - /// - public Task RemoveReactionAsync(IEmote emote, IUser user, RequestOptions options = null) => _message.RemoveReactionAsync(emote, user, options); - - /// - public Task RemoveReactionAsync(IEmote emote, ulong userId, RequestOptions options = null) => _message.RemoveReactionAsync(emote, userId, options); - - /// - public Task RemoveAllReactionsAsync(RequestOptions options = null) => _message.RemoveAllReactionsAsync(options); - - /// - public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions options = null) => _message.RemoveAllReactionsForEmoteAsync(emote, options); - - /// - public IAsyncEnumerable> 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(); - _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" : "")})"; - } - - /// - /// Represents the event where a message gets cached. - /// - public enum MessageSourceEvent - { - /// - /// The message has been received. - /// - MessageReceived, - - /// - /// The message has been deleted. - /// - MessageDeleted, - - /// - /// The message has been updated (edited). - /// - MessageUpdated - } - - public static class MessageCacheExtensions - { - /// - /// Gets a message from this message channel. - /// - /// The channel. - /// The message cache service. - /// The snowflake identifier of the message. - /// The source event. - /// - /// - /// A task that represents an asynchronous get operation for retrieving the message. The task result contains - /// the retrieved message; null if no message is found with the specified identifier. - public static async Task 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; - } - - /// - /// Gets a cached message from this message channel from the optimized cache. - /// - /// The channel. - /// The message cache service. - /// The snowflake identifier of the message. - /// The source event. - /// A task that represents an asynchronous get operation for retrieving the message. The task result contains - /// the retrieved message; null if no message is found with the specified identifier. - 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; - } - - /// - /// Gets the last N messages from this message channel. - /// - /// The channel. - /// The message cache service. - /// The numbers of message to be gotten from. - /// The options to be used when sending the request. - /// A paged collection of messages. - public static IAsyncEnumerable> GetMessagesAsync(this IMessageChannel channel, MessageCacheService cache, - int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) - => GetMessagesInternalAsync(channel, cache, null, Direction.Before, limit, CacheMode.AllowDownload, options); - - /// - /// Gets the last N messages from this message channel. - /// - /// The channel. - /// The message cache service. - /// The ID of the starting message to get the messages from. - /// The direction of the messages to be gotten from. - /// The numbers of message to be gotten from. - /// The options to be used when sending the request. - /// A paged collection of messages. - public static IAsyncEnumerable> 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); - - /// - /// Gets the last N messages from this message channel. - /// - /// The channel. - /// The message cache service. - /// The starting message to get the messages from. - /// The direction of the messages to be gotten from. - /// The numbers of message to be gotten from. - /// The options to be used when sending the request. - /// A paged collection of messages. - public static IAsyncEnumerable> 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); - - /// - /// Gets a collection of cached messages from this message channel. - /// - /// 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. - /// The channel. - /// The message cache service. - /// The source event. - /// A read-only collection of cached messages. - public static IReadOnlyCollection GetCachedMessages(this IMessageChannel channel, MessageCacheService cache, - MessageSourceEvent sourceEvent = MessageSourceEvent.MessageReceived) - => cache - .GetCacheForChannel(channel, sourceEvent) - .Values - .ToArray(); - - /// - /// Gets the last N cached messages from this message channel. - /// - /// The channel. - /// The message cache service. - /// The number of messages to get. - /// A read-only collection of cached messages. - public static IReadOnlyCollection GetCachedMessages(this IMessageChannel channel, MessageCacheService cache, - int limit = DiscordConfig.MaxMessagesPerBatch) - => GetCachedMessagesInternal(channel, cache, null, Direction.Before, limit); - - /// - /// Gets the last N cached messages from this message channel. - /// - /// The channel. - /// The message cache service. - /// The message ID to start the fetching from. - /// The direction of which the message should be gotten from. - /// The number of messages to get. - /// A read-only collection of cached messages. - public static IReadOnlyCollection GetCachedMessages(this IMessageChannel channel, MessageCacheService cache, ulong fromMessageId, - Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) - => GetCachedMessagesInternal(channel, cache, fromMessageId, dir, limit); - - /// - /// Gets the last N cached messages from this message channel. - /// - /// The channel. - /// The message cache service. - /// The message to start the fetching from. - /// The direction of which the message should be gotten from. - /// The number of messages to get. - /// A read-only collection of cached messages. - public static IReadOnlyCollection GetCachedMessages(this IMessageChannel channel, MessageCacheService cache, IMessage fromMessage, - Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) - => GetCachedMessagesInternal(channel, cache, fromMessage.Id, dir, limit); - - private static IReadOnlyCollection GetCachedMessagesInternal(IMessageChannel channel, MessageCacheService cache, - ulong? fromMessageId, Direction dir, int limit) - => cache == null || cache.IsDisabled ? Array.Empty() : GetMany(cache, channel, fromMessageId, dir, limit); - - private static IAsyncEnumerable> GetMessagesInternalAsync(IMessageChannel channel, MessageCacheService cache, - ulong? fromMessageId, Direction dir, int limit, CacheMode mode, RequestOptions options) - { - if (dir == Direction.After && fromMessageId == null) - return AsyncEnumerable.Empty>(); - - var cachedMessages = GetMany(cache, channel, fromMessageId, dir, limit); - var result = ImmutableArray.Create(cachedMessages).ToAsyncEnumerable>(); - - 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 GetMany(MessageCacheService cache, IMessageChannel channel, - ulong? fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) - => GetMany(cache, channel.Id, fromMessageId, dir, limit); - - private static IReadOnlyCollection 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(); - - var cachedChannel = cache.GetCacheForChannel(channelId); - if (cachedChannel.Count == 0) - return Array.Empty(); - - var orderedChannel = cache.GetMessageQueue(channelId); - - IEnumerable 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(); - - 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 _loopDict = new ConcurrentDictionary(); - 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 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 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 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)> 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 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 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 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 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 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 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 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 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 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 _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 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 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; - } - - /// - /// Disposes the cancellation token - /// and unsubscribes from the and events. - /// - /// - /// If a sharded client is used, unsubscribes from the and events. - /// - 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 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 GetCpuUsageForProcessAsync() { - private static Dictionary _fergunPaginatorEmotes; - - public static async Task 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 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("", "**", StringComparison.Ordinal) - .Replace("", "**", StringComparison.Ordinal) - .Replace("", "*", StringComparison.Ordinal) - .Replace("", "*", StringComparison.Ordinal) - .Replace("
", "\n", StringComparison.Ordinal) - .Replace("", "\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 GetFergunPaginatorEmotes(FergunConfig config) - { - if (_fergunPaginatorEmotes != null) - { - return _fergunPaginatorEmotes; - } - - _fergunPaginatorEmotes = new Dictionary(); - - 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 - { - /// - /// Gets or sets the cached global prefix. - /// - /// This prefix may not be up-to-date if its value is modified externally. - public static string CachedGlobalPrefix { get; set; } - - /// - /// Gets or sets the guild prefix cache. - /// - /// These prefixes may not be up-to-date if their values are modified externally. - public static ConcurrentDictionary PrefixCache { get; private set; } - - /// - /// Gets or sets the user config cache. - /// - /// These configs may not be up-to-date if their values are modified externally. - public static ConcurrentDictionary UserConfigCache { get; private set; } - - /// - /// Initializes the prefix cache. - /// - public static void Initialize() - { - CachedGlobalPrefix = DatabaseConfig.GlobalPrefix; - var guilds = FergunClient.Database.GetAllDocuments(Constants.GuildConfigCollection); - PrefixCache = new ConcurrentDictionary(guilds?.ToDictionary(x => x.Id, x => x.Prefix) ?? new Dictionary()); - var users = FergunClient.Database.GetAllDocuments(Constants.UserConfigCollection); - UserConfigCache = new ConcurrentDictionary( - users?.Where(x => x != null).ToDictionary(x => x.Id, x => x) ?? new Dictionary()); - } - - /// - /// Returns a cached prefix corresponding to the specified channel. - /// - /// The channel. - /// The cached prefix of the channel. - public static string GetCachedPrefix(IMessageChannel channel) - => channel.IsPrivate() ? CachedGlobalPrefix : PrefixCache.GetValueOrDefault(((IGuildChannel)channel).GuildId, CachedGlobalPrefix) ?? CachedGlobalPrefix; - - /// - /// Returns the configuration of a guild using the specified channel. - /// - /// The channel. - /// The configuration of the guild, or null if the guild cannot be found in the database. - public static GuildConfig GetGuildConfig(IMessageChannel channel) - => channel.IsPrivate() ? null : GetGuildConfig(((IGuildChannel)channel).GuildId); - - /// - /// Returns the configuration of the specified guild Id. - /// - /// The Id of the guild. - /// The configuration of the guild, or null if the guild cannot be found in the database. - public static GuildConfig GetGuildConfig(ulong guildId) - => FergunClient.Database.FindDocument(Constants.GuildConfigCollection, x => x.Id == guildId); - - /// - /// Returns the configuration of the specified guild. - /// - /// The guild. - /// The configuration of the guild, or null if the guild cannot be found in the database. - public static GuildConfig GetGuildConfig(IGuild guild) - => GetGuildConfig(guild.Id); - - /// - /// Returns the prefix of the specified channel. - /// - /// The channel. - /// The prefix of the channel. - public static string GetPrefix(IMessageChannel channel) - => GetGuildConfig(channel)?.Prefix ?? DatabaseConfig.GlobalPrefix; - - /// - /// Returns the language of the specified channel. - /// - /// The channel. - /// The language of the channel. - public static string GetLanguage(IMessageChannel channel) - => GetGuildConfig(channel)?.Language ?? Constants.DefaultLanguage; - - /// - /// Returns the localized value of a resource key in a channel. - /// - /// The resource key to localize. - /// The channel. - /// The localized text, or if the value cannot be found. - public static string Locate(string key, IMessageChannel channel) - => Locate(key, GetLanguage(channel)); - - /// - /// Returns the localized value of a resource key in the specified language. - /// - /// The resource key to localize. - /// The language to localize the resource key. - /// The localized text, or if the value cannot be found. - 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 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 GetUrlContentLengthAsync(string url) - { - var response = await GetUrlResponseHeadersAsync(url); - return response?.Content?.Headers?.ContentLength; - } - - public static async Task GetUrlMediaTypeAsync(string url) - { - var response = await GetUrlResponseHeadersAsync(url); - return response?.Content?.Headers?.ContentType?.MediaType; - } - - public static async Task 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..d7e6d39 --- /dev/null +++ b/src/appsettings.json @@ -0,0 +1,35 @@ +{ + "ConnectionStrings": + { + "FergunDatabase": "Data Source=Fergun.db" + }, + "Startup": + { + "Token": "", + "TestingGuildId": 0, + "OwnerCommandsGuildId": 0, + "MobileStatus": false + }, + "Fergun": + { + "SupportServerUrl": "", + "PaginatorTimeout": "00:10:00", + "SelectionTimeout": "00:10:00", + "PaginatorEmotes": + { + "Backward": "◀️", + "Forward": "▶️", + "Jump": "🔢", + "Exit": "🛑" + } + }, + "BotList": + { + "UpdatePeriod": "00:30:00", + "Tokens": + { + "TopGg": "", + "DiscordBots": "" + } + } +} \ 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 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace Fergun -{ - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "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() - { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [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; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - قائمة الأوامر - - - أوامر الترفيه - - - Fergun {0} - إجمالي عدد الأوامر: {1} - - - أوامر الإشراف - - - أوامر الموسيقى - - - ملاحظات - - - استخدم `help [command]{0}` لمعرفة المزيد من المعلومات حول الأمر. - - - أوامر أخرى - - - أوامر نصية - - - أوامر المنفعة - - - الأوامر القادمة - - - القيادة لم يتم العثور. استخدم `{0} help` لعرض قائمة الأوامر. - - - (لا يوجد وصف متاح) - - - (اختياري) - - - إستعمال - - - اسماء مستعارة) - - - المعلمات - - - يظهر أحرف يونيكود عشوائية. - - - طول النتيجة. - - - يجب أن يحتوي النص على 3 أحرف أو أكثر. - - - النص الناتج أطول من أن يمكن عرضه ({0}) - - - تطبيع النص. - - - النص المطلوب تطبيعه. - - - عشوائية النص. - - - النص العشوائي. - - - يكرر نصًا عدة مرات. - - - مرات التكرار. - - - النص المكرر. - - - عكس النص. - - - النص المراد عكسه. - - - لعكس ترتيب سطر النص. - - - النص لعكس خطوطه. - - - لعكس ترتيب الكلمات في النص. - - - النص لعكس كلماته. - - - sARCAstIC TEXt. - - - النص المطلوب تحويله. - - - يحول النص إلى vaporwave. - - - النص المطلوب تحويله. - - - إرجاع الصورة الرمزية للمتصل بالأمر ، أو مستخدم معين ، إذا تم تمريره. - - - المستخدم للحصول على الصورة الرمزية الخاصة به. - - - يشفر النص إلى Base64. - - - النص المطلوب ترميزه. - - - يفك تشفير نص من Base64. - - - النص لفك. - - - نص غير صالح. - - - يختار خيار من القائمة. - - - قائمة خيارات مفصولة بمسافات. - - - انا اخترت... - - - ... لأنك منحتني خيارًا واحدًا فقط - - - يظهر لونًا عشوائيًا أو محددًا. - - - اللون المحدد للاستخدام. يجب أن تكون قيمة سداسية عشرية أو قيمة خام أو اسم لون معروف. - - - إظهار قائمة التعليمات أو معلومات الأمر ، إذا تم تمريرها. - - - الأمر للحصول على معلومات. - - - يحدد صورة باستخدام Microsoft CaptionBot. - - - عنوان URL للصورة المراد استخدامها. - - - لا تحديد عنوان URL سيجعل الأمر للبحث عن رسائل س مشاركة في ذاكرة التخزين المؤقت لرابط أو مرفق. - - - المرفق ليس صورة. - - - تعذر العثور على أي عنوان url أو مرفق في آخر {0} رسائل. - - - عنوان url ليس صورة صالحة. - - - يبحث عن الصور باستخدام صور Google. - - - الكلمة (الكلمات) الرئيسية للبحث. - - - تعذر العثور على أي نتائج. - - - إذا كان البحث NSFW ، استخدم الأمر في قناة NSFW. - - - بحث في الصور - - - Urban Dictionary - صفحة {0} من {1} - - - تم الوصول إلى الحد الأقصى لبحث الصور :( - - - ينفذ OCR على الصورة. - - - عنوان URL للصورة المراد استخدامها. - - - الملف كبير جدًا. - - - لم يعط OCR نتائج. - - - ينفذ OCR إلى صورة. - - - عنوان URL للصورة المراد استخدامها. - - - رمز الحالة: - - - يحصل الكمون في إرسال رسالة والكمون من قاعدة البيانات. - - - يغير حجم الصورة مع waifu2x. - - - عنوان URL للصورة المراد استخدامها. - - - حدث خطأ. - - - تغيير حجم النتائج - - - يأخذ لقطة شاشة لموقع ويب. - - - الموقع لأخذ لقطة شاشة. - - - يعود من المعلومات حول الملقم الحالي. - - - معلومات الخادم - - - اسم - - - صاحب - - - عدد الأدوار - - - عدد المستخدمين - - - مستوى التحقق - - - (بلا) - - - أنشئت في - - - يعرض آخر رسالة محذوفة في القناة الحالية. - - - لا يوجد شيء للقنص في {0} - - - يترجم النص. - - - اللغة الهدف في كود ISO (`en` ،` es` ، `br` ، إلخ.) - - - النص المراد ترجمته. - - - لغة الهدف غير صالحة. استخدم `{0}translate language codes` لمشاهدة قائمة اللغات. - - - لقد تم حظر IP من قبل Google :( لول - - - النص إلى الكلام. - - - اللغة الهدف في رمز ISO (`en` ،` es` ، `br` ، إلخ) ، تتراجع إلى اللغة الإنجليزية إذا كان الهدف غير صالح. - - - النص المطلوب تحويله إلى كلام. - - - البحث في القاموس الحضري. - - - الكلمة (الكلمات) الرئيسية للبحث. - - - لا نتائج. - - - بواسطة - - - مثال - - - (لم يتم تقديم مثال) - - - إرجاع معلومات حول المستخدم الحالي ، أو مستخدم معين ، في حالة مرور المستخدم. - - - المستخدم للحصول على معلومات من. - - - معلومات المستخدم - - - نشاط - - - بوت - - - تاريخ انضمام النقابة - - - المستخدم ليس موجود. -حاول استخدام علامة ({0}) أو إشارة ({1}) أو معرّف ({2}). - - - يبحث عن مقال في ويكيبيديا. - - - الكلمة (الكلمات) الرئيسية للبحث. - - - بحث ويكيبيديا - - - إرجاع كوميدي عشوائي xkcd أو كوميدي محدد إذا تم تمرير رقم. - - - الرقم الهزلي. - - - يجب أن يكون الرقم بين 1 و {0}. - - - يبحث عن فيديو على موقع يوتيوب. - - - الكلمة (الكلمات) الرئيسية للبحث. - - - تعذر العثور على أي نتائج. - - - إرجاع فيديو YouTube "عشوائي". - - - من مقاطع الفيديو المخزنة مؤقتًا -جارٍ إنشاء ذاكرة التخزين المؤقت ... - - - يحظر مستخدم. - - - المستخدم لحظر. - - - سبب الحظر. - - - ماذا؟ تريد حظر نفسك؟ - - - لن أحظر نفسي ههههههههه - - - المستخدم محظور بالفعل. - - - تم حظر المستخدم {0}. - - - يمسح آخر x رسائل للقناة الحالية. - - - عدد الرسائل المراد مسحها. - - - مستخدم لحذف رسائله. - - - يجب أن يكون الرقم بين {0} و {1}. - - - تعذر العثور على أي رسائل بواسطة {0} في رسائل {1} الأخيرة. - - - بواسطة - - - Hackbans مستخدم. - - - معرف المستخدم لقرصنة. - - - سبب الإختراق. - - - تم اختراق المستخدم {0}. - - - يركل مستخدمًا. - - - المستخدم لركل. - - - سبب الركلة. - - - تم طرد المستخدم {0}. - - - يغير لقب المستخدم. - - - المستخدم لتغيير لقبه. - - - اللقب الجديد. اترك هذا فارغًا لإزالة اللقب. - - - إلغاء حظر مستخدم. - - - معرف المستخدم لإلغاء الحظر. - - - تم حظر المستخدم {0}. - - - الوقت التوافه! - - - الفئة المطلوب تحديدها. حدد "الفئات" لرؤية قائمة الفئات أو "ليدربورد" / "الرتب" لرؤية ليدربورد. - - - قائمة الفئات - - - المتصدرين التوافه - - - يبدو أنك أجبت على جميع الأسئلة في الفئة المحددة ، حدد فئة أخرى .. - - - حدث خطأ. خطا بالكود - - - الفئة - - - اكتب - - - صعوبة - - - سؤال - - - خيارات - - - لديك {0} ثانية للإجابة. - - - خيار غير صالح! - - - لقد فقدت نقطة واحدة. - - - اجابة صحيحة! - - - لقد ربحت نقطة واحدة. - - - غير صحيح! - - - الجواب هو - - - انتهى الوقت! - - - نقاط - - - ينضم إلى قناة صوتية. - - - تحتاج إلى الاتصال بقناة صوتية. - - - متصل الآن بـ {0}. - - - يترك قناة صوتية. - - - انضم إلى القناة التي يتواجد فيها البوت لجعله يغادر. - - - وقد غادر بوت الآن {0}. - - - ينقل البوت إلى القناة الصوتية للمستخدم الحالي. - - - انضم إلى قناة صوتية حيث تريد أن يكون البوت. - - - يبحث ويلعب مسار من يوتيوب. - - - الكلمة (الكلمات) الرئيسية للبحث. - - - إرسال رقم من قائمة - - - تم إلغاء البحث. - - - الخيار خارج الفهرس. - - - لم ترد قبل انتهاء المهلة! - - - يعيد المسار الحالي الذي يتم تشغيله ، إن وجد. - - - يوقف اللاعب مؤقتًا. - - - استئناف التشغيل. - - - توقف اللاعب. - - - تخطي المسار الحالي ، إن وجد. - - - يضبط مستوى صوت المشغل. - - - الحجم المطلوب ضبطه (2 - 150) - - - يظهر قائمة الانتظار. - - - خلط الترتيب. - - - إزالة مسار في قائمة الانتظار من فهرس محدد. - - - فهرس المسار المطلوب إزالته. - - - يعرض كلمات الأغنية المحددة ، أو المسار الحالي في المشغل إذا لم يمر أي منها. - - - الأغنية للبحث في كلماتها. - - - يعرض العمل الفني للمسار الحالي في المشغل. - - - لا مزيد من المسارات للعب. - - - الان العب - - - حدث خطأ - - - علق اللاعب بالمسار ** {0} ** لمدة {1} ثانية. - - - تم النقل من ** {0} ** إلى ** {1} ** - - - لم يتم العثور على تطابق. حاول استخدام وصلة يوتيوب أو ارتباط ملف MP3. - - - تمت إضافة قائمة التشغيل ** {0} ** ({1} المسارات) ({2}) إلى قائمة الانتظار. - - - مسارات قائمة الانتظار {0} ({1}) - -يلعب الآن: {2} - - - تمت إضافة {0} إلى قائمة الانتظار. - - - قيد التشغيل الآن: {0} - - - مسار غير صالح. - - - إعادة التشغيل {0} - - - قائمة الانتظار فارغة. - - - اللاعب لا يلعب. - - - توقف اللاعب الآن. - - - تم التخطي: {0} - -يلعب الآن: {1} - - - استخدم رقمًا بين 2 و 150. - - - تم تعيين مستوى الصوت على: {0}. - - - تم إيقاف المشغل مؤقتًا الآن. - - - تم استئناف التشغيل. - - - المشغل غير متوقف مؤقتًا. - - - يلعب حاليًا: {0} ({1} من {2}) - - - المسارات في قائمة الانتظار: - - - يوجد عنصر واحد فقط في قائمة الانتظار. - - - تم ترتيب قائمة الانتظار بشكل عشوائي. - - - الفهرس خارج النطاق. - - - تمت إزالة المسار {0} في الموضع {1}. - - - لا توجد كلمات ل{0}. - - - يحدد حالة لعبة البوت. - - - النص المطلوب تعيينه. - - - مالك بوت فقط. - - - يحدد حالة البوت. - - - الحالة المطلوب ضبطها (0 - 5). - - - يعرض التعليمات البرمجية المصدر للر. - - - الأمر للحصول على رمزها. - - - لتعيين لون التضمين. - - - اللون الجديد بالنظام الست عشري أو العشري. - - - اللون غير صالح. - - - إخوانه قمت بنشره للتو تذلل! - - - لتعيين البادئة العامة للبوت. - - - البادئة العالمية الجديدة. - - - البادئة العالمية الحالية هي: - - - البادئة الهدف والبادئة الحالية هي نفسها. - - - البادئة عالمية جديدة هي: "{0}" - - - يرسل رابط دعوة الروبوت. - - - يضبط بادئة البوت. - - - البادئة الجديدة. - - - البادئة الحالية هي: - - - البادئة النقابة الجديدة هي: "{0}" - - - إعادة تشغيل البوت. - - - يقول شيئا. - - - النص ليقول. - - - يظهر وقت تشغيل البوت. - - - يضبط لغة البوت. - - - اللغة بوت الآن: الإنجليزية -⚠ وجاء هذا ترجمة مع جوجل ترجمة ذلك قد يكون هناك بعض الأخطاء مع النص. - - - الصفحة {0} من {1} - - - فشل في التنفيذ - - - يحصل على المسار الحالي في المشغل. - - - يظهر سجل التغيير في برنامج التتبُّع. - - - معطل مؤقتا. - - - احصل على بعض الاقتباسات الملهمة. - - - أحتاج إلى إذن `{0}` لتنفيذ هذا الأمر. - - - إدارة الرسائل - - - الاتصال - - - تحدث - - - يقيم رمز . - - - رمز التقييم. - - - قد تحتوي لقطة الشاشة على محتوى NSFW ولن يتم عرضها. حاول استخدام الأمر على قناة NSFW أو استخدم عنوان url آخر. - - - البوت غير متصل بقناة صوتية. - - - تم إلغاء الإنشاء. - - - مرحبًا بك في AI Dungeon - - - تأكد من قراءة المعلومات والأوامر باستخدام `aid info{0}` قبل المتابعة. - -حدد الوضع: - - - حدد شخصية - - - إرفاق ملفات - - - نتائج التقييم - - - انتاج | - - - تم التنفيذ في غضون {0} مللي ثانية - - - تم تنفيذ الرمز دون أي قيمة إرجاع. - - - لا يمكن استخدام هذا الأمر إلا على قناة NSFW. - - - انتظر بضع ثوان قبل استخدام هذا الأمر! - - - قناة محددة للقنص. - - - المعرف غير موجود. - - - معرف القصة هذا ليس عامًا ولا يمكنك استخدامه. اطلب من مالك المعرّف ({0}) نشره باستخدام "makepublic <ID>` - - - يجب الانتظار حتى يتم إنشاء القصة قبل استخدام هذا المعرّف! - - - توليد مغامرة جديدة -مع الوضع: ** {0} ** -والشخصية: ** {1} ** ... - - - إنشاء شخصية مخصصة - - - أدخل نصًا يصف هويتك وأول جملتين من حيث تبدأ. -ستنتهي مهلة هذه المطالبة في غضون 5 دقائق. - - - توليد مغامرة جديدة مع موجه مخصص ... - - - جارٍ إنشاء قصة ... - - - تحرير سياق القصة ... - - - سيتذكر الذكاء الاصطناعي الآن: - - - تعديل الناتج الأخير ... - - - تم تغيير الإخراج الأخير إلى: - - - أنت لست مالك هذا المعرّف. - - - المعرّف عام بالفعل. - - - المعرّف الآن عام ويمكن للجميع استخدامه. يمكنك إعادة تعيينه إلى خاص باستخدام `makeprivate <ID>` - - - المعرّف خاص بالفعل. - - - المعرّف الآن خاص ويمكنك أنت فقط استخدامه. يمكنك إعادة تعيينه إلى "عام" باستخدام "makepublic <ID>` - - - {0} ليس لديها أي معرفات المغامرة. - - - قائمة معرفات {0} - - - عامة - - - استخدام "idinfo <ID> 'للحصول على معلومات حول هوية المغامرة. - - - معلومات مغامرة ID - - - AI Dungeon هي أول مغامرة نصية من نوع الذكاء الاصطناعي. باستخدام نموذج تعلُّم الآلة ذي المعلمة 1.5B يسمى GPT-2 ، تُنشئ AI Dungeon قصة ونتائج أفعالك أثناء اللعب في هذا العالم الافتراضي. على عكس كل لعبة أخرى موجودة فعليًا ، فأنت لا تقتصر على خيال المطور في ما يمكنك القيام به. يمكن أن يكون أي شيء يمكنك التعبير عنه باللغة هو عملك وسوف يقرر سيد البرج المحصن AI كيف يستجيب العالم لأفعالك. - - - قواعد AI Dungeon بسيطة: -1. تضع اللعبة "أنت" في مقدمة كل عمل ، لذا يجب أن يبدأ كل فعل بفعل. على سبيل المثال "مهاجمة التنين" ، "استدعي شطيرة" إلخ ... -2. كاستثناء لما سبق: إذا وضعت الإجراء الخاص بك بين علامتي اقتباس. "هل ستنضم إلي في سعيي؟" ثم ستضيف اللعبة "أنت تقول" إلى المقدمة. - - - حول AI Dungeon - - - مساعدة AI Dungeon - - - كيف ألعب - - - أوامر - - - حاول استخدام كلمات جديدة كثيرًا ، فالذكاء الاصطناعي يصبح أكثر إبداعًا مع التنوع. -هل تريد المزيد من القصة لتوليدها؟ استخدم الأمر متابعة بدون أي نص. -ابدأ العمل مع! لإيقاف اللعبة من وضع "أنت" في المقدمة. على سبيل المثال "! فجأة ظهر غول" -حاول استخدام كلمات جديدة كثيرًا ، فالذكاء الاصطناعي يصبح أكثر إبداعًا مع التنوع. -يمكنك استخدام الاقتباسات للتحدث ، على سبيل المثال. "دعهم و شأنهم!" -تذكر أن تبدأ العمل الخاص بك بفعل ، على سبيل المثال: Attack the orc -استخدم الأمر الارتداد لإرجاع الإدخال الأخير وحاول إجراء شيء آخر. -الجمل الطويلة للأعمال ليست مشكلة! الحصول على الإبداع! -استخدم الأمر تذكر لتحرير سياق القصة الذي يتذكره الذكاء الاصطناعي دائمًا. - - - في - - - يعرض التضمين مع خيارات تكوين البوت على مستوى النقابة. - - - يجب أن تكون مسؤولاً لاستخدام هذا الأمر. - - - جار التحميل... - - - تكوين Fergun - - - تم إلغاء التكوين. - - - لا يمكنني فعل ذلك. - - - Softbans مستخدم (ركلة + حذف رسائل المستخدم). - - - المستخدم ل softban. - - - عدد أيام حذف رسائله الأخيرة. 7 بشكل افتراضي. - - - سبب softban. - - - ماذا؟ تريد softban نفسك؟ - - - أنا لن softban نفسي ههههههههه - - - تم حظر المستخدم {0}. - - - زنزانة AI (استخدم {0}aid <command>) - - - جارٍ الرجوع إلى الإجراء الأخير ... - - - 140 حرفًا كحد أقصى. - - - رابط الدعوة - - - ستعمل بعض الأوامر بشكل أفضل مع إذن `إدارة الرسائل` (` img`، `urban`،` config`) - - - - نص غير صالح. - - - إذا تم تمرير مستخدم ، فسيحاول الأمر حذف جميع رسائل المستخدمين في رسائل `العدد 'الأخيرة. - - - حظر الأعضاء - - - ركلة الأعضاء - - - المستخدم غير محظور. - - - البادئة هنا هي "{0}`. استخدم `{0} help` لرؤية قائمة الأوامر الخاصة بي و` {0} البادئة` لتغيير البادئة. - - - لا أعتقد أنه يمكنك استخدام ذلك هنا. - - - هذا القط غير موجود - - - هذا الشخص غير موجود - - - بلغ الحد الأقصى للبحث يوتيوب :( - - - شخص ما يقوم بإنشاء ذاكرة التخزين المؤقت للفيديو ، يرجى الانتظار .. - - - المستعمل - - - يعرض آخر رسالة تم تحريرها في القناة الحالية. - - - يضيف رد فعل على رسالة. - - - رد الفعل لإضافة (واحد فقط) - - - معرف الرسالة لإضافة رد فعل (يجب أن يكون من نفس القناة التي يتم تنفيذ الأمر). - - - - معرف الرسالة غير صالح. تأكد من أن الرسالة من هذه القناة. - - - رمز تعبيري / رمز تعبيري غير صالح. - - - - عدد الوسيطة غير صالح. استخدم `{0} help{1}` للحصول على مزيد من المعلومات حول الأمر. - - - خطأ تحليل الحجج الأوامر. حجة غير صالحة أو ترتيب الحجج غير صحيح. -استخدام {0} {1} مساعدة للحصول على مزيد من المعلومات حول الأمر. - - - نتائج الترجمة - - - - لغة المصدر (تم الكشف عنها) - - - - اللغة الهدف - - - - نتيجة - - - نوع الخطأ - - - - رسالة خطأ - - - يبحث عن المسار الحالي إلى الموضع المحدد. - - - الثانية الثانية للبحث أو وقت صالح بالتنسيق `m: ss` أو` mm: ss` أو `h: mm: ss` أو` hh: mm: ss`. - - - لا يمكن البحث عن هذا المسار. - - - الثانية التي يجب الذهاب إليها ({0}) لا يمكن أن تكون أعلى أو مساوية لطول المسار ({1}) - - - تم التخطي إلى الثانية {0} ({1} من {2}) - - - حدث خطأ في واجهة برمجة تطبيقات الترجمة. - - - يظهر احصائيات البوت. - - - تم حذف المغامرة. - - - استخدام المعالج - - - استخدام ذاكرة الوصول العشوائي - - - مكتبة - - - نسخة بوت - - - نظام التشغيل - - - مالك بوت - - - اللقب الحالي والنك الجديد متطابقان. - - - نعم - - - لا - - - صحيح - - - خاطئة - - - السيارات ترجمة AI الأبراج المدخلات والمخرجات -تتبع اختيار `play` - - - استخدام ردود الفعل لتمكين أو تعطيل خيار. - - - تكوين Fergun - - - اختيار - - - القيمة - - - تم إدراج المستخدم {0} في القائمة السوداء. - - - تم إدراج المستخدم {0} في القائمة السوداء للسبب: {1} - - - تمت إزالة المستخدم {0} من القائمة السوداء. - - - يضيف مستخدمًا إلى القائمة السوداء ، أو يزيله. - - - معرف المستخدم إلى القائمة السوداء. - - - سبب يجري على القائمة السوداء. - - - انت في القائمة السوداء - - - أنت في القائمة السوداء للسبب: {0} - - - ليس لديك أذونات لتشغيل هذا الأمر. - - - تحتاج إلى إذن `حظر الأعضاء` لاستخدام هذا الأمر. - - - أحتاج إلى إذن "حظر الأعضاء" لتنفيذ هذا الأمر. - - - تحتاج إلى إذن `إدارة الرسائل` لاستخدام هذا الأمر. - - - أحتاج إلى إذن "إدارة الرسائل" لتنفيذ هذا الأمر. - - - تحتاج إلى إذن "Kick Members" لاستخدام هذا الأمر. - - - أحتاج إلى إذن "Kick Members" لتنفيذ هذا الأمر. - - - تحتاج إلى إذن "إدارة الألقاب" لاستخدام هذا الأمر. - - - معرف الرسالة غير صالح. تأكد من أن الرسالة من هذه القناة. - - - يجب أن يكون المستخدم المحدد أقل في التسلسل الهرمي. - - - أحتاج إلى إذن `Connect` للانضمام إلى القناة الصوتية. - - - أحتاج إلى إذن `Speak` للمتابعة. - - - انتقل إلى القناة الصوتية التي تريد مني نقلها. - - - وقت المعالجة: {0} مللي ثانية - - - نتائج التعرف الضوئي على الحروف - - - أحتاج إلى إذن "إرفاق ملفات" لتنفيذ هذا الأمر. - - - خطأ في OCR API. - - - يفرض البادئة على هذه النقابة. - - - استخدم {0} help Continue <ID> [text] لمتابعة قصتك. - - - {البادئة كبيرة جدا. الحد الأقصى. طول البادئة هو: {0. - - - URL غير صالح. - - - OCR وترجمة. - - - اللغة الهدف في كود ISO (en ، es ، br ، إلخ.) - - - عنوان URL للصورة المراد استخدامها. - - - إدخال - - - الدور غير موجود. - - - معلومات الدور - - - اللون - - - هل يذكر - - - أذونات - - - عدد الأعضاء - - - أشير - - - يحصل على معلومات حول دور. - - - دور الحصول على معلومات من (ليس من الضروري ذكرها). - - - يفصل البوت. - - - أفضل أمر. - - - إرجاع مستخدم عشوائي. - - - عملاء نشيطون - - - منخفض - - - متوسط - - - عالي - - - أقصى - - - يرفع - - - موضع - - - ميزات - - - طبقة Nitro Boost - - - عد دفعة نيترو - - - لا يمكن أن يكون الوقت المستغرق ({0}) أعلى أو مساويًا لطول المسار ({1}) - - - تم التخطي إلى {0} من {1} - - - يجب عليك تمرير عدد صحيح أو سلسلة بالتنسيق `m: ss` أو` mm: ss` أو `h: mm: ss` أو` hh: mm: ss`. - - - انتظر {0} ثانية قبل استخدام هذا الأمر مرة أخرى! - - - المفعول به غير موجود. - - - تم العثور على تطابقات متعددة. -حاول استخدام علامة ({0}) أو إشارة ({1}) أو معرّف ({2}). - - - الأدوار - - - تعزيز منذ ذلك الحين - - - البوت متصل بالفعل بقناة صوتية. - - - نوع الملف غير صالح. - - - أوامر المالك - - - يمكنك التصويت بالنسبة لي [هنا] ({0}). شكرا لك. - - - يظهر على تضمين مع رابط التصويت. - - - مجموع خوادم - - - عدد الأعضاء - - - إذا كان لديك أي تقرير خطأ أو سؤال أو اقتراح ، يمكنك الانضمام إلى [خادم الدعم] ({0}) أو الاتصال بي عبر رسالة مباشرة ({1}). - - - الإعادة الإجراء الأخير ... - - - لم يكن لديك أي معرفات المغامرة. -يمكنك استخدام قيادة new لخلق مغامرة جديدة. - - - .. و {0} أكثر المسارات! - - - في حالة استمرار حدوث ذلك ، يرجى الإبلاغ عن هذا الخطأ في [خادم الدعم]({0}) أو فتح مشكلة في [مستودع GitHub]({1}) تصف الخطأ وخطوات إعادة إنتاجه. - - - يدير أمر باش. - - - يظهر معلومات الدعم. - - - حدث خطأ أثناء استدعاء API. - - - موضوع - - - غير NSFW - - - وضع بطيء - - - معلومات القناة - - - يظهر من المعلومات حول القناة. - - - OCR ونتائج الترجمة - - - تحتاج إلى تمرير لا يقل عن 1 الاختيار. - - - قناة غير موجود. - - - القناة للحصول على معلومات من. - - - كنت في حاجة إلى إدارة Server إذن لاستخدام هذا الأمر. - - - - - يمر النص من خلال مترجم سيئة. - - - النص للاستخدام. - - - سلسلة لغة - - - نسخة غير موجود. الإصدارات هي: {0} - - - إصدارات أخرى: {0} - - - حذف {0} رسائل. - - - حذف {0} رسائل كتبها {1}. - - - يقيم التعبير الرياضيات. - - - التعبير لتقييم. - - - التعبير صالح. - - - النتائج احسب - - - تم تحديث لاعب لتكرار المسار {0} مرات. استخدام هذا الأمر بدون معلمات لتعطيل حلقات المسار. - - - تمرير عدد من مرة تريد تكرار المسار، مثلا: -`{0}loop 5` - - - سيتم تكرار المسار الحالي الآن {0} مرة. -لتعطيل الحلقة ، استخدم هذا الأمر بدون معلمات. - - - انتهت {0}: في حلقات لمسار. - - - يكرر الحالية تتبع عددا من المرات. - - - عدد مرات تكرار المسار. - - - تم تعطيل حلقات المسار. - - - تحتاج إلى تشغيل مسار أو تمرير نام المسار - - - كلمات عبقرية - - - هذا هو paginator. يتفاعل مع الرموز المعنية لصفحة تغيير - - - صفحة الفنان - - - اضغط على الزر / رمز تعبيري أدناه لحذف المغامرة. - - - أنت لم تتفاعل قبل المهلة! - - - اختيار اللغة - - - حدد اللغة التي تريد تعيينها. - - - استخدام -headers في نهاية الأمر للحفاظ على العلامات ( [المعلومات] ). - - - <> = إلزامية | [] = اختياري | لم تكتب هذه الرموز عند كتابة الأمر. - - - يحول (يلغي) صورة. - - - عنوان URL للصورة المراد استخدامها. - - - يجب أن تكون رسائل الأصغر من أسبوعين. - - - لا يمكن حذف {0} رسائل لأنهم هم من كبار السن من أسبوعين. - - - لم يتم العثور على الخادم. - - - حدث خطأ أثناء تحليل كلمات ل{0}. -ربما انها مفيدة؟ - - - المعلمة `{0}` يجب أن يكون أقل من {1}. - - - بطاقة التعريف غير صالحة. - - - رابط Hastebin - - - غير متوفر. - - - في انتظار تفريغ قائمة الانتظار ... - - - أدخل النص الجديد - - - جارٍ إنشاء رد جديد ... - - - خيارات الإدخال - - - افعل: الإجراء الذي تريد القيام به في القصة. ابدأ دائمًا بإجراء ، على سبيل المثال. "البحث عن الكنز المخفي". هذا هو الخيار الافتراضي إذا لم يتم الكشف عن أي شخص. - -قل: يمكنك استخدام هذا للتحدث ، على سبيل المثال. "دعهم و شأنهم!" - -القصة: يمنع هذا الإدخال AI من وضع "أنت" أمام إدخالك. - - - عدد قناة - - - منطقة - - - القناة الإفتراضية - - - لم يتم العثور على اللغة. - - - الفئة العد - - - إلقاء المغامرة... - - - تحرير إلغاؤها. - - - ليس هناك شيء لحذفه. - - - أفراد - - - عبر الانترنت - - - خامل - - - السحب والإفلات - - - غير متصل على الانترنت - - - يظهر من المعلومات AI الأبراج. - - - يخلق مغامرة جديدة. - - - يواصل المغامرة مع نص المقدمة. إذا كان يتم تمرير أي نص، فإن منظمة العفو الدولية توليد القصة. - - - معرف المغامرة. - - - النص للاستخدام. - - - التراجع عن الإجراء الأخير. - - - معرف المغامرة. - - - Redoes العمل التراجع الماضي. - - - معرف المغامرة. - - - ويضيف النص إلى إطار الذاكرة. - - - معرف المغامرة. - - - النص لإضافة. - - - التعديلات استجابة الماضية. - - - معرف المغامرة. - - - عمليات إعادة الإجراء الأخير ويولد استجابة جديدة. - - - معرف المغامرة. - - - يجعل الجمهور ID. - - - مغامرة IF. عليك أن تملك هذا الرقم. - - - يجعل الخاص ID. - - - مغامرة IF. عليك أن تملك هذا الرقم. - - - يحصل على قائمة معرف مستخدم. - - - للمستخدم الحصول على معرفات لها. - - - يظهر من المعلومات حول هوية. - - - معرف المغامرة. - - - يزيل معرف. - - - معرف المغامرة. - - - مقالب عن النص من معرف. - - - معرف المغامرة. - - - يستخدم {0} كل {1} - - - المتطلبات - - - تبين الإحصاءات الأوامر. - - - إحصائيات الأوامر (منذ {0}) - - - اسمحوا لي أن جوجل ذلك لك. - - - الكلمة (الكلمات) الرئيسية للبحث. - - - تحميل النص إلى Hastebin. - - - النص للتحميل. - - - تحميل... - - - يظهر آخر حذف الرسائل في القناة الحالية أو المحدد. - - - قناة خاصة لقنص. - - - {0} دقيقة - - - المرفق - - - يظهر الرسائل التي تم تحريرها مشاركة في القناة الحالية أو المحدد. - - - قناة خاصة لقنص. - - - قناة النص - - - قناة إعلان - - - قناة صوت - - - قناة DM - - - معدل البت - - - حد المستخدم - - - لا حدود - - - تعطيل أمر محليا (لهذا الملقم). - - - اسم الأمر إلى تعطيل. - - - {0} لا يمكن تعطيل! - - - {0} تم تعطيل بالفعل! - - - تم تعطيل الأمر {0} في هذا الخادم. - - - تمكن أمر محليا (لهذا الملقم). - - - اسم الأمر إلى تمكين. - - - {0} تمكين بالفعل! - - - تم تمكين الأمر {0} في هذا الخادم.$$ - - - تعطيل على مستوى العالم الأمر (في كافة ملقمات).$$ - - - اسم الأمر لتعطيل عالميا. - - - السبب للاستخدام. - - - {0} تم تعطيل بالفعل على مستوى العالم! - - - تم تعطيل الأمر {0} في كافة الملقمات. - - - تمكن عالميا الأمر (في كافة ملقمات). - - - اسم الأمر لتمكين عالميا. - - - {0} تمكين بالفعل على مستوى العالم! - - - تم تمكين الأمر {0} في كافة الملقمات. - - - السبب - - - لقد تركت قناة صوت بسبب عدم النشاط. - - - سيتم إعادة تشغيل Fergun / إيقاف في بعض ثوان، ومشغل الموسيقى الخاص بك سيتم إيقاف. آسف للإزعاج. - - - تحذير - - - معرف العام من هذه المغامرة هو باطل. يرجى إنشاء مغامرة جديدة. - - - يعطي (ينقل) المعرف المحدد للمستخدم. - - - معرف المغامرة. - - - يعطي المستخدم المعرف. - - - لا يمكنك إعطاء الهوية بنفسك! - - - تم نقل المعرف بنجاح إلى {0}. - - - يدعو - - - صفحة Top.GG Bot - - - رابط التصويت - - - خادم الدعم - - - إذا كان لديك أي تقرير خطأ أو سؤال أو اقتراح ، يمكنك الاتصال بي عبر رسالة مباشرة ({0}). - - - لم يتم تحديد قيمة `{0}` في التهيئة. إذا استمرت هذه المشكلة ، فاتصل بالمطورين. - - - لا يمكنك إعطاء المعرف لروبوت! - - - يبدو أنه لا توجد لغات متاحة. - - - الطلب منتهي المدة. - - - خطأ في خوادم الخلاف - - - فشل الأمر نظرا لوجود مشكلة على جانب الخلاف في. -الرجاء معاودة المحاولة في وقت لاحق. - - - تفاصيل الخطأ - - - لا يوجد مكان للتصويت. - - - تعذر الاتصال بخادم Lavalink. الرجاء المحاولة مرة أخرى لاحقاً. - - - تم وضع الخادم {0} في القائمة السوداء. - - - تم وضع الخادم {0} في القائمة السوداء للسبب: {1} - - - تمت إزالة الخادم {0} من القائمة السوداء. - - - يضيف خادمًا إلى القائمة السوداء أو يزيله. - - - معرف الخادم إلى القائمة السوداء. - - - سبب إدراجها في القائمة السوداء. - - - لا يمكن الحصول على حالة Spotify لأنني لا أمتلك: Guild Presences Intent. - - - لم يتم العثور على حالة Spotify للمستخدم {0}. - - - انقر هنا - - - عنوان - - - الفنانين - - - البوم - - - المدة الزمنية - - - كلمات الاغنية - - - تتبع عنوان Url - - - يحصل على معلومات حالة Spotify لمستخدم. - - - يحصل المستخدم على معلومات حالة Spotify الخاصة به. - - - يحصل على استجابة من Wolfram | Alpha بناءً على الاستعلام ، - - - الاستعلام المراد إرساله. - - - يحصل على تعريفات للكلمة. - - - كلمة البحث. - - - كلمة - - - تعريف - - - المرادفات - - - التضاد - - - عنوان Url الخاص بالموقع لأخذ لقطة شاشة. - - - طابع زمني بالتنسيق المحدد. - - - يجب أن يكون الطابع الزمني بتنسيق `YYYYMMDDhhmmss` ، حيث يكون `YYYY `مطلوبًا وتكون القيم الأخرى اختيارية. - - - الطابع الزمني غير صالح. - - - لم يتم العثور على لقطات لعنوان url والطابع الزمني المحددين. - - - الحصول على لقطة شاشة لموقع ويب في السنة / الشهر / اليوم المحدد باستخدام طابع زمني. - - - الطابع الزمني - - - تعذر العثور على سطر أسلوب الأوامر. - - - تقصير عنوان Url. - - - عنوان Url المطلوب اختصاره. - - - شارات - - - يبحث عن الصور باستخدام DuckDuckGo. - - - الكلمة (الكلمات) الرئيسية للبحث. - - - يعرض سياسة الخصوصية وتكوين الخصوصية. - - - سياسة خاصة - - - ما البيانات التي نجمعها - - - أ. تكوين الخادم (معرف الخادم ، البادئة المخصصة ، لغة الروبوت ، الأوامر المعطلة ، إلخ) -ب. تكوين / إحصائيات المستخدم (معرّف المستخدم ونقاط لعبة Trivia وتكوين الخصوصية وحالة القائمة السوداء) -ج. مغامرات AI Dungeon (معرف المغامرة ومعرف المالك وتوافر الاستخدام) -د. مجموعة مؤقتة من الرسائل المحذوفة / المعدلة ، والمستخدمة في أمري "snipe" (`snipe`, `editsnipe`, `bigsnipe` و `bigeditsnipe`) - - - متى نجمعها - - - أ. عندما تقوم بتعيين بادئة مخصصة أو لغة أو تكوين آخر. تتم إزالة هذه البيانات عندما يغادر الروبوت ذلك الخادم. -ب. عندما تلعب لعبة Trivia ، فإنك تستخدم تكوين الخصوصية أو يتم إدراجك في القائمة السوداء. -ج. عندما تنشئ مغامرة AI Dungeon مع الروبوت. تتم إزالة هذه البيانات عند استخدام الأمر `aid delete`. -د. عندما يتم حذف / تحرير رسالة. يمكن رؤية هذه الرسائل فقط باستخدام أوامر "snipe" في القناة حيث تم حذف / تحرير الرسالة. -يتم تخزين هذه الرسائل لمدة 6 ساعات فقط ، ويمكنك أن تقرر إلغاء الاشتراك باستخدام ردود الفعل أدناه. - - - تكوين الخصوصية - - - ستجد هنا بعض الإعدادات التي يمكنك تغييرها لتحسين خصوصيتك. - - - إلغاء الاشتراك من المجموعة المؤقتة للرسائل المحذوفة / المعدلة في أوامر "snipe" - - - خصوصية - - - ألا تريد عرض رسالتك هنا؟ راجع `privacy`. - - - لا يمكنك استخدام هذا التفاعل. - - - حذف المغامرة... - - - يتبرع - - - قائمة اللغات - - - يبحث عن الصور مع Brave. - - - الكلمة (الكلمات) للبحث. - - - خيط - - - الأرشفة التلقائية - - - قناة المرحلة - - - قناة المرحلة - - - مؤرشف - - \ 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Lista de comandos - - - Comandos de entretenimiento - - - Fergun {0} - Cantidad total de comandos: {1} - - - Comandos de moderación - - - Comandos de música - - - Notas - - - Usa `{0}help [comando]` para obtener mas información sobre un comando. - - - Otros comandos - - - Comandos de texto - - - Comandos de utilidad - - - Próximos comandos - - - Comando no encontrado. Usa `{0}help` para ver la lista de comandos. - - - (Descripción no disponible) - - - (Opcional) - - - Uso - - - Alias - - - Parámetros - - - Muestra caráctereres unicode aleatorios. - - - El tamaño del resultado. - - - El texto debe tener 3 o mas carácteres. - - - El texto resultante es demasiado largo para ser mostrado ({0}). - - - Normaliza un texto. - - - El texto a normalizar. - - - Aleatoriza un texto. - - - El texto a aleatorizar. - - - Repite un texto un número de veces. - - - Las veces a repetir. - - - El texto a repetir. - - - Invierte un texto. - - - El texto a invertir. - - - Invierte el orden de las líneas de un texto. - - - El texto a invertir sus líneas. - - - Invierte el orden de las palabras de un texto. - - - El texto a invertir sus palabras. - - - tEXto sARcaStIco. - - - El texto a convertir. - - - Convierte un texto a vaporwave. - - - El texto a convertir. - - - Retorna el avatar del usuario actual, o de un usuario en específico, si se pasa alguno. - - - El usuario a obtener su avatar. - - - Codifica un texto a Base64. - - - El texto a codificar. - - - Decodifica un texto en Base64. - - - El texto a decodificar. - - - Texto codificado inválido. - - - Escoje una opción de una lista. - - - Una lista separada por espacios de opciones. - - - Yo escojo... - - - -...porque solo me distes una opción - - - Muestra un color aleatorio o uno específico. - - - El color específico a usar. Debe ser un valor en hex, un valor en bruto o el nombre de un color conocido. - - - Muestra el menú de ayuda o la información de un comando, si se pasa alguno. - - - El comando a obtener su información. - - - Identifica una imagen con Microsoft CaptionBot. - - - La url de una imagen a usar. - - - 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. - - - El archivo adjunto no es una imagen. - - - No se pudo encontrar alguna url o archivo adjunto en los últimos {0} mensajes. - - - La url no es una imagen válida. - - - Busca imágenes con Google Images. - - - Las palabras clave a buscar. - - - No se encontraron resultados. - - - Si la búsqueda es NSFW, usa el comando en un canal NSFW. - - - Búsqueda de imagen - - - Urban Dictionary - Página {0} de {1} - - - Límite de búsqueda de imágenes alcanzada :( - - - Realiza OCR a una imagen. - - - La url de una imagen a usar. - - - El archivo es demasiado grande. - - - El OCR no dió resultados. - - - Realiza OCR a una imagen con Tesseract. - - - La url de una imagen a usar. - - - Código de estado: - - - Obtiene la latencia al enviar un mensaje y la latencia de la base de datos. - - - Redimensiona una imagen con waifu2x. - - - La url de una imagen a usar. - - - Ocurrió un error. - - - Resultados de redimensión - - - Toma una captura de pantalla a un sitio web. - - - El sitio web a tomar una captura de pantalla. - - - Retorna información del servidor. - - - Información de servidor - - - Nombre - - - Dueño - - - Número de roles - - - Número de usuarios - - - Nivel de verificación - - - (Nada) - - - Creado en - - - Muestra el último mensaje eliminado en el canal actual. - - - Nada que mostrar en {0} - - - Traduce un texto. - - - El idioma de destino en el código ISO (en, es, br, etc.) - - - El texto a traducir. - - - Idioma de destino inválido. Usa `{0}translate language codes` para ver la lista de idiomas. - - - Google me dió IP ban :( lol - - - Texto a voz. - - - El idioma de destino en el código ISO (en, es, br, etc.), Inglés por defecto en caso de idioma inválido. - - - El texto a convertir a voz. - - - Búsqueda en Urban Dictionary. - - - Las palabras clave a buscar. - - - Sin resultados. - - - Por - - - Ejemplo - - - (Sin ejemplo proporcionado) - - - Retorna información del usuario actual, o de un usuario en específico, si se pasa alguno. - - - El usuario a obtener su información. - - - Información del usuario - - - Actividad - - - Es bot - - - Dia de ingreso al servidor - - - Usuario no encontrado. -Intenta usar un tag ({0}), una mención ({1}) o una ID ({2}). - - - Busca un artículo en Wikipedia. - - - Las palabras clave a buscar. - - - Búsqueda en Wikipedia - - - Retorna un comic de xkcd aleatorio o uno en específico, si un número es pasado. - - - El número de cómic. - - - El número debe estar entre 1 y {0}. - - - Busca un video en YouTube. - - - Las palabras clave a buscar. - - - No se encontró resultados. - - - Envía un video "aleatorio" de YouTube. - - - Sin videos en el caché -Creando caché... - - - Banea un usuario. - - - El usuario a banear. - - - La razón del baneo. - - - Que? Te quieres banear a ti mismo? - - - No me banearé lol - - - El usuario ya está baneado. - - - El usuario {0} fue baneado. - - - Elimina los últimos x mensajes en el canal actual. - - - El número de mensajes a eliminar. - - - Información del usuario - - - El número debe estar entre {0} y {1}. - - - No se pudo encontrar algún mensaje de {0} en los últimos {1} mensajes. - - - por - - - Hackbanea un usuario. - - - La ID del usuario a hackbanear. - - - La razón del hackbaneo. - - - El usuario {0} fue hackbaneado. - - - Expulsa un usuario. - - - El usuario a expulsar. - - - La razón de la expulsión. - - - El usuario {0} fue expulsado. - - - Cambia el nickname de un usuario. - - - El usuario a cambiar su nickname. - - - El nuevo nickname. Deja esto vacío para eliminar el nickname. - - - Desbanea un usuario. - - - La ID del usuario a desbanear. - - - El usuario {0} fue desbaneado. - - - Hora de trivia! - - - La categoría a seleccionar. Especifica "categories" para ver la lista de categorias o "leaderboard"/"ranks" para ver la tabla de clasificación. - - - Lista de categorías - - - Tabla de clasificación de Trivia - - - Parece que respondistes todas las preguntas en la categoría especificada, selecciona otra categoría. - - - Ocurrió un error. Código de error - - - Categoría - - - Tipo - - - Dificultad - - - Pregunta - - - Opciones - - - Tienes {0} segundos para responder. - - - Opcion invalida! - - - Perdiste 1 punto. - - - Respuesta correcta! - - - Ganaste 1 punto. - - - La respuesta es - - - Se acabó el tiempo! - - - Puntos - - - Ingresa a un canal de voz. - - - Debes conectarte a un canal de voz. - - - Conectado a {0}. - - - Deja un canal de voz. - - - Ingresa al canal donde está el bot para hacerlo salir. - - - El bot ha salido de {0}. - - - Mueve el bot donde está el usuario actual. - - - Entra a un canal de voz donde quieras que el bot esté. - - - Busca y reproduce un video de YouTube. - - - Las palabras clave a buscar. - - - Envía un número de la lista - - - Búsqueda cancelada. - - - Opción fuera de índice. - - - No has respondido antes del límite de tiempo! - - - Vuelve a reproducir el video que está en curso, si hay alguno. - - - Pausa el reproductor. - - - Reanuda la reproducción. - - - Detiene el reproductor. - - - Salta la pista actual, si hay alguno - - - Establece el volumen del reproductor. - - - El volumen a establecer (2 - 150) - - - Muestra la cola de reproducción. - - - Aleatoriza la cola de reproducción. - - - Elimina una pista en la cola en el índice especificado. - - - El índice de la pista a eliminar. - - - Muestra la letra de la canción especificada, o de la canción actual en el reproductor si no se pasa alguna canción. - - - La canción a buscar su letra. - - - Muestra el artwork de la pista actual en el reproductor. - - - Sin mas pistas para reproducir. - - - Reproduciendo ahora - - - Ocurrió un error - - - El reproductor se quedó atascado en la pista {0} por {1} segundos. - - - Movido de **{0}** a **{1}** - - - No se encontraron resultados. Prueba usando un link de YouTube o un link de un archivo mp3. - - - Playlist **{0}** ({1} pistas) ({2}) ha sido agregado a la cola. - - - Añadido a la cola {0} pistas ({1}) - -Reproduciendo ahora: {2} - - - {0} ha sido añadido a la cola. - - - Reproduciendo ahora: {0} - - - Pista inválida. - - - Volviendo a reproducir {0} - - - La cola está vacia. - - - El reproductor no está reproduciendo nada. - - - El reproductor fue detenido. - - - Saltado: {0} - -Reproduciendo ahora: {1} - - - Usa un número entre 2 y 150. - - - Volumen establecido a: {0}. - - - El reproductor está pausado ahora. - - - Reproducción reanudada. - - - El reproductor no está pausado. - - - Reproduciendo actualmente: {0} ({1} de {2}) - - - Pistas en la cola: - - - Solo hay 1 item en la cola. - - - La cola fue aleatorizada. - - - Índice fuera de rango. - - - La pista {0} en la posición {1} fue eliminada. - - - No se encontró la letra para {0} - - - Incorrecto! - - - Establece el estado de juego del bot. - - - El texto a establecer. - - - Solo el dueño del bot puede usar esto. - - - Establece el estado del bot. - - - El estado a establecer (0 - 5). - - - Muestra el código fuente de un comando. - - - El comando a obtener su código. - - - Establece el color de los embeds. - - - El nuevo color en hexadecimal o decimal. - - - Color inválido. - - - Acabas de postear cringe... - - - Establece el prefijo global del bot. - - - El nuevo prefijo global. - - - El prefijo global actual es: - - - El prefijo de destino y el prefijo actual son iguales. - - - El nuevo prefijo global es: "{0}" - - - Envía el link de invitación del bot. - - - Establece el prefijo del bot. - - - El nuevo prefijo. - - - El prefijo actual es: - - - El nuevo prefijo del servidor es: "{0}" - - - Reinicia el bot. - - - Dice algo. - - - El texto a decir. - - - Muestra el tiempo de actividad del bot. - - - Establece el idioma del bot. - - - El idioma del bot ahora es: Español - - - Página {0} de {1} - - - Error al ejecutar - - - Obtiene la pista actual en el reproductor. - - - Muestra el registro de cambios del bot. - - - Deshabilitado temporalmente. - - - Obtiene algunas frases inspiradoras. - - - Necesito el permiso `{0}` para ejecutar este comando. - - - Gestionar mensajes - - - Conectar - - - Hablar - - - Evalúa código. - - - El código a evaluar. - - - 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. - - - El bot no está conectado a un canal de voz. - - - Creación cancelada. - - - Bienvenido a AI Dungeon - - - Asegúrate de leer la información y los comandos con `{0}aid info` antes de continuar. - -Selecciona un modo: - - - Selecciona un personaje - - - Adjuntar archivos - - - Resultados del Eval - - - Salida - - - Ejecutado en {0}ms - - - Código ejecutado sin ningún valor de retorno. - - - Este comando solo puede usarse en un canal NSFW. - - - Espera unos segundos antes de usar este comando! - - - Un canal en específico a buscar. - - - ID no encontrada. - - - 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 <ID>` - - - Debes esperar que la historia se genere antes de usar esta ID! - - - Generando una nueva aventura -con el modo: **{0}** -y el personaje: **{1}**... - - - Creación personalizada de personaje - - - Ingresa un texto que describa quién eres y las primeras dos oraciones de dónde empiezas. -Este aviso expirará en 5 minutos. - - - Generando una nueva aventura con el texto personalizado... - - - Generando historia... - - - Editando el contexto de la historia... - - - La IA ahora recordará: - - - Alterando la última salida... - - - Cambiada la última salida a: - - - No eres el dueño de esta ID. - - - La ID ya es pública. - - - La ID ahora es pública y todos pueden usarla. Puedes volver a establecerlo como privado con `makeprivate <ID>` - - - La ID ya es privada. - - - La ID ahora es privada y solo tú puedes usarla. Puedes volver a establecerlo como público con `makepublic <ID>` - - - {0} no tiene ningun(a) ID de aventura. - - - Lista de IDs para {0} - - - Es pública - - - Usa 'idinfo <ID>' para obtener información de una ID de aventura. - - - Información de la ID de aventura - - - 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. - - - 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. - - - Sobre AI Dungeon - - - Ayuda de AI Dungeon - - - Cómo jugar - - - Comandos - - - 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. - - - En - - - Muestra un embed con las opciones configuración del bot a nivel de servidor. - - - Debes ser un administrador para usar este comando! - - - Cargando... - - - Configración de Fergun - - - Configuración cancelada. - - - No puedo hacer eso. - - - Softbanea un usuario (kick + eliminar los mensajes del usuario). - - - El usuario a softbanear. - - - El número de días a eliminar sus últimos mensajes. 7 por defecto. - - - La razón del softbaneo. - - - Que? Te quieres softbanear a ti mismo? - - - No me voy a softbanear lol - - - El usuario {0} fue softbaneado. - - - AI Dungeon (usa {0}aid <comando>) - - - Revirtiendo la última acción... - - - Máximo 140 carácteres. - - - Link de invitación - - - Algunos comandos funcionan mejor con el permiso `Gestionar mensajes` (`img`, `urban`, `config`) - - - Texto inválido. - - - Si se pasa un usuario, el comando intentará eliminar todos los mensajes del usuario en los últimos `count` mensajes. - - - Banear miembros - - - Expulsar miembros - - - El usuario no está baneado. - - - Mi prefijo aquí es `{0}`. Usa `{0}help` para ver mi lista de comandos y `{0}prefix` para cambiar el prefijo. - - - No creo que puedas usar eso aquí. - - - Este gato no existe - - - Esta persona no existe - - - Límite de búsqueda de YouTube alcanzada :( - - - Alguien ya está creando un caché de video, espera.. - - - Usuario - - - Muestra el último mensaje editado en el canal actual. - - - Agrega una reacción a un mensaje. - - - La reacción a agregar (solo una). - - - La ID del mensaje a agregar la reacción (debe ser del mismo canal donde el comando es ejecutado). - - - ID de mensaje inválido. El mensaje debe ser de este canal. - - - Emoji/Emote inválido. - - - Cantidad de parámetros invalida. Usa `{0}help {1}` para obtener mas información del comando. - - - 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. - - - Resultados de traducción - - - Lenguaje de origen (Detectado) - - - Lenguaje de destino - - - Resultado - - - Tipo de error - - - Mensaje del error - - - Cambia la posición de reproducción de la pista a la especificada. - - - 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`. - - - No puedo cambiar la posición de esta pista. - - - El segundo a ir ({0}) no puede ser mayor o igual que la longitud de la pista ({1}). - - - Saltado al segundo {0} ({1} de {2}). - - - Ocurrió un error en la API de traducción. - - - Muestra las estadísticas del bot. - - - La aventura fue eliminada. - - - Uso de CPU - - - Uso de RAM - - - Librería - - - Versión del bot - - - Sistema Operativo - - - Dueño del bot - - - El nickname actual y el nuevo nickname son iguales. - - - Si - - - No - - - Verdadero - - - Falso - - - Auto traducir resultados de entrada y salida de AI Dungeon -Selección de pista para `play` - - - Usa las reacciones para habilitar o deshabilitar una opción. - - - Configuración de Fergun - - - Opción - - - Valor - - - No tienes los permisos necesarios para ejecutar este comando. - - - El usuario {0} fue agregado a la lista negra. - - - El usuario {0} fue agregado a la lista negra con la razón: {1} - - - El usuario {0} fue eliminado de la lista negra. - - - Agrega un usuario a la lista negra, o lo elimina de ella. - - - La ID del usuario a agregar a la lista negra. - - - La razón de ser incluido en la lista negra. - - - Estás en la lista negra. - - - Estás en la lista negra con la razón: {0} - - - Necesitas el permiso `Banear miembros` para usar este comando. - - - Necesito el permiso `Banear miembros` para ejecutar este comando. - - - Necesitas el permiso `Manejar mensajes` para usar este comando. - - - Necesito el permiso `Manejar mensajes` para ejecutar este comando. - - - Necesitas el permiso `Expulsar miembros` para usar este comando. - - - Necesito el permiso `Expulsar miembros` para ejecutar este comando. - - - Necesitas el permiso `Gestionar apodos` para usar este comando. - - - ID de mensaje inválido. El mensaje debe ser de este canal. - - - El/La usuario(a) especificado(a) debe ser inferior en jerarquía. - - - Necesito el permiso `Conectar` para entrar al canal de voz. - - - Necesito el permiso `Hablar` para continuar. - - - Ve al canal de voz donde quieres que vaya. - - - Tiempo de procesado: {0}ms - - - Resultados de OCR - - - Necesito el permiso `Adjuntar archivos` para ejecutar este comando. - - - Error en el API de OCR. - - - Fuerza el prefijo en este servidor. - - - Usa {0}aid continue <ID> [texto] para continuar tu aventura. - - - El prefijo es demasiado largo. El tamaño máximo del prefijo es: {0}. - - - URL inválida. - - - OCR y traducción. - - - El idioma de destino en el código ISO (en, es, br, etc.) - - - La url de una imagen a usar. - - - Entrada - - - Rol no encontrado. - - - Información de rol - - - Color - - - Es mencionable - - - Permisos - - - Cantidad de miembros - - - Mención - - - Obtiene información sobre un rol. - - - El rol a obtener información (no es necesario mencionarlo). - - - Desconecta el bot. - - - El mejor comando. - - - Retorna un usuario aleatorio. - - - Clientes activos - - - Bajo - - - Medio - - - Alto - - - Extremo - - - Es un rol separado - - - Posición - - - Mejoras - - - Nivel de mejora - - - Cantidad de mejoras - - - El tiempo a ir ({0}) no puede ser mayor o igual que la longitud de la pista ({1}). - - - Saltado a {0} de {1} - - - Debes pasar un número o un texto con el formato `m:ss`, `mm:ss`, `h:mm:ss` o `hh:mm:ss`. - - - Espera {0} segundos antes de volver a usar este comando! - - - El objeto no fue encontrado. - - - Se encontraron múltiples coincidencias. -Intenta usar un tag ({0}), una mención ({1}) o una ID ({2}). - - - Roles - - - Mejorando desde - - - El bot ya está conectado a un canal de voz. - - - Tipo de archivo invalido. - - - Comandos del dueño - - - Puedes votar por mi [aquí]({0}). Gracias. - - - Muestra un embed con el link de votación. - - - Servidores totales - - - Usuarios totales - - - Si tienes algún reporte de bug, pregunta o sugerencia, puedes entrar al [servidor de soporte]({0}) o contactarme vía mensaje directo ({1}). - - - Rehaciendo la última acción... - - - No tienes ninguna ID de aventura. -Puedes usar el comando `new` para crear una nueva aventura. - - - ..y {0} pistas mas! - - - 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. - - - Ejecuta un comando de bash. - - - Muestra la información de soporte. - - - Ocurrió un error mientras se llamaba a la API. - - - Tema - - - Es NSFW - - - Modo pausado - - - Información del canal - - - Muestra información acerca de un canal. - - - Resultados de OCR y traducción - - - Debes pasar al menos una opción. - - - Canal no encontrado. - - - El canal a obtener la información. - - - Necesitas el permiso `Gestionar servidor` para usar este comando. - - - Pasa un texto a través de un traductor malo. - - - El texto a usar. - - - Cadena de idiomas - - - Versión no encontrada. Las versiones son: {0} - - - Otras versiones: {0} - - - Eliminado {0} mensajes. - - - Eliminado {0} mensajes por {1}. - - - Evalúa una expresion matemática. - - - La expresión a evaluar. - - - Expresión inválida. - - - Resultados de calc - - - El reproductor fue actualizado para repetir la pista {0} veces. Usa este comando sin parámetros para deshabilitar la repetición de pistas. - - - Pasa el número de veces que quieres repetir la pista, ej: -`{0}loop 5` - - - Ahora la pista actual se repetirá {0} veces. -Para desactivar la repetición, usa este comando sin pasar ningún parámetro. - - - La repetición para la pista: {0} ha terminado. - - - Repite la pista en reproducción un número de veces. - - - El número de veces a repetir la pista. - - - La repetición de la pista fue deshabilitada. - - - Debes reproducir una pista con el bot o pasar el nombre de una pista. - - - Letra por Genius - - - Esto es un paginador. Reacciona a los íconos respectivos para cambiar la página. - - - Página del artista - - - Presiona el botón/emoji debajo para eliminar la aventura. - - - No reaccionaste antes del límite de tiempo! - - - Selección de idioma - - - Selecciona el idioma que quieres establecer. - - - Usa `-headers` al final del comando para mantener los tags (`[info]`). - - - <> = Requerido | [] = Opcional | No escribas estos símbolos con el comando. - - - Invierte (convierte a negativo) una imagen. - - - La url de una imagen a usar. - - - Los mensajes deben tener menos de dos semanas de antigüedad. - - - No se pudieron eliminar {0} mensajes porque tienen mas de dos semanas de antigüedad. - - - Servidor no encontrado. - - - Ocurrió un error mientras se extraía la letra de {0}. -Tal vez es un instrumental? - - - El parámetro `{0}` debe ser menor que {1}. - - - ID inválida. - - - Link de Hastebin - - - No disponible. - - - Esperando que la cola se vacíe... - - - Ingresa el texto nuevo - - - Generando una nueva respuesta... - - - Opciones de entrada - - - 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. - - - Miembros - - - En línea - - - Ausente - - - No molestar - - - Desconectado - - - Número de canales - - - Región - - - Canal por defecto - - - Idioma no encontrado. - - - Número de categorías - - - Volcando la aventura... - - - Edición cancelada. - - - Nada que eliminar. - - - Muestra la info de AI Dungeon. - - - Crea una nueva aventura. - - - Continúa la aventura con el texto ingresado. Si no se da un texto, la IA generará una historia. - - - La ID de aventura. - - - El texto a usar. - - - Deshace la última acción. - - - La ID de aventura. - - - Rehace la última acción deshecha. - - - La ID de aventura. - - - Agrega texto en el contexto de la memoria. - - - La ID de aventura. - - - El texto a agregar. - - - Edita la última respuesta. - - - La ID de aventura. - - - Reintenta la última acción y genera una nueva respuesta. - - - La ID de aventura. - - - Hace público una ID. - - - La ID de aventura. Tienes que ser el dueño de esta ID. - - - Hace privado una ID. - - - La ID de aventura. Tienes que ser el dueño de esta ID. - - - Muestra la lista de IDs de un usuario. - - - El usuario a obtener sus IDs. - - - Muestra información acerca de una ID. - - - La ID de aventura. - - - Elimina una ID. - - - La ID de aventura. - - - Extrae todo el texto de una ID. - - - La ID de aventura. - - - {0} uso(s) cada {1} - - - Requisitos - - - Muestra las estadísticas de los comandos. - - - Command stats (desde {0}) - - - Permíteme que use Google por ti. - - - Las palabras clave a buscar. - - - Sube texto a Hastebin. - - - El texto a subir. - - - Subiendo... - - - Muestra los últimos mensajes eliminados en el canal actual. - - - Un canal en específico a buscar. - - - Hace {0} minutos - - - Archivo adjunto - - - Muestra los últimos mensajes editados en el canal actual. - - - Un canal en específico a buscar. - - - Canal de Texto - - - Canal de Anuncios - - - Canal de Voz - - - Canal de MD - - - Tasa de bits - - - Límite de usuarios - - - Sin límite - - - Deshabilita un comando localmente (para este servidor). - - - El nombre del comando a deshabilitar. - - - {0} no puede ser deshabilitado! - - - {0} ya está deshabilitado! - - - El comando {0} ha sido deshabilitado en este servidor. - - - Habilita un comando localmente (para este servidor). - - - El nombre del comando a habilitar. - - - {0} ya está habilitado! - - - El comando {0} ha sido habilitado en este servidor. - - - Deshabilita un comando globalmente (en todos los servidores). - - - El nombre del comando a deshabilitar globalmente. - - - The razón a usar. - - - {0} ya está deshabilitado globalmente! - - - El comando {0} ha sido deshabilitado en todos los servidores. - - - Habilita un comando globalmente (en todos los servidores). - - - {0} ya está habilitado globalmente! - - - El comando {0} ha sido habilitado en todos los servidores. - - - El nombre del comando a habilitar globalmente. - - - Razón - - - He salido del canal de voz por inactividad. - - - Fergun será reiniciado/apagado en unos segundos y tu reproductor de música sera cerrado. Lo siento por los inconvenientes ocasionados. - - - Advertencia - - - La ID pública de esta aventura es nula. Por favor crea una nueva aventura. - - - Regala (transfiere) la ID especificada a un usuario. - - - La ID de aventura. - - - El usuario a regalar la ID. - - - No puedes regalar la ID a ti mismo! - - - Se transfirió correctamente la ID a {0}. - - - Invitación - - - Página de Top.GG - - - Link de Votación - - - Servidor de Soporte - - - Si tienes algún reporte de bug, pregunta o sugerencia, puedes contactarme vía mensaje directo ({0}). - - - El valor de `{0}` no ha sido establecido en la configuración. Si este problema persiste, contacta a los desarrolladores. - - - No puedes dar la ID a un bot! - - - Parece que no hay idiomas disponibles. - - - Tiempo de espera agotado para la solicitud. - - - Error en los servidores de Discord - - - El comando ha fallado debido a un problema en los servidores de Discord. -Por favor inténtelo de nuevo más tarde. - - - Detalles del error - - - No hay ningún lugar para votar. - - - No se pudo conectar al servidor de Lavalink. Por favor inténtelo de nuevo más tarde. - - - El servidor {0} fue agregado a la lista negra. - - - El servidor {0} fue agregado a la lista negra con la razón: {1} - - - Agrega un servidor a la lista negra, o lo elimina de ella. - - - La ID del servidor a agregar a la lista negra. - - - La razón de ser incluido en la lista negra. - - - El servidor {0} fue eliminado de la lista negra. - - - No puedo obtener el estado de Spotify porque no tengo: Guild Presences Intent. - - - No se encontró ningún estado de Spotify para el usuario {0}. - - - Click aquí - - - Título - - - Artista(s) - - - Álbum - - - Duración - - - Letra - - - Url de la pista musical - - - Obtiene la información del estado de Spotify de un usuario. - - - El usuario a obtener la información de estado de Spotify. - - - Obtiene una respuesta de Wolfram|Alpha basado en la consulta. - - - La consulta a enviar. - - - Obtiene definiciones de una palabra. - - - La palabra a buscar. - - - Palabra - - - Definición - - - Sinónimos - - - Antónimos - - - Obtiene una captura de pantalla de un sitio web en el año/mes/día especifado usando una marca de tiempo. - - - La Url del sitio web a tomar una captura de pantalla. - - - La marca de tiempo con el formato especificado. - - - La marca de tiempo debe tener el formato `YYYYMMDDhhmmss`, donde `YYYY` es requerido y los otros valores son opcionales. - - - Marca de tiempo inválida. - - - No se encontraron instantáneas para la url y marca de tiempo especificados. - - - Marca de tiempo - - - No se pudo encontrar el número de línea del método del comando. - - - Acorta una Url. - - - La Url a acortar. - - - Insignias - - - Busca imágenes con DuckDuckGo, - - - Las palabras clave a buscar. - - - Muestra la política de privacidad y la configuración de privacidad. - - - Política de Privacidad - - - Que datos recopilamos - - - 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`) - - - Cuándo lo recopilamos - - - 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. - - - Configuración de privacidad - - - Aquí encontrarás algunos ajustes que puedes cambiar para mejorar tu privacidad. - - - Optar por no participar en la colección temporal de mensajes eliminados/editados en los comandos de "snipe" - - - Privacidad - - - ¿No quieres que tu mensaje se muestre aquí? Vea `privacy`. - - - No puedes usar esta interacción. - - - Eliminando la aventura... - - - Donar - - - Lista de Idiomas - - - Busca imágenes con Brave. - - - Las palabras clave a buscar. - - - Hilo - - - Auto Archivado - - - Canal de escenario - - - Iniciado - - - Archivado - - \ 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Command List - - - Entertainment commands - - - Fergun {0} - Total command count: {1} - - - Moderation commands - - - Music commands - - - Notes - - - Use `{0}help [command]` to get more info about a command. - - - Other commands - - - Text commands - - - Utility commands - - - Upcoming commands - - - Command not found. Use `{0}help` to view the command list. - - - (No description available) - - - (Optional) - - - Usage - - - Alias(es) - - - Parameters - - - Shows random unicode characters. - - - The result length. - - - The text must have 3 or more characters. - - - The resulting text is too long to display ({0}). - - - Normalizes a text. - - - The text to normalize. - - - Randomizes a text. - - - The text to randomize. - - - Repeats a text a number of times. - - - The times to repeat. - - - The text to repeat. - - - Reverses a text. - - - The text to reverse. - - - Reverses the line order of a text. - - - The text to reverse its lines. - - - Reverses the words order of a text. - - - The text to reverse its words. - - - sARcAstIC teXt. - - - The text to convert. - - - Converts a text to vaporwave. - - - The text to convert. - - - Returns the avatar of the current user, or a specific user, if passed. - - - The user to get its avatar. - - - Encodes a text to Base64. - - - The text to encode. - - - Decodes a text from Base64. - - - The text to decode. - - - Invalid encoded text. - - - Chooses an option from a list. - - - A space separated list of choices. - - - I choose... - - - -...because you only gave me one choice - - - Shows a random or specific color. - - - The specific color to use. Must be a hex value, raw value or a known color name. - - - Shows the help menu or the info of a command, if passed. - - - The command to get info from. - - - Identifies an image with Microsoft CaptionBot. - - - The url of an image to use. - - - Not specifying a url will make the command to search for the last x messages in cache for a link or an attachment. - - - The attachment is not an image. - - - Could not find any url or attachment in the last {0} messages. - - - The url is not a valid image. - - - Searches for images with Google Images. - - - The keyword(s) to search. - - - Could not find any results. - - - If the search is NSFW, use the command in a NSFW channel. - - - Image search - - - Urban Dictionary - Page {0} of {1} - - - Image search cap reached :( - - - Performs OCR to an image. - - - The url of an image to use. - - - The file is too large. - - - The OCR did not give results. - - - Performs OCR to an image with Tesseract. - - - The url of an image to use. - - - Status code: - - - Gets the latency in sending a message and the latency of the Database. - - - Resizes an image with waifu2x. - - - The url of an image to use. - - - An error occurred. - - - Resize results - - - Takes a screenshot to a website. - - - The website to take a screenshot. - - - Returns info about the current server. - - - Server info - - - Name - - - Owner - - - Role count - - - User count - - - Verification level - - - (None) - - - Created at - - - Shows the last deleted message in the current channel or the specified. - - - Nothing to snipe in {0} - - - Translates a text. - - - The target language in ISO code (`en`, `es`, `br`, etc.) - - - The text to translate. - - - Invalid target language. Use `{0}translate language codes` to see the language list. - - - I got IP banned by Google :( lol - - - Text to speech. - - - The target language in ISO code (`en`, `es`, `br`, etc.), fallback to English if the target is invalid. - - - The text to convert. - - - Urban Dictionary search. - - - The keyword(s) to search. - - - No results. - - - By - - - Example - - - (No example provided) - - - Returns info about the current user, or a specific user, if passed. - - - The user to get info from. - - - User info - - - Activity - - - Is bot - - - Guild join date - - - User not found. -Try using a tag ({0}), a mention ({1}) or an ID ({2}). - - - Searches for an article on Wikipedia. - - - The keyword(s) to search. - - - Wikipedia Search - - - Returns a random xkcd comic or a specific comic, if a number is passed. - - - The comic number. - - - Number must be between 1 and {0}. - - - Searches a video on YouTube. - - - The keyword(s) to search. - - - Could not find any results. - - - Returns a "random" YouTube video. - - - Out of cached videos -Creating cache... - - - Bans a user. - - - The user to ban. - - - The reason of the ban. - - - What? You want to ban yourself? - - - I won't ban myself lol - - - The user is already banned. - - - User {0} was banned. - - - Clears the last x messages in the current channel. - - - The number of messages to clear. - - - A user to delete its messages. - - - The number must be between {0} and {1}. - - - Could not find any messages by {0} in the last {1} messages. - - - by - - - Hackbans a user. - - - The ID of the user to hackban. - - - The reason of the hackban. - - - User {0} was hackbanned. - - - Kicks a user. - - - The user to kick. - - - The reason of the kick. - - - User {0} was kicked. - - - Changes the nickname of a user. - - - The user to change its nickname. - - - The new nickname. Leave this empty to remove the nickname. - - - Unbans a user. - - - The user ID to unban. - - - User {0} was unbanned. - - - Trivia time! - - - The category to select. Specify "categories" to see the list of categories or "leaderboard"/"ranks" to see the leaderboard. - - - Category list - - - Trivia leaderboard - - - Seems that you answered all the questions in the specified category, select another category.. - - - An error occurred. Error code - - - Category - - - Type - - - Difficulty - - - Question - - - Options - - - You have {0} seconds to answer. - - - Invalid option! - - - You lost 1 point. - - - Correct answer! - - - You won 1 point. - - - Incorrect! - - - The answer is - - - Time's up! - - - Points - - - Joins a voice channel. - - - You need to connect to a voice channel. - - - Now connected to {0}. - - - Leaves a voice channel. - - - Join the channel the bot is in to make it leave. - - - Bot has now left {0}. - - - Moves the bot to the voice channel the current user is. - - - Join a voice channel where you want the bot to be. - - - Searches and plays a track from YouTube. - - - The keyword(s) to search. - - - Send a number from the list - - - Search canceled. - - - Option out of index. - - - You did not reply before the timeout! - - - Replays the current track that is playing, if there's any. - - - Pauses the player. - - - Resumes the playback. - - - Stops the player. - - - Skips the current track, if there's any. - - - Sets the player volume. - - - The volume to set (2 - 150) - - - Shows the queue. - - - Shuffles the queue. - - - Removes a track in the queue at a specified index. - - - The index of the track to remove. - - - Shows the lyrics of the specified song, or the current track in the player if none passed. - - - The song to search its lyrics. - - - Shows the artwork of the current track in the player. - - - No more tracks to play. - - - Now playing - - - An error occurred - - - The player got stuck with the track **{0}** for {1} seconds. - - - Moved from **{0}** to **{1}** - - - No matches found. Try using a Youtube link or a mp3 file link. - - - Playlist **{0}** ({1} tracks) ({2}) has been added to the queue. - - - Queued {0} tracks ({1}) - -Now playing: {2} - - - {0} has been added to the queue. - - - Now playing: {0} - - - Invalid track. - - - Replaying {0} - - - The queue is empty. - - - Player isn't playing. - - - Player now stopped. - - - Skipped: {0} - -Now playing: {1} - - - Use a number between 2 and 150. - - - Volume set to: {0}. - - - Player is now paused. - - - Playback resumed. - - - Player isn't paused. - - - Currently playing: {0} ({1} of {2}) - - - Tracks in queue: - - - There's only 1 item in the queue. - - - The queue was shuffled. - - - Index out of range. - - - The track {0} at position {1} was removed. - - - No lyrics found for {0}. - - - Sets the game status of the bot. - - - The text to set. - - - Bot owner only. - - - Sets the status of the bot. - - - The status to set (0 - 5). - - - Shows the source code of a command. - - - The command to get its code. - - - Sets the embed color. - - - The new color in hexadecimal or decimal. - - - Invalid color. - - - Bro you just posted cringe! - - - Sets the bot global prefix. - - - The new global prefix. - - - The current global prefix is: - - - The target prefix and the current prefix are the same. - - - The new global prefix is: "{0}" - - - Sends the bot invite link. - - - Sets the bot prefix. - - - The new prefix. - - - The current prefix is: - - - The new guild prefix is: "{0}" - - - Restarts the bot. - - - Says something. - - - The text to say. - - - Shows the bot up-time. - - - Sets the bot language. - - - The bot language is now: English - - - Page {0} of {1} - - - Failed to execute - - - Gets the current track in the player. - - - Shows the bot changelog. - - - Disabled temporarily. - - - Get some inspirational quotes. - - - I need the `{0}` permission to execute this command. - - - Manage Messages - - - Connect - - - Speak - - - Evaluates code. - - - The code to evaluate. - - - The screenshot may have NSFW content and it won't be showed. Try using the command on a NSFW channel or use other url. - - - The bot isn't connected to a voice channel. - - - Creation canceled. - - - Welcome to AI Dungeon - - - Make sure to read the info and commands with `{0}aid info` before continuing. - -Select a mode: - - - Select a character - - - Attach Files - - - Eval results - - - Output - - - Executed in {0}ms - - - Code executed without any return value. - - - This command can only be used on a NSFW channel. - - - Wait some seconds before using this command! - - - A specific channel to snipe. - - - ID not found. - - - This story ID is not public and you can't use it. Ask the ID owner ({0}) to make it public with `makepublic <ID>` - - - You must wait for the story to be generated before using this ID! - - - Generating a new adventure -with the mode: **{0}** -and the character: **{1}**... - - - Custom character creation - - - 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. - - - Generating a new adventure with the custom prompt... - - - Generating story... - - - Editing the story context... - - - The AI will now remember: - - - Altering the last output... - - - Changed the last output to: - - - You are not the owner of this ID. - - - The ID is already public. - - - The ID is now public and everyone can use it. You can set it back to private with `makeprivate <ID>` - - - The ID is already private. - - - The ID is now private and and only you can use it. You can set it back to public with `makepublic <ID>` - - - {0} doesn't have any adventure IDs. - - - ID list for {0} - - - Is public - - - Use 'idinfo <ID>' to get info about an adventure ID. - - - Adventure ID info - - - 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. - - - 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. - - - About AI Dungeon - - - AI Dungeon Help - - - How to play - - - Commands - - - 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. - - - In - - - Shows an embed with the bot configuration options at guild level. - - - You need to be an administrator to use this command. - - - Loading... - - - Fergun configuration - - - Config canceled. - - - I can't do that. - - - Softbans a user (kick + delete user messages). - - - The user to softban. - - - The number of days to delete it's last messages. 7 by default. - - - The reason of the softban. - - - What? You want to softban yourself? - - - I won't softban myself lol - - - User {0} was softbanned. - - - AI Dungeon (use {0}aid <command>) - - - Reverting last action... - - - 140 characters max. - - - Invite link - - - Some commands work better with the `Manage Messages` permission (`img`, `urban`, `config`) - - - Invalid text. - - - 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. - - - Ban Members - - - Kick Members - - - The user isn't banned. - - - My prefix here is `{0}`. Use `{0}help` to see my command list and `{0}prefix` to change the prefix. - - - I don't think you can use that here. - - - This cat does not exist - - - This person does not exist - - - YouTube search cap reached :( - - - Someone is already creating a video cache, please wait.. - - - User - - - Shows the last edited message in the current channel. - - - Adds a reaction to a message. - - - The reaction to add (only one). - - - The message ID to add a reaction (must be from same channel the command is executed). - - - Invalid message ID. Make sure the message is from this channel. - - - Invalid emote/emoji. - - - The command is missing required arguments. Use `{0}help {1}` to get more info about the command. - - - 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. - - - Translation results - - - Source language (Detected) - - - Target language - - - Result - - - Error type - - - Error message - - - Seeks the current track to the specified position. - - - The nth second to seek or a valid time with the format `m:ss`, `mm:ss`, `h:mm:ss` or `hh:mm:ss`. - - - Cannot seek this track. - - - The second to go ({0}) cannot be higher or equal than the track's length ({1}). - - - Skipped to second {0} ({1} of {2}). - - - An error occurred in the translation API. Please try again later. - - - Shows the bot stats. - - - The adventure was deleted. - - - CPU Usage - - - RAM Usage - - - Library - - - Bot version - - - Operating System - - - Bot owner - - - The current nickname and the new nick are the same. - - - Yes - - - No - - - True - - - False - - - Auto translate AI Dungeon input & output -Track selection for `play` - - - Use the reactions to enable or disable an option. - - - Fergun configuration - - - Option - - - Value - - - You don't have the permissions to run this command. - - - The user {0} was blacklisted. - - - The user {0} was blacklisted with the reason: {1} - - - The user {0} was removed from the blacklist. - - - The server {0} was blacklisted. - - - The server {0} was blacklisted with the reason: {1} - - - The server {0} was removed from the blacklist. - - - Adds a user to the blacklist, or removes it. - - - The ID of the user to blacklist. - - - The reason for being blacklisted. - - - Adds a server to the blacklist, or removes it. - - - The ID of the server to blacklist. - - - The reason for being blacklisted. - - - You are in the blacklist. - - - You are in the blacklist with the reason: {0} - - - You need the `Ban Members` permission to use this command. - - - I need the `Ban Members` permission to execute this command. - - - You need the `Manage Messages` permission to use this command. - - - I need the `Manage Messages` permission to execute this command. - - - You need the `Kick Members` permission to use this command. - - - I need the `Kick Members` permission to execute this command. - - - You need the `Manage Nicknames` permission to use this command. - - - I need the `Manage Nicknames` permission to execute this command. - - - Specified user must be lower in hierarchy. - - - I need the `Connect` permission to join the voice channel. - - - I need the `Speak` permission to continue. - - - Move to the voice channel you want me to go. - - - Processing time: {0}ms - - - OCR Results - - - I need the `Attach Files` permission to execute this command. - - - Error in the OCR API. - - - Force sets the prefix in this guild. - - - Use {0}aid continue <ID> [text] to continue your adventure. - - - The prefix is too large. The max. prefix length is: {0}. - - - Invalid URL. - - - OCR and translate. - - - The target language in ISO code (en, es, br, etc.) - - - The url of an image to use. - - - Input - - - Role not found. - - - Role info - - - Color - - - Is mentionable - - - Permissions - - - Member count - - - Mention - - - Gets information about a role. - - - The role to get info from (it isn't necessary to mention it). - - - Disconnects the bot. - - - The best command. - - - Returns a random user. - - - Active clients - - - Low - - - Medium - - - High - - - Extreme - - - Is hoisted - - - Position - - - Features - - - Nitro Boost Tier - - - Nitro Boost Count - - - The time to go ({0}) cannot be higher or equal than the track's length ({1}). - - - Skipped to {0} of {1} - - - You must pass an integer or a string with the format `m:ss`, `mm:ss`, `h:mm:ss` or `hh:mm:ss`. - - - Wait {0} seconds before using this command again! - - - Object not found. - - - Multiple matches found. -Try using a tag ({0}), a mention ({1}) or an ID ({2}). - - - Roles - - - Boosting since - - - The bot is already connected to a voice channel. - - - Invalid file type. - - - Owner commands - - - You can vote for me [here]({0}). Thank you. - - - Shows an embed with the vote link. - - - Total servers - - - Total users - - - Invite - - - Top.GG Bot Page - - - Vote Link - - - Support Server - - - If you have any bug report, question or suggestion, you can join the [support server]({0}) or contact me via direct message ({1}). - - - If you have any bug report, question or suggestion, you can contact me via direct message ({0}). - - - Redoing last action... - - - You don't have any adventure IDs. -You can use the `new` command to create a new adventure. - - - ..and {0} more tracks! - - - 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. - - - Runs a bash command. - - - Shows the support info. - - - An error occurred while calling the API. - - - Topic - - - Is NSFW - - - Slow mode - - - Channel Info - - - Shows info about a channel. - - - OCR and translation results - - - You need to pass at least 1 choice. - - - Channel not found. - - - The channel to get info from. - - - You need the `Manage Server` permission to use this command. - - - Passes a text through a bad translator. - - - The text to use. - - - Language chain - - - Version not found. The versions are: {0} - - - Other versions: {0} - - - Deleted {0} messages. - - - Deleted {0} messages by {1}. - - - Evaluates a math expression. - - - The expression to evaluate. - - - Invalid expression. - - - Calc results - - - The player was updated to repeat the track {0} times. Use this command without parameters to disable the track looping. - - - Pass the number of times you want to repeat the track, ex: -`{0}loop 5` - - - The current track will now be repeated {0} times. -To disable the loop, use this command without parameters. - - - The looping for the track: {0} has ended. - - - Repeats the current track a number of times. - - - The number of times to repeat the track. - - - The track looping has been disabled. - - - You need to play a track or pass a track name. - - - Lyrics by Genius - - - This is a paginator. React with the respective icons to change page. - - - Artist Page - - - Press the button/emoji below to delete the adventure. - - - You did not react before the timeout! - - - Language Selection - - - Select the language you want to set. - - - Use `-headers` at the end of the command to keep the tags (`[info]`). - - - <> = Required | [] = Optional | Do not type these symbols when writing the command. - - - Inverts (negates) an image. - - - The url of an image to use. - - - Messages must be younger than two weeks. - - - {0} messages could not be deleted because they are older than two weeks. - - - Server not found. - - - An error occurred while parsing the lyrics for {0}. -Maybe it's an instrumental? - - - Parameter `{0}` must be lower than {1}. - - - Invalid ID. - - - Hastebin link - - - Not available. - - - Waiting for the queue to empty... - - - Enter the new text - - - Generating a new response... - - - Input options - - - 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. - - - Members - - - Online - - - Idle - - - DnD - - - Offline - - - Channel count - - - Region - - - Default channel - - - Language not found. - - - Category count - - - Dumping the adventure... - - - Edit canceled. - - - Nothing to delete. - - - Shows the AI Dungeon info. - - - Creates a new adventure. - - - Continues the adventure with the provided text. If no text is passed, the AI will generate the story. - - - The adventure ID. - - - The text to use. - - - Undoes the last action. - - - The adventure ID. - - - Redoes the last undone action. - - - The adventure ID. - - - Adds text to the memory context. - - - The adventure ID. - - - The text to add. - - - Edits the last response. - - - The adventure ID. - - - Retries the last action and generates a new response. - - - The adventure ID. - - - Makes an ID public. - - - The adventure ID. You have to own this ID. - - - Makes an ID private. - - - The adventure ID. You have to own this ID. - - - Gets the ID list of a user. - - - The user to get its IDs. - - - Shows info about a ID. - - - The adventure ID. - - - Removes an ID. - - - The adventure ID. - - - Dumps all the text from an ID. - - - The adventure ID. - - - {0} use(s) every {1} - - - Requirements - - - Shows the command stats. - - - Command stats (since {0}) - - - Let me Google that for you. - - - The keyword(s) to search. - - - Uploads text to Hastebin. - - - The text to upload. - - - Uploading... - - - Shows the last deleted messages in the current channel or the specified. - - - A specific channel to snipe. - - - {0} minutes ago - - - Attachment - - - Shows the last edited messages in the current channel or the specified. - - - A specific channel to snipe. - - - Text Channel - - - Announcement Channel - - - Voice Channel - - - DM Channel - - - Bitrate - - - User Limit - - - No Limit - - - Disables a command locally (for this server). - - - The name of the command to disable. - - - {0} can't be disabled! - - - {0} is already disabled! - - - The command {0} has been disabled in this server. - - - Enables a command locally (for this server). - - - The name of the command to enable. - - - {0} is already enabled! - - - The command {0} has been enabled in this server. - - - Disables a command globally (in all servers). - - - The name of the command to disable globally. - - - The reason to use. - - - {0} is already disabled globally! - - - The command {0} has been disabled in all servers. - - - Enables a command globally (in all servers). - - - The name of the command to enable globally. - - - {0} is already enabled globally! - - - The command {0} has been enabled in all servers. - - - Reason - - - I have left the voice channel due to inactivity. - - - Fergun will be restarted/turned off in some seconds and your music player will be shut down. Sorry for the inconvenience. - - - Warning - - - The public ID of this adventure is null. Please create a new adventure. - - - Gives (transfers) the specified ID to a user. - - - The adventure ID. - - - The user to give the ID. - - - You cannot give the ID yourself! - - - You cannot give the ID to a bot! - - - The ID was successfully transferred to {0}. - - - The value of `{0}` has not been established in the config. If this problem persists, contact the developers. - - - It seems there are no available languages. - - - Request timed out. - - - Error in Discord Servers - - - The command failed due to an issue on Discord's side. -Please try again later. - - - Error Details - - - There's nowhere to vote. - - - Could not connect to the Lavalink server. Please try again later. - - - Cannot get Spotify status because I don't have the Guild Presences intent. - - - No Spotify status found for user {0}. - - - Click here - - - Title - - - Artist(s) - - - Album - - - Duration - - - Lyrics - - - Track Url - - - Gets the Spotify status info of a user. - - - The user to get its Spotify status info. - - - Gets a response from Wolfram|Alpha based on the query. - - - The query to send. - - - Gets definitions of a word. - - - The word to search. - - - Word - - - Definition - - - Synonyms - - - Antonyms - - - Gets a screenshot of a website in the specified year/month/day using a timestamp. - - - The Url of the website to take a screenshot. - - - A timestamp with the specified format. - - - The timestamp must have format `YYYYMMDDhhmmss`, where `YYYY` is required and the other values are optional. - - - Invalid timestamp. - - - No snapshots found for specified url and timestamp. - - - Timestamp - - - Could not find the command method line. - - - Shortens a Url. - - - The Url to shorten. - - - Badges - - - Searches for images with DuckDuckGo. - - - The keyword(s) to search. - - - Displays the privacy policy and the privacy configuration. - - - Privacy Policy - - - What data we collect - - - 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`) - - - When we collect it - - - 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. - - - Privacy configuration - - - Here you'll find some settings that you can change to improve your privacy. - - - Opt out of the temporary collection of deleted/edited messages in the "snipe" commands - - - Privacy - - - Don't want your message to be displayed here? See `privacy`. - - - You can't use this interaction. - - - Deleting the adventure... - - - Donate - - - Language List - - - Searches for images with Brave. - - - The keyword(s) to search. - - - Thread - - - Auto Archive - - - Stage Channel - - - Is Live - - - Archived - - \ 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Список команд - - - Развлекательные команды - - - Fergun {0} - Общее количество командование: {1} - - - Команды модерации - - - Музыкальные команды - - - Ноты - - - Используйте `{0}help [command]`, чтобы получить больше информации о команде. - - - Другие команды - - - Текстовые команды - - - Служебные команды - - - Предстоящие команды - - - Команда не найдена. Используйте `{0}help` для просмотра списка команд. - - - (Нет доступного описания) - - - (Необязательный) - - - Применение - - - Псевдоним(ы) - - - Параметры - - - Показывает случайные символы Юникода. - - - Длина результата. - - - Текст должен содержать 3 или более символов. - - - Полученный текст слишком длинный для отображения ({0}) - - - Нормализует текст. - - - Текст для нормализации. - - - Рандомизирует текст. - - - Текст для рандомизации. - - - Повторяет текст несколько раз. - - - Время повторить. - - - Текст повторить. - - - Переворачивает текст. - - - Текст для реверса. - - - Меняет порядок строк текста. - - - Текст, чтобы перевернуть его строки. - - - Меняет порядок слов в тексте. - - - Текст, чтобы поменять его слова. - - - саркастический текст - - - Текст для конвертации. - - - Преобразует текст в steamwave. - - - Текст для конвертации. - - - Возвращает аватар команды вызывающего абонента или определенного пользователя, если он пройден. - - - Пользователь, чтобы получить свой аватар. - - - Кодирует текст в Base64. - - - Текст для кодирования. - - - Декодирует текст из Base64. - - - Текст для декодирования. - - - Неверный закодированный текст. - - - Выбирает вариант из списка. - - - Разделенный пробелами список вариантов. - - - Я выбираю... - - - ... потому что ты дал мне только один выбор - - - Показывает случайный или определенный цвет. - - - Определенный цвет для использования. Должно быть шестнадцатеричное значение, исходное значение или известное название цвета. - - - Показывает меню справки или информацию о команде, если она прошла. - - - Команда для получения информации. - - - URL изображения для использования. - - - Вложение не является изображением. - - - Не удалось найти ни url-адреса, ни вложения в последних сообщениях ({0}). - - - Ключевое слово (а) для поиска. - - - Не удалось найти никаких результатов. - - - Если поиск - NSFW, используйте команду в канале NSFW. - - - Поиск изображения - - - Достигнут предел поиска изображений :( - - - Выполняет распознавание текста для изображения. - - - OCR не дал результатов. - - - Выполняет OCR для изображения. - - - Получает задержку в отправке сообщения и латентность базы данных. - - - Определяет изображение с помощью Microsoft CaptionBot. - - - Не указав URL заставит команду для поиска последних х сообщений в кэше ссылки или вложения. - - - URL не является допустимым изображением. - - - Ищет изображения с помощью Google Images. - - - Городской словарь - Страница {0} из {1} - - - URL изображения для использования. - - - Файл слишком большой. - - - URL изображения для использования. - - - Код состояния: - - - Изменяет размер изображения с waifu2x. - - - URL изображения для использования. - - - Произошла ошибка. - - - Изменить размер результатов - - - Делает скриншот на сайт. - - - На сайте сделать скриншот. - - - Возвращает информацию о текущем сервере. - - - Информация о сервере - - - имя - - - владелец - - - Количество ролей - - - Количество пользователей - - - Уровень проверки - - - (Никто) - - - Создано на - - - Показывает последнее удаленное сообщение в текущем канале. - - - Нечего стрелять из {0} - - - Переводит текст. - - - Целевой язык в коде ISO (`en`, `es`, `br` и т. Д.) - - - Текст для перевода. - - - Недействительный целевой язык. Используйте `{0}translate language codes` для просмотра списка языков. - - - Я получил IP, забаненный Google :( LOL - - - Текст в речь. - - - Целевой язык в коде ISO (`en`, `es`, `br` и т. Д.), Отступает на английский, если цель неверна. - - - Текст для конвертации. - - - Городской словарь поиска. - - - Ключевое слово (а) для поиска. - - - Нет результатов. - - - По - - - пример - - - (Пример не предоставлен) - - - Возвращает информацию о текущем или конкретном пользователе, если он был пройден. - - - Пользователь, чтобы получить информацию от. - - - Ищет статью в Википедии. - - - Ключевое слово (а) для поиска. - - - Поиск в Википедии - - - Возвращает случайный комикс xkcd или определенный комикс, если передано число. - - - Комический номер. - - - Число должно быть от 1 до {0}. - - - Ищет видео на YouTube. - - - Ключевое слово (а) для поиска. - - - Не удалось найти никаких результатов. - - - Возвращает «случайное» видео с YouTube. - - - Из кэшированных видео -Создание кеша ... - - - Запрет пользователя. - - - Пользователь забанить. - - - Причина запрета. - - - Я не буду банить себя лол - - - Пользователь уже забанен. - - - Пользователь {0} был запрещен. - - - Удаляет последние x сообщений в текущем канале. - - - Количество сообщений для очистки. - - - Информация о пользователе - - - Число должно быть между {0} и {1}. - - - Не удалось найти ни одного сообщения {0} в последних {1} сообщениях. - - - Пинает пользователя. - - - Пользователь пнуть. - - - Изменяет ник пользователя. - - - Новый ник. Оставьте это поле пустым, чтобы удалить псевдоним. - - - Идентификатор пользователя для разблокировки. - - - Мелочи время! - - - Произошла ошибка при вызове API. - - - Информация о пользователе - - - Деятельность - - - Бот - - - Дата вступления в гильдию - - - Пользователь не найден. -Попробуйте использовать тег ({0}), упоминание ({1}) или идентификатор ({2}). - - - Какая? Вы хотите забанить себя? - - - по - - - Взломать пользователя. - - - Идентификатор пользователя для хакбана. - - - Причина взлома. - - - Пользователь {0} был взломан. - - - Причина удара. - - - Пользователь {0} был выгнан. - - - Пользователь меняет свой ник. - - - Unbans пользователя. - - - Пользователь {0} был заблокирован. - - - Категория для выбора. Укажите «категории», чтобы увидеть список категорий, или «список лидеров» / «ранги», чтобы увидеть список лидеров. - - - Список категорий - - - Таблица лидеров викторины - - - Похоже, что вы ответили на все вопросы в указанной категории, выберите другую категорию. - - - Произошла ошибка. Код ошибки - - - категория - - - Тип - - - трудность - - - Вопрос - - - Опции - - - У вас есть {0} секунд, чтобы ответить. - - - Неверный вариант! - - - Вы потеряли 1 очко. - - - Правильный ответ! - - - Вы выиграли 1 очко. - - - Некорректное! - - - Ответ - - - Время вышло! - - - Точки - - - Присоединяется к голосовому каналу. - - - Вам необходимо подключиться к голосовому каналу. - - - Теперь подключен к {0}. - - - Оставляет голосовой канал. - - - Присоединяйтесь к каналу, в котором находится бот, чтобы он ушел. - - - Bot теперь осталось {0}. - - - Перемещает бота на голосовой канал, которым является текущий пользователь. - - - Присоединяйтесь к голосовому каналу, где вы хотите, чтобы бот был. - - - Поиск и воспроизведение трека с YouTube. - - - Ключевое слово (а) для поиска. - - - Отправить номер из списка - - - Поиск отменен. - - - Вариант вне индекса. - - - Вы не ответили до истечения времени ожидания! - - - Воспроизводит текущий воспроизводимый трек, если таковой имеется. - - - Пауза игрока. - - - Возобновляет воспроизведение. - - - Останавливает плеер. - - - Пропускает текущий трек, если есть. - - - Устанавливает громкость проигрывателя. - - - Объем для установки (2 - 150) - - - Показывает очередь. - - - Перетасовывает очередь. - - - Удаляет трек в очереди по указанному индексу. - - - Индекс дорожки для удаления. - - - Показывает текст указанной песни или текущий трек в плеере, если ничего не пропущено. - - - Песня для поиска ее текст. - - - Показывает обложку текущего трека в плеере. - - - Больше нет треков для воспроизведения. - - - Сейчас играет - - - Произошла ошибка - - - Игрок застрял с дорожкой ** {0} ** на {1} секунд. - - - Перемещено с ** {0} ** на ** {1} ** - - - Совпадений не найдено. Попробуйте использовать ссылку Youtube или ссылку mp3 файл. - - - Плейлист ** {0} ** ({1} дорожек) ({2}) добавлен в очередь. - - - В очереди {0} треков ({1}) - -Сейчас играет: {2} - - - {0} был добавлен в очередь. - - - Сейчас играет: {0} - - - Неверный трек. - - - Воспроизведение {0} - - - Очередь пуста. - - - Игрок не играет. - - - Игрок теперь остановлен. - - - Пропущено: {0} - -Сейчас играет: {1} - - - Используйте число от 2 до 150. - - - Громкость установлена ​​на: {0}. - - - Игрок теперь приостановлен. - - - Воспроизведение возобновлено. - - - Игрок не остановлен. - - - На данный момент играют: {0} ({1} {2}) - - - Треки в очереди: - - - В очереди только 1 предмет. - - - Очередь была перетасована. - - - Индекс вне диапазона. - - - Трек {0} в позиции {1} был удален. - - - Нет текст не найдено для {0}. - - - Устанавливает игровой статус бота. - - - Текст для установки. - - - Только владелец бота. - - - Устанавливает статус бота. - - - Статус для установки (0 - 5). - - - Показывает исходный код команды. - - - Команда для получения своего кода. - - - Устанавливает цвет встраивания. - - - Новый цвет в шестнадцатеричном или десятичном виде. - - - Неправильный цвет. - - - Братан ты только что выложил передергиваться! - - - Устанавливает глобальный префикс бота. - - - Новый глобальный префикс. - - - Текущий глобальный префикс: - - - Префикс цели и текущий префикс совпадают. - - - Новый глобальный префикс: "{0}" - - - Отправляет ссылку приглашения бота. - - - Устанавливает префикс бота. - - - Новый префикс. - - - Текущий префикс: - - - Новый префикс гильдии: "{0}" - - - Перезапускает бот. - - - Что-то говорит - - - Текст сказать. - - - Показывает время работы бота. - - - Устанавливает язык бота. - - - Язык бот теперь: Русский -⚠ Google Translate был использован для перевода бота России, так что может быть какая-то ошибка с текстом. - - - Страница {0} из {1} - - - Не удалось выполнить - - - Получает текущий трек в плеере. - - - Показывает журнал изменений бота. - - - Временно отключен - - - Получить вдохновляющие цитаты. - - - Мне нужно`{0}` разрешение на выполнение этой команды. - - - Управление сообщениями - - - Подключить - - - Разговаривать - - - Оценивает код . - - - Код для оценки. - - - На скриншоте может быть содержимое NSFW, и оно не будет отображаться. Попробуйте использовать команду на канале NSFW или использовать другой URL. - - - Бот не подключен к голосовому каналу. - - - Создание отменено. - - - Добро пожаловать в AI Dungeon - - - Обязательно прочитайте информацию и команды с `{0}aid info`, прежде чем продолжить. - -Выберите режим: - - - Выберите символ - - - Прикреплять файлы - - - Результаты Eval - - - Вывод - - - Выполнено за {0} мс - - - Код выполняется без какого-либо возвращаемого значения. - - - Эта команда может использоваться только на канале NSFW. - - - Подождите несколько секунд, прежде чем использовать эту команду! - - - Конкретный канал, чтобы стрелять. - - - Идентификатор не найден. - - - Этот идентификатор истории не является общедоступным, и вы не можете его использовать. Попросите владельца идентификатора ({0}) сделать его общедоступным с помощью `makepublic <ID>` - - - Вы должны дождаться генерации истории, прежде чем использовать этот идентификатор! - - - Генерация нового приключения -в режиме: ** {0} ** -и персонаж: ** {1} ** ... - - - Создание собственного персонажа - - - Введите текст, который описывает, кто вы, и первые пару предложений о том, с чего вы начали. -Это приглашение истечет через 5 минут. - - - Создание нового приключения с пользовательской строкой... - - - Генерация истории ... - - - Редактирование контекста истории ... - - - ИИ теперь запомнит: - - - Изменение последнего вывода ... - - - Изменен последний вывод на: - - - Вы не являетесь владельцем этого идентификатора. - - - Идентификатор уже открыт. - - - Идентификатор теперь общедоступен, и каждый может его использовать. Вы можете установить его обратно в приватное состояние с помощью `makeprivate <ID>` - - - Идентификатор уже закрыт. - - - Идентификатор теперь является частным, и только вы можете использовать его. Вы можете установить его обратно в public с помощью `makepublic <ID>` - - - {0} не имеет идентификаторов приключений. - - - Список идентификаторов для {0} - - - Является публичным - - - Используйте 'idinfo <ID>', чтобы получить информацию об идентификаторе приключения. - - - Информация об идентификаторе приключения - - - AI Dungeon - первое в своем роде текстовое приключение, созданное AI. Используя модель машинного обучения с параметром 1,5B под названием GPT-2, AI Dungeon генерирует историю и результаты ваших действий во время игры в этом виртуальном мире. В отличие от практически всех существующих игр, вы не ограничены воображением разработчика в том, что вы можете сделать. Любая вещь, которую вы можете выразить на языке, может быть вашим действием, и мастер искусственного интеллекта будет решать, как мир реагирует на ваши действия. - - - Правила AI Dungeon просты: -1. Игра ставит «Вы» впереди каждого действия, поэтому каждое действие должно начинаться с глагола. Ex. «напасть на дракона», «вызвать сэндвич» и т. д. -2. В качестве исключения из вышеперечисленного: если вы поместите свое действие в кавычки. «Вы присоединитесь ко мне в моем квесте?», Тогда игра добавит «Вы говорите» в начале. - - - Об AI Dungeon - - - Справка AI Dungeon - - - Как играть - - - команды - - - Попробуйте использовать новые слова часто, ИИ становится более креативным с разнообразием. -Не забудьте начать ввод "do" с глагола, например: Атака орка. -Используйте кнопку отмены, чтобы удалить ваш последний ввод вместе с ответом ИИ. -Длинные предложения для действий не проблема! Проявите творческий подход! -Хотите больше истории для создания? Используйте команду continue без текста. -Попробуйте использовать новые слова часто, ИИ становится более креативным с разнообразием. -Используйте команду запомнить, чтобы редактировать контекст истории, который ИИ всегда запоминает. -Вы можете использовать Say перед текстом, чтобы поговорить. - - - В - - - Показывает встраивание с параметрами конфигурации бота на уровне гильдии. - - - Вы должны быть администратором, чтобы использовать эту команду. - - - загрузка... - - - Конфигурация Fergun - - - Конфиг отменен. - - - Я не могу этого сделать. - - - Софтбаны пользователя (кик + удаление сообщений пользователя). - - - Пользователь на софтбан. - - - Количество дней, чтобы удалить его последние сообщения. 7 по умолчанию. - - - Причина софтбана. - - - Какая? Хочешь софтбан сам? - - - Я не буду софтбан сам LOL - - - Пользователь {0} был заблокирован. - - - AI Dungeon (используйте {0}aid <command>) - - - Возврат последнего действия ... - - - Макс. 140 символов - - - Пригласить ссылку - - - Некоторые команды работают лучше с разрешением «Управление сообщениями» («img», «urban», «config») - - - Неверный текст. - - - Если пользователь пропущен, команда попытается удалить все сообщения пользователя в последних сообщениях `count`. -Максимальное количество дней, в течение которых бот может искать сообщения, - 14. - - - Забанить участников - - - Участники Kick - - - Пользователь не забанен. - - - Мой префикс здесь `{0}`. Используйте `{0}help`, чтобы увидеть мой список команд, и` {0}prefix`, чтобы изменить префикс. - - - Я не думаю, что вы можете использовать это здесь. - - - Этот кот не существует - - - Этот человек не существует - - - Достигнут лимит поиска в YouTube :( - - - Кто-то уже создает видео кеш, пожалуйста, подождите .. - - - пользователь - - - Показывает последнее отредактированное сообщение в текущем канале. - - - Добавляет реакцию на сообщение. - - - Реакция на добавление (только одна) - - - Идентификатор сообщения для добавления реакции (должно быть с того же канала, на котором выполняется команда). - - - Неверный идентификатор сообщения. Убедитесь, что сообщение пришло с этого канала. - - - Недопустимая эмоция / эмодзи. - - - В команде отсутствуют обязательные аргументы. Используйте `{0} help {1}`, чтобы получить больше информации о команде. - - - Ошибка разбора аргументов команды. Аргумент неверен или порядок аргументов неверен. -Используйте `{0} help {1}`, чтобы получить больше информации о команде. - - - Результаты перевода - - - Исходный язык (обнаружено) - - - Язык перевода - - - Результат - - - Тип ошибки - - - Сообщение об ошибке - - - Поиск текущей дорожки в указанной позиции. - - - Девятая секунда для поиска или правильное время в формате `m: ss`,` mm: ss`, `h: mm: ss` или` hh: mm: ss`. - - - Не могу найти этот трек. - - - Второй ход ({0}) не может быть больше или равен длине трека ({1}) - - - Пропущен до второго {0} ({1} из {2}) - - - Произошла ошибка в API перевода. - - - Показывает статистику ботов. - - - Приключение было удалено. - - - Использование процессора - - - Использование ОЗУ - - - Библиотека - - - Бот версия - - - Операционная система - - - Владелец бота - - - Текущий ник и новый ник совпадают. - - - да - - - нет - - - Правда - - - Ложь - - - Автоматический перевод ввода и вывода AI Dungeon -Выбор трека для `play` - - - Используйте реакции, чтобы включить или выключить опцию. - - - Конфигурация Fergun - - - вариант - - - Ценность - - - У вас нет прав для запуска этой команды. - - - Пользователь {0} был в черном списке. - - - Пользователь {0} занесен в черный список по причине: {1} - - - Пользователь {0} был удален из черного списка. - - - Добавляет пользователя в черный список или удаляет его. - - - Идентификатор пользователя в черный список. - - - Причина попадания в черный список. - - - Вы в черном списке. - - - Вы в черном списке с причиной: {0} - - - Вам нужно разрешение `Ban Members`, чтобы использовать эту команду. - - - Мне нужно разрешение `Ban Members` для выполнения этой команды. - - - Для использования этой команды вам необходимо разрешение «Управление сообщениями». - - - Мне нужно разрешение «Управление сообщениями» для выполнения этой команды. - - - Для использования этой команды вам необходимо разрешение `Kick Members`. - - - Мне нужно разрешение `Kick Members` для выполнения этой команды. - - - Чтобы использовать эту команду, вам необходимо разрешение «Управление никами». - - - Мне нужно разрешение «Управление никами» для выполнения этой команды. - - - Указанный пользователь должен быть ниже по иерархии. - - - Мне нужно разрешение `Connect ', чтобы присоединиться к голосовому каналу. - - - Мне нужно разрешение «Говорить», чтобы продолжить. - - - Перейдите на голосовой канал, на который вы хотите, чтобы я пошел. - - - Время обработки: {0} мс - - - Результаты OCR - - - Мне нужно разрешение `Attach Files` для выполнения этой команды. - - - Ошибка в OCR API. - - - Force устанавливает префикс в этой гильдии. - - - Используйте {0} помощи, продолжайте <ID> [текст], чтобы продолжить ваше приключение. - - - Префикс слишком большой. Макс. длина префикса: {0}. - - - Неправильный адрес. - - - OCR и перевести. - - - Целевой язык в коде ISO (en, es, br и т. Д.) - - - URL изображения для использования. - - - вход - - - Роль не найдена. - - - Информация о роли - - - цвет - - - Упоминается - - - права доступа - - - Количество участников - - - Упоминание - - - Получает информацию о роли. - - - Роль для получения информации (упоминать ее не обязательно). - - - Отключает бот. - - - Лучшая команда. - - - Возвращает случайного пользователя. - - - Активные клиенты - - - Низкий - - - средний - - - Высокая - - - крайность - - - Лапы - - - Должность - - - особенности - - - Уровень Nitro Boost - - - Счетчик нитроускорения - - - Время перехода ({0}) не может быть больше или равно длине трека ({1}) - - - Пропущено до {0} из {1} - - - Вы должны передать целое число или строку в формате `m: ss`,` mm: ss`, `h: mm: ss` или` hh: mm: ss`. - - - Подождите {0} секунд, прежде чем снова использовать эту команду! - - - Объект не найден. - - - Найдено несколько совпадений. -Попробуйте использовать тег ({0}), упоминание ({1}) или идентификатор ({2}). - - - Роли - - - Повышение с - - - Бот уже подключен к голосовому каналу. - - - Неверный тип файла. - - - Владелец команды - - - Вы можете проголосовать за меня [здесь] ({0}). Спасибо. - - - Показывает вставку со ссылкой для голосования. - - - Всего серверов - - - Всего пользователей - - - Если у вас есть отчет об ошибке, вопрос или предложение, вы можете присоединиться к [серверу поддержки] ({0}) или связаться со мной через прямое сообщение ({1}). - - - Повтор последнего действия ... - - - У вас нет идентификаторов приключений. -Вы можете использовать команду `new`, чтобы создать новое приключение. - - - ..и еще {0} треков! - - - Если это продолжится, сообщите об этой ошибке на [сервере поддержки]({0}) или откройте проблему в [репозитории GitHub]({1}), описав ошибку и шаги по ее воспроизведению. - - - Запускает команду bash. - - - Показывает информацию поддержки. - - - Тема - - - Является NSFW - - - Медленный режим - - - Информация о канале - - - Показывает информацию о канале. - - - OCR и результаты перевода - - - Вам необходимо пройти хотя бы 1 выбор. - - - Канал не найден. - - - Канал для получения информации. - - - Вам нужно разрешение `Manage Server`, чтобы использовать эту команду. - - - Пропускает текст через плохого переводчика. - - - Текст для использования. - - - Языковая цепочка - - - Версия не найдена. Версии: {0} - - - Другие версии: {0} - - - Удалено {0} сообщений. - - - Удалено {0} сообщений от {1}. - - - Оценивает математическое выражение. - - - Выражение для оценки. - - - Неверное выражение. - - - Результаты вычислений - - - Проигрыватель обновился, чтобы повторить трек {0} раз. Используйте эту команду без параметров, чтобы отключить зацикливание дорожки. - - - Передайте количество раз, которое вы хотите повторить трек, например: -`{0}loop 5` - - - Текущий трек теперь будет повторяться {0} раз. -Чтобы отключить цикл, используйте эту команду без параметров. - - - Цикл для дорожки: {0} завершен. - - - Повторяет текущую дорожку несколько раз. - - - Количество раз, чтобы повторить трек. - - - Зацикливание трека отключено. - - - Вам необходимо воспроизвести трек или передать название трека. - - - Текст песни Genius - - - Это пагинатор. Реагируйте с соответствующими значками, чтобы изменить страницу. - - - Страница исполнителя - - - Нажмите кнопку / смайлик ниже, чтобы удалить приключение. - - - Вы не реагировали до истечения времени ожидания! - - - Выбор языка - - - Выберите язык, который хотите установить. - - - Используйте `-headers` в конце команды, чтобы сохранить теги (` [info] `). - - - <> = необходимые | [] = По желанию | Не вводите эти символы при написании команды. - - - Инвертирует (отрицает) изображение. - - - URL изображения для использования. - - - Сообщения должны быть моложе двух недель. - - - {0} Сообщения не могут быть удалены, потому что они старше, чем через две недели. - - - Сервер не найден. - - - при разборе текст для {0} произошла ошибка. -Может быть, это инструментальное? - - - Параметр {0} должен быть ниже, чем {1}. - - - Неверный ID. - - - Hastebin ссылка - - - Недоступно. - - - Ожидание, пока очередь опустеет ... - - - Введите новый текст - - - Генерация нового ответа ... - - - Варианты ввода - - - Do: действие, которое вы хотите предпринять в истории. Всегда начинай с действия, напр. «Поиск спрятанных сокровищ». Это опция по умолчанию, если никто не обнаружен. - -Скажите: Вы можете использовать это, чтобы говорить, напр. "Оставь их в покое!" - -История: Этот вход не дает ИИ поставить «Вы» перед вашим входом. - - - Количество каналов - - - Область - - - Канал по умолчанию - - - Язык не найден. - - - Количество категорий - - - Демпинг приключения... - - - Редактировать отменен. - - - Нечего удалять. - - - члены - - - В сети - - - вхолостую - - - DnD - - - Не в сети - - - Показывает информацию AI Dungeon. - - - Создает новое приключение. - - - Продолжает приключение с предоставленному текста. Если текст не будет принят, ИИ будет генерировать историю. - - - Приключение ID. - - - Текст для использования. - - - Отменяет последнее действие. - - - Приключение ID. - - - Повторяет последнее действие отмененного. - - - Приключение ID. - - - Добавляет текст в контекст памяти. - - - Приключение ID. - - - Текст для добавления. - - - Редактирует последний ответ. - - - Приключение ID. - - - Повторит последнее действие и порождает новый ответ. - - - Приключение ID. - - - Делает ID общественности. - - - Приключение IF. Вы должны иметь этот идентификатор. - - - Делает ID приватным. - - - Приключение IF. Вы должны иметь этот идентификатор. - - - Получает список идентификаторов, принадлежащий пользователя. - - - Пользователю получить свои идентификаторы. - - - Показывает информацию о идентификатора. - - - Приключение ID. - - - Удаляет идентификатор. - - - Приключение ID. - - - Сплин весь текст из идентификатора. - - - Приключение ID. - - - {0} использует каждый {1} - - - Требования - - - Показывает статистику команд. - - - Команда Статистика (с {0}) - - - Позвольте мне Google, что для Вас. - - - Ключевое слово (а) для поиска. - - - Закачивает текст Hastebin. - - - Текст для загрузки. - - - Загрузка... - - - Показывает последние удаленные сообщения в текущем канале или указанный. - - - Конкретный канал бекас. - - - {0} минут назад - - - прикрепление - - - Показывает последние отредактированные сообщения в текущем канале или указанный. - - - Конкретный канал бекас. - - - Текст канал - - - Объявление канала - - - речевой канал - - - Канал DM - - - Битрейт - - - Лимит пользователя - - - Нет ограничений - - - Отключает команду локально (для этого сервера). - - - Имя команды, чтобы отключить. - - - {0} не может быть отключена! - - - {0} уже отключена! - - - Команда {0} была отключена в этом сервере. - - - Включает команду локально (для этого сервера). - - - Имя команды, чтобы включить. - - - {0} уже включен! - - - Команда {0} была включена в этом сервере.$$ - - - Отключение команды глобально (на всех серверах).$$ - - - Имя команды, чтобы отключить глобально. - - - Причина использования. - - - {0} уже отключена во всем мире! - - - Команда {0} была отключена на всех серверах. - - - Включает команду глобально (на всех серверах). - - - Имя команды, чтобы включить в глобальном масштабе. - - - {0} уже включена в глобальном масштабе! - - - Команда {0} была включена во всех серверах. - - - причина - - - Я оставил голосовой канал из-за неактивности. - - - Fergun будет перезапущена / выключена через несколько секунд, и ваш музыкальный проигрыватель будет закрыт. Приносим извинения за неудобства. - - - Предупреждение - - - Общественность ID этой авантюры является недействительным. Пожалуйста, создайте новое приключение. - - - Выдает (передает) указанный ID пользователю. - - - Идентификатор приключения. - - - Пользователь, которому необходимо присвоить идентификатор. - - - Вы не можете сами дать ID! - - - Идентификатор был успешно перенесен на {0}. - - - приглашение - - - Top.GG Страница бота - - - Ссылка для голосования - - - Сервер поддержки - - - Если у вас есть отчет об ошибке, вопрос или предложение, вы можете связаться со мной через прямое сообщение ({0}). - - - Значение `{0}` не установлено в конфигурации. Если проблема не исчезнет, обратитесь к разработчикам. - - - Вы не можете дать ID боту! - - - Вроде нет доступных языков. - - - Истекло время запроса. - - - Ошибка на серверах Discord - - - Команда не удалась из-за проблемы на стороне Discord. -Пожалуйста, повторите попытку позже. - - - Детали ошибки - - - Голосовать негде. - - - Не удалось подключиться к серверу Lavalink. Пожалуйста, повторите попытку позже. - - - Сервер {0} занесен в черный список. - - - Сервер {0} был занесен в черный список по причине: {1} - - - Добавляет сервер в черный список или удаляет его. - - - ID сервера в черный список. - - - Причина попадания в черный список. - - - Сервер {0} был удален из черного списка. - - - Не могу получить статус Spotify, потому что у меня нет: Guild Presences Intent. - - - Для пользователя {0} не найден статус Spotify. - - - кликните сюда - - - заглавие - - - Художники - - - Альбом - - - Продолжительность - - - Текст песни - - - URL отслеживания - - - Получает информацию о статусе Spotify пользователя. - - - Пользователь, который получает информацию о статусе Spotify. - - - Получает ответ от Wolfram | Alpha на основе запроса, - - - Запрос для отправки. - - - Получает определения слова. - - - Слово для поиска. - - - слово - - - Определение - - - Синонимы - - - Антонимы - - - URL-адрес веб-сайта, на котором нужно сделать снимок экрана. - - - Отметка времени в указанном формате. - - - Отметка времени должна иметь формат `YYYYMMDDhhmmss`, где `YYYY` является обязательным, а другие значения необязательны. - - - Неверная метка времени. - - - Для указанного URL и отметки времени не найдено снимков. - - - Получает снимок экрана веб-сайта в указанный год / месяц / день с использованием метки времени. - - - Отметка времени - - - Не удалось найти строку командного метода. - - - Укорачивает URL. - - - URL-адрес, который нужно сократить. - - - Значки - - - Ищет изображения с DuckDuckGo. - - - Ключевое слово (а) для поиска. - - - Отображает политику конфиденциальности и конфигурацию конфиденциальности. - - - Политика конфиденциальности - - - Какие данные мы собираем - - - а. Конфигурация сервера (ID сервера, настраиваемый префикс, язык бота, отключенные команды и т. Д.) -б. Конфигурация пользователя / статистика (идентификатор пользователя, игровые очки викторины, конфигурация конфиденциальности и статус черного списка) -c. AI Dungeon adventures (идентификатор приключения, идентификатор владельца и доступность использования) -d. Временный сбор удаленных / отредактированных сообщений, используемый в командах «snipe» (`snipe`,` editsnipe`, `bigsnipe` и `bigeditsnipe`) - - - Когда мы его соберем - - - а. Когда вы устанавливаете собственный префикс, язык или другую конфигурацию. Эти данные удаляются, когда бот покидает этот сервер. -б. Когда вы играете в викторину, вы используете конфигурацию конфиденциальности или попадаете в черный список. -c. Когда вы создаете приключение AI Dungeon с ботом. Эти данные удаляются, когда вы используете команду `aid delete`. -d. Когда сообщение удалено / отредактировано. Эти сообщения можно увидеть только с помощью команд "snipe" в канале, где сообщение было удалено / отредактировано. -Эти сообщения хранятся только 6 часов, и вы можете отказаться от них, используя приведенные ниже реакции. - - - Конфигурация конфиденциальности - - - Здесь вы найдете некоторые настройки, которые вы можете изменить, чтобы улучшить свою конфиденциальность. - - - Отказаться от временного сбора удаленных / отредактированных сообщений в командах "snipe" - - - Конфиденциальность - - - Не хотите, чтобы ваше сообщение отображалось здесь? См. `privacy`. - - - Вы не можете использовать это взаимодействие. - - - Удаление приключения... - - - Пожертвовать - - - Список языков - - - Ищет изображения с Brave. - - - - Ключевые слова для поиска. - - - Нить - - - Автоархив - - - Сценический канал - - - Сценический канал - - - Архивировано - - \ 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Komut listesi - - - Eğlence komutları - - - Fergun {0} - Toplam komut sayısı: {1} - - - Denetleme komutları - - - Müzik komutları - - - Notlar - - - Bir komut hakkında daha fazla bilgi almak için `{0}help [komut] 'kullanın. - - - Diğer komutlar - - - Metin komutları - - - Yardımcı program komutları - - - Yaklaşan komutlar - - - Komut bulunamadı. Kullanım `{0}help` komut listesini görmek için. - - - (Açıklama yok) - - - (İsteğe bağlı) - - - Kullanımı - - - Takma ad(lar) - - - Parametreler - - - Rasgele unicode karakterleri gösterir. - - - Sonuç uzunluğu. - - - Metin 3 veya daha fazla karakter içermelidir. - - - Ortaya çıkan metin görüntülenemeyecek kadar uzun ({0}) - - - Bir metni normalleştirir. - - - Normalleştirilecek metin. - - - Metni rastgele seçer. - - - Rastgele yazılacak metin. - - - Bir metni birkaç kez tekrarlar. - - - Tekrarlama zamanı. - - - Tekrarlanacak metin. - - - Metni ters çevirir. - - - Tersine çevrilecek metin. - - - Metnin satır sırasını tersine çevirir. - - - Çizgilerini ters çevirecek metin. - - - Bir metnin sözcük sırasını tersine çevirir. - - - Kelimelerini tersine çevirmek için metin. - - - SARCASTİK TEXT. - - - Dönüştürülecek metin. - - - Bir metni vaporwave'e dönüştürür. - - - Dönüştürülecek metin. - - - Geçerli kullanıcının veya belirli bir kullanıcının avatarını iletilirse döndürür. - - - Avatarını almak için kullanıcı. - - - Metni Base64'e kodlar. - - - Kodlanacak metin. - - - Base64'ten bir metni çözer. - - - Kod çözülecek metin. - - - Geçersiz kodlanmış metin. - - - Listeden bir seçenek seçer. - - - Boşluktan ayrılmış seçenekler listesi. - - - Seçerim... - - - ... çünkü bana sadece bir seçim yaptın - - - Rastgele veya belirli bir renk gösterir. - - - Kullanılacak belirli renk. Onaltılık değer, ham değer veya bilinen bir renk adı olmalıdır. - - - Aktarılırsa yardım menüsünü veya komutun bilgilerini gösterir. - - - Bilgi alma komutu. - - - Microsoft CaptionBot ile bir görüntü tanımlar. - - - Kullanılacak bir resmin URL'si. - - - komutu bir bağlantı veya bir eki için önbellekte Son x ileti aramak için yapacak bir url belirterek Değil. - - - Ek bir resim değil. - - - Son {0} iletide herhangi bir url veya ek bulunamadı. - - - URL geçerli bir resim değil. - - - Google Görseller ile görselleri arar. - - - Aranacak anahtar kelimeler. - - - Herhangi bir sonuç bulunamadı. - - - Arama NSFW ise, komutu bir NSFW kanalında kullanın. - - - Görsel arama - - - Kentsel Sözlük - Sayfa {0} / {1} - - - Görsel arama sınırına ulaşıldı :( - - - Bir görüntüye OCR uygular. - - - Kullanılacak bir resmin URL'si. - - - Dosya çok büyük. - - - OCR sonuç vermedi. - - - Tesseract ile bir görüntüye OCR işlemi yapar. - - - Kullanılacak bir resmin URL'si. - - - Durum kodu: - - - Bir mesaj ve Veritabanı gecikme gönderme gecikmeyi alır. - - - Bir görüntüyü waifu2x ile yeniden boyutlandırır. - - - Kullanılacak bir resmin URL'si. - - - Bir hata oluştu. - - - Sonuçları yeniden boyutlandır - - - Bir web sitesine ekran görüntüsü alır. - - - Ekran görüntüsü alacak web sitesi. - - - Geçerli sunucu hakkında bilgi döndürür. - - - Sunucu bilgisi - - - ad - - - Sahip - - - Rol sayısı - - - Kullanıcı sayısı - - - Doğrulama seviyesi - - - (Yok) - - - Oluşturma tarihi - - - Geçerli kanaldaki son silinen mesajı gösterir. - - - {0} içinde kaçacak bir şey yok - - - Bir metni çevirir. - - - ISO kodundaki (`en`, `es`, `br`, vb.) - - - Çevrilecek metin. - - - Geçersiz hedef dil. Dil listesini görmek için `{0}translate language codes` kullanın. - - - Google tarafından IP yasaklandı :( lol - - - Konuşma metni. - - - ISO kodundaki (`en`, `es`, `br`, vb.) Hedef dil, hedef geçersizse İngilizce'ye döner. - - - Dönüştürülecek metin. - - - Kentsel Sözlük arama. - - - Aranacak anahtar kelimeler. - - - Sonuç yok. - - - Tarafından - - - Misal - - - (Örnek verilmemiştir) - - - Geçerli kullanıcı veya geçildiyse belirli bir kullanıcı hakkında bilgi döndürür. - - - Bilgi almak için kullanıcı. - - - Kullanıcı bilgisi - - - Aktivite - - - Bot mu - - - Lonca katılma tarihi - - - Kullanıcı bulunamadı. -Bir etiket ({0}), bir söz ({1}) veya bir kimlik ({2}) kullanmayı deneyin. - - - Wikipedia'da bir makale arar. - - - Aranacak anahtar kelimeler. - - - Wikipedia Arama - - - Bir sayı iletilirse rastgele bir xkcd komik veya belirli bir komik döndürür. - - - Komik numara. - - - Sayı 1 ile {0} arasında olmalıdır. - - - Önbelleğe alınmış videoların dışında -Önbellek oluşturuluyor ... - - - Bir kullanıcıyı yasaklar. - - - Yasaklanacak kullanıcı. - - - Yasağın nedeni. - - - Ne? Kendinizi yasaklamak mı istiyorsunuz? - - - Kendimi yasaklamayacağım lol - - - Kullanıcı {0} yasaklandı. - - - Geçerli kanaldaki son x iletiyi siler. - - - Silinecek mesaj sayısı. - - - Bir kullanıcı mesajlarını silebilir. - - - Bir kullanıcıyı hack eder. - - - Hackban için kullanıcının kimliği. - - - Hackban'ın nedeni. - - - {0} kullanıcısı hackbanned. - - - Bir kullanıcıyı vurur. - - - Kullanılacak kullanıcı. - - - Vuruş sebebi. - - - Bir kullanıcının engellemesini kaldırır. - - - Yasağının kaldırılacağı kullanıcı kimliği. - - - {0} kullanıcısının yasağı kaldırıldı. - - - YouTube'da bir video arar. - - - Aranacak anahtar kelimeler. - - - Herhangi bir sonuç bulunamadı. - - - "Rastgele" bir YouTube videosu döndürür. - - - Kullanıcı zaten yasaklanmış. - - - Sayı {0} ve {1} arasında olmalıdır. - - - Son {1} mesajda {0} tarafından herhangi bir mesaj bulunamadı. - - - tarafından - - - {0} kullanıcısı tekmelendi. - - - Kullanıcının takma adını değiştirir. - - - Kullanıcı takma adını değiştirir. - - - Yeni takma ad. Takma adı kaldırmak için bunu boş bırakın. - - - Diğer bilgiler zamanı! - - - Seçilecek kategori. Kategori listesini görmek için "kategoriler" veya skor tablosunu görmek için "büyük şerit" / "sıralar" belirtin. - - - Kategori Listesi - - - Diğer bilgiler afiş - - - Belirtilen kategorideki tüm soruları yanıtladığınız anlaşılıyor, başka bir kategori seçin. - - - Bir hata oluştu. Hata kodu - - - Kategori - - - tip - - - zorluk - - - Soru - - - Seçenekler - - - Cevaplamak için {0} saniyeniz var. - - - Geçersiz seçenek! - - - 1 puan kaybettin. - - - Doğru cevap! - - - 1 puan kazandınız. - - - Yanlış! - - - Cevap - - - Süre doldu! - - - makas - - - Bir ses kanalına katılır. - - - Bir ses kanalına bağlanmanız gerekir. - - - Şimdi bağlı {0}. - - - Bir ses kanalı bırakır. - - - Botun ayrılmak için bulunduğu kanala katılın. - - - Bot şimdi {0} bıraktı. - - - Botu geçerli kullanıcının bulunduğu ses kanalına taşır. - - - Botun olmasını istediğiniz bir ses kanalına katılın. - - - YouTube'dan bir parça arar ve oynatır. - - - Aranacak anahtar kelimeler. - - - Listeden bir numara gönderme - - - Arama iptal edildi. - - - Seçenek dizin dışında. - - - Zaman aşımından önce cevap vermediniz! - - - Varsa, çalmakta olan parçayı yeniden çalar. - - - Oynatıcıyı duraklatır. - - - Oynatmaya devam eder. - - - Oynatıcıyı durdurur. - - - Varsa, geçerli parçayı atlar. - - - Oynatıcı ses seviyesini ayarlar. - - - Ayarlanacak hacim (2 - 150) - - - Kuyruğu gösterir. - - - Kuyruğu karıştırır. - - - Belirli bir dizindeki kuyruktaki bir parçayı kaldırır. - - - Kaldırılacak parçanın dizini. - - - Belirtilen şarkının sözlerini veya müzikçalardan hiç geçmediyse geçerli parçayı gösterir. - - - Şarkı sözlerini aramak için şarkı. - - - Oynatıcıdaki geçerli parçanın resmini gösterir. - - - Çalınacak başka parça yok. - - - Şimdi oynuyor - - - Bir hata oluştu - - - Oyuncu {1} saniye ** {0} ** parçasına sıkıştı. - - - ** {0} ** 'den ** {1} **' ye taşındı - - - Hiçbir sonuç bulunamadı. Bir Youtube bağlantı veya bir mp3 dosyası bağlantısını kullanmayı deneyin. - - - Oynatma listesi ** {0} ** ({1} parça) ({2}) kuyruğa eklendi. - - - Sıraya alınmış {0} parça ({1}) - -Şimdi çalıyor: {2} - - - {0} kuyruğa eklendi. - - - Şimdi çalıyor: {0} - - - Geçersiz parça. - - - Tekrar oynatılıyor {0} - - - Sıra boş. - - - Oyuncu oynamıyor. - - - Oyuncu şimdi durdu. - - - Atlandı: {0} - -Şimdi çalıyor: {1} - - - 2 ile 150 arasında bir sayı kullanın. - - - Ses şiddeti: {0}. - - - Oyuncu şimdi duraklatıldı. - - - Oynatma devam etti. - - - Oyuncu duraklatılmadı. - - - Şu anda çalmaya: {0} ({1} ve {2}) - - - Kuyruktaki parçalar: - - - Kuyrukta sadece 1 öğe var. - - - Kuyruk karıştırıldı. - - - Dizin aralık dışında. - - - {1} konumundaki {0} parçası kaldırıldı. - - - Hiçbir şarkı {0} bulundu. - - - Botun oyun durumunu ayarlar. - - - Ayarlanacak metin. - - - Sadece bot sahibi. - - - Botun durumunu ayarlar. - - - Ayarlanacak durum (0 - 5). - - - Bir komutun kaynak kodunu gösterir. - - - Kodunu alma komutu. - - - Gömme rengini ayarlar. - - - Onaltılık veya ondalık olarak yeni renk. - - - Geçersiz renk. - - - Abi az önce yayınladın! - - - Bot genel önekini ayarlar. - - - Yeni global önek. - - - Geçerli genel önek: - - - Hedef önek ve geçerli önek aynıdır. - - - Yeni küresel öneki: "{0}" - - - Bot davet bağlantısını gönderir. - - - Bot önekini ayarlar. - - - Yeni önek. - - - Geçerli önek: - - - Yeni lonca öneki: "{0}" - - - Botu yeniden başlatır. - - - Bir şeyler söylüyor. - - - Söylenecek metin. - - - Botun çalışma süresini gösterir. - - - Bot dilini ayarlar. - - - Bot dili artık şudur: Türk -⚠ Google, bu çevirmek için kullanıldı Çevir yüzden metin ile bazı hatalar olabilir. - - - Sayfa {0} / {1} - - - Çalıştırılamadı - - - Oynatıcıdaki geçerli parçayı alır. - - - Bot değişiklik günlüğünü gösterir. - - - Geçici olarak devre dışı bırakıldı. - - - Bazı ilham verici alıntılar alın. - - - Ben bu komutu çalıştırmak için {0} izni gerekir. - - - Mesajları Yönet - - - Bağlan - - - Konuş - - - Kodu değerlendirir. - - - Değerlendirilecek kod. - - - 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. - - - Bot bir ses kanalına bağlı değil. - - - Oluşturma iptal edildi. - - - AI Dungeon'a Hoşgeldiniz - - - Devam etmeden önce `{0}aid info` ile bilgi ve komutları okuduğunuzdan emin olun. - -Bir mod seçin: - - - Bir karakter seçin - - - Dosyaları ekle - - - Sonuçları değerlendirin - - - Çıktı - - - {0} ms içinde yürütüldü - - - Kod herhangi bir dönüş değeri olmadan yürütülür. - - - Bu komut yalnızca bir NSFW kanalında kullanılabilir. - - - Bu komutu kullanmadan önce birkaç saniye bekleyin! - - - Su çulluğu yapmak için belirli bir kana.l - - - Kimlik bulunamadı. - - - Bu hikaye kimliği herkese açık değil ve onu kullanamazsınız. Kimlik sahibinden ({0}) `makepublic <ID>` ile herkese açık hale getirmesini isteyin - - - Bu kimliği kullanmadan önce hikayenin oluşturulmasını beklemelisiniz! - - - Yeni bir macera yaratmak -modu ile: ** {0} ** -ve karakteri: ** {1} ** ... - - - Özel karakter oluşturma - - - 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. - - - Özel istemi ile yeni bir macera oluşturuluyor... - - - Hikaye oluşturuluyor ... - - - Hikaye bağlamını düzenleme ... - - - AI şimdi hatırlayacak: - - - Son çıktının değiştirilmesi ... - - - Son çıktı şu şekilde değiştirildi: - - - Bu kimliğin sahibi değilsiniz. - - - Kimlik zaten herkese açık. - - - Kimlik artık herkese açık ve herkes kullanabilir. `Makeprivate <ID>` ile tekrar özel olarak ayarlayabilirsiniz. - - - Kimlik zaten özel. - - - Kimlik artık özeldir ve yalnızca siz kullanabilirsiniz. `Makepublic <ID>` ile herkese açık olarak ayarlayabilirsiniz. - - - {0} hiçbir macera kimliğine sahip değil. - - - {0} için kimlik listesi - - - Herkese açık mı - - - Bir macera kimliği hakkında bilgi almak için 'idinfo <ID>' kullanın. - - - Macera Kimliği bilgisi - - - 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. - - - 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. - - - AI Dungeon Hakkında - - - AI Dungeon Yardımı - - - Nasıl oynanır - - - Komutları - - - 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. - - - İçinde - - - Lonca seviyesinde bot yapılandırma seçenekleriyle bir gömme gösterir. - - - Bu komutu kullanabilmek için yönetici olmanız gerekir. - - - Yükleniyor... - - - Fergun yapılandırması - - - Yapılandırma iptal edildi. - - - Bunu yapamam. - - - Bir kullanıcıyı devre dışı bırakır (kullanıcı mesajlarını tekme + silme). - - - Softban kullanıcısı. - - - Silinecek son gün sayısı. 7 varsayılan olarak. - - - Softbanın nedeni. - - - Ne? Kendinizi yasaklamak ister misiniz? - - - Kendimi softban yapmayacağım lol - - - {0} kullanıcısı yazılımla yasaklandı. - - - AI Dungeon (kullanım {0}aid <komut>) - - - Son işlem geri döndürülüyor ... - - - 140 karakter maks. - - - Davet bağlantısı - - - Bazı komutlar `` Mesajları Yönet '' izniyle daha iyi çalışır (`img`,` kentsel`, `config`) - - - Geçersiz metin. - - - 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. - - - Bu kedi mevcut değil - - - Bu kişi mevcut değil - - - YouTube arama sınırına ulaşıldı :( - - - Birisi zaten bir video önbelleği oluşturuyor, lütfen bekleyin .. - - - kullanıcı - - - Geçerli kanalda en son düzenlenen mesajı gösterir. - - - Mesaja bir tepki ekler. - - - Ekleme reaksiyonu (sadece bir tane) - - - Reaksiyon eklemek için ileti kimliği (komutun yürütüldüğü kanaldan aynı olmalıdır). - - - Hedef dil - - - Sonuç - - - Hata türü - - - Hata mesajı - - - Geçerli parçayı belirtilen konuma arar. - - - Aranacak n. Saniye veya `m: ss`,` mm: ss`, `h: mm: ss` veya` ss: dd: ss` biçiminde geçerli bir saat. - - - Bu parça aranamıyor. - - - İkinci saniye ({0}) parçanın uzunluğundan ({1}) daha yüksek veya eşit olamaz - - - İkinci {0} 'a atlandı ({2} / {1}) - - - Çeviri API'sında bir hata oluştu. - - - Bot istatistiklerini gösterir. - - - Macera silindi. - - - CPU kullanımı - - - RAM Kullanımı - - - Kütüphane - - - Bot versiyonu - - - İşletim sistemi - - - Bot sahibi - - - Mevcut takma ad ve yeni takma ad aynı. - - - Evet - - - Hayır - - - Doğru - - - Yanlış - - - AI Dungeon giriş ve çıkışını otomatik çevir -"Oynat" için parça seçimi - - - Bir seçeneği etkinleştirmek veya devre dışı bırakmak için tepkiler kullanın. - - - Fergun yapılandırması - - - seçenek - - - değer - - - Bu komutu çalıştırma izniniz yok. - - - {0} kullanıcısı kara listeye alındı. - - - {0} kullanıcısı şu nedenden dolayı kara listeye alındı: {1} - - - {0} kullanıcısı kara listeden çıkarıldı. - - - Kara listeye bir kullanıcı ekler veya listeyi kaldırır. - - - Kara listeye alınacak kullanıcının kimliği. - - - Bu komutu kullanabilmek için `Kick Members 'iznine ihtiyacınız var. - - - Bu komutu yürütmek için `` Kick Members '' iznine ihtiyacım var. - - - Bu komutu kullanabilmek için `Takma Adları Yönet 'iznine ihtiyacınız var. - - - Geçersiz mesaj kimliği. İletinin bu kanaldan geldiğinden emin olun. - - - Belirtilen kullanıcı hiyerarşide daha düşük olmalıdır. - - - Ses kanalına katılmak için `` Bağlan '' iznine ihtiyacım var. - - - Devam etmek için `` Konuş '' iznine ihtiyacım var. - - - Gitmemi istediğin ses kanalına git. - - - İşlem süresi: {0} ms - - - Rol bulunamadı. - - - Rol bilgileri - - - Renk - - - Kayda değer - - - İzinler - - - Bilgi alma rolü (bundan bahsetmek gerekli değildir). - - - Botu ayırır. - - - En iyi komut. - - - Rasgele bir kullanıcı döndürür. - - - Aktif müşteriler - - - Düşük - - - Orta - - - Yüksek - - - Aşırı - - - Kaldırıldı - - - Durum - - - Nitro Boost Seviyesi - - - Nitro Boost Sayısı - - - Gitme süresi ({0}) parçanın uzunluğundan ({1}) daha yüksek veya eşit olamaz - - - Roller - - - O zamandan beri artıyor - - - Geçersiz mesaj kimliği. Emin olun mesajı bu kanaldan olduğunu. - - - Üyeleri Yasakla - - - Kick Üyeler - - - Geçersiz emote / emoji. - - - Komutta gerekli bağımsız değişkenler eksik. Komut hakkında daha fazla bilgi almak için {{0} yardım {1} `kullanın. - - - Bu komutu kullanmak için `` Mesajları Yönet '' iznine ihtiyacınız vardır. - - - Bu komutu yürütmek için `` Mesajları Yönet '' iznine ihtiyacım var. - - - Önek çok büyük. Maks. önek uzunluğu: {0}. - - - Geçersiz URL. - - - OCR ve tercüme. - - - OCR API'sında hata. - - - Kullanıcı yasaklanmadı. - - - Buradaki önekim `{0}`. Komut listemi görmek için `{0}help` ve öneki değiştirmek için `{0}prefix ` kullanın. - - - Bunu burada kullanabileceğini sanmıyorum. - - - 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. - - - Çeviri sonuçları - - - Kaynak dil (Tespit edildi) - - - Kara listeye alınma nedeni. - - - Kara listede bulunuyorsunuz. - - - {0} - - - Bu komutu kullanmak için `Üyeleri Yasakla 'iznine ihtiyacınız var. - - - Bu komutu yürütmek için `` Üyeleri Yasakla '' iznine ihtiyacım var. - - - OCR Sonuçları - - - Bu komutu yürütmek için `` Dosya Ekle '' iznine ihtiyacım var. - - - Force bu guild'deki öneki ayarlar. - - - Maceranıza devam etmek için {0} yardım devam <ID> [metin] kullanın. - - - ISO kodunda hedef dil (en, es, br, vb.) - - - Kullanılacak bir resmin URL'si. - - - Giriş - - - Üye Sayısı - - - Anma - - - Bir rol hakkında bilgi alır. - - - Özellikleri - - - {1} / {0} 'a atlandı - - - `M: ss`,` mm: ss`, `h: mm: ss` veya` ss: dd: ss` biçiminde bir tam sayı veya dize iletmeniz gerekir. - - - Bu komutu tekrar kullanmadan önce {0} saniye bekleyin! - - - Nesne bulunamadı. - - - Birden fazla eşleşme bulundu. -Bir etiket ({0}), bir söz ({1}) veya bir kimlik ({2}) kullanmayı deneyin. - - - Bot zaten bir ses kanalına bağlı. - - - Geçersiz dosya türü. - - - Sahip komutları - - - Bana oy verebilirsiniz [burada] ({0}). Teşekkür ederim. - - - Oylama bağlantısı içeren bir yerleştirme gösterir. - - - Toplam sunucu - - - Toplam kullanıcı - - - 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. - - - Son işlem yeniden yapılıyor ... - - - Hiç macera kimliğiniz yok. -Yeni bir macera oluşturmak için `new` komutunu kullanabilirsiniz. - - - ..ve {0} parça daha! - - - 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. - - - Bir bash komutu çalıştırır. - - - destek bilgisi gösterir. - - - API çağrılırken bir hata oluştu. - - - konu - - - NSFW mı - - - Yavaş mod - - - Kanal Bilgisi - - - Bir kanal hakkında bilgi gösterir. - - - Kanal bulunamadı. - - - Bilgi alacağınız kanal. - - - Bu komutu kullanmak için `` Sunucuyu Yönet '' iznine ihtiyacınız vardır. - - - Metni kötü bir çevirmenden geçirir. - - - Kullanılacak metin. - - - Dil zinciri - - - Sürüm bulunamadı. Sürümler: {0} - - - Diğer sürümler: {0} - - - {0} mesaj silindi. - - - {1} tarafından {0} mesaj silindi. - - - Matematik ifadesini değerlendirir. - - - Değerlendirilecek ifade. - - - Geçersiz ifade. - - - Kireç sonuçları - - - 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. - - - Parçayı tekrarlamak istediğiniz sayıyı girin, örn: -"{0}loop 5` - - - Geçerli parça şimdi {0} kez tekrarlanacaktır. -Döngüyü devre dışı bırakmak için bu komutu parametre kullanmadan kullanın. - - - {0} parçasının döngüsü sona erdi. - - - Geçerli parçayı birkaç kez tekrarlar. - - - Ayarlamak istediğiniz dili seçin. - - - Etiketleri korumak için komutun sonunda `-headers` kullanın (` [info] `). - - - <> = gereklidir | [] = İsteğe bağlı | Komutu yazarken bu sembolleri yazmayın. - - - Görüntüyü tersine çevirir (reddeder). - - - Kullanılacak bir resmin URL'si. - - - OCR ve çeviri sonuçları - - - Sanatçı Sayfası - - - Macerayı silmek için aşağıdaki düğmeye/emojiye basın. - - - Zaman aşımından önce tepki göstermediniz! - - - En az 1 seçenek geçmeniz gerekiyor. - - - Parçayı tekrarlama sayısı. - - - İz döngüsü devre dışı bırakıldı. - - - Bir parça çalmanız veya bir parça adı geçmeniz gerekiyor. - - - Şarkı sözleri Genius - - - Bu bir sayfa gösterici. Sayfayı değiştirmek için ilgili simgelerle reaksiyona geçin. - - - Dil seçimi - - - Mesajlar iki hafta yaşından küçük olması gerekmektedir. - - - iki hafta daha eski olduğu için {0} mesajlar silinemedi. - - - Sunucu bulunamadı. - - - {0} sözlerini ayrıştırılırken bir hata meydana geldi. -Belki bu bir vesile değil mi? - - - Parametre {0} '{1} daha düşük olması gerekir. - - - Geçersiz kimlik. - - - Hastebin bağlantı - - - Müsait değil. - - - Sıranın boşalması bekleniyor ... - - - Yeni metni girin - - - Yeni bir yanıt oluşturuluyor ... - - - Giriş seçenekleri - - - 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. - - - Kanal sayısı - - - bölge - - - Standart kanal - - - Dil bulunamadı. - - - Kategori sayımı - - - macera Damping... - - - Düzenleme iptal edildi. - - - Silinecek bir şey yok. - - - Üyeler - - - İnternet üzerinden - - - Boşta - - - DnD - - - Çevrim - - - AI Zindan bilgi gösterir. - - - Yeni bir macera oluşturur. - - - sağlanan metin ile macera devam ediyor. Hiçbir metin iletilirse, AI hikaye üretecektir. - - - macera kimliği. - - - Metin kullanmak. - - - Son işlemi geri alır. - - - macera kimliği. - - - Son eylemi yineler. - - - macera kimliği. - - - Hafıza bağlamında metin ekler. - - - macera kimliği. - - - Metin eklemek için. - - - Geçen tepkisini düzenler. - - - macera kimliği. - - - Son eylemi yeniden dener ve yeni bir yanıt oluşturur. - - - macera kimliği. - - - bir kimlik halka yapar. - - - macera IF. Bu Kimliği sahibi olmak gerekir. - - - bir kimlik olarak gizli yapar. - - - macera IF. Bu Kimliği sahibi olmak gerekir. - - - bir kullanıcının kimliği liste alır. - - - Kullanıcı, Kimliğini almak için. - - - Bir Kimliği hakkında Gösterileri bilgi. - - - macera kimliği. - - - Bir kimlik kaldırır. - - - macera kimliği. - - - Bir kimliğinden tüm metin döker. - - - macera kimliği. - - - Her {1} için {0} kullanım - - - Gereksinimler - - - Komut istatistikleri gösterir. - - - Komut İstatistikler ({0} beri) - - - Bunu senin için Google'da arayalım. - - - Aranacak anahtar kelimeler. - - - Hastebin yükledikleriniz metni. - - - Metin yüklemek için. - - - Yükleme... - - - Gösteriler son mevcut kanal veya belirtilen mesajlar silindi. - - - Belirli bir kanal snipe. - - - {0} dakika önce - - - Ek dosya - - - Gösterim mevcut kanal veya belirtilen son düzenlenmiş mesajlar. - - - Belirli bir kanal snipe. - - - Metin Kanal - - - duyuru Kanal - - - Ses Kanalı - - - DM Kanal - - - Bit Hızı - - - Kullanıcı Sınırı - - - Limit yok - - - (Bu sunucu için) yerel olarak bir komut devre dışı bırakır. - - - komutunun adı devre dışı bırakmak için. - - - {0} devre dışı bırakılamaz! - - - {0} zaten devre dışı bırakılmış! - - - Komut {0} bu sunucuda devre dışı bırakıldı. - - - (Bu sunucu için) yerel olarak bir komut sağlar. - - - komutunun adı etkinleştirmek için. - - - {0} zaten etkindir! - - - Komut {0} bu sunucuda etkinleştirilmiştir.$$ - - - Devre dışı bırakır (bütün sunucularda) bir komut global.$$ - - - komutunun adı global olarak devre dışı bırakmak için. - - - sebebi kullanmak. - - - {0} zaten küresel devre dışı bırakılmış! - - - Komut {0} Tüm sunucularda devre dışı bırakılmıştır. - - - (Bütün sunucularda) bir komut tüm dünyada sağlar. - - - komutunun adı global olarak etkinleştirmek için. - - - {0} zaten küresel etkindir! - - - Komut {0} Tüm sunucularda etkinleştirildi. - - - neden - - - Ben etkin olmaması nedeniyle ses kanalını bıraktık. - - - Fergun bazı saniyede kapalı / yeniden başlatılacak ve müzik çalar kapatılacak. Rahatsızlıktan dolayı özür dileriz. - - - Uyarı - - - Bu maceranın kamu kimliği boş. Yeni bir macera oluşturun. - - - Belirtilen kimliği bir kullanıcıya verir (aktarır). - - - Macera kimliği. - - - Kullanıcı kimliği verecek. - - - Kimliği kendiniz veremezsiniz! - - - Kimlik başarıyla {0} 'a aktarıldı. - - - Davet et - - - Top.GG Bot Sayfası - - - Oylama Bağlantısı - - - Destek Sunucusu - - - Herhangi bir hata raporunuz, sorunuz veya öneriniz varsa, doğrudan mesaj ({0}) aracılığıyla benimle iletişime geçebilirsiniz. - - - Yapılandırmada `{0}` değeri belirlenmedi. Bu sorun devam ederse, geliştiricilerle iletişime geçin. - - - Kimliği bir bota veremezsiniz! - - - Görünüşe göre mevcut dil yok. - - - İstek zaman aşımına uğradı. - - - Discord Sunucularında Hata - - - Discord tarafındaki bir sorun nedeniyle komut başarısız oldu. -Lütfen daha sonra tekrar deneyiniz. - - - Hata detayları - - - Oy verecek yer yok. - - - Lavalink sunucusuna bağlanılamadı. Lütfen daha sonra tekrar deneyiniz. - - - {0} sunucusu kara listeye alındı. - - - {0} sunucusu şu nedenle kara listeye alındı: {1} - - - Kara listeye bir sunucu ekler veya onu kaldırır. - - - Kara listeye alınacak sunucunun kimliği. - - - Kara listeye alınma nedeni. - - - {0} sunucusu kara listeden kaldırıldı. - - - Guild Presences Intent sahip olmadığım için Spotify durumu alınamıyor - - - {0} kullanıcısı için Spotify durumu bulunamadı. - - - Buraya Tıkla - - - Başlık - - - Sanatçı(lar) - - - Albüm - - - Süresi - - - Şarkı sözleri - - - Takip URL'si - - - Bir kullanıcının Spotify durum bilgisini alır. - - - Kullanıcı, Spotify durum bilgisini alacak. - - - Wolfram | Alpha'dan sorguya göre bir yanıt alır, - - - Gönderilecek sorgu. - - - Bir kelimenin tanımlarını alır. - - - Слово для поиска. - - - Kelime - - - Tanım - - - Eş anlamlı - - - Zıt anlamlılar - - - Ekran görüntüsü almak için web sitesinin URL'si. - - - Belirtilen biçime sahip bir zaman damgası. - - - Zaman damgası, `YYYYMMDDhhmmss` biçiminde olmalıdır; burada `YYYY` gereklidir ve diğer değerler isteğe bağlıdır. - - - Geçersiz zaman damgası. - - - Belirtilen url ve zaman damgası için anlık görüntü bulunamadı. - - - Zaman damgası kullanarak belirtilen yıl / ay / gün içindeki bir web sitesinin ekran görüntüsünü alır. - - - Zaman damgası - - - Komut yöntemi satırı bulunamadı. - - - Bir URL'yi kısaltır. - - - Kısaltılacak URL. - - - Rozet - - - DuckDuckGo ile görüntüleri arar. - - - Aranacak anahtar kelimeler. - - - Gizlilik politikasını ve gizlilik yapılandırmasını görüntüler. - - - Gizlilik Politikası - - - Hangi verileri topluyoruz - - - 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`) - - - Ne zaman toplayacağız - - - 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. - - - Gizlilik yapılandırması - - - Burada, gizliliğinizi iyileştirmek için değiştirebileceğiniz bazı ayarlar bulacaksınız. - - - "snipe" komutlarında silinen / düzenlenen mesajların geçici olarak toplanmasını devre dışı bırakın - - - Gizlilik - - - Mesajınızın burada görüntülenmesini istemiyor musunuz? Bkz. `privacy`. - - - Bu etkileşimi kullanamazsınız. - - - Macera siliniyor... - - - bağış yap - - - Dil Listesi - - - Cesur ile görüntüleri arar. - - - Aranacak anahtar kelime(ler). - - - Konu - - - Oto Arşiv - - - Sahne Kanalı - - - Sahne Kanalı - - - Arşivlendi - - \ No newline at end of file diff --git a/tests/Fergun.Tests/Apis/BingVisualSearchTests.cs b/tests/Fergun.Tests/Apis/BingVisualSearchTests.cs new file mode 100644 index 0000000..9964f90 --- /dev/null +++ b/tests/Fergun.Tests/Apis/BingVisualSearchTests.cs @@ -0,0 +1,72 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Fergun.Apis.Bing; +using Moq; +using Xunit; + +namespace Fergun.Tests.Apis; + +public class BingVisualSearchTests +{ + private readonly IBingVisualSearch _bingVisualSearch = new BingVisualSearch(); + + [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(() => task); + } + + [Theory] + [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, language)).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())); + } + + [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); + + await Assert.ThrowsAsync(() => task); + } + + [Fact] + public async Task Disposed_BingVisualSearch_Usage_Throws_ObjectDisposedException() + { + (_bingVisualSearch as IDisposable)?.Dispose(); + (_bingVisualSearch as IDisposable)?.Dispose(); + + await Assert.ThrowsAsync(() => _bingVisualSearch.OcrAsync(It.IsAny())); + await Assert.ThrowsAsync(() => _bingVisualSearch.ReverseImageSearchAsync(It.IsAny(), It.IsAny(), It.IsAny())); + } +} \ No newline at end of file diff --git a/tests/Fergun.Tests/Apis/UrbanDictionaryTests.cs b/tests/Fergun.Tests/Apis/UrbanDictionaryTests.cs new file mode 100644 index 0000000..cc457a4 --- /dev/null +++ b/tests/Fergun.Tests/Apis/UrbanDictionaryTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Threading.Tasks; +using Fergun.Apis.Urban; +using Moq; +using Xunit; + +namespace Fergun.Tests.Apis; + +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(() => _urbanDictionary.GetDefinitionsAsync(It.IsAny())); + await Assert.ThrowsAsync(() => _urbanDictionary.GetRandomDefinitionsAsync()); + await Assert.ThrowsAsync(() => _urbanDictionary.GetDefinitionAsync(It.IsAny())); + await Assert.ThrowsAsync(() => _urbanDictionary.GetWordsOfTheDayAsync()); + await Assert.ThrowsAsync(() => _urbanDictionary.GetAutocompleteResultsAsync(It.IsAny())); + await Assert.ThrowsAsync(() => _urbanDictionary.GetAutocompleteResultsExtraAsync(It.IsAny())); + } + + 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 diff --git a/tests/Fergun.Tests/Apis/WikipediaClientTests.cs b/tests/Fergun.Tests/Apis/WikipediaClientTests.cs new file mode 100644 index 0000000..7e9546b --- /dev/null +++ b/tests/Fergun.Tests/Apis/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.Apis; + +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_WikipediaClient_Usage_Should_Throw_ObjectDisposedException() + { + (_wikipediaClient as IDisposable)?.Dispose(); + (_wikipediaClient as IDisposable)?.Dispose(); + + await Assert.ThrowsAsync(() => _wikipediaClient.GetArticlesAsync(It.IsAny(), It.IsAny())); + await Assert.ThrowsAsync(() => _wikipediaClient.GetAutocompleteResultsAsync(It.IsAny(), It.IsAny())); + } +} \ No newline at end of file diff --git a/tests/Fergun.Tests/Apis/YandexImageSearchTests.cs b/tests/Fergun.Tests/Apis/YandexImageSearchTests.cs new file mode 100644 index 0000000..6587f54 --- /dev/null +++ b/tests/Fergun.Tests/Apis/YandexImageSearchTests.cs @@ -0,0 +1,198 @@ +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; +using Xunit; + +namespace Fergun.Tests.Apis; + +public class YandexImageSearchTests +{ + private readonly IYandexImageSearch _yandexImageSearch = new YandexImageSearch(); + + [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(); + + messageHandlerMock + .Protected() + .As() + .SetupSequence(x => x.SendAsync(It.IsAny(), It.IsAny())) + .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(() => task); + Assert.Equal(message, exception.Message); + } + + [Fact] + public async Task OcrAsync_Throws_YandexException_If_Captcha_Is_Present() + { + var messageHandlerMock = new Mock(); + + messageHandlerMock + .Protected() + .As() + .SetupSequence(x => x.SendAsync(It.IsAny(), It.IsAny())) + .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(() => 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(); + + messageHandlerMock + .Protected() + .As() + .SetupSequence(x => x.SendAsync(It.IsAny(), It.IsAny())) + .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(() => 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(); + serpList.ClassName = "serp-list"; + + serpList.Append(rawResults.Select(x => + { + var item = document.CreateElement(); + 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(); + + messageHandlerMock + .Protected() + .As() + .SetupSequence(x => x.SendAsync(It.IsAny(), It.IsAny())) + .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() + { + (_yandexImageSearch as IDisposable)?.Dispose(); + (_yandexImageSearch as IDisposable)?.Dispose(); + + await Assert.ThrowsAsync(() => _yandexImageSearch.OcrAsync(It.IsAny())); + await Assert.ThrowsAsync(() => _yandexImageSearch.ReverseImageSearchAsync(It.IsAny())); + } +} \ 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..9ba6080 --- /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 TestExtensions +{ + public static void SetPropertyValue(this TSource obj, Expression> expression, TProperty newValue) + => ((PropertyInfo)((MemberExpression)expression.Body).Member).SetValue(obj, newValue); +} \ No newline at end of file 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(); + channelMock1.SetupGet(x => x.IsNsfw).Returns(true); + + var channelMock2 = new Mock(); + channelMock2.SetupGet(x => x.IsNsfw).Returns(false); + + var channelMock3 = new Mock(); + + 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(); + var channelMock2 = new Mock(); + var channelMock3 = new Mock(); + + 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..176bfbb --- /dev/null +++ b/tests/Fergun.Tests/Extensions/InteractionExtensionsTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Bogus; +using Discord; +using Fergun.Extensions; +using GTranslate; +using Moq; +using Xunit; + +namespace Fergun.Tests.Extensions; + +public class InteractionExtensionsTests +{ + [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(); + 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(); + interactionMock.SetupGet(x => x.UserLocale).Returns(locale); + var interactionMock2 = new Mock(); + 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(); + 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 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 GetLocales() + { + var faker = new Faker(); + + return faker.MakeLazy(10, () => faker.Random.RandomLocale().Replace('_', '-')) + .Select(x => new object[] { x }); + } + + private static IEnumerable 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(); + messageMock.SetupGet(x => x.Content).Returns(content); + messageMock.SetupGet(x => x.Embeds).Returns(Array.Empty()); + + 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(); + 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 GetEmbeds() + { + return new Faker().MakeLazy(10, Utils.CreateFakeEmbedBuilder).Select(x => new object[] { x.Build() }); + } + + private static IEnumerable GetContentsAndEmbeds() + { + return GetRandomStrings().Zip(GetEmbeds()).Select(x => new[] { x.First[0], x.Second[0] }); + } + + private static IEnumerable 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, $""); + } + + private static IEnumerable 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 similarity index 61% rename from Tests/Fergun.Tests/Fergun.Tests.csproj rename to tests/Fergun.Tests/Fergun.Tests.csproj index 94f67f6..155f65a 100644 --- a/Tests/Fergun.Tests/Fergun.Tests.csproj +++ b/tests/Fergun.Tests/Fergun.Tests.csproj @@ -1,16 +1,20 @@ - + net6.0 - false + enable - - + + + + + + - - all + runtime; build; native; contentfiles; analyzers; buildtransitive + all diff --git a/tests/Fergun.Tests/Modules/Handlers/AutocompleteHandlerTests.cs b/tests/Fergun.Tests/Modules/Handlers/AutocompleteHandlerTests.cs new file mode 100644 index 0000000..48910ed --- /dev/null +++ b/tests/Fergun.Tests/Modules/Handlers/AutocompleteHandlerTests.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Bogus; +using Discord; +using Discord.Interactions; +using Fergun.Apis.Urban; +using Fergun.Apis.Wikipedia; +using Fergun.Extensions; +using Fergun.Modules.Handlers; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace Fergun.Tests.Modules.Handlers; + +public class AutocompleteHandlerTests +{ + private readonly Mock _contextMock = new(); + private readonly Mock _channelMock = new(); + private readonly IParameterInfo _parameter = Mock.Of(); + private readonly IServiceProvider _services = GetServiceProvider(); + private readonly Mock _interactionMock = new(); + private readonly Mock _dataMock = new(); + + [Theory] + [MemberData(nameof(GetBraveTestData))] + public async Task BraveAutocomplete_Should_Return_Valid_Suggestions(string text) + { + var handler = new BraveAutocompleteHandler(); + var option = Utils.CreateInstance(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)); + } + } + + [Theory] + [MemberData(nameof(GetDuckDuckGoTestData))] + public async Task DuckDuckGoAutocomplete_Should_Return_Valid_Suggestions(string text, string locale, bool isNsfw) + { + var handler = new DuckDuckGoAutocompleteHandler(); + var option = Utils.CreateInstance(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(GetGoogleTestData))] + public async Task GoogleAutocomplete_Should_Return_Valid_Suggestions(string text, string locale) + { + var handler = new GoogleAutocompleteHandler(); + var option = Utils.CreateInstance(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)); + } + } + + [Theory] + [MemberData(nameof(GetGoogleTestData))] + public async Task YouTubeAutocomplete_Should_Return_Valid_Suggestions(string text, string locale) + { + var handler = new YouTubeAutocompleteHandler(); + var option = Utils.CreateInstance(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)); + } + } + + [Theory] + [MemberData(nameof(GetUrbanTestData))] + public async Task UrbanAutocomplete_Should_Return_Valid_Suggestions(string text) + { + var handler = new UrbanAutocompleteHandler(); + var option = Utils.CreateInstance(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)); + } + } + + [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(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() + .AddFergunPolicies(); + + services.AddHttpClient("autocomplete", client => client.DefaultRequestHeaders.UserAgent.ParseAdd(Constants.ChromeUserAgent)) + .SetHandlerLifetime(TimeSpan.FromMinutes(30)); + + services.AddHttpClient() + .SetHandlerLifetime(TimeSpan.FromMinutes(30)) + .AddRetryPolicy(); + + services.AddHttpClient() + .SetHandlerLifetime(TimeSpan.FromMinutes(30)) + .AddRetryPolicy(); + + return services.BuildServiceProvider(); + } + + private static IEnumerable GetBraveTestData() + { + var faker = new Faker(); + return faker.MakeLazy(10, () => faker.Random.String2(1)) + .Append(string.Empty).Append(null).Select(x => new object?[] { x }); + } + + private static IEnumerable 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 GetGoogleTestData() + { + var faker = new Faker(); + 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 }); + } + + private static IEnumerable 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/Modules/ImageModuleTests.cs b/tests/Fergun.Tests/Modules/ImageModuleTests.cs new file mode 100644 index 0000000..1d7c088 --- /dev/null +++ b/tests/Fergun.Tests/Modules/ImageModuleTests.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +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.Modules; + +public class ImageModuleTests +{ + private readonly Mock _contextMock = new(); + private readonly Mock _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 _localizer = Utils.CreateMockedLocalizer(); + private readonly Mock _moduleMock; + private readonly ImageModule _module; + + public ImageModuleTests() + { + var logger = Mock.Of>(); + var options = Utils.CreateMockedFergunOptions(); + var interactive = new InteractiveService(_client, new InteractiveConfig { DeferStopSelectionInteractions = false, ReturnAfterSendingPaginator = true }); + _moduleMock = new Mock(() => new ImageModule(logger, _localizer, options, 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()); + Assert.Equal("en", _localizer.CurrentCulture.TwoLetterISOLanguageName); + } + + [Theory] + [InlineData("Discord", false, true)] + [InlineData("Google", true, false)] + public async Task GoogleAsync_Sends_Paginator(string query, bool multiImages, bool nsfw) + { + var channel = new Mock(); + channel.SetupGet(x => x.IsNsfw).Returns(nsfw); + _contextMock.SetupGet(x => x.Channel).Returns(channel.Object); + + var result = await _module.GoogleAsync(query, multiImages); + Assert.True(result.IsSuccess); + + _interactionMock.Verify(x => x.DeferAsync(It.IsAny(), It.IsAny()), Times.Once); + _contextMock.VerifyGet(x => x.Channel); + _interactionMock.VerifyGet(x => x.User); + channel.VerifyGet(x => x.IsNsfw); + + _interactionMock.Verify(x => x.FollowupAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task GoogleAsync_Returns_No_Results() + { + var result = await _module.GoogleAsync(" "); + Assert.False(result.IsSuccess); + + Mock.Get(_localizer).VerifyGet(x => x[It.Is(y => y == "No results.")]); + } + + [Theory] + [InlineData("Discord", false, true)] + [InlineData("DuckDuckGo", true, false)] + public async Task DuckDuckGoAsync_Sends_Paginator(string query, bool multiImages, bool nsfw) + { + var channel = new Mock(); + channel.SetupGet(x => x.IsNsfw).Returns(nsfw); + _contextMock.SetupGet(x => x.Channel).Returns(channel.Object); + + var result = await _module.DuckDuckGoAsync(query, multiImages); + Assert.True(result.IsSuccess); + + _interactionMock.Verify(x => x.DeferAsync(It.IsAny(), It.IsAny()), Times.Once); + _contextMock.VerifyGet(x => x.Channel); + _interactionMock.VerifyGet(x => x.User); + channel.VerifyGet(x => x.IsNsfw); + + _interactionMock.Verify(x => x.FollowupAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task DuckDuckGoAsync_Returns_No_Results() + { + var result = await _module.DuckDuckGoAsync("\u200b"); + Assert.False(result.IsSuccess); + + Mock.Get(_localizer).VerifyGet(x => x[It.Is(y => y == "No results.")]); + } + + [Theory] + [InlineData("Discord", false, true)] + [InlineData("Brave", true, false)] + public async Task BraveAsync_Sends_Paginator(string query, bool multiImages, bool nsfw) + { + var channel = new Mock(); + channel.SetupGet(x => x.IsNsfw).Returns(nsfw); + _contextMock.SetupGet(x => x.Channel).Returns(channel.Object); + + var result = await _module.BraveAsync(query, multiImages); + Assert.True(result.IsSuccess); + + _interactionMock.Verify(x => x.DeferAsync(It.IsAny(), It.IsAny()), Times.Once); + _contextMock.VerifyGet(x => x.Channel); + _interactionMock.VerifyGet(x => x.User); + channel.VerifyGet(x => x.IsNsfw); + + _interactionMock.Verify(x => x.FollowupAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task BraveAsync_Returns_No_Results() + { + var result = await _module.BraveAsync("\u200b"); + Assert.False(result.IsSuccess); + + Mock.Get(_localizer).VerifyGet(x => x[It.Is(y => y == "No results.")]); + } + + [Theory] + [MemberData(nameof(GetReverseImageSearchData))] + public async Task ReverseAsync_Sends_Paginator(string? url, IAttachment? file, ImageModule.ReverseImageSearchEngine engine, bool multiImages, bool nsfw) + { + var channel = new Mock(); + 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, file, engine, multiImages); + Assert.True(result.IsSuccess); + + _contextMock.VerifyGet(x => x.Channel); + _interactionMock.VerifyGet(x => x.User); + channel.VerifyGet(x => x.IsNsfw); + _interactionMock.Verify(x => x.DeferAsync(It.Is(b => !b), It.IsAny()), Times.Once); + _interactionMock.Verify(x => x.FollowupAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + + if (engine == ImageModule.ReverseImageSearchEngine.Bing) + { + _moduleMock.Verify(x => x.BingAsync(It.Is(s => s == (file == null ? url : file.Url)), It.Is(b => b == multiImages), It.IsAny(), It.Is(b => !b)), Times.Once); + Mock.Get(_bingVisualSearch).Verify(x => x.ReverseImageSearchAsync(It.Is(s => s == (file == null ? url : file.Url)), It.Is(l => l == (nsfw ? BingSafeSearchLevel.Off : BingSafeSearchLevel.Strict)), It.IsAny()), Times.Once); + } + else if (engine == ImageModule.ReverseImageSearchEngine.Yandex) + { + _moduleMock.Verify(x => x.YandexAsync(It.Is(s => s == (file == null ? url : file.Url)), It.Is(b => b == multiImages), It.IsAny(), It.Is(b => !b)), Times.Once); + Mock.Get(_yandexImageSearch).Verify(x => x.ReverseImageSearchAsync(It.Is(s => s == (file == null ? url : file.Url)), It.Is(l => l == (nsfw ? YandexSearchFilterMode.None : YandexSearchFilterMode.Family))), Times.Once); + } + } + + [Theory] + [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(); + 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, file, engine, multiImages); + Assert.False(result.IsSuccess); + + Mock.Get(_localizer).VerifyGet(x => x[It.Is(y => y == message)]); + } + + [Fact] + public async Task ReverseAsync_Throws_Exception_If_Invalid_Engine_Is_Passed() + { + await Assert.ThrowsAsync("engine", () => _module.ReverseAsync("", It.IsAny(), (ImageModule.ReverseImageSearchEngine)2, It.IsAny())); + } + + [Theory] + [InlineData(ImageModule.ReverseImageSearchEngine.Bing)] + [InlineData(ImageModule.ReverseImageSearchEngine.Yandex)] + public async Task ReverseAsync_Throws_Exception_If_Invalid_Parameters_Are_Passed(ImageModule.ReverseImageSearchEngine engine) + { + var channel = new Mock(); + channel.SetupGet(x => x.IsNsfw).Returns(false); + _contextMock.SetupGet(x => x.Channel).Returns(channel.Object); + + var result = await _module.ReverseAsync("https://example.com/error", null, engine, It.IsAny()); + Assert.False(result.IsSuccess); + Assert.Equal("Error message.", result.ErrorReason); + + _interactionMock.Verify(x => x.DeferAsync(It.Is(b => !b), It.IsAny()), Times.Once); + } + + private static IEnumerable GetReverseImageSearchData() + { + var attachmentMock = new Mock(); + 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/Modules/OcrModuleTests.cs b/tests/Fergun.Tests/Modules/OcrModuleTests.cs new file mode 100644 index 0000000..c00293d --- /dev/null +++ b/tests/Fergun.Tests/Modules/OcrModuleTests.cs @@ -0,0 +1,131 @@ +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 GTranslate.Translators; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Fergun.Tests.Modules; + +public class OcrModuleTests +{ + private readonly Mock _contextMock = new(); + private readonly Mock _interactionMock = new(); + private readonly Mock _bingVisualSearchMock = new(); + private readonly Mock _yandexImageSearchMock = new(); + private readonly Mock> _loggerMock = new(); + private readonly DiscordSocketClient _client = new(); + private readonly InteractiveService _interactive; + private readonly InteractiveConfig _interactiveConfig = new() { DeferStopSelectionInteractions = false }; + private readonly IFergunLocalizer _ocrLocalizer = Utils.CreateMockedLocalizer(); + private readonly Mock _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"; + + public OcrModuleTests() + { + _bingVisualSearchMock.Setup(x => x.OcrAsync(It.Is(s => s == TextImageUrl))).ReturnsAsync("test"); + _bingVisualSearchMock.Setup(x => x.OcrAsync(It.Is(s => s == EmptyImageUrl))).ReturnsAsync(string.Empty); + _bingVisualSearchMock.Setup(x => x.OcrAsync(It.Is(s => s == InvalidImageUrl))).ThrowsAsync(new BingException("Invalid image.")); + _yandexImageSearchMock.Setup(x => x.OcrAsync(It.Is(s => s == TextImageUrl))).ReturnsAsync("test"); + _yandexImageSearchMock.Setup(x => x.OcrAsync(It.Is(s => s == EmptyImageUrl))).ReturnsAsync(string.Empty); + _yandexImageSearchMock.Setup(x => x.OcrAsync(It.Is(s => s == InvalidImageUrl))).ThrowsAsync(new YandexException("Invalid image.")); + + var sharedLogger = Mock.Of>(); + var sharedLocalizer = Utils.CreateMockedLocalizer(); + var shared = new SharedModule(sharedLogger, sharedLocalizer, Mock.Of(), new()); + + _interactive = new InteractiveService(_client, _interactiveConfig); + _moduleMock = new Mock(() => new OcrModule(_loggerMock.Object, _ocrLocalizer, shared, _interactive, + _bingVisualSearchMock.Object, _yandexImageSearchMock.Object)) { CallBase = true }; + _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.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()); + Assert.Equal("en", _ocrLocalizer.CurrentCulture.TwoLetterISOLanguageName); + } + + [Theory] + [InlineData(TextImageUrl, true)] + [InlineData(EmptyImageUrl, false)] + public async Task BingAsync_Uses_BingVisualSearch(string url, bool success) + { + var module = _moduleMock.Object; + const bool isEphemeral = false; + + var result = await module.BingAsync(url); + Assert.Equal(success, result.IsSuccess); + + _interactionMock.Verify(x => x.DeferAsync(It.Is(b => b == isEphemeral), It.IsAny()), Times.Once); + + _bingVisualSearchMock.Verify(x => x.OcrAsync(It.Is(s => s == url)), Times.Once); + + _interactionMock.Verify(x => x.FollowupAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.Is(b => b == isEphemeral), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), success ? Times.Once : Times.Never); + } + + [Theory] + [InlineData(TextImageUrl, true)] + [InlineData(EmptyImageUrl, false)] + public async Task YandexAsync_Uses_YandexImageSearch(string url, bool success) + { + var module = _moduleMock.Object; + const bool isEphemeral = false; + + var result = await module.YandexAsync(url); + Assert.Equal(success, result.IsSuccess); + + _interactionMock.Verify(x => x.DeferAsync(It.Is(b => b == isEphemeral), It.IsAny()), Times.Once); + + _yandexImageSearchMock.Verify(x => x.OcrAsync(It.Is(s => s == url)), Times.Once); + + _interactionMock.Verify(x => x.FollowupAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.Is(b => b == isEphemeral), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), success ? Times.Once : Times.Never); + } + + [Fact] + public async Task OcrAsync_Returns_Warning_If_Url_Is_Invalid() + { + var module = _moduleMock.Object; + const bool isEphemeral = true; + + var result = await module.OcrAsync(It.IsAny(), string.Empty, _interactionMock.Object, isEphemeral); + Assert.False(result.IsSuccess); + } + + [Fact] + 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); + + await Assert.ThrowsAsync(() => task); + } + + [Fact] + public async Task OcrAsync_Returns_Warning_On_Exception() + { + var module = _moduleMock.Object; + const bool isEphemeral = true; + + var result = await module.OcrAsync(It.IsAny(), InvalidImageUrl, _interactionMock.Object, isEphemeral); + Assert.False(result.IsSuccess); + + _interactionMock.Verify(x => x.DeferAsync(It.Is(b => b == isEphemeral), It.IsAny()), Times.Once); + } +} \ No newline at end of file diff --git a/tests/Fergun.Tests/Modules/SharedModuleTests.cs b/tests/Fergun.Tests/Modules/SharedModuleTests.cs new file mode 100644 index 0000000..074f70b --- /dev/null +++ b/tests/Fergun.Tests/Modules/SharedModuleTests.cs @@ -0,0 +1,143 @@ +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.Modules; + +public class SharedModuleTests +{ + private readonly Mock _translatorMock = new(); + private readonly Mock _sharedModuleMock; + private readonly Mock _interactionMock = new(); + private readonly Mock _componentInteractionMock = new(); + + public SharedModuleTests() + { + var localizer = Utils.CreateMockedLocalizer(); + _sharedModuleMock = new Mock(() => new SharedModule(Mock.Of>(), localizer, _translatorMock.Object, new())); + _translatorMock.Setup(x => x.TranslateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((text, target, source) => + { + if (text == "Error") + { + throw new ArgumentException(null, nameof(text)); + } + + var targetLanguage = Language.GetLanguage(target); + Language.TryGetLanguage(source ?? string.Empty, out var sourceLanguage); + + var mock = new Mock(); + 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)] + [InlineData("Error", "fr", "it", true)] + public async Task TranslateAsync_Returns_Results_Or_Fails_Preconditions(string text, string target, string? source, bool 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 _)); + + if (text != "Error") + { + Assert.Equal(result.IsSuccess, passedPreconditions); + } + + _interactionMock.Verify(x => x.DeferAsync(It.Is(b => b == ephemeral), It.IsAny()), passedPreconditions ? Times.Once : Times.Never); + + _translatorMock.Verify(x => x.TranslateAsync(It.Is(s => s == text), It.Is(s => s == target), It.Is(s => s == source)), passedPreconditions ? Times.Once : Times.Never); + + _interactionMock.Verify(x => x.FollowupAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.Is(b => b == ephemeral), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), result.IsSuccess && passedPreconditions ? Times.Once : Times.Never); + } + + [Theory] + [InlineData("Microsoft", "tr", "es", true)] + [InlineData("Yandex", "ru", "en", false)] + public async Task TranslateAsync_Uses_DeferLoadingAsync(string text, string target, string? source, bool ephemeral) + { + var result = await _sharedModuleMock.Object.TranslateAsync(_componentInteractionMock.Object, text, target, source, ephemeral); + + Assert.True(result.IsSuccess); + + _componentInteractionMock.VerifyGet(x => x.UserLocale); + + _componentInteractionMock.Verify(x => x.DeferAsync(It.Is(b => b == ephemeral), It.IsAny()), Times.Never); + _componentInteractionMock.Verify(x => x.DeferLoadingAsync(It.Is(b => b == ephemeral), It.IsAny()), Times.Once); + + _translatorMock.Verify(x => x.TranslateAsync(It.Is(s => s == text), It.Is(s => s == target), It.Is(s => s == source)), Times.Once); + + _componentInteractionMock.Verify(x => x.FollowupAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.Is(b => b == ephemeral), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), 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) + { + 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); + Assert.Equal(passedPreconditions, result.IsSuccess); + + _interactionMock.Verify(x => x.DeferAsync(It.Is(b => b == ephemeral), It.IsAny()), passedPreconditions ? Times.Once : Times.Never); + + _interactionMock.Verify(x => x.FollowupWithFileAsync(It.Is(f => f.FileName == "tts.mp3"), It.IsAny(), It.IsAny(), It.IsAny(), It.Is(b => b == ephemeral), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), 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.GoogleTtsAsync(_componentInteractionMock.Object, text, target, ephemeral); + + _componentInteractionMock.VerifyGet(x => x.UserLocale); + + _componentInteractionMock.Verify(x => x.DeferAsync(It.Is(b => b == ephemeral), It.IsAny()), Times.Never); + _componentInteractionMock.Verify(x => x.DeferLoadingAsync(It.Is(b => b == ephemeral), It.IsAny()), Times.Once); + + _componentInteractionMock.Verify(x => x.FollowupWithFileAsync(It.Is(f => f.FileName == "tts.mp3"), It.IsAny(), It.IsAny(), It.IsAny(), It.Is(b => b == ephemeral), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } +} \ No newline at end of file diff --git a/tests/Fergun.Tests/Modules/UrbanModuleTests.cs b/tests/Fergun.Tests/Modules/UrbanModuleTests.cs new file mode 100644 index 0000000..fa38936 --- /dev/null +++ b/tests/Fergun.Tests/Modules/UrbanModuleTests.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AutoBogus; +using AutoBogus.Moq; +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using Fergun.Apis.Urban; +using Fergun.Interactive; +using Fergun.Modules; +using Moq; +using Xunit; + +namespace Fergun.Tests.Modules; + +public class UrbanModuleTests +{ + private readonly Mock _contextMock = new(); + private readonly Mock _interactionMock = new(); + private readonly IUrbanDictionary _urbanDictionary = Utils.CreateMockedUrbanDictionaryApi(); + private readonly Mock _moduleMock; + private readonly DiscordSocketClient _client = new(); + private readonly InteractiveConfig _interactiveConfig = new() { ReturnAfterSendingPaginator = true }; + private readonly IFergunLocalizer _localizer = Utils.CreateMockedLocalizer(); + + public UrbanModuleTests() + { + var options = Utils.CreateMockedFergunOptions(); + var interactive = new InteractiveService(_client, _interactiveConfig); + + _moduleMock = new Mock(() => new UrbanModule(_localizer, options, _urbanDictionary, interactive)) { CallBase = true }; + _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object); + _contextMock.SetupGet(x => x.User).Returns(() => AutoFaker.Generate(b => b.WithBinder(new MoqBinder()))); + ((IInteractionModuleBase)_moduleMock.Object).SetContext(_contextMock.Object); + } + + [Fact] + public void BeforeExecute_Sets_Language() + { + _interactionMock.SetupGet(x => x.UserLocale).Returns("en"); + _moduleMock.Object.BeforeExecute(It.IsAny()); + Assert.Equal("en", _localizer.CurrentCulture.TwoLetterISOLanguageName); + } + + [Theory] + [MemberData(nameof(GetRandomWords))] + public async Task SearchAsync_Returns_Definitions(string term) + { + var result = await _moduleMock.Object.SearchAsync(term); + Assert.True(result.IsSuccess); + + _interactionMock.Verify(x => x.DeferAsync(It.Is(b => !b), It.IsAny()), Times.Once); + Mock.Get(_urbanDictionary).Verify(u => u.GetDefinitionsAsync(It.Is(x => x == term)), Times.Once); + } + + [Theory] + [InlineData(null)] + public async Task SearchAsync_Returns_No_Definitions(string term) + { + var result = await _moduleMock.Object.SearchAsync(term); + Assert.False(result.IsSuccess); + + _interactionMock.Verify(x => x.DeferAsync(It.Is(b => !b), It.IsAny()), Times.Once); + Mock.Get(_urbanDictionary).Verify(u => u.GetDefinitionsAsync(It.Is(x => x == term)), Times.Once); + } + + [Fact] + public async Task RandomAsync_Calls_GetRandomDefinitionsAsync() + { + var result = await _moduleMock.Object.RandomAsync(); + Assert.True(result.IsSuccess); + + _interactionMock.Verify(x => x.DeferAsync(It.Is(b => !b), It.IsAny()), Times.Once); + Mock.Get(_urbanDictionary).Verify(u => u.GetRandomDefinitionsAsync(), Times.Once); + } + + [Fact] + public async Task WordsOfTheDayAsync_Calls_GetWordsOfTheDayAsync() + { + var result = await _moduleMock.Object.WordsOfTheDayAsync(); + Assert.True(result.IsSuccess); + + _interactionMock.Verify(x => x.DeferAsync(It.Is(b => !b), It.IsAny()), Times.Once); + Mock.Get(_urbanDictionary).Verify(u => u.GetWordsOfTheDayAsync(), Times.Once); + } + + [Fact] + public async Task Invalid_SearchType_Throws_ArgumentException() + { + var task = _moduleMock.Object.SearchAndSendAsync((UrbanModule.UrbanSearchType)3); + + await Assert.ThrowsAsync(() => task); + } + + private static IEnumerable GetRandomWords() => AutoFaker.Generate(10).Select(x => new object[] { x }); +} \ No newline at end of file diff --git a/tests/Fergun.Tests/Modules/UtilityModuleTests.cs b/tests/Fergun.Tests/Modules/UtilityModuleTests.cs new file mode 100644 index 0000000..de0088e --- /dev/null +++ b/tests/Fergun.Tests/Modules/UtilityModuleTests.cs @@ -0,0 +1,145 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +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 UtilityModuleTests +{ + private readonly Mock _contextMock = new(); + private readonly Mock _interactionMock = new(); + private readonly IFergunLocalizer _localizer = Utils.CreateMockedLocalizer(); + private readonly GoogleTranslator2 _googleTranslator2 = new(); + private readonly SearchClient _searchClient = new(new()); + private readonly IWikipediaClient _wikipediaClient = null!; + private readonly Mock _moduleMock; + + public UtilityModuleTests() + { + var options = Utils.CreateMockedFergunOptions(); + SharedModule shared = new(Mock.Of>(), Utils.CreateMockedLocalizer(), Mock.Of(), _googleTranslator2); + var interactive = new InteractiveService(new DiscordSocketClient(), new InteractiveConfig { ReturnAfterSendingPaginator = true }); + _moduleMock = new Mock(() => new UtilityModule(Mock.Of>(), _localizer, options, shared, + interactive, Mock.Of(), _searchClient, _wikipediaClient)) { CallBase = true }; + _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.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()); + Assert.Equal("en", _localizer.CurrentCulture.TwoLetterISOLanguageName); + } + + [Theory] + [MemberData(nameof(GetFakeUsers))] + public async Task AvatarAsync_Should_Return_Embed_With_Avatar(Mock userMock) + { + var result = await _moduleMock.Object.AvatarAsync(userMock.Object); + Assert.True(result.IsSuccess); + + userMock.Verify(x => x.ToString()); + userMock.Verify(x => x.GetAvatarUrl(It.IsAny(), It.IsAny())); + if (userMock.Object.GetAvatarUrl(It.IsAny(), It.IsAny()) is null) + { + userMock.Verify(x => x.GetDefaultAvatarUrl()); + } + + VerifyRespondAsyncCall(userMock.Object); + } + + [Theory] + [MemberData(nameof(GetFakeGuildUsers))] + public async Task AvatarAsync_Should_Return_Embed_With_Guild_Avatar(Mock guildUserMock) + { + var result = await _moduleMock.Object.AvatarAsync(guildUserMock.Object); + Assert.True(result.IsSuccess); + + guildUserMock.Verify(x => x.ToString()); + guildUserMock.Verify(x => x.GetGuildAvatarUrl(It.IsAny(), It.IsAny())); + VerifyRespondAsyncCall(guildUserMock.Object); + } + + [Theory] + [MemberData(nameof(GetFakeUsers))] + public async Task UserInfoAsync_Should_Return_Embed_With_Avatar(Mock userMock) + { + var result = await _moduleMock.Object.UserInfoAsync(userMock.Object); + Assert.True(result.IsSuccess); + + userMock.Verify(x => x.ToString()); + userMock.Verify(x => x.GetAvatarUrl(It.IsAny(), It.IsAny())); + if (userMock.Object.GetAvatarUrl(It.IsAny(), It.IsAny()) 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 UserInfoAsync_Should_Return_Embed_With_Guild_Avatar(Mock guildUserMock) + { + var result = await _moduleMock.Object.UserInfoAsync(guildUserMock.Object); + Assert.True(result.IsSuccess); + + guildUserMock.Verify(x => x.ToString()); + guildUserMock.Verify(x => x.GetGuildAvatarUrl(It.IsAny(), It.IsAny())); + 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) + { + _interactionMock.Verify(x => x.RespondAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.Is(e => EmbedImageUrlIsUserAvatarUrl(user, e)), It.IsAny()), Times.Once); + } + + 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 GetFakeUsers() + { + var faker = new Faker(); + + return faker.MakeLazy(20, () => Utils.CreateMockedUser()).Select(x => new object[] { Mock.Get(x) }); + } + + private static IEnumerable GetFakeGuildUsers() + { + var faker = new Faker(); + + 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 new file mode 100644 index 0000000..6d5e0e0 --- /dev/null +++ b/tests/Fergun.Tests/Utils.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +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 Fergun.Interactive.Pagination; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using Moq; + +namespace Fergun.Tests; + +internal static class Utils +{ + public static T CreateInstance(params object?[]? args) where T : class + => (T)Activator.CreateInstance(typeof(T), BindingFlags.NonPublic | BindingFlags.Instance, null, args, CultureInfo.InvariantCulture)!; + + public static IFergunLocalizer CreateMockedLocalizer() + { + var localizerMock = new Mock>(); + localizerMock.Setup(x => x[It.IsAny()]).Returns(s => new LocalizedString(s, s)); + localizerMock.Setup(x => x[It.IsAny(), It.IsAny()]).Returns((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(); + + 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, CreateFakeCustomStatusGame(faker))).ToArray()); + userMock.SetupGet(x => x.ActiveClients).Returns(faker.MakeLazy(faker.Random.Number(3), + () => faker.PickRandom(Enum.GetValues()).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(), It.IsAny())).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(); + + 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, CreateFakeCustomStatusGame(faker))).ToArray()); + userMock.SetupGet(x => x.ActiveClients).Returns(faker.MakeLazy(faker.Random.Number(3), + () => faker.PickRandom(Enum.GetValues()).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(), It.IsAny())).Returns(faker.Internet.Avatar()); + userMock.Setup(x => x.GetAvatarUrl(It.IsAny(), It.IsAny())).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 CreateFakeCustomStatusGame(Faker? faker = null) + { + faker ??= new Faker(); + + var status = CreateInstance(); + 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 EmbedBuilder CreateFakeEmbedBuilder() + { + return new Faker() + .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) + { + faker ??= new Faker(); + var bingMock = new Mock(); + + bingMock.Setup(x => x.OcrAsync(It.Is(s => s == string.Empty))).ReturnsAsync(() => string.Empty); + bingMock.Setup(x => x.OcrAsync(It.Is(s => !string.IsNullOrEmpty(s)))).ReturnsAsync(() => faker.Lorem.Sentence()); + bingMock.Setup(x => x.OcrAsync(It.Is(s => s == "https://example.com/error"))).ThrowsAsync(new BingException("Error message.")); + + bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is(s => s == string.Empty), It.IsAny(), It.IsAny())).ReturnsAsync(Enumerable.Empty); + bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is(s => !string.IsNullOrEmpty(s)), It.IsAny(), It.IsAny())).ReturnsAsync(() => faker.MakeLazy(50, () => CreateMockedBingReverseImageSearchResult(faker))); + bingMock.Setup(x => x.ReverseImageSearchAsync(It.Is(s => s == "https://example.com/error"), It.IsAny(), It.IsAny())).ThrowsAsync(new BingException("Error message.")); + + return bingMock.Object; + } + + public static IBingReverseImageSearchResult CreateMockedBingReverseImageSearchResult(Faker? faker = null) + { + faker ??= new Faker(); + var resultMock = new Mock(); + + 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) + { + faker ??= new Faker(); + var yandexMock = new Mock(); + + yandexMock.Setup(x => x.OcrAsync(It.Is(s => s == string.Empty))).ReturnsAsync(() => string.Empty); + yandexMock.Setup(x => x.OcrAsync(It.Is(s => !string.IsNullOrEmpty(s)))).ReturnsAsync(() => faker.Lorem.Sentence()); + yandexMock.Setup(x => x.OcrAsync(It.Is(s => s == "https://example.com/error"))).ThrowsAsync(new YandexException("Error message.")); + + yandexMock.Setup(x => x.ReverseImageSearchAsync(It.Is(s => s == string.Empty), It.IsAny())).ReturnsAsync(Enumerable.Empty); + yandexMock.Setup(x => x.ReverseImageSearchAsync(It.Is(s => !string.IsNullOrEmpty(s)), It.IsAny())).ReturnsAsync(() => faker.MakeLazy(50, () => CreateMockedYandexReverseImageSearchResult(faker))); + yandexMock.Setup(x => x.ReverseImageSearchAsync(It.Is(s => s == "https://example.com/error"), It.IsAny())).ThrowsAsync(new YandexException("Error message.")); + + return yandexMock.Object; + } + + public static IYandexReverseImageSearchResult CreateMockedYandexReverseImageSearchResult(Faker? faker = null) + { + faker ??= new Faker(); + var resultMock = new Mock(); + + 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; + } + + public static IUrbanDictionary CreateMockedUrbanDictionaryApi(Faker? faker = null) + { + faker ??= new Faker(); + var mock = new Mock(); + + mock.Setup(u => u.GetDefinitionsAsync(It.IsNotNull())).ReturnsAsync(() => faker.MakeLazy(10, CreateFakeUrbanDefinition).ToList()); + mock.Setup(u => u.GetDefinitionsAsync(It.Is(s => s == null))).ReturnsAsync(Array.Empty()); + mock.Setup(u => u.GetRandomDefinitionsAsync()).ReturnsAsync(() => faker.MakeLazy(10, CreateFakeUrbanDefinition).ToList()); + mock.Setup(u => u.GetDefinitionAsync(It.IsAny())).ReturnsAsync(CreateFakeUrbanDefinition); + mock.Setup(u => u.GetWordsOfTheDayAsync()).ReturnsAsync(() => faker.MakeLazy(10, CreateFakeUrbanDefinition).ToList()); + mock.Setup(u => u.GetAutocompleteResultsAsync(It.IsAny())).ReturnsAsync(AutoFaker.Generate(20)); + mock.Setup(u => u.GetAutocompleteResultsExtraAsync(It.IsAny())).ReturnsAsync(AutoFaker.Generate(20)); + + return mock.Object; + } + + public static UrbanDefinition CreateFakeUrbanDefinition() + { + return new AutoFaker() + .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()) + .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(); + } + + public static IOptionsSnapshot CreateMockedFergunOptions() + { + var mock = new Mock>(); + var faker = new Faker() + .RuleFor(x => x.PaginatorTimeout, f => f.Date.Timespan()) + .RuleFor(x => x.SelectionTimeout, f => f.Date.Timespan()) + .RuleFor(x => x.PaginatorEmotes, f => new Dictionary + { + { 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