From 7cb15f5278c02792cb20627776cbfdd5ed420345 Mon Sep 17 00:00:00 2001 From: Kwoth Date: Sat, 16 Apr 2022 12:44:46 +0000 Subject: [PATCH] Medusa System Added Read about the medusa system [here](https://nadekobot.readthedocs.io/en/latest/medusa/creating-a-medusa/) --- .dockerignore | 2 + .gitignore | 6 + CHANGELOG.md | 4 + Dockerfile | 1 + NadekoBot.sln | 10 + docs/medusa/creating-a-medusa.md | 251 +++++ docs/medusa/getting-started.md | 31 + docs/medusa/snek-lifecycle.md | 19 + docs/placeholders.md | 10 +- mkdocs.yml | 5 +- .../Attributes/FilterAttribute.cs | 10 + src/Nadeko.Medusa/Attributes/cmdAttribute.cs | 37 + .../Attributes/injectAttribute.cs | 10 + .../Attributes/leftoverAttribute.cs | 10 + src/Nadeko.Medusa/Attributes/prioAttribute.cs | 20 + src/Nadeko.Medusa/Attributes/svcAttribute.cs | 23 + src/Nadeko.Medusa/Context/AnyContext.cs | 47 + src/Nadeko.Medusa/Context/DmContext.cs | 11 + src/Nadeko.Medusa/Context/GuildContext.cs | 12 + src/Nadeko.Medusa/EmbedColor.cs | 8 + .../Extensions/EmbedBuilderExtensions.cs | 14 + .../Extensions/MedusaExtensions.cs | 66 ++ .../Common => Nadeko.Medusa}/IEmbedBuilder.cs | 16 +- src/Nadeko.Medusa/Nadeko.Medusa.csproj | 21 + src/Nadeko.Medusa/ParamParser/ParamParser.cs | 16 + src/Nadeko.Medusa/ParamParser/ParseResult.cs | 48 + src/Nadeko.Medusa/README.md | 1 + src/Nadeko.Medusa/Snek.cs | 143 +++ src/Nadeko.Medusa/Strings/CommandStrings.cs | 24 + src/Nadeko.Medusa/Strings/IMedusaStrings.cs | 15 + .../Strings/IMedusaStringsProvider.cs | 28 + .../Strings/LocalMedusaStringsProvider.cs | 40 + src/Nadeko.Medusa/Strings/MedusaStrings.cs | 79 ++ src/Nadeko.Medusa/Strings/StringsLoader.cs | 137 +++ src/Nadeko.Medusa/pack-and-push.ps1 | 2 + src/NadekoBot/Bot.cs | 13 +- .../Common/Medusa/Adapters/BehaviorAdapter.cs | 76 ++ .../Medusa/Adapters/ContextAdapterFactory.cs | 7 + .../Medusa/Adapters/DmContextAdapter.cs | 48 + .../Medusa/Adapters/GuildContextAdapter.cs | 51 + .../Medusa/Adapters/ParamParserAdapter.cs | 32 + .../Common/Medusa/CommandContextType.cs | 27 + .../Medusa/Config/IMedusaConfigService.cs | 8 + .../Common/Medusa/Config/MedusaConfig.cs | 19 + .../Medusa/Config/MedusaConfigService.cs | 43 + .../Common/Medusa/IMedusaLoaderService.cs | 23 + .../Medusa/MedusaAssemblyLoadContext.cs | 36 + .../Common/Medusa/MedusaLoaderService.cs | 880 ++++++++++++++++++ .../Common/Medusa/MedusaServiceProvider.cs | 24 + .../Common/Medusa/Models/ParamData.cs | 9 + .../Common/Medusa/Models/ResolvedMedusa.cs | 14 + .../Common/Medusa/Models/SnekCommandData.cs | 41 + .../Common/Medusa/Models/SnekData.cs | 11 + .../Common/ModuleBehaviors/IEarlyBehavior.cs | 10 - .../Common/ModuleBehaviors/IExecNoCommand.cs | 19 + .../Common/ModuleBehaviors/IExecOnMessage.cs | 21 + .../ModuleBehaviors/IExecPostCommand.cs | 22 + .../Common/ModuleBehaviors/IExecPreCommand.cs | 25 + .../ModuleBehaviors/IInputTransformer.cs | 17 +- .../Common/ModuleBehaviors/ILateBlocker.cs | 8 - .../Common/ModuleBehaviors/ILateExecutor.cs | 9 - .../Common/NoPublicBotPrecondition.cs | 9 + .../Common/TypeReaders/NadekoTypeReader.cs | 1 + src/NadekoBot/Directory.Build.props | 7 + src/NadekoBot/GlobalUsings.cs | 1 + .../Administration/ImageOnlyChannelService.cs | 4 +- .../DiscordPermOverrideService.cs | 4 +- .../Administration/Self/SelfCommands.cs | 10 +- .../Administration/Self/SelfService.cs | 4 +- .../ServerLog/ServerLogCommandService.cs | 8 +- .../UserPunish/UserPunishCommands.cs | 6 +- .../NadekoExpressionsService.cs | 4 +- .../Gambling/Events/GameStatusEvent.cs | 2 +- .../Modules/Gambling/Events/ReactionEvent.cs | 2 +- .../Gambling/PlantPick/PlantPickService.cs | 9 +- .../Games/ChatterBot/ChatterbotService.cs | 4 +- .../Modules/Games/Hangman/HangmanService.cs | 4 +- .../Modules/Games/Polls/PollService.cs | 4 +- src/NadekoBot/Modules/Help/Help.cs | 10 +- src/NadekoBot/Modules/Help/HelpService.cs | 22 +- src/NadekoBot/Modules/Medusae/Medusa.cs | 200 ++++ .../Permissions/Blacklist/BlacklistService.cs | 4 +- .../CommandCooldown/CmdCdService.cs | 4 +- .../Permissions/Filter/FilterService.cs | 6 +- .../GlobalPermissionService.cs | 4 +- .../Modules/Permissions/PermissionsService.cs | 4 +- .../Searches/Translate/TranslateService.cs | 4 +- .../Utility/CommandMap/CommandMapService.cs | 4 +- .../Modules/Utility/ConfigCommands.cs | 2 +- src/NadekoBot/Modules/Xp/XpService.cs | 6 +- src/NadekoBot/NadekoBot.csproj | 22 +- src/NadekoBot/Services/CommandHandler.cs | 50 +- src/NadekoBot/Services/IBehaviourExecutor.cs | 19 +- src/NadekoBot/Services/ICustomBehavior.cs | 13 + .../Services/Impl/BehaviorExecutor.cs | 297 +++++- src/NadekoBot/Services/Impl/StatsService.cs | 2 +- src/NadekoBot/_Extensions/Extensions.cs | 47 +- .../ServiceCollectionExtensions.cs | 24 +- src/NadekoBot/data/aliases.yml | 14 +- src/NadekoBot/data/medusae/medusa.yml | 5 + .../data/strings/commands/commands.en-US.yml | 31 + .../strings/responses/responses.en-US.json | 18 +- src/ayu/Ayu.Discord.Voice/VoiceClient.cs | 1 - 103 files changed, 3363 insertions(+), 203 deletions(-) create mode 100644 docs/medusa/creating-a-medusa.md create mode 100644 docs/medusa/getting-started.md create mode 100644 docs/medusa/snek-lifecycle.md create mode 100644 src/Nadeko.Medusa/Attributes/FilterAttribute.cs create mode 100644 src/Nadeko.Medusa/Attributes/cmdAttribute.cs create mode 100644 src/Nadeko.Medusa/Attributes/injectAttribute.cs create mode 100644 src/Nadeko.Medusa/Attributes/leftoverAttribute.cs create mode 100644 src/Nadeko.Medusa/Attributes/prioAttribute.cs create mode 100644 src/Nadeko.Medusa/Attributes/svcAttribute.cs create mode 100644 src/Nadeko.Medusa/Context/AnyContext.cs create mode 100644 src/Nadeko.Medusa/Context/DmContext.cs create mode 100644 src/Nadeko.Medusa/Context/GuildContext.cs create mode 100644 src/Nadeko.Medusa/EmbedColor.cs create mode 100644 src/Nadeko.Medusa/Extensions/EmbedBuilderExtensions.cs create mode 100644 src/Nadeko.Medusa/Extensions/MedusaExtensions.cs rename src/{NadekoBot/Common => Nadeko.Medusa}/IEmbedBuilder.cs (57%) create mode 100644 src/Nadeko.Medusa/Nadeko.Medusa.csproj create mode 100644 src/Nadeko.Medusa/ParamParser/ParamParser.cs create mode 100644 src/Nadeko.Medusa/ParamParser/ParseResult.cs create mode 100644 src/Nadeko.Medusa/README.md create mode 100644 src/Nadeko.Medusa/Snek.cs create mode 100644 src/Nadeko.Medusa/Strings/CommandStrings.cs create mode 100644 src/Nadeko.Medusa/Strings/IMedusaStrings.cs create mode 100644 src/Nadeko.Medusa/Strings/IMedusaStringsProvider.cs create mode 100644 src/Nadeko.Medusa/Strings/LocalMedusaStringsProvider.cs create mode 100644 src/Nadeko.Medusa/Strings/MedusaStrings.cs create mode 100644 src/Nadeko.Medusa/Strings/StringsLoader.cs create mode 100644 src/Nadeko.Medusa/pack-and-push.ps1 create mode 100644 src/NadekoBot/Common/Medusa/Adapters/BehaviorAdapter.cs create mode 100644 src/NadekoBot/Common/Medusa/Adapters/ContextAdapterFactory.cs create mode 100644 src/NadekoBot/Common/Medusa/Adapters/DmContextAdapter.cs create mode 100644 src/NadekoBot/Common/Medusa/Adapters/GuildContextAdapter.cs create mode 100644 src/NadekoBot/Common/Medusa/Adapters/ParamParserAdapter.cs create mode 100644 src/NadekoBot/Common/Medusa/CommandContextType.cs create mode 100644 src/NadekoBot/Common/Medusa/Config/IMedusaConfigService.cs create mode 100644 src/NadekoBot/Common/Medusa/Config/MedusaConfig.cs create mode 100644 src/NadekoBot/Common/Medusa/Config/MedusaConfigService.cs create mode 100644 src/NadekoBot/Common/Medusa/IMedusaLoaderService.cs create mode 100644 src/NadekoBot/Common/Medusa/MedusaAssemblyLoadContext.cs create mode 100644 src/NadekoBot/Common/Medusa/MedusaLoaderService.cs create mode 100644 src/NadekoBot/Common/Medusa/MedusaServiceProvider.cs create mode 100644 src/NadekoBot/Common/Medusa/Models/ParamData.cs create mode 100644 src/NadekoBot/Common/Medusa/Models/ResolvedMedusa.cs create mode 100644 src/NadekoBot/Common/Medusa/Models/SnekCommandData.cs create mode 100644 src/NadekoBot/Common/Medusa/Models/SnekData.cs delete mode 100644 src/NadekoBot/Common/ModuleBehaviors/IEarlyBehavior.cs create mode 100644 src/NadekoBot/Common/ModuleBehaviors/IExecNoCommand.cs create mode 100644 src/NadekoBot/Common/ModuleBehaviors/IExecOnMessage.cs create mode 100644 src/NadekoBot/Common/ModuleBehaviors/IExecPostCommand.cs create mode 100644 src/NadekoBot/Common/ModuleBehaviors/IExecPreCommand.cs delete mode 100644 src/NadekoBot/Common/ModuleBehaviors/ILateBlocker.cs delete mode 100644 src/NadekoBot/Common/ModuleBehaviors/ILateExecutor.cs create mode 100644 src/NadekoBot/Directory.Build.props create mode 100644 src/NadekoBot/Modules/Medusae/Medusa.cs create mode 100644 src/NadekoBot/Services/ICustomBehavior.cs create mode 100644 src/NadekoBot/data/medusae/medusa.yml diff --git a/.dockerignore b/.dockerignore index 3574dd55f..2df92d23a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,8 @@ # Ignore all files * +# Use Nadeko.Medusa project +!src/Nadeko.Medusa/** # Use NadekoBot project !src/NadekoBot/** # Use NadekoBot.Coordinator project diff --git a/.gitignore b/.gitignore index af3d75808..c41ad2b87 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ #Manually added files +# medusa stuff +!src/NadekoBot/data/medusae/medusa.yml +src/NadekoBot/data/medusae/** + +# other + command_errors*.txt output/ src/NadekoBot/output diff --git a/CHANGELOG.md b/CHANGELOG.md index c892dbb3c..eebad908c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Experimental changelog. Mostly based on [keepachangelog](https://keepachangelog. - NadekoBot now supports mysql, postgresql and sqlite - To change the db nadeko will use, simply change the `db type` in `creds.yml` - There is no migration code right now, which means that if you want to switch to another system you'll either have to manually export/import your database or start fresh +- Medusa system + - A massive new feature which allows developers to create custom modules/plugins/cogs + - They can be load/unloaded/updated at runtime without restarting the bot ### Changed @@ -18,6 +21,7 @@ Experimental changelog. Mostly based on [keepachangelog](https://keepachangelog. - Removed discriminators - Current discriminators which are greater than 1 are appended to clubnames to avoid duplicates, you can rename your club with `.clubrename` to remove it - Most of the clubs with #1 discriminator no longer have it (For example MyClub#1 will now just be MyClub) +- [dev] A lot of refactoring and slight functionality changes within Nadeko's behavior system and command handler which were required in order to support the medusa system ### Removed diff --git a/Dockerfile b/Dockerfile index 3df92a29c..62371a24b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build WORKDIR /source +COPY src/Nadeko.Medusa/*.csproj src/Nadeko.Medusa/ COPY src/NadekoBot/*.csproj src/NadekoBot/ COPY src/NadekoBot.Coordinator/*.csproj src/NadekoBot.Coordinator/ COPY src/NadekoBot.Generators/*.csproj src/NadekoBot.Generators/ diff --git a/NadekoBot.sln b/NadekoBot.sln index 815e8a0bb..15d30e71c 100644 --- a/NadekoBot.sln +++ b/NadekoBot.sln @@ -11,6 +11,7 @@ ProjectSection(SolutionItems) = preProject LICENSE.md = LICENSE.md README.md = README.md .gitlab-ci.yml = .gitlab-ci.yml + Dockerfile = Dockerfile EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NadekoBot", "src\NadekoBot\NadekoBot.csproj", "{45EC1473-C678-4857-A544-07DFE0D0B478}" @@ -27,6 +28,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NadekoBot.Generators", "src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NadekoBot.VotesApi", "src\NadekoBot.VotesApi\NadekoBot.VotesApi.csproj", "{3BC82CFE-BEE7-451F-986B-17EDD1570C4F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nadeko.Medusa", "src\Nadeko.Medusa\Nadeko.Medusa.csproj", "{E685977E-31A4-46F4-A5D7-4E3E39E82E43}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -70,6 +73,12 @@ Global {3BC82CFE-BEE7-451F-986B-17EDD1570C4F}.GlobalNadeko|Any CPU.Build.0 = Debug|Any CPU {3BC82CFE-BEE7-451F-986B-17EDD1570C4F}.Release|Any CPU.ActiveCfg = Release|Any CPU {3BC82CFE-BEE7-451F-986B-17EDD1570C4F}.Release|Any CPU.Build.0 = Release|Any CPU + {E685977E-31A4-46F4-A5D7-4E3E39E82E43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E685977E-31A4-46F4-A5D7-4E3E39E82E43}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E685977E-31A4-46F4-A5D7-4E3E39E82E43}.GlobalNadeko|Any CPU.ActiveCfg = Debug|Any CPU + {E685977E-31A4-46F4-A5D7-4E3E39E82E43}.GlobalNadeko|Any CPU.Build.0 = Debug|Any CPU + {E685977E-31A4-46F4-A5D7-4E3E39E82E43}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E685977E-31A4-46F4-A5D7-4E3E39E82E43}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -82,6 +91,7 @@ Global {AE9B7F8C-81D7-4401-83A3-643B38258374} = {04929013-5BAB-42B0-B9B2-8F2BB8F16AF2} {3BC3BDF8-1A0B-45EB-AB2B-C0891D4D37B8} = {04929013-5BAB-42B0-B9B2-8F2BB8F16AF2} {3BC82CFE-BEE7-451F-986B-17EDD1570C4F} = {04929013-5BAB-42B0-B9B2-8F2BB8F16AF2} + {E685977E-31A4-46F4-A5D7-4E3E39E82E43} = {04929013-5BAB-42B0-B9B2-8F2BB8F16AF2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5F3F555C-855F-4BE8-B526-D062D3E8ACA4} diff --git a/docs/medusa/creating-a-medusa.md b/docs/medusa/creating-a-medusa.md new file mode 100644 index 000000000..8a67e5ad2 --- /dev/null +++ b/docs/medusa/creating-a-medusa.md @@ -0,0 +1,251 @@ +# Creating A Medusa + +## Theory + +### Introduction + +Medusa system allows you to write independent medusae (known as "modules", "cogs" or "plugins" in other software) which you can then load, unload and update at will without restarting the bot. + +The system itself borrows some design from the current way Nadeko's Modules are written but mostly from never-released `Ayu.Commands` system which was designed to be used for a full Nadeko v3 rewrite. + +The medusa base classes used for development are open source [here](https://gitlab.com/Kwoth/nadekobot/-/tree/v4/src/Nadeko.Medusa) in case you need reference, as there is no generated documentation at the moment. + +### Term list + +#### Medusa + +- The project itself which compiles to a single `.dll` (and some optional auxiliary files), it can contain multiple [Sneks](#snek), [Services](#service), and [ParamParsers](#param-parser) + +#### Snek + +- A class which will be added as a single Module to NadekoBot on load. It also acts as a [lifecycle handler](snek-lifecycle.md) and as a singleton service with the support for initialize and cleanup. +- It can contain a Snek (called SubSnek) but only 1 level of nesting is supported (you can only have a snek contain a subsnek, but a subsnek can't contain any other sneks) +- Sneks can have their own prefix + - For example if you set this to 'test' then a command called 'cmd' will have to be invoked by using `.test cmd` instead of `.cmd` + +#### Snek Command + +- Acts as a normal command +- Has context injected as a first argument which controls where the command can be executed + - `AnyContext` the command can be executed in both DMs and Servers + - `GuildContext` the command can only be executed in Servers + - `DmContext` the command can only be executed in DMs +- Support the usual features such as default values, leftover, params, etc. +- It also supports dependency injection via `[inject]` attribute. These dependencies must come after the context and before any input parameters +- Supports `ValueTask`, `Task`, `Task` and `void` return types + +#### Param Parser + +- Allows custom parsing of command arguments into your own types. +- Overriding existing parsers (for example for IGuildUser, etc...) can cause issues. + +#### Service + +- Usually not needed. +- They are marked with a `[svc]` attribute, and offer a way to inject dependencies to different parts of your medusa. +- Transient and Singleton lifetimes are supported. + +### Localization + +Response and command strings can be kept in one of three different places based on whether you plan to allow support for localization + +option 1) `res.yml` and `cmds.yml` + +If you don't plan on having your app localized, but you just *may* in the future, you should keep your strings in the `res.yml` and `cmds.yml` file the root folder of your project, and they will be automatically copied to the output whenever you build your medusa. + +##### Example project folder structure: + - uwu/ + - uwu.csproj + - uwu.cs + - res.yml + - cmds.yml + +##### Example output folder structure: + - medusae/uwu/ + - uwu.dll + - res.yml + - cmds.yml + +option 2) `strings` folder + +If you plan on having your app localized (or want to allow your consumers to easily add languages themselves), you should keep your response strings in the `strings/res/en-us.yml` and your command strings in `strings/cmds/en-us.yml` file. This will be your base file, and from there you can make support for additional languages, for example `strings/res/ru-ru.yml` and `strings/cmds/ru-ru.yml` + +##### Example project folder structure: + - uwu/ + - uwu.csproj + - uwu.cs + - strings/ + - res/ + - en-us.yml + - ru-ru.yml + - cmds/ + - en-us.yml + - ru-ru.yml + +##### Example output folder structure: + - medusae/uwu/ + - uwu.dll + - strings/ + - res/ + - en-us.yml + - ru-ru.yml + - cmds/ + - en-us.yml + - ru-ru.yml + +option 3) In the code + +If you don't want any auxiliary files, and you don't want to bother making new .yml files to keep your strings in, you can specify the command strings directly in the `[cmd]` attribute itself, and use non-localized methods for message sending in your commands. + +If you update your response strings .yml file(s) while the medusa is loaded and running, running `.stringsreload` will reload the responses without the need to reload the medusa or restart the bot. + +#### Config + +- Medusa config is kept in `medusae/medusa.yml` file +- At the moment this config only keeps track of which medusae are currently loaded (they will also be always loaded at startup) +- If a medusa is causing issues and you're unable to unload it, you can remove it from the `loaded:` list in this config file and restart the bot. It won't be loaded next time the bot is started up + +#### Unloadability issues + +To make sure your medusa can be properly unloaded/reloaded you must: + +- Make sure that none of your types and objects are referenced by the Bot or Bot's services after the DisposeAsync is called on your Snek instances. + +- Make sure that all of your commands execute quickly and don't have any long running tasks, as they will hold a reference to a type from your assembly + +- If you are still having issues, you can always run `.meunload` followed by a bot restart, or if you want to find what is causing the medusa unloadability issues, you can check the [microsoft's assembly unloadability debugging guide](https://docs.microsoft.com/en-us/dotnet/standard/assembly/unloadability) + +## Practice + +This section will guide you through how to create a simple custom medusa. You can find the entirety of this code hosted [here](https://gitlab.com/nadeko/example_medusa) + +#### Prerequisite +- [.net6 sdk](https://dotnet.microsoft.com/en-us/download) installed +- Optional: use [vscode](https://code.visualstudio.com/download) to write code + +#### Guide + + +- Open your favorite terminal and navigate to a folder where you will keep your project . + +- Create a new folder + - `mkdir example_medusa` +- Create a new .net class library + - `dotnet new classlib` +- Open the current folder with your favorite editor/IDE. In this case we'll use VsCode + - `code .` +- Remove the `Class1.cs` file +- Replace the contents of the `.csproj` file with the following contents +```xml + + + net6.0 + + + enable + + + preview + true + + + true + + + + + + + all + + + + + + + + + PreserveNewest + + + + +``` +- Create a `MySnek.cs` file and add the following contents +```cs +using Nadeko.Snake; +using NadekoBot; +using Discord; + +public sealed class MySnek : Snek +{ + [cmd] + public async Task Hello(AnyContext ctx) + { + await ctx.Channel.SendMessageAsync($"Hello everyone!"); + } + + [cmd] + public async Task Hello(AnyContext ctx, IUser target) + { + await ctx.ConfirmLocalizedAsync("hello", target); + } +} +``` +- Create `res.yml` and `cmds.yml` files with the following contents +`res.yml` +```yml +medusa.description: "This is my medusa's description" +hello: "Hello {0}, from res.yml!" +``` + +`cmds.yml` +```yml +hello: + desc: "This is a basic hello command" + args: + - "" + - "@Someone" +``` + +- Add `NuGet.Config` file which will let you use the base Nadeko.Medusa package. This file should always look like this and you shouldn't change it + +```xml + + + + + + +``` + +### Build it + +- Build your Medusa into a dll that Nadeko can load. In your terminal, type: + - `dotnet publish -o bin/medusae/example_medusa /p:DebugType=embedded` + +- Done. You can now try it out in action. + +### Try it out + +- Copy the `bin/medusae/example_medusa` folder into your NadekoBot's `data/medusae/` folder. (Nadeko version 4.1.0+) + +- Load it with `.meload example_medusa` + +- In the channel your bot can see, run the following commands to try it out + - `.hello` and + - `.hello @` + +- Check its information with + - `.meinfo example_medusa` + +- Unload it + - `.meunload example_medusa` + +- Congrats! You've just made your first medusa! \ No newline at end of file diff --git a/docs/medusa/getting-started.md b/docs/medusa/getting-started.md new file mode 100644 index 000000000..0f23e893f --- /dev/null +++ b/docs/medusa/getting-started.md @@ -0,0 +1,31 @@ +## Getting Started + +### What is the Medusa system? + +- It is a dynamic module/plugin/cog system for NadekoBot introduced in **NadekoBot 4.1.0** + +- Allows developers to add custom functionality to Nadeko without modifying the original code + +- Allows for those custom features to be updated during bot runtime (if properly written), without the need for bot restart. + +- They are added to `data/medusae` folder and are loaded, unloaded and handled through discord commands. + - `.meload` Loads the specified medusa (see `.h .meload`) + - `.meunload` Unloads the specified medusa (see `.h .meunload`) + - `.meinfo` Checks medusae information (see `.h .meinfo`) + - `.melist` Lists the available medusae (see `.h .melist`) + +### How to make one? + +Medusae are written in [C#](https://docs.microsoft.com/en-us/dotnet/csharp/tour-of-csharp/) programming language, so you will need at least low-intermediate knowledge of it in order to make a useful Medusa. + +Follow the [creating a medusa guide](creating-a-medusa.md) + +### Where to get medusae other people made? + +⚠ *It is EXTREMELY, and I repeat **EXTREMELY** dangerous to run medusae of strangers or people you don't FULLY trust.* ⚠ +⚠ *It can not only lead to your bot being stolen, but it also puts your entire computer and personal files in jeopardy.* ⚠ + +**It is strongly recommended to run only the medusae you yourself wrote, and only on a hosted VPS or dedicated server which ONLY hosts your bot, to minimize the potential damage caused by bad actors.** + +No easy way at the moment, except asking in the `#dev-and-modding` chat in [#NadekoLog server][https://discord.nadeko.bot] + diff --git a/docs/medusa/snek-lifecycle.md b/docs/medusa/snek-lifecycle.md new file mode 100644 index 000000000..476c63a3d --- /dev/null +++ b/docs/medusa/snek-lifecycle.md @@ -0,0 +1,19 @@ +# Snek Lifecycle + +*You can override several methods to hook into command handler's lifecycle. +These methods start with `Exec*`* + + +- `ExecOnMessageAsync` runs first right after any message was received +- `ExecInputTransformAsync` runs after ExecOnMessageAsync and allows you to transform the message content before the bot looks for the matching command +- `ExecPreCommandAsync` runs after a command was found but not executed, allowing you to potentially prevent command execution +- `ExecPostCommandAsync` runs if the command was successfully executed +- `ExecOnNoCommandAsync` runs instead of ExecPostCommandAsync if no command was found for a message + + +*Besides that, sneks have 2 methods with which you can initialize and cleanup your snek* + + +- `InitializeAsync` Runs when the medusa which contains this snek is being loaded +- `DisposeAsync` Runs when the medusa which contains this snek is being unloaded + diff --git a/docs/placeholders.md b/docs/placeholders.md index 8ece1768e..57e3a9527 100644 --- a/docs/placeholders.md +++ b/docs/placeholders.md @@ -28,6 +28,8 @@ Some features have their own specific placeholders which are noted in that featu - `%server.id%` - Server ID - `%server.name%` - Server name - `%server.members%` - Member count +- `%server.boosters%` - Number of users boosting the server +- `%server.boost_level%` - Server Boost level - `%server.time%` - Server time (requires `.timezone` to be set) ### Channel placeholders @@ -79,11 +81,9 @@ Some features have their own specific placeholders which are noted in that featu ### Music placeholders -!!! Note - These placeholders will only work in rotating playing statuses. - -- `%music.queued%` - Amount of songs currently queued -- `%music.playing%` - Current song name +- `%music.queued%` - Number of songs currently queued +- `%music.playing%` - Current song name (random playing song if bot is playing on multiple servers) +- `%music.servers%` - Number of servers currently listening to music ### Miscellaneous placeholders diff --git a/mkdocs.yml b/mkdocs.yml index 29ef64f80..163039b32 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -86,7 +86,10 @@ nav: - Custom Reactions: custom-reactions.md - Placeholders: placeholders.md - Config: config-guide.md - - Bot Config: bce-guide.md + - Medusa System: + - medusa/getting-started.md + - medusa/creating-a-medusa.md + - medusa/snek-lifecycle.md - Contribution Guide: contribution-guide.md - Donate: donate.md - License: license.md diff --git a/src/Nadeko.Medusa/Attributes/FilterAttribute.cs b/src/Nadeko.Medusa/Attributes/FilterAttribute.cs new file mode 100644 index 000000000..574bb32d6 --- /dev/null +++ b/src/Nadeko.Medusa/Attributes/FilterAttribute.cs @@ -0,0 +1,10 @@ +namespace Nadeko.Snake; + +/// +/// Overridden to implement custom checks which commands have to pass in order to be executed. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)] +public abstract class FilterAttribute : Attribute +{ + public abstract ValueTask CheckAsync(AnyContext ctx); +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/Attributes/cmdAttribute.cs b/src/Nadeko.Medusa/Attributes/cmdAttribute.cs new file mode 100644 index 000000000..109f185f3 --- /dev/null +++ b/src/Nadeko.Medusa/Attributes/cmdAttribute.cs @@ -0,0 +1,37 @@ +namespace Nadeko.Snake; + +/// +/// Marks a method as a snek command +/// +[AttributeUsage(AttributeTargets.Method)] +public class cmdAttribute : Attribute +{ + /// + /// Command description. Avoid using, as cmds.yml is preferred + /// + public string? desc { get; set; } + + /// + /// Command args examples. Avoid using, as cmds.yml is preferred + /// + public string[]? args { get; set; } + + /// + /// Command aliases + /// + public string[] Aliases { get; } + + public cmdAttribute() + { + desc = null; + args = null; + Aliases = Array.Empty(); + } + + public cmdAttribute(params string[] aliases) + { + Aliases = aliases; + desc = null; + args = null; + } +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/Attributes/injectAttribute.cs b/src/Nadeko.Medusa/Attributes/injectAttribute.cs new file mode 100644 index 000000000..29cebddd6 --- /dev/null +++ b/src/Nadeko.Medusa/Attributes/injectAttribute.cs @@ -0,0 +1,10 @@ +namespace Nadeko.Snake; + +/// +/// Marks services in command arguments for injection. +/// The injected services must come after the context and before any input parameters. +/// +public class injectAttribute : Attribute +{ + +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/Attributes/leftoverAttribute.cs b/src/Nadeko.Medusa/Attributes/leftoverAttribute.cs new file mode 100644 index 000000000..d38968b12 --- /dev/null +++ b/src/Nadeko.Medusa/Attributes/leftoverAttribute.cs @@ -0,0 +1,10 @@ +namespace Nadeko.Snake; + +/// +/// Marks the parameter to take +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class leftoverAttribute : Attribute +{ + +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/Attributes/prioAttribute.cs b/src/Nadeko.Medusa/Attributes/prioAttribute.cs new file mode 100644 index 000000000..40804a8b7 --- /dev/null +++ b/src/Nadeko.Medusa/Attributes/prioAttribute.cs @@ -0,0 +1,20 @@ +namespace Nadeko.Snake; + +/// +/// Sets the priority of a command in case there are multiple commands with the same name but different parameters. +/// Higher value means higher priority. +/// +[AttributeUsage(AttributeTargets.Method)] +public class prioAttribute : Attribute +{ + public int Priority { get; } + + /// + /// Snek command priority + /// + /// Priority value. The higher the value, the higher the priority + public prioAttribute(int priority) + { + Priority = priority; + } +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/Attributes/svcAttribute.cs b/src/Nadeko.Medusa/Attributes/svcAttribute.cs new file mode 100644 index 000000000..03e8e7c5a --- /dev/null +++ b/src/Nadeko.Medusa/Attributes/svcAttribute.cs @@ -0,0 +1,23 @@ +namespace Nadeko.Snake; + +/// +/// Marks the class as a service which can be used within the same Medusa +/// +[AttributeUsage(AttributeTargets.Class)] +public class svcAttribute : Attribute +{ + public Lifetime Lifetime { get; } + public svcAttribute(Lifetime lifetime) + { + Lifetime = lifetime; + } +} + +/// +/// Lifetime for +/// +public enum Lifetime +{ + Singleton, + Transient +} diff --git a/src/Nadeko.Medusa/Context/AnyContext.cs b/src/Nadeko.Medusa/Context/AnyContext.cs new file mode 100644 index 000000000..964001a4b --- /dev/null +++ b/src/Nadeko.Medusa/Context/AnyContext.cs @@ -0,0 +1,47 @@ +using Discord; +using NadekoBot; + +namespace Nadeko.Snake; + +/// +/// Commands which take this class as a first parameter can be executed in both DMs and Servers +/// +public abstract class AnyContext +{ + /// + /// Channel from the which the command is invoked + /// + public abstract IMessageChannel Channel { get; } + + /// + /// Message which triggered the command + /// + public abstract IUserMessage Message { get; } + + /// + /// The user who invoked the command + /// + public abstract IUser User { get; } + + /// + /// Provides access to strings used by this medusa + /// + public abstract IMedusaStrings Strings { get; } + + /// + /// Gets a formatted localized string using a key and arguments which should be formatted in + /// + /// The key of the string as specified in localization files + /// Arguments (if any) to format in + /// A formatted localized string + public abstract string GetText(string key, object[]? args = null); + + /// + /// Creates a context-aware instance + /// (future feature for guild-based embed colors) + /// Any code dealing with embeds should use it for future-proofness + /// instead of manually creating embedbuilder instances + /// + /// A context-aware instance + public abstract IEmbedBuilder Embed(); +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/Context/DmContext.cs b/src/Nadeko.Medusa/Context/DmContext.cs new file mode 100644 index 000000000..35f9dd723 --- /dev/null +++ b/src/Nadeko.Medusa/Context/DmContext.cs @@ -0,0 +1,11 @@ +using Discord; + +namespace Nadeko.Snake; + +/// +/// Commands which take this type as the first parameter can only be executed in DMs +/// +public abstract class DmContext : AnyContext +{ + public abstract override IDMChannel Channel { get; } +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/Context/GuildContext.cs b/src/Nadeko.Medusa/Context/GuildContext.cs new file mode 100644 index 000000000..3fcd68812 --- /dev/null +++ b/src/Nadeko.Medusa/Context/GuildContext.cs @@ -0,0 +1,12 @@ +using Discord; + +namespace Nadeko.Snake; + +/// +/// Commands which take this type as a first parameter can only be executed in a server +/// +public abstract class GuildContext : AnyContext +{ + public abstract override ITextChannel Channel { get; } + public abstract IGuild Guild { get; } +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/EmbedColor.cs b/src/Nadeko.Medusa/EmbedColor.cs new file mode 100644 index 000000000..48e52b945 --- /dev/null +++ b/src/Nadeko.Medusa/EmbedColor.cs @@ -0,0 +1,8 @@ +namespace NadekoBot; + +public enum EmbedColor +{ + Ok, + Pending, + Error +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/Extensions/EmbedBuilderExtensions.cs b/src/Nadeko.Medusa/Extensions/EmbedBuilderExtensions.cs new file mode 100644 index 000000000..6506308c4 --- /dev/null +++ b/src/Nadeko.Medusa/Extensions/EmbedBuilderExtensions.cs @@ -0,0 +1,14 @@ +namespace NadekoBot; + +public static class EmbedBuilderExtensions +{ + public static IEmbedBuilder WithOkColor(this IEmbedBuilder eb) + => eb.WithColor(EmbedColor.Ok); + + public static IEmbedBuilder WithPendingColor(this IEmbedBuilder eb) + => eb.WithColor(EmbedColor.Pending); + + public static IEmbedBuilder WithErrorColor(this IEmbedBuilder eb) + => eb.WithColor(EmbedColor.Error); + +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/Extensions/MedusaExtensions.cs b/src/Nadeko.Medusa/Extensions/MedusaExtensions.cs new file mode 100644 index 000000000..2c10bad51 --- /dev/null +++ b/src/Nadeko.Medusa/Extensions/MedusaExtensions.cs @@ -0,0 +1,66 @@ +using Discord; +using Nadeko.Snake; + +namespace NadekoBot; + +public static class MedusaExtensions +{ + public static Task EmbedAsync(this IMessageChannel ch, IEmbedBuilder embed, string msg = "") + => ch.SendMessageAsync(msg, + embed: embed.Build(), + options: new() + { + RetryMode = RetryMode.AlwaysRetry + }); + + // unlocalized + public static Task SendConfirmAsync(this IMessageChannel ch, AnyContext ctx, string msg) + => ch.EmbedAsync(ctx.Embed().WithOkColor().WithDescription(msg)); + + public static Task SendPendingAsync(this IMessageChannel ch, AnyContext ctx, string msg) + => ch.EmbedAsync(ctx.Embed().WithPendingColor().WithDescription(msg)); + + public static Task SendErrorAsync(this IMessageChannel ch, AnyContext ctx, string msg) + => ch.EmbedAsync(ctx.Embed().WithErrorColor().WithDescription(msg)); + + // unlocalized + public static Task SendConfirmAsync(this AnyContext ctx, string msg) + => ctx.Channel.SendConfirmAsync(ctx, msg); + + public static Task SendPendingAsync(this AnyContext ctx, string msg) + => ctx.Channel.SendPendingAsync(ctx, msg); + + public static Task SendErrorAsync(this AnyContext ctx, string msg) + => ctx.Channel.SendErrorAsync(ctx, msg); + + // localized + public static Task ConfirmAsync(this AnyContext ctx) + => ctx.Message.AddReactionAsync(new Emoji("✅")); + + public static Task ErrorAsync(this AnyContext ctx) + => ctx.Message.AddReactionAsync(new Emoji("❌")); + + public static Task WarningAsync(this AnyContext ctx) + => ctx.Message.AddReactionAsync(new Emoji("⚠️")); + + public static Task WaitAsync(this AnyContext ctx) + => ctx.Message.AddReactionAsync(new Emoji("🤔")); + + public static Task ErrorLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendErrorAsync(ctx.GetText(key)); + + public static Task PendingLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendPendingAsync(ctx.GetText(key, args)); + + public static Task ConfirmLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendConfirmAsync(ctx.GetText(key, args)); + + public static Task ReplyErrorLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendErrorAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key)}"); + + public static Task ReplyPendingLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendPendingAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key)}"); + + public static Task ReplyConfirmLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendConfirmAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key)}"); +} \ No newline at end of file diff --git a/src/NadekoBot/Common/IEmbedBuilder.cs b/src/Nadeko.Medusa/IEmbedBuilder.cs similarity index 57% rename from src/NadekoBot/Common/IEmbedBuilder.cs rename to src/Nadeko.Medusa/IEmbedBuilder.cs index 2eca32430..81c4ce55b 100644 --- a/src/NadekoBot/Common/IEmbedBuilder.cs +++ b/src/Nadeko.Medusa/IEmbedBuilder.cs @@ -1,23 +1,17 @@ -#nullable disable +using Discord; + namespace NadekoBot; public interface IEmbedBuilder { - IEmbedBuilder WithDescription(string desc); + IEmbedBuilder WithDescription(string? desc); IEmbedBuilder WithTitle(string title); IEmbedBuilder AddField(string title, object value, bool isInline = false); - IEmbedBuilder WithFooter(string text, string iconUrl = null); - IEmbedBuilder WithAuthor(string name, string iconUrl = null, string url = null); + IEmbedBuilder WithFooter(string text, string? iconUrl = null); + IEmbedBuilder WithAuthor(string name, string? iconUrl = null, string? url = null); IEmbedBuilder WithColor(EmbedColor color); Embed Build(); IEmbedBuilder WithUrl(string url); IEmbedBuilder WithImageUrl(string url); IEmbedBuilder WithThumbnailUrl(string url); -} - -public enum EmbedColor -{ - Ok, - Pending, - Error } \ No newline at end of file diff --git a/src/Nadeko.Medusa/Nadeko.Medusa.csproj b/src/Nadeko.Medusa/Nadeko.Medusa.csproj new file mode 100644 index 000000000..9489c1dc0 --- /dev/null +++ b/src/Nadeko.Medusa/Nadeko.Medusa.csproj @@ -0,0 +1,21 @@ + + + + net6.0 + enable + enable + preview + true + Nadeko.Snake + + The NadekoBot Team + 1.0.2 + + + + + + + + + diff --git a/src/Nadeko.Medusa/ParamParser/ParamParser.cs b/src/Nadeko.Medusa/ParamParser/ParamParser.cs new file mode 100644 index 000000000..8c8abbe1c --- /dev/null +++ b/src/Nadeko.Medusa/ParamParser/ParamParser.cs @@ -0,0 +1,16 @@ +namespace Nadeko.Snake; + +/// +/// Overridden to implement parsers for custom types +/// +/// Type into which to parse the input +public abstract class ParamParser +{ + /// + /// Overridden to implement parsing logic + /// + /// Context + /// Input to parse + /// A with successful or failed status + public abstract ValueTask> TryParseAsync(AnyContext ctx, string input); +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/ParamParser/ParseResult.cs b/src/Nadeko.Medusa/ParamParser/ParseResult.cs new file mode 100644 index 000000000..4b4ad60c7 --- /dev/null +++ b/src/Nadeko.Medusa/ParamParser/ParseResult.cs @@ -0,0 +1,48 @@ +namespace Nadeko.Snake; + +public readonly struct ParseResult +{ + /// + /// Whether the parsing was successful + /// + public bool IsSuccess { get; private init; } + + /// + /// Parsed value. It should only have value if is set to true + /// + public T? Data { get; private init; } + + /// + /// Instantiate a **successful** parse result + /// + /// Parsed value + public ParseResult(T data) + { + Data = data; + IsSuccess = true; + } + + + /// + /// Create a new with IsSuccess = false + /// + /// A new + public static ParseResult Fail() + => new ParseResult + { + IsSuccess = false, + Data = default, + }; + + /// + /// Create a new with IsSuccess = true + /// + /// Value of the parsed object + /// A new + public static ParseResult Success(T obj) + => new ParseResult + { + IsSuccess = true, + Data = obj, + }; +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/README.md b/src/Nadeko.Medusa/README.md new file mode 100644 index 000000000..f297b11d9 --- /dev/null +++ b/src/Nadeko.Medusa/README.md @@ -0,0 +1 @@ +This is the library which is the base of any medusa. \ No newline at end of file diff --git a/src/Nadeko.Medusa/Snek.cs b/src/Nadeko.Medusa/Snek.cs new file mode 100644 index 000000000..6fa963268 --- /dev/null +++ b/src/Nadeko.Medusa/Snek.cs @@ -0,0 +1,143 @@ +using Discord; + +namespace Nadeko.Snake; + +/// +/// The base class which will be loaded as a module into NadekoBot +/// Any user-defined snek has to inherit from this class. +/// Sneks get instantiated ONLY ONCE during the loading, +/// and any snek commands will be executed on the same instance. +/// +public abstract class Snek : IAsyncDisposable +{ + /// + /// Name of the snek. Defaults to the lowercase class name + /// + public virtual string Name + => GetType().Name.ToLowerInvariant(); + + /// + /// The prefix required before the command name. For example + /// if you set this to 'test' then a command called 'cmd' will have to be invoked by using + /// '.test cmd' instead of `.cmd` + /// + public virtual string Prefix + => string.Empty; + + /// + /// Executed once this snek has been instantiated and before any command is executed. + /// + /// A representing completion + public virtual ValueTask InitializeAsync() + => default; + + /// + /// Override to cleanup any resources or references which might hold this snek in memory + /// + /// + public virtual ValueTask DisposeAsync() + => default; + + /// + /// This method is called right after the message was received by the bot. + /// You can use this method to make the bot conditionally ignore some messages and prevent further processing. + /// Execution order: + /// + /// ** → + /// → + /// → + /// OR + /// + /// + /// Guild in which the message was sent + /// Message received by the bot + /// A representing whether the message should be ignored and not processed further + public virtual ValueTask ExecOnMessageAsync(IGuild? guild, IUserMessage msg) + => default; + + /// + /// Override this method to modify input before the bot searches for any commands matching the input + /// Executed after + /// This is useful if you want to reinterpret the message under some conditions + /// Execution order: + /// + /// → + /// ** → + /// → + /// OR + /// + /// + /// Guild in which the message was sent + /// Channel in which the message was sent + /// User who sent the message + /// Content of the message + /// A representing new, potentially modified content + public virtual ValueTask ExecInputTransformAsync( + IGuild? guild, + IMessageChannel channel, + IUser user, + string input + ) + => default; + + /// + /// This method is called after the command was found but not executed, + /// and can be used to prevent the command's execution. + /// The command information doesn't have to be from this snek as this method + /// will be called when *any* command from any module or snek was found. + /// You can choose to prevent the execution of the command by returning "true" value. + /// Execution order: + /// + /// → + /// → + /// ** → + /// OR + /// + /// + /// Command context + /// Name of the snek or module from which the command originates + /// Name of the command which is about to be executed + /// A representing whether the execution should be blocked + public virtual ValueTask ExecPreCommandAsync( + AnyContext context, + string moduleName, + string commandName + ) + => default; + + /// + /// This method is called after the command was succesfully executed. + /// If this method was called, then will not be executed + /// Execution order: + /// + /// → + /// → + /// → + /// ** OR + /// + /// + /// A representing completion + public virtual ValueTask ExecPostCommandAsync(AnyContext ctx, string moduleName, string commandName) + => default; + + /// + /// This method is called if no command was found for the input. + /// Useful if you want to have games or features which take arbitrary input + /// but ignore any messages which were blocked or caused a command execution + /// If this method was called, then will not be executed + /// Execution order: + /// + /// → + /// → + /// → + /// OR ** + /// + /// + /// A representing completion + public virtual ValueTask ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg) + => default; +} + +public readonly struct ExecResponse +{ +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/Strings/CommandStrings.cs b/src/Nadeko.Medusa/Strings/CommandStrings.cs new file mode 100644 index 000000000..a10c280c9 --- /dev/null +++ b/src/Nadeko.Medusa/Strings/CommandStrings.cs @@ -0,0 +1,24 @@ +using YamlDotNet.Serialization; + +namespace Nadeko.Snake; + +public readonly struct CommandStrings +{ + public CommandStrings(string? desc, string[]? args) + { + Desc = desc; + Args = args; + } + + [YamlMember(Alias = "desc")] + public string? Desc { get; init; } + + [YamlMember(Alias = "args")] + public string[]? Args { get; init; } + + public void Deconstruct(out string? desc, out string[]? args) + { + desc = Desc; + args = Args; + } +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/Strings/IMedusaStrings.cs b/src/Nadeko.Medusa/Strings/IMedusaStrings.cs new file mode 100644 index 000000000..87de00179 --- /dev/null +++ b/src/Nadeko.Medusa/Strings/IMedusaStrings.cs @@ -0,0 +1,15 @@ +using System.Globalization; + +namespace Nadeko.Snake; + +/// +/// Defines methods to retrieve and reload medusa strings +/// +public interface IMedusaStrings +{ + // string GetText(string key, ulong? guildId = null, params object[] data); + string? GetText(string key, CultureInfo locale, params object[] data); + void Reload(); + CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo); + string? GetDescription(CultureInfo? locale); +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/Strings/IMedusaStringsProvider.cs b/src/Nadeko.Medusa/Strings/IMedusaStringsProvider.cs new file mode 100644 index 000000000..a369049fc --- /dev/null +++ b/src/Nadeko.Medusa/Strings/IMedusaStringsProvider.cs @@ -0,0 +1,28 @@ +namespace Nadeko.Snake; + +/// +/// Implemented by classes which provide localized strings in their own ways +/// +public interface IMedusaStringsProvider +{ + /// + /// Gets localized string + /// + /// Language name + /// String key + /// Localized string + string? GetText(string localeName, string key); + + /// + /// Reloads string cache + /// + void Reload(); + + // /// + // /// Gets command arg examples and description + // /// + // /// Language name + // /// Command name + // CommandStrings GetCommandStrings(string localeName, string commandName); + CommandStrings? GetCommandStrings(string localeName, string commandName); +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/Strings/LocalMedusaStringsProvider.cs b/src/Nadeko.Medusa/Strings/LocalMedusaStringsProvider.cs new file mode 100644 index 000000000..422dd7e63 --- /dev/null +++ b/src/Nadeko.Medusa/Strings/LocalMedusaStringsProvider.cs @@ -0,0 +1,40 @@ +namespace Nadeko.Snake; + +public class LocalMedusaStringsProvider : IMedusaStringsProvider +{ + private readonly StringsLoader _source; + private IReadOnlyDictionary> _responseStrings; + private IReadOnlyDictionary> _commandStrings; + + public LocalMedusaStringsProvider(StringsLoader source) + { + _source = source; + _responseStrings = _source.GetResponseStrings(); + _commandStrings = _source.GetCommandStrings(); + } + + public void Reload() + { + _responseStrings = _source.GetResponseStrings(); + _commandStrings = _source.GetCommandStrings(); + } + + + public string? GetText(string localeName, string key) + { + if (_responseStrings.TryGetValue(localeName.ToLowerInvariant(), out var langStrings) + && langStrings.TryGetValue(key.ToLowerInvariant(), out var text)) + return text; + + return null; + } + + public CommandStrings? GetCommandStrings(string localeName, string commandName) + { + if (_commandStrings.TryGetValue(localeName.ToLowerInvariant(), out var langStrings) + && langStrings.TryGetValue(commandName.ToLowerInvariant(), out var strings)) + return strings; + + return null; + } +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/Strings/MedusaStrings.cs b/src/Nadeko.Medusa/Strings/MedusaStrings.cs new file mode 100644 index 000000000..6f6137163 --- /dev/null +++ b/src/Nadeko.Medusa/Strings/MedusaStrings.cs @@ -0,0 +1,79 @@ +using System.Globalization; +using Serilog; + +namespace Nadeko.Snake; + +public class MedusaStrings : IMedusaStrings +{ + /// + /// Used as failsafe in case response key doesn't exist in the selected or default language. + /// + private readonly CultureInfo _usCultureInfo = new("en-US"); + + private readonly IMedusaStringsProvider _stringsProvider; + + public MedusaStrings(IMedusaStringsProvider stringsProvider) + { + _stringsProvider = stringsProvider; + } + + private string? GetString(string key, CultureInfo cultureInfo) + => _stringsProvider.GetText(cultureInfo.Name, key); + + public string? GetText(string key, CultureInfo cultureInfo) + => GetString(key, cultureInfo) + ?? GetString(key, _usCultureInfo); + + public string? GetText(string key, CultureInfo cultureInfo, params object[] data) + { + var text = GetText(key, cultureInfo); + + if (string.IsNullOrWhiteSpace(text)) + return null; + + try + { + return string.Format(text, data); + } + catch (FormatException) + { + Log.Warning(" Key '{Key}' is not properly formatted in '{LanguageName}' response strings", + key, + cultureInfo.Name); + + return $"⚠️ Response string key '{key}' is not properly formatted. Please report this.\n\n{text}"; + } + } + + public CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo) + { + var cmdStrings = _stringsProvider.GetCommandStrings(cultureInfo.Name, commandName); + if (cmdStrings is null) + { + if (cultureInfo.Name == _usCultureInfo.Name) + { + Log.Warning("'{CommandName}' doesn't exist in 'en-US' command strings for one of the medusae", + commandName); + + return new(null, null); + } + + Log.Information("Missing '{CommandName}' command strings for the '{LocaleName}' locale", + commandName, + cultureInfo.Name); + + return GetCommandStrings(commandName, _usCultureInfo); + } + + return cmdStrings.Value; + } + + public string? GetDescription(CultureInfo? locale = null) + => GetText("medusa.description", locale ?? _usCultureInfo); + + public static MedusaStrings CreateDefault(string basePath) + => new MedusaStrings(new LocalMedusaStringsProvider(new(basePath))); + + public void Reload() + => _stringsProvider.Reload(); +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/Strings/StringsLoader.cs b/src/Nadeko.Medusa/Strings/StringsLoader.cs new file mode 100644 index 000000000..0a58020ef --- /dev/null +++ b/src/Nadeko.Medusa/Strings/StringsLoader.cs @@ -0,0 +1,137 @@ +using System.Diagnostics.CodeAnalysis; +using Serilog; +using YamlDotNet.Serialization; + +namespace Nadeko.Snake; + +/// +/// Loads strings from the shortcut or localizable path +/// +public class StringsLoader +{ + private readonly string _localizableResponsesPath; + private readonly string _shortcutResponsesFile; + + private readonly string _localizableCommandsPath; + private readonly string _shortcutCommandsFile; + + public StringsLoader(string basePath) + { + _localizableResponsesPath = Path.Join(basePath, "strings/res"); + _shortcutResponsesFile = Path.Join(basePath, "res.yml"); + + _localizableCommandsPath = Path.Join(basePath, "strings/cmds"); + _shortcutCommandsFile = Path.Join(basePath, "cmds.yml"); + } + + public IReadOnlyDictionary> GetCommandStrings() + { + var outputDict = new Dictionary>(); + + if (File.Exists(_shortcutCommandsFile)) + { + if (TryLoadCommandsFromFile(_shortcutCommandsFile, out var dict, out _)) + { + outputDict["en-us"] = dict; + } + + return outputDict; + } + + if (Directory.Exists(_localizableCommandsPath)) + { + foreach (var cmdsFile in Directory.EnumerateFiles(_localizableCommandsPath)) + { + if (TryLoadCommandsFromFile(cmdsFile, out var dict, out var locale) && locale is not null) + { + outputDict[locale.ToLowerInvariant()] = dict; + } + } + } + + return outputDict; + } + + + private static readonly IDeserializer _deserializer = new DeserializerBuilder().Build(); + private static bool TryLoadCommandsFromFile(string file, + [NotNullWhen(true)] out IReadOnlyDictionary? strings, + out string? localeName) + { + try + { + var text = File.ReadAllText(file); + strings = _deserializer.Deserialize?>(text) + ?? new(); + localeName = GetLocaleName(file); + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Error loading {FileName} command strings: {ErrorMessage}", file, ex.Message); + } + + strings = null; + localeName = null; + return false; + } + + + public IReadOnlyDictionary> GetResponseStrings() + { + var outputDict = new Dictionary>(); + + // try to load a shortcut file + if (File.Exists(_shortcutResponsesFile)) + { + if (TryLoadResponsesFromFile(_shortcutResponsesFile, out var dict, out _)) + { + outputDict["en-us"] = dict; + } + + return outputDict; + } + + if (!Directory.Exists(_localizableResponsesPath)) + return outputDict; + + // if shortcut file doesn't exist, try to load localizable files + foreach (var file in Directory.GetFiles(_localizableResponsesPath)) + { + if (TryLoadResponsesFromFile(file, out var strings, out var localeName) && localeName is not null) + { + outputDict[localeName.ToLowerInvariant()] = strings; + } + } + + return outputDict; + } + + private static bool TryLoadResponsesFromFile(string file, + [NotNullWhen(true)] out IReadOnlyDictionary? strings, + out string? localeName) + { + try + { + strings = _deserializer.Deserialize?>(File.ReadAllText(file)); + if (strings is null) + { + localeName = null; + return false; + } + + localeName = GetLocaleName(file).ToLowerInvariant(); + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Error loading {FileName} response strings: {ErrorMessage}", file, ex.Message); + strings = null; + localeName = null; + return false; + } + } + + private static string GetLocaleName(string fileName) + => Path.GetFileNameWithoutExtension(fileName); +} \ No newline at end of file diff --git a/src/Nadeko.Medusa/pack-and-push.ps1 b/src/Nadeko.Medusa/pack-and-push.ps1 new file mode 100644 index 000000000..b7e4d883a --- /dev/null +++ b/src/Nadeko.Medusa/pack-and-push.ps1 @@ -0,0 +1,2 @@ +dotnet pack -o bin/Release/packed +dotnet nuget push bin/Release/packed/ --api-key $env:nadeko_myget_api_key --source https://www.myget.org/F/nadeko/api/v2/package \ No newline at end of file diff --git a/src/NadekoBot/Bot.cs b/src/NadekoBot/Bot.cs index 9e80d0c78..494f41b49 100644 --- a/src/NadekoBot/Bot.cs +++ b/src/NadekoBot/Bot.cs @@ -147,12 +147,14 @@ public sealed class Bot typeof(INService), // behaviours - typeof(IEarlyBehavior), - typeof(ILateBlocker), + typeof(IExecOnMessage), typeof(IInputTransformer), - typeof(ILateExecutor)) + typeof(IExecPreCommand), + typeof(IExecPostCommand), + typeof(IExecNoCommand)) + .WithoutAttribute() #if GLOBAL_NADEKO - .WithoutAttribute() + .WithoutAttribute() #endif ) .AsSelfWithInterfaces() @@ -160,8 +162,7 @@ public sealed class Bot //initialize Services Services = svcs.BuildServiceProvider(); - var exec = Services.GetRequiredService(); - exec.Initialize(); + Services.GetRequiredService().Initialize(); if (Client.ShardId == 0) ApplyConfigMigrations(); diff --git a/src/NadekoBot/Common/Medusa/Adapters/BehaviorAdapter.cs b/src/NadekoBot/Common/Medusa/Adapters/BehaviorAdapter.cs new file mode 100644 index 000000000..ea0c06da7 --- /dev/null +++ b/src/NadekoBot/Common/Medusa/Adapters/BehaviorAdapter.cs @@ -0,0 +1,76 @@ +#nullable enable + +[DontAddToIocContainer] +public sealed class BehaviorAdapter : ICustomBehavior +{ + private readonly WeakReference _snekWr; + private readonly IMedusaStrings _strings; + private readonly IServiceProvider _services; + private readonly string _name; + + // unused + public int Priority + => 0; + + public BehaviorAdapter(WeakReference snekWr, IMedusaStrings strings, IServiceProvider services) + { + _snekWr = snekWr; + _strings = strings; + _services = services; + + _name = snekWr.TryGetTarget(out var snek) + ? $"snek/{snek.GetType().Name}" + : "unknown"; + } + + public async Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command) + { + if (!_snekWr.TryGetTarget(out var snek)) + return false; + + return await snek.ExecPreCommandAsync(ContextAdapterFactory.CreateNew(context, _strings, _services), + moduleName, + command.Name); + } + + public async Task ExecOnMessageAsync(IGuild? guild, IUserMessage msg) + { + if (!_snekWr.TryGetTarget(out var snek)) + return false; + + return await snek.ExecOnMessageAsync(guild, msg); + } + + public async Task TransformInput( + IGuild guild, + IMessageChannel channel, + IUser user, + string input) + { + if (!_snekWr.TryGetTarget(out var snek)) + return null; + + return await snek.ExecInputTransformAsync(guild, channel, user, input); + } + + public async Task ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg) + { + if (!_snekWr.TryGetTarget(out var snek)) + return; + + await snek.ExecOnNoCommandAsync(guild, msg); + } + + public async ValueTask ExecPostCommandAsync(ICommandContext context, string moduleName, string commandName) + { + if (!_snekWr.TryGetTarget(out var snek)) + return; + + await snek.ExecPostCommandAsync(ContextAdapterFactory.CreateNew(context, _strings, _services), + moduleName, + commandName); + } + + public override string ToString() + => _name; +} \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/Adapters/ContextAdapterFactory.cs b/src/NadekoBot/Common/Medusa/Adapters/ContextAdapterFactory.cs new file mode 100644 index 000000000..3f3be033c --- /dev/null +++ b/src/NadekoBot/Common/Medusa/Adapters/ContextAdapterFactory.cs @@ -0,0 +1,7 @@ +internal class ContextAdapterFactory +{ + public static AnyContext CreateNew(ICommandContext context, IMedusaStrings strings, IServiceProvider services) + => context.Guild is null + ? new DmContextAdapter(context, strings, services) + : new GuildContextAdapter(context, strings, services); +} \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/Adapters/DmContextAdapter.cs b/src/NadekoBot/Common/Medusa/Adapters/DmContextAdapter.cs new file mode 100644 index 000000000..89fdaa370 --- /dev/null +++ b/src/NadekoBot/Common/Medusa/Adapters/DmContextAdapter.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.DependencyInjection; + +public sealed class DmContextAdapter : DmContext +{ + public override IMedusaStrings Strings { get; } + public override IDMChannel Channel { get; } + public override IUserMessage Message { get; } + public override IUser User + => Message.Author; + + private readonly IServiceProvider _services; + private readonly Lazy _ebs; + private readonly Lazy _botStrings; + private readonly Lazy _localization; + + public DmContextAdapter(ICommandContext ctx, IMedusaStrings strings, IServiceProvider services) + { + if (ctx is not { Channel: IDMChannel ch }) + { + throw new ArgumentException("Can't use non-dm context to create DmContextAdapter", nameof(ctx)); + } + + Strings = strings; + + _services = services; + + Channel = ch; + Message = ctx.Message; + + + _ebs = new(_services.GetRequiredService()); + _botStrings = new(_services.GetRequiredService); + _localization = new(_services.GetRequiredService()); + } + + public override IEmbedBuilder Embed() + => _ebs.Value.Create(); + + public override string GetText(string key, object[]? args = null) + { + var cultureInfo = _localization.Value.GetCultureInfo(default(ulong?)); + var output = Strings.GetText(key, cultureInfo, args ?? Array.Empty()); + if (!string.IsNullOrWhiteSpace(output)) + return output; + + return _botStrings.Value.GetText(key, cultureInfo, args); + } +} \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/Adapters/GuildContextAdapter.cs b/src/NadekoBot/Common/Medusa/Adapters/GuildContextAdapter.cs new file mode 100644 index 000000000..87b22923a --- /dev/null +++ b/src/NadekoBot/Common/Medusa/Adapters/GuildContextAdapter.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.DependencyInjection; + +public sealed class GuildContextAdapter : GuildContext +{ + private readonly IServiceProvider _services; + private readonly ICommandContext _ctx; + private readonly Lazy _ebs; + private readonly Lazy _botStrings; + private readonly Lazy _localization; + + public override IMedusaStrings Strings { get; } + public override IGuild Guild { get; } + public override ITextChannel Channel { get; } + public override IUserMessage Message + => _ctx.Message; + + public override IGuildUser User { get; } + + public override IEmbedBuilder Embed() + => _ebs.Value.Create(); + + public GuildContextAdapter(ICommandContext ctx, IMedusaStrings strings, IServiceProvider services) + { + if (ctx.Guild is not IGuild guild || ctx.Channel is not ITextChannel channel) + { + throw new ArgumentException("Can't use non-guild context to create GuildContextAdapter", nameof(ctx)); + } + + Strings = strings; + User = (IGuildUser)ctx.User; + + _services = services; + _ebs = new(_services.GetRequiredService()); + _botStrings = new(_services.GetRequiredService); + _localization = new(_services.GetRequiredService()); + + (_ctx, Guild, Channel) = (ctx, guild, channel); + } + + public override string GetText(string key, object[]? args = null) + { + args ??= Array.Empty(); + + var cultureInfo = _localization.Value.GetCultureInfo(_ctx.Guild.Id); + var output = Strings.GetText(key, cultureInfo, args); + if (!string.IsNullOrWhiteSpace(output)) + return output; + + return _botStrings.Value.GetText(key, cultureInfo, args); + } +} \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/Adapters/ParamParserAdapter.cs b/src/NadekoBot/Common/Medusa/Adapters/ParamParserAdapter.cs new file mode 100644 index 000000000..5d0696fbb --- /dev/null +++ b/src/NadekoBot/Common/Medusa/Adapters/ParamParserAdapter.cs @@ -0,0 +1,32 @@ +public sealed class ParamParserAdapter : TypeReader +{ + private readonly ParamParser _parser; + private readonly IMedusaStrings _strings; + private readonly IServiceProvider _services; + + public ParamParserAdapter(ParamParser parser, + IMedusaStrings strings, + IServiceProvider services) + { + _parser = parser; + _strings = strings; + _services = services; + } + + public override async Task ReadAsync( + ICommandContext context, + string input, + IServiceProvider services) + { + var medusaContext = ContextAdapterFactory.CreateNew(context, + _strings, + _services); + + var result = await _parser.TryParseAsync(medusaContext, input); + + if(result.IsSuccess) + return Discord.Commands.TypeReaderResult.FromSuccess(result.Data); + + return Discord.Commands.TypeReaderResult.FromError(CommandError.Unsuccessful, "Invalid input"); + } +} \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/CommandContextType.cs b/src/NadekoBot/Common/Medusa/CommandContextType.cs new file mode 100644 index 000000000..0b6bde876 --- /dev/null +++ b/src/NadekoBot/Common/Medusa/CommandContextType.cs @@ -0,0 +1,27 @@ +namespace Nadeko.Medusa; + +/// +/// Enum specifying in which context the command can be executed +/// +public enum CommandContextType +{ + /// + /// Command can only be executed in a guild + /// + Guild, + + /// + /// Command can only be executed in DMs + /// + Dm, + + /// + /// Command can be executed anywhere + /// + Any, + + /// + /// Command can be executed anywhere, and it doesn't require context to be passed to it + /// + Unspecified +} \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/Config/IMedusaConfigService.cs b/src/NadekoBot/Common/Medusa/Config/IMedusaConfigService.cs new file mode 100644 index 000000000..01e9fe4c1 --- /dev/null +++ b/src/NadekoBot/Common/Medusa/Config/IMedusaConfigService.cs @@ -0,0 +1,8 @@ +namespace Nadeko.Medusa; + +public interface IMedusaConfigService +{ + IReadOnlyCollection GetLoadedMedusae(); + void AddLoadedMedusa(string name); + void RemoveLoadedMedusa(string name); +} \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/Config/MedusaConfig.cs b/src/NadekoBot/Common/Medusa/Config/MedusaConfig.cs new file mode 100644 index 000000000..158ea4b79 --- /dev/null +++ b/src/NadekoBot/Common/Medusa/Config/MedusaConfig.cs @@ -0,0 +1,19 @@ +using Cloneable; +using NadekoBot.Common.Yml; + +namespace Nadeko.Medusa; + +[Cloneable] +public sealed partial class MedusaConfig : ICloneable +{ + [Comment(@"DO NOT CHANGE")] + public int Version { get; set; } = 1; + + [Comment("List of medusae automatically loaded at startup")] + public List Loaded { get; set; } + + public MedusaConfig() + { + Loaded = new(); + } +} \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/Config/MedusaConfigService.cs b/src/NadekoBot/Common/Medusa/Config/MedusaConfigService.cs new file mode 100644 index 000000000..fddfe7dd0 --- /dev/null +++ b/src/NadekoBot/Common/Medusa/Config/MedusaConfigService.cs @@ -0,0 +1,43 @@ +using NadekoBot.Common.Configs; + +namespace Nadeko.Medusa; + +public sealed class MedusaConfigService : ConfigServiceBase, IMedusaConfigService +{ + private const string FILE_PATH = "data/medusae/medusa.yml"; + private static readonly TypedKey _changeKey = new("config.medusa.updated"); + + public override string Name + => "medusa"; + + public MedusaConfigService( + IConfigSeria serializer, + IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + } + + public IReadOnlyCollection GetLoadedMedusae() + => Data.Loaded.ToList(); + + public void AddLoadedMedusa(string name) + { + name = name.Trim().ToLowerInvariant(); + + ModifyConfig(conf => + { + if(!conf.Loaded.Contains(name)) + conf.Loaded.Add(name); + }); + } + + public void RemoveLoadedMedusa(string name) + { + name = name.Trim().ToLowerInvariant(); + + ModifyConfig(conf => + { + conf.Loaded.Remove(name); + }); + } +} \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/IMedusaLoaderService.cs b/src/NadekoBot/Common/Medusa/IMedusaLoaderService.cs new file mode 100644 index 000000000..9b60c418b --- /dev/null +++ b/src/NadekoBot/Common/Medusa/IMedusaLoaderService.cs @@ -0,0 +1,23 @@ +using System.Globalization; + +namespace Nadeko.Medusa; + +public interface IMedusaLoaderService +{ + Task LoadMedusaAsync(string medusaName); + Task UnloadMedusaAsync(string medusaName); + string GetCommandDescription(string medusaName, string commandName, CultureInfo culture); + string[] GetCommandExampleArgs(string medusaName, string commandName, CultureInfo culture); + Task ReloadStrings(); + IReadOnlyCollection GetAllMedusae(); + IReadOnlyCollection GetLoadedMedusae(CultureInfo? cultureInfo = null); +} + +public sealed record MedusaStats(string Name, + string? Description, + IReadOnlyCollection Sneks); + +public sealed record SnekStats(string Name, + IReadOnlyCollection Commands); + +public sealed record SnekCommandStats(string Name); \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/MedusaAssemblyLoadContext.cs b/src/NadekoBot/Common/Medusa/MedusaAssemblyLoadContext.cs new file mode 100644 index 000000000..51aaa4915 --- /dev/null +++ b/src/NadekoBot/Common/Medusa/MedusaAssemblyLoadContext.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace Nadeko.Medusa; + +public sealed class MedusaAssemblyLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _depResolver; + + public MedusaAssemblyLoadContext(string pluginPath) : base(isCollectible: true) + { + _depResolver = new(pluginPath); + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + var assemblyPath = _depResolver.ResolveAssemblyToPath(assemblyName); + if (assemblyPath != null) + { + return LoadFromAssemblyPath(assemblyPath); + } + + return null; + } + + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) + { + var libraryPath = _depResolver.ResolveUnmanagedDllToPath(unmanagedDllName); + if (libraryPath != null) + { + return LoadUnmanagedDllFromPath(libraryPath); + } + + return IntPtr.Zero; + } +} \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/MedusaLoaderService.cs b/src/NadekoBot/Common/Medusa/MedusaLoaderService.cs new file mode 100644 index 000000000..5f38cc826 --- /dev/null +++ b/src/NadekoBot/Common/Medusa/MedusaLoaderService.cs @@ -0,0 +1,880 @@ +using Discord.Commands.Builders; +using Microsoft.Extensions.DependencyInjection; +using NadekoBot.Common.ModuleBehaviors; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace Nadeko.Medusa; + +// ReSharper disable RedundantAssignment +public sealed class MedusaLoaderService : IMedusaLoaderService, IReadyExecutor, INService +{ + private readonly CommandService _cmdService; + private readonly IServiceProvider _botServices; + private readonly IBehaviorHandler _behHandler; + private readonly IPubSub _pubSub; + private readonly IMedusaConfigService _medusaConfig; + + private readonly ConcurrentDictionary _resolved = new(); + private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); + + private readonly TypedKey _loadKey = new("medusa:load"); + private readonly TypedKey _unloadKey = new("medusa:unload"); + + private readonly TypedKey _stringsReload = new("medusa:reload_strings"); + + private const string BASE_DIR = "data/medusae"; + + public MedusaLoaderService(CommandService cmdService, + IServiceProvider botServices, + IBehaviorHandler behHandler, + IPubSub pubSub, + IMedusaConfigService medusaConfig) + { + _cmdService = cmdService; + _botServices = botServices; + _behHandler = behHandler; + _pubSub = pubSub; + _medusaConfig = medusaConfig; + + // has to be done this way to support this feature on sharded bots + _pubSub.Sub(_loadKey, async name => await InternalLoadAsync(name)); + _pubSub.Sub(_unloadKey, async name => await InternalUnloadAsync(name)); + + _pubSub.Sub(_stringsReload, async _ => await ReloadStringsInternal()); + } + + public IReadOnlyCollection GetAllMedusae() + { + if (!Directory.Exists(BASE_DIR)) + return Array.Empty(); + + return Directory.GetDirectories(BASE_DIR) + .Select(x => Path.GetRelativePath(BASE_DIR, x)) + .ToArray(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public IReadOnlyCollection GetLoadedMedusae(CultureInfo? culture) + { + var toReturn = new List(_resolved.Count); + foreach (var (name, resolvedData) in _resolved) + { + var sneks = new List(resolvedData.SnekInfos.Count); + + foreach (var snekInfos in resolvedData.SnekInfos.Concat(resolvedData.SnekInfos.SelectMany(x => x.Subsneks))) + { + var commands = new List(); + + foreach (var command in snekInfos.Commands) + { + commands.Add(new SnekCommandStats(command.Aliases.First())); + } + + sneks.Add(new SnekStats(snekInfos.Name, commands)); + } + + toReturn.Add(new MedusaStats(name, resolvedData.Strings.GetDescription(culture), sneks)); + } + return toReturn; + } + + public async Task OnReadyAsync() + { + foreach (var name in _medusaConfig.GetLoadedMedusae()) + { + var result = await InternalLoadAsync(name); + if(result != MedusaLoadResult.Success) + Log.Warning("Unable to load '{MedusaName}' medusa", name); + else + Log.Warning("Loaded medusa '{MedusaName}'", name); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public async Task LoadMedusaAsync(string medusaName) + { + // try loading on this shard first to see if it works + var res = await InternalLoadAsync(medusaName); + if (res == MedusaLoadResult.Success) + { + // if it does publish it so that other shards can load the medusa too + // this method will be ran twice on this shard but it doesn't matter as + // the second attempt will be ignored + await _pubSub.Pub(_loadKey, medusaName); + } + + return res; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public async Task UnloadMedusaAsync(string medusaName) + { + var res = await InternalUnloadAsync(medusaName); + if (res == MedusaUnloadResult.Success) + { + await _pubSub.Pub(_unloadKey, medusaName); + } + + return res; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public string[] GetCommandExampleArgs(string medusaName, string commandName, CultureInfo culture) + { + if (!_resolved.TryGetValue(medusaName, out var data)) + return Array.Empty(); + + return data.Strings.GetCommandStrings(commandName, culture).Args + ?? data.SnekInfos + .SelectMany(x => x.Commands) + .FirstOrDefault(x => x.Aliases.Any(alias + => alias.Equals(commandName, StringComparison.InvariantCultureIgnoreCase))) + ?.OptionalStrings + .Args + ?? new[] { string.Empty }; + } + + public Task ReloadStrings() + => _pubSub.Pub(_stringsReload, true); + + [MethodImpl(MethodImplOptions.NoInlining)] + private void ReloadStringsSync() + { + foreach (var resolved in _resolved.Values) + { + resolved.Strings.Reload(); + } + } + + private async Task ReloadStringsInternal() + { + await _lock.WaitAsync(); + try + { + ReloadStringsSync(); + } + finally + { + _lock.Release(); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public string GetCommandDescription(string medusaName, string commandName, CultureInfo culture) + { + if (!_resolved.TryGetValue(medusaName, out var data)) + return string.Empty; + + return data.Strings.GetCommandStrings(commandName, culture).Desc + ?? data.SnekInfos + .SelectMany(x => x.Commands) + .FirstOrDefault(x => x.Aliases.Any(alias + => alias.Equals(commandName, StringComparison.InvariantCultureIgnoreCase))) + ?.OptionalStrings + .Desc + ?? string.Empty; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async ValueTask InternalLoadAsync(string name) + { + if (_resolved.ContainsKey(name)) + return MedusaLoadResult.AlreadyLoaded; + + var safeName = Uri.EscapeDataString(name); + name = name.ToLowerInvariant(); + + await _lock.WaitAsync(); + try + { + var success = LoadAssemblyInternal(safeName, + out var ctx, + out var snekData, + out var services, + out var strings, + out var typeReaders); + + if (success) + { + var moduleInfos = new List(); + + // todo uncomment + LoadTypeReadersInternal(typeReaders); + + foreach (var point in snekData) + { + try + { + // initialize snek and subsneks + await point.Instance.InitializeAsync(); + foreach (var sub in point.Subsneks) + { + await sub.Instance.InitializeAsync(); + } + + var module = await LoadModuleInternalAsync(name, point, strings, services); + moduleInfos.Add(module); + } + catch (Exception ex) + { + Log.Warning(ex, + "Error loading snek {SnekName}", + point.Name); + } + } + + var execs = GetExecsInternal(snekData, strings, services); + await _behHandler.AddRangeAsync(execs); + + _resolved[name] = new(LoadContext: ctx, + ModuleInfos: moduleInfos.ToImmutableArray(), + SnekInfos: snekData.ToImmutableArray(), + strings, + typeReaders, + execs) + { + Services = services + }; + + + services = null; + _medusaConfig.AddLoadedMedusa(safeName); + return MedusaLoadResult.Success; + } + + return MedusaLoadResult.Empty; + } + catch (Exception ex) when (ex is FileNotFoundException or BadImageFormatException) + { + return MedusaLoadResult.NotFound; + } + catch (Exception ex) + { + Log.Error(ex, "An error occurred loading a medusa"); + return MedusaLoadResult.UnknownError; + } + finally + { + _lock.Release(); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private IReadOnlyCollection GetExecsInternal(IReadOnlyCollection snekData, IMedusaStrings strings, IServiceProvider services) + { + var behs = new List(); + foreach (var snek in snekData) + { + behs.Add(new BehaviorAdapter(new(snek.Instance), strings, services)); + + foreach (var sub in snek.Subsneks) + { + behs.Add(new BehaviorAdapter(new(sub.Instance), strings, services)); + } + } + + + return behs; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void LoadTypeReadersInternal(Dictionary typeReaders) + { + var notAddedTypeReaders = new List(); + foreach (var (type, typeReader) in typeReaders) + { + // if type reader for this type already exists, it will not be replaced + if (_cmdService.TypeReaders.Contains(type)) + { + notAddedTypeReaders.Add(type); + continue; + } + + _cmdService.AddTypeReader(type, typeReader); + } + + // remove the ones that were not added + // to prevent them from being unloaded later + // as they didn't come from this medusa + foreach (var toRemove in notAddedTypeReaders) + { + typeReaders.Remove(toRemove); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private bool LoadAssemblyInternal( + string safeName, + [NotNullWhen(true)] out WeakReference? ctxWr, + [NotNullWhen(true)] out IReadOnlyCollection? snekData, + out IServiceProvider services, + out IMedusaStrings strings, + out Dictionary typeReaders) + { + ctxWr = null; + snekData = null; + + var path = $"{BASE_DIR}/{safeName}/{safeName}.dll"; + strings = MedusaStrings.CreateDefault($"{BASE_DIR}/{safeName}"); + var ctx = new MedusaAssemblyLoadContext(Path.GetDirectoryName(path)!); + var a = ctx.LoadFromAssemblyPath(Path.GetFullPath(path)); + var sis = LoadSneksFromAssembly(a, out services); + typeReaders = LoadTypeReadersFromAssembly(a, strings, services); + + if (sis.Count == 0) + { + return false; + } + + ctxWr = new(ctx); + snekData = sis; + + return true; + } + + private static readonly Type _paramParserType = typeof(ParamParser<>); + + [MethodImpl(MethodImplOptions.NoInlining)] + private Dictionary LoadTypeReadersFromAssembly( + Assembly assembly, + IMedusaStrings strings, + IServiceProvider services) + { + var paramParsers = assembly.GetExportedTypes() + .Where(x => x.IsClass + && !x.IsAbstract + && x.BaseType is not null + && x.BaseType.IsGenericType + && x.BaseType.GetGenericTypeDefinition() == _paramParserType); + + var typeReaders = new Dictionary(); + foreach (var parserType in paramParsers) + { + var parserObj = ActivatorUtilities.CreateInstance(services, parserType); + + var targetType = parserType.BaseType!.GetGenericArguments()[0]; + var typeReaderInstance = (TypeReader)Activator.CreateInstance( + typeof(ParamParserAdapter<>).MakeGenericType(targetType), + args: new[] { parserObj, strings, services })!; + + typeReaders.Add(targetType, typeReaderInstance); + } + + return typeReaders; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async Task LoadModuleInternalAsync(string medusaName, SnekInfo snekInfo, IMedusaStrings strings, IServiceProvider services) + { + var module = await _cmdService.CreateModuleAsync(snekInfo.Instance.Prefix, + CreateModuleFactory(medusaName, snekInfo, strings, services)); + + return module; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private Action CreateModuleFactory( + string medusaName, + SnekInfo snekInfo, + IMedusaStrings strings, + IServiceProvider medusaServices) + => mb => + { + var m = mb.WithName(snekInfo.Name); + + foreach (var cmd in snekInfo.Commands) + { + m.AddCommand(cmd.Aliases.First(), + CreateCallback(cmd.ContextType, + new(snekInfo), + new(cmd), + new(medusaServices), + strings), + CreateCommandFactory(medusaName, cmd)); + } + + foreach (var subInfo in snekInfo.Subsneks) + m.AddModule(subInfo.Instance.Prefix, CreateModuleFactory(medusaName, subInfo, strings, medusaServices)); + }; + + private static readonly RequireContextAttribute _reqGuild = new RequireContextAttribute(ContextType.Guild); + private static readonly RequireContextAttribute _reqDm = new RequireContextAttribute(ContextType.DM); + private Action CreateCommandFactory(string medusaName, SnekCommandData cmd) + => (cb) => + { + cb.AddAliases(cmd.Aliases.Skip(1).ToArray()); + + if (cmd.ContextType == CommandContextType.Guild) + cb.AddPrecondition(_reqGuild); + else if (cmd.ContextType == CommandContextType.Dm) + cb.AddPrecondition(_reqDm); + + cb.WithPriority(cmd.Priority); + + // using summary to save method name + // method name is used to retrieve desc/usages + cb.WithRemarks($"medusa///{medusaName}"); + cb.WithSummary(cmd.MethodInfo.Name.ToLowerInvariant()); + + foreach (var param in cmd.Parameters) + { + cb.AddParameter(param.Name, param.Type, CreateParamFactory(param)); + } + }; + + private Action CreateParamFactory(ParamData paramData) + => (pb) => + { + pb.WithIsMultiple(paramData.IsParams) + .WithIsOptional(paramData.IsOptional) + .WithIsRemainder(paramData.IsLeftover); + }; + + [MethodImpl(MethodImplOptions.NoInlining)] + private Func CreateCallback( + CommandContextType contextType, + WeakReference snekDataWr, + WeakReference snekCommandDataWr, + WeakReference medusaServicesWr, + IMedusaStrings strings) + => async (context, parameters, svcs, _) => + { + if (!snekCommandDataWr.TryGetTarget(out var cmdData) + || !snekDataWr.TryGetTarget(out var snekData) + || !medusaServicesWr.TryGetTarget(out var medusaServices)) + { + Log.Warning("Attempted to run an unloaded snek's command"); + return; + } + + var paramObjs = ParamObjs(contextType, cmdData, parameters, context, svcs, medusaServices, strings); + + try + { + var methodInfo = cmdData.MethodInfo; + if (methodInfo.ReturnType == typeof(Task) + || (methodInfo.ReturnType.IsGenericType + && methodInfo.ReturnType.GetGenericTypeDefinition() == typeof(Task<>))) + { + await (Task)methodInfo.Invoke(snekData.Instance, paramObjs)!; + } + else if (methodInfo.ReturnType == typeof(ValueTask)) + { + await ((ValueTask)methodInfo.Invoke(snekData.Instance, paramObjs)!).AsTask(); + } + else // if (methodInfo.ReturnType == typeof(void)) + { + methodInfo.Invoke(snekData.Instance, paramObjs); + } + } + finally + { + paramObjs = null; + cmdData = null; + + snekData = null; + medusaServices = null; + } + }; + + [MethodImpl(MethodImplOptions.NoInlining)] + private static object[] ParamObjs( + CommandContextType contextType, + SnekCommandData cmdData, + object[] parameters, + ICommandContext context, + IServiceProvider svcs, + IServiceProvider svcProvider, + IMedusaStrings strings) + { + var extraParams = contextType == CommandContextType.Unspecified ? 0 : 1; + extraParams += cmdData.InjectedParams.Count; + + var paramObjs = new object[parameters.Length + extraParams]; + + var startAt = 0; + if (contextType != CommandContextType.Unspecified) + { + paramObjs[0] = ContextAdapterFactory.CreateNew(context, strings, svcs); + + startAt = 1; + } + + for (var i = 0; i < cmdData.InjectedParams.Count; i++) + { + var svc = svcProvider.GetService(cmdData.InjectedParams[i]); + if (svc is null) + { + throw new ArgumentException($"Cannot inject a service of type {cmdData.InjectedParams[i]}"); + } + + paramObjs[i + startAt] = svc; + + svc = null; + } + + startAt += cmdData.InjectedParams.Count; + + for (var i = 0; i < parameters.Length; i++) + paramObjs[startAt + i] = parameters[i]; + + return paramObjs; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async Task InternalUnloadAsync(string name) + { + name = name.ToLowerInvariant(); + if (!_resolved.Remove(name, out var lsi)) + return MedusaUnloadResult.NotLoaded; + + await _lock.WaitAsync(); + try + { + UnloadTypeReaders(lsi.TypeReaders); + + foreach (var mi in lsi.ModuleInfos) + { + await _cmdService.RemoveModuleAsync(mi); + } + + await _behHandler.RemoveRangeAsync(lsi.Execs); + + await DisposeSnekInstances(lsi); + + var lc = lsi.LoadContext; + + // removing this line will prevent assembly from being unloaded quickly + // as this local variable will be held for a long time potentially + // due to how async works + lsi.Services = null!; + lsi = null; + + _medusaConfig.RemoveLoadedMedusa(name); + return UnloadInternal(lc) + ? MedusaUnloadResult.Success + : MedusaUnloadResult.PossiblyUnable; + } + finally + { + _lock.Release(); + } + } + + private void UnloadTypeReaders(Dictionary valueTypeReaders) + { + foreach (var tr in valueTypeReaders) + { + _cmdService.TryRemoveTypeReader(tr.Key, false, out _); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async Task DisposeSnekInstances(ResolvedMedusa medusa) + { + foreach (var si in medusa.SnekInfos) + { + try + { + await si.Instance.DisposeAsync(); + foreach (var sub in si.Subsneks) + { + await sub.Instance.DisposeAsync(); + } + } + catch (Exception ex) + { + Log.Warning(ex, + "Failed cleanup of Snek {SnekName}. This medusa might not unload correctly", + si.Instance.Name); + } + } + + // medusae = null; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private bool UnloadInternal(WeakReference lsi) + { + UnloadContext(lsi); + GcCleanup(); + + return !lsi.TryGetTarget(out _); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void UnloadContext(WeakReference lsiLoadContext) + { + if(lsiLoadContext.TryGetTarget(out var ctx)) + ctx.Unload(); + } + + private void GcCleanup() + { + // cleanup + for (var i = 0; i < 10; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.WaitForFullGCComplete(); + GC.Collect(); + } + } + + private static readonly Type _snekType = typeof(Snek); + + [MethodImpl(MethodImplOptions.NoInlining)] + private IServiceProvider LoadMedusaServicesInternal(Assembly a) + => new ServiceCollection() + .Scan(x => x.FromAssemblies(a) + .AddClasses(static x => x.WithAttribute(x => x.Lifetime == Lifetime.Transient)) + .AsSelfWithInterfaces() + .WithTransientLifetime() + .AddClasses(static x => x.WithAttribute(x => x.Lifetime == Lifetime.Singleton)) + .AsSelfWithInterfaces() + .WithSingletonLifetime()) + .BuildServiceProvider(); + + + [MethodImpl(MethodImplOptions.NoInlining)] + public IReadOnlyCollection LoadSneksFromAssembly(Assembly a, out IServiceProvider services) + { + var medusaServices = LoadMedusaServicesInternal(a); + services = new MedusaServiceProvider(_botServices, medusaServices); + + // find all types in teh assembly + var types = a.GetExportedTypes(); + // snek is always a public non abstract class + var classes = types.Where(static x => x.IsClass + && (x.IsNestedPublic || x.IsPublic) + && !x.IsAbstract + && x.BaseType == _snekType + && (x.DeclaringType is null || x.DeclaringType.IsAssignableTo(_snekType))) + .ToList(); + + var topModules = new Dictionary(); + + foreach (var cl in classes) + { + if (cl.DeclaringType is not null) + continue; + + // get module data, and add it to the topModules dictionary + var module = GetModuleData(cl, services); + topModules.Add(cl, module); + } + + foreach (var c in classes) + { + if (c.DeclaringType is not Type dt) + continue; + + // if there is no top level module which this module is a child of + // just print a warning and skip it + if (!topModules.TryGetValue(dt, out var parentData)) + { + Log.Warning("Can't load submodule {SubName} because parent module {Name} does not exist", + c.Name, + dt.Name); + continue; + } + + GetModuleData(c, services, parentData); + } + + return topModules.Values.ToArray(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private SnekInfo GetModuleData(Type type, IServiceProvider services, SnekInfo? parentData = null) + { + var filters = type.GetCustomAttributes(true) + .ToArray(); + + var instance = (Snek)ActivatorUtilities.CreateInstance(services, type); + + var module = new SnekInfo(instance.Name, + parentData, + instance, + GetCommands(instance, type), + filters); + + if (parentData is not null) + parentData.Subsneks.Add(module); + + return module; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private IReadOnlyCollection GetCommands(Snek instance, Type type) + { + var methodInfos = type + .GetMethods(BindingFlags.Instance + | BindingFlags.DeclaredOnly + | BindingFlags.Public) + .Where(static x => + { + if(x.GetCustomAttribute(true) is null) + return false; + + if (x.ReturnType.IsGenericType) + { + var genericType = x.ReturnType.GetGenericTypeDefinition(); + if (genericType == typeof(Task<>)) + return true; + + // if (genericType == typeof(ValueTask<>)) + // return true; + + Log.Warning("Method {MethodName} has an invalid return type: {ReturnType}", + x.Name, + x.ReturnType); + + return false; + } + + var succ = x.ReturnType == typeof(Task) + || x.ReturnType == typeof(ValueTask) + || x.ReturnType == typeof(void); + + if (!succ) + { + Log.Warning("Method {MethodName} has an invalid return type: {ReturnType}", + x.Name, + x.ReturnType); + } + + return succ; + }); + + + var cmds = new List(); + foreach (var method in methodInfos) + { + var filters = method.GetCustomAttributes().ToArray(); + var prio = method.GetCustomAttribute()?.Priority ?? 0; + + var paramInfos = method.GetParameters(); + var cmdParams = new List(); + var diParams = new List(); + var cmdContext = CommandContextType.Unspecified; + var canInject = false; + for (var paramCounter = 0; paramCounter < paramInfos.Length; paramCounter++) + { + var pi = paramInfos[paramCounter]; + + var paramName = pi.Name ?? "unnamed"; + var isContext = paramCounter == 0 && pi.ParameterType.IsAssignableTo(typeof(AnyContext)); + + var leftoverAttribute = pi.GetCustomAttribute(true); + var hasDefaultValue = pi.HasDefaultValue; + var isLeftover = leftoverAttribute != null; + var isParams = pi.GetCustomAttribute() is not null; + var paramType = pi.ParameterType; + var isInjected = pi.GetCustomAttribute(true) is not null; + + if (isContext) + { + if (hasDefaultValue || leftoverAttribute != null || isParams) + throw new ArgumentException("IContext parameter cannot be optional, leftover, constant or params. " + GetErrorPath(method, pi)); + + if (paramCounter != 0) + throw new ArgumentException($"IContext parameter has to be first. {GetErrorPath(method, pi)}"); + + canInject = true; + + if (paramType.IsAssignableTo(typeof(GuildContext))) + cmdContext = CommandContextType.Guild; + else if (paramType.IsAssignableTo(typeof(DmContext))) + cmdContext = CommandContextType.Dm; + else + cmdContext = CommandContextType.Any; + + continue; + } + + if (isInjected) + { + if (!canInject && paramCounter != 0) + throw new ArgumentException($"Parameters marked as [Injected] have to come after IContext"); + + canInject = true; + + diParams.Add(paramType); + continue; + } + + canInject = false; + + if (isParams) + { + if (hasDefaultValue) + throw new NotSupportedException("Params can't have const values at the moment. " + + GetErrorPath(method, pi)); + // if it's params, it means it's an array, and i only need a parser for the actual type, + // as the parser will run on each array element, it can't be null + paramType = paramType.GetElementType()!; + } + + // leftover can only be the last parameter. + if (isLeftover && paramCounter != paramInfos.Length - 1) + { + var path = GetErrorPath(method, pi); + Log.Error("Only one parameter can be marked [Leftover] and it has to be the last one. {Path} ", + path); + throw new ArgumentException("Leftover attribute error."); + } + + cmdParams.Add(new ParamData(paramType, paramName, hasDefaultValue, isLeftover, isParams)); + } + + + var cmdAttribute = method.GetCustomAttribute()!; + var aliases = cmdAttribute.Aliases; + if (aliases.Length == 0) + aliases = new[] { method.Name.ToLowerInvariant() }; + + cmds.Add(new( + aliases, + method, + instance, + filters, + cmdContext, + diParams, + cmdParams, + new(cmdAttribute.desc, cmdAttribute.args), + prio + )); + } + + return cmds; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private string GetErrorPath(MethodInfo m, System.Reflection.ParameterInfo pi) + => $@"Module: {m.DeclaringType?.Name} +Command: {m.Name} +ParamName: {pi.Name} +ParamType: {pi.ParameterType.Name}"; +} + +public enum MedusaLoadResult +{ + Success, + NotFound, + AlreadyLoaded, + Empty, + UnknownError, +} + +public enum MedusaUnloadResult +{ + Success, + NotLoaded, + PossiblyUnable, + NotFound, +} \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/MedusaServiceProvider.cs b/src/NadekoBot/Common/Medusa/MedusaServiceProvider.cs new file mode 100644 index 000000000..e019ced9c --- /dev/null +++ b/src/NadekoBot/Common/Medusa/MedusaServiceProvider.cs @@ -0,0 +1,24 @@ +using System.Runtime.CompilerServices; + +namespace Nadeko.Medusa; + +public class MedusaServiceProvider : IServiceProvider +{ + private readonly IServiceProvider _nadekoServices; + private readonly IServiceProvider _medusaServices; + + public MedusaServiceProvider(IServiceProvider nadekoServices, IServiceProvider medusaServices) + { + _nadekoServices = nadekoServices; + _medusaServices = medusaServices; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public object? GetService(Type serviceType) + { + if (!serviceType.Assembly.IsCollectible) + return _nadekoServices.GetService(serviceType); + + return _medusaServices.GetService(serviceType); + } +} \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/Models/ParamData.cs b/src/NadekoBot/Common/Medusa/Models/ParamData.cs new file mode 100644 index 000000000..acb3b5008 --- /dev/null +++ b/src/NadekoBot/Common/Medusa/Models/ParamData.cs @@ -0,0 +1,9 @@ +namespace Nadeko.Medusa; + +public sealed record ParamData( + Type Type, + string Name, + bool IsOptional, + bool IsLeftover, + bool IsParams +); \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/Models/ResolvedMedusa.cs b/src/NadekoBot/Common/Medusa/Models/ResolvedMedusa.cs new file mode 100644 index 000000000..f645e8146 --- /dev/null +++ b/src/NadekoBot/Common/Medusa/Models/ResolvedMedusa.cs @@ -0,0 +1,14 @@ +using System.Collections.Immutable; + +namespace Nadeko.Medusa; + +public sealed record ResolvedMedusa( + WeakReference LoadContext, + IImmutableList ModuleInfos, + IImmutableList SnekInfos, + IMedusaStrings Strings, + Dictionary TypeReaders, + IReadOnlyCollection Execs) +{ + public IServiceProvider Services { get; set; } = null!; +} \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/Models/SnekCommandData.cs b/src/NadekoBot/Common/Medusa/Models/SnekCommandData.cs new file mode 100644 index 000000000..a4cafe588 --- /dev/null +++ b/src/NadekoBot/Common/Medusa/Models/SnekCommandData.cs @@ -0,0 +1,41 @@ +using Microsoft.VisualBasic; +using System.Reflection; +using CommandStrings = Nadeko.Snake.CommandStrings; + +namespace Nadeko.Medusa; + +public sealed class SnekCommandData +{ + public SnekCommandData( + IReadOnlyCollection aliases, + MethodInfo methodInfo, + Snek module, + FilterAttribute[] filters, + CommandContextType contextType, + IReadOnlyList injectedParams, + IReadOnlyList parameters, + CommandStrings strings, + int priority) + { + Aliases = aliases; + MethodInfo = methodInfo; + Module = module; + Filters = filters; + ContextType = contextType; + InjectedParams = injectedParams; + Parameters = parameters; + Priority = priority; + OptionalStrings = strings; + } + + public CommandStrings OptionalStrings { get; set; } + + public IReadOnlyCollection Aliases { get; } + public MethodInfo MethodInfo { get; set; } + public Snek Module { get; set; } + public FilterAttribute[] Filters { get; set; } + public CommandContextType ContextType { get; } + public IReadOnlyList InjectedParams { get; } + public IReadOnlyList Parameters { get; } + public int Priority { get; } +} \ No newline at end of file diff --git a/src/NadekoBot/Common/Medusa/Models/SnekData.cs b/src/NadekoBot/Common/Medusa/Models/SnekData.cs new file mode 100644 index 000000000..95b46da72 --- /dev/null +++ b/src/NadekoBot/Common/Medusa/Models/SnekData.cs @@ -0,0 +1,11 @@ +namespace Nadeko.Medusa; + +public sealed record SnekInfo( + string Name, + SnekInfo? Parent, + Snek Instance, + IReadOnlyCollection Commands, + IReadOnlyCollection Filters) +{ + public List Subsneks { get; set; } = new(); +} \ No newline at end of file diff --git a/src/NadekoBot/Common/ModuleBehaviors/IEarlyBehavior.cs b/src/NadekoBot/Common/ModuleBehaviors/IEarlyBehavior.cs deleted file mode 100644 index 9236a22d4..000000000 --- a/src/NadekoBot/Common/ModuleBehaviors/IEarlyBehavior.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace NadekoBot.Common.ModuleBehaviors; - -/// -/// Implemented by modules which block execution before anything is executed -/// -public interface IEarlyBehavior -{ - int Priority { get; } - Task RunBehavior(IGuild guild, IUserMessage msg); -} \ No newline at end of file diff --git a/src/NadekoBot/Common/ModuleBehaviors/IExecNoCommand.cs b/src/NadekoBot/Common/ModuleBehaviors/IExecNoCommand.cs new file mode 100644 index 000000000..1edcf705c --- /dev/null +++ b/src/NadekoBot/Common/ModuleBehaviors/IExecNoCommand.cs @@ -0,0 +1,19 @@ +namespace NadekoBot.Common.ModuleBehaviors; + +/// +/// Executed if no command was found for this message +/// +public interface IExecNoCommand +{ + /// + /// Executed at the end of the lifecycle if no command was found + /// → + /// → + /// → + /// [ | **] + /// + /// + /// + /// A task representing completion + Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg); +} \ No newline at end of file diff --git a/src/NadekoBot/Common/ModuleBehaviors/IExecOnMessage.cs b/src/NadekoBot/Common/ModuleBehaviors/IExecOnMessage.cs new file mode 100644 index 000000000..32f90d2a9 --- /dev/null +++ b/src/NadekoBot/Common/ModuleBehaviors/IExecOnMessage.cs @@ -0,0 +1,21 @@ +namespace NadekoBot.Common.ModuleBehaviors; + +/// +/// Implemented by modules to handle non-bot messages received +/// +public interface IExecOnMessage +{ + int Priority { get; } + + /// + /// Ran after a non-bot message was received + /// ** → + /// → + /// → + /// [ | ] + /// + /// Guild where the message was sent + /// The message that was received + /// Whether further processing of this message should be blocked + Task ExecOnMessageAsync(IGuild guild, IUserMessage msg); +} \ No newline at end of file diff --git a/src/NadekoBot/Common/ModuleBehaviors/IExecPostCommand.cs b/src/NadekoBot/Common/ModuleBehaviors/IExecPostCommand.cs new file mode 100644 index 000000000..621b06bac --- /dev/null +++ b/src/NadekoBot/Common/ModuleBehaviors/IExecPostCommand.cs @@ -0,0 +1,22 @@ +namespace NadekoBot.Common.ModuleBehaviors; + +/// +/// This interface's method is executed after the command successfully finished execution. +/// ***There is no support for this method in NadekoBot services.*** +/// It is only meant to be used in medusa system +/// +public interface IExecPostCommand +{ + /// + /// Executed after a command was successfully executed + /// → + /// → + /// → + /// [** | ] + /// + /// Command context + /// Module name + /// Command name + /// A task representing completion + ValueTask ExecPostCommandAsync(ICommandContext ctx, string moduleName, string commandName); +} \ No newline at end of file diff --git a/src/NadekoBot/Common/ModuleBehaviors/IExecPreCommand.cs b/src/NadekoBot/Common/ModuleBehaviors/IExecPreCommand.cs new file mode 100644 index 000000000..7f8b1f05d --- /dev/null +++ b/src/NadekoBot/Common/ModuleBehaviors/IExecPreCommand.cs @@ -0,0 +1,25 @@ +namespace NadekoBot.Common.ModuleBehaviors; + +/// +/// This interface's method is executed after a command was found but before it was executed. +/// Able to block further processing of a command +/// +public interface IExecPreCommand +{ + public int Priority { get; } + + /// + /// + /// Ran after a command was found but before execution. + /// + /// → + /// → + /// ** → + /// [ | ] + /// + /// Command context + /// Name of the module + /// Command info + /// Whether further processing of the command is blocked + Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command); +} \ No newline at end of file diff --git a/src/NadekoBot/Common/ModuleBehaviors/IInputTransformer.cs b/src/NadekoBot/Common/ModuleBehaviors/IInputTransformer.cs index c8446f633..7b320ae83 100644 --- a/src/NadekoBot/Common/ModuleBehaviors/IInputTransformer.cs +++ b/src/NadekoBot/Common/ModuleBehaviors/IInputTransformer.cs @@ -1,8 +1,23 @@ namespace NadekoBot.Common.ModuleBehaviors; +/// +/// Implemented by services which may transform input before a command is searched for +/// public interface IInputTransformer { - Task TransformInput( + /// + /// Ran after a non-bot message was received + /// -> + /// ** -> + /// -> + /// [ OR ] + /// + /// Guild + /// Channel in which the message was sent + /// User who sent the message + /// Content of the message + /// New input, if any, otherwise null + Task TransformInput( IGuild guild, IMessageChannel channel, IUser user, diff --git a/src/NadekoBot/Common/ModuleBehaviors/ILateBlocker.cs b/src/NadekoBot/Common/ModuleBehaviors/ILateBlocker.cs deleted file mode 100644 index a9e8c617a..000000000 --- a/src/NadekoBot/Common/ModuleBehaviors/ILateBlocker.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace NadekoBot.Common.ModuleBehaviors; - -public interface ILateBlocker -{ - public int Priority { get; } - - Task TryBlockLate(ICommandContext context, string moduleName, CommandInfo command); -} \ No newline at end of file diff --git a/src/NadekoBot/Common/ModuleBehaviors/ILateExecutor.cs b/src/NadekoBot/Common/ModuleBehaviors/ILateExecutor.cs deleted file mode 100644 index d4ce7bccf..000000000 --- a/src/NadekoBot/Common/ModuleBehaviors/ILateExecutor.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NadekoBot.Common.ModuleBehaviors; - -/// -/// Last thing to be executed, won't stop further executions -/// -public interface ILateExecutor -{ - Task LateExecute(IGuild guild, IUserMessage msg); -} \ No newline at end of file diff --git a/src/NadekoBot/Common/NoPublicBotPrecondition.cs b/src/NadekoBot/Common/NoPublicBotPrecondition.cs index 8eca3e031..432c1ef6c 100644 --- a/src/NadekoBot/Common/NoPublicBotPrecondition.cs +++ b/src/NadekoBot/Common/NoPublicBotPrecondition.cs @@ -18,4 +18,13 @@ public sealed class NoPublicBotAttribute : PreconditionAttribute return Task.FromResult(PreconditionResult.FromSuccess()); #endif } +} + +/// +/// Classed marked with this attribute will not be added to the service provider +/// +[AttributeUsage(AttributeTargets.Class)] +public class DontAddToIocContainerAttribute : Attribute +{ + } \ No newline at end of file diff --git a/src/NadekoBot/Common/TypeReaders/NadekoTypeReader.cs b/src/NadekoBot/Common/TypeReaders/NadekoTypeReader.cs index ea7b5a115..e5ef5d632 100644 --- a/src/NadekoBot/Common/TypeReaders/NadekoTypeReader.cs +++ b/src/NadekoBot/Common/TypeReaders/NadekoTypeReader.cs @@ -1,4 +1,5 @@ #nullable disable + namespace NadekoBot.Common.TypeReaders; [MeansImplicitUse(ImplicitUseTargetFlags.Default | ImplicitUseTargetFlags.WithInheritors)] diff --git a/src/NadekoBot/Directory.Build.props b/src/NadekoBot/Directory.Build.props new file mode 100644 index 000000000..d157d2874 --- /dev/null +++ b/src/NadekoBot/Directory.Build.props @@ -0,0 +1,7 @@ + + + + all + + + \ No newline at end of file diff --git a/src/NadekoBot/GlobalUsings.cs b/src/NadekoBot/GlobalUsings.cs index cb6993591..47f23928b 100644 --- a/src/NadekoBot/GlobalUsings.cs +++ b/src/NadekoBot/GlobalUsings.cs @@ -10,6 +10,7 @@ global using NadekoBot.Services; global using NadekoBot.Common; global using NadekoBot.Common.Attributes; global using NadekoBot.Extensions; +global using Nadeko.Snake; // discord global using Discord; diff --git a/src/NadekoBot/Modules/Administration/ImageOnlyChannelService.cs b/src/NadekoBot/Modules/Administration/ImageOnlyChannelService.cs index 450b6a0b5..0860fa618 100644 --- a/src/NadekoBot/Modules/Administration/ImageOnlyChannelService.cs +++ b/src/NadekoBot/Modules/Administration/ImageOnlyChannelService.cs @@ -7,7 +7,7 @@ using System.Threading.Channels; namespace NadekoBot.Modules.Administration.Services; -public sealed class ImageOnlyChannelService : IEarlyBehavior +public sealed class ImageOnlyChannelService : IExecOnMessage { public int Priority { get; } = 0; private readonly IMemoryCache _ticketCache; @@ -93,7 +93,7 @@ public sealed class ImageOnlyChannelService : IEarlyBehavior return newState; } - public async Task RunBehavior(IGuild guild, IUserMessage msg) + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage msg) { if (msg.Channel is not ITextChannel tch) return false; diff --git a/src/NadekoBot/Modules/Administration/PermOverrides/DiscordPermOverrideService.cs b/src/NadekoBot/Modules/Administration/PermOverrides/DiscordPermOverrideService.cs index 7fcb584e3..847982377 100644 --- a/src/NadekoBot/Modules/Administration/PermOverrides/DiscordPermOverrideService.cs +++ b/src/NadekoBot/Modules/Administration/PermOverrides/DiscordPermOverrideService.cs @@ -5,7 +5,7 @@ using NadekoBot.Services.Database.Models; namespace NadekoBot.Modules.Administration.Services; -public class DiscordPermOverrideService : INService, ILateBlocker +public class DiscordPermOverrideService : INService, IExecPreCommand { public int Priority { get; } = int.MaxValue; private readonly DbService _db; @@ -118,7 +118,7 @@ public class DiscordPermOverrideService : INService, ILateBlocker .ToListAsync(); } - public async Task TryBlockLate(ICommandContext context, string moduleName, CommandInfo command) + public async Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command) { if (TryGetOverrides(context.Guild?.Id ?? 0, command.Name, out var perm) && perm is not null) { diff --git a/src/NadekoBot/Modules/Administration/Self/SelfCommands.cs b/src/NadekoBot/Modules/Administration/Self/SelfCommands.cs index 54a725834..be238ca63 100644 --- a/src/NadekoBot/Modules/Administration/Self/SelfCommands.cs +++ b/src/NadekoBot/Modules/Administration/Self/SelfCommands.cs @@ -1,4 +1,5 @@ #nullable disable +using Nadeko.Medusa; using NadekoBot.Modules.Administration.Services; using NadekoBot.Services.Database.Models; @@ -19,13 +20,19 @@ public partial class Administration private readonly DiscordSocketClient _client; private readonly IBotStrings _strings; + private readonly IMedusaLoaderService _medusaLoader; private readonly ICoordinator _coord; - public SelfCommands(DiscordSocketClient client, IBotStrings strings, ICoordinator coord) + public SelfCommands( + DiscordSocketClient client, + IBotStrings strings, + ICoordinator coord, + IMedusaLoaderService medusaLoader) { _client = client; _strings = strings; _coord = coord; + _medusaLoader = medusaLoader; } [Cmd] @@ -506,6 +513,7 @@ public partial class Administration public async partial Task StringsReload() { _strings.Reload(); + await _medusaLoader.ReloadStrings(); await ReplyConfirmLocalizedAsync(strs.bot_strings_reloaded); } diff --git a/src/NadekoBot/Modules/Administration/Self/SelfService.cs b/src/NadekoBot/Modules/Administration/Self/SelfService.cs index e7e4808bf..56ddd88f0 100644 --- a/src/NadekoBot/Modules/Administration/Self/SelfService.cs +++ b/src/NadekoBot/Modules/Administration/Self/SelfService.cs @@ -6,7 +6,7 @@ using System.Collections.Immutable; namespace NadekoBot.Modules.Administration.Services; -public sealed class SelfService : ILateExecutor, IReadyExecutor, INService +public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService { private readonly CommandHandler _cmdHandler; private readonly DbService _db; @@ -206,7 +206,7 @@ public sealed class SelfService : ILateExecutor, IReadyExecutor, INService => _pubSub.Pub(_guildLeaveKey, guildStr); // forwards dms - public async Task LateExecute(IGuild guild, IUserMessage msg) + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) { var bs = _bss.Data; if (msg.Channel is IDMChannel && bs.ForwardMessages && ownerChannels.Any()) diff --git a/src/NadekoBot/Modules/Administration/ServerLog/ServerLogCommandService.cs b/src/NadekoBot/Modules/Administration/ServerLog/ServerLogCommandService.cs index 1ca000b87..832b96b4c 100644 --- a/src/NadekoBot/Modules/Administration/ServerLog/ServerLogCommandService.cs +++ b/src/NadekoBot/Modules/Administration/ServerLog/ServerLogCommandService.cs @@ -913,7 +913,7 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor var embed = _eb.Create() .WithOkColor() .WithTitle("♻️ " + GetText(logChannel.Guild, strs.user_unbanned)) - .WithDescription(usr.ToString()) + .WithDescription(usr.ToString()!) .AddField("Id", usr.Id.ToString()) .WithFooter(CurrentTime(guild)); @@ -948,7 +948,7 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor var embed = _eb.Create() .WithOkColor() .WithTitle("🚫 " + GetText(logChannel.Guild, strs.user_banned)) - .WithDescription(usr.ToString()) + .WithDescription(usr.ToString()!) .AddField("Id", usr.Id.ToString()) .WithFooter(CurrentTime(guild)); @@ -999,7 +999,7 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor .WithOkColor() .WithTitle("🗑 " + GetText(logChannel.Guild, strs.msg_del(((ITextChannel)msg.Channel).Name))) - .WithDescription(msg.Author.ToString()) + .WithDescription(msg.Author.ToString()!) .AddField(GetText(logChannel.Guild, strs.content), string.IsNullOrWhiteSpace(resolvedMessage) ? "-" : resolvedMessage) .AddField("Id", msg.Id.ToString()) @@ -1060,7 +1060,7 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor .WithTitle("📝 " + GetText(logChannel.Guild, strs.msg_update(((ITextChannel)after.Channel).Name))) - .WithDescription(after.Author.ToString()) + .WithDescription(after.Author.ToString()!) .AddField(GetText(logChannel.Guild, strs.old_msg), string.IsNullOrWhiteSpace(before.Content) ? "-" diff --git a/src/NadekoBot/Modules/Administration/UserPunish/UserPunishCommands.cs b/src/NadekoBot/Modules/Administration/UserPunish/UserPunishCommands.cs index a6f174ec9..7e19c56e6 100644 --- a/src/NadekoBot/Modules/Administration/UserPunish/UserPunishCommands.cs +++ b/src/NadekoBot/Modules/Administration/UserPunish/UserPunishCommands.cs @@ -577,7 +577,7 @@ public partial class Administration [BotPerm(GuildPerm.BanMembers)] public async partial Task Unban([Leftover] string user) { - var bans = await ctx.Guild.GetBansAsync(); + var bans = await ctx.Guild.GetBansAsync().FlattenAsync(); var bun = bans.FirstOrDefault(x => x.User.ToString()!.ToLowerInvariant() == user.ToLowerInvariant()); @@ -596,9 +596,7 @@ public partial class Administration [BotPerm(GuildPerm.BanMembers)] public async partial Task Unban(ulong userId) { - var bans = await ctx.Guild.GetBansAsync(); - - var bun = bans.FirstOrDefault(x => x.User.Id == userId); + var bun = await ctx.Guild.GetBanAsync(userId); if (bun is null) { diff --git a/src/NadekoBot/Modules/CustomReactions/NadekoExpressionsService.cs b/src/NadekoBot/Modules/CustomReactions/NadekoExpressionsService.cs index 02bfe93bf..3cfef526b 100644 --- a/src/NadekoBot/Modules/CustomReactions/NadekoExpressionsService.cs +++ b/src/NadekoBot/Modules/CustomReactions/NadekoExpressionsService.cs @@ -12,7 +12,7 @@ using YamlDotNet.Serialization.NamingConventions; namespace NadekoBot.Modules.NadekoExpressions; -public sealed class NadekoExpressionsService : IEarlyBehavior, IReadyExecutor +public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor { private const string MENTION_PH = "%bot.mention%"; @@ -220,7 +220,7 @@ public sealed class NadekoExpressionsService : IEarlyBehavior, IReadyExecutor return result[_rng.Next(0, result.Count)]; } - public async Task RunBehavior(IGuild guild, IUserMessage msg) + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage msg) { // maybe this message is an expression var expr = TryGetExpression(msg); diff --git a/src/NadekoBot/Modules/Gambling/Events/GameStatusEvent.cs b/src/NadekoBot/Modules/Gambling/Events/GameStatusEvent.cs index 105807ba8..730357d82 100644 --- a/src/NadekoBot/Modules/Gambling/Events/GameStatusEvent.cs +++ b/src/NadekoBot/Modules/Gambling/Events/GameStatusEvent.cs @@ -93,7 +93,7 @@ public class GameStatusEvent : ICurrencyEvent }); } - Log.Information("Awarded {Count} users {Amount} currency.{Remaining}", + Log.Information("Game status event awarded {Count} users {Amount} currency.{Remaining}", toAward.Count, _amount, _isPotLimited ? $" {PotSize} left." : ""); diff --git a/src/NadekoBot/Modules/Gambling/Events/ReactionEvent.cs b/src/NadekoBot/Modules/Gambling/Events/ReactionEvent.cs index 1651ca36b..bececebda 100644 --- a/src/NadekoBot/Modules/Gambling/Events/ReactionEvent.cs +++ b/src/NadekoBot/Modules/Gambling/Events/ReactionEvent.cs @@ -86,7 +86,7 @@ public class ReactionEvent : ICurrencyEvent }); } - Log.Information("Awarded {Count} users {Amount} currency.{Remaining}", + Log.Information("Reaction Event awarded {Count} users {Amount} currency.{Remaining}", toAward.Count, _amount, _isPotLimited ? $" {PotSize} left." : ""); diff --git a/src/NadekoBot/Modules/Gambling/PlantPick/PlantPickService.cs b/src/NadekoBot/Modules/Gambling/PlantPick/PlantPickService.cs index 5ecd56e10..7036ed7b4 100644 --- a/src/NadekoBot/Modules/Gambling/PlantPick/PlantPickService.cs +++ b/src/NadekoBot/Modules/Gambling/PlantPick/PlantPickService.cs @@ -1,5 +1,6 @@ #nullable disable using Microsoft.EntityFrameworkCore; +using NadekoBot.Common.ModuleBehaviors; using NadekoBot.Db; using NadekoBot.Services.Database.Models; using SixLabors.Fonts; @@ -12,7 +13,7 @@ using Image = SixLabors.ImageSharp.Image; namespace NadekoBot.Modules.Gambling.Services; -public class PlantPickService : INService +public class PlantPickService : INService, IExecNoCommand { //channelId/last generation public ConcurrentDictionary LastGenerations { get; } = new(); @@ -49,8 +50,7 @@ public class PlantPickService : INService _rng = new(); _client = client; _gss = gss; - - cmd.OnMessageNoTrigger += PotentialFlowerGeneration; + using var uow = db.GetDbContext(); var guildIds = client.Guilds.Select(x => x.Id).ToList(); var configs = uow.Set() @@ -62,6 +62,9 @@ public class PlantPickService : INService _generationChannels = new(configs.SelectMany(c => c.GenerateCurrencyChannelIds.Select(obj => obj.ChannelId))); } + public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + => PotentialFlowerGeneration(msg); + private string GetText(ulong gid, LocStr str) => _strings.GetText(str, gid); diff --git a/src/NadekoBot/Modules/Games/ChatterBot/ChatterbotService.cs b/src/NadekoBot/Modules/Games/ChatterBot/ChatterbotService.cs index 4ea788798..6d925f2b2 100644 --- a/src/NadekoBot/Modules/Games/ChatterBot/ChatterbotService.cs +++ b/src/NadekoBot/Modules/Games/ChatterBot/ChatterbotService.cs @@ -6,7 +6,7 @@ using NadekoBot.Modules.Permissions.Services; namespace NadekoBot.Modules.Games.Services; -public class ChatterBotService : IEarlyBehavior +public class ChatterBotService : IExecOnMessage { public ConcurrentDictionary> ChatterBotGuilds { get; } @@ -95,7 +95,7 @@ public class ChatterBotService : IEarlyBehavior return true; } - public async Task RunBehavior(IGuild guild, IUserMessage usrMsg) + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage usrMsg) { if (guild is not SocketGuild sg) return false; diff --git a/src/NadekoBot/Modules/Games/Hangman/HangmanService.cs b/src/NadekoBot/Modules/Games/Hangman/HangmanService.cs index d57ae3725..249c27dd8 100644 --- a/src/NadekoBot/Modules/Games/Hangman/HangmanService.cs +++ b/src/NadekoBot/Modules/Games/Hangman/HangmanService.cs @@ -5,7 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace NadekoBot.Modules.Games.Hangman; -public sealed class HangmanService : IHangmanService, ILateExecutor +public sealed class HangmanService : IHangmanService, IExecNoCommand { private readonly ConcurrentDictionary _hangmanGames = new(); private readonly IHangmanSource _source; @@ -64,7 +64,7 @@ public sealed class HangmanService : IHangmanService, ILateExecutor public IReadOnlyCollection GetHangmanTypes() => _source.GetCategories(); - public async Task LateExecute(IGuild guild, IUserMessage msg) + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) { if (_hangmanGames.ContainsKey(msg.Channel.Id)) { diff --git a/src/NadekoBot/Modules/Games/Polls/PollService.cs b/src/NadekoBot/Modules/Games/Polls/PollService.cs index 00f23c9d3..e4a850c7f 100644 --- a/src/NadekoBot/Modules/Games/Polls/PollService.cs +++ b/src/NadekoBot/Modules/Games/Polls/PollService.cs @@ -7,7 +7,7 @@ using NadekoBot.Services.Database.Models; namespace NadekoBot.Modules.Games.Services; -public class PollService : IEarlyBehavior +public class PollService : IExecOnMessage { public ConcurrentDictionary ActivePolls { get; } = new(); @@ -103,7 +103,7 @@ public class PollService : IEarlyBehavior catch { } } - public async Task RunBehavior(IGuild guild, IUserMessage msg) + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage msg) { if (guild is null) return false; diff --git a/src/NadekoBot/Modules/Help/Help.cs b/src/NadekoBot/Modules/Help/Help.cs index 2f6aace34..6f9563b8b 100644 --- a/src/NadekoBot/Modules/Help/Help.cs +++ b/src/NadekoBot/Modules/Help/Help.cs @@ -1,5 +1,6 @@ #nullable disable using Amazon.S3; +using Nadeko.Medusa; using NadekoBot.Modules.Help.Common; using NadekoBot.Modules.Help.Services; using NadekoBot.Modules.Permissions.Services; @@ -23,6 +24,7 @@ public partial class Help : NadekoModule private readonly IBotStrings _strings; private readonly AsyncLazy _lazyClientId; + private readonly IMedusaLoaderService _medusae; public Help( GlobalPermissionService perms, @@ -30,7 +32,8 @@ public partial class Help : NadekoModule BotConfigService bss, IServiceProvider services, DiscordSocketClient client, - IBotStrings strings) + IBotStrings strings, + IMedusaLoaderService medusae) { _cmds = cmds; _bss = bss; @@ -38,6 +41,7 @@ public partial class Help : NadekoModule _services = services; _client = client; _strings = strings; + _medusae = medusae; _lazyClientId = new(async () => (await _client.GetApplicationInfoAsync()).Id); } @@ -329,8 +333,8 @@ public partial class Help : NadekoModule return new CommandJsonObject { Aliases = com.Aliases.Select(alias => prefix + alias).ToArray(), - Description = com.RealSummary(_strings, ctx.Guild?.Id, prefix), - Usage = com.RealRemarksArr(_strings, ctx.Guild?.Id, prefix), + Description = com.RealSummary(_strings, _medusae, Culture, prefix), + Usage = com.RealRemarksArr(_strings, _medusae, Culture, prefix), Submodule = com.Module.Name, Module = com.Module.GetTopLevelModule().Name, Options = optHelpStr, diff --git a/src/NadekoBot/Modules/Help/HelpService.cs b/src/NadekoBot/Modules/Help/HelpService.cs index ef02561bc..2f5b96095 100644 --- a/src/NadekoBot/Modules/Help/HelpService.cs +++ b/src/NadekoBot/Modules/Help/HelpService.cs @@ -1,33 +1,40 @@ #nullable disable using CommandLine; +using Nadeko.Medusa; using NadekoBot.Common.ModuleBehaviors; using NadekoBot.Modules.Administration.Services; namespace NadekoBot.Modules.Help.Services; -public class HelpService : ILateExecutor, INService +public class HelpService : IExecNoCommand, INService { private readonly CommandHandler _ch; private readonly IBotStrings _strings; private readonly DiscordPermOverrideService _dpos; private readonly BotConfigService _bss; private readonly IEmbedBuilderService _eb; + private readonly ILocalization _loc; + private readonly IMedusaLoaderService _medusae; public HelpService( CommandHandler ch, IBotStrings strings, DiscordPermOverrideService dpos, BotConfigService bss, - IEmbedBuilderService eb) + IEmbedBuilderService eb, + ILocalization loc, + IMedusaLoaderService medusae) { _ch = ch; _strings = strings; _dpos = dpos; _bss = bss; _eb = eb; + _loc = loc; + _medusae = medusae; } - public Task LateExecute(IGuild guild, IUserMessage msg) + public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) { var settings = _bss.Data; if (guild is null) @@ -57,13 +64,16 @@ public class HelpService : ILateExecutor, INService public IEmbedBuilder GetCommandHelp(CommandInfo com, IGuild guild) { var prefix = _ch.GetPrefix(guild); - + var str = $"**`{prefix + com.Aliases.First()}`**"; var alias = com.Aliases.Skip(1).FirstOrDefault(); if (alias is not null) str += $" **/ `{prefix + alias}`**"; - var em = _eb.Create().AddField(str, $"{com.RealSummary(_strings, guild?.Id, prefix)}", true); + var culture = _loc.GetCultureInfo(guild); + + var em = _eb.Create() + .AddField(str, $"{com.RealSummary(_strings, _medusae, culture, prefix)}", true); _dpos.TryGetOverrides(guild?.Id ?? 0, com.Name, out var overrides); var reqs = GetCommandRequirements(com, overrides); @@ -72,7 +82,7 @@ public class HelpService : ILateExecutor, INService em.AddField(_strings.GetText(strs.usage), string.Join("\n", - Array.ConvertAll(com.RealRemarksArr(_strings, guild?.Id, prefix), arg => Format.Code(arg)))) + Array.ConvertAll(com.RealRemarksArr(_strings,_medusae, culture, prefix), arg => Format.Code(arg)))) .WithFooter(GetText(strs.module(com.Module.GetTopLevelModule().Name), guild)) .WithOkColor(); diff --git a/src/NadekoBot/Modules/Medusae/Medusa.cs b/src/NadekoBot/Modules/Medusae/Medusa.cs new file mode 100644 index 000000000..ec58afb92 --- /dev/null +++ b/src/NadekoBot/Modules/Medusae/Medusa.cs @@ -0,0 +1,200 @@ +using Nadeko.Medusa; + +namespace NadekoBot.Modules; + +[OwnerOnly] +public partial class Medusa : NadekoModule +{ + [Cmd] + [OwnerOnly] + public async partial Task MedusaLoad(string? name = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + var loaded = _service.GetLoadedMedusae() + .Select(x => x.Name) + .ToHashSet(); + + var unloaded = _service.GetAllMedusae() + .Where(x => !loaded.Contains(x)) + .Select(x => Format.Code(x.ToString())) + .ToArray(); + + if (unloaded.Length == 0) + { + await ReplyPendingLocalizedAsync(strs.no_medusa_available); + return; + } + + await ctx.SendPaginatedConfirmAsync(0, + page => + { + return _eb.Create(ctx) + .WithOkColor() + .WithTitle(GetText(strs.list_of_unloaded)) + .WithDescription(unloaded.Skip(10 * page).Take(10).Join('\n')); + }, + unloaded.Length, + 10); + return; + } + + var res = await _service.LoadMedusaAsync(name); + if (res == MedusaLoadResult.Success) + await ReplyConfirmLocalizedAsync(strs.medusa_loaded(Format.Code(name))); + else + { + var locStr = res switch + { + MedusaLoadResult.Empty => strs.medusa_empty, + MedusaLoadResult.AlreadyLoaded => strs.medusa_already_loaded(Format.Code(name)), + MedusaLoadResult.NotFound => strs.medusa_invalid_not_found, + MedusaLoadResult.UnknownError => strs.error_occured, + _ => strs.error_occured + }; + + await ReplyErrorLocalizedAsync(locStr); + } + } + + [Cmd] + [OwnerOnly] + public async partial Task MedusaUnload(string? name = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + var loaded = _service.GetLoadedMedusae(); + if (loaded.Count == 0) + { + await ReplyPendingLocalizedAsync(strs.no_medusa_loaded); + return; + } + + await ctx.Channel.EmbedAsync(_eb.Create(ctx) + .WithOkColor() + .WithTitle(GetText(strs.loaded_medusae)) + .WithDescription(loaded.Select(x => x.Name) + .Join("\n"))); + + return; + } + + var res = await _service.UnloadMedusaAsync(name); + if (res == MedusaUnloadResult.Success) + await ReplyConfirmLocalizedAsync(strs.medusa_unloaded(Format.Code(name))); + else + { + var locStr = res switch + { + MedusaUnloadResult.NotLoaded => strs.medusa_not_loaded, + MedusaUnloadResult.PossiblyUnable => strs.medusa_possibly_cant_unload, + _ => strs.error_occured + }; + + await ReplyErrorLocalizedAsync(locStr); + } + } + + [Cmd] + [OwnerOnly] + public async partial Task MedusaList() + { + var all = _service.GetAllMedusae(); + + if (all.Count == 0) + { + await ReplyPendingLocalizedAsync(strs.no_medusa_available); + return; + } + + var loaded = _service.GetLoadedMedusae() + .Select(x => x.Name) + .ToHashSet(); + + var output = all + .Select(m => + { + var emoji = loaded.Contains(m) ? "`✅`" : "`🔴`"; + return $"{emoji} `{m}`"; + }) + .ToArray(); + + + await ctx.SendPaginatedConfirmAsync(0, + page => _eb.Create(ctx) + .WithOkColor() + .WithTitle(GetText(strs.list_of_medusae)) + .WithDescription(output.Skip(page * 10).Take(10).Join('\n')), + output.Length, + 10); + } + + [Cmd] + [OwnerOnly] + public async partial Task MedusaInfo(string? name = null) + { + var medusae = _service.GetLoadedMedusae(); + + if (name is not null) + { + var found = medusae.FirstOrDefault(x => string.Equals(x.Name, + name, + StringComparison.InvariantCultureIgnoreCase)); + + if (found is null) + { + await ReplyErrorLocalizedAsync(strs.medusa_name_not_found); + return; + } + + var cmdCount = found.Sneks.Sum(x => x.Commands.Count); + var cmdNames = found.Sneks + .SelectMany(x => x.Commands) + .Select(x => Format.Code(x.Name)) + .Join(" | "); + + var eb = _eb.Create(ctx) + .WithOkColor() + .WithAuthor(GetText(strs.medusa_info)) + .WithTitle(found.Name) + .WithDescription(found.Description) + .AddField(GetText(strs.sneks_count(found.Sneks.Count)), + found.Sneks.Count == 0 + ? "-" + : found.Sneks.Select(x => x.Name).Join('\n'), + true) + .AddField(GetText(strs.commands_count(cmdCount)), + string.IsNullOrWhiteSpace(cmdNames) + ? "-" + : cmdNames, + true); + + await ctx.Channel.EmbedAsync(eb); + return; + } + + if (medusae.Count == 0) + { + await ReplyPendingLocalizedAsync(strs.no_medusa_loaded); + return; + } + + await ctx.SendPaginatedConfirmAsync(0, + page => + { + var eb = _eb.Create(ctx) + .WithOkColor(); + + foreach (var medusa in medusae.Skip(page * 9).Take(9)) + { + eb.AddField(medusa.Name, + $@"`Sneks:` {medusa.Sneks.Count} +`Commands:` {medusa.Sneks.Sum(x => x.Commands.Count)} +-- +{medusa.Description}"); + } + + return eb; + }, medusae.Count, 9); + } +} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Permissions/Blacklist/BlacklistService.cs b/src/NadekoBot/Modules/Permissions/Blacklist/BlacklistService.cs index 638c71289..2ffd393de 100644 --- a/src/NadekoBot/Modules/Permissions/Blacklist/BlacklistService.cs +++ b/src/NadekoBot/Modules/Permissions/Blacklist/BlacklistService.cs @@ -6,7 +6,7 @@ using NadekoBot.Services.Database.Models; namespace NadekoBot.Modules.Permissions.Services; -public sealed class BlacklistService : IEarlyBehavior +public sealed class BlacklistService : IExecOnMessage { public int Priority => int.MaxValue; @@ -34,7 +34,7 @@ public sealed class BlacklistService : IEarlyBehavior return default; } - public Task RunBehavior(IGuild guild, IUserMessage usrMsg) + public Task ExecOnMessageAsync(IGuild guild, IUserMessage usrMsg) { foreach (var bl in blacklist) { diff --git a/src/NadekoBot/Modules/Permissions/CommandCooldown/CmdCdService.cs b/src/NadekoBot/Modules/Permissions/CommandCooldown/CmdCdService.cs index 7c8cb3e9e..76371d9e8 100644 --- a/src/NadekoBot/Modules/Permissions/CommandCooldown/CmdCdService.cs +++ b/src/NadekoBot/Modules/Permissions/CommandCooldown/CmdCdService.cs @@ -4,7 +4,7 @@ using NadekoBot.Services.Database.Models; namespace NadekoBot.Modules.Permissions.Services; -public class CmdCdService : ILateBlocker, INService +public class CmdCdService : IExecPreCommand, INService { public ConcurrentDictionary> CommandCooldowns { get; } public ConcurrentDictionary> ActiveCooldowns { get; } = new(); @@ -51,7 +51,7 @@ public class CmdCdService : ILateBlocker, INService return Task.FromResult(false); } - public Task TryBlockLate(ICommandContext ctx, string moduleName, CommandInfo command) + public Task ExecPreCommandAsync(ICommandContext ctx, string moduleName, CommandInfo command) { var guild = ctx.Guild; var user = ctx.User; diff --git a/src/NadekoBot/Modules/Permissions/Filter/FilterService.cs b/src/NadekoBot/Modules/Permissions/Filter/FilterService.cs index 7840d8b9f..6dcd7f790 100644 --- a/src/NadekoBot/Modules/Permissions/Filter/FilterService.cs +++ b/src/NadekoBot/Modules/Permissions/Filter/FilterService.cs @@ -6,7 +6,7 @@ using NadekoBot.Services.Database.Models; namespace NadekoBot.Modules.Permissions.Services; -public sealed class FilterService : IEarlyBehavior +public sealed class FilterService : IExecOnMessage { public ConcurrentHashSet InviteFilteringChannels { get; } public ConcurrentHashSet InviteFilteringServers { get; } @@ -69,7 +69,7 @@ public sealed class FilterService : IEarlyBehavior if (guild is null || newMsg is not IUserMessage usrMsg) return Task.CompletedTask; - return RunBehavior(guild, usrMsg); + return ExecOnMessageAsync(guild, usrMsg); }); return Task.CompletedTask; }; @@ -110,7 +110,7 @@ public sealed class FilterService : IEarlyBehavior return words; } - public async Task RunBehavior(IGuild guild, IUserMessage msg) + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage msg) { if (msg.Author is not IGuildUser gu || gu.GuildPermissions.Administrator) return false; diff --git a/src/NadekoBot/Modules/Permissions/GlobalPermissions/GlobalPermissionService.cs b/src/NadekoBot/Modules/Permissions/GlobalPermissions/GlobalPermissionService.cs index 3f459b128..d5313bac2 100644 --- a/src/NadekoBot/Modules/Permissions/GlobalPermissions/GlobalPermissionService.cs +++ b/src/NadekoBot/Modules/Permissions/GlobalPermissions/GlobalPermissionService.cs @@ -3,7 +3,7 @@ using NadekoBot.Common.ModuleBehaviors; namespace NadekoBot.Modules.Permissions.Services; -public class GlobalPermissionService : ILateBlocker, INService +public class GlobalPermissionService : IExecPreCommand, INService { public int Priority { get; } = 0; @@ -19,7 +19,7 @@ public class GlobalPermissionService : ILateBlocker, INService => _bss = bss; - public Task TryBlockLate(ICommandContext ctx, string moduleName, CommandInfo command) + public Task ExecPreCommandAsync(ICommandContext ctx, string moduleName, CommandInfo command) { var settings = _bss.Data; var commandName = command.Name.ToLowerInvariant(); diff --git a/src/NadekoBot/Modules/Permissions/PermissionsService.cs b/src/NadekoBot/Modules/Permissions/PermissionsService.cs index 05db976ad..d3a311912 100644 --- a/src/NadekoBot/Modules/Permissions/PermissionsService.cs +++ b/src/NadekoBot/Modules/Permissions/PermissionsService.cs @@ -7,7 +7,7 @@ using NadekoBot.Services.Database.Models; namespace NadekoBot.Modules.Permissions.Services; -public class PermissionService : ILateBlocker, INService +public class PermissionService : IExecPreCommand, INService { public int Priority { get; } = 0; @@ -94,7 +94,7 @@ public class PermissionService : ILateBlocker, INService return old; }); - public async Task TryBlockLate(ICommandContext ctx, string moduleName, CommandInfo command) + public async Task ExecPreCommandAsync(ICommandContext ctx, string moduleName, CommandInfo command) { var guild = ctx.Guild; var msg = ctx.Message; diff --git a/src/NadekoBot/Modules/Searches/Translate/TranslateService.cs b/src/NadekoBot/Modules/Searches/Translate/TranslateService.cs index c5eda7c0d..68588e955 100644 --- a/src/NadekoBot/Modules/Searches/Translate/TranslateService.cs +++ b/src/NadekoBot/Modules/Searches/Translate/TranslateService.cs @@ -8,7 +8,7 @@ using System.Net; namespace NadekoBot.Modules.Searches; -public sealed class TranslateService : ITranslateService, ILateExecutor, IReadyExecutor, INService +public sealed class TranslateService : ITranslateService, IExecNoCommand, IReadyExecutor, INService { private readonly IGoogleApiService _google; private readonly DbService _db; @@ -50,7 +50,7 @@ public sealed class TranslateService : ITranslateService, ILateExecutor, IReadyE } - public async Task LateExecute(IGuild guild, IUserMessage msg) + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) { if (string.IsNullOrWhiteSpace(msg.Content)) return; diff --git a/src/NadekoBot/Modules/Utility/CommandMap/CommandMapService.cs b/src/NadekoBot/Modules/Utility/CommandMap/CommandMapService.cs index ce2397fa6..054195e8c 100644 --- a/src/NadekoBot/Modules/Utility/CommandMap/CommandMapService.cs +++ b/src/NadekoBot/Modules/Utility/CommandMap/CommandMapService.cs @@ -52,7 +52,7 @@ public class CommandMapService : IInputTransformer, INService string input) { if (guild is null || string.IsNullOrWhiteSpace(input)) - return input; + return null; if (AliasMaps.TryGetValue(guild.Id, out var maps)) { @@ -86,6 +86,6 @@ public class CommandMapService : IInputTransformer, INService } } - return input; + return null; } } \ No newline at end of file diff --git a/src/NadekoBot/Modules/Utility/ConfigCommands.cs b/src/NadekoBot/Modules/Utility/ConfigCommands.cs index 067a815df..7f15c186f 100644 --- a/src/NadekoBot/Modules/Utility/ConfigCommands.cs +++ b/src/NadekoBot/Modules/Utility/ConfigCommands.cs @@ -8,7 +8,7 @@ public partial class Utility private readonly IEnumerable _settingServices; public ConfigCommands(IEnumerable settingServices) - => _settingServices = settingServices; + => _settingServices = settingServices.Where(x => x.Name != "medusa"); [Cmd] [OwnerOnly] diff --git a/src/NadekoBot/Modules/Xp/XpService.cs b/src/NadekoBot/Modules/Xp/XpService.cs index 4bec7a219..19b740af2 100644 --- a/src/NadekoBot/Modules/Xp/XpService.cs +++ b/src/NadekoBot/Modules/Xp/XpService.cs @@ -18,7 +18,7 @@ using Image = SixLabors.ImageSharp.Image; namespace NadekoBot.Modules.Xp.Services; // todo improve xp with linqtodb -public class XpService : INService, IReadyExecutor +public class XpService : INService, IReadyExecutor, IExecNoCommand { public const int XP_REQUIRED_LVL_1 = 36; @@ -109,8 +109,6 @@ public class XpService : INService, IReadyExecutor _excludedServers = new(allGuildConfigs.Where(x => x.XpSettings.ServerExcluded).Select(x => x.GuildId)); - _cmd.OnMessageNoTrigger += Cmd_OnMessageNoTrigger; - #if !GLOBAL_NADEKO _client.UserVoiceStateUpdated += Client_OnUserVoiceStateUpdated; @@ -552,7 +550,7 @@ public class XpService : INService, IReadyExecutor return true; } - private Task Cmd_OnMessageNoTrigger(IUserMessage arg) + public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage arg) { if (arg.Author is not SocketGuildUser user || user.IsBot) return Task.CompletedTask; diff --git a/src/NadekoBot/NadekoBot.csproj b/src/NadekoBot/NadekoBot.csproj index 870919593..9b94adb48 100644 --- a/src/NadekoBot/NadekoBot.csproj +++ b/src/NadekoBot/NadekoBot.csproj @@ -19,14 +19,16 @@ - - + + all + + - + - + @@ -39,7 +41,7 @@ - + @@ -47,14 +49,18 @@ - + - + + + all + + @@ -86,11 +92,11 @@ - + diff --git a/src/NadekoBot/Services/CommandHandler.cs b/src/NadekoBot/Services/CommandHandler.cs index 49eb2e67f..215c2fba5 100644 --- a/src/NadekoBot/Services/CommandHandler.cs +++ b/src/NadekoBot/Services/CommandHandler.cs @@ -16,7 +16,6 @@ public class CommandHandler : INService, IReadyExecutor public event Func CommandExecuted = delegate { return Task.CompletedTask; }; public event Func CommandErrored = delegate { return Task.CompletedTask; }; - public event Func OnMessageNoTrigger = delegate { return Task.CompletedTask; }; //userid/msg count public ConcurrentDictionary UserMessagesSent { get; } = new(); @@ -27,7 +26,7 @@ public class CommandHandler : INService, IReadyExecutor private readonly CommandService _commandService; private readonly BotConfigService _bss; private readonly Bot _bot; - private readonly IBehaviourExecutor _behaviourExecutor; + private readonly IBehaviorHandler _behaviorHandler; private readonly IServiceProvider _services; private readonly ConcurrentDictionary _prefixes; @@ -41,7 +40,7 @@ public class CommandHandler : INService, IReadyExecutor CommandService commandService, BotConfigService bss, Bot bot, - IBehaviourExecutor behaviourExecutor, + IBehaviorHandler behaviorHandler, // InteractionService interactions, IServiceProvider services) { @@ -49,7 +48,7 @@ public class CommandHandler : INService, IReadyExecutor _commandService = commandService; _bss = bss; _bot = bot; - _behaviourExecutor = behaviourExecutor; + _behaviorHandler = behaviorHandler; _db = db; _services = services; // _interactions = interactions; @@ -243,44 +242,53 @@ public class CommandHandler : INService, IReadyExecutor { var startTime = Environment.TickCount; - var blocked = await _behaviourExecutor.RunEarlyBehavioursAsync(guild, usrMsg); + var blocked = await _behaviorHandler.RunExecOnMessageAsync(guild, usrMsg); if (blocked) return; var blockTime = Environment.TickCount - startTime; - var messageContent = await _behaviourExecutor.RunInputTransformersAsync(guild, usrMsg); + var messageContent = await _behaviorHandler.RunInputTransformersAsync(guild, usrMsg); var prefix = GetPrefix(guild?.Id); var isPrefixCommand = messageContent.StartsWith(".prefix", StringComparison.InvariantCultureIgnoreCase); // execute the command and measure the time it took if (messageContent.StartsWith(prefix, StringComparison.InvariantCulture) || isPrefixCommand) { - var (success, error, info) = await ExecuteCommandAsync(new(_client, usrMsg), + var context = new CommandContext(_client, usrMsg); + var (success, error, info) = await ExecuteCommandAsync(context, messageContent, isPrefixCommand ? 1 : prefix.Length, _services, MultiMatchHandling.Best); startTime = Environment.TickCount - startTime; - if (success) + // if a command is found + if (info is not null) { - await LogSuccessfulExecution(usrMsg, channel as ITextChannel, blockTime, startTime); - await CommandExecuted(usrMsg, info); - return; - } + // if it successfully executed + if (success) + { + await LogSuccessfulExecution(usrMsg, channel as ITextChannel, blockTime, startTime); + await CommandExecuted(usrMsg, info); + await _behaviorHandler.RunPostCommandAsync(context, info.Module.GetTopLevelModule().Name, info); + return; + } - if (error is not null) - { - LogErroredExecution(error, usrMsg, channel as ITextChannel, blockTime, startTime); - if (guild is not null) - await CommandErrored(info, channel as ITextChannel, error); + // if it errored + if (error is not null) + { + LogErroredExecution(error, usrMsg, channel as ITextChannel, blockTime, startTime); + + if (guild is not null) + await CommandErrored(info, channel as ITextChannel, error); + + return; + } } } - else - await OnMessageNoTrigger(usrMsg); - await _behaviourExecutor.RunLateExecutorsAsync(guild, usrMsg); + await _behaviorHandler.RunOnNoCommandAsync(guild, usrMsg); } public Task<(bool Success, string Error, CommandInfo Info)> ExecuteCommandAsync( @@ -384,7 +392,7 @@ public class CommandHandler : INService, IReadyExecutor return (false, null, cmd); //return SearchResult.FromError(CommandError.Exception, "You are on a global cooldown."); - var blocked = await _behaviourExecutor.RunLateBlockersAsync(context, cmd); + var blocked = await _behaviorHandler.RunPreCommandAsync(context, cmd); if (blocked) return (false, null, cmd); diff --git a/src/NadekoBot/Services/IBehaviourExecutor.cs b/src/NadekoBot/Services/IBehaviourExecutor.cs index d7340abe1..3a516b537 100644 --- a/src/NadekoBot/Services/IBehaviourExecutor.cs +++ b/src/NadekoBot/Services/IBehaviourExecutor.cs @@ -1,12 +1,17 @@ #nullable disable namespace NadekoBot.Services; -public interface IBehaviourExecutor +public interface IBehaviorHandler { - public Task RunEarlyBehavioursAsync(SocketGuild guild, IUserMessage usrMsg); - public Task RunInputTransformersAsync(SocketGuild guild, IUserMessage usrMsg); - Task RunLateBlockersAsync(ICommandContext context, CommandInfo cmd); - Task RunLateExecutorsAsync(SocketGuild guild, IUserMessage usrMsg); - - public void Initialize(); + Task AddAsync(ICustomBehavior behavior); + Task AddRangeAsync(IEnumerable behavior); + Task RemoveAsync(ICustomBehavior behavior); + Task RemoveRangeAsync(IEnumerable behs); + + Task RunExecOnMessageAsync(SocketGuild guild, IUserMessage usrMsg); + Task RunInputTransformersAsync(SocketGuild guild, IUserMessage usrMsg); + Task RunPreCommandAsync(ICommandContext context, CommandInfo cmd); + ValueTask RunPostCommandAsync(ICommandContext ctx, string moduleName, CommandInfo cmd); + Task RunOnNoCommandAsync(SocketGuild guild, IUserMessage usrMsg); + void Initialize(); } \ No newline at end of file diff --git a/src/NadekoBot/Services/ICustomBehavior.cs b/src/NadekoBot/Services/ICustomBehavior.cs new file mode 100644 index 000000000..ef948a80e --- /dev/null +++ b/src/NadekoBot/Services/ICustomBehavior.cs @@ -0,0 +1,13 @@ +using NadekoBot.Common.ModuleBehaviors; + +namespace NadekoBot.Services; + +public interface ICustomBehavior + : IExecOnMessage, + IInputTransformer, + IExecPreCommand, + IExecNoCommand, + IExecPostCommand +{ + +} \ No newline at end of file diff --git a/src/NadekoBot/Services/Impl/BehaviorExecutor.cs b/src/NadekoBot/Services/Impl/BehaviorExecutor.cs index 16c5b1b48..06976b499 100644 --- a/src/NadekoBot/Services/Impl/BehaviorExecutor.cs +++ b/src/NadekoBot/Services/Impl/BehaviorExecutor.cs @@ -4,82 +4,299 @@ using NadekoBot.Common.ModuleBehaviors; namespace NadekoBot.Services; -public sealed class BehaviorExecutor : IBehaviourExecutor, INService +// should be renamed to handler as it's not only executing +public sealed class BehaviorHandler : IBehaviorHandler, INService { private readonly IServiceProvider _services; - private IEnumerable lateExecutors; - private IEnumerable lateBlockers; - private IEnumerable earlyBehaviors; - private IEnumerable transformers; + + private IReadOnlyCollection noCommandExecs; + private IReadOnlyCollection preCommandExecs; + private IReadOnlyCollection onMessageExecs; + private IReadOnlyCollection inputTransformers; - public BehaviorExecutor(IServiceProvider services) - => _services = services; + private readonly SemaphoreSlim _customLock = new(1, 1); + private readonly List _customExecs = new(); + + public BehaviorHandler(IServiceProvider services) + { + _services = services; + } public void Initialize() { - lateExecutors = _services.GetServices(); - lateBlockers = _services.GetServices(); - earlyBehaviors = _services.GetServices().OrderByDescending(x => x.Priority); - transformers = _services.GetServices(); + noCommandExecs = _services.GetServices().ToArray(); + preCommandExecs = _services.GetServices().ToArray(); + onMessageExecs = _services.GetServices().OrderByDescending(x => x.Priority).ToArray(); + inputTransformers = _services.GetServices().ToArray(); } - public async Task RunEarlyBehavioursAsync(SocketGuild guild, IUserMessage usrMsg) + #region Add/Remove + + public async Task AddRangeAsync(IEnumerable execs) { - foreach (var beh in earlyBehaviors) + await _customLock.WaitAsync(); + try { - if (await beh.RunBehavior(guild, usrMsg)) + foreach (var exe in execs) + { + if (_customExecs.Contains(exe)) + continue; + + _customExecs.Add(exe); + } + } + finally + { + _customLock.Release(); + } + } + + public async Task AddAsync(ICustomBehavior behavior) + { + await _customLock.WaitAsync(); + try + { + if (_customExecs.Contains(behavior)) + return false; + + _customExecs.Add(behavior); + return true; + } + finally + { + _customLock.Release(); + } + } + + public async Task RemoveAsync(ICustomBehavior behavior) + { + await _customLock.WaitAsync(); + try + { + return _customExecs.Remove(behavior); + } + finally + { + _customLock.Release(); + } + } + + public async Task RemoveRangeAsync(IEnumerable behs) + { + await _customLock.WaitAsync(); + try + { + foreach(var beh in behs) + _customExecs.Remove(beh); + } + finally + { + _customLock.Release(); + } + } + + #endregion + + #region Running + + public async Task RunExecOnMessageAsync(SocketGuild guild, IUserMessage usrMsg) + { + async Task Exec(IReadOnlyCollection execs) + where T : IExecOnMessage + { + foreach (var exec in execs) + { + try + { + if (await exec.ExecOnMessageAsync(guild, usrMsg)) + { + Log.Information("{TypeName} blocked message g:{GuildId} u:{UserId} c:{ChannelId} msg:{Message}", + GetExecName(exec), + guild?.Id, + usrMsg.Author.Id, + usrMsg.Channel.Id, + usrMsg.Content?.TrimTo(10)); + } + } + catch (Exception ex) + { + Log.Error(ex, + "An error occurred in {TypeName} late blocker: {ErrorMessage}", + GetExecName(exec), + ex.Message); + } + } + + return false; + } + + if (await Exec(onMessageExecs)) + { + return true; + } + + await _customLock.WaitAsync(); + try + { + if (await Exec(_customExecs)) return true; } + finally + { + _customLock.Release(); + } return false; } + private string GetExecName(object exec) + => exec is BehaviorAdapter ba + ? ba.ToString() + : exec.GetType().Name; + + public async Task RunPreCommandAsync(ICommandContext ctx, CommandInfo cmd) + { + async Task Exec(IReadOnlyCollection execs) where T: IExecPreCommand + { + foreach (var exec in execs) + { + try + { + if (await exec.ExecPreCommandAsync(ctx, cmd.Module.GetTopLevelModule().Name, cmd)) + { + Log.Information("{TypeName} Pre-Command blocked [{User}] Command: [{Command}]", + GetExecName(exec), + ctx.User, + cmd.Aliases[0]); + return true; + } + } + catch (Exception ex) + { + Log.Error(ex, + "An error occurred in {TypeName} PreCommand: {ErrorMessage}", + GetExecName(exec), + ex.Message); + } + } + + return false; + } + + if (await Exec(preCommandExecs)) + return true; + + await _customLock.WaitAsync(); + try + { + if (await Exec(_customExecs)) + return true; + } + finally + { + _customLock.Release(); + } + + return false; + } + + public async Task RunOnNoCommandAsync(SocketGuild guild, IUserMessage usrMsg) + { + async Task Exec(IReadOnlyCollection execs) where T : IExecNoCommand + { + foreach (var exec in execs) + { + try + { + await exec.ExecOnNoCommandAsync(guild, usrMsg); + } + catch (Exception ex) + { + Log.Error(ex, + "An error occurred in {TypeName} OnNoCommand: {ErrorMessage}", + GetExecName(exec), + ex.Message); + } + } + } + + await Exec(noCommandExecs); + + await _customLock.WaitAsync(); + try + { + await Exec(_customExecs); + } + finally + { + _customLock.Release(); + } + } + public async Task RunInputTransformersAsync(SocketGuild guild, IUserMessage usrMsg) { - var messageContent = usrMsg.Content; - foreach (var exec in transformers) + async Task Exec(IReadOnlyCollection execs, string content) + where T : IInputTransformer { - string newContent; - if ((newContent = await exec.TransformInput(guild, usrMsg.Channel, usrMsg.Author, messageContent)) - != messageContent.ToLowerInvariant()) + foreach (var exec in execs) { - messageContent = newContent; - break; + try + { + var newContent = await exec.TransformInput(guild, usrMsg.Channel, usrMsg.Author, content); + if (newContent is not null) + { + Log.Information("{ExecName} transformed content {OldContent} -> {NewContent}", + GetExecName(exec), + content, + newContent); + return newContent; + } + } + catch (Exception ex) + { + Log.Warning(ex, "An error occured during InputTransform handling: {ErrorMessage}", ex.Message); + } } + + return null; } - return messageContent; - } - - public async Task RunLateBlockersAsync(ICommandContext ctx, CommandInfo cmd) - { - foreach (var exec in lateBlockers) + var newContent = await Exec(inputTransformers, usrMsg.Content); + if (newContent is not null) + return newContent; + + await _customLock.WaitAsync(); + try { - if (await exec.TryBlockLate(ctx, cmd.Module.GetTopLevelModule().Name, cmd)) - { - Log.Information("Late blocking User [{User}] Command: [{Command}] in [{Module}]", - ctx.User, - cmd.Aliases[0], - exec.GetType().Name); - return true; - } + newContent = await Exec(_customExecs, usrMsg.Content); + if (newContent is not null) + return newContent; + } + finally + { + _customLock.Release(); } - return false; + return usrMsg.Content; } - public async Task RunLateExecutorsAsync(SocketGuild guild, IUserMessage usrMsg) + public async ValueTask RunPostCommandAsync(ICommandContext ctx, string moduleName, CommandInfo cmd) { - foreach (var exec in lateExecutors) + foreach (var exec in _customExecs) { try { - await exec.LateExecute(guild, usrMsg); + await exec.ExecPostCommandAsync(ctx, moduleName, cmd.Name); } catch (Exception ex) { - Log.Error(ex, "Error in {TypeName} late executor: {ErrorMessage}", exec.GetType().Name, ex.Message); + Log.Warning(ex, + "An error occured during PostCommand handling in {ExecName}: {ErrorMessage}", + GetExecName(exec), + ex.Message); } } } + + #endregion } \ No newline at end of file diff --git a/src/NadekoBot/Services/Impl/StatsService.cs b/src/NadekoBot/Services/Impl/StatsService.cs index 8600eeb9d..9b3464197 100644 --- a/src/NadekoBot/Services/Impl/StatsService.cs +++ b/src/NadekoBot/Services/Impl/StatsService.cs @@ -7,7 +7,7 @@ namespace NadekoBot.Services; public sealed class StatsService : IStatsService, IReadyExecutor, INService { - public const string BOT_VERSION = "4.0.6"; + public const string BOT_VERSION = "4.1.0"; public string Author => "Kwoth#2452"; diff --git a/src/NadekoBot/_Extensions/Extensions.cs b/src/NadekoBot/_Extensions/Extensions.cs index a1b3339dd..7b5a4c269 100644 --- a/src/NadekoBot/_Extensions/Extensions.cs +++ b/src/NadekoBot/_Extensions/Extensions.cs @@ -1,4 +1,5 @@ using Humanizer.Localisation; +using Nadeko.Medusa; using System.Globalization; using System.Net.Http.Headers; using System.Text.Json; @@ -12,7 +13,7 @@ public static class Extensions new(@"^(https?|ftp)://(?[^\s/$.?#].[^\s]*)$", RegexOptions.Compiled); public static IEmbedBuilder WithAuthor(this IEmbedBuilder eb, IUser author) - => eb.WithAuthor(author.ToString(), author.RealAvatarUrl().ToString()); + => eb.WithAuthor(author.ToString()!, author.RealAvatarUrl().ToString()); public static Task EditAsync(this IUserMessage msg, SmartText text) => text switch @@ -71,17 +72,53 @@ public static class Extensions public static string RealSummary( this CommandInfo cmd, IBotStrings strings, - ulong? guildId, + IMedusaLoaderService medusae, + CultureInfo culture, string prefix) - => string.Format(strings.GetCommandStrings(cmd.Summary, guildId).Desc, prefix); + { + string description; + if (cmd.Remarks?.StartsWith("medusa///") ?? false) + { + // command method name is kept in Summary + // medusa/// is kept in remarks + // this way I can find the name of the medusa, and then name of the command for which + // the description should be loaded + var medusaName = cmd.Remarks.Split("///")[1]; + description = medusae.GetCommandDescription(medusaName, cmd.Summary, culture); + } + else + { + description = strings.GetCommandStrings(cmd.Summary, culture).Desc; + } + + return string.Format(description, prefix); + } public static string[] RealRemarksArr( this CommandInfo cmd, IBotStrings strings, - ulong? guildId, + IMedusaLoaderService medusae, + CultureInfo culture, string prefix) - => Array.ConvertAll(strings.GetCommandStrings(cmd.Summary, guildId).Args, + { + string[] args; + if (cmd.Remarks?.StartsWith("medusa///") ?? false) + { + // command method name is kept in Summary + // medusa/// is kept in remarks + // this way I can find the name of the medusa, + // and command for which data should be loaded + var medusaName = cmd.Remarks.Split("///")[1]; + args = medusae.GetCommandExampleArgs(medusaName, cmd.Summary, culture); + } + else + { + args = strings.GetCommandStrings(cmd.Summary, culture).Args; + } + + return Array.ConvertAll(args, arg => GetFullUsage(cmd.Name, arg, prefix)); + } private static string GetFullUsage(string commandName, string args, string prefix) => $"{prefix}{commandName} {string.Format(args, prefix)}".TrimEnd(); diff --git a/src/NadekoBot/_Extensions/ServiceCollectionExtensions.cs b/src/NadekoBot/_Extensions/ServiceCollectionExtensions.cs index dca5c2b8f..14c1966e4 100644 --- a/src/NadekoBot/_Extensions/ServiceCollectionExtensions.cs +++ b/src/NadekoBot/_Extensions/ServiceCollectionExtensions.cs @@ -20,16 +20,20 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddConfigServices(this IServiceCollection services) { - var baseType = typeof(ConfigServiceBase<>); - - foreach (var type in Assembly.GetCallingAssembly().ExportedTypes.Where(x => x.IsSealed)) - { - if (type.BaseType?.IsGenericType == true && type.BaseType.GetGenericTypeDefinition() == baseType) - { - services.AddSingleton(type); - services.AddSingleton(x => (IConfigService)x.GetRequiredService(type)); - } - } + services.Scan(x => x.FromCallingAssembly() + .AddClasses(f => f.AssignableTo(typeof(ConfigServiceBase<>))) + .AsSelfWithInterfaces()); + + // var baseType = typeof(ConfigServiceBase<>); + // + // foreach (var type in Assembly.GetCallingAssembly().ExportedTypes.Where(x => x.IsSealed)) + // { + // if (type.BaseType?.IsGenericType == true && type.BaseType.GetGenericTypeDefinition() == baseType) + // { + // services.AddSingleton(type); + // services.AddSingleton(x => (IConfigService)x.GetRequiredService(type)); + // } + // } return services; } diff --git a/src/NadekoBot/data/aliases.yml b/src/NadekoBot/data/aliases.yml index 7877a5c7e..5fa41e4fd 100644 --- a/src/NadekoBot/data/aliases.yml +++ b/src/NadekoBot/data/aliases.yml @@ -1270,4 +1270,16 @@ exprsexport: deleteemptyservers: - deleteemptyservers stock: - - stock \ No newline at end of file + - stock +medusaload: + - medusaload + - meload +medusaunload: + - medusaunload + - meunload +medusalist: + - medusalist + - melist +medusainfo: + - medusainfo + - meinfo \ No newline at end of file diff --git a/src/NadekoBot/data/medusae/medusa.yml b/src/NadekoBot/data/medusae/medusa.yml new file mode 100644 index 000000000..fdc4b82a7 --- /dev/null +++ b/src/NadekoBot/data/medusae/medusa.yml @@ -0,0 +1,5 @@ +# DO NOT CHANGE +version: 1 +# List of medusae automatically loaded at startup +loaded: + - uwu diff --git a/src/NadekoBot/data/strings/commands/commands.en-US.yml b/src/NadekoBot/data/strings/commands/commands.en-US.yml index 89f010595..dfba7f0f5 100644 --- a/src/NadekoBot/data/strings/commands/commands.en-US.yml +++ b/src/NadekoBot/data/strings/commands/commands.en-US.yml @@ -2151,3 +2151,34 @@ deleteemptyservers: desc: "Deletes all servers in which the bot is the only member." args: - "" +medusaload: + desc: |- + Loads a medusa with the specified name from the data/medusae/ folder. + Provide no name to see the list of loadable medusae. + Read about the medusa system [here](https://nadekobot.readthedocs.io/en/latest/medusa/creating-a-medusa/) + args: + - "mycoolmedusa" + - "" +medusaunload: + desc: |- + Unloads the previously loaded medusa. + Provide no name to see the list of unloadable medusae. + Read about the medusa system [here](https://nadekobot.readthedocs.io/en/latest/medusa/creating-a-medusa/) + args: + - "mycoolmedusa" + - "" +medusainfo: + desc: |- + Shows information about the specified medusa such as the author, name, description, list of sneks, number of commands etc. + Provide no name to see the basic information about all loaded medusae. + Read about the medusa system [here](https://nadekobot.readthedocs.io/en/latest/medusa/creating-a-medusa/) + args: + - "mycoolmedusa" + - "" +medusalist: + desc: |- + Lists all loaded and unloaded medusae. + Read about the medusa system [here](https://nadekobot.readthedocs.io/en/latest/medusa/creating-a-medusa/) + args: + - "" + \ No newline at end of file diff --git a/src/NadekoBot/data/strings/responses/responses.en-US.json b/src/NadekoBot/data/strings/responses/responses.en-US.json index d8803016b..c45f32e7a 100644 --- a/src/NadekoBot/data/strings/responses/responses.en-US.json +++ b/src/NadekoBot/data/strings/responses/responses.en-US.json @@ -974,5 +974,21 @@ "deleted_x_servers": "Deleted {0} servers.", "curtr_gift": "Gift from {0} [{1}]", "curtr_award": "Awarded by bot owner {0} [{1}]", - "curtr_take": "Taken by bot owner {0} [{1}]" + "curtr_take": "Taken by bot owner {0} [{1}]", + "list_of_medusae": "List of Medusae", + "list_of_unloaded": "List of Available Medusae", + "medusa_name_not_found": "Medusa with that name doesn't exist or isn't loaded.", + "medusa_info": "Medusa Information", + "sneks_count": "Sneks ({0})", + "commands_count": "Commands ({0})", + "no_medusa_loaded": "There are no loaded medusae.", + "no_medusa_available": "No medusa available.", + "loaded_medusae": "Loaded Medusae", + "medusa_not_loaded": "Medusa with that name is not loaded.", + "medusa_possibly_cant_unload": "Medusa is probably not fully unloaded. Please restart the bot if issues arise.", + "medusa_loaded": "Medusa {0} has been loaded.", + "medusa_unloaded": "Medusa {0} has been unloaded.", + "medusa_empty": "Medusa wasn't loaded as it didn't contain any Sneks.", + "medusa_already_loaded": "Medusa {0} is already loaded", + "medusa_invalid_not_found": "Medusa with that name wasn't found or the file was invalid" } diff --git a/src/ayu/Ayu.Discord.Voice/VoiceClient.cs b/src/ayu/Ayu.Discord.Voice/VoiceClient.cs index b29b8d7ac..9a97a7105 100644 --- a/src/ayu/Ayu.Discord.Voice/VoiceClient.cs +++ b/src/ayu/Ayu.Discord.Voice/VoiceClient.cs @@ -58,7 +58,6 @@ namespace Ayu.Discord.Voice _arrayPool = ArrayPool.Shared; } - // todo future direct opus streams public int SendPcmFrame(VoiceGateway gw, Span data, int offset, int count) { var secretKey = gw.SecretKey;