diff --git a/Discord.Net.sln b/Discord.Net.sln index 084d8a8343..c22d91e27a 100644 --- a/Discord.Net.sln +++ b/Discord.Net.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.28407.52 +# Visual Studio Version 17 +VisualStudioVersion = 17.1.31903.286 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Core", "src\Discord.Net.Core\Discord.Net.Core.csproj", "{91E9E7BD-75C9-4E98-84AA-2C271922E5C2}" EndProject @@ -12,16 +12,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Commands", "src EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.WebSocket", "src\Discord.Net.WebSocket\Discord.Net.WebSocket.csproj", "{688FD1D8-7F01-4539-B2E9-F473C5D699C7}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Providers", "Providers", "{B0657AAE-DCC5-4FBF-8E5D-1FB578CF3012}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Providers.WS4Net", "src\Discord.Net.Providers.WS4Net\Discord.Net.Providers.WS4Net.csproj", "{6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{CC3D4B1C-9DE0-448B-8AE7-F3F1F3EC5C3A}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Webhook", "src\Discord.Net.Webhook\Discord.Net.Webhook.csproj", "{9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Analyzers", "src\Discord.Net.Analyzers\Discord.Net.Analyzers.csproj", "{BBA8E7FB-C834-40DC-822F-B112CB7F0140}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{BB59D5B5-E7B0-4BF4-8F82-D14431B2799B}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "01_basic_ping_bot", "samples\01_basic_ping_bot\01_basic_ping_bot.csproj", "{F2FF84FB-F6AD-47E5-9EE5-18206CAE136E}" @@ -42,6 +36,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Examples", "src EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "idn", "samples\idn\idn.csproj", "{4A03840B-9EBE-47E3-89AB-E0914DF21AFB}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Interactions", "src\Discord.Net.Interactions\Discord.Net.Interactions.csproj", "{137DB209-B357-4EE8-A6EE-4B6127F6DEE9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "04_interactions_framework", "samples\04_interactions_framework\04_interactions_framework.csproj", "{A23E46D2-1610-4AE5-820F-422D34810887}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Analyzers", "src\Discord.Net.Analyzers\Discord.Net.Analyzers.csproj", "{24C231FD-8CF3-444A-9E7C-45C18BAD4A0D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -100,18 +100,6 @@ Global {688FD1D8-7F01-4539-B2E9-F473C5D699C7}.Release|x64.Build.0 = Release|Any CPU {688FD1D8-7F01-4539-B2E9-F473C5D699C7}.Release|x86.ActiveCfg = Release|Any CPU {688FD1D8-7F01-4539-B2E9-F473C5D699C7}.Release|x86.Build.0 = Release|Any CPU - {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7}.Debug|x64.ActiveCfg = Debug|Any CPU - {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7}.Debug|x64.Build.0 = Debug|Any CPU - {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7}.Debug|x86.ActiveCfg = Debug|Any CPU - {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7}.Debug|x86.Build.0 = Debug|Any CPU - {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7}.Release|Any CPU.Build.0 = Release|Any CPU - {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7}.Release|x64.ActiveCfg = Release|Any CPU - {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7}.Release|x64.Build.0 = Release|Any CPU - {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7}.Release|x86.ActiveCfg = Release|Any CPU - {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7}.Release|x86.Build.0 = Release|Any CPU {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30}.Debug|Any CPU.Build.0 = Debug|Any CPU {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -124,18 +112,6 @@ Global {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30}.Release|x64.Build.0 = Release|Any CPU {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30}.Release|x86.ActiveCfg = Release|Any CPU {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30}.Release|x86.Build.0 = Release|Any CPU - {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Debug|x64.ActiveCfg = Debug|Any CPU - {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Debug|x64.Build.0 = Debug|Any CPU - {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Debug|x86.ActiveCfg = Debug|Any CPU - {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Debug|x86.Build.0 = Debug|Any CPU - {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Release|Any CPU.Build.0 = Release|Any CPU - {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Release|x64.ActiveCfg = Release|Any CPU - {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Release|x64.Build.0 = Release|Any CPU - {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Release|x86.ActiveCfg = Release|Any CPU - {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Release|x86.Build.0 = Release|Any CPU {F2FF84FB-F6AD-47E5-9EE5-18206CAE136E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F2FF84FB-F6AD-47E5-9EE5-18206CAE136E}.Debug|Any CPU.Build.0 = Debug|Any CPU {F2FF84FB-F6AD-47E5-9EE5-18206CAE136E}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -232,6 +208,42 @@ Global {4A03840B-9EBE-47E3-89AB-E0914DF21AFB}.Release|x64.Build.0 = Release|Any CPU {4A03840B-9EBE-47E3-89AB-E0914DF21AFB}.Release|x86.ActiveCfg = Release|Any CPU {4A03840B-9EBE-47E3-89AB-E0914DF21AFB}.Release|x86.Build.0 = Release|Any CPU + {137DB209-B357-4EE8-A6EE-4B6127F6DEE9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {137DB209-B357-4EE8-A6EE-4B6127F6DEE9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {137DB209-B357-4EE8-A6EE-4B6127F6DEE9}.Debug|x64.ActiveCfg = Debug|Any CPU + {137DB209-B357-4EE8-A6EE-4B6127F6DEE9}.Debug|x64.Build.0 = Debug|Any CPU + {137DB209-B357-4EE8-A6EE-4B6127F6DEE9}.Debug|x86.ActiveCfg = Debug|Any CPU + {137DB209-B357-4EE8-A6EE-4B6127F6DEE9}.Debug|x86.Build.0 = Debug|Any CPU + {137DB209-B357-4EE8-A6EE-4B6127F6DEE9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {137DB209-B357-4EE8-A6EE-4B6127F6DEE9}.Release|Any CPU.Build.0 = Release|Any CPU + {137DB209-B357-4EE8-A6EE-4B6127F6DEE9}.Release|x64.ActiveCfg = Release|Any CPU + {137DB209-B357-4EE8-A6EE-4B6127F6DEE9}.Release|x64.Build.0 = Release|Any CPU + {137DB209-B357-4EE8-A6EE-4B6127F6DEE9}.Release|x86.ActiveCfg = Release|Any CPU + {137DB209-B357-4EE8-A6EE-4B6127F6DEE9}.Release|x86.Build.0 = Release|Any CPU + {A23E46D2-1610-4AE5-820F-422D34810887}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A23E46D2-1610-4AE5-820F-422D34810887}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A23E46D2-1610-4AE5-820F-422D34810887}.Debug|x64.ActiveCfg = Debug|Any CPU + {A23E46D2-1610-4AE5-820F-422D34810887}.Debug|x64.Build.0 = Debug|Any CPU + {A23E46D2-1610-4AE5-820F-422D34810887}.Debug|x86.ActiveCfg = Debug|Any CPU + {A23E46D2-1610-4AE5-820F-422D34810887}.Debug|x86.Build.0 = Debug|Any CPU + {A23E46D2-1610-4AE5-820F-422D34810887}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A23E46D2-1610-4AE5-820F-422D34810887}.Release|Any CPU.Build.0 = Release|Any CPU + {A23E46D2-1610-4AE5-820F-422D34810887}.Release|x64.ActiveCfg = Release|Any CPU + {A23E46D2-1610-4AE5-820F-422D34810887}.Release|x64.Build.0 = Release|Any CPU + {A23E46D2-1610-4AE5-820F-422D34810887}.Release|x86.ActiveCfg = Release|Any CPU + {A23E46D2-1610-4AE5-820F-422D34810887}.Release|x86.Build.0 = Release|Any CPU + {24C231FD-8CF3-444A-9E7C-45C18BAD4A0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {24C231FD-8CF3-444A-9E7C-45C18BAD4A0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {24C231FD-8CF3-444A-9E7C-45C18BAD4A0D}.Debug|x64.ActiveCfg = Debug|Any CPU + {24C231FD-8CF3-444A-9E7C-45C18BAD4A0D}.Debug|x64.Build.0 = Debug|Any CPU + {24C231FD-8CF3-444A-9E7C-45C18BAD4A0D}.Debug|x86.ActiveCfg = Debug|Any CPU + {24C231FD-8CF3-444A-9E7C-45C18BAD4A0D}.Debug|x86.Build.0 = Debug|Any CPU + {24C231FD-8CF3-444A-9E7C-45C18BAD4A0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {24C231FD-8CF3-444A-9E7C-45C18BAD4A0D}.Release|Any CPU.Build.0 = Release|Any CPU + {24C231FD-8CF3-444A-9E7C-45C18BAD4A0D}.Release|x64.ActiveCfg = Release|Any CPU + {24C231FD-8CF3-444A-9E7C-45C18BAD4A0D}.Release|x64.Build.0 = Release|Any CPU + {24C231FD-8CF3-444A-9E7C-45C18BAD4A0D}.Release|x86.ActiveCfg = Release|Any CPU + {24C231FD-8CF3-444A-9E7C-45C18BAD4A0D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -240,9 +252,7 @@ Global {BFC6DC28-0351-4573-926A-D4124244C04F} = {288C363D-A636-4EAE-9AC1-4698B641B26E} {078DD7E6-943D-4D09-AFC2-D2BA58B76C9C} = {CC3D4B1C-9DE0-448B-8AE7-F3F1F3EC5C3A} {688FD1D8-7F01-4539-B2E9-F473C5D699C7} = {288C363D-A636-4EAE-9AC1-4698B641B26E} - {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7} = {B0657AAE-DCC5-4FBF-8E5D-1FB578CF3012} {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30} = {CC3D4B1C-9DE0-448B-8AE7-F3F1F3EC5C3A} - {BBA8E7FB-C834-40DC-822F-B112CB7F0140} = {CC3D4B1C-9DE0-448B-8AE7-F3F1F3EC5C3A} {F2FF84FB-F6AD-47E5-9EE5-18206CAE136E} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B} {4E1F1F40-B1DD-40C9-A4B1-A2046A4C9C76} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B} {9B4C4AFB-3D15-44C6-9E36-12ED625AAA26} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B} @@ -251,6 +261,9 @@ Global {FC67057C-E92F-4E1C-98BE-46F839C8AD71} = {C7CF5621-7D36-433B-B337-5A2E3C101A71} {47820065-3CFB-401C-ACEA-862BD564A404} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B} {4A03840B-9EBE-47E3-89AB-E0914DF21AFB} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B} + {137DB209-B357-4EE8-A6EE-4B6127F6DEE9} = {CC3D4B1C-9DE0-448B-8AE7-F3F1F3EC5C3A} + {A23E46D2-1610-4AE5-820F-422D34810887} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B} + {24C231FD-8CF3-444A-9E7C-45C18BAD4A0D} = {CC3D4B1C-9DE0-448B-8AE7-F3F1F3EC5C3A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D2404771-EEC8-45F2-9D71-F3373F6C1495} diff --git a/README.md b/README.md index 87b46fb642..a10a6cf515 100644 --- a/README.md +++ b/README.md @@ -64,4 +64,4 @@ Due to the nature of the Discord API, we will oftentimes need to add a property Furthermore, while we will never break the API (outside of interface changes) on minor builds, we will occasionally need to break the ABI, by introducing parameters to a method to match changes upstream with Discord. As such, a minor version increment may require you to recompile your code, and dependencies, such as addons, may also need to be recompiled and republished on the newer version. When a binary breaking change is made, the change will be noted in the release notes. -An increment of the MAJOR component indicates that breaking changes have been made to the library; consumers should check the release notes to determine what changes need to be made. +An increment of the MAJOR component indicates that breaking changes have been made to the library; consumers should check the release notes to determine what changes need to be made. \ No newline at end of file diff --git a/azure/build.yml b/azure/build.yml index 63ba93964c..a4646ad73a 100644 --- a/azure/build.yml +++ b/azure/build.yml @@ -1,4 +1,11 @@ steps: +- task: UseDotNet@2 + displayName: 'Use .NET Core sdk' + inputs: + packageType: 'sdk' + version: '6.0.x' + includePreviewVersions: true + - task: DotNetCoreCLI@2 inputs: command: 'restore' diff --git a/azure/deploy.yml b/azure/deploy.yml index 61994299ea..4742da3c8b 100644 --- a/azure/deploy.yml +++ b/azure/deploy.yml @@ -7,6 +7,7 @@ steps: dotnet pack "src\Discord.Net.Webhook\Discord.Net.Webhook.csproj" --no-restore --no-build -v minimal -c $(buildConfiguration) -o "$(Build.ArtifactStagingDirectory)" /p:BuildNumber=$(buildNumber) /p:IsTagBuild=$(buildTag) dotnet pack "src\Discord.Net.Providers.WS4Net\Discord.Net.Providers.WS4Net.csproj" --no-restore --no-build -v minimal -c $(buildConfiguration) -o "$(Build.ArtifactStagingDirectory)" /p:BuildNumber=$(buildNumber) /p:IsTagBuild=$(buildTag) dotnet pack "src\Discord.Net.Analyzers\Discord.Net.Analyzers.csproj" --no-restore --no-build -v minimal -c $(buildConfiguration) -o "$(Build.ArtifactStagingDirectory)" /p:BuildNumber=$(buildNumber) /p:IsTagBuild=$(buildTag) + dotnet pack "src\Discord.Net.Interactions\Discord.Net.Interactions.csproj" --no-restore --no-build -v minimal -c $(buildConfiguration) -o "$(Build.ArtifactStagingDirectory)" /p:BuildNumber=$(buildNumber) /p:IsTagBuild=$(buildTag) displayName: Pack projects - task: NuGetCommand@2 diff --git a/docs/guides/interactions_framework/autocompleters.md b/docs/guides/interactions_framework/autocompleters.md new file mode 100644 index 0000000000..9f84ace758 --- /dev/null +++ b/docs/guides/interactions_framework/autocompleters.md @@ -0,0 +1,27 @@ +--- +uid: Guides.InteractionsFramework.Autocompleters +title: Autocompleters +--- + +# Autocompleters + +Autocompleters provide a similar pattern to TypeConverters. Autocompleters are cached, singleton services and they are used by the Interaction Service to handle Autocomplete Interations targeted to a specific Slash Command parameter. + +To start using Autocompleters, use the `[AutocompleteAttribute(Type autocompleterType)]` overload of the `[AutocompleteAttribute]`. This will dynamically link the parameter to the Autocompleter type. + +## Creating Autocompleters + +A valid Autocompleter must inherit `AutocompleteHandler` base type and implement all of its abstract methods. + +### GenerateSuggestionsAsync() + +Interactions Service uses this method to generate a response to a Autocomplete Interaction. This method should return `AutocompletionResult.FromSuccess(IEnumerable)` to display parameter sugesstions to the user. If there are no suggestions to be presented to the user, you have two options: + +1. Returning the parameterless `AutocompletionResult.FromSuccess()` will display "No options match your search." message to the user. +2. Returning `AutocompleteResult.FromError()` will make the Interaction Service not respond to the interation, consequently displaying the user "Loading options failed." message. + +## Resolving Autocompleter Dependencies + +Autocompleter dependencies are resolved using the same dependency injection pattern as the Interaction Modules. Property injection and constructor injection are both valid ways to get service dependencies. + +Because Autocompleters are constructed at service startup, class dependencies are resolved only once. If you need to access per-request dependencies you can use the IServiceProvider parameter of the `GenerateSuggestionsAsync()` method. diff --git a/docs/guides/interactions_framework/dependency-injection.md b/docs/guides/interactions_framework/dependency-injection.md new file mode 100644 index 0000000000..bed58e1c3c --- /dev/null +++ b/docs/guides/interactions_framework/dependency-injection.md @@ -0,0 +1,27 @@ +--- +uid: Guides.InteractionsFramework.DependencyInjection +title: Dependency Injection +--- + +# Dependency Injection + +Interaction Service uses dependency injection to perform most of its operations. This way, you can access service dependencies throughout the framework. + +## Setup + +1. Create a `Microsoft.Extensions.DependencyInjection.ServiceCollection`. +2. Add the dependencies you wish to use in the modules. +3. Build a `IServiceProvider` using the `BuildServiceProvider()` method of the `ServiceCollection`. +4. Pass the `IServiceProvider` to `AddModulesAsync()`, `AddModuleAsync()` and `ExecuteAsync()` methods. + +## Accessing the Dependencies + +Services of a `IServiceProvider` can be accessed using *Contructor Injection* and *Property Injection*. + +Interaction Service will populate the constructor parameters using the provided `IServiceProvider`. Any public settable class Property will also be populated in the same manner. + +## Service Scopes + +Interaction Service has built-in support for scoped service types. Scoped lifetime services are instantiated once per command execution. Including the Preconditon checks, every module operation is executed within a single service scope (which is sepearate from the global service scope). + +> For more in-depth information about service lifetimes check out [Microsoft Docs](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-5.0#service-lifetimes-1). diff --git a/docs/guides/interactions_framework/intro.md b/docs/guides/interactions_framework/intro.md new file mode 100644 index 0000000000..f240eddd0f --- /dev/null +++ b/docs/guides/interactions_framework/intro.md @@ -0,0 +1,360 @@ +--- +uid: Guides.InteractionsFramework.Intro +title: Introduction to the Interaction Framework +--- + +# Getting Started + +Interaction Service provides an attribute based framework for creating Discord Interaction handlers. + +To start using the Interaction Service, you need to create a service instance. Optionally you can provide the `InterctionService` constructor with a `InteractionServiceConfig` to change the services behaviour to suit your needs. + +```csharp +... + +var commands = new InteractionService(discord); + +... +``` + +## Modules + +Attribute based Interaction handlers must be defined within a command module class. Command modules are responsible for executing the Interaction handlers and providing them with the necessary execution info and helper functions. + +Command modules are transient objects. A new module instance is created before a command execution starts then it will be disposed right after the method returns. + +Every module class must: + +- be public +- inherit `InteractionModuleBase` + +Optionally you can override the included : + +- OnModuleBuilding (executed after the module is built) +- BeforeExecute (executed before a command execution starts) +- AfterExecute (executed after a command execution concludes) + +methods to configure the modules behaviour. + +Every command module exposes a set of helper methods, namely: + +- `RespondAsync()` => Respond to the interaction +- `FollowupAsync()` => Create a followup message for an interaction +- `ReplyAsync()` => Send a message to the origin channel of the interaction +- `DeleteOriginalResponseAsync()` => Delete the original interaction response + +## Commands + +Valid **Interaction Commands** must comply with the following requirements: + +| | return type | max parameter count | allowed parameter types | attribute | +|-------------------------------|------------------------------|---------------------|-------------------------------|--------------------------| +|[Slash Command](#slash-commands)| `Task`/`Task` | 25 | any* | `[SlashCommand]` | +|[User Command](#user-commands) | `Task`/`Task` | 1 | Implementations of `IUser` | `[UserCommand]` | +|[Message Command](#message-commands)| `Task`/`Task` | 1 | Implementations of `IMessage` | `[MessageCommand]` | +|[Component Interaction Command](#component-interaction-commands)| `Task`/`Task` | inf | `string` or `string[]` | `[ComponentInteraction]` | +|[Autocomplete Command](#autocomplete-commands)| `Task`/`Task` | - | - | `[AutocompleteCommand]`| + +> [!NOTE] +> a `TypeConverter` that is capable of parsing type in question must be registered to the `InteractionService` instance. + +You should avoid using long running code in your command module. Depending on your setup, long running code may block the Gateway thread of your bot, interrupting its connection to Discord. + +### Slash Commands + +Slash Commands are created using the `[SlashCommandAttribute]`. Every Slash Command must declare a name and a description. You can check Discords **Application Command Naming Guidelines** [here](https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-naming). + +```csharp +[SlashCommand("echo", "Echo an input")] +public async Task Echo(string input) +{ + await RespondAsync(input); +} +``` + +#### Parameters + +Slash Commands can have up to 25 method parameters. You must name your parameters in accordance with [Discords Naming Guidelines](https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-naming). Interaction Service also features a pascal casing seperator for formatting parameter names with pascal casing into Discord compliant parameter names('parameterName' => 'parameter-name'). By default, your methods can feature the following parameter types: + +- Implementations of `IUser` +- Implementations of `IChannel`* +- Implementations of `IRole` +- Implementations of `IMentionable` +- `string` +- `float`, `double`, `decimal` +- `bool` +- `char` +- `sbyte`, `byte` +- `int16`, `int32`, `int64` +- `uint16`, `uint32`, `uint64` +- `enum` (Values are registered as multiple choice options and are enforced by Discord. Use `[HideAttribute]' on enum values to prevent them from getting registered.) +- `DateTime` +- `TimeSpan` + +--- + +**You can use more specialized implementations of `IChannel` to restrict the allowed channel types for a channel type option.* +| interface | Channel Type | +|---------------------|-------------------------------| +| `IStageChannel` | Stage Channels | +| `IVoiceChannel` | Voice Channels | +| `IDMChannel` | DM Channels | +| `IGroupChannel` | Group Channels | +| `ICategory Channel` | Category Channels | +| `INewsChannel` | News Channels | +| `IThreadChannel` | Public, Private, News Threads | +| `ITextChannel` | Text Channels | + +--- + +##### Optional Parameters + +Parameters with default values (ie. `int count = 0`) will be displayed as optional parameters on Discord Client. + +##### Parameter Summary + +By using the `[SummaryAttribute]` you can customize the displayed name and description of a parameter + +```csharp +[Summary(description: "this is a parameter description")] string input +``` + +##### Parameter Choices + +`[ChoiceAttribute]` can be used to add choices to a parameter. + +```csharp +[SlashCommand("blep", "Send a random adorable animal photo")] +public async Task Blep([Choice("Dog","dog"), Choice("Cat", "cat"), Choice("Penguin", "penguin")] string animal) +{ + ... +} +``` + +In most cases, instead of relying on this attribute, you should use an `Enum` to create multiple choice parameters. Ex. + +```csharp +public enum Animal +{ + Cat, + Dog, + Penguin +} + +[SlashCommand("blep", "Send a random adorable animal photo")] +public async Task Blep(Animal animal) +{ + ... +} +``` + +This Slash Command will be displayed exactly the same as the previous example. + +##### Channel Types + +Channel types for an `IChannel` parameter can also be restricted using the `[ChannelTypesAttribute]`. + +```csharp +[SlashCommand("name", "Description")] +public async Task Command([ChannelTypes(ChannelType.Stage, ChannelType.Text)]IChannel channel) +{ + ... +} +``` + +In this case, user can only input Stage Channels and Text Channels to this parameter. + +##### Autocomplete + +You can enable Autocomple Interactions for a Slash Command parameter using the `[AutocompleteAttribute]`. To handle the Autocomplete Interactions raised by this parameter you can either create [Autocomplete Commands](#autocomplete-commands) or you can opt to use the [Autocompleters Pattern](./autocompleters) + +##### Min/Max Value + +You can specify the permitted max/min value for a number type parameter using the `[MaxValueAttribute]` and `[MinValueAttribute]`. + +### User Commands + +A valid User Command must have the following structure: + +```csharp +[UserCommand("Say Hello")] +public async Task SayHello(IUser user) +{ + ... +} +``` + +User commands can only have one parameter and its type must be an implementation of `IUser`. + +### Message Commands + +A valid Message Command must have the following structure: + +```csharp +[MessageCommand("Bookmark")] +public async Task Bookmark(IMessage user) +{ + ... +} +``` + +Message commands can only have one parameter and its type must be an implementation of `IMessage`. + +### Component Interaction Commands + +Component Interaction Commands are used to handle interactions that originate from **Discord Message Component**s. This pattern is particularly useful if you will be reusing a set a **Custom ID**s. + +```csharp +[ComponentInteraction("custom_id")] +public async Task RoleSelection() +{ + ... +} +``` + +Component Interaction Commands support wild card matching, by default `*` character can be used to create a wild card pattern. Interaction Service will use lazy matching to capture the words corresponding to the wild card character. And the captured words will be passed on to the command method in the same order they were captured. + +*Ex.* + +If Interaction Service recieves a component interaction with **player:play,rickroll** custom id, `op` will be *play* and `name` will be *rickroll* + +```csharp +[ComponentInteraction("player:*,*")] +public async Task Play(string op, string name) +{ + ... +} +``` + +You may use as many wild card characters as you want. + +#### Select Menus + +Unlike button interactions, select menu interactions also contain the values of the selected menu items. In this case, you should structure your method to accept a string array. + +```csharp +[ComponentInteraction("role_selection")] +public async Task RoleSelection(string[] selectedRoles) +{ + ... +} +``` + + Wild card pattern can also be used to match select menu custom ids but remember that the array containing the select menu values should be the last parameter. + +```csharp +[ComponentInteraction("role_selection_*")] +public async Task RoleSelection(string id, string[] selectedRoles) +{ + ... +} +``` + +### Autocomplete Commands + +Autocomplete commands must be parameterless methods. A valid Autocomplete command must have the following structure: + +```csharp +[AutocompleteCommand("command_name", "parameter_name")] +public async Task Autocomplete() +{ + IEnumerable results; + + ... + + await (Context.Interaction as SocketAutocompleteInteraction).RespondAsync(results); +} +``` + +Alternatively, you can use the *Autocompleters* to simplify this workflow. + +## Interaction Context + +Every command module provides its commands with an execution context. This context property includes general information about the underlying interaction that triggered the command execution. The base command context. + +You can design your modules to work with different implementation types of `IInteractionContext`. To achieve this, make sure your module classes inherit from the generic variant of the `InteractionModuleBase`. + +> Context type must be consistent throughout the project, or you will run into issues during runtime. + +Interaction Service ships with 4 different kinds of `InteractionContext`s: + +1. InteractionContext: A bare-bones execution context consisting of only implementation netural interfaces +2. SocketInteractionContext: An execution context for use with `DiscordSocketClient`. Socket entities are exposed in this context without the need of casting them. +3. ShardedInteractionContext: `DiscordShardedClient` variant of the `SocketInteractionContext` +4. RestInteractionContext: An execution context designed to be used with a `DiscordRestClient` and webhook based interactions pattern + +You can create custom Interaction Contexts by implementing the `IInteracitonContext` interface. + +One problem with using the concrete type InteractionContexts is that you cannot access the information that is specific to different interaction types without casting. Concrete type interaction contexts are great for creating shared interaction modules but you can also use the generic variants of the built-in interaction contexts to create interaction specific interaction modules. + +Ex. +Message component interactions have access to a special method called `UpdateAsync()` to update the body of the method the interaction originated from. Normally this wouldn't be accessable without casting the `Context.Interaction`. + +```csharp +discordClient.ButtonExecuted += async (interaction) => +{ + var ctx = new SocketInteractionContext(discordClient, interaction); + await interactionService.ExecuteAsync(ctx, serviceProvider); +}; + +public class MessageComponentModule : InteractionModuleBase> +{ + [ComponentInteraction("custom_id")] + public async Command() + { + Context.Interaction.UpdateAsync(...); + } +} +``` + +## Loading Modules + +Interaction Service can automatically discover and load modules that inherit `InteractionModuleBase` from an `Assembly`. Call `InteractionService.AddModulesAsync()` to use this functionality. + +You can also manually add Interaction modules using the `InteractionService.AddModuleAsync()` method by providing the module type you want to load. + +## Resolving Module Dependencies + +Module dependencies are resolved using the Constructor Injection and Property Injection patterns. Meaning, the constructor parameters and public settable properties of a module will be assigned using the `IServiceProvider`. For more information on dependency injection, check out [Dependency Injection](./dependency-injection.md) + +## Module Groups + +Module groups allow you to create sub-commands and sub-commands groups. By nesting commands inside a module that is tagged with `[GroupAttribute]` you can create prefixed commands. + +Although creating nested module stuctures are allowed, you are not permitted to use more than 2 `[GroupAttribute]`s in module hierarchy. + +## Executing Commands + +Any of the following socket events can be used to execute commands: + +- InteractionCreated +- ButtonExecuted +- SelectMenuExecuted +- AutocompleteExecuted +- UserCommandExecuted +- MessageCommandExecuted + +Commands can be either executed on the gateway thread or on a seperate thread from the thread pool. This behaviour can be configured by changing the *RunMode* property of `InteractionServiceConfig` or by setting the *runMode* parameter of a command attribute. + +You can also configure the way `InteractionService` executes the commands. By default, commands are executed using `ConstructorInfo.Invoke()` to create module instances and `MethodInfo.Invoke()` method for executing the method bodies. By setting, `InteractionServiceConfig.UseCompiledLambda` to `true`, you can make `InteractionService` create module instances and execute commands using *Compiled Lambda* expressions. This cuts down on command execution time but it might add some memory overhead. + +Time it takes to create a module instance and execute a `Task.Delay(0)` method using the Reflection methods compared to Compiled Lambda expressions: + +| Method | Mean | Error | StdDev | +|----------------- |----------:|---------:|---------:| +| ReflectionInvoke | 225.93 ns | 4.522 ns | 7.040 ns | +| CompiledLambda | 48.79 ns | 0.981 ns | 1.276 ns | + +## Registering Commands to Discord + +Application commands loaded to the Interaction Service can be registered to Discord using a number of different methods. In most cases `RegisterCommandsGloballyAsync()` and `RegisterCommandsToGuildAsync()` are the methods to use. Command registration methods can only be used after the gateway client is ready or the rest client is logged in. + +In debug environment, since Global commands can take up to 1 hour to register/update, you should register your commands to a test guild for your changes to take effect immediately. You can use the preprocessor directives to create a simple logic for registering commands: + +```csharp +#if DEBUG + await interactionService.RegisterCommandsToGuildAsync(); +#else + await interactionService.RegisterCommandsGloballyAsync(); +#endif +``` diff --git a/docs/guides/interactions_framework/post_execution.md b/docs/guides/interactions_framework/post_execution.md new file mode 100644 index 0000000000..f34ba5cfea --- /dev/null +++ b/docs/guides/interactions_framework/post_execution.md @@ -0,0 +1,73 @@ +--- +uid: Guides.InteractionsFramework.PostEx +title: Post-Execution +--- + +# Post-Execution Logic + +Interaction Service uses `IResult`s to provide information about the state of command execution. These can be used to log internal exceptions or provide some insight to the command user. + +If you are running your commands using `RunMode.Sync` these command results can be retrieved from the return value of `InteractionService.ExecuteCommandAsync()` method or by registering delegates to Interaction Service events. + +If you are using the `RunMode.Async` to run your commands, you must use the Interaction Service events to get the execution results. When using `RunMode.Async`, `InteractionService.ExecuteCommandAsync()` will always return a successful result. + +## Results + +Interaction Result come in a handful of different flavours: + +1. `AutocompletionResult`: returned by Autocompleters +2. `ExecuteResult`: contains the result of method body execution process +3. `PreconditionGroupResult`: returned by Precondition groups +4. `PreconditionResult`: returned by preconditions +5. `RuntimeResult`: a user implementable result for returning user defined results +6. `SearchResult`: returned by command lookup map +7. `TypeConverterResult`: returned by TypeConverters + +You can either use the `IResult.Error` property of an Interaction result or create type check for the afformentioned result types to branch out your post-execution logic to handle different situations. + +## CommandExecuted Events + +Every time a command gets executed, Interaction Service raises a *CommandExecuted event. These events can be used to create a post-execution pipeline. + +```csharp +interactionService.SlashCommandExecuted += SlashCommandExecuted; + +async Task SlashCommandExecuted (SlashCommandInfo arg1, Discord.IInteractionContext arg2, IResult arg3) + { + if (!arg3.IsSuccess) + { + switch (arg3.Error) + { + case InteractionCommandError.UnmetPrecondition: + await arg2.Interaction.RespondAsync($"Unmet Precondition: {arg3.ErrorReason}"); + break; + case InteractionCommandError.UnknownCommand: + await arg2.Interaction.RespondAsync("Unknown command"); + break; + case InteractionCommandError.BadArgs: + await arg2.Interaction.RespondAsync("Invalid number or arguments"); + break; + case InteractionCommandError.Exception: + await arg2.Interaction.RespondAsync($"Command exception:{arg3.ErrorReason}"); + break; + case InteractionCommandError.Unsuccessful: + await arg2.Interaction.RespondAsync("Command could not be executed"); + break; + default: + break; + } + } + } +``` + +## Log Event + +InteractionService regularly outputs information about the occuring events to keep the developer informed. + +## Runtime Result + +Interaction commands allow you to return `Task` to pass on additional information about the command execution process back to your post-execution logic. + +Custom `RuntimeResult` classes can be created by inheriting the base `RuntimeResult` class. + +If command execution process reaches the method body of the command and no exceptions are thrown during the execution of the method body, `RuntimeResult` returned by your command will be accessible by casting/type-checking the `IResult` parameter of the *CommandExecuted event delegate. diff --git a/docs/guides/interactions_framework/preconditions.md b/docs/guides/interactions_framework/preconditions.md new file mode 100644 index 0000000000..e9b5d73b6d --- /dev/null +++ b/docs/guides/interactions_framework/preconditions.md @@ -0,0 +1,8 @@ +--- +uid: Guides.InteractionsFramework.Preconditions +title: Preconditions +--- + +# Preconditions + +Preconditions in Interaction Service work exactly the same as they do in ***Discord.Net.Commands***. For more information, check out [Preconditions](../commands/preconditions.md) \ No newline at end of file diff --git a/docs/guides/interactions_framework/typeconverters.md b/docs/guides/interactions_framework/typeconverters.md new file mode 100644 index 0000000000..12ca7dab5e --- /dev/null +++ b/docs/guides/interactions_framework/typeconverters.md @@ -0,0 +1,130 @@ +--- +uid: Guides.InteractionsFramework.TypeConverters +title: Type Converters +--- + +# TypeConverters + +TypeConverters are responsible for registering command parameters to Discord and parsing the user inputs into method parameters. + +By default, TypeConverters for the following types are provided with `Discord.Net.Interactions` library. + +- Implementations of `IUser` +- Implementations of `IChannel` +- Implementations of `IRole` +- Implementations of `IMentionable` +- `string` +- `float`, `double`, `decimal` +- `bool` +- `char` +- `sbyte`, `byte` +- `int16`, `int32`, `int64` +- `uint16`, `uint32`, `uint64` +- `enum` +- `DateTime` +- `TimeSpan` + +## Creating TypeConverters + +Depending on your needs, there are two types of `TypeConverter`s you can create: + +- Concrete type +- Generic type + +A valid converter must inherit `TypeConverter` base type. And override the abstract base methods. + +### CanConvertTo() Method + +This method is used by Interaction Service to search for alternative Type Converters. + +Interaction Services determines the most suitable `TypeConverter` for a parameter type in the following order: + +1. It searches for a `TypeConverter` that is registered to specifically target that parameter type +2. It searches for a generic `TypeConverter` with a matching type constraint. If there are more multiple matches, the one whose type constraint is the most specialized will be chosen. +3. It searches for a `TypeConverter` that returns `true` when its `CanConvertTo()` method is invoked for thaty parameter type. + +> Alternatively, you can use the generic variant (`TypeConverter`) of the `TypeConverter` base class which implements the following method body for `CanConvertTo()` method + +```csharp +public sealed override bool CanConvertTo (Type type) => + typeof(T).IsAssignableFrom(type); +``` + +### GetDiscordType() Method + +This method is used by Interaction Service to determine the [Discord Application Command Option type](https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-type) of a parameter type. + +### ReadAsync() Method + +This method is used by Interaction Service to parse the user input. This method should return `TypeConverterResult.FromSuccess` if the parsing operation is successful, otherwise it should return `TypeConverterResult.FromError`. The inner logic of this method is totally up to you, however you should avoid using long running code. + +### Write() Method + +This method is used to configure the **Discord Application Command Option** before it gets registered to Discord. Command Option is configured by modifying the `ApplicationCommandOptionProperties` instance. + +The default parameter building pipeline is isolated and will not be disturbed by the `TypeConverter` workflow. But changes made in this method will override the values generated by the Interaction Service for a **Discord Application Command Option**. + +--- + +### Example Enum TypeConverter + +```csharp +internal sealed class EnumConverter : TypeConverter where T : struct, Enum +{ + public override ApplicationCommandOptionType GetDiscordType ( ) => ApplicationCommandOptionType.String; + + public override Task ReadAsync (IInteractionCommandContext context, SocketSlashCommandDataOption option, IServiceProvider services) + { + if (Enum.TryParse((string)option.Value, out var result)) + return Task.FromResult(TypeConverterResult.FromSuccess(result)); + else + return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"Value {option.Value} cannot be converted to {nameof(T)}")); + } + + public override void Write (ApplicationCommandOptionProperties properties, IParameterInfo parameterInfo) + { + var names = Enum.GetNames(typeof(T)); + if (names.Length <= 25) + { + var choices = new List(); + + foreach (var name in names) + choices.Add(new ApplicationCommandOptionChoiceProperties + { + Name = name, + Value = name + }); + + properties.Choices = choices; + } + } +} +``` + +--- + +## Registering TypeConverters + +> TypeConverters must be registered prior to module discovery. If Interaction Service encounters a parameter type that doesn't belong to any of the registered `TypeConverter`s during this phase, it will throw an exception. + +### Concrete TypeConverters + +Registering Concrete TypeConverters are as simple as creating an instance of your custom converter and invoking `AddTypeConverter()` method. + +```csharp +interactionService.AddTypeConverter(new StringArrayConverter()); +``` + +### Generic TypeConverters + +To register a generic TypeConverter, you need to invoke the `AddGenericTypeConverter()` method of the Interaction Service class. You need to pass the type of your `TypeConverter` and a target base type to this method. + +For instance, to register the previously mentioned [Example Enum Converter](#example-enum-converter) the following can be used: + +```csharp +interactionService.AddGenericTypeConverter(typeof(EnumConverter<>)); +``` + +Interaction service checks if the target base type satisfies the type constraints of the Generic TypeConverter class. + +> Dependencies of Generic TypeConverters are also resolved using the Dependency Injection pattern. diff --git a/docs/guides/toc.yml b/docs/guides/toc.yml index 9684684169..fba188b78d 100644 --- a/docs/guides/toc.yml +++ b/docs/guides/toc.yml @@ -60,6 +60,14 @@ topicUid: Guides.MessageComponents.SelectMenus - name: Advanced Concepts topicUid: Guides.MessageComponents.Advanced +- name: Interaction Framework + items: + - name: Getting started + topicUid: Guides.InteractionsFramework.Intro + - name: Dependency Injection + topicUid: Guides.Commands.DI + - name: Post-execution Handling + topicUid: Guides.Commands.PostExecution - name: Emoji topicUid: Guides.Emoji - name: Voice diff --git a/samples/01_basic_ping_bot/01_basic_ping_bot.csproj b/samples/01_basic_ping_bot/01_basic_ping_bot.csproj index 128082edb7..6e1a6365f9 100644 --- a/samples/01_basic_ping_bot/01_basic_ping_bot.csproj +++ b/samples/01_basic_ping_bot/01_basic_ping_bot.csproj @@ -2,7 +2,7 @@ Exe - netcoreapp3.1 + net5.0 diff --git a/samples/02_commands_framework/02_commands_framework.csproj b/samples/02_commands_framework/02_commands_framework.csproj index 83a62f8d74..30c25e8466 100644 --- a/samples/02_commands_framework/02_commands_framework.csproj +++ b/samples/02_commands_framework/02_commands_framework.csproj @@ -2,7 +2,7 @@ Exe - netcoreapp3.1 + net5.0 diff --git a/samples/03_sharded_client/03_sharded_client.csproj b/samples/03_sharded_client/03_sharded_client.csproj index 91cacef646..c4c42516e0 100644 --- a/samples/03_sharded_client/03_sharded_client.csproj +++ b/samples/03_sharded_client/03_sharded_client.csproj @@ -2,7 +2,7 @@ Exe - netcoreapp3.1 + net5.0 _03_sharded_client diff --git a/samples/04_interactions_framework/04_interactions_framework.csproj b/samples/04_interactions_framework/04_interactions_framework.csproj new file mode 100644 index 0000000000..780ab69bda --- /dev/null +++ b/samples/04_interactions_framework/04_interactions_framework.csproj @@ -0,0 +1,25 @@ + + + + Exe + net5.0 + _04_interactions_framework + + + + + + + + + + + + + + + + + + + diff --git a/samples/04_interactions_framework/CommandHandler.cs b/samples/04_interactions_framework/CommandHandler.cs new file mode 100644 index 0000000000..735557da59 --- /dev/null +++ b/samples/04_interactions_framework/CommandHandler.cs @@ -0,0 +1,146 @@ +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace _04_interactions_framework +{ + public class CommandHandler + { + private readonly DiscordSocketClient _client; + private readonly InteractionService _commands; + private readonly IServiceProvider _services; + + public CommandHandler(DiscordSocketClient client, InteractionService commands, IServiceProvider services) + { + _client = client; + _commands = commands; + _services = services; + } + + public async Task InitializeAsync ( ) + { + // Add the public modules that inherit InteractionModuleBase to the InteractionService + await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services); + + // Process the InteractionCreated payloads to execute Interactions commands + _client.InteractionCreated += HandleInteraction; + + // Process the command execution results + _commands.SlashCommandExecuted += SlashCommandExecuted; + _commands.ContextCommandExecuted += ContextCommandExecuted; + _commands.ComponentCommandExecuted += ComponentCommandExecuted; + } + + private Task ComponentCommandExecuted (ComponentCommandInfo arg1, Discord.IInteractionContext arg2, IResult arg3) + { + if (!arg3.IsSuccess) + { + switch (arg3.Error) + { + case InteractionCommandError.UnmetPrecondition: + // implement + break; + case InteractionCommandError.UnknownCommand: + // implement + break; + case InteractionCommandError.BadArgs: + // implement + break; + case InteractionCommandError.Exception: + // implement + break; + case InteractionCommandError.Unsuccessful: + // implement + break; + default: + break; + } + } + + return Task.CompletedTask; + } + + private Task ContextCommandExecuted (ContextCommandInfo arg1, Discord.IInteractionContext arg2, IResult arg3) + { + if (!arg3.IsSuccess) + { + switch (arg3.Error) + { + case InteractionCommandError.UnmetPrecondition: + // implement + break; + case InteractionCommandError.UnknownCommand: + // implement + break; + case InteractionCommandError.BadArgs: + // implement + break; + case InteractionCommandError.Exception: + // implement + break; + case InteractionCommandError.Unsuccessful: + // implement + break; + default: + break; + } + } + + return Task.CompletedTask; + } + + private Task SlashCommandExecuted (SlashCommandInfo arg1, Discord.IInteractionContext arg2, IResult arg3) + { + if (!arg3.IsSuccess) + { + switch (arg3.Error) + { + case InteractionCommandError.UnmetPrecondition: + // implement + break; + case InteractionCommandError.UnknownCommand: + // implement + break; + case InteractionCommandError.BadArgs: + // implement + break; + case InteractionCommandError.Exception: + // implement + break; + case InteractionCommandError.Unsuccessful: + // implement + break; + default: + break; + } + } + + return Task.CompletedTask; + } + + private async Task HandleInteraction (SocketInteraction arg) + { + try + { + // Create an execution context that matches the generic type parameter of your InteractionModuleBase modules + var ctx = new SocketInteractionContext(_client, arg); + await _commands.ExecuteCommandAsync(ctx, _services); + } + catch (Exception ex) + { + Console.WriteLine(ex); + + // If a Slash Command execution fails it is most likely that the original interaction acknowledgement will persist. It is a good idea to delete the original + // response, or at least let the user know that something went wrong during the command execution. + if(arg.Type == InteractionType.ApplicationCommand) + await arg.GetOriginalResponseAsync().ContinueWith(async (msg) => await msg.Result.DeleteAsync()); + } + } + } +} diff --git a/samples/04_interactions_framework/ExampleEnum.cs b/samples/04_interactions_framework/ExampleEnum.cs new file mode 100644 index 0000000000..2ea5733c07 --- /dev/null +++ b/samples/04_interactions_framework/ExampleEnum.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace _04_interactions_framework +{ + public enum ExampleEnum + { + First, + Second, + Third, + Fourth + } +} diff --git a/samples/04_interactions_framework/Modules/UtilityModule.cs b/samples/04_interactions_framework/Modules/UtilityModule.cs new file mode 100644 index 0000000000..d6cbb1a9ff --- /dev/null +++ b/samples/04_interactions_framework/Modules/UtilityModule.cs @@ -0,0 +1,93 @@ +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace _04_interactions_framework.Modules +{ + // Interation modules must be public and inherit from an IInterationModuleBase + public class UtilityModule : InteractionModuleBase + { + // Dependencies can be accessed through Property injection, public properties with public setters will be set by the service provider + public InteractionService Commands { get; set; } + + private CommandHandler _handler; + + // Constructor injection is also a valid way to access the dependecies + public UtilityModule ( CommandHandler handler ) + { + _handler = handler; + } + + // Slash Commands are declared using the [SlashCommand], you need to provide a name and a description, both following the Discord guidelines + [SlashCommand("ping", "Recieve a pong")] + // By setting the DefaultPermission to false, you can disable the command by default. No one can use the command until you give them permission + [DefaultPermission(false)] + public async Task Ping ( ) + { + await RespondAsync("pong"); + } + + // You can use a number of parameter types in you Slash Command handlers (string, int, double, bool, IUser, IChannel, IMentionable, IRole, Enums) by default. Optionally, + // you can implement your own TypeConverters to support a wider range of parameter types. For more information, refer to the library documentation. + // Optional method parameters(parameters with a default value) also will be displayed as optional on Discord. + + // [Summary] lets you customize the name and the description of a parameter + [SlashCommand("echo", "Repeat the input")] + public async Task Echo(string echo, [Summary(description: "mention the user")]bool mention = false) + { + await RespondAsync(echo + (mention ? Context.User.Mention : string.Empty)); + } + + // [Group] will create a command group. [SlashCommand]s and [ComponentInteraction]s will be registered with the group prefix + [Group("test_group", "This is a command group")] + public class GroupExample : InteractionModuleBase + { + // You can create command choices either by using the [Choice] attribute or by creating an enum. Every enum with 25 or less values will be registered as a multiple + // choice option + [SlashCommand("choice_example", "Enums create choices")] + public async Task ChoiceExample(ExampleEnum input) + { + await RespondAsync(input.ToString()); + } + } + + // User Commands can only have one parameter, which must be a type of SocketUser + [UserCommand("SayHello")] + public async Task SayHello(IUser user) + { + await RespondAsync($"Hello, {user.Mention}"); + } + + // Message Commands can only have one parameter, which must be a type of SocketMessage + [MessageCommand("Delete")] + [RequireOwner] + public async Task DeleteMesage(IMessage message) + { + await message.DeleteAsync(); + await RespondAsync("Deleted message."); + } + + // Use [ComponentInteraction] to handle message component interactions. Message component interaction with the matching customId will be executed. + // Alternatively, you can create a wild card pattern using the '*' character. Interaction Service will perform a lazy regex search and capture the matching strings. + // You can then access these capture groups from the method parameters, in the order they were captured. Using the wild card pattern, you can cherry pick component interactions. + [ComponentInteraction("musicSelect:*,*")] + public async Task ButtonPress(string id, string name) + { + // ... + await RespondAsync($"Playing song: {name}/{id}"); + } + + // Select Menu interactions, contain ids of the menu options that were selected by the user. You can access the option ids from the method parameters. + // You can also use the wild card pattern with Select Menus, in that case, the wild card captures will be passed on to the method first, followed by the option ids. + [ComponentInteraction("roleSelect")] + public async Task RoleSelect(params string[] selections) + { + // implement + } + } +} diff --git a/samples/04_interactions_framework/Program.cs b/samples/04_interactions_framework/Program.cs new file mode 100644 index 0000000000..5dedbfae96 --- /dev/null +++ b/samples/04_interactions_framework/Program.cs @@ -0,0 +1,85 @@ +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace _04_interactions_framework +{ + class Program + { + static void Main ( string[] args ) + { + // One of the more flexable ways to access the configuration data is to use the Microsoft's Configuration model, + // this way we can avoid hard coding the environment secrets. I opted to use the Json and environment variable providers here. + IConfiguration config = new ConfigurationBuilder() + .AddEnvironmentVariables(prefix: "DC_") + .AddJsonFile("appsettings.json", optional: true) + .Build(); + + RunAsync(config).GetAwaiter().GetResult(); + } + + static async Task RunAsync (IConfiguration configuration ) + { + // Dependency injection is a key part of the Interactions framework but it needs to be disposed at the end of the app's lifetime. + using var services = ConfigureServices(configuration); + + var client = services.GetRequiredService(); + var commands = services.GetRequiredService(); + + client.Log += LogAsync; + commands.Log += LogAsync; + + // Slash Commands and Context Commands are can be automatically registered, but this process needs to happen after the client enters the READY state. + // Since Global Commands take around 1 hour to register, we should use a test guild to instantly update and test our commands. To determine the method we should + // register the commands with, we can check whether we are in a DEBUG environment and if we are, we can register the commands to a predetermined test guild. + client.Ready += async ( ) => + { + if (IsDebug()) + // Id of the test guild can be provided from the Configuration object + await commands.RegisterCommandsToGuildAsync(configuration.GetValue("testGuild"), true); + else + await commands.RegisterCommandsGloballyAsync(true); + }; + + // Here we can initialize the service that will register and execute our commands + await services.GetRequiredService().InitializeAsync(); + + // Bot token can be provided from the Configuration object we set up earlier + await client.LoginAsync(TokenType.Bot, configuration["token"]); + await client.StartAsync(); + + await Task.Delay(Timeout.Infinite); + } + + static Task LogAsync(LogMessage message) + { + Console.WriteLine(message.ToString()); + return Task.CompletedTask; + } + + static ServiceProvider ConfigureServices ( IConfiguration configuration ) + { + return new ServiceCollection() + .AddSingleton(configuration) + .AddSingleton() + .AddSingleton(x => new InteractionService(x.GetRequiredService())) + .AddSingleton() + .BuildServiceProvider(); + } + + static bool IsDebug ( ) + { + #if DEBUG + return true; + #else + return false; + #endif + } + } +} diff --git a/samples/04_interactions_framework/RequireOwnerAttribute.cs b/samples/04_interactions_framework/RequireOwnerAttribute.cs new file mode 100644 index 0000000000..2f24938386 --- /dev/null +++ b/samples/04_interactions_framework/RequireOwnerAttribute.cs @@ -0,0 +1,27 @@ +using Discord; +using Discord.Interactions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace _04_interactions_framework +{ + public class RequireOwnerAttribute : PreconditionAttribute + { + public override async Task CheckRequirementsAsync (IInteractionContext context, ICommandInfo commandInfo, IServiceProvider services) + { + switch (context.Client.TokenType) + { + case TokenType.Bot: + var application = await context.Client.GetApplicationInfoAsync().ConfigureAwait(false); + if (context.User.Id != application.Owner.Id) + return PreconditionResult.FromError(ErrorMessage ?? "Command can only be run by the owner of the bot."); + return PreconditionResult.FromSuccess(); + default: + return PreconditionResult.FromError($"{nameof(RequireOwnerAttribute)} is not supported by this {nameof(TokenType)}."); + } + } + } +} diff --git a/samples/idn/idn.csproj b/samples/idn/idn.csproj index f982ff86d9..fafb3df3f6 100644 --- a/samples/idn/idn.csproj +++ b/samples/idn/idn.csproj @@ -2,7 +2,7 @@ Exe - netcoreapp3.1 + net5.0 diff --git a/src/Discord.Net.Commands/Discord.Net.Commands.csproj b/src/Discord.Net.Commands/Discord.Net.Commands.csproj index ec2795de26..811a0470e4 100644 --- a/src/Discord.Net.Commands/Discord.Net.Commands.csproj +++ b/src/Discord.Net.Commands/Discord.Net.Commands.csproj @@ -5,8 +5,8 @@ Discord.Net.Commands Discord.Commands A Discord.Net extension adding support for bot commands. - net461;netstandard2.0;netstandard2.1 - netstandard2.0;netstandard2.1 + net6.0;net5.0;net461;netstandard2.0;netstandard2.1 + net6.0;net5.0;netstandard2.0;netstandard2.1 diff --git a/src/Discord.Net.Commands/IModuleBase.cs b/src/Discord.Net.Commands/IModuleBase.cs index 3b641ec5ff..8b021f4de5 100644 --- a/src/Discord.Net.Commands/IModuleBase.cs +++ b/src/Discord.Net.Commands/IModuleBase.cs @@ -2,14 +2,34 @@ namespace Discord.Commands { - internal interface IModuleBase + /// + /// Represents a generic module base. + /// + public interface IModuleBase { + /// + /// Sets the context of this module base. + /// + /// The context to set. void SetContext(ICommandContext context); + /// + /// Executed before a command is run in this module base. + /// + /// The command thats about to run. void BeforeExecute(CommandInfo command); - + + /// + /// Executed after a command is ran in this module base. + /// + /// The command that ran. void AfterExecute(CommandInfo command); + /// + /// Executed when this module is building. + /// + /// The command service that is building this module. + /// The builder constructing this module. void OnModuleBuilding(CommandService commandService, ModuleBuilder builder); } } diff --git a/src/Discord.Net.Core/AssemblyInfo.cs b/src/Discord.Net.Core/AssemblyInfo.cs index b7c60f3d3a..0031c2dbf3 100644 --- a/src/Discord.Net.Core/AssemblyInfo.cs +++ b/src/Discord.Net.Core/AssemblyInfo.cs @@ -8,3 +8,4 @@ [assembly: InternalsVisibleTo("Discord.Net.Commands")] [assembly: InternalsVisibleTo("Discord.Net.Tests")] [assembly: InternalsVisibleTo("Discord.Net.Tests.Unit")] +[assembly: InternalsVisibleTo("Discord.Net.Interactions")] diff --git a/src/Discord.Net.Core/Discord.Net.Core.csproj b/src/Discord.Net.Core/Discord.Net.Core.csproj index 7dc55b1cf3..bfbff6f5ca 100644 --- a/src/Discord.Net.Core/Discord.Net.Core.csproj +++ b/src/Discord.Net.Core/Discord.Net.Core.csproj @@ -1,12 +1,12 @@ - + Discord.Net.Core Discord The core components for the Discord.Net library. - net461;netstandard2.0;netstandard2.1 - netstandard2.0;netstandard2.1 + net6.0;net5.0;net461;netstandard2.0;netstandard2.1 + net6.0;net5.0;netstandard2.0;netstandard2.1 diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs index 9a69d9d186..5857bac81d 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; namespace Discord @@ -29,6 +30,9 @@ public string Name if (!Regex.IsMatch(value, @"^[\w-]{1,32}$")) throw new FormatException($"{nameof(value)} must match the regex ^[\\w-]{{1,32}}$"); + if (value.Any(x => char.IsUpper(x))) + throw new FormatException("Name cannot contain any uppercase characters."); + _name = value; } } diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IMessageCommandInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IMessageCommandInteraction.cs index b1b331e8be..07d66bfcb1 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IMessageCommandInteraction.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IMessageCommandInteraction.cs @@ -3,7 +3,7 @@ namespace Discord /// /// Represents a Message Command interaction. /// - public interface IMessageCommandInteraction : IDiscordInteraction + public interface IMessageCommandInteraction : IApplicationCommandInteraction { /// /// Gets the data associated with this interaction. diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IUserCommandInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IUserCommandInteraction.cs index f7cfd67f05..2ffdfd9f6a 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IUserCommandInteraction.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IUserCommandInteraction.cs @@ -3,7 +3,7 @@ namespace Discord /// /// Represents a User Command interaction. /// - public interface IUserCommandInteraction : IDiscordInteraction + public interface IUserCommandInteraction : IApplicationCommandInteraction { /// /// Gets the data associated with this interaction. diff --git a/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandInteraction.cs new file mode 100644 index 0000000000..b079a47be1 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandInteraction.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents an application command interaction. + /// + public interface IApplicationCommandInteraction : IDiscordInteraction + { + /// + /// Gets the data of the application command interaction + /// + new IApplicationCommandInteractionData Data { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs index d9e250118d..77971b9f3f 100644 --- a/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs +++ b/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; namespace Discord @@ -33,6 +35,11 @@ public interface IDiscordInteraction : ISnowflakeEntity /// int Version { get; } + /// + /// Gets the user who invoked the interaction. + /// + IUser User { get; } + /// /// Responds to an Interaction with type . /// @@ -42,10 +49,14 @@ public interface IDiscordInteraction : ISnowflakeEntity /// if the response should be hidden to everyone besides the invoker of the command, otherwise . /// The allowed mentions for this response. /// The request options for this response. - /// A to be sent with this response. + /// A to be sent with this response. /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. - Task RespondAsync(string text = null, Embed[] embeds = null, bool isTTS = false, - bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null); + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + Task RespondAsync(string text = null, Embed[] embeds = null, bool isTTS = false, + bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); /// /// Sends a followup message for this interaction. @@ -56,13 +67,90 @@ Task RespondAsync(string text = null, Embed[] embeds = null, bool isTTS = false, /// if the response should be hidden to everyone besides the invoker of the command, otherwise . /// The allowed mentions for this response. /// The request options for this response. - /// A to be sent with this response. + /// A to be sent with this response. /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. /// - /// The sent message. + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. /// Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, - AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null); + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// The file to upload. + /// The file name of the attachment. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// The file to upload. + /// The file name of the attachment. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public Task FollowupWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + /// + /// Sends a followup message for this interaction. + /// + /// The attachment containing the file and description. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + Task FollowupWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + /// + /// Sends a followup message for this interaction. + /// + /// A collection of attachments to upload. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + Task FollowupWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); /// /// Gets the original response for this interaction. @@ -76,14 +164,17 @@ Task FollowupAsync(string text = null, Embed[] embeds = null, bool /// /// A delegate containing the properties to modify the message with. /// The request options for this request. - /// A that represents the initial response. + /// + /// A task that represents an asynchronous modification operation. The task result + /// contains the updated message. + /// Task ModifyOriginalResponseAsync(Action func, RequestOptions options = null); /// /// Acknowledges this interaction. /// /// - /// A task that represents the asynchronous operation of acknowledging the interaction. + /// A task that represents the asynchronous operation of deferring the interaction. /// Task DeferAsync(bool ephemeral = false, RequestOptions options = null); } diff --git a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/ISlashCommandInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/ISlashCommandInteraction.cs index 5561829878..f28c35e408 100644 --- a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/ISlashCommandInteraction.cs +++ b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/ISlashCommandInteraction.cs @@ -3,7 +3,7 @@ namespace Discord /// /// Represents a slash command interaction. /// - public interface ISlashCommandInteraction : IDiscordInteraction + public interface ISlashCommandInteraction : IApplicationCommandInteraction { /// /// Gets the data associated with this interaction. diff --git a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs index b4fc89cc22..074a52f321 100644 --- a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs @@ -40,6 +40,9 @@ public string Name if (!Regex.IsMatch(value, @"^[\w-]{1,32}$")) throw new ArgumentException("Command name cannot contain any special characters or whitespaces!", nameof(value)); + if (value.Any(x => char.IsUpper(x))) + throw new FormatException("Name cannot contain any uppercase characters."); + _name = value; } } diff --git a/src/Discord.Net.Core/Interactions/IInteractionContext.cs b/src/Discord.Net.Core/Interactions/IInteractionContext.cs new file mode 100644 index 0000000000..3b5ba521d4 --- /dev/null +++ b/src/Discord.Net.Core/Interactions/IInteractionContext.cs @@ -0,0 +1,36 @@ +namespace Discord +{ + /// + /// Represents the context of an Interaction. + /// + public interface IInteractionContext + { + /// + /// Gets the client that will be used to handle this interaction. + /// + IDiscordClient Client { get; } + + /// + /// Gets the guild the interaction originated from. + /// + /// + /// Will be if the interaction originated from a DM channel or the interaction was a Context Command interaction. + /// + IGuild Guild { get; } + + /// + /// Gets the channel the interaction originated from. + /// + IMessageChannel Channel { get; } + + /// + /// Gets the user who invoked the interaction event. + /// + IUser User { get; } + + /// + /// Gets the underlying interaction. + /// + IDiscordInteraction Interaction { get; } + } +} diff --git a/src/Discord.Net.Examples/Discord.Net.Examples.csproj b/src/Discord.Net.Examples/Discord.Net.Examples.csproj index 3371432b83..b4a336f9f6 100644 --- a/src/Discord.Net.Examples/Discord.Net.Examples.csproj +++ b/src/Discord.Net.Examples/Discord.Net.Examples.csproj @@ -1,7 +1,7 @@ - netstandard2.0 + net5.0 diff --git a/src/Discord.Net.Interactions/Attributes/AutocompleteAttribute.cs b/src/Discord.Net.Interactions/Attributes/AutocompleteAttribute.cs new file mode 100644 index 0000000000..e17c9ff144 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/AutocompleteAttribute.cs @@ -0,0 +1,36 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Set the to . + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public class AutocompleteAttribute : Attribute + { + /// + /// Type of the . + /// + public Type AutocompleteHandlerType { get; } + + /// + /// Set the to and define a to handle + /// Autocomplete interactions targeting the parameter this is applied to. + /// + /// + /// must be set to to use this constructor. + /// + public AutocompleteAttribute(Type autocompleteHandlerType) + { + if (!typeof(IAutocompleteHandler).IsAssignableFrom(autocompleteHandlerType)) + throw new InvalidOperationException($"{autocompleteHandlerType.FullName} isn't a valid {nameof(IAutocompleteHandler)} type"); + + AutocompleteHandlerType = autocompleteHandlerType; + } + + /// + /// Set the to without specifying a . + /// + public AutocompleteAttribute() { } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/ChannelTypesAttribute.cs b/src/Discord.Net.Interactions/Attributes/ChannelTypesAttribute.cs new file mode 100644 index 0000000000..0136413776 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/ChannelTypesAttribute.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Interactions +{ + /// + /// Specify the target channel types for a option. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public sealed class ChannelTypesAttribute : Attribute + { + /// + /// Gets the allowed channel types for this option. + /// + public IReadOnlyCollection ChannelTypes { get; } + + /// + /// Specify the target channel types for a option. + /// + /// The allowed channel types for this option. + public ChannelTypesAttribute (params ChannelType[] channelTypes) + { + if (channelTypes is null) + throw new ArgumentNullException(nameof(channelTypes)); + + ChannelTypes = channelTypes.ToImmutableArray(); + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/ChoiceAttribute.cs b/src/Discord.Net.Interactions/Attributes/ChoiceAttribute.cs new file mode 100644 index 0000000000..200c75f9ed --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/ChoiceAttribute.cs @@ -0,0 +1,64 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Add a pre-determined argument value to a command parameter. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = true, Inherited = true)] + public class ChoiceAttribute : Attribute + { + /// + /// Gets the name of the choice. + /// + public string Name { get; } + + /// + /// Gets the type of this choice. + /// + public SlashCommandChoiceType Type { get; } + + /// + /// Gets the value that will be used whenever this choice is selected. + /// + public object Value { get; } + + private ChoiceAttribute (string name) + { + Name = name; + } + + /// + /// Create a parameter choice with type . + /// + /// Name of the choice. + /// Predefined value of the choice. + public ChoiceAttribute (string name, string value) : this(name) + { + Type = SlashCommandChoiceType.String; + Value = value; + } + + /// + /// Create a parameter choice with type . + /// + /// Name of the choice. + /// Predefined value of the choice. + public ChoiceAttribute (string name, int value) : this(name) + { + Type = SlashCommandChoiceType.Integer; + Value = value; + } + + /// + /// Create a parameter choice with type . + /// + /// Name of the choice. + /// Predefined value of the choice. + public ChoiceAttribute (string name, double value) : this(name) + { + Type = SlashCommandChoiceType.Number; + Value = value; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Commands/AutocompleteCommandAttribute.cs b/src/Discord.Net.Interactions/Attributes/Commands/AutocompleteCommandAttribute.cs new file mode 100644 index 0000000000..df46dcfa40 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Commands/AutocompleteCommandAttribute.cs @@ -0,0 +1,39 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Create an Autocomplete Command. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class AutocompleteCommandAttribute : Attribute + { + /// + /// Gets the name of the target parameter. + /// + public string ParameterName { get; } + + /// + /// Gets the name of the target command. + /// + public string CommandName { get; } + + /// + /// Get the run mode this command gets executed with. + /// + public RunMode RunMode { get; } + + /// + /// Create a command for Autocomplete interaction handling. + /// + /// Name of the target parameter. + /// Name of the target command. + /// Set the run mode of the command. + public AutocompleteCommandAttribute(string parameterName, string commandName, RunMode runMode = RunMode.Default) + { + ParameterName = parameterName; + CommandName = commandName; + RunMode = runMode; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Commands/ComponentInteractionAttribute.cs b/src/Discord.Net.Interactions/Attributes/Commands/ComponentInteractionAttribute.cs new file mode 100644 index 0000000000..70bc285fc9 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Commands/ComponentInteractionAttribute.cs @@ -0,0 +1,44 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Create a Message Component interaction handler, CustomId represents + /// the CustomId of the Message Component that will be handled. + /// + /// + /// s will add prefixes to this command if is set to + /// CustomID supports a Wild Card pattern where you can use the to match a set of CustomIDs. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class ComponentInteractionAttribute : Attribute + { + /// + /// Gets the string to compare the Message Component CustomIDs with. + /// + public string CustomId { get; } + + /// + /// Gets if s will be ignored while creating this command and this method will be treated as a top level command. + /// + public bool IgnoreGroupNames { get; } + + /// + /// Gets the run mode this command gets executed with. + /// + public RunMode RunMode { get; } + + /// + /// Create a command for component interaction handling. + /// + /// String to compare the Message Component CustomIDs with. + /// If s will be ignored while creating this command and this method will be treated as a top level command. + /// Set the run mode of the command. + public ComponentInteractionAttribute (string customId, bool ignoreGroupNames = false, RunMode runMode = RunMode.Default) + { + CustomId = customId; + IgnoreGroupNames = ignoreGroupNames; + RunMode = runMode; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Commands/ContextCommandAttribute.cs b/src/Discord.Net.Interactions/Attributes/Commands/ContextCommandAttribute.cs new file mode 100644 index 0000000000..fd303a45ce --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Commands/ContextCommandAttribute.cs @@ -0,0 +1,36 @@ +using System; +using System.Reflection; + +namespace Discord.Interactions +{ + /// + /// Base attribute for creating a Context Commands. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public abstract class ContextCommandAttribute : Attribute + { + /// + /// Gets the name of this Context Command. + /// + public string Name { get; } + + /// + /// Gets the type of this Context Command. + /// + public ApplicationCommandType CommandType { get; } + + /// + /// Gets the run mode this command gets executed with. + /// + public RunMode RunMode { get; } + + internal ContextCommandAttribute (string name, ApplicationCommandType commandType, RunMode runMode = RunMode.Default) + { + Name = name; + CommandType = commandType; + RunMode = runMode; + } + + internal virtual void CheckMethodDefinition (MethodInfo methodInfo) { } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Commands/MessageCommandAttribute.cs b/src/Discord.Net.Interactions/Attributes/Commands/MessageCommandAttribute.cs new file mode 100644 index 0000000000..93d010c81e --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Commands/MessageCommandAttribute.cs @@ -0,0 +1,29 @@ +using System; +using System.Reflection; + +namespace Discord.Interactions +{ + /// + /// Create a Message Context Command. + /// + /// + /// s won't add prefixes to this command. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class MessageCommandAttribute : ContextCommandAttribute + { + /// + /// Register a method as a Message Context Command. + /// + /// Name of the context command. + public MessageCommandAttribute (string name) : base(name, ApplicationCommandType.Message) { } + + internal override void CheckMethodDefinition (MethodInfo methodInfo) + { + var parameters = methodInfo.GetParameters(); + + if (parameters.Length != 1 || !typeof(IMessage).IsAssignableFrom(parameters[0].ParameterType)) + throw new InvalidOperationException($"Message Commands must have only one parameter that is a type of {nameof(IMessage)}"); + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Commands/SlashCommandAttribute.cs b/src/Discord.Net.Interactions/Attributes/Commands/SlashCommandAttribute.cs new file mode 100644 index 0000000000..aebc366a05 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Commands/SlashCommandAttribute.cs @@ -0,0 +1,49 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Create an Slash Application Command. + /// + /// + /// prefix will be used to created nested Slash Application Commands. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class SlashCommandAttribute : Attribute + { + /// + /// Gets the name of the Slash Command. + /// + public string Name { get; } + + /// + /// Gets the description of the Slash Command. + /// + public string Description { get; } + + /// + /// Gets if s will be ignored while creating this command and this method will be treated as a top level command. + /// + public bool IgnoreGroupNames { get; } + + /// + /// Gets the run mode this command gets executed with. + /// + public RunMode RunMode { get; } + + /// + /// Register a method as a Slash Command. + /// + /// Name of the command. + /// Description of the command. + /// If , s will be ignored while creating this command and this method will be treated as a top level command. + /// Set the run mode of the command. + public SlashCommandAttribute (string name, string description, bool ignoreGroupNames = false, RunMode runMode = RunMode.Default) + { + Name = name; + Description = description; + IgnoreGroupNames = ignoreGroupNames; + RunMode = runMode; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Commands/UserCommandAttribute.cs b/src/Discord.Net.Interactions/Attributes/Commands/UserCommandAttribute.cs new file mode 100644 index 0000000000..592dcf5c3c --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Commands/UserCommandAttribute.cs @@ -0,0 +1,29 @@ +using System; +using System.Reflection; + +namespace Discord.Interactions +{ + /// + /// Create an User Context Command. + /// + /// + /// s won't add prefixes to this command. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class UserCommandAttribute : ContextCommandAttribute + { + /// + /// Register a command as a User Context Command. + /// + /// Name of this User Context Command. + public UserCommandAttribute (string name) : base(name, ApplicationCommandType.User) { } + + internal override void CheckMethodDefinition (MethodInfo methodInfo) + { + var parameters = methodInfo.GetParameters(); + + if (parameters.Length != 1 || !typeof(IUser).IsAssignableFrom(parameters[0].ParameterType)) + throw new InvalidOperationException($"User Commands must have only one parameter that is a type of {nameof(IUser)}"); + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/DefaultPermissionAttribute.cs b/src/Discord.Net.Interactions/Attributes/DefaultPermissionAttribute.cs new file mode 100644 index 0000000000..ed0a532be6 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/DefaultPermissionAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Set the "Default Permission" property of an Application Command. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class DefaultPermissionAttribute : Attribute + { + /// + /// Gets whether the users are allowed to use a Slash Command by default or not. + /// + public bool IsDefaultPermission { get; } + + /// + /// Set the default permission of a Slash Command. + /// + /// if the users are allowed to use this command. + public DefaultPermissionAttribute (bool isDefaultPermission) + { + IsDefaultPermission = isDefaultPermission; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/DontAutoRegisterAttribute.cs b/src/Discord.Net.Interactions/Attributes/DontAutoRegisterAttribute.cs new file mode 100644 index 0000000000..18deb785eb --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/DontAutoRegisterAttribute.cs @@ -0,0 +1,13 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// s with this attribute will not be registered by the or + /// methods. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public class DontAutoRegisterAttribute : Attribute + { + } +} diff --git a/src/Discord.Net.Interactions/Attributes/GroupAttribute.cs b/src/Discord.Net.Interactions/Attributes/GroupAttribute.cs new file mode 100644 index 0000000000..7014c35a54 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/GroupAttribute.cs @@ -0,0 +1,35 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Create nested Slash Commands by marking a module as a command group. + /// + /// + /// commands wil not be affected by this. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public class GroupAttribute : Attribute + { + /// + /// Gets the name of the group. + /// + public string Name { get; } + + /// + /// Gets the description of the group. + /// + public string Description { get; } + + /// + /// Create a command group. + /// + /// Name of the group. + /// Description of the group. + public GroupAttribute (string name, string description) + { + Name = name; + Description = description; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/MaxValueAttribute.cs b/src/Discord.Net.Interactions/Attributes/MaxValueAttribute.cs new file mode 100644 index 0000000000..1b46a4ecd1 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/MaxValueAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Set the maximum value permitted for a number type parameter. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public sealed class MaxValueAttribute : Attribute + { + /// + /// Gets the maximum value permitted. + /// + public double Value { get; } + + /// + /// Set the maximum value permitted for a number type parameter. + /// + /// The maximum value permitted. + public MaxValueAttribute(double value) + { + Value = value; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/MinValueAttribute.cs b/src/Discord.Net.Interactions/Attributes/MinValueAttribute.cs new file mode 100644 index 0000000000..cce7a3b2c9 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/MinValueAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Set the minimum value permitted for a number type parameter. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public sealed class MinValueAttribute : Attribute + { + /// + /// Gets the minimum value permitted. + /// + public double Value { get; } + + /// + /// Set the minimum value permitted for a number type parameter. + /// + /// The minimum value permitted. + public MinValueAttribute(double value) + { + Value = value; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/ParameterPreconditionAttribute.cs b/src/Discord.Net.Interactions/Attributes/ParameterPreconditionAttribute.cs new file mode 100644 index 0000000000..22b9dc42ba --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/ParameterPreconditionAttribute.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Requires the parameter to pass the specified precondition before execution can begin. + /// + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = true, Inherited = true)] + public abstract class ParameterPreconditionAttribute : Attribute + { + /// + /// Gets the error message to be returned if execution context doesn't pass the precondition check. + /// + /// + /// When overridden in a derived class, uses the supplied string + /// as the error message if the precondition doesn't pass. + /// Setting this for a class that doesn't override + /// this property is a no-op. + /// + public virtual string ErrorMessage { get; } + + /// + /// Checks whether the condition is met before execution of the command. + /// + /// The context of the command. + /// The parameter of the command being checked against. + /// The raw value of the parameter. + /// The service collection used for dependency injection. + public abstract Task CheckRequirementsAsync (IInteractionContext context, IParameterInfo parameterInfo, object value, + IServiceProvider services); + } +} diff --git a/src/Discord.Net.Interactions/Attributes/PreconditionAttribute.cs b/src/Discord.Net.Interactions/Attributes/PreconditionAttribute.cs new file mode 100644 index 0000000000..37c9de2cfa --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/PreconditionAttribute.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Requires the module or class to pass the specified precondition before execution can begin. + /// + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public abstract class PreconditionAttribute : Attribute + { + /// + /// Gets the group that this precondition belongs to. + /// + /// + /// of the same group require only one of the preconditions to pass in order to + /// be successful (A || B). Specifying = null or not at all will + /// require *all* preconditions to pass, just like normal (A && B). + /// + public string Group { get; set; } = null; + + /// + /// Gets the error message to be returned if execution context doesn't pass the precondition check. + /// + /// + /// When overridden in a derived class, uses the supplied string + /// as the error message if the precondition doesn't pass. + /// Setting this for a class that doesn't override + /// this property is a no-op. + /// + public virtual string ErrorMessage { get; } + + /// + /// Checks if the command to be executed meets the precondition requirements. + /// + /// The context of the command. + /// The command being executed. + /// The service collection used for dependency injection. + public abstract Task CheckRequirementsAsync (IInteractionContext context, ICommandInfo commandInfo, IServiceProvider services); + } +} diff --git a/src/Discord.Net.Interactions/Attributes/SummaryAttribute.cs b/src/Discord.Net.Interactions/Attributes/SummaryAttribute.cs new file mode 100644 index 0000000000..694569257a --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/SummaryAttribute.cs @@ -0,0 +1,32 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Customize the name and description of an Slash Application Command parameter. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public class SummaryAttribute : Attribute + { + /// + /// Gets the name of the parameter. + /// + public string Name { get; } = null; + + /// + /// Gets the description of the parameter. + /// + public string Description { get; } = null; + + /// + /// Modify the default name and description values of a Slash Command parameter. + /// + /// Name of the parameter. + /// Description of the parameter. + public SummaryAttribute (string name = null, string description = null) + { + Name = name; + Description = description; + } + } +} diff --git a/src/Discord.Net.Interactions/AutocompleteHandlers/AutocompleteHandler.cs b/src/Discord.Net.Interactions/AutocompleteHandlers/AutocompleteHandler.cs new file mode 100644 index 0000000000..fd0bc83de9 --- /dev/null +++ b/src/Discord.Net.Interactions/AutocompleteHandlers/AutocompleteHandler.cs @@ -0,0 +1,102 @@ +using Discord.Rest; +using Discord.WebSocket; +using System; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Base class for creating Autocompleters. uses Autocompleters to generate parameter suggestions. + /// + public abstract class AutocompleteHandler : IAutocompleteHandler + { + /// + public InteractionService InteractionService { get; set; } + + /// + public abstract Task GenerateSuggestionsAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, + IServiceProvider services); + + protected virtual string GetLogString(IInteractionContext context) + { + var interaction = (context.Interaction as IAutocompleteInteraction); + return $"{interaction.Data.CommandName}: {interaction.Data.Current.Name} Autocomplete"; + } + + /// + public async Task ExecuteAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, + IServiceProvider services) + { + switch (InteractionService._runMode) + { + case RunMode.Sync: + { + return await ExecuteInternalAsync(context, autocompleteInteraction, parameter, services).ConfigureAwait(false); + } + case RunMode.Async: + _ = Task.Run(async () => + { + await ExecuteInternalAsync(context, autocompleteInteraction, parameter, services).ConfigureAwait(false); + }); + break; + default: + throw new InvalidOperationException($"RunMode {InteractionService._runMode} is not supported."); + } + + return ExecuteResult.FromSuccess(); + } + + private async Task ExecuteInternalAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, + IServiceProvider services) + { + try + { + var result = await GenerateSuggestionsAsync(context, autocompleteInteraction, parameter, services).ConfigureAwait(false); + + if (result.IsSuccess) + switch (autocompleteInteraction) + { + case RestAutocompleteInteraction restAutocomplete: + var payload = restAutocomplete.Respond(result.Suggestions); + await InteractionService._restResponseCallback(context, payload).ConfigureAwait(false); + break; + case SocketAutocompleteInteraction socketAutocomplete: + await socketAutocomplete.RespondAsync(result.Suggestions).ConfigureAwait(false); + break; + } + + await InteractionService._autocompleteHandlerExecutedEvent.InvokeAsync(this, context, result).ConfigureAwait(false); + return result; + } + catch (Exception ex) + { + var originalEx = ex; + while (ex is TargetInvocationException) + ex = ex.InnerException; + + await InteractionService._cmdLogger.ErrorAsync(ex).ConfigureAwait(false); + + var result = ExecuteResult.FromError(ex); + await InteractionService._autocompleteHandlerExecutedEvent.InvokeAsync(this, context, result).ConfigureAwait(false); + + if (InteractionService._throwOnError) + { + if (ex == originalEx) + throw; + else + ExceptionDispatchInfo.Capture(ex).Throw(); + } + + return result; + } + finally + { + await InteractionService._cmdLogger.VerboseAsync($"Executed {GetLogString(context)}").ConfigureAwait(false); + } + } + } +} + + diff --git a/src/Discord.Net.Interactions/AutocompleteHandlers/IAutocompleteHandler.cs b/src/Discord.Net.Interactions/AutocompleteHandlers/IAutocompleteHandler.cs new file mode 100644 index 0000000000..8072b30ed6 --- /dev/null +++ b/src/Discord.Net.Interactions/AutocompleteHandlers/IAutocompleteHandler.cs @@ -0,0 +1,43 @@ +using Discord.WebSocket; +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represent a Autocomplete handler object that can be executed to generate parameter suggestions. + /// + public interface IAutocompleteHandler + { + /// + /// Gets the the underlying command service. + /// + InteractionService InteractionService { get; } + + /// + /// Will be used to generate parameter suggestions. + /// + /// Command execution context. + /// Autocomplete Interaction payload. + /// Parameter information of the target parameter. + /// Dependencies that will be used to create the module instance. + /// + /// A task representing the execution process. The task result contains the Autocompletion result. + /// + Task GenerateSuggestionsAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, + IServiceProvider services); + + /// + /// Executes the with the provided context. + /// + /// The execution context. + /// AutocompleteInteraction payload. + /// Parameter information of the target parameter. + /// Dependencies that will be used to create the module instance. + /// + /// A task representing the execution process. The task result contains the execution result. + /// + Task ExecuteAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, + IServiceProvider services); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Commands/AutocompleteCommandBuilder.cs b/src/Discord.Net.Interactions/Builders/Commands/AutocompleteCommandBuilder.cs new file mode 100644 index 0000000000..c383f1be16 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Commands/AutocompleteCommandBuilder.cs @@ -0,0 +1,76 @@ +using System; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public sealed class AutocompleteCommandBuilder : CommandBuilder + { + /// + /// Gets the name of the target parameter. + /// + public string ParameterName { get; set; } + + /// + /// Gets the name of the target command. + /// + public string CommandName { get; set; } + + protected override AutocompleteCommandBuilder Instance => this; + + internal AutocompleteCommandBuilder(ModuleBuilder module) : base(module) { } + + /// + /// Initializes a new . + /// + /// Parent module of this command. + /// Name of this command. + /// Execution callback of this command. + public AutocompleteCommandBuilder(ModuleBuilder module, string name, ExecuteCallback callback) : base(module, name, callback) { } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public AutocompleteCommandBuilder WithParameterName(string name) + { + ParameterName = name; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public AutocompleteCommandBuilder WithCommandName(string name) + { + CommandName = name; + return this; + } + + /// + /// Adds a command parameter to the parameters collection. + /// + /// factory. + /// + /// The builder instance. + /// + public override AutocompleteCommandBuilder AddParameter(Action configure) + { + var parameter = new CommandParameterBuilder(this); + configure(parameter); + AddParameters(parameter); + return this; + } + + internal override AutocompleteCommandInfo Build(ModuleInfo module, InteractionService commandService) => + new AutocompleteCommandInfo(this, module, commandService); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Commands/CommandBuilder.cs b/src/Discord.Net.Interactions/Builders/Commands/CommandBuilder.cs new file mode 100644 index 0000000000..5c35e8871e --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Commands/CommandBuilder.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents the base builder class for creating . + /// + /// The this builder yields when built. + /// Inherited type. + /// Builder type for this commands parameters. + public abstract class CommandBuilder : ICommandBuilder + where TInfo : class, ICommandInfo + where TBuilder : CommandBuilder + where TParamBuilder : class, IParameterBuilder + { + private readonly List _attributes; + private readonly List _preconditions; + private readonly List _parameters; + + protected abstract TBuilder Instance { get; } + + /// + public ModuleBuilder Module { get; } + + //// + public ExecuteCallback Callback { get; internal set; } + + /// + public string Name { get; internal set; } + + /// + public string MethodName { get; set; } + + /// + public bool IgnoreGroupNames { get; set; } + + /// + public RunMode RunMode { get; set; } + + /// + public IReadOnlyList Attributes => _attributes; + + /// + public IReadOnlyList Parameters => _parameters; + + /// + public IReadOnlyList Preconditions => _preconditions; + + /// + IReadOnlyList ICommandBuilder.Parameters => Parameters; + + internal CommandBuilder (ModuleBuilder module) + { + _attributes = new List(); + _preconditions = new List(); + _parameters = new List(); + + Module = module; + } + + protected CommandBuilder (ModuleBuilder module, string name, ExecuteCallback callback) : this(module) + { + Name = name; + Callback = callback; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder WithName (string name) + { + Name = name; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder WithMethodName (string name) + { + MethodName = name; + return Instance; + } + + /// + /// Adds attributes to . + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + public TBuilder WithAttributes (params Attribute[] attributes) + { + _attributes.AddRange(attributes); + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder SetRunMode (RunMode runMode) + { + RunMode = runMode; + return Instance; + } + + /// + /// Adds parameter builders to . + /// + /// New parameter builders to be added to . + /// + /// The builder instance. + /// + public TBuilder AddParameters (params TParamBuilder[] parameters) + { + _parameters.AddRange(parameters); + return Instance; + } + + /// + /// Adds preconditions to . + /// + /// New preconditions to be added to . + /// + /// The builder instance. + /// + public TBuilder WithPreconditions (params PreconditionAttribute[] preconditions) + { + _preconditions.AddRange(preconditions); + return Instance; + } + + /// + public abstract TBuilder AddParameter (Action configure); + + internal abstract TInfo Build (ModuleInfo module, InteractionService commandService); + + //ICommandBuilder + /// + ICommandBuilder ICommandBuilder.WithName (string name) => + WithName(name); + + /// + ICommandBuilder ICommandBuilder.WithMethodName (string name) => + WithMethodName(name); + ICommandBuilder ICommandBuilder.WithAttributes (params Attribute[] attributes) => + WithAttributes(attributes); + + /// + ICommandBuilder ICommandBuilder.SetRunMode (RunMode runMode) => + SetRunMode(runMode); + + /// + ICommandBuilder ICommandBuilder.AddParameters (params IParameterBuilder[] parameters) => + AddParameters(parameters as TParamBuilder); + + /// + ICommandBuilder ICommandBuilder.WithPreconditions (params PreconditionAttribute[] preconditions) => + WithPreconditions(preconditions); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Commands/ComponentCommandBuilder.cs b/src/Discord.Net.Interactions/Builders/Commands/ComponentCommandBuilder.cs new file mode 100644 index 0000000000..e42dfabced --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Commands/ComponentCommandBuilder.cs @@ -0,0 +1,40 @@ +using System; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public sealed class ComponentCommandBuilder : CommandBuilder + { + protected override ComponentCommandBuilder Instance => this; + + internal ComponentCommandBuilder (ModuleBuilder module) : base(module) { } + + /// + /// Initializes a new . + /// + /// Parent module of this command. + /// Name of this command. + /// Execution callback of this command. + public ComponentCommandBuilder (ModuleBuilder module, string name, ExecuteCallback callback) : base(module, name, callback) { } + + /// + /// Adds a command parameter to the parameters collection. + /// + /// factory. + /// + /// The builder instance. + /// + public override ComponentCommandBuilder AddParameter (Action configure) + { + var parameter = new CommandParameterBuilder(this); + configure(parameter); + AddParameters(parameter); + return this; + } + + internal override ComponentCommandInfo Build (ModuleInfo module, InteractionService commandService) => + new ComponentCommandInfo(this, module, commandService); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Commands/ContextCommandBuilder.cs b/src/Discord.Net.Interactions/Builders/Commands/ContextCommandBuilder.cs new file mode 100644 index 0000000000..d40547b3ca --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Commands/ContextCommandBuilder.cs @@ -0,0 +1,76 @@ +using System; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public sealed class ContextCommandBuilder : CommandBuilder + { + protected override ContextCommandBuilder Instance => this; + + /// + /// Gets the type of this command. + /// + public ApplicationCommandType CommandType { get; set; } + + /// + /// Gets the default permission of this command. + /// + public bool DefaultPermission { get; set; } = true; + + internal ContextCommandBuilder (ModuleBuilder module) : base(module) { } + + /// + /// Initializes a new . + /// + /// Parent module of this command. + /// Name of this command. + /// Execution callback of this command. + public ContextCommandBuilder (ModuleBuilder module, string name, ExecuteCallback callback) : base(module, name, callback) { } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ContextCommandBuilder SetType (ApplicationCommandType commandType) + { + CommandType = commandType; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ContextCommandBuilder SetDefaultPermission (bool defaultPermision) + { + DefaultPermission = defaultPermision; + return this; + } + + /// + /// Adds a command parameter to the parameters collection. + /// + /// factory. + /// + /// The builder instance. + /// + public override ContextCommandBuilder AddParameter (Action configure) + { + var parameter = new CommandParameterBuilder(this); + configure(parameter); + AddParameters(parameter); + return this; + } + + internal override ContextCommandInfo Build (ModuleInfo module, InteractionService commandService) => + ContextCommandInfo.Create(this, module, commandService); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Commands/ICommandBuilder.cs b/src/Discord.Net.Interactions/Builders/Commands/ICommandBuilder.cs new file mode 100644 index 0000000000..95007296c9 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Commands/ICommandBuilder.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders +{ + /// + /// Represent a command builder for creating . + /// + public interface ICommandBuilder + { + /// + /// Gets the execution delegate of this command. + /// + ExecuteCallback Callback { get; } + + /// + /// Gets the parent module of this command. + /// + ModuleBuilder Module { get; } + + /// + /// Gets the name of this command. + /// + string Name { get; } + + /// + /// Gets or sets the method name of this command. + /// + string MethodName { get; set; } + + /// + /// Gets or sets if this command will be registered and executed as a standalone command, unaffected by the s of + /// of the commands parents. + /// + bool IgnoreGroupNames { get; set; } + + /// + /// Gets or sets the run mode this command gets executed with. + /// + RunMode RunMode { get; set; } + + /// + /// Gets a collection of the attributes of this command. + /// + IReadOnlyList Attributes { get; } + + /// + /// Gets a collection of the parameters of this command. + /// + IReadOnlyList Parameters { get; } + + /// + /// Gets a collection of the preconditions of this command. + /// + IReadOnlyList Preconditions { get; } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + ICommandBuilder WithName (string name); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + ICommandBuilder WithMethodName (string name); + + /// + /// Adds attributes to . + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + ICommandBuilder WithAttributes (params Attribute[] attributes); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + ICommandBuilder SetRunMode (RunMode runMode); + + /// + /// Adds parameter builders to . + /// + /// New parameter builders to be added to . + /// + /// The builder instance. + /// + ICommandBuilder AddParameters (params IParameterBuilder[] parameters); + + /// + /// Adds preconditions to . + /// + /// New preconditions to be added to . + /// + /// The builder instance. + /// + ICommandBuilder WithPreconditions (params PreconditionAttribute[] preconditions); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Commands/SlashCommandBuilder.cs b/src/Discord.Net.Interactions/Builders/Commands/SlashCommandBuilder.cs new file mode 100644 index 0000000000..d8e9b0658b --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Commands/SlashCommandBuilder.cs @@ -0,0 +1,76 @@ +using System; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public sealed class SlashCommandBuilder : CommandBuilder + { + protected override SlashCommandBuilder Instance => this; + + /// + /// Gets and sets the description of this command. + /// + public string Description { get; set; } + + /// + /// Gets and sets the default permission of this command. + /// + public bool DefaultPermission { get; set; } = true; + + internal SlashCommandBuilder (ModuleBuilder module) : base(module) { } + + /// + /// Initializes a new . + /// + /// Parent module of this command. + /// Name of this command. + /// Execution callback of this command. + public SlashCommandBuilder (ModuleBuilder module, string name, ExecuteCallback callback) : base(module, name, callback) { } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public SlashCommandBuilder WithDescription (string description) + { + Description = description; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public SlashCommandBuilder WithDefaultPermission (bool permission) + { + DefaultPermission = permission; + return Instance; + } + + /// + /// Adds a command parameter to the parameters collection. + /// + /// factory. + /// + /// The builder instance. + /// + public override SlashCommandBuilder AddParameter (Action configure) + { + var parameter = new SlashCommandParameterBuilder(this); + configure(parameter); + AddParameters(parameter); + return this; + } + + internal override SlashCommandInfo Build (ModuleInfo module, InteractionService commandService) => + new SlashCommandInfo(this, module, commandService); + } +} diff --git a/src/Discord.Net.Interactions/Builders/ModuleBuilder.cs b/src/Discord.Net.Interactions/Builders/ModuleBuilder.cs new file mode 100644 index 0000000000..73d5ed62fb --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/ModuleBuilder.cs @@ -0,0 +1,279 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public class ModuleBuilder + { + private readonly List _attributes; + private readonly List _preconditions; + private readonly List _subModules; + private readonly List _slashCommands; + private readonly List _contextCommands; + private readonly List _componentCommands; + private readonly List _autocompleteCommands; + + /// + /// Gets the underlying Interaction Service. + /// + public InteractionService InteractionService { get; } + + /// + /// Gets the parent module if this module is a sub-module. + /// + public ModuleBuilder Parent { get; } + + /// + /// Gets the name of this module. + /// + public string Name { get; internal set; } + + /// + /// Gets and sets the group name of this module. + /// + public string SlashGroupName { get; set; } + + /// + /// Gets whether this has a . + /// + public bool IsSlashGroup => !string.IsNullOrEmpty(SlashGroupName); + + /// + /// Gets and sets the description of this module. + /// + public string Description { get; set; } + + /// + /// Gets and sets the default permission of this module. + /// + public bool DefaultPermission { get; set; } = true; + + /// + /// Gets and sets whether this has a . + /// + public bool DontAutoRegister { get; set; } = false; + + /// + /// Gets a collection of the attributes of this module. + /// + public IReadOnlyList Attributes => _attributes; + + /// + /// Gets a collection of the preconditions of this module. + /// + public IReadOnlyCollection Preconditions => _preconditions; + + /// + /// Gets a collection of the sub-modules of this module. + /// + public IReadOnlyList SubModules => _subModules; + + /// + /// Gets a collection of the Slash Commands of this module. + /// + public IReadOnlyList SlashCommands => _slashCommands; + + /// + /// Gets a collection of the Context Commands of this module. + /// + public IReadOnlyList ContextCommands => _contextCommands; + + /// + /// Gets a collection of the Component Commands of this module. + /// + public IReadOnlyList ComponentCommands => _componentCommands; + + /// + /// Gets a collection of the Autocomplete Commands of this module. + /// + public IReadOnlyList AutocompleteCommands => _autocompleteCommands; + + internal TypeInfo TypeInfo { get; set; } + + internal ModuleBuilder (InteractionService interactionService, ModuleBuilder parent = null) + { + InteractionService = interactionService; + Parent = parent; + + _attributes = new List(); + _subModules = new List(); + _slashCommands = new List(); + _contextCommands = new List(); + _componentCommands = new List(); + _autocompleteCommands = new List(); + _preconditions = new List(); + } + + /// + /// Initializes a new . + /// + /// The underlying Interaction Service. + /// Name of this module. + /// Parent module of this sub-module. + public ModuleBuilder (InteractionService interactionService, string name, ModuleBuilder parent = null) : this(interactionService, parent) + { + Name = name; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ModuleBuilder WithGroupName (string name) + { + SlashGroupName = name; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ModuleBuilder WithDescription (string description) + { + Description = description; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ModuleBuilder WithDefaultPermision (bool permission) + { + DefaultPermission = permission; + return this; + } + + /// + /// Adds attributes to + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + public ModuleBuilder AddAttributes (params Attribute[] attributes) + { + _attributes.AddRange(attributes); + return this; + } + + /// + /// Adds preconditions to + /// + /// New preconditions to be added to . + /// + /// The builder instance. + /// + public ModuleBuilder AddPreconditions (params PreconditionAttribute[] preconditions) + { + _preconditions.AddRange(preconditions); + return this; + } + + /// + /// Adds slash command builder to + /// + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddSlashCommand (Action configure) + { + var command = new SlashCommandBuilder(this); + configure(command); + _slashCommands.Add(command); + return this; + } + + /// + /// Adds context command builder to + /// + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddContextCommand (Action configure) + { + var command = new ContextCommandBuilder(this); + configure(command); + _contextCommands.Add(command); + return this; + } + + /// + /// Adds component command builder to + /// + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddComponentCommand (Action configure) + { + var command = new ComponentCommandBuilder(this); + configure(command); + _componentCommands.Add(command); + return this; + } + + /// + /// Adds autocomplete command builder to + /// + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddAutocompleteCommand(Action configure) + { + var command = new AutocompleteCommandBuilder(this); + configure(command); + _autocompleteCommands.Add(command); + return this; + } + + /// + /// Adds sub-module builder to + /// + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddModule (Action configure) + { + var subModule = new ModuleBuilder(InteractionService, this); + configure(subModule); + _subModules.Add(subModule); + return this; + } + + internal ModuleInfo Build (InteractionService interactionService, IServiceProvider services, ModuleInfo parent = null) + { + var moduleInfo = new ModuleInfo(this, interactionService, services, parent); + + IInteractionModuleBase instance = ReflectionUtils.CreateObject(TypeInfo, interactionService, services); + try + { + instance.OnModuleBuilding(interactionService, moduleInfo); + } + finally + { + ( instance as IDisposable )?.Dispose(); + } + + return moduleInfo; + } + } +} diff --git a/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs b/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs new file mode 100644 index 0000000000..58b00c5f8b --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs @@ -0,0 +1,469 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Discord.Interactions.Builders +{ + internal static class ModuleClassBuilder + { + private static readonly TypeInfo ModuleTypeInfo = typeof(IInteractionModuleBase).GetTypeInfo(); + + public const int MaxCommandDepth = 3; + + public static async Task> SearchAsync (Assembly assembly, InteractionService commandService) + { + static bool IsLoadableModule (TypeInfo info) + { + return info.DeclaredMethods.Any(x => x.GetCustomAttribute() != null); + } + + var result = new List(); + + foreach (var type in assembly.DefinedTypes) + { + if (( type.IsPublic || type.IsNestedPublic ) && IsValidModuleDefinition(type)) + { + result.Add(type); + } + else if (IsLoadableModule(type)) + { + await commandService._cmdLogger.WarningAsync($"Class {type.FullName} is not public and cannot be loaded.").ConfigureAwait(false); + } + } + return result; + } + + public static async Task> BuildAsync (IEnumerable validTypes, InteractionService commandService, + IServiceProvider services) + { + var topLevelGroups = validTypes.Where(x => x.DeclaringType == null || !IsValidModuleDefinition(x.DeclaringType.GetTypeInfo())); + var built = new List(); + + var result = new Dictionary(); + + foreach (var type in topLevelGroups) + { + var builder = new ModuleBuilder(commandService); + + BuildModule(builder, type, commandService, services); + BuildSubModules(builder, type.DeclaredNestedTypes, built, commandService, services); + built.Add(type); + + var moduleInfo = builder.Build(commandService, services); + + result.Add(type.AsType(), moduleInfo); + } + + await commandService._cmdLogger.DebugAsync($"Successfully built {built.Count} Slash Command modules.").ConfigureAwait(false); + + return result; + } + + private static void BuildModule (ModuleBuilder builder, TypeInfo typeInfo, InteractionService commandService, + IServiceProvider services) + { + var attributes = typeInfo.GetCustomAttributes(); + + builder.Name = typeInfo.Name; + builder.TypeInfo = typeInfo; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case GroupAttribute group: + { + builder.SlashGroupName = group.Name; + builder.Description = group.Description; + } + break; + case DefaultPermissionAttribute defPermission: + { + builder.DefaultPermission = defPermission.IsDefaultPermission; + } + break; + case PreconditionAttribute precondition: + builder.AddPreconditions(precondition); + break; + case DontAutoRegisterAttribute dontAutoRegister: + builder.DontAutoRegister = true; + break; + default: + builder.AddAttributes(attribute); + break; + } + } + + var methods = typeInfo.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + var validSlashCommands = methods.Where(IsValidSlashCommandDefinition); + var validContextCommands = methods.Where(IsValidContextCommandDefinition); + var validInteractions = methods.Where(IsValidComponentCommandDefinition); + var validAutocompleteCommands = methods.Where(IsValidAutocompleteCommandDefinition); + + Func createInstance = commandService._useCompiledLambda ? + ReflectionUtils.CreateLambdaBuilder(typeInfo, commandService) : ReflectionUtils.CreateBuilder(typeInfo, commandService); + + foreach (var method in validSlashCommands) + builder.AddSlashCommand(x => BuildSlashCommand(x, createInstance, method, commandService, services)); + + foreach (var method in validContextCommands) + builder.AddContextCommand(x => BuildContextCommand(x, createInstance, method, commandService, services)); + + foreach (var method in validInteractions) + builder.AddComponentCommand(x => BuildComponentCommand(x, createInstance, method, commandService, services)); + + foreach(var method in validAutocompleteCommands) + builder.AddAutocompleteCommand(x => BuildAutocompleteCommand(x, createInstance, method, commandService, services)); + } + + private static void BuildSubModules (ModuleBuilder parent, IEnumerable subModules, IList builtTypes, InteractionService commandService, + IServiceProvider services, int slashGroupDepth = 0) + { + foreach (var submodule in subModules.Where(IsValidModuleDefinition)) + { + if (builtTypes.Contains(submodule)) + continue; + + parent.AddModule((builder) => + { + BuildModule(builder, submodule, commandService, services); + + if (slashGroupDepth >= MaxCommandDepth - 1) + throw new InvalidOperationException($"Slash Commands only support {MaxCommandDepth - 1} command prefixes for sub-commands"); + + BuildSubModules(builder, submodule.DeclaredNestedTypes, builtTypes, commandService, services, builder.IsSlashGroup ? slashGroupDepth + 1 : slashGroupDepth); + }); + builtTypes.Add(submodule); + } + } + + private static void BuildSlashCommand (SlashCommandBuilder builder, Func createInstance, MethodInfo methodInfo, + InteractionService commandService, IServiceProvider services) + { + var attributes = methodInfo.GetCustomAttributes(); + + builder.MethodName = methodInfo.Name; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case SlashCommandAttribute command: + { + builder.Name = command.Name; + builder.Description = command.Description; + builder.IgnoreGroupNames = command.IgnoreGroupNames; + builder.RunMode = command.RunMode; + } + break; + case DefaultPermissionAttribute defaultPermission: + { + builder.DefaultPermission = defaultPermission.IsDefaultPermission; + } + break; + case PreconditionAttribute precondition: + builder.WithPreconditions(precondition); + break; + default: + builder.WithAttributes(attribute); + break; + } + } + + var parameters = methodInfo.GetParameters(); + + foreach (var parameter in parameters) + builder.AddParameter(x => BuildSlashParameter(x, parameter, services)); + + builder.Callback = CreateCallback(createInstance, methodInfo, commandService); + } + + private static void BuildContextCommand (ContextCommandBuilder builder, Func createInstance, MethodInfo methodInfo, + InteractionService commandService, IServiceProvider services) + { + var attributes = methodInfo.GetCustomAttributes(); + + builder.MethodName = methodInfo.Name; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case ContextCommandAttribute command: + { + builder.Name = command.Name; + builder.CommandType = command.CommandType; + builder.RunMode = command.RunMode; + + command.CheckMethodDefinition(methodInfo); + } + break; + case DefaultPermissionAttribute defaultPermission: + { + builder.DefaultPermission = defaultPermission.IsDefaultPermission; + } + break; + case PreconditionAttribute precondition: + builder.WithPreconditions(precondition); + break; + default: + builder.WithAttributes(attribute); + break; + } + } + + var parameters = methodInfo.GetParameters(); + + foreach (var parameter in parameters) + builder.AddParameter(x => BuildParameter(x, parameter)); + + builder.Callback = CreateCallback(createInstance, methodInfo, commandService); + } + + private static void BuildComponentCommand (ComponentCommandBuilder builder, Func createInstance, MethodInfo methodInfo, + InteractionService commandService, IServiceProvider services) + { + if (!methodInfo.GetParameters().All(x => x.ParameterType == typeof(string) || x.ParameterType == typeof(string[]))) + throw new InvalidOperationException($"Interaction method parameters all must be types of {typeof(string).Name} or {typeof(string[]).Name}"); + + var attributes = methodInfo.GetCustomAttributes(); + + builder.MethodName = methodInfo.Name; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case ComponentInteractionAttribute interaction: + { + builder.Name = interaction.CustomId; + builder.RunMode = interaction.RunMode; + builder.IgnoreGroupNames = interaction.IgnoreGroupNames; + } + break; + case PreconditionAttribute precondition: + builder.WithPreconditions(precondition); + break; + default: + builder.WithAttributes(attribute); + break; + } + } + + var parameters = methodInfo.GetParameters(); + + foreach (var parameter in parameters) + builder.AddParameter(x => BuildParameter(x, parameter)); + + builder.Callback = CreateCallback(createInstance, methodInfo, commandService); + } + + private static void BuildAutocompleteCommand(AutocompleteCommandBuilder builder, Func createInstance, MethodInfo methodInfo, + InteractionService commandService, IServiceProvider services) + { + var attributes = methodInfo.GetCustomAttributes(); + + builder.MethodName = methodInfo.Name; + + foreach(var attribute in attributes) + { + switch (attribute) + { + case AutocompleteCommandAttribute autocomplete: + { + builder.ParameterName = autocomplete.ParameterName; + builder.CommandName = autocomplete.CommandName; + builder.Name = autocomplete.CommandName + " " + autocomplete.ParameterName; + builder.RunMode = autocomplete.RunMode; + } + break; + case PreconditionAttribute precondition: + builder.WithPreconditions(precondition); + break; + default: + builder.WithAttributes(attribute); + break; + } + } + + var parameters = methodInfo.GetParameters(); + + foreach (var parameter in parameters) + builder.AddParameter(x => BuildParameter(x, parameter)); + + builder.Callback = CreateCallback(createInstance, methodInfo, commandService); + } + + private static ExecuteCallback CreateCallback (Func createInstance, + MethodInfo methodInfo, InteractionService commandService) + { + Func commandInvoker = commandService._useCompiledLambda ? + ReflectionUtils.CreateMethodInvoker(methodInfo) : (module, args) => methodInfo.Invoke(module, args) as Task; + + async Task ExecuteCallback (IInteractionContext context, object[] args, IServiceProvider serviceProvider, ICommandInfo commandInfo) + { + var instance = createInstance(serviceProvider); + instance.SetContext(context); + + try + { + instance.BeforeExecute(commandInfo); + var task = commandInvoker(instance, args) ?? Task.Delay(0); + + if (task is Task runtimeTask) + { + return await runtimeTask.ConfigureAwait(false); + } + else + { + await task.ConfigureAwait(false); + return ExecuteResult.FromSuccess(); + + } + } + catch (Exception ex) + { + await commandService._cmdLogger.ErrorAsync(ex).ConfigureAwait(false); + return ExecuteResult.FromError(ex); + } + finally + { + instance.AfterExecute(commandInfo); + ( instance as IDisposable )?.Dispose(); + } + } + + return ExecuteCallback; + } + + #region Parameters + private static void BuildSlashParameter (SlashCommandParameterBuilder builder, ParameterInfo paramInfo, IServiceProvider services) + { + var attributes = paramInfo.GetCustomAttributes(); + var paramType = paramInfo.ParameterType; + + builder.Name = paramInfo.Name; + builder.Description = paramInfo.Name; + builder.IsRequired = !paramInfo.IsOptional; + builder.DefaultValue = paramInfo.DefaultValue; + builder.SetParameterType(paramType, services); + + foreach (var attribute in attributes) + { + switch (attribute) + { + case SummaryAttribute description: + { + if (!string.IsNullOrEmpty(description.Name)) + builder.Name = description.Name; + + if (!string.IsNullOrEmpty(description.Description)) + builder.Description = description.Description; + } + break; + case ChoiceAttribute choice: + builder.WithChoices(new ParameterChoice(choice.Name, choice.Value)); + break; + case ParamArrayAttribute _: + builder.IsParameterArray = true; + break; + case ParameterPreconditionAttribute precondition: + builder.AddPreconditions(precondition); + break; + case ChannelTypesAttribute channelTypes: + builder.WithChannelTypes(channelTypes.ChannelTypes); + break; + case AutocompleteAttribute autocomplete: + builder.Autocomplete = true; + if(autocomplete.AutocompleteHandlerType is not null) + builder.WithAutocompleteHandler(autocomplete.AutocompleteHandlerType, services); + break; + case MaxValueAttribute maxValue: + builder.MaxValue = maxValue.Value; + break; + case MinValueAttribute minValue: + builder.MinValue = minValue.Value; + break; + default: + builder.AddAttributes(attribute); + break; + } + } + + // Replace pascal casings with '-' + builder.Name = Regex.Replace(builder.Name, "(?<=[a-z])(?=[A-Z])", "-").ToLower(); + } + + private static void BuildParameter (CommandParameterBuilder builder, ParameterInfo paramInfo) + { + var attributes = paramInfo.GetCustomAttributes(); + var paramType = paramInfo.ParameterType; + + builder.Name = paramInfo.Name; + builder.IsRequired = !paramInfo.IsOptional; + builder.DefaultValue = paramInfo.DefaultValue; + builder.SetParameterType(paramType); + + foreach (var attribute in attributes) + { + switch (attribute) + { + case ParameterPreconditionAttribute precondition: + builder.AddPreconditions(precondition); + break; + case ParamArrayAttribute _: + builder.IsParameterArray = true; + break; + default: + builder.AddAttributes(attribute); + break; + } + } + } + #endregion + + internal static bool IsValidModuleDefinition (TypeInfo typeInfo) + { + return ModuleTypeInfo.IsAssignableFrom(typeInfo) && + !typeInfo.IsAbstract && + !typeInfo.ContainsGenericParameters; + } + + private static bool IsValidSlashCommandDefinition (MethodInfo methodInfo) + { + return methodInfo.IsDefined(typeof(SlashCommandAttribute)) && + ( methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task) ) && + !methodInfo.IsStatic && + !methodInfo.IsGenericMethod; + } + + private static bool IsValidContextCommandDefinition (MethodInfo methodInfo) + { + return methodInfo.IsDefined(typeof(ContextCommandAttribute)) && + ( methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task) ) && + !methodInfo.IsStatic && + !methodInfo.IsGenericMethod; + } + + private static bool IsValidComponentCommandDefinition (MethodInfo methodInfo) + { + return methodInfo.IsDefined(typeof(ComponentInteractionAttribute)) && + (methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task)) && + !methodInfo.IsStatic && + !methodInfo.IsGenericMethod; + } + + private static bool IsValidAutocompleteCommandDefinition (MethodInfo methodInfo) + { + return methodInfo.IsDefined(typeof(AutocompleteCommandAttribute)) && + (methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task)) && + !methodInfo.IsStatic && + !methodInfo.IsGenericMethod && + methodInfo.GetParameters().Length == 0; + } + } +} diff --git a/src/Discord.Net.Interactions/Builders/Parameters/CommandParameterBuilder.cs b/src/Discord.Net.Interactions/Builders/Parameters/CommandParameterBuilder.cs new file mode 100644 index 0000000000..0aada98335 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Parameters/CommandParameterBuilder.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public sealed class CommandParameterBuilder : ParameterBuilder + { + protected override CommandParameterBuilder Instance => this; + + internal CommandParameterBuilder (ICommandBuilder command) : base(command) { } + + /// + /// Initializes a new . + /// + /// Parent command of this parameter. + /// Name of this command. + /// Type of this parameter. + public CommandParameterBuilder (ICommandBuilder command, string name, Type type) : base(command, name, type) { } + + internal override CommandParameterInfo Build (ICommandInfo command) => + new CommandParameterInfo(this, command); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Parameters/IParameterBuilder.cs b/src/Discord.Net.Interactions/Builders/Parameters/IParameterBuilder.cs new file mode 100644 index 0000000000..6dd5ad3a0b --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Parameters/IParameterBuilder.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders +{ + /// + /// Represent a command builder for creating . + /// + public interface IParameterBuilder + { + /// + /// Gets the parent command of this parameter. + /// + ICommandBuilder Command { get; } + + /// + /// Gets the name of this parameter. + /// + string Name { get; } + + /// + /// Gets the type of this parameter. + /// + Type ParameterType { get; } + + /// + /// Gets whether this parameter is required. + /// + bool IsRequired { get; } + + /// + /// Gets whether this parameter is . + /// + bool IsParameterArray { get; } + + /// + /// Gets the deafult value of this parameter. + /// + object DefaultValue { get; } + + /// + /// Gets a collection of the attributes of this command. + /// + IReadOnlyCollection Attributes { get; } + + /// + /// Gets a collection of the preconditions of this command. + /// + IReadOnlyCollection Preconditions { get; } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IParameterBuilder WithName (string name); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IParameterBuilder SetParameterType (Type type); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IParameterBuilder SetRequired (bool isRequired); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IParameterBuilder SetDefaultValue (object defaultValue); + + /// + /// Adds attributes to . + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + IParameterBuilder AddAttributes (params Attribute[] attributes); + + /// + /// Adds preconditions to . + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + IParameterBuilder AddPreconditions (params ParameterPreconditionAttribute[] preconditions); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Parameters/ParameterBuilder.cs b/src/Discord.Net.Interactions/Builders/Parameters/ParameterBuilder.cs new file mode 100644 index 0000000000..78d007d449 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Parameters/ParameterBuilder.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents the base builder class for creating . + /// + /// The this builder yields when built. + /// Inherited type. + public abstract class ParameterBuilder : IParameterBuilder + where TInfo : class, IParameterInfo + where TBuilder : ParameterBuilder + { + private readonly List _preconditions; + private readonly List _attributes; + + /// + public ICommandBuilder Command { get; } + + /// + public string Name { get; internal set; } + + /// + public Type ParameterType { get; private set; } + + /// + public bool IsRequired { get; set; } = true; + + /// + public bool IsParameterArray { get; set; } = false; + + /// + public object DefaultValue { get; set; } + + /// + public IReadOnlyCollection Attributes => _attributes; + + /// + public IReadOnlyCollection Preconditions => _preconditions; + protected abstract TBuilder Instance { get; } + + internal ParameterBuilder (ICommandBuilder command) + { + _attributes = new List(); + _preconditions = new List(); + + Command = command; + } + + protected ParameterBuilder (ICommandBuilder command, string name, Type type) : this(command) + { + Name = name; + SetParameterType(type); + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public virtual TBuilder WithName (string name) + { + Name = name; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public virtual TBuilder SetParameterType (Type type) + { + ParameterType = type; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public virtual TBuilder SetRequired (bool isRequired) + { + IsRequired = isRequired; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public virtual TBuilder SetDefaultValue (object defaultValue) + { + DefaultValue = defaultValue; + return Instance; + } + + /// + /// Adds attributes to + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + public virtual TBuilder AddAttributes (params Attribute[] attributes) + { + _attributes.AddRange(attributes); + return Instance; + } + + /// + /// Adds preconditions to + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + public virtual TBuilder AddPreconditions (params ParameterPreconditionAttribute[] attributes) + { + _preconditions.AddRange(attributes); + return Instance; + } + + internal abstract TInfo Build (ICommandInfo command); + + //IParameterBuilder + /// + IParameterBuilder IParameterBuilder.WithName (string name) => + WithName(name); + + /// + IParameterBuilder IParameterBuilder.SetParameterType (Type type) => + SetParameterType(type); + + /// + IParameterBuilder IParameterBuilder.SetRequired (bool isRequired) => + SetRequired(isRequired); + + /// + IParameterBuilder IParameterBuilder.SetDefaultValue (object defaultValue) => + SetDefaultValue(defaultValue); + + /// + IParameterBuilder IParameterBuilder.AddAttributes (params Attribute[] attributes) => + AddAttributes(attributes); + + /// + IParameterBuilder IParameterBuilder.AddPreconditions (params ParameterPreconditionAttribute[] preconditions) => + AddPreconditions(preconditions); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Parameters/SlashCommandParameterBuilder.cs b/src/Discord.Net.Interactions/Builders/Parameters/SlashCommandParameterBuilder.cs new file mode 100644 index 0000000000..c208a4b0e2 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Parameters/SlashCommandParameterBuilder.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public sealed class SlashCommandParameterBuilder : ParameterBuilder + { + private readonly List _choices = new(); + private readonly List _channelTypes = new(); + + /// + /// Gets or sets the description of this parameter. + /// + public string Description { get; set; } + + /// + /// Gets or sets the max value of this parameter. + /// + public double? MaxValue { get; set; } + + /// + /// Gets or sets the min value of this parameter. + /// + public double? MinValue { get; set; } + + /// + /// Gets a collection of the choices of this command. + /// + public IReadOnlyCollection Choices => _choices; + + /// + /// Gets a collection of the channel types of this command. + /// + public IReadOnlyCollection ChannelTypes => _channelTypes; + + /// + /// Gets or sets whether this parameter should be configured for Autocomplete Interactions. + /// + public bool Autocomplete { get; set; } + + /// + /// Gets or sets the of this parameter. + /// + public TypeConverter TypeConverter { get; private set; } + + /// + /// Gets or sets the of this parameter. + /// + public IAutocompleteHandler AutocompleteHandler { get; set; } + protected override SlashCommandParameterBuilder Instance => this; + + internal SlashCommandParameterBuilder(ICommandBuilder command) : base(command) { } + + /// + /// Initializes a new . + /// + /// Parent command of this parameter. + /// Name of this command. + /// Type of this parameter. + public SlashCommandParameterBuilder(ICommandBuilder command, string name, Type type) : base(command, name, type) { } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithDescription(string description) + { + Description = description; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithMinValue(double value) + { + MinValue = value; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithMaxValue(double value) + { + MaxValue = value; + return this; + } + + /// + /// Adds parameter choices to . + /// + /// New choices to be added to . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithChoices(params ParameterChoice[] options) + { + _choices.AddRange(options); + return this; + } + + /// + /// Adds channel types to . + /// + /// New channel types to be added to . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithChannelTypes(params ChannelType[] channelTypes) + { + _channelTypes.AddRange(channelTypes); + return this; + } + + /// + /// Adds channel types to . + /// + /// New channel types to be added to . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithChannelTypes(IEnumerable channelTypes) + { + _channelTypes.AddRange(channelTypes); + return this; + } + + /// + /// Sets . + /// + /// Type of the . + /// Service container to be used to resolve the dependencies of this parameters . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithAutocompleteHandler(Type autocompleteHandlerType, IServiceProvider services = null) + { + AutocompleteHandler = Command.Module.InteractionService.GetAutocompleteHandler(autocompleteHandlerType, services); + return this; + } + + /// + public override SlashCommandParameterBuilder SetParameterType(Type type) => SetParameterType(type, null); + + /// + /// Sets . + /// + /// New value of the . + /// Service container to be used to resolve the dependencies of this parameters . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder SetParameterType(Type type, IServiceProvider services = null) + { + base.SetParameterType(type); + TypeConverter = Command.Module.InteractionService.GetTypeConverter(ParameterType, services); + return this; + } + + internal override SlashCommandParameterInfo Build(ICommandInfo command) => + new SlashCommandParameterInfo(this, command as SlashCommandInfo); + } +} diff --git a/src/Discord.Net.Interactions/Discord.Net.Interactions.csproj b/src/Discord.Net.Interactions/Discord.Net.Interactions.csproj new file mode 100644 index 0000000000..7eeb819ca1 --- /dev/null +++ b/src/Discord.Net.Interactions/Discord.Net.Interactions.csproj @@ -0,0 +1,25 @@ + + + + + net6.0;net5.0;net461;netstandard2.0;netstandard2.1 + net6.0;net5.0;netstandard2.0;netstandard2.1 + Discord.Interactions + Discord.Net.Interactions + Discord.Net.Labs.Interactions + A Discord.Net extension adding support for Application Commands. + + + + + + + + + + + + + + + diff --git a/src/Discord.Net.Interactions/Entities/ParameterChoice.cs b/src/Discord.Net.Interactions/Entities/ParameterChoice.cs new file mode 100644 index 0000000000..30f1078589 --- /dev/null +++ b/src/Discord.Net.Interactions/Entities/ParameterChoice.cs @@ -0,0 +1,24 @@ +namespace Discord.Interactions +{ + /// + /// Represents a Slash Command parameter choice. + /// + public class ParameterChoice + { + /// + /// Gets the name of the choice. + /// + public string Name { get; } + + /// + /// Gets the value of the choice. + /// + public object Value { get; } + + internal ParameterChoice (string name, object value) + { + Name = name; + Value = value; + } + } +} diff --git a/src/Discord.Net.Interactions/Entities/SlashCommandChoiceType.cs b/src/Discord.Net.Interactions/Entities/SlashCommandChoiceType.cs new file mode 100644 index 0000000000..c3497d5c98 --- /dev/null +++ b/src/Discord.Net.Interactions/Entities/SlashCommandChoiceType.cs @@ -0,0 +1,21 @@ +namespace Discord.Interactions +{ + /// + /// Supported types of pre-defined parameter choices. + /// + public enum SlashCommandChoiceType + { + /// + /// Discord type for . + /// + String, + /// + /// Discord type for . + /// + Integer, + /// + /// Discord type for . + /// + Number + } +} diff --git a/src/Discord.Net.Interactions/Extensions/AutocompleteOptionComparer.cs b/src/Discord.Net.Interactions/Extensions/AutocompleteOptionComparer.cs new file mode 100644 index 0000000000..9f16afa310 --- /dev/null +++ b/src/Discord.Net.Interactions/Extensions/AutocompleteOptionComparer.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace Discord.Interactions +{ + internal class AutocompleteOptionComparer : IComparer + { + public int Compare(ApplicationCommandOptionType x, ApplicationCommandOptionType y) + { + if (x == ApplicationCommandOptionType.SubCommandGroup) + { + if (y == ApplicationCommandOptionType.SubCommandGroup) + return 0; + else + return 1; + } + else if (x == ApplicationCommandOptionType.SubCommand) + { + if (y == ApplicationCommandOptionType.SubCommandGroup) + return -1; + else if (y == ApplicationCommandOptionType.SubCommand) + return 0; + else + return 1; + } + else + { + if (y == ApplicationCommandOptionType.SubCommand || y == ApplicationCommandOptionType.SubCommandGroup) + return -1; + else + return 0; + } + } + } +} diff --git a/src/Discord.Net.Interactions/Extensions/WebSocketExtensions.cs b/src/Discord.Net.Interactions/Extensions/WebSocketExtensions.cs new file mode 100644 index 0000000000..388efcbf9c --- /dev/null +++ b/src/Discord.Net.Interactions/Extensions/WebSocketExtensions.cs @@ -0,0 +1,53 @@ +using Discord.Rest; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.WebSocket +{ + internal static class WebSocketExtensions + { + /// + /// Get the name of the executed command and its parents in hierarchical order. + /// + /// + /// + /// The name of the executed command and its parents in hierarchical order. + /// + public static IList GetCommandKeywords(this IApplicationCommandInteractionData data) + { + var keywords = new List { data.Name }; + + var child = data.Options?.ElementAtOrDefault(0); + + while (child?.Type == ApplicationCommandOptionType.SubCommandGroup || child?.Type == ApplicationCommandOptionType.SubCommand) + { + keywords.Add(child.Name); + child = child.Options?.ElementAtOrDefault(0); + } + + return keywords; + } + + /// + /// Get the name of the executed command and its parents in hierarchical order. + /// + /// + /// + /// The name of the executed command and its parents in hierarchical order. + /// + public static IList GetCommandKeywords(this IAutocompleteInteractionData data) + { + var keywords = new List { data.CommandName }; + + var group = data.Options?.FirstOrDefault(x => x.Type == ApplicationCommandOptionType.SubCommandGroup); + if (group is not null) + keywords.Add(group.Name); + + var subcommand = data.Options?.FirstOrDefault(x => x.Type == ApplicationCommandOptionType.SubCommand); + if (subcommand is not null) + keywords.Add(subcommand.Name); + + return keywords; + } + } +} diff --git a/src/Discord.Net.Interactions/IInteractionModuleBase.cs b/src/Discord.Net.Interactions/IInteractionModuleBase.cs new file mode 100644 index 0000000000..e840e6a0f9 --- /dev/null +++ b/src/Discord.Net.Interactions/IInteractionModuleBase.cs @@ -0,0 +1,33 @@ +namespace Discord.Interactions +{ + /// + /// Represents a generic interaction module base. + /// + public interface IInteractionModuleBase + { + /// + /// Sets the context of this module. + /// + /// + void SetContext (IInteractionContext context); + + /// + /// Method body to be executed before executing an application command. + /// + /// Command information related to the Discord Application Command. + void BeforeExecute (ICommandInfo command); + + /// + /// Method body to be executed after an application command execution. + /// + /// Command information related to the Discord Application Command. + void AfterExecute (ICommandInfo command); + + /// + /// Method body to be executed before the derived module is built. + /// + /// Command Service instance that built this module. + /// Info class of this module. + void OnModuleBuilding (InteractionService commandService, ModuleInfo module); + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/AutocompleteCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/AutocompleteCommandInfo.cs new file mode 100644 index 0000000000..712b058a3f --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/AutocompleteCommandInfo.cs @@ -0,0 +1,89 @@ +using Discord.Interactions.Builders; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents the info class of an attribute based method for handling Autocomplete Interaction events. + /// + public sealed class AutocompleteCommandInfo : CommandInfo + { + /// + /// Gets the name of the target parameter. + /// + public string ParameterName { get; } + + /// + /// Gets the name of the target command. + /// + public string CommandName { get; } + + /// + public override IReadOnlyCollection Parameters { get; } + + /// + public override bool SupportsWildCards => false; + + internal AutocompleteCommandInfo(AutocompleteCommandBuilder builder, ModuleInfo module, InteractionService commandService) : base(builder, module, commandService) + { + Parameters = builder.Parameters.Select(x => x.Build(this)).ToImmutableArray(); + ParameterName = builder.ParameterName; + CommandName = builder.CommandName; + } + + /// + public override async Task ExecuteAsync(IInteractionContext context, IServiceProvider services) + { + if (context.Interaction is not IAutocompleteInteraction) + return ExecuteResult.FromError(InteractionCommandError.ParseFailed, $"Provided {nameof(IInteractionContext)} doesn't belong to a Autocomplete Interaction"); + + try + { + return await RunAsync(context, Array.Empty(), services).ConfigureAwait(false); + } + catch (Exception ex) + { + return ExecuteResult.FromError(ex); + } + } + + /// + protected override Task InvokeModuleEvent(IInteractionContext context, IResult result) => + CommandService._autocompleteCommandExecutedEvent.InvokeAsync(this, context, result); + + /// + protected override string GetLogString(IInteractionContext context) + { + if (context.Guild != null) + return $"Autocomplete Command: \"{base.ToString()}\" for {context.User} in {context.Guild}/{context.Channel}"; + else + return $"Autocomplete Command: \"{base.ToString()}\" for {context.User} in {context.Channel}"; + } + + internal IList GetCommandKeywords() + { + var keywords = new List() { ParameterName, CommandName }; + + if(!IgnoreGroupNames) + { + var currentParent = Module; + + while (currentParent != null) + { + if (!string.IsNullOrEmpty(currentParent.SlashGroupName)) + keywords.Add(currentParent.SlashGroupName); + + currentParent = currentParent.Parent; + } + } + + keywords.Reverse(); + + return keywords; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/CommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/CommandInfo.cs new file mode 100644 index 0000000000..f9fdb6e382 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/CommandInfo.cs @@ -0,0 +1,247 @@ +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents a cached method execution delegate. + /// + /// Execution context that will be injected into the module class. + /// Method arguments array. + /// Service collection for initializing the module. + /// Command info class of the executed method. + /// + /// A task representing the execution operation. + /// + public delegate Task ExecuteCallback (IInteractionContext context, object[] args, IServiceProvider serviceProvider, ICommandInfo commandInfo); + + /// + /// The base information class for commands. + /// + /// The type of that is used by this command type. + public abstract class CommandInfo : ICommandInfo where TParameter : class, IParameterInfo + { + private readonly ExecuteCallback _action; + private readonly ILookup _groupedPreconditions; + + /// + public ModuleInfo Module { get; } + + /// + public InteractionService CommandService { get; } + + /// + public string Name { get; } + + /// + public string MethodName { get; } + + /// + public virtual bool IgnoreGroupNames { get; } + + /// + public abstract bool SupportsWildCards { get; } + + /// + public bool IsTopLevelCommand => IgnoreGroupNames || !Module.IsTopLevelGroup; + + /// + public RunMode RunMode { get; } + + /// + public IReadOnlyCollection Attributes { get; } + + /// + public IReadOnlyCollection Preconditions { get; } + + /// + public abstract IReadOnlyCollection Parameters { get; } + + internal CommandInfo(Builders.ICommandBuilder builder, ModuleInfo module, InteractionService commandService) + { + CommandService = commandService; + Module = module; + + Name = builder.Name; + MethodName = builder.MethodName; + IgnoreGroupNames = builder.IgnoreGroupNames; + RunMode = builder.RunMode != RunMode.Default ? builder.RunMode : commandService._runMode; + Attributes = builder.Attributes.ToImmutableArray(); + Preconditions = builder.Preconditions.ToImmutableArray(); + + _action = builder.Callback; + _groupedPreconditions = builder.Preconditions.ToLookup(x => x.Group, x => x, StringComparer.Ordinal); + } + + /// + public abstract Task ExecuteAsync(IInteractionContext context, IServiceProvider services); + protected abstract Task InvokeModuleEvent(IInteractionContext context, IResult result); + protected abstract string GetLogString(IInteractionContext context); + + /// + public async Task CheckPreconditionsAsync(IInteractionContext context, IServiceProvider services) + { + async Task CheckGroups(ILookup preconditions, string type) + { + foreach (IGrouping preconditionGroup in preconditions) + { + if (preconditionGroup.Key == null) + { + foreach (PreconditionAttribute precondition in preconditionGroup) + { + var result = await precondition.CheckRequirementsAsync(context, this, services).ConfigureAwait(false); + if (!result.IsSuccess) + return result; + } + } + else + { + var results = new List(); + foreach (PreconditionAttribute precondition in preconditionGroup) + results.Add(await precondition.CheckRequirementsAsync(context, this, services).ConfigureAwait(false)); + + if (!results.Any(p => p.IsSuccess)) + return PreconditionGroupResult.FromError($"{type} precondition group {preconditionGroup.Key} failed.", results); + } + } + return PreconditionGroupResult.FromSuccess(); + } + + var moduleResult = await CheckGroups(Module.GroupedPreconditions, "Module").ConfigureAwait(false); + if (!moduleResult.IsSuccess) + return moduleResult; + + var commandResult = await CheckGroups(_groupedPreconditions, "Command").ConfigureAwait(false); + if (!commandResult.IsSuccess) + return commandResult; + + return PreconditionResult.FromSuccess(); + } + + protected async Task RunAsync(IInteractionContext context, object[] args, IServiceProvider services) + { + switch (RunMode) + { + case RunMode.Sync: + { + using var scope = services?.CreateScope(); + return await ExecuteInternalAsync(context, args, scope?.ServiceProvider ?? EmptyServiceProvider.Instance).ConfigureAwait(false); + } + case RunMode.Async: + _ = Task.Run(async () => + { + using var scope = services?.CreateScope(); + await ExecuteInternalAsync(context, args, scope?.ServiceProvider ?? EmptyServiceProvider.Instance).ConfigureAwait(false); + }); + break; + default: + throw new InvalidOperationException($"RunMode {RunMode} is not supported."); + } + + return ExecuteResult.FromSuccess(); + } + + private async Task ExecuteInternalAsync(IInteractionContext context, object[] args, IServiceProvider services) + { + await CommandService._cmdLogger.DebugAsync($"Executing {GetLogString(context)}").ConfigureAwait(false); + + try + { + var preconditionResult = await CheckPreconditionsAsync(context, services).ConfigureAwait(false); + if (!preconditionResult.IsSuccess) + { + await InvokeModuleEvent(context, preconditionResult).ConfigureAwait(false); + return preconditionResult; + } + + var index = 0; + foreach (var parameter in Parameters) + { + var result = await parameter.CheckPreconditionsAsync(context, args[index++], services).ConfigureAwait(false); + if (!result.IsSuccess) + { + await InvokeModuleEvent(context, result).ConfigureAwait(false); + return result; + } + } + + var task = _action(context, args, services, this); + + if (task is Task resultTask) + { + var result = await resultTask.ConfigureAwait(false); + await InvokeModuleEvent(context, result).ConfigureAwait(false); + if (result is RuntimeResult || result is ExecuteResult) + return result; + } + else + { + await task.ConfigureAwait(false); + var result = ExecuteResult.FromSuccess(); + await InvokeModuleEvent(context, result).ConfigureAwait(false); + return result; + } + + var failResult = ExecuteResult.FromError(InteractionCommandError.Unsuccessful, "Command execution failed for an unknown reason"); + await InvokeModuleEvent(context, failResult).ConfigureAwait(false); + return failResult; + } + catch (Exception ex) + { + var originalEx = ex; + while (ex is TargetInvocationException) + ex = ex.InnerException; + + await Module.CommandService._cmdLogger.ErrorAsync(ex).ConfigureAwait(false); + + var result = ExecuteResult.FromError(ex); + await InvokeModuleEvent(context, result).ConfigureAwait(false); + + if (Module.CommandService._throwOnError) + { + if (ex == originalEx) + throw; + else + ExceptionDispatchInfo.Capture(ex).Throw(); + } + + return result; + } + finally + { + await CommandService._cmdLogger.VerboseAsync($"Executed {GetLogString(context)}").ConfigureAwait(false); + } + } + + // ICommandInfo + + /// + IReadOnlyCollection ICommandInfo.Parameters => Parameters; + + /// + public override string ToString() + { + StringBuilder builder = new(); + + var currentParent = Module; + + while (currentParent != null) + { + if (currentParent.IsSlashGroup) + builder.AppendFormat(" {0}", currentParent.SlashGroupName); + + currentParent = currentParent.Parent; + } + builder.AppendFormat(" {0}", Name); + + return builder.ToString(); + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs new file mode 100644 index 0000000000..62195bf0c0 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs @@ -0,0 +1,132 @@ +using Discord.Interactions.Builders; +using Discord.WebSocket; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents the info class of an attribute based method for handling Component Interaction events. + /// + public class ComponentCommandInfo : CommandInfo + { + /// + public override IReadOnlyCollection Parameters { get; } + + /// + public override bool SupportsWildCards => true; + + internal ComponentCommandInfo(ComponentCommandBuilder builder, ModuleInfo module, InteractionService commandService) : base(builder, module, commandService) + { + Parameters = builder.Parameters.Select(x => x.Build(this)).ToImmutableArray(); + } + + /// + public override async Task ExecuteAsync(IInteractionContext context, IServiceProvider services) + => await ExecuteAsync(context, services, null).ConfigureAwait(false); + + /// + /// Execute this command using dependency injection. + /// + /// Context that will be injected to the . + /// Services that will be used while initializing the . + /// Provide additional string parameters to the method along with the auto generated parameters. + /// + /// A task representing the asyncronous command execution process. + /// + public async Task ExecuteAsync(IInteractionContext context, IServiceProvider services, params string[] additionalArgs) + { + if (context.Interaction is not IComponentInteraction componentInteraction) + return ExecuteResult.FromError(InteractionCommandError.ParseFailed, $"Provided {nameof(IInteractionContext)} doesn't belong to a Message Component Interaction"); + + var args = new List(); + + if (additionalArgs is not null) + args.AddRange(additionalArgs); + + if (componentInteraction.Data?.Values is not null) + args.AddRange(componentInteraction.Data.Values); + + return await ExecuteAsync(context, Parameters, args, services); + } + + /// + public async Task ExecuteAsync(IInteractionContext context, IEnumerable paramList, IEnumerable values, + IServiceProvider services) + { + if (context.Interaction is not SocketMessageComponent messageComponent) + return ExecuteResult.FromError(InteractionCommandError.ParseFailed, $"Provided {nameof(IInteractionContext)} doesn't belong to a Component Command Interaction"); + + try + { + var strCount = Parameters.Count(x => x.ParameterType == typeof(string)); + + if (strCount > values?.Count()) + return ExecuteResult.FromError(InteractionCommandError.BadArgs, "Command was invoked with too few parameters"); + + var componentValues = messageComponent.Data?.Values; + + var args = new object[Parameters.Count]; + + if (componentValues is not null) + { + if (Parameters.Last().ParameterType == typeof(string[])) + args[args.Length - 1] = componentValues.ToArray(); + else + return ExecuteResult.FromError(InteractionCommandError.BadArgs, $"Select Menu Interaction handlers must accept a {typeof(string[]).FullName} as its last parameter"); + } + + for (var i = 0; i < strCount; i++) + args[i] = values.ElementAt(i); + + return await RunAsync(context, args, services).ConfigureAwait(false); + } + catch (Exception ex) + { + return ExecuteResult.FromError(ex); + } + } + + private static object[] GenerateArgs(IEnumerable paramList, IEnumerable argList) + { + var result = new object[paramList.Count()]; + + for (var i = 0; i < paramList.Count(); i++) + { + var parameter = paramList.ElementAt(i); + + if (argList?.ElementAt(i) == null) + { + if (!parameter.IsRequired) + result[i] = parameter.DefaultValue; + else + throw new InvalidOperationException($"Component Interaction handler is executed with too few args."); + } + else if (parameter.IsParameterArray) + { + string[] paramArray = new string[argList.Count() - i]; + argList.ToArray().CopyTo(paramArray, i); + result[i] = paramArray; + } + else + result[i] = argList?.ElementAt(i); + } + + return result; + } + + protected override Task InvokeModuleEvent(IInteractionContext context, IResult result) + => CommandService._componentCommandExecutedEvent.InvokeAsync(this, context, result); + + protected override string GetLogString(IInteractionContext context) + { + if (context.Guild != null) + return $"Component Interaction: \"{base.ToString()}\" for {context.User} in {context.Guild}/{context.Channel}"; + else + return $"Component Interaction: \"{base.ToString()}\" for {context.User} in {context.Channel}"; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/ContextCommands/ContextCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/ContextCommands/ContextCommandInfo.cs new file mode 100644 index 0000000000..4c2e7af7df --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/ContextCommands/ContextCommandInfo.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Base information class for attribute based context command handlers. + /// + public abstract class ContextCommandInfo : CommandInfo, IApplicationCommandInfo + { + /// + public ApplicationCommandType CommandType { get; } + + /// + public bool DefaultPermission { get; } + + /// + public override IReadOnlyCollection Parameters { get; } + + /// + public override bool SupportsWildCards => false; + + /// + public override bool IgnoreGroupNames => true; + + internal ContextCommandInfo (Builders.ContextCommandBuilder builder, ModuleInfo module, InteractionService commandService) + : base(builder, module, commandService) + { + CommandType = builder.CommandType; + DefaultPermission = builder.DefaultPermission; + Parameters = builder.Parameters.Select(x => x.Build(this)).ToImmutableArray(); + } + + internal static ContextCommandInfo Create (Builders.ContextCommandBuilder builder, ModuleInfo module, InteractionService commandService) + { + return builder.CommandType switch + { + ApplicationCommandType.User => new UserCommandInfo(builder, module, commandService), + ApplicationCommandType.Message => new MessageCommandInfo(builder, module, commandService), + _ => throw new InvalidOperationException("This command type is not a supported Context Command"), + }; + } + + /// + protected override Task InvokeModuleEvent (IInteractionContext context, IResult result) + => CommandService._contextCommandExecutedEvent.InvokeAsync(this, context, result); + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/ContextCommands/MessageCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/ContextCommands/MessageCommandInfo.cs new file mode 100644 index 0000000000..e05955df88 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/ContextCommands/MessageCommandInfo.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents the info class of an attribute based method for command type . + /// + public class MessageCommandInfo : ContextCommandInfo + { + internal MessageCommandInfo(Builders.ContextCommandBuilder builder, ModuleInfo module, InteractionService commandService) + : base(builder, module, commandService) { } + + /// + public override async Task ExecuteAsync(IInteractionContext context, IServiceProvider services) + { + if (context.Interaction is not IMessageCommandInteraction messageCommand) + return ExecuteResult.FromError(InteractionCommandError.ParseFailed, $"Provided {nameof(IInteractionContext)} doesn't belong to a Message Command Interation"); + + try + { + object[] args = new object[1] { messageCommand.Data.Message }; + + return await RunAsync(context, args, services).ConfigureAwait(false); + } + catch (Exception ex) + { + return ExecuteResult.FromError(ex); + } + } + + /// + protected override string GetLogString(IInteractionContext context) + { + if (context.Guild != null) + return $"Message Command: \"{base.ToString()}\" for {context.User} in {context.Guild}/{context.Channel}"; + else + return $"Message Command: \"{base.ToString()}\" for {context.User} in {context.Channel}"; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/ContextCommands/UserCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/ContextCommands/UserCommandInfo.cs new file mode 100644 index 0000000000..8862e1798b --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/ContextCommands/UserCommandInfo.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents the info class of an attribute based method for command type . + /// + public class UserCommandInfo : ContextCommandInfo + { + internal UserCommandInfo(Builders.ContextCommandBuilder builder, ModuleInfo module, InteractionService commandService) + : base(builder, module, commandService) { } + + /// + public override async Task ExecuteAsync(IInteractionContext context, IServiceProvider services) + { + if (context.Interaction is not IUserCommandInteraction userCommand) + return ExecuteResult.FromError(InteractionCommandError.ParseFailed, $"Provided {nameof(IInteractionContext)} doesn't belong to a Message Command Interation"); + + try + { + object[] args = new object[1] { userCommand.Data.User }; + + return await RunAsync(context, args, services).ConfigureAwait(false); + } + catch (Exception ex) + { + return ExecuteResult.FromError(ex); + } + } + + /// + protected override string GetLogString(IInteractionContext context) + { + if (context.Guild != null) + return $"User Command: \"{base.ToString()}\" for {context.User} in {context.Guild}/{context.Channel}"; + else + return $"User Command: \"{base.ToString()}\" for {context.User} in {context.Channel}"; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/SlashCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/SlashCommandInfo.cs new file mode 100644 index 0000000000..116a07ab44 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/SlashCommandInfo.cs @@ -0,0 +1,112 @@ +using Discord.Rest; +using Discord.WebSocket; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents the info class of an attribute based method for command type . + /// + public class SlashCommandInfo : CommandInfo, IApplicationCommandInfo + { + /// + /// Gets the command description that will be displayed on Discord. + /// + public string Description { get; } + + /// + public ApplicationCommandType CommandType { get; } = ApplicationCommandType.Slash; + + /// + public bool DefaultPermission { get; } + + /// + public override IReadOnlyCollection Parameters { get; } + + /// + public override bool SupportsWildCards => false; + + internal SlashCommandInfo (Builders.SlashCommandBuilder builder, ModuleInfo module, InteractionService commandService) : base(builder, module, commandService) + { + Description = builder.Description; + DefaultPermission = builder.DefaultPermission; + Parameters = builder.Parameters.Select(x => x.Build(this)).ToImmutableArray(); + } + + /// + public override async Task ExecuteAsync (IInteractionContext context, IServiceProvider services) + { + if(context.Interaction is not ISlashCommandInteraction slashCommand) + return ExecuteResult.FromError(InteractionCommandError.ParseFailed, $"Provided {nameof(IInteractionContext)} doesn't belong to a Slash Command Interaction"); + + var options = slashCommand.Data.Options; + + while (options != null && options.Any(x => x.Type == ApplicationCommandOptionType.SubCommand || x.Type == ApplicationCommandOptionType.SubCommandGroup)) + options = options.ElementAt(0)?.Options; + + return await ExecuteAsync(context, Parameters, options?.ToList(), services); + } + + private async Task ExecuteAsync (IInteractionContext context, IEnumerable paramList, + List argList, IServiceProvider services) + { + try + { + if (paramList?.Count() < argList?.Count()) + return ExecuteResult.FromError(InteractionCommandError.BadArgs ,"Command was invoked with too many parameters"); + + var args = new object[paramList.Count()]; + + for (var i = 0; i < paramList.Count(); i++) + { + var parameter = paramList.ElementAt(i); + + var arg = argList?.Find(x => string.Equals(x.Name, parameter.Name, StringComparison.OrdinalIgnoreCase)); + + if (arg == default) + { + if (parameter.IsRequired) + return ExecuteResult.FromError(InteractionCommandError.BadArgs, "Command was invoked with too few parameters"); + else + args[i] = parameter.DefaultValue; + } + else + { + var typeConverter = parameter.TypeConverter; + + var readResult = await typeConverter.ReadAsync(context, arg, services).ConfigureAwait(false); + + if (!readResult.IsSuccess) + { + await InvokeModuleEvent(context, readResult).ConfigureAwait(false); + return readResult; + } + + args[i] = readResult.Value; + } + } + + return await RunAsync(context, args, services).ConfigureAwait(false); + } + catch (Exception ex) + { + return ExecuteResult.FromError(ex); + } + } + + protected override Task InvokeModuleEvent (IInteractionContext context, IResult result) + => CommandService._slashCommandExecutedEvent.InvokeAsync(this, context, result); + + protected override string GetLogString (IInteractionContext context) + { + if (context.Guild != null) + return $"Slash Command: \"{base.ToString()}\" for {context.User} in {context.Guild}/{context.Channel}"; + else + return $"Slash Command: \"{base.ToString()}\" for {context.User} in {context.Channel}"; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/IApplicationCommandInfo.cs b/src/Discord.Net.Interactions/Info/IApplicationCommandInfo.cs new file mode 100644 index 0000000000..1e0d532b02 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/IApplicationCommandInfo.cs @@ -0,0 +1,23 @@ +namespace Discord.Interactions +{ + /// + /// Represents a command that can be registered to Discord. + /// + public interface IApplicationCommandInfo + { + /// + /// Gets the name of this command. + /// + string Name { get; } + + /// + /// Gets the type of this command. + /// + ApplicationCommandType CommandType { get; } + + /// + /// Gets the DefaultPermission of this command. + /// + bool DefaultPermission { get; } + } +} diff --git a/src/Discord.Net.Interactions/Info/ICommandInfo.cs b/src/Discord.Net.Interactions/Info/ICommandInfo.cs new file mode 100644 index 0000000000..843d5198b5 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/ICommandInfo.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represent a command information object that can be executed. + /// + public interface ICommandInfo + { + /// + /// Gets the name of the command. + /// + string Name { get; } + + /// + /// Gets the name of the command handler method. + /// + string MethodName { get; } + + /// + /// Gets if this command will be registered and executed as a standalone command, unaffected by the s of + /// of the commands parents. + /// + bool IgnoreGroupNames { get; } + + /// + /// Gets wheter this command supports wild card patterns. + /// + bool SupportsWildCards { get; } + + /// + /// Gets if this command is a top level command and none of its parents have a . + /// + bool IsTopLevelCommand { get; } + + /// + /// Gets the module that the method belongs to. + /// + ModuleInfo Module { get; } + + /// + /// Gets the the underlying command service. + /// + InteractionService CommandService { get; } + + /// + /// Get the run mode this command gets executed with. + /// + RunMode RunMode { get; } + + /// + /// Gets a collection of the attributes of this command. + /// + IReadOnlyCollection Attributes { get; } + + /// + /// Gets a collection of the preconditions of this command. + /// + IReadOnlyCollection Preconditions { get; } + + /// + /// Gets a collection of the parameters of this command. + /// + IReadOnlyCollection Parameters { get; } + + /// + /// Executes the command with the provided context. + /// + /// The execution context. + /// Dependencies that will be used to create the module instance. + /// + /// A task representing the execution process. The task result contains the execution result. + /// + Task ExecuteAsync (IInteractionContext context, IServiceProvider services); + + /// + /// Check if an execution context meets the command precondition requirements. + /// + Task CheckPreconditionsAsync (IInteractionContext context, IServiceProvider services); + } +} diff --git a/src/Discord.Net.Interactions/Info/IParameterInfo.cs b/src/Discord.Net.Interactions/Info/IParameterInfo.cs new file mode 100644 index 0000000000..8244476e14 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/IParameterInfo.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents a parameter. + /// + public interface IParameterInfo + { + /// + /// Gets the command that this parameter belongs to. + /// + ICommandInfo Command { get; } + + /// + /// Gets the name of this parameter. + /// + string Name { get; } + + /// + /// Gets the type of this parameter. + /// + Type ParameterType { get; } + + /// + /// Gets whether this parameter is required. + /// + bool IsRequired { get; } + + /// + /// Gets whether this parameter is marked with a keyword. + /// + bool IsParameterArray { get; } + + /// + /// Gets the default value of this parameter if the parameter is optional. + /// + object DefaultValue { get; } + + /// + /// Gets a list of the attributes this parameter has. + /// + IReadOnlyCollection Attributes { get; } + + /// + /// Gets a list of the preconditions this parameter has. + /// + IReadOnlyCollection Preconditions { get; } + + /// + /// Check if an execution context meets the parameter precondition requirements. + /// + Task CheckPreconditionsAsync (IInteractionContext context, object value, IServiceProvider services); + } +} diff --git a/src/Discord.Net.Interactions/Info/ModuleInfo.cs b/src/Discord.Net.Interactions/Info/ModuleInfo.cs new file mode 100644 index 0000000000..8bf367df8d --- /dev/null +++ b/src/Discord.Net.Interactions/Info/ModuleInfo.cs @@ -0,0 +1,217 @@ +using Discord.Interactions.Builders; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Interactions +{ + /// + /// Contains the information of a Interactions Module. + /// + public class ModuleInfo + { + internal ILookup GroupedPreconditions { get; } + + /// + /// Gets the underlying command service. + /// + public InteractionService CommandService { get; } + + /// + /// Gets the name of this module class. + /// + public string Name { get; } + + /// + /// Gets the group name of this module, if the module is marked with a . + /// + public string SlashGroupName { get; } + + /// + /// Gets if this module is marked with a . + /// + public bool IsSlashGroup => !string.IsNullOrEmpty(SlashGroupName); + + /// + /// Gets the description of this module if is . + /// + public string Description { get; } + + /// + /// Gets the default Permission of this module. + /// + public bool DefaultPermission { get; } + + /// + /// Gets the collection of Sub Modules of this module. + /// + public IReadOnlyList SubModules { get; } + + /// + /// Gets the Slash Commands that are declared in this module. + /// + public IReadOnlyList SlashCommands { get; } + + /// + /// Gets the Context Commands that are declared in this module. + /// + public IReadOnlyList ContextCommands { get; } + + /// + /// Gets the Component Commands that are declared in this module. + /// + public IReadOnlyCollection ComponentCommands { get; } + + /// + /// Gets the Autocomplete Commands that are declared in this module. + /// + public IReadOnlyCollection AutocompleteCommands { get; } + + /// + /// Gets the declaring type of this module, if is . + /// + public ModuleInfo Parent { get; } + + /// + /// Gets if this module is declared by another . + /// + public bool IsSubModule => Parent != null; + + /// + /// Gets a collection of the attributes of this module. + /// + public IReadOnlyCollection Attributes { get; } + + /// + /// Gets a collection of the preconditions of this module. + /// + public IReadOnlyCollection Preconditions { get; } + + /// + /// Gets if this module has a valid and has no parent with a . + /// + public bool IsTopLevelGroup { get; } + + /// + /// Gets if this module will not be registered by + /// or methods. + /// + public bool DontAutoRegister { get; } + + internal ModuleInfo (ModuleBuilder builder, InteractionService commandService, IServiceProvider services, ModuleInfo parent = null) + { + CommandService = commandService; + + Name = builder.Name; + SlashGroupName = builder.SlashGroupName; + Description = builder.Description; + Parent = parent; + DefaultPermission = builder.DefaultPermission; + SlashCommands = BuildSlashCommands(builder).ToImmutableArray(); + ContextCommands = BuildContextCommands(builder).ToImmutableArray(); + ComponentCommands = BuildComponentCommands(builder).ToImmutableArray(); + AutocompleteCommands = BuildAutocompleteCommands(builder).ToImmutableArray(); + SubModules = BuildSubModules(builder, commandService, services).ToImmutableArray(); + Attributes = BuildAttributes(builder).ToImmutableArray(); + Preconditions = BuildPreconditions(builder).ToImmutableArray(); + IsTopLevelGroup = CheckTopLevel(parent); + DontAutoRegister = builder.DontAutoRegister; + + GroupedPreconditions = Preconditions.ToLookup(x => x.Group, x => x, StringComparer.Ordinal); + } + + private IEnumerable BuildSubModules (ModuleBuilder builder, InteractionService commandService, IServiceProvider services) + { + var result = new List(); + + foreach (Builders.ModuleBuilder moduleBuilder in builder.SubModules) + result.Add(moduleBuilder.Build(commandService, services, this)); + + return result; + } + + private IEnumerable BuildSlashCommands (ModuleBuilder builder) + { + var result = new List(); + + foreach (Builders.SlashCommandBuilder commandBuilder in builder.SlashCommands) + result.Add(commandBuilder.Build(this, CommandService)); + + return result; + } + + private IEnumerable BuildContextCommands (ModuleBuilder builder) + { + var result = new List(); + + foreach (Builders.ContextCommandBuilder commandBuilder in builder.ContextCommands) + result.Add(commandBuilder.Build(this, CommandService)); + + return result; + } + + private IEnumerable BuildComponentCommands (ModuleBuilder builder) + { + var result = new List(); + + foreach (var interactionBuilder in builder.ComponentCommands) + result.Add(interactionBuilder.Build(this, CommandService)); + + return result; + } + + private IEnumerable BuildAutocompleteCommands( ModuleBuilder builder) + { + var result = new List(); + + foreach (var commandBuilder in builder.AutocompleteCommands) + result.Add(commandBuilder.Build(this, CommandService)); + + return result; + } + + private IEnumerable BuildAttributes (ModuleBuilder builder) + { + var result = new List(); + var currentParent = builder; + + while (currentParent != null) + { + result.AddRange(currentParent.Attributes); + currentParent = currentParent.Parent; + } + + return result; + } + + private static IEnumerable BuildPreconditions (ModuleBuilder builder) + { + var preconditions = new List(); + + var parent = builder; + + while (parent != null) + { + preconditions.AddRange(parent.Preconditions); + parent = parent.Parent; + } + + return preconditions; + } + + private static bool CheckTopLevel (ModuleInfo parent) + { + var currentParent = parent; + + while (currentParent != null) + { + if (currentParent.IsTopLevelGroup) + return false; + + currentParent = currentParent.Parent; + } + return true; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Parameters/CommandParameterInfo.cs b/src/Discord.Net.Interactions/Info/Parameters/CommandParameterInfo.cs new file mode 100644 index 0000000000..050cf42301 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Parameters/CommandParameterInfo.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents the base parameter info class for commands. + /// + public class CommandParameterInfo : IParameterInfo + { + /// + public ICommandInfo Command { get; } + + /// + public string Name { get; } + + /// + public Type ParameterType { get; } + + /// + public bool IsRequired { get; } + + /// + public bool IsParameterArray { get; } + + /// + public object DefaultValue { get; } + + /// + public IReadOnlyCollection Attributes { get; } + + /// + public IReadOnlyCollection Preconditions { get; } + + internal CommandParameterInfo (Builders.IParameterBuilder builder, ICommandInfo command) + { + Command = command; + Name = builder.Name; + ParameterType = builder.ParameterType; + IsRequired = builder.IsRequired; + IsParameterArray = builder.IsParameterArray; + DefaultValue = builder.DefaultValue; + Attributes = builder.Attributes.ToImmutableArray(); + Preconditions = builder.Preconditions.ToImmutableArray(); + } + + /// + public async Task CheckPreconditionsAsync (IInteractionContext context, object value, IServiceProvider services) + { + foreach (var precondition in Preconditions) + { + var result = await precondition.CheckRequirementsAsync(context, this, value, services).ConfigureAwait(false); + if (!result.IsSuccess) + return result; + } + + return PreconditionResult.FromSuccess(); + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Parameters/SlashCommandParameterInfo.cs b/src/Discord.Net.Interactions/Info/Parameters/SlashCommandParameterInfo.cs new file mode 100644 index 0000000000..68b63c8065 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Parameters/SlashCommandParameterInfo.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Interactions +{ + /// + /// Represents the parameter info class for commands. + /// + public class SlashCommandParameterInfo : CommandParameterInfo + { + /// + public new SlashCommandInfo Command => base.Command as SlashCommandInfo; + + /// + /// Gets the description of the Slash Command Parameter. + /// + public string Description { get; } + + /// + /// Gets the minimum value permitted for a number type parameter. + /// + public double? MinValue { get; } + + /// + /// Gets the maxmimum value permitted for a number type parameter. + /// + public double? MaxValue { get; } + + /// + /// Gets the that will be used to convert the incoming into + /// . + /// + public TypeConverter TypeConverter { get; } + + /// + /// Gets the thats linked to this parameter. + /// + public IAutocompleteHandler AutocompleteHandler { get; } + + /// + /// Gets whether this parameter is configured for Autocomplete Interactions. + /// + public bool IsAutocomplete { get; } + + /// + /// Gets the Discord option type this parameter represents. + /// + public ApplicationCommandOptionType DiscordOptionType => TypeConverter.GetDiscordType(); + + /// + /// Gets the parameter choices of this Slash Application Command parameter. + /// + public IReadOnlyCollection Choices { get; } + + /// + /// Gets the allowed channel types for this option. + /// + public IReadOnlyCollection ChannelTypes { get; } + + internal SlashCommandParameterInfo(Builders.SlashCommandParameterBuilder builder, SlashCommandInfo command) : base(builder, command) + { + TypeConverter = builder.TypeConverter; + AutocompleteHandler = builder.AutocompleteHandler; + Description = builder.Description; + MaxValue = builder.MaxValue; + MinValue = builder.MinValue; + IsAutocomplete = builder.Autocomplete; + Choices = builder.Choices.ToImmutableArray(); + ChannelTypes = builder.ChannelTypes.ToImmutableArray(); + } + } +} diff --git a/src/Discord.Net.Interactions/InteractionCommandError.cs b/src/Discord.Net.Interactions/InteractionCommandError.cs new file mode 100644 index 0000000000..7000e9ab38 --- /dev/null +++ b/src/Discord.Net.Interactions/InteractionCommandError.cs @@ -0,0 +1,43 @@ +namespace Discord.Interactions +{ + /// + /// Defines the type of error a command can throw. + /// + public enum InteractionCommandError + { + /// + /// Thrown when the command is unknown. + /// + UnknownCommand, + + /// + /// Thrown when the Slash Command parameter fails to be converted by a TypeReader. + /// + ConvertFailed, + + /// + /// Thrown when the input text has too few or too many arguments. + /// + BadArgs, + + /// + /// Thrown when an exception occurs mid-command execution. + /// + Exception, + + /// + /// Thrown when the command is not successfully executed on runtime. + /// + Unsuccessful, + + /// + /// Thrown when the command fails to meet a 's conditions. + /// + UnmetPrecondition, + + /// + /// Thrown when the command context cannot be parsed by the . + /// + ParseFailed + } +} diff --git a/src/Discord.Net.Interactions/InteractionContext.cs b/src/Discord.Net.Interactions/InteractionContext.cs new file mode 100644 index 0000000000..53cda58718 --- /dev/null +++ b/src/Discord.Net.Interactions/InteractionContext.cs @@ -0,0 +1,34 @@ +namespace Discord.Interactions +{ + /// + public class InteractionContext : IInteractionContext + { + /// + public IDiscordClient Client { get; } + /// + public IGuild Guild { get; } + /// + public IMessageChannel Channel { get; } + /// + public IUser User { get; } + /// + public IDiscordInteraction Interaction { get; } + + /// + /// Initializes a new . + /// + /// The underlying client. + /// The underlying interaction. + /// who executed the command. + /// the command originated from. + public InteractionContext(IDiscordClient client, IDiscordInteraction interaction, IUser user, IMessageChannel channel = null) + { + Client = client; + Interaction = interaction; + Channel = channel; + Guild = (interaction as IGuildUser)?.Guild; + User = user; + Interaction = interaction; + } + } +} diff --git a/src/Discord.Net.Interactions/InteractionModuleBase.cs b/src/Discord.Net.Interactions/InteractionModuleBase.cs new file mode 100644 index 0000000000..f0e50da8d7 --- /dev/null +++ b/src/Discord.Net.Interactions/InteractionModuleBase.cs @@ -0,0 +1,68 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Provides a base class for a command module to inherit from. + /// + /// Type of interaction context to be injected into the module. + public abstract class InteractionModuleBase : IInteractionModuleBase where T : class, IInteractionContext + { + /// + /// Gets the underlying context of the command. + /// + public T Context { get; private set; } + + /// + public virtual void AfterExecute (ICommandInfo command) { } + + /// + public virtual void BeforeExecute (ICommandInfo command) { } + + /// + public virtual void OnModuleBuilding (InteractionService commandService, ModuleInfo module) { } + + internal void SetContext (IInteractionContext context) + { + var newValue = context as T; + Context = newValue ?? throw new InvalidOperationException($"Invalid context type. Expected {typeof(T).Name}, got {context.GetType().Name}."); + } + + /// + protected virtual async Task DeferAsync(bool ephemeral = false, RequestOptions options = null) => + await Context.Interaction.DeferAsync(ephemeral, options).ConfigureAwait(false); + + /// + protected virtual async Task RespondAsync (string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null) => + await Context.Interaction.RespondAsync(text, embeds, isTTS, ephemeral, allowedMentions, component, embed, options).ConfigureAwait(false); + + /// + protected virtual async Task FollowupAsync (string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null) => + await Context.Interaction.FollowupAsync(text, embeds, isTTS, ephemeral, allowedMentions, component, embed, options).ConfigureAwait(false); + + /// + protected virtual async Task ReplyAsync (string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, + AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent component = null) => + await Context.Channel.SendMessageAsync(text, false, embed, options, allowedMentions, messageReference, component).ConfigureAwait(false); + + /// + protected virtual async Task DeleteOriginalResponseAsync ( ) + { + var response = await Context.Interaction.GetOriginalResponseAsync().ConfigureAwait(false); + await response.DeleteAsync().ConfigureAwait(false); + } + + //IInteractionModuleBase + + /// + void IInteractionModuleBase.SetContext (IInteractionContext context) => SetContext(context); + } + + /// + /// Provides a base class for a command module to inherit from. + /// + public abstract class InteractionModuleBase : InteractionModuleBase { } +} diff --git a/src/Discord.Net.Interactions/InteractionService.cs b/src/Discord.Net.Interactions/InteractionService.cs new file mode 100644 index 0000000000..6c2a70f165 --- /dev/null +++ b/src/Discord.Net.Interactions/InteractionService.cs @@ -0,0 +1,949 @@ +using Discord.Interactions.Builders; +using Discord.Logging; +using Discord.Rest; +using Discord.WebSocket; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Provides the framework for building and registering Discord Application Commands. + /// + public class InteractionService : IDisposable + { + /// + /// Occurs when a Slash Command related information is recieved. + /// + public event Func Log { add { _logEvent.Add(value); } remove { _logEvent.Remove(value); } } + internal readonly AsyncEvent> _logEvent = new (); + + /// + /// Occurs when a Slash Command is executed. + /// + public event Func SlashCommandExecuted { add { _slashCommandExecutedEvent.Add(value); } remove { _slashCommandExecutedEvent.Remove(value); } } + internal readonly AsyncEvent> _slashCommandExecutedEvent = new (); + + /// + /// Occurs when a Context Command is executed. + /// + public event Func ContextCommandExecuted { add { _contextCommandExecutedEvent.Add(value); } remove { _contextCommandExecutedEvent.Remove(value); } } + internal readonly AsyncEvent> _contextCommandExecutedEvent = new (); + + /// + /// Occurs when a Message Component command is executed. + /// + public event Func ComponentCommandExecuted { add { _componentCommandExecutedEvent.Add(value); } remove { _componentCommandExecutedEvent.Remove(value); } } + internal readonly AsyncEvent> _componentCommandExecutedEvent = new (); + + /// + /// Occurs when a Autocomplete command is executed. + /// + public event Func AutocompleteCommandExecuted { add { _autocompleteCommandExecutedEvent.Add(value); } remove { _autocompleteCommandExecutedEvent.Remove(value); } } + internal readonly AsyncEvent> _autocompleteCommandExecutedEvent = new(); + + /// + /// Occurs when a AutocompleteHandler is executed. + /// + public event Func AutocompleteHandlerExecuted { add { _autocompleteHandlerExecutedEvent.Add(value); } remove { _autocompleteHandlerExecutedEvent.Remove(value); } } + internal readonly AsyncEvent> _autocompleteHandlerExecutedEvent = new(); + + private readonly ConcurrentDictionary _typedModuleDefs; + private readonly CommandMap _slashCommandMap; + private readonly ConcurrentDictionary> _contextCommandMaps; + private readonly CommandMap _componentCommandMap; + private readonly CommandMap _autocompleteCommandMap; + private readonly HashSet _moduleDefs; + private readonly ConcurrentDictionary _typeConverters; + private readonly ConcurrentDictionary _genericTypeConverters; + private readonly ConcurrentDictionary _autocompleteHandlers = new(); + private readonly SemaphoreSlim _lock; + internal readonly Logger _cmdLogger; + internal readonly LogManager _logManager; + internal readonly Func _getRestClient; + + internal readonly bool _throwOnError, _deleteUnkownSlashCommandAck, _useCompiledLambda, _enableAutocompleteHandlers; + internal readonly string _wildCardExp; + internal readonly RunMode _runMode; + internal readonly RestResponseCallback _restResponseCallback; + + /// + /// Rest client to be used to register application commands. + /// + public DiscordRestClient RestClient { get => _getRestClient(); } + + /// + /// Represents all modules loaded within . + /// + public IReadOnlyList Modules => _moduleDefs.ToList(); + + /// + /// Represents all Slash Commands loaded within . + /// + public IReadOnlyList SlashCommands => _moduleDefs.SelectMany(x => x.SlashCommands).ToList(); + + /// + /// Represents all Context Commands loaded within . + /// + public IReadOnlyList ContextCommands => _moduleDefs.SelectMany(x => x.ContextCommands).ToList(); + + /// + /// Represents all Component Commands loaded within . + /// + public IReadOnlyCollection ComponentCommands => _moduleDefs.SelectMany(x => x.ComponentCommands).ToList(); + + /// + /// Initialize a with provided configurations. + /// + /// The discord client. + /// The configuration class. + public InteractionService (DiscordSocketClient discord, InteractionServiceConfig config = null) + : this(() => discord.Rest, config ?? new InteractionServiceConfig()) { } + + /// + /// Initialize a with provided configurations. + /// + /// The discord client. + /// The configuration class. + public InteractionService (DiscordShardedClient discord, InteractionServiceConfig config = null) + : this(() => discord.Rest, config ?? new InteractionServiceConfig()) { } + + /// + /// Initialize a with provided configurations. + /// + /// The discord client. + /// The configuration class. + public InteractionService (BaseSocketClient discord, InteractionServiceConfig config = null) + :this(() => discord.Rest, config ?? new InteractionServiceConfig()) { } + + /// + /// Initialize a with provided configurations. + /// + /// The discord client. + /// The configuration class. + public InteractionService (DiscordRestClient discord, InteractionServiceConfig config = null) + :this(() => discord, config ?? new InteractionServiceConfig()) { } + + private InteractionService (Func getRestClient, InteractionServiceConfig config = null) + { + config ??= new InteractionServiceConfig(); + + _lock = new SemaphoreSlim(1, 1); + _typedModuleDefs = new ConcurrentDictionary(); + _moduleDefs = new HashSet(); + + _logManager = new LogManager(config.LogLevel); + _logManager.Message += async msg => await _logEvent.InvokeAsync(msg).ConfigureAwait(false); + _cmdLogger = _logManager.CreateLogger("App Commands"); + + _slashCommandMap = new CommandMap(this); + _contextCommandMaps = new ConcurrentDictionary>(); + _componentCommandMap = new CommandMap(this, config.InteractionCustomIdDelimiters); + _autocompleteCommandMap = new CommandMap(this); + + _getRestClient = getRestClient; + + _runMode = config.DefaultRunMode; + if (_runMode == RunMode.Default) + throw new InvalidOperationException($"RunMode cannot be set to {RunMode.Default}"); + + _throwOnError = config.ThrowOnError; + _deleteUnkownSlashCommandAck = config.DeleteUnknownSlashCommandAck; + _wildCardExp = config.WildCardExpression; + _useCompiledLambda = config.UseCompiledLambda; + _enableAutocompleteHandlers = config.EnableAutocompleteHandlers; + _restResponseCallback = config.RestResponseCallback; + + _genericTypeConverters = new ConcurrentDictionary + { + [typeof(IChannel)] = typeof(DefaultChannelConverter<>), + [typeof(IRole)] = typeof(DefaultRoleConverter<>), + [typeof(IUser)] = typeof(DefaultUserConverter<>), + [typeof(IMentionable)] = typeof(DefaultMentionableConverter<>), + [typeof(IConvertible)] = typeof(DefaultValueConverter<>), + [typeof(Enum)] = typeof(EnumConverter<>) + }; + + _typeConverters = new ConcurrentDictionary + { + [typeof(TimeSpan)] = new TimeSpanConverter() + }; + } + + /// + /// Create and loads a using a builder factory. + /// + /// Name of the module. + /// The for your dependency injection solution if using one; otherwise, pass null. + /// Module builder factory. + /// + /// A task representing the operation for adding modules. The task result contains the built module instance. + /// + public async Task CreateModuleAsync(string name, IServiceProvider services, Action buildFunc) + { + services ??= EmptyServiceProvider.Instance; + + await _lock.WaitAsync().ConfigureAwait(false); + try + { + var builder = new ModuleBuilder(this, name); + buildFunc(builder); + + var moduleInfo = builder.Build(this, services); + LoadModuleInternal(moduleInfo); + + return moduleInfo; + } + finally + { + _lock.Release(); + } + } + + /// + /// Discover and load command modules from an . + /// + /// the command modules are defined in. + /// The for your dependency injection solution if using one; otherwise, pass null. + /// + /// A task representing the operation for adding modules. The task result contains a collection of the modules added. + /// + public async Task> AddModulesAsync (Assembly assembly, IServiceProvider services) + { + services ??= EmptyServiceProvider.Instance; + + await _lock.WaitAsync().ConfigureAwait(false); + + try + { + var types = await ModuleClassBuilder.SearchAsync(assembly, this); + var moduleDefs = await ModuleClassBuilder.BuildAsync(types, this, services); + + foreach (var info in moduleDefs) + { + _typedModuleDefs[info.Key] = info.Value; + LoadModuleInternal(info.Value); + } + return moduleDefs.Values; + } + finally + { + _lock.Release(); + } + } + + /// + /// Add a command module from a . + /// + /// Type of the module. + /// The for your dependency injection solution if using one; otherwise, pass null . + /// + /// A task representing the operation for adding the module. The task result contains the built module. + /// + /// + /// Thrown if this module has already been added. + /// + /// + /// Thrown when the is not a valid module definition. + /// + public Task AddModuleAsync (IServiceProvider services) where T : class => + AddModuleAsync(typeof(T), services); + + /// + /// Add a command module from a . + /// + /// Type of the module. + /// The for your dependency injection solution if using one; otherwise, pass null . + /// + /// A task representing the operation for adding the module. The task result contains the built module. + /// + /// + /// Thrown if this module has already been added. + /// + /// + /// Thrown when the is not a valid module definition. + /// + public async Task AddModuleAsync (Type type, IServiceProvider services) + { + if (!typeof(IInteractionModuleBase).IsAssignableFrom(type)) + throw new ArgumentException("Type parameter must be a type of Slash Module", "T"); + + services ??= EmptyServiceProvider.Instance; + + await _lock.WaitAsync().ConfigureAwait(false); + + try + { + var typeInfo = type.GetTypeInfo(); + + if (_typedModuleDefs.ContainsKey(typeInfo)) + throw new ArgumentException("Module definition for this type already exists."); + + var moduleDef = ( await ModuleClassBuilder.BuildAsync(new List { typeInfo }, this, services).ConfigureAwait(false) ).FirstOrDefault(); + + if (moduleDef.Value == default) + throw new InvalidOperationException($"Could not build the module {typeInfo.FullName}, did you pass an invalid type?"); + + if (!_typedModuleDefs.TryAdd(type, moduleDef.Value)) + throw new ArgumentException("Module definition for this type already exists."); + + _typedModuleDefs[moduleDef.Key] = moduleDef.Value; + LoadModuleInternal(moduleDef.Value); + + return moduleDef.Value; + } + finally + { + _lock.Release(); + } + } + + /// + /// Register Application Commands from and to a guild. + /// + /// Id of the target guild. + /// If , this operation will not delete the commands that are missing from . + /// + /// A task representing the command registration process. The task result contains the active application commands of the target guild. + /// + public async Task> RegisterCommandsToGuildAsync (ulong guildId, bool deleteMissing = true) + { + EnsureClientReady(); + + var topLevelModules = _moduleDefs.Where(x => !x.IsSubModule); + var props = topLevelModules.SelectMany(x => x.ToApplicationCommandProps()).ToList(); + + if (!deleteMissing) + { + + var existing = await RestClient.GetGuildApplicationCommands(guildId).ConfigureAwait(false); + var missing = existing.Where(x => !props.Any(y => y.Name.IsSpecified && y.Name.Value == x.Name)); + props.AddRange(missing.Select(x => x.ToApplicationCommandProps())); + } + + return await RestClient.BulkOverwriteGuildCommands(props.ToArray(), guildId).ConfigureAwait(false); + } + + /// + /// Register Application Commands from and to Discord on in global scope. + /// + /// If , this operation will not delete the commands that are missing from . + /// + /// A task representing the command registration process. The task result contains the active global application commands of bot. + /// + public async Task> RegisterCommandsGloballyAsync (bool deleteMissing = true) + { + EnsureClientReady(); + + var topLevelModules = _moduleDefs.Where(x => !x.IsSubModule); + var props = topLevelModules.SelectMany(x => x.ToApplicationCommandProps()).ToList(); + + if (!deleteMissing) + { + var existing = await RestClient.GetGlobalApplicationCommands().ConfigureAwait(false); + var missing = existing.Where(x => !props.Any(y => y.Name.IsSpecified && y.Name.Value == x.Name)); + props.AddRange(missing.Select(x => x.ToApplicationCommandProps())); + } + + return await RestClient.BulkOverwriteGlobalCommands(props.ToArray()).ConfigureAwait(false); + } + + /// + /// Register Application Commands from to a guild. + /// + /// + /// Commands will be registered as standalone commands, if you want the to take effect, + /// use . Registering a commands without group names might cause the command traversal to fail. + /// + /// The target guild. + /// Commands to be registered to Discord. + /// + /// A task representing the command registration process. The task result contains the active application commands of the target guild. + /// + public async Task> AddCommandsToGuildAsync(IGuild guild, bool deleteMissing = false, params ICommandInfo[] commands) + { + EnsureClientReady(); + + if (guild is null) + throw new ArgumentNullException(nameof(guild)); + + var props = new List(); + + foreach (var command in commands) + { + switch (command) + { + case SlashCommandInfo slashCommand: + props.Add(slashCommand.ToApplicationCommandProps()); + break; + case ContextCommandInfo contextCommand: + props.Add(contextCommand.ToApplicationCommandProps()); + break; + default: + throw new InvalidOperationException($"Command type {command.GetType().FullName} isn't supported yet"); + } + } + + if (!deleteMissing) + { + var existing = await RestClient.GetGuildApplicationCommands(guild.Id).ConfigureAwait(false); + var missing = existing.Where(x => !props.Any(y => y.Name.IsSpecified && y.Name.Value == x.Name)); + props.AddRange(missing.Select(x => x.ToApplicationCommandProps())); + } + + return await RestClient.BulkOverwriteGuildCommands(props.ToArray(), guild.Id).ConfigureAwait(false); + } + + /// + /// Register Application Commands from modules provided in to a guild. + /// + /// The target guild. + /// Modules to be registered to Discord. + /// + /// A task representing the command registration process. The task result contains the active application commands of the target guild. + /// + public async Task> AddModulesToGuildAsync(IGuild guild, bool deleteMissing = false, params ModuleInfo[] modules) + { + EnsureClientReady(); + + if (guild is null) + throw new ArgumentNullException(nameof(guild)); + + var props = modules.SelectMany(x => x.ToApplicationCommandProps(true)).ToList(); + + if (!deleteMissing) + { + var existing = await RestClient.GetGuildApplicationCommands(guild.Id).ConfigureAwait(false); + var missing = existing.Where(x => !props.Any(y => y.Name.IsSpecified && y.Name.Value == x.Name)); + props.AddRange(missing.Select(x => x.ToApplicationCommandProps())); + } + + return await RestClient.BulkOverwriteGuildCommands(props.ToArray(), guild.Id).ConfigureAwait(false); + } + + /// + /// Register Application Commands from modules provided in as global commands. + /// + /// Modules to be registered to Discord. + /// + /// A task representing the command registration process. The task result contains the active application commands of the target guild. + /// + public async Task> AddModulesGloballyAsync(bool deleteMissing = false, params ModuleInfo[] modules) + { + EnsureClientReady(); + + var props = modules.SelectMany(x => x.ToApplicationCommandProps(true)).ToList(); + + if (!deleteMissing) + { + var existing = await RestClient.GetGlobalApplicationCommands().ConfigureAwait(false); + var missing = existing.Where(x => !props.Any(y => y.Name.IsSpecified && y.Name.Value == x.Name)); + props.AddRange(missing.Select(x => x.ToApplicationCommandProps())); + } + + return await RestClient.BulkOverwriteGlobalCommands(props.ToArray()).ConfigureAwait(false); + } + + /// + /// Register Application Commands from as global commands. + /// + /// + /// Commands will be registered as standalone commands, if you want the to take effect, + /// use . Registering a commands without group names might cause the command traversal to fail. + /// + /// Commands to be registered to Discord. + /// + /// A task representing the command registration process. The task result contains the active application commands of the target guild. + /// + public async Task> AddCommandsGloballyAsync(bool deleteMissing = false, params IApplicationCommandInfo[] commands) + { + EnsureClientReady(); + + var props = new List(); + + foreach (var command in commands) + { + switch (command) + { + case SlashCommandInfo slashCommand: + props.Add(slashCommand.ToApplicationCommandProps()); + break; + case ContextCommandInfo contextCommand: + props.Add(contextCommand.ToApplicationCommandProps()); + break; + default: + throw new InvalidOperationException($"Command type {command.GetType().FullName} isn't supported yet"); + } + } + + if (!deleteMissing) + { + var existing = await RestClient.GetGlobalApplicationCommands().ConfigureAwait(false); + var missing = existing.Where(x => !props.Any(y => y.Name.IsSpecified && y.Name.Value == x.Name)); + props.AddRange(missing.Select(x => x.ToApplicationCommandProps())); + } + + return await RestClient.BulkOverwriteGlobalCommands(props.ToArray()).ConfigureAwait(false); + } + + private void LoadModuleInternal (ModuleInfo module) + { + _moduleDefs.Add(module); + + foreach (var command in module.SlashCommands) + _slashCommandMap.AddCommand(command, command.IgnoreGroupNames); + + foreach (var command in module.ContextCommands) + _contextCommandMaps.GetOrAdd(command.CommandType, new CommandMap(this)).AddCommand(command, command.IgnoreGroupNames); + + foreach (var interaction in module.ComponentCommands) + _componentCommandMap.AddCommand(interaction, interaction.IgnoreGroupNames); + + foreach (var command in module.AutocompleteCommands) + _autocompleteCommandMap.AddCommand(command.GetCommandKeywords(), command); + + foreach (var subModule in module.SubModules) + LoadModuleInternal(subModule); + } + + /// + /// Remove a command module. + /// + /// The of the module. + /// + /// A task that represents the asynchronous removal operation. The task result contains a value that + /// indicates whether the module is successfully removed. + /// + public Task RemoveModuleAsync ( ) => + RemoveModuleAsync(typeof(T)); + + /// + /// Remove a command module. + /// + /// The of the module. + /// + /// A task that represents the asynchronous removal operation. The task result contains a value that + /// indicates whether the module is successfully removed. + /// + public async Task RemoveModuleAsync (Type type) + { + await _lock.WaitAsync().ConfigureAwait(false); + + try + { + if (!_typedModuleDefs.TryRemove(type, out var module)) + return false; + + return RemoveModuleInternal(module); + } + finally + { + _lock.Release(); + } + } + + /// + /// Remove a command module. + /// + /// The to be removed from the service. + /// + /// A task that represents the asynchronous removal operation. The task result contains a value that + /// indicates whether the is successfully removed. + /// + public async Task RemoveModuleAsync(ModuleInfo module) + { + await _lock.WaitAsync().ConfigureAwait(false); + + try + { + var typeModulePair = _typedModuleDefs.FirstOrDefault(x => x.Value.Equals(module)); + + if (!typeModulePair.Equals(default(KeyValuePair))) + _typedModuleDefs.TryRemove(typeModulePair.Key, out var _); + + return RemoveModuleInternal(module); + } + finally + { + _lock.Release(); + } + } + + private bool RemoveModuleInternal (ModuleInfo moduleInfo) + { + if (!_moduleDefs.Remove(moduleInfo)) + return false; + + foreach (var command in moduleInfo.SlashCommands) + { + _slashCommandMap.RemoveCommand(command); + } + + return true; + } + + /// + /// Execute a Command from a given . + /// + /// Name context of the command. + /// The service to be used in the command's dependency injection. + /// + /// A task representing the command execution process. The task result contains the result of the execution. + /// + public async Task ExecuteCommandAsync (IInteractionContext context, IServiceProvider services) + { + var interaction = context.Interaction; + + return interaction switch + { + ISlashCommandInteraction slashCommand => await ExecuteSlashCommandAsync(context, slashCommand, services).ConfigureAwait(false), + IComponentInteraction messageComponent => await ExecuteComponentCommandAsync(context, messageComponent.Data.CustomId, services).ConfigureAwait(false), + IUserCommandInteraction userCommand => await ExecuteContextCommandAsync(context, userCommand.Data.Name, ApplicationCommandType.User, services).ConfigureAwait(false), + IMessageCommandInteraction messageCommand => await ExecuteContextCommandAsync(context, messageCommand.Data.Name, ApplicationCommandType.Message, services).ConfigureAwait(false), + IAutocompleteInteraction autocomplete => await ExecuteAutocompleteAsync(context, autocomplete, services).ConfigureAwait(false), + _ => throw new InvalidOperationException($"{interaction.Type} interaction type cannot be executed by the Interaction service"), + }; + } + + private async Task ExecuteSlashCommandAsync (IInteractionContext context, ISlashCommandInteraction interaction, IServiceProvider services) + { + var keywords = interaction.Data.GetCommandKeywords(); + + var result = _slashCommandMap.GetCommand(keywords); + + if (!result.IsSuccess) + { + await _cmdLogger.DebugAsync($"Unknown slash command, skipping execution ({string.Join(" ", keywords).ToUpper()})"); + + if (_deleteUnkownSlashCommandAck) + { + var response = await context.Interaction.GetOriginalResponseAsync().ConfigureAwait(false); + await response.DeleteAsync().ConfigureAwait(false); + } + + await _slashCommandExecutedEvent.InvokeAsync(null, context, result).ConfigureAwait(false); + return result; + } + return await result.Command.ExecuteAsync(context, services).ConfigureAwait(false); + } + + private async Task ExecuteContextCommandAsync (IInteractionContext context, string input, ApplicationCommandType commandType, IServiceProvider services) + { + if (!_contextCommandMaps.TryGetValue(commandType, out var map)) + return SearchResult.FromError(input, InteractionCommandError.UnknownCommand, $"No {commandType} command found."); + + var result = map.GetCommand(input); + + if (!result.IsSuccess) + { + await _cmdLogger.DebugAsync($"Unknown context command, skipping execution ({result.Text.ToUpper()})"); + + await _contextCommandExecutedEvent.InvokeAsync(null, context, result).ConfigureAwait(false); + return result; + } + return await result.Command.ExecuteAsync(context, services).ConfigureAwait(false); + } + + private async Task ExecuteComponentCommandAsync (IInteractionContext context, string input, IServiceProvider services) + { + var result = _componentCommandMap.GetCommand(input); + + if (!result.IsSuccess) + { + await _cmdLogger.DebugAsync($"Unknown custom interaction id, skipping execution ({input.ToUpper()})"); + + await _componentCommandExecutedEvent.InvokeAsync(null, context, result).ConfigureAwait(false); + return result; + } + return await result.Command.ExecuteAsync(context, services, result.RegexCaptureGroups).ConfigureAwait(false); + } + + private async Task ExecuteAutocompleteAsync (IInteractionContext context, IAutocompleteInteraction interaction, IServiceProvider services ) + { + var keywords = interaction.Data.GetCommandKeywords(); + + if(_enableAutocompleteHandlers) + { + var autocompleteHandlerResult = _slashCommandMap.GetCommand(keywords); + + if(autocompleteHandlerResult.IsSuccess) + { + var parameter = autocompleteHandlerResult.Command.Parameters.FirstOrDefault(x => string.Equals(x.Name, interaction.Data.Current.Name, StringComparison.Ordinal)); + + if(parameter?.AutocompleteHandler is not null) + return await parameter.AutocompleteHandler.ExecuteAsync(context, interaction, parameter, services).ConfigureAwait(false); + } + } + + keywords.Add(interaction.Data.Current.Name); + + var commandResult = _autocompleteCommandMap.GetCommand(keywords); + + if(!commandResult.IsSuccess) + { + await _cmdLogger.DebugAsync($"Unknown command name, skipping autocomplete process ({interaction.Data.CommandName.ToUpper()})"); + + await _autocompleteCommandExecutedEvent.InvokeAsync(null, context, commandResult).ConfigureAwait(false); + return commandResult; + } + + return await commandResult.Command.ExecuteAsync(context, services).ConfigureAwait(false); + } + + internal TypeConverter GetTypeConverter (Type type, IServiceProvider services = null) + { + if (_typeConverters.TryGetValue(type, out var specific)) + return specific; + + else if (_genericTypeConverters.Any(x => x.Key.IsAssignableFrom(type))) + { + services ??= EmptyServiceProvider.Instance; + + var converterType = GetMostSpecificTypeConverter(type); + var converter = ReflectionUtils.CreateObject(converterType.MakeGenericType(type).GetTypeInfo(), this, services); + _typeConverters[type] = converter; + return converter; + } + + else if (_typeConverters.Any(x => x.Value.CanConvertTo(type))) + return _typeConverters.First(x => x.Value.CanConvertTo(type)).Value; + + throw new ArgumentException($"No type {nameof(TypeConverter)} is defined for this {type.FullName}", "type"); + } + + /// + /// Add a concrete type . + /// + /// Primary target of the . + /// The instance. + public void AddTypeConverter (TypeConverter converter) => + AddTypeConverter(typeof(T), converter); + + /// + /// Add a concrete type . + /// + /// Primary target of the . + /// The instance. + public void AddTypeConverter (Type type, TypeConverter converter) + { + if (!converter.CanConvertTo(type)) + throw new ArgumentException($"This {converter.GetType().FullName} cannot read {type.FullName} and cannot be registered as its {nameof(TypeConverter)}"); + + _typeConverters[type] = converter; + } + + /// + /// Add a generic type . + /// + /// Generic Type constraint of the of the . + /// Type of the . + + public void AddGenericTypeConverter (Type converterType) => + AddGenericTypeConverter(typeof(T), converterType); + + /// + /// Add a generic type . + /// + /// Generic Type constraint of the of the . + /// Type of the . + public void AddGenericTypeConverter (Type targetType, Type converterType) + { + if (!converterType.IsGenericTypeDefinition) + throw new ArgumentException($"{converterType.FullName} is not generic."); + + var genericArguments = converterType.GetGenericArguments(); + + if (genericArguments.Count() > 1) + throw new InvalidOperationException($"Valid generic {converterType.FullName}s cannot have more than 1 generic type parameter"); + + var constraints = genericArguments.SelectMany(x => x.GetGenericParameterConstraints()); + + if (!constraints.Any(x => x.IsAssignableFrom(targetType))) + throw new InvalidOperationException($"This generic class does not support type {targetType.FullName}"); + + _genericTypeConverters[targetType] = converterType; + } + + internal IAutocompleteHandler GetAutocompleteHandler(Type autocompleteHandlerType, IServiceProvider services = null) + { + services ??= EmptyServiceProvider.Instance; + + if (!_enableAutocompleteHandlers) + throw new InvalidOperationException($"{nameof(IAutocompleteHandler)}s are not enabled. To use this feature set {nameof(InteractionServiceConfig.EnableAutocompleteHandlers)} to TRUE"); + + if (_autocompleteHandlers.TryGetValue(autocompleteHandlerType, out var autocompleteHandler)) + return autocompleteHandler; + else + { + autocompleteHandler = ReflectionUtils.CreateObject(autocompleteHandlerType.GetTypeInfo(), this, services); + _autocompleteHandlers[autocompleteHandlerType] = autocompleteHandler; + return autocompleteHandler; + } + } + + /// + /// Modify the command permissions of the matching Discord Slash Command. + /// + /// Module representing the top level Slash Command. + /// Target guild. + /// New permission values. + /// + /// The active command permissions after the modification. + /// + public async Task ModifySlashCommandPermissionsAsync (ModuleInfo module, IGuild guild, + params ApplicationCommandPermission[] permissions) + { + if (!module.IsSlashGroup) + throw new InvalidOperationException($"This module does not have a {nameof(GroupAttribute)} and does not represent an Application Command"); + + if (!module.IsTopLevelGroup) + throw new InvalidOperationException("This module is not a top level application command. You cannot change its permissions"); + + if (guild is null) + throw new ArgumentNullException("guild"); + + var commands = await RestClient.GetGuildApplicationCommands(guild.Id).ConfigureAwait(false); + var appCommand = commands.First(x => x.Name == module.SlashGroupName); + + return await appCommand.ModifyCommandPermissions(permissions).ConfigureAwait(false); + } + + /// + /// Modify the command permissions of the matching Discord Slash Command. + /// + /// The Slash Command. + /// Target guild. + /// New permission values. + /// + /// The active command permissions after the modification. + /// + public async Task ModifySlashCommandPermissionsAsync (SlashCommandInfo command, IGuild guild, + params ApplicationCommandPermission[] permissions) => + await ModifyApplicationCommandPermissionsAsync(command, guild, permissions).ConfigureAwait(false); + + /// + /// Modify the command permissions of the matching Discord Slash Command. + /// + /// The Context Command. + /// Target guild. + /// New permission values. + /// + /// The active command permissions after the modification. + /// + public async Task ModifyContextCommandPermissionsAsync (ContextCommandInfo command, IGuild guild, + params ApplicationCommandPermission[] permissions) => + await ModifyApplicationCommandPermissionsAsync(command, guild, permissions).ConfigureAwait(false); + + private async Task ModifyApplicationCommandPermissionsAsync (T command, IGuild guild, + params ApplicationCommandPermission[] permissions) where T : class, IApplicationCommandInfo, ICommandInfo + { + if (!command.IsTopLevelCommand) + throw new InvalidOperationException("This command is not a top level application command. You cannot change its permissions"); + + if (guild is null) + throw new ArgumentNullException("guild"); + + var commands = await RestClient.GetGuildApplicationCommands(guild.Id).ConfigureAwait(false); + var appCommand = commands.First(x => x.Name == ( command as IApplicationCommandInfo ).Name); + + return await appCommand.ModifyCommandPermissions(permissions).ConfigureAwait(false); + } + + /// + /// Gets a . + /// + /// Declaring module type of this command, must be a type of . + /// Method name of the handler, use of is recommended. + /// + /// instance for this command. + /// + /// Module or Slash Command couldn't be found. + public SlashCommandInfo GetSlashCommandInfo (string methodName) where TModule : class + { + var module = GetModuleInfo(); + + return module.SlashCommands.First(x => x.MethodName == methodName); + } + + /// + /// Gets a . + /// + /// Declaring module type of this command, must be a type of . + /// Method name of the handler, use of is recommended. + /// + /// instance for this command. + /// + /// Module or Context Command couldn't be found. + public ContextCommandInfo GetContextCommandInfo (string methodName) where TModule : class + { + var module = GetModuleInfo(); + + return module.ContextCommands.First(x => x.MethodName == methodName); + } + + /// + /// Gets a . + /// + /// Declaring module type of this command, must be a type of . + /// Method name of the handler, use of is recommended. + /// + /// instance for this command. + /// + /// Module or Component Command couldn't be found. + public ComponentCommandInfo GetComponentCommandInfo (string methodName) where TModule : class + { + var module = GetModuleInfo(); + + return module.ComponentCommands.First(x => x.MethodName == methodName); + } + + /// + /// Gets a built . + /// + /// Type of the module, must be a type of . + /// + /// instance for this module. + /// + public ModuleInfo GetModuleInfo ( ) where TModule : class + { + if (!typeof(IInteractionModuleBase).IsAssignableFrom(typeof(TModule))) + throw new ArgumentException("Type parameter must be a type of Slash Module", "TModule"); + + var module = _typedModuleDefs[typeof(TModule)]; + + if (module is null) + throw new InvalidOperationException($"{typeof(TModule).FullName} is not loaded to the Slash Command Service"); + + return module; + } + + /// + public void Dispose ( ) + { + _lock.Dispose(); + } + + private Type GetMostSpecificTypeConverter (Type type) + { + if (_genericTypeConverters.TryGetValue(type, out var matching)) + return matching; + + var typeInterfaces = type.GetInterfaces(); + var candidates = _genericTypeConverters.Where(x => x.Key.IsAssignableFrom(type)) + .OrderByDescending(x => typeInterfaces.Count(y => y.IsAssignableFrom(x.Key))); + + return candidates.First().Value; + } + + private void EnsureClientReady() + { + if (RestClient?.CurrentUser is null || RestClient?.CurrentUser?.Id == 0) + throw new InvalidOperationException($"Provided client is not ready to execute this operation, invoke this operation after a `Client Ready` event"); + } + } +} diff --git a/src/Discord.Net.Interactions/InteractionServiceConfig.cs b/src/Discord.Net.Interactions/InteractionServiceConfig.cs new file mode 100644 index 0000000000..8e495a5ca3 --- /dev/null +++ b/src/Discord.Net.Interactions/InteractionServiceConfig.cs @@ -0,0 +1,70 @@ +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents a configuration class for . + /// + public class InteractionServiceConfig + { + /// + /// Gets or sets the minimum log level severity that will be sent to the event. + /// + public LogSeverity LogLevel { get; set; } = LogSeverity.Info; + + /// + /// Gets or sets the default commands should have, if one is not specified on the + /// Command attribute or builder. + /// + public RunMode DefaultRunMode { get; set; } = RunMode.Async; + + /// + /// Gets or sets whether commands should push exceptions up to the caller. + /// + public bool ThrowOnError { get; set; } = true; + + /// + /// Gets or sets the delimiters that will be used to seperate group names and the method name when a Message Component Interaction is recieved. + /// + public char[] InteractionCustomIdDelimiters { get; set; } + + /// + /// Gets or sets the string expression that will be treated as a wild card. + /// + public string WildCardExpression { get; set; } + + /// + /// Gets or sets the option to delete Slash Command acknowledgements if no Slash Command handler is found in the . + /// + public bool DeleteUnknownSlashCommandAck { get; set; } = true; + + /// + /// Gets or sets the option to use compiled lambda expressions to create module instances and execute commands. This method improves performance at the cost of memory. + /// + public bool UseCompiledLambda { get; set; } = false; + + /// + /// Gets or sets the option allowing you to use s. + /// + /// + /// Since s are prioritized over s, if s are not used, this should be + /// disabled to decrease the lookup time. + /// + public bool EnableAutocompleteHandlers { get; set; } = true; + + /// + /// Gets or sets delegate to be used by the when responding to a Rest based interaction. + /// + public RestResponseCallback RestResponseCallback { get; set; } = (ctx, str) => Task.CompletedTask; + } + + /// + /// Represents a cached delegate for creating interaction responses to webhook based Discord Interactions. + /// + /// Execution context that will be injected into the module class. + /// Body of the interaction response. + /// + /// A task representing the response operation. + /// + public delegate Task RestResponseCallback(IInteractionContext context, string responseBody); +} diff --git a/src/Discord.Net.Interactions/Map/CommandMap.cs b/src/Discord.Net.Interactions/Map/CommandMap.cs new file mode 100644 index 0000000000..2e7bf53685 --- /dev/null +++ b/src/Discord.Net.Interactions/Map/CommandMap.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.Interactions +{ + internal class CommandMap where T : class, ICommandInfo + { + private readonly char[] _seperators; + + private readonly CommandMapNode _root; + private readonly InteractionService _commandService; + + public IReadOnlyCollection Seperators => _seperators; + + public CommandMap(InteractionService commandService, char[] seperators = null) + { + _seperators = seperators ?? Array.Empty(); + + _commandService = commandService; + _root = new CommandMapNode(null, _commandService._wildCardExp); + } + + public void AddCommand(T command, bool ignoreGroupNames = false) + { + if (ignoreGroupNames) + AddCommandToRoot(command); + else + AddCommand(command); + } + + public void AddCommandToRoot(T command) + { + string[] key = new string[] { command.Name }; + _root.AddCommand(key, 0, command); + } + + public void AddCommand(IList input, T command) + { + _root.AddCommand(input, 0, command); + } + + public void RemoveCommand(T command) + { + var key = ParseCommandName(command); + + _root.RemoveCommand(key, 0); + } + + public SearchResult GetCommand(string input) + { + if(_seperators.Any()) + return GetCommand(input.Split(_seperators)); + else + return GetCommand(new string[] { input }); + } + + public SearchResult GetCommand(IList input) => + _root.GetCommand(input, 0); + + private void AddCommand(T command) + { + var key = ParseCommandName(command); + + _root.AddCommand(key, 0, command); + } + + private IList ParseCommandName(T command) + { + var keywords = new List() { command.Name }; + + var currentParent = command.Module; + + while (currentParent != null) + { + if (!string.IsNullOrEmpty(currentParent.SlashGroupName)) + keywords.Add(currentParent.SlashGroupName); + + currentParent = currentParent.Parent; + } + + keywords.Reverse(); + + return keywords; + } + } +} diff --git a/src/Discord.Net.Interactions/Map/CommandMapNode.cs b/src/Discord.Net.Interactions/Map/CommandMapNode.cs new file mode 100644 index 0000000000..c866fe00e1 --- /dev/null +++ b/src/Discord.Net.Interactions/Map/CommandMapNode.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Discord.Interactions +{ + internal class CommandMapNode where T : class, ICommandInfo + { + private const string RegexWildCardExp = "(\\S+)?"; + + private readonly string _wildCardStr = "*"; + private readonly ConcurrentDictionary> _nodes; + private readonly ConcurrentDictionary _commands; + private readonly ConcurrentDictionary _wildCardCommands; + + public IReadOnlyDictionary> Nodes => _nodes; + public IReadOnlyDictionary Commands => _commands; + public IReadOnlyDictionary WildCardCommands => _wildCardCommands; + public string Name { get; } + + public CommandMapNode (string name, string wildCardExp = null) + { + Name = name; + _nodes = new ConcurrentDictionary>(); + _commands = new ConcurrentDictionary(); + _wildCardCommands = new ConcurrentDictionary(); + + if (!string.IsNullOrEmpty(wildCardExp)) + _wildCardStr = wildCardExp; + } + + public void AddCommand (IList keywords, int index, T commandInfo) + { + if (keywords.Count == index + 1) + { + if (commandInfo.SupportsWildCards && commandInfo.Name.Contains(_wildCardStr)) + { + var escapedStr = RegexUtils.EscapeExcluding(commandInfo.Name, _wildCardStr.ToArray()); + var patternStr = "\\A" + escapedStr.Replace(_wildCardStr, RegexWildCardExp) + "\\Z"; + var regex = new Regex(patternStr, RegexOptions.Singleline | RegexOptions.Compiled); + + if (!_wildCardCommands.TryAdd(regex, commandInfo)) + throw new InvalidOperationException($"A {typeof(T).FullName} already exists with the same name: {string.Join(" ", keywords)}"); + } + else + { + if (!_commands.TryAdd(keywords[index], commandInfo)) + throw new InvalidOperationException($"A {typeof(T).FullName} already exists with the same name: {string.Join(" ", keywords)}"); + } + } + else + { + var node = _nodes.GetOrAdd(keywords[index], (key) => new CommandMapNode(key, _wildCardStr)); + node.AddCommand(keywords, ++index, commandInfo); + } + } + + public bool RemoveCommand (IList keywords, int index) + { + if (keywords.Count == index + 1) + return _commands.TryRemove(keywords[index], out var _); + else + { + if (!_nodes.TryGetValue(keywords[index], out var node)) + throw new InvalidOperationException($"No descendant node was found with the name {keywords[index]}"); + + return node.RemoveCommand(keywords, ++index); + } + } + + public SearchResult GetCommand (IList keywords, int index) + { + string name = string.Join(" ", keywords); + + if (keywords.Count == index + 1) + { + if (_commands.TryGetValue(keywords[index], out var cmd)) + return SearchResult.FromSuccess(name, cmd); + else + { + foreach (var cmdPair in _wildCardCommands) + { + var match = cmdPair.Key.Match(keywords[index]); + if (match.Success) + { + var args = new string[match.Groups.Count - 1]; + + for (var i = 1; i < match.Groups.Count; i++) + args[i - 1] = match.Groups[i].Value; + + return SearchResult.FromSuccess(name, cmdPair.Value, args.ToArray()); + } + } + } + } + else + { + if (_nodes.TryGetValue(keywords[index], out var node)) + return node.GetCommand(keywords, ++index); + } + + return SearchResult.FromError(name, InteractionCommandError.UnknownCommand, $"No {typeof(T).FullName} found for {name}"); + } + + public SearchResult GetCommand (string text, int index, char[] seperators) + { + var keywords = text.Split(seperators); + return GetCommand(keywords, index); + } + } +} diff --git a/src/Discord.Net.Interactions/RestInteractionModuleBase.cs b/src/Discord.Net.Interactions/RestInteractionModuleBase.cs new file mode 100644 index 0000000000..ee1ce229ea --- /dev/null +++ b/src/Discord.Net.Interactions/RestInteractionModuleBase.cs @@ -0,0 +1,59 @@ +using Discord.Rest; +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Provides a base class for a Rest based command module to inherit from. + /// + /// Type of interaction context to be injected into the module. + public abstract class RestInteractionModuleBase : InteractionModuleBase + where T : class, IInteractionContext + { + /// + /// Gets or sets the underlying Interaction Service. + /// + public InteractionService InteractionService { get; set; } + + /// + /// Defer a Rest based Discord Interaction using the delegate. + /// + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The request options for this response. + /// + /// A Task representing the operation of creating the interaction response. + /// + /// Thrown if the interaction isn't a type of . + protected override async Task DeferAsync(bool ephemeral = false, RequestOptions options = null) + { + if (Context.Interaction is not RestInteraction restInteraction) + throw new InvalidOperationException($"Invalid interaction type. Interaction must be a type of {nameof(RestInteraction)} in order to execute this method"); + + await InteractionService._restResponseCallback(Context, restInteraction.Defer(ephemeral, options)).ConfigureAwait(false); + } + + /// + /// Respond to a Rest based Discord Interaction using the delegate. + /// + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A Task representing the operation of creating the interaction response. + /// + /// Thrown if the interaction isn't a type of . + protected override async Task RespondAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null) + { + if (Context.Interaction is not RestInteraction restInteraction) + throw new InvalidOperationException($"Invalid interaction type. Interaction must be a type of {nameof(RestInteraction)} in order to execute this method"); + + await InteractionService._restResponseCallback(Context, restInteraction.Respond(text, embeds, isTTS, ephemeral, allowedMentions, component, embed, options)).ConfigureAwait(false); + } + } +} diff --git a/src/Discord.Net.Interactions/Results/AutocompletionResult.cs b/src/Discord.Net.Interactions/Results/AutocompletionResult.cs new file mode 100644 index 0000000000..2dc9f93bee --- /dev/null +++ b/src/Discord.Net.Interactions/Results/AutocompletionResult.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Interactions +{ + /// + /// Contains the information of a Autocomplete Interaction result. + /// + public struct AutocompletionResult : IResult + { + /// + public InteractionCommandError? Error { get; } + + /// + public string ErrorReason { get; } + + /// + public bool IsSuccess => Error is null; + + /// + /// Get the collection of Autocomplete suggestions to be displayed to the user. + /// + public IReadOnlyCollection Suggestions { get; } + + private AutocompletionResult(IEnumerable suggestions, InteractionCommandError? error, string reason) + { + Suggestions = suggestions?.ToImmutableArray(); + Error = error; + ErrorReason = reason; + } + + /// + /// Initializes a new with no error and without any indicating the command service shouldn't + /// return any suggestions. + /// + /// + /// A that does not contain any errors. + /// + public static AutocompletionResult FromSuccess() => + new AutocompletionResult(null, null, null); + + /// + /// Initializes a new with no error. + /// + /// Autocomplete suggestions to be displayed to the user + /// + /// A that does not contain any errors. + /// + public static AutocompletionResult FromSuccess(IEnumerable suggestions) => + new AutocompletionResult(suggestions, null, null); + + /// + /// Initializes a new with a specified result; this may or may not be an + /// successful execution depending on the and + /// specified. + /// + /// The result to inherit from. + /// + /// A that inherits the error type and reason. + /// + public static AutocompletionResult FromError(IResult result) => + new AutocompletionResult(null, result.Error, result.ErrorReason); + + /// + /// Initializes a new with a specified exception, indicating an unsuccessful + /// execution. + /// + /// The exception that caused the autocomplete process to fail. + /// + /// A that contains the exception that caused the unsuccessful execution, along + /// with a of type as well as the exception message as the + /// reason. + /// + public static AutocompletionResult FromError(Exception exception) => + new AutocompletionResult(null, InteractionCommandError.Exception, exception.Message); + + /// + /// Initializes a new with a specified and its + /// reason, indicating an unsuccessful execution. + /// + /// The type of error. + /// The reason behind the error. + /// + /// A that contains a and reason. + /// + public static AutocompletionResult FromError(InteractionCommandError error, string reason) => + new AutocompletionResult(null, error, reason); + + /// + /// Gets a string that indicates the autocompletion result. + /// + /// + /// Success if is true; otherwise ": + /// ". + /// + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Interactions/Results/ExecuteResult.cs b/src/Discord.Net.Interactions/Results/ExecuteResult.cs new file mode 100644 index 0000000000..ad2ccd1d35 --- /dev/null +++ b/src/Discord.Net.Interactions/Results/ExecuteResult.cs @@ -0,0 +1,86 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Contains information of the command's overall execution result. + /// + public struct ExecuteResult : IResult + { + /// + /// Gets the exception that may have occurred during the command execution. + /// + public Exception Exception { get; } + + /// + public InteractionCommandError? Error { get; } + + /// + public string ErrorReason { get; } + + /// + public bool IsSuccess => !Error.HasValue; + + private ExecuteResult (Exception exception, InteractionCommandError? commandError, string errorReason) + { + Exception = exception; + Error = commandError; + ErrorReason = errorReason; + } + + /// + /// Initializes a new with no error, indicating a successful execution. + /// + /// + /// A that does not contain any errors. + /// + public static ExecuteResult FromSuccess ( ) => + new ExecuteResult(null, null, null); + + /// + /// Initializes a new with a specified and its + /// reason, indicating an unsuccessful execution. + /// + /// The type of error. + /// The reason behind the error. + /// + /// A that contains a and reason. + /// + public static ExecuteResult FromError (InteractionCommandError commandError, string reason) => + new ExecuteResult(null, commandError, reason); + + /// + /// Initializes a new with a specified exception, indicating an unsuccessful + /// execution. + /// + /// The exception that caused the command execution to fail. + /// + /// A that contains the exception that caused the unsuccessful execution, along + /// with a of type Exception as well as the exception message as the + /// reason. + /// + public static ExecuteResult FromError (Exception exception) => + new ExecuteResult(exception, InteractionCommandError.Exception, exception.Message); + + /// + /// Initializes a new with a specified result; this may or may not be an + /// successful execution depending on the and + /// specified. + /// + /// The result to inherit from. + /// + /// A that inherits the error type and reason. + /// + public static ExecuteResult FromError (IResult result) => + new ExecuteResult(null, result.Error, result.ErrorReason); + + /// + /// Gets a string that indicates the execution result. + /// + /// + /// Success if is ; otherwise ": + /// ". + /// + public override string ToString ( ) => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Interactions/Results/IResult.cs b/src/Discord.Net.Interactions/Results/IResult.cs new file mode 100644 index 0000000000..1b6089c3a5 --- /dev/null +++ b/src/Discord.Net.Interactions/Results/IResult.cs @@ -0,0 +1,33 @@ +namespace Discord.Interactions +{ + /// + /// Contains information of the result related to a command. + /// + public interface IResult + { + /// + /// Gets the error type that may have occurred during the operation. + /// + /// + /// A indicating the type of error that may have occurred during the operation; + /// if the operation was successful. + /// + InteractionCommandError? Error { get; } + + /// + /// Gets the reason for the error. + /// + /// + /// A string containing the error reason. + /// + string ErrorReason { get; } + + /// + /// Indicates whether the operation was successful or not. + /// + /// + /// if the result is positive; otherwise . + /// + bool IsSuccess { get; } + } +} diff --git a/src/Discord.Net.Interactions/Results/PreconditionGroupResult.cs b/src/Discord.Net.Interactions/Results/PreconditionGroupResult.cs new file mode 100644 index 0000000000..40acb6099f --- /dev/null +++ b/src/Discord.Net.Interactions/Results/PreconditionGroupResult.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Interactions +{ + /// + /// Represents a result type for grouped command preconditions. + /// + public class PreconditionGroupResult : PreconditionResult + { + /// + /// Gets the results of the preconditions of this group. + /// + public IReadOnlyCollection Results { get; } + + private PreconditionGroupResult (InteractionCommandError? error, string reason, IEnumerable results) : base(error, reason) + { + Results = results?.ToImmutableArray(); + } + + /// + /// Returns a with no errors. + /// + public static new PreconditionGroupResult FromSuccess ( ) => + new PreconditionGroupResult(null, null, null); + + /// + /// Returns a with and the . + /// + /// The exception that caused the precondition check to fail. + public static new PreconditionGroupResult FromError (Exception exception) => + new PreconditionGroupResult(InteractionCommandError.Exception, exception.Message, null); + + /// + /// Returns a with the specified type. + /// + /// The result of failure. + public static new PreconditionGroupResult FromError (IResult result) => + new PreconditionGroupResult(result.Error, result.ErrorReason, null); + + /// + /// Returns a with and the + /// specified reason. + /// + /// The reason of failure. + /// Precondition results of this group + public static PreconditionGroupResult FromError (string reason, IEnumerable results) => + new PreconditionGroupResult(InteractionCommandError.UnmetPrecondition, reason, results); + } +} diff --git a/src/Discord.Net.Interactions/Results/PreconditionResult.cs b/src/Discord.Net.Interactions/Results/PreconditionResult.cs new file mode 100644 index 0000000000..3d80f976c4 --- /dev/null +++ b/src/Discord.Net.Interactions/Results/PreconditionResult.cs @@ -0,0 +1,59 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Represents a result type for command preconditions. + /// + public class PreconditionResult : IResult + { + /// + public InteractionCommandError? Error { get; } + + /// + public string ErrorReason { get; } + + /// + public bool IsSuccess => Error == null; + + /// + /// Initializes a new class with the command type + /// and reason. + /// + /// The type of failure. + /// The reason of failure. + protected PreconditionResult (InteractionCommandError? error, string reason) + { + Error = error; + ErrorReason = reason; + } + + /// + /// Returns a with no errors. + /// + public static PreconditionResult FromSuccess ( ) => + new PreconditionResult(null, null); + + /// + /// Returns a with and the . + /// + /// The exception that caused the precondition check to fail. + public static PreconditionResult FromError (Exception exception) => + new PreconditionResult(InteractionCommandError.Exception, exception.Message); + + /// + /// Returns a with the specified type. + /// + /// The result of failure. + public static PreconditionResult FromError (IResult result) => + new PreconditionResult(result.Error, result.ErrorReason); + + /// + /// Returns a with and the + /// specified reason. + /// + /// The reason of failure. + public static PreconditionResult FromError (string reason) => + new PreconditionResult(InteractionCommandError.UnmetPrecondition, reason); + } +} diff --git a/src/Discord.Net.Interactions/Results/RuntimeResult.cs b/src/Discord.Net.Interactions/Results/RuntimeResult.cs new file mode 100644 index 0000000000..a29b29252c --- /dev/null +++ b/src/Discord.Net.Interactions/Results/RuntimeResult.cs @@ -0,0 +1,37 @@ +namespace Discord.Interactions +{ + /// + /// Represents the base class for creating command result containers. + /// + public abstract class RuntimeResult : IResult + { + /// + public InteractionCommandError? Error { get; } + + /// + public string ErrorReason { get; } + + /// + public bool IsSuccess => !Error.HasValue; + + /// + /// Initializes a new class with the type of error and reason. + /// + /// The type of failure, or null if none. + /// The reason of failure. + protected RuntimeResult (InteractionCommandError? error, string reason) + { + Error = error; + ErrorReason = reason; + } + + /// + /// Gets a string that indicates the runtime result. + /// + /// + /// Success if is true; otherwise ": + /// ". + /// + public override string ToString ( ) => ErrorReason ?? ( IsSuccess ? "Successful" : "Unsuccessful" ); + } +} diff --git a/src/Discord.Net.Interactions/Results/SearchResult.cs b/src/Discord.Net.Interactions/Results/SearchResult.cs new file mode 100644 index 0000000000..874e57d453 --- /dev/null +++ b/src/Discord.Net.Interactions/Results/SearchResult.cs @@ -0,0 +1,37 @@ +using System; + +namespace Discord.Interactions +{ + internal struct SearchResult : IResult where T : class, ICommandInfo + { + public string Text { get; } + public T Command { get; } + public string[] RegexCaptureGroups { get; } + public InteractionCommandError? Error { get; } + + public string ErrorReason { get; } + + public bool IsSuccess => !Error.HasValue; + + private SearchResult (string text, T commandInfo, string[] captureGroups, InteractionCommandError? error, string reason) + { + Text = text; + Error = error; + RegexCaptureGroups = captureGroups; + Command = commandInfo; + ErrorReason = reason; + } + + public static SearchResult FromSuccess (string text, T commandInfo, string[] wildCardMatch = null) => + new SearchResult(text, commandInfo, wildCardMatch, null, null); + + public static SearchResult FromError (string text, InteractionCommandError error, string reason) => + new SearchResult(text, null, null, error, reason); + public static SearchResult FromError (Exception ex) => + new SearchResult(null, null, null, InteractionCommandError.Exception, ex.Message); + public static SearchResult FromError (IResult result) => + new SearchResult(null, null, null, result.Error, result.ErrorReason); + + public override string ToString ( ) => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Interactions/Results/TypeConverterResult.cs b/src/Discord.Net.Interactions/Results/TypeConverterResult.cs new file mode 100644 index 0000000000..bd89bf6b7b --- /dev/null +++ b/src/Discord.Net.Interactions/Results/TypeConverterResult.cs @@ -0,0 +1,61 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Represents a result type for . + /// + public struct TypeConverterResult : IResult + { + /// + /// Gets the result of the convertion if the operation was successful. + /// + public object Value { get; } + + /// + public InteractionCommandError? Error { get; } + + /// + public string ErrorReason { get; } + + /// + public bool IsSuccess => !Error.HasValue; + + private TypeConverterResult (object value, InteractionCommandError? error, string reason) + { + Value = value; + Error = error; + ErrorReason = reason; + } + + /// + /// Returns a with no errors. + /// + public static TypeConverterResult FromSuccess (object value) => + new TypeConverterResult(value, null, null); + + /// + /// Returns a with and the . + /// + /// The exception that caused the type convertion to fail. + public static TypeConverterResult FromError (Exception exception) => + new TypeConverterResult(null, InteractionCommandError.Exception, exception.Message); + + /// + /// Returns a with the specified error and the reason. + /// + /// The type of error. + /// The reason of failure. + public static TypeConverterResult FromError (InteractionCommandError error, string reason) => + new TypeConverterResult(null, error, reason); + + /// + /// Returns a with the specified type. + /// + /// The result of failure. + public static TypeConverterResult FromError (IResult result) => + new TypeConverterResult(null, result.Error, result.ErrorReason); + + public override string ToString ( ) => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Interactions/RunMode.cs b/src/Discord.Net.Interactions/RunMode.cs new file mode 100644 index 0000000000..1577da8ad3 --- /dev/null +++ b/src/Discord.Net.Interactions/RunMode.cs @@ -0,0 +1,22 @@ +namespace Discord.Interactions +{ + /// + /// Specifies the behavior of the command execution workflow. + /// + /// + public enum RunMode + { + /// + /// Executes the command on the same thread as gateway one. + /// + Sync, + /// + /// Executes the command on a different thread from the gateway one. + /// + Async, + /// + /// The default behaviour set in . + /// + Default + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/DefaultEntityTypeConverter.cs b/src/Discord.Net.Interactions/TypeConverters/DefaultEntityTypeConverter.cs new file mode 100644 index 0000000000..943ad39b71 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/DefaultEntityTypeConverter.cs @@ -0,0 +1,83 @@ +using Discord.WebSocket; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal abstract class DefaultEntityTypeConverter : TypeConverter where T : class + { + public override Task ReadAsync (IInteractionContext context, IApplicationCommandInteractionDataOption option, IServiceProvider services) + { + var value = option.Value as T; + + if (value is not null) + return Task.FromResult(TypeConverterResult.FromSuccess(option.Value as T)); + else + return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"Provided input cannot be read as {nameof(IChannel)}")); + } + } + + internal class DefaultRoleConverter : DefaultEntityTypeConverter where T : class, IRole + { + public override ApplicationCommandOptionType GetDiscordType ( ) => ApplicationCommandOptionType.Role; + } + + internal class DefaultUserConverter : DefaultEntityTypeConverter where T : class, IUser + { + public override ApplicationCommandOptionType GetDiscordType ( ) => ApplicationCommandOptionType.User; + } + + internal class DefaultChannelConverter : DefaultEntityTypeConverter where T : class, IChannel + { + private readonly List _channelTypes; + + public DefaultChannelConverter ( ) + { + var type = typeof(T); + + _channelTypes = true switch + { + _ when typeof(IStageChannel).IsAssignableFrom(type) + => new List { ChannelType.Stage }, + + _ when typeof(IVoiceChannel).IsAssignableFrom(type) + => new List { ChannelType.Voice }, + + _ when typeof(IDMChannel).IsAssignableFrom(type) + => new List { ChannelType.DM }, + + _ when typeof(IGroupChannel).IsAssignableFrom(type) + => new List { ChannelType.Group }, + + _ when typeof(ICategoryChannel).IsAssignableFrom(type) + => new List { ChannelType.Category }, + + _ when typeof(INewsChannel).IsAssignableFrom(type) + => new List { ChannelType.News }, + + _ when typeof(IThreadChannel).IsAssignableFrom(type) + => new List { ChannelType.PublicThread, ChannelType.PrivateThread, ChannelType.NewsThread }, + + _ when typeof(ITextChannel).IsAssignableFrom(type) + => new List { ChannelType.Text }, + + _ => null + }; + } + + public override ApplicationCommandOptionType GetDiscordType ( ) => ApplicationCommandOptionType.Channel; + + public override void Write (ApplicationCommandOptionProperties properties, IParameterInfo parameter) + { + properties.ChannelTypes = _channelTypes; + } + } + + internal class DefaultMentionableConverter : DefaultEntityTypeConverter where T : class, IMentionable + { + public override ApplicationCommandOptionType GetDiscordType ( ) => ApplicationCommandOptionType.Mentionable; + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/DefaultValueConverter.cs b/src/Discord.Net.Interactions/TypeConverters/DefaultValueConverter.cs new file mode 100644 index 0000000000..8e049ea7a5 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/DefaultValueConverter.cs @@ -0,0 +1,61 @@ +using Discord.WebSocket; +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal class DefaultValueConverter : TypeConverter where T : IConvertible + { + public override ApplicationCommandOptionType GetDiscordType ( ) + { + switch (Type.GetTypeCode(typeof(T))) + { + case TypeCode.Boolean: + return ApplicationCommandOptionType.Boolean; + + case TypeCode.DateTime: + case TypeCode.SByte: + case TypeCode.Byte: + case TypeCode.Char: + case TypeCode.String: + case TypeCode.Single: + return ApplicationCommandOptionType.String; + + case TypeCode.Int16: + case TypeCode.Int32: + case TypeCode.Int64: + case TypeCode.UInt16: + case TypeCode.UInt32: + case TypeCode.UInt64: + return ApplicationCommandOptionType.Integer; + + case TypeCode.Decimal: + case TypeCode.Double: + return ApplicationCommandOptionType.Number; + + case TypeCode.DBNull: + default: + throw new InvalidOperationException($"Parameter Type {typeof(T).FullName} is not supported by Discord."); + } + } + public override Task ReadAsync (IInteractionContext context, IApplicationCommandInteractionDataOption option, IServiceProvider services) + { + object value; + + if (option.Value is Optional optional) + value = optional.IsSpecified ? optional.Value : default(T); + else + value = option.Value; + + try + { + var converted = Convert.ChangeType(value, typeof(T)); + return Task.FromResult(TypeConverterResult.FromSuccess(converted)); + } + catch (InvalidCastException castEx) + { + return Task.FromResult(TypeConverterResult.FromError(castEx)); + } + } + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/EnumConverter.cs b/src/Discord.Net.Interactions/TypeConverters/EnumConverter.cs new file mode 100644 index 0000000000..a06c70ec4a --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/EnumConverter.cs @@ -0,0 +1,49 @@ +using Discord.WebSocket; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal sealed class EnumConverter : TypeConverter where T : struct, Enum + { + public override ApplicationCommandOptionType GetDiscordType ( ) => ApplicationCommandOptionType.String; + public override Task ReadAsync (IInteractionContext context, IApplicationCommandInteractionDataOption option, IServiceProvider services) + { + if (Enum.TryParse((string)option.Value, out var result)) + return Task.FromResult(TypeConverterResult.FromSuccess(result)); + else + return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"Value {option.Value} cannot be converted to {nameof(T)}")); + } + + public override void Write (ApplicationCommandOptionProperties properties, IParameterInfo parameterInfo) + { + var names = Enum.GetNames(typeof(T)); + var members = names.SelectMany(x => typeof(T).GetMember(x)).Where(x => !x.IsDefined(typeof(HideAttribute), true)); + + if (members.Count() <= 25) + { + var choices = new List(); + + foreach (var member in members) + choices.Add(new ApplicationCommandOptionChoiceProperties + { + Name = member.Name, + Value = member.Name + }); + + properties.Choices = choices; + } + } + } + + /// + /// Enum values tagged with this attribute will not be displayed as a parameter choice + /// + /// + /// This attributer must be used along with the default + /// + [AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = true)] + public sealed class HideAttribute : Attribute { } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/TimeSpanConverter.cs b/src/Discord.Net.Interactions/TypeConverters/TimeSpanConverter.cs new file mode 100644 index 0000000000..9a5274ff9b --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/TimeSpanConverter.cs @@ -0,0 +1,36 @@ +using Discord.WebSocket; +using System; +using System.Globalization; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal sealed class TimeSpanConverter : TypeConverter + { + public override ApplicationCommandOptionType GetDiscordType ( ) => ApplicationCommandOptionType.String; + public override Task ReadAsync (IInteractionContext context, IApplicationCommandInteractionDataOption option, IServiceProvider services) + { + return ( TimeSpan.TryParseExact(( option.Value as string ).ToLowerInvariant(), Formats, CultureInfo.InvariantCulture, out var timeSpan) ) + ? Task.FromResult(TypeConverterResult.FromSuccess(timeSpan)) + : Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, "Failed to parse TimeSpan")); + } + + private static readonly string[] Formats = { + "%d'd'%h'h'%m'm'%s's'", //4d3h2m1s + "%d'd'%h'h'%m'm'", //4d3h2m + "%d'd'%h'h'%s's'", //4d3h 1s + "%d'd'%h'h'", //4d3h + "%d'd'%m'm'%s's'", //4d 2m1s + "%d'd'%m'm'", //4d 2m + "%d'd'%s's'", //4d 1s + "%d'd'", //4d + "%h'h'%m'm'%s's'", // 3h2m1s + "%h'h'%m'm'", // 3h2m + "%h'h'%s's'", // 3h 1s + "%h'h'", // 3h + "%m'm'%s's'", // 2m1s + "%m'm'", // 2m + "%s's'", // 1s + }; + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/TypeConverter.cs b/src/Discord.Net.Interactions/TypeConverters/TypeConverter.cs new file mode 100644 index 0000000000..360b6ce4a7 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/TypeConverter.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Base class for creating TypeConverters. uses TypeConverters to interface with Slash Command parameters. + /// + public abstract class TypeConverter + { + /// + /// Will be used to search for alternative TypeConverters whenever the Command Service encounters an unknown parameter type. + /// + /// + /// + public abstract bool CanConvertTo(Type type); + + /// + /// Will be used to get the Application Command Option type. + /// + /// The option type. + public abstract ApplicationCommandOptionType GetDiscordType(); + + /// + /// Will be used to read the incoming payload before executing the method body. + /// + /// Command exexution context. + /// Recieved option payload. + /// Service provider that will be used to initialize the command module. + /// The result of the read process. + public abstract Task ReadAsync(IInteractionContext context, IApplicationCommandInteractionDataOption option, IServiceProvider services); + + /// + /// Will be used to manipulate the outgoing command option, before the command gets registered to Discord. + /// + public virtual void Write(ApplicationCommandOptionProperties properties, IParameterInfo parameter) { } + } + + /// + public abstract class TypeConverter : TypeConverter + { + /// + public sealed override bool CanConvertTo(Type type) => + typeof(T).IsAssignableFrom(type); + } +} diff --git a/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs b/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs new file mode 100644 index 0000000000..ad1f7d646c --- /dev/null +++ b/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.Interactions +{ + internal static class ApplicationCommandRestUtil + { + #region Parameters + public static ApplicationCommandOptionProperties ToApplicationCommandOptionProps(this SlashCommandParameterInfo parameterInfo) + { + var props = new ApplicationCommandOptionProperties + { + Name = parameterInfo.Name, + Description = parameterInfo.Description, + Type = parameterInfo.DiscordOptionType, + IsRequired = parameterInfo.IsRequired, + Choices = parameterInfo.Choices?.Select(x => new ApplicationCommandOptionChoiceProperties + { + Name = x.Name, + Value = x.Value + })?.ToList(), + ChannelTypes = parameterInfo.ChannelTypes?.ToList(), + IsAutocomplete = parameterInfo.IsAutocomplete, + MaxValue = parameterInfo.MaxValue, + MinValue = parameterInfo.MinValue + }; + + parameterInfo.TypeConverter.Write(props, parameterInfo); + + return props; + } + #endregion + + #region Commands + + public static SlashCommandProperties ToApplicationCommandProps(this SlashCommandInfo commandInfo) + { + var props = new SlashCommandBuilder() + { + Name = commandInfo.Name, + Description = commandInfo.Description, + IsDefaultPermission = commandInfo.DefaultPermission, + }.Build(); + + if (commandInfo.Parameters.Count > SlashCommandBuilder.MaxOptionsCount) + throw new InvalidOperationException($"Slash Commands cannot have more than {SlashCommandBuilder.MaxOptionsCount} command parameters"); + + props.Options = commandInfo.Parameters.Select(x => x.ToApplicationCommandOptionProps())?.ToList() ?? Optional>.Unspecified; + + return props; + } + + public static ApplicationCommandOptionProperties ToApplicationCommandOptionProps(this SlashCommandInfo commandInfo) => + new ApplicationCommandOptionProperties + { + Name = commandInfo.Name, + Description = commandInfo.Description, + Type = ApplicationCommandOptionType.SubCommand, + IsRequired = false, + Options = commandInfo.Parameters?.Select(x => x.ToApplicationCommandOptionProps())?.ToList() + }; + + public static ApplicationCommandProperties ToApplicationCommandProps(this ContextCommandInfo commandInfo) + => commandInfo.CommandType switch + { + ApplicationCommandType.Message => new MessageCommandBuilder { Name = commandInfo.Name, IsDefaultPermission = commandInfo.DefaultPermission}.Build(), + ApplicationCommandType.User => new UserCommandBuilder { Name = commandInfo.Name, IsDefaultPermission=commandInfo.DefaultPermission}.Build(), + _ => throw new InvalidOperationException($"{commandInfo.CommandType} isn't a supported command type.") + }; + #endregion + + #region Modules + + public static IReadOnlyCollection ToApplicationCommandProps(this ModuleInfo moduleInfo, bool ignoreDontRegister = false) + { + var args = new List(); + + moduleInfo.ParseModuleModel(args, ignoreDontRegister); + return args; + } + + private static void ParseModuleModel(this ModuleInfo moduleInfo, List args, bool ignoreDontRegister) + { + if (moduleInfo.DontAutoRegister && !ignoreDontRegister) + return; + + args.AddRange(moduleInfo.ContextCommands?.Select(x => x.ToApplicationCommandProps())); + + if (!moduleInfo.IsSlashGroup) + { + args.AddRange(moduleInfo.SlashCommands?.Select(x => x.ToApplicationCommandProps())); + + foreach (var submodule in moduleInfo.SubModules) + submodule.ParseModuleModel(args, ignoreDontRegister); + } + else + { + var options = new List(); + + foreach (var command in moduleInfo.SlashCommands) + { + if (command.IgnoreGroupNames) + args.Add(command.ToApplicationCommandProps()); + else + options.Add(command.ToApplicationCommandOptionProps()); + } + + options.AddRange(moduleInfo.SubModules?.SelectMany(x => x.ParseSubModule(args, ignoreDontRegister))); + + var props = new SlashCommandBuilder + { + Name = moduleInfo.SlashGroupName.ToLower(), + Description = moduleInfo.Description, + IsDefaultPermission = moduleInfo.DefaultPermission, + }.Build(); + + if (options.Count > SlashCommandBuilder.MaxOptionsCount) + throw new InvalidOperationException($"Slash Commands cannot have more than {SlashCommandBuilder.MaxOptionsCount} command parameters"); + + props.Options = options; + + args.Add(props); + } + } + + private static IReadOnlyCollection ParseSubModule(this ModuleInfo moduleInfo, List args, + bool ignoreDontRegister) + { + if (moduleInfo.DontAutoRegister && !ignoreDontRegister) + return Array.Empty(); + + args.AddRange(moduleInfo.ContextCommands?.Select(x => x.ToApplicationCommandProps())); + + var options = new List(); + options.AddRange(moduleInfo.SubModules?.SelectMany(x => x.ParseSubModule(args, ignoreDontRegister))); + + foreach (var command in moduleInfo.SlashCommands) + { + if (command.IgnoreGroupNames) + args.Add(command.ToApplicationCommandProps()); + else + options.Add(command.ToApplicationCommandOptionProps()); + } + + if (!moduleInfo.IsSlashGroup) + return options; + else + return new List() { new ApplicationCommandOptionProperties + { + Name = moduleInfo.SlashGroupName.ToLower(), + Description = moduleInfo.Description, + Type = ApplicationCommandOptionType.SubCommandGroup, + Options = options + } }; + } + + #endregion + + public static ApplicationCommandProperties ToApplicationCommandProps(this IApplicationCommand command) + { + return command.Type switch + { + ApplicationCommandType.Slash => new SlashCommandProperties + { + Name = command.Name, + Description = command.Description, + IsDefaultPermission = command.IsDefaultPermission, + Options = command.Options?.Select(x => x.ToApplicationCommandOptionProps())?.ToList() ?? Optional>.Unspecified + }, + ApplicationCommandType.User => new UserCommandProperties + { + Name = command.Name, + IsDefaultPermission = command.IsDefaultPermission + }, + ApplicationCommandType.Message => new MessageCommandProperties + { + Name = command.Name, + IsDefaultPermission = command.IsDefaultPermission + }, + _ => throw new InvalidOperationException($"Cannot create command properties for command type {command.Type}"), + }; + } + + public static ApplicationCommandOptionProperties ToApplicationCommandOptionProps(this IApplicationCommandOption commandOption) => + new ApplicationCommandOptionProperties + { + Name = commandOption.Name, + Description = commandOption.Description, + Type = commandOption.Type, + IsRequired = commandOption.IsRequired, + Choices = commandOption.Choices?.Select(x => new ApplicationCommandOptionChoiceProperties + { + Name = x.Name, + Value = x.Value + }).ToList(), + Options = commandOption.Options?.Select(x => x.ToApplicationCommandOptionProps()).ToList() + }; + } +} diff --git a/src/Discord.Net.Interactions/Utilities/EmptyServiceProvider.cs b/src/Discord.Net.Interactions/Utilities/EmptyServiceProvider.cs new file mode 100644 index 0000000000..a53800946c --- /dev/null +++ b/src/Discord.Net.Interactions/Utilities/EmptyServiceProvider.cs @@ -0,0 +1,11 @@ +using System; + +namespace Discord.Interactions +{ + internal class EmptyServiceProvider : IServiceProvider + { + public static EmptyServiceProvider Instance => new EmptyServiceProvider(); + + public object GetService (Type serviceType) => null; + } +} diff --git a/src/Discord.Net.Interactions/Utilities/InteractionUtility.cs b/src/Discord.Net.Interactions/Utilities/InteractionUtility.cs new file mode 100644 index 0000000000..00062ced6f --- /dev/null +++ b/src/Discord.Net.Interactions/Utilities/InteractionUtility.cs @@ -0,0 +1,113 @@ +using Discord.WebSocket; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Utility class containing helper methods for interacting with Discord Interactions. + /// + public static class InteractionUtility + { + /// + /// Wait for an Interaction event for a given amount of time as an asynchronous opration. + /// + /// Client that should be listened to for the event. + /// Timeout duration for this operation. + /// Delegate for cheking whether an Interaction meets the requirements. + /// Token for canceling the wait operation. + /// + /// A Task representing the asyncronous waiting operation. If the user responded in the given amount of time, Task result contains the user response, + /// otherwise the Task result is . + /// + public static async Task WaitForInteractionAsync (BaseSocketClient client, TimeSpan timeout, + Predicate predicate, CancellationToken cancellationToken = default) + { + var tcs = new TaskCompletionSource(); + + var waitCancelSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task wait = Task.Delay(timeout, waitCancelSource.Token) + .ContinueWith((t) => + { + if (!t.IsCanceled) + tcs.SetResult(null); + }); + + cancellationToken.Register(( ) => tcs.SetCanceled()); + + client.InteractionCreated += HandleInteraction; + var result = await tcs.Task.ConfigureAwait(false); + client.InteractionCreated -= HandleInteraction; + + return result; + + Task HandleInteraction (SocketInteraction interaction) + { + if (predicate(interaction)) + { + waitCancelSource.Cancel(); + tcs.SetResult(interaction); + } + + return Task.CompletedTask; + } + } + + /// + /// Wait for an Message Component Interaction event for a given amount of time as an asynchronous opration . + /// + /// Client that should be listened to for the event. + /// The message that or should originate from. + /// Timeout duration for this operation. + /// Token for canceling the wait operation. + /// + /// A Task representing the asyncronous waiting operation with a result, + /// the result is null if the process timed out before receiving a valid Interaction. + /// + public static Task WaitForMessageComponentAsync(BaseSocketClient client, IUserMessage fromMessage, TimeSpan timeout, + CancellationToken cancellationToken = default) + { + bool Predicate (SocketInteraction interaction) => interaction is SocketMessageComponent component && + component.Message.Id == fromMessage.Id; + + return WaitForInteractionAsync(client, timeout, Predicate, cancellationToken); + } + + /// + /// Create a confirmation dialog and wait for user input asynchronously. + /// + /// Client that should be listened to for the event. + /// Send the confirmation prompt to this channel. + /// Timeout duration of this operation. + /// Optional custom prompt message. + /// Token for canceling the wait operation. + /// + /// A Task representing the asyncronous waiting operation with a result, + /// the result is if the user declined the prompt or didnt answer in time, if the user confirmed the prompt. + /// + public static async Task ConfirmAsync (BaseSocketClient client, IMessageChannel channel, TimeSpan timeout, string message = null, + CancellationToken cancellationToken = default) + { + message ??= "Would you like to continue?"; + var confirmId = $"confirm"; + var declineId = $"decline"; + + var component = new ComponentBuilder() + .WithButton("Confirm", confirmId, ButtonStyle.Success) + .WithButton("Cancel", declineId, ButtonStyle.Danger) + .Build(); + + var prompt = await channel.SendMessageAsync(message, component: component).ConfigureAwait(false); + + var response = await WaitForMessageComponentAsync(client, prompt, timeout, cancellationToken).ConfigureAwait(false) as SocketMessageComponent; + + await prompt.DeleteAsync().ConfigureAwait(false); + + if (response != null && response.Data.CustomId == confirmId) + return true; + else + return false; + } + } +} diff --git a/src/Discord.Net.Interactions/Utilities/ReflectionUtils.cs b/src/Discord.Net.Interactions/Utilities/ReflectionUtils.cs new file mode 100644 index 0000000000..b15662bfb7 --- /dev/null +++ b/src/Discord.Net.Interactions/Utilities/ReflectionUtils.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal static class ReflectionUtils + { + private static readonly TypeInfo ObjectTypeInfo = typeof(object).GetTypeInfo(); + + internal static T CreateObject (TypeInfo typeInfo, InteractionService commandService, IServiceProvider services = null) => + CreateBuilder(typeInfo, commandService)(services); + + internal static Func CreateBuilder (TypeInfo typeInfo, InteractionService commandService) + { + var constructor = GetConstructor(typeInfo); + var parameters = constructor.GetParameters(); + var properties = GetProperties(typeInfo); + + return (services) => + { + var args = new object[parameters.Length]; + for (int i = 0; i < parameters.Length; i++) + args[i] = GetMember(commandService, services, parameters[i].ParameterType, typeInfo); + + var obj = InvokeConstructor(constructor, args, typeInfo); + foreach (var property in properties) + property.SetValue(obj, GetMember(commandService, services, property.PropertyType, typeInfo)); + return obj; + }; + } + + private static T InvokeConstructor (ConstructorInfo constructor, object[] args, TypeInfo ownerType) + { + try + { + return (T)constructor.Invoke(args); + } + catch (Exception ex) + { + throw new Exception($"Failed to create \"{ownerType.FullName}\".", ex); + } + } + private static ConstructorInfo GetConstructor (TypeInfo ownerType) + { + var constructors = ownerType.DeclaredConstructors.Where(x => !x.IsStatic).ToArray(); + if (constructors.Length == 0) + throw new InvalidOperationException($"No constructor found for \"{ownerType.FullName}\"."); + else if (constructors.Length > 1) + throw new InvalidOperationException($"Multiple constructors found for \"{ownerType.FullName}\"."); + return constructors[0]; + } + private static PropertyInfo[] GetProperties (TypeInfo ownerType) + { + var result = new List(); + while (ownerType != ObjectTypeInfo) + { + foreach (var prop in ownerType.DeclaredProperties) + { + if (prop.SetMethod?.IsStatic == false && prop.SetMethod?.IsPublic == true) + result.Add(prop); + } + ownerType = ownerType.BaseType.GetTypeInfo(); + } + return result.ToArray(); + } + private static object GetMember (InteractionService commandService, IServiceProvider services, Type memberType, TypeInfo ownerType) + { + if (memberType == typeof(InteractionService)) + return commandService; + if (memberType == typeof(IServiceProvider) || memberType == services.GetType()) + return services; + var service = services.GetService(memberType); + if (service != null) + return service; + throw new InvalidOperationException($"Failed to create \"{ownerType.FullName}\", dependency \"{memberType.Name}\" was not found."); + } + + internal static Func CreateMethodInvoker (MethodInfo methodInfo) + { + var parameters = methodInfo.GetParameters(); + var paramsExp = new Expression[parameters.Length]; + + var instanceExp = Expression.Parameter(typeof(T), "instance"); + var argsExp = Expression.Parameter(typeof(object[]), "args"); + + for (var i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + + var indexExp = Expression.Constant(i); + var accessExp = Expression.ArrayIndex(argsExp, indexExp); + paramsExp[i] = Expression.Convert(accessExp, parameter.ParameterType); + } + + var callExp = Expression.Call(Expression.Convert(instanceExp, methodInfo.ReflectedType), methodInfo, paramsExp); + var finalExp = Expression.Convert(callExp, typeof(Task)); + var lambda = Expression.Lambda>(finalExp, instanceExp, argsExp).Compile(); + + return lambda; + } + + /// + /// Create a type initializer using compiled lambda expressions + /// + internal static Func CreateLambdaBuilder (TypeInfo typeInfo, InteractionService commandService) + { + var constructor = GetConstructor(typeInfo); + var parameters = constructor.GetParameters(); + var properties = GetProperties(typeInfo); + + var argsExp = Expression.Parameter(typeof(object[]), "args"); + var propsExp = Expression.Parameter(typeof(object[]), "props"); + + var parameterExps = new Expression[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var indexExp = Expression.Constant(i); + var accessExp = Expression.ArrayIndex(argsExp, indexExp); + parameterExps[i] = Expression.Convert(accessExp, parameters[i].ParameterType); + } + + var newExp = Expression.New(constructor, parameterExps); + + var memberExps = new MemberAssignment[properties.Length]; + + for (var i = 0; i < properties.Length; i++) + { + var indexEx = Expression.Constant(i); + var accessExp = Expression.Convert(Expression.ArrayIndex(propsExp, indexEx), properties[i].PropertyType); + memberExps[i] = Expression.Bind(properties[i], accessExp); + } + var memberInit = Expression.MemberInit(newExp, memberExps); + var lambda = Expression.Lambda>(memberInit, argsExp, propsExp).Compile(); + + return (services) => + { + var args = new object[parameters.Length]; + var props = new object[properties.Length]; + + for (int i = 0; i < parameters.Length; i++) + args[i] = GetMember(commandService, services, parameters[i].ParameterType, typeInfo); + + for (int i = 0; i < properties.Length; i++) + props[i] = GetMember(commandService, services, properties[i].PropertyType, typeInfo); + + var instance = lambda(args, props); + + return instance; + }; + } + } +} diff --git a/src/Discord.Net.Interactions/Utilities/RegexUtils.cs b/src/Discord.Net.Interactions/Utilities/RegexUtils.cs new file mode 100644 index 0000000000..82ba944f80 --- /dev/null +++ b/src/Discord.Net.Interactions/Utilities/RegexUtils.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; + +namespace System.Text.RegularExpressions +{ + internal static class RegexUtils + { + internal const byte Q = 5; // quantifier + internal const byte S = 4; // ordinary stoppper + internal const byte Z = 3; // ScanBlank stopper + internal const byte X = 2; // whitespace + internal const byte E = 1; // should be escaped + + internal static readonly byte[] _category = new byte[] { + // 0 1 2 3 4 5 6 7 8 9 A B C D E F 0 1 2 3 4 5 6 7 8 9 A B C D E F + 0,0,0,0,0,0,0,0,0,X,X,0,X,X,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + // ! " # $ % & ' ( ) * + , - . / 0 1 2 3 4 5 6 7 8 9 : ; < = > ? + X,0,0,Z,S,0,0,0,S,S,Q,Q,0,0,S,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,Q, + // @ A B C D E F G H I J K L M N O P Q R S T U V W X Y Z [ \ ] ^ _ + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,S,S,0,S,0, + // ' a b c d e f g h i j k l m n o p q r s t u v w x y z { | } ~ + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,Q,S,0,0,0}; + + internal static string EscapeExcluding(string input, params char[] exclude) + { + if (exclude is null) + throw new ArgumentNullException("exclude"); + + for (int i = 0; i < input.Length; i++) + { + if (IsMetachar(input[i]) && !exclude.Contains(input[i])) + { + StringBuilder sb = new StringBuilder(); + char ch = input[i]; + int lastpos; + + sb.Append(input, 0, i); + do + { + sb.Append('\\'); + switch (ch) + { + case '\n': + ch = 'n'; + break; + case '\r': + ch = 'r'; + break; + case '\t': + ch = 't'; + break; + case '\f': + ch = 'f'; + break; + } + sb.Append(ch); + i++; + lastpos = i; + + while (i < input.Length) + { + ch = input[i]; + if (IsMetachar(ch) && !exclude.Contains(input[i])) + break; + + i++; + } + + sb.Append(input, lastpos, i - lastpos); + + } while (i < input.Length); + + return sb.ToString(); + } + } + + return input; + } + + internal static bool IsMetachar(char ch) + { + return (ch <= '|' && _category[ch] >= E); + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateWebhookMessageParams.cs b/src/Discord.Net.Rest/API/Rest/CreateWebhookMessageParams.cs index bda0f7ff1e..ef0e0dd1da 100644 --- a/src/Discord.Net.Rest/API/Rest/CreateWebhookMessageParams.cs +++ b/src/Discord.Net.Rest/API/Rest/CreateWebhookMessageParams.cs @@ -13,7 +13,7 @@ internal class CreateWebhookMessageParams private static JsonSerializer _serializer = new JsonSerializer { ContractResolver = new DiscordContractResolver() }; [JsonProperty("content")] - public string Content { get; set; } + public Optional Content { get; set; } [JsonProperty("nonce")] public Optional Nonce { get; set; } diff --git a/src/Discord.Net.Rest/API/Rest/UploadWebhookFileParams.cs b/src/Discord.Net.Rest/API/Rest/UploadWebhookFileParams.cs index 3d09ad1456..1a25e47826 100644 --- a/src/Discord.Net.Rest/API/Rest/UploadWebhookFileParams.cs +++ b/src/Discord.Net.Rest/API/Rest/UploadWebhookFileParams.cs @@ -21,6 +21,7 @@ internal class UploadWebhookFileParams public Optional Embeds { get; set; } public Optional AllowedMentions { get; set; } public Optional MessageComponents { get; set; } + public Optional Flags { get; set; } public UploadWebhookFileParams(params FileAttachment[] files) { @@ -48,6 +49,8 @@ public IReadOnlyDictionary ToDictionary() payload["embeds"] = Embeds.Value; if (AllowedMentions.IsSpecified) payload["allowed_mentions"] = AllowedMentions.Value; + if (Flags.IsSpecified) + payload["flags"] = Flags.Value; List attachments = new(); diff --git a/src/Discord.Net.Rest/AssemblyInfo.cs b/src/Discord.Net.Rest/AssemblyInfo.cs index 5c9351d641..837fd1d044 100644 --- a/src/Discord.Net.Rest/AssemblyInfo.cs +++ b/src/Discord.Net.Rest/AssemblyInfo.cs @@ -6,6 +6,7 @@ [assembly: InternalsVisibleTo("Discord.Net.Commands")] [assembly: InternalsVisibleTo("Discord.Net.Tests")] [assembly: InternalsVisibleTo("Discord.Net.Tests.Unit")] +[assembly: InternalsVisibleTo("Discord.Net.Interactions")] [assembly: TypeForwardedTo(typeof(Discord.Embed))] [assembly: TypeForwardedTo(typeof(Discord.EmbedBuilder))] diff --git a/src/Discord.Net.Rest/Discord.Net.Rest.csproj b/src/Discord.Net.Rest/Discord.Net.Rest.csproj index 8407abfd68..a1c6892cad 100644 --- a/src/Discord.Net.Rest/Discord.Net.Rest.csproj +++ b/src/Discord.Net.Rest/Discord.Net.Rest.csproj @@ -5,8 +5,8 @@ Discord.Net.Rest Discord.Rest A core Discord.Net library containing the REST client and models. - net461;netstandard2.0;netstandard2.1 - netstandard2.0;netstandard2.1 + net6.0;net5.0;net461;netstandard2.0;netstandard2.1 + net6.0;net5.0;netstandard2.0;netstandard2.1 diff --git a/src/Discord.Net.Rest/DiscordRestApiClient.cs b/src/Discord.Net.Rest/DiscordRestApiClient.cs index abe059c64b..67e3107ed9 100644 --- a/src/Discord.Net.Rest/DiscordRestApiClient.cs +++ b/src/Discord.Net.Rest/DiscordRestApiClient.cs @@ -786,8 +786,9 @@ public async Task CreateWebhookMessageAsync(ulong webhookId, CreateWebh if (!args.Embeds.IsSpecified || args.Embeds.Value == null || args.Embeds.Value.Length == 0) Preconditions.NotNullOrEmpty(args.Content, nameof(args.Content)); - if (args.Content?.Length > DiscordConfig.MaxMessageSize) + if (args.Content.IsSpecified && args.Content.Value?.Length > DiscordConfig.MaxMessageSize) throw new ArgumentException(message: $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}.", paramName: nameof(args.Content)); + options = RequestOptions.CreateOrClone(options); var ids = new BucketIds(webhookId: webhookId); @@ -1198,25 +1199,25 @@ public async Task CreateGlobalApplicationCommandAsync(Create options = RequestOptions.CreateOrClone(options); - return await TrySendApplicationCommandAsync(SendJsonAsync("POST", () => $"applications/{CurrentUserId}/commands", command, new BucketIds(), options: options)).ConfigureAwait(false); + return await SendJsonAsync("POST", () => $"applications/{CurrentUserId}/commands", command, new BucketIds(), options: options).ConfigureAwait(false); } public async Task ModifyGlobalApplicationCommandAsync(ModifyApplicationCommandParams command, ulong commandId, RequestOptions options = null) { options = RequestOptions.CreateOrClone(options); - return await TrySendApplicationCommandAsync(SendJsonAsync("PATCH", () => $"applications/{CurrentUserId}/commands/{commandId}", command, new BucketIds(), options: options)).ConfigureAwait(false); + return await SendJsonAsync("PATCH", () => $"applications/{CurrentUserId}/commands/{commandId}", command, new BucketIds(), options: options).ConfigureAwait(false); } public async Task ModifyGlobalApplicationUserCommandAsync(ModifyApplicationCommandParams command, ulong commandId, RequestOptions options = null) { options = RequestOptions.CreateOrClone(options); - return await TrySendApplicationCommandAsync(SendJsonAsync("PATCH", () => $"applications/{CurrentUserId}/commands/{commandId}", command, new BucketIds(), options: options)).ConfigureAwait(false); + return await SendJsonAsync("PATCH", () => $"applications/{CurrentUserId}/commands/{commandId}", command, new BucketIds(), options: options).ConfigureAwait(false); } public async Task ModifyGlobalApplicationMessageCommandAsync(ModifyApplicationCommandParams command, ulong commandId, RequestOptions options = null) { options = RequestOptions.CreateOrClone(options); - return await TrySendApplicationCommandAsync(SendJsonAsync("PATCH", () => $"applications/{CurrentUserId}/commands/{commandId}", command, new BucketIds(), options: options)).ConfigureAwait(false); + return await SendJsonAsync("PATCH", () => $"applications/{CurrentUserId}/commands/{commandId}", command, new BucketIds(), options: options).ConfigureAwait(false); } public async Task DeleteGlobalApplicationCommandAsync(ulong commandId, RequestOptions options = null) { @@ -1229,7 +1230,7 @@ public async Task BulkOverwriteGlobalApplicationCommandsAs { options = RequestOptions.CreateOrClone(options); - return await TrySendApplicationCommandAsync(SendJsonAsync("PUT", () => $"applications/{CurrentUserId}/commands", commands, new BucketIds(), options: options)).ConfigureAwait(false); + return await SendJsonAsync("PUT", () => $"applications/{CurrentUserId}/commands", commands, new BucketIds(), options: options).ConfigureAwait(false); } public async Task GetGuildApplicationCommandsAsync(ulong guildId, RequestOptions options = null) @@ -1271,7 +1272,7 @@ public async Task CreateGuildApplicationCommandAsync(CreateA var bucket = new BucketIds(guildId: guildId); - return await TrySendApplicationCommandAsync(SendJsonAsync("POST", () => $"applications/{CurrentUserId}/guilds/{guildId}/commands", command, bucket, options: options)).ConfigureAwait(false); + return await SendJsonAsync("POST", () => $"applications/{CurrentUserId}/guilds/{guildId}/commands", command, bucket, options: options).ConfigureAwait(false); } public async Task ModifyGuildApplicationCommandAsync(ModifyApplicationCommandParams command, ulong guildId, ulong commandId, RequestOptions options = null) { @@ -1279,7 +1280,7 @@ public async Task ModifyGuildApplicationCommandAsync(ModifyA var bucket = new BucketIds(guildId: guildId); - return await TrySendApplicationCommandAsync(SendJsonAsync("PATCH", () => $"applications/{CurrentUserId}/guilds/{guildId}/commands/{commandId}", command, bucket, options: options)).ConfigureAwait(false); + return await SendJsonAsync("PATCH", () => $"applications/{CurrentUserId}/guilds/{guildId}/commands/{commandId}", command, bucket, options: options).ConfigureAwait(false); } public async Task DeleteGuildApplicationCommandAsync(ulong guildId, ulong commandId, RequestOptions options = null) { @@ -1296,7 +1297,7 @@ public async Task BulkOverwriteGuildApplicationCommandsAsy var bucket = new BucketIds(guildId: guildId); - return await TrySendApplicationCommandAsync(SendJsonAsync("PUT", () => $"applications/{CurrentUserId}/guilds/{guildId}/commands", commands, bucket, options: options)).ConfigureAwait(false); + return await SendJsonAsync("PUT", () => $"applications/{CurrentUserId}/guilds/{guildId}/commands", commands, bucket, options: options).ConfigureAwait(false); } #endregion @@ -1316,7 +1317,7 @@ public async Task GetInteractionResponseAsync(string interactionToken, options = RequestOptions.CreateOrClone(options); - return await SendAsync("GET", () => $"webhooks/{CurrentUserId}/{interactionToken}/messages/@original", new BucketIds(), options: options).ConfigureAwait(false); + return await NullifyNotFound(SendAsync("GET", () => $"webhooks/{CurrentUserId}/{interactionToken}/messages/@original", new BucketIds(), options: options)).ConfigureAwait(false); } public async Task ModifyInteractionResponseAsync(ModifyInteractionResponseParams args, string interactionToken, RequestOptions options = null) { @@ -1336,7 +1337,7 @@ public async Task CreateInteractionFollowupMessageAsync(CreateWebhookMe if ((!args.Embeds.IsSpecified || args.Embeds.Value == null || args.Embeds.Value.Length == 0) && !args.File.IsSpecified) Preconditions.NotNullOrEmpty(args.Content, nameof(args.Content)); - if (args.Content?.Length > DiscordConfig.MaxMessageSize) + if(args.Content.IsSpecified && args.Content.Value?.Length > DiscordConfig.MaxMessageSize) throw new ArgumentException(message: $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}.", paramName: nameof(args.Content)); options = RequestOptions.CreateOrClone(options); @@ -1347,6 +1348,21 @@ public async Task CreateInteractionFollowupMessageAsync(CreateWebhookMe return await SendMultipartAsync("POST", () => $"webhooks/{CurrentUserId}/{token}?wait=true", args.ToDictionary(), new BucketIds(), options: options).ConfigureAwait(false); } + public async Task CreateInteractionFollowupMessageAsync(UploadWebhookFileParams args, string token, RequestOptions options = null) + { + if ((!args.Embeds.IsSpecified || args.Embeds.Value == null || args.Embeds.Value.Length == 0) && !args.Files.Any()) + Preconditions.NotNullOrEmpty(args.Content, nameof(args.Content)); + + + if (args.Content.IsSpecified && args.Content.Value.Length > DiscordConfig.MaxMessageSize) + throw new ArgumentException(message: $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}.", paramName: nameof(args.Content)); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(); + return await SendMultipartAsync("POST", () => $"webhooks/{CurrentUserId}/{token}?wait=true", args.ToDictionary(), ids, clientBucket: ClientBucketType.SendEdit, options: options).ConfigureAwait(false); + } + public async Task ModifyInteractionFollowupMessageAsync(ModifyInteractionResponseParams args, ulong id, string token, RequestOptions options = null) { Preconditions.NotNull(args, nameof(args)); @@ -2222,40 +2238,6 @@ protected T DeserializeJson(Stream jsonStream) return _serializer.Deserialize(reader); } - protected async Task TrySendApplicationCommandAsync(Task sendTask) - { - try - { - var result = await sendTask.ConfigureAwait(false); - - if (sendTask.Exception != null) - { - if (sendTask.Exception.InnerException is HttpException x) - { - if (x.HttpCode == HttpStatusCode.BadRequest) - { - var json = (x.Request as JsonRestRequest).Json; - throw new ApplicationCommandException(x); - } - } - - throw sendTask.Exception; - } - else - return result; - } - catch (HttpException x) - { - if (x.HttpCode == HttpStatusCode.BadRequest) - { - var json = (x.Request as JsonRestRequest).Json; - throw new ApplicationCommandException(x); - } - - throw; - } - } - protected async Task NullifyNotFound(Task sendTask) where T : class { try diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs b/src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs index b3aaf582cc..edbb2bea83 100644 --- a/src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs +++ b/src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs @@ -52,6 +52,9 @@ private static readonly Dictionary + /// Contains a piece of audit log data related to a thread creation. + /// + public class ThreadCreateAuditLogData : IAuditLogData + { + private ThreadCreateAuditLogData(IThreadChannel thread, ulong id, string name, ThreadType type, bool archived, + ThreadArchiveDuration autoArchiveDuration, bool locked, int? rateLimit) + { + Thread = thread; + ThreadId = id; + ThreadName = name; + ThreadType = type; + IsArchived = archived; + AutoArchiveDuration = autoArchiveDuration; + IsLocked = locked; + SlowModeInterval = rateLimit; + } + + internal static ThreadCreateAuditLogData Create(BaseDiscordClient discord, Model log, EntryModel entry) + { + var changes = entry.Changes; + + var id = entry.TargetId.Value; + + var nameModel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "name"); + var typeModel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "type"); + + var archivedModel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "archived"); + var autoArchiveDurationModel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "auto_archive_duration"); + var lockedModel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "locked"); + var rateLimitPerUserModel = changes.FirstOrDefault(x => x.ChangedProperty == "rate_limit_per_user"); + + var name = nameModel.NewValue.ToObject(discord.ApiClient.Serializer); + var type = typeModel.NewValue.ToObject(discord.ApiClient.Serializer); + + var archived = archivedModel.NewValue.ToObject(discord.ApiClient.Serializer); + var autoArchiveDuration = autoArchiveDurationModel.NewValue.ToObject(discord.ApiClient.Serializer); + var locked = lockedModel.NewValue.ToObject(discord.ApiClient.Serializer); + var rateLimit = rateLimitPerUserModel?.NewValue?.ToObject(discord.ApiClient.Serializer); + + var threadInfo = log.Threads.FirstOrDefault(x => x.Id == id); + var threadChannel = threadInfo == null ? null : RestThreadChannel.Create(discord, (IGuild)null, threadInfo); + + return new ThreadCreateAuditLogData(threadChannel, id, name, type, archived, autoArchiveDuration, locked, rateLimit); + } + + // Doc Note: Corresponds to the *current* data + + /// + /// Gets the thread that was created if it still exists. + /// + /// + /// A thread object representing the thread that was created if it still exists, otherwise returns null. + /// + public IThreadChannel Thread { get; } + /// + /// Gets the snowflake ID of the thread. + /// + /// + /// A representing the snowflake identifier for the thread. + /// + public ulong ThreadId { get; } + /// + /// Gets the name of the thread. + /// + /// + /// A string containing the name of the thread. + /// + public string ThreadName { get; } + /// + /// Gets the type of the thread. + /// + /// + /// The type of thread. + /// + public ThreadType ThreadType { get; } + /// + /// Gets the value that indicates whether the thread is archived. + /// + /// + /// true if this thread has the Archived flag enabled; otherwise false. + /// + public bool IsArchived { get; } + /// + /// Gets the auto archive duration of the thread. + /// + /// + /// The thread auto archive duration of the thread. + /// + public ThreadArchiveDuration AutoArchiveDuration { get; } + /// + /// Gets the value that indicates whether the thread is locked. + /// + /// + /// true if this thread has the Locked flag enabled; otherwise false. + /// + public bool IsLocked { get; } + /// + /// Gets the slow-mode delay of the thread. + /// + /// + /// An representing the time in seconds required before the user can send another + /// message; 0 if disabled. + /// null if this is not mentioned in this entry. + /// + public int? SlowModeInterval { get; } + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadDeleteAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadDeleteAuditLogData.cs new file mode 100644 index 0000000000..962ec1773f --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadDeleteAuditLogData.cs @@ -0,0 +1,103 @@ +using System.Linq; + +using Model = Discord.API.AuditLog; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.Rest +{ + /// + /// Contains a piece of audit log data related to a thread deletion. + /// + public class ThreadDeleteAuditLogData : IAuditLogData + { + private ThreadDeleteAuditLogData(ulong id, string name, ThreadType type, bool archived, + ThreadArchiveDuration autoArchiveDuration, bool locked, int? rateLimit) + { + ThreadId = id; + ThreadName = name; + ThreadType = type; + IsArchived = archived; + AutoArchiveDuration = autoArchiveDuration; + IsLocked = locked; + SlowModeInterval = rateLimit; + } + + internal static ThreadDeleteAuditLogData Create(BaseDiscordClient discord, Model log, EntryModel entry) + { + var changes = entry.Changes; + + var id = entry.TargetId.Value; + var thread = log.Threads.FirstOrDefault(x => x.Id == id); + + var nameModel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "name"); + var typeModel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "type"); + + var archivedModel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "archived"); + var autoArchiveDurationModel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "auto_archive_duration"); + var lockedModel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "locked"); + var rateLimitPerUserModel = changes.FirstOrDefault(x => x.ChangedProperty == "rate_limit_per_user"); + + var name = nameModel.OldValue.ToObject(discord.ApiClient.Serializer); + var type = typeModel.OldValue.ToObject(discord.ApiClient.Serializer); + + var archived = archivedModel.OldValue.ToObject(discord.ApiClient.Serializer); + var autoArchiveDuration = autoArchiveDurationModel.OldValue.ToObject(discord.ApiClient.Serializer); + var locked = lockedModel.OldValue.ToObject(discord.ApiClient.Serializer); + var rateLimit = rateLimitPerUserModel?.NewValue?.ToObject(discord.ApiClient.Serializer); + + return new ThreadDeleteAuditLogData(id, name, type, archived, autoArchiveDuration, locked, rateLimit); + } + + /// + /// Gets the snowflake ID of the deleted thread. + /// + /// + /// A representing the snowflake identifier for the deleted thread. + /// + public ulong ThreadId { get; } + /// + /// Gets the name of the deleted thread. + /// + /// + /// A string containing the name of the deleted thread. + /// + public string ThreadName { get; } + /// + /// Gets the type of the deleted thread. + /// + /// + /// The type of thread that was deleted. + /// + public ThreadType ThreadType { get; } + /// + /// Gets the value that indicates whether the deleted thread was archived. + /// + /// + /// true if this thread had the Archived flag enabled; otherwise false. + /// + public bool IsArchived { get; } + /// + /// Gets the thread auto archive duration of the deleted thread. + /// + /// + /// The thread auto archive duration of the thread that was deleted. + /// + public ThreadArchiveDuration AutoArchiveDuration { get; } + /// + /// Gets the value that indicates whether the deleted thread was locked. + /// + /// + /// true if this thread had the Locked flag enabled; otherwise false. + /// + public bool IsLocked { get; } + /// + /// Gets the slow-mode delay of the deleted thread. + /// + /// + /// An representing the time in seconds required before the user can send another + /// message; 0 if disabled. + /// null if this is not mentioned in this entry. + /// + public int? SlowModeInterval { get; } + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadInfo.cs new file mode 100644 index 0000000000..0e01ba8a7b --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadInfo.cs @@ -0,0 +1,39 @@ +namespace Discord.Rest +{ + /// + /// Represents information for a thread. + /// + public class ThreadInfo + { + /// + /// Gets the name of the thread. + /// + public string Name { get; } + /// + /// Gets the value that indicates whether the thread is archived. + /// + public bool IsArchived { get; } + /// + /// Gets the auto archive duration of thread. + /// + public ThreadArchiveDuration AutoArchiveDuration { get; } + /// + /// Gets the value that indicates whether the thread is locked. + /// + public bool IsLocked { get; } + + /// + /// Gets the slow-mode delay of the ´thread. + /// + public int? SlowModeInterval { get; } + + internal ThreadInfo(string name, bool archived, ThreadArchiveDuration autoArchiveDuration, bool locked, int? rateLimit) + { + Name = name; + IsArchived = archived; + AutoArchiveDuration = autoArchiveDuration; + IsLocked = locked; + SlowModeInterval = rateLimit; + } + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadUpdateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadUpdateAuditLogData.cs new file mode 100644 index 0000000000..2b9b954183 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadUpdateAuditLogData.cs @@ -0,0 +1,88 @@ +using System.Linq; + +using Model = Discord.API.AuditLog; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.Rest +{ + /// + /// Contains a piece of audit log data related to a thread update. + /// + public class ThreadUpdateAuditLogData : IAuditLogData + { + private ThreadUpdateAuditLogData(IThreadChannel thread, ThreadType type, ThreadInfo before, ThreadInfo after) + { + Thread = thread; + ThreadType = type; + Before = before; + After = After; + } + + internal static ThreadUpdateAuditLogData Create(BaseDiscordClient discord, Model log, EntryModel entry) + { + var changes = entry.Changes; + + var id = entry.TargetId.Value; + + var nameModel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "name"); + var typeModel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "type"); + + var archivedModel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "archived"); + var autoArchiveDurationModel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "auto_archive_duration"); + var lockedModel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "locked"); + var rateLimitPerUserModel = changes.FirstOrDefault(x => x.ChangedProperty == "rate_limit_per_user"); + + var type = typeModel.OldValue.ToObject(discord.ApiClient.Serializer); + + var oldName = nameModel.OldValue.ToObject(discord.ApiClient.Serializer); + var oldArchived = archivedModel.OldValue.ToObject(discord.ApiClient.Serializer); + var oldAutoArchiveDuration = autoArchiveDurationModel.OldValue.ToObject(discord.ApiClient.Serializer); + var oldLocked = lockedModel.OldValue.ToObject(discord.ApiClient.Serializer); + var oldRateLimit = rateLimitPerUserModel?.OldValue?.ToObject(discord.ApiClient.Serializer); + var before = new ThreadInfo(oldName, oldArchived, oldAutoArchiveDuration, oldLocked, oldRateLimit); + + var newName = nameModel.NewValue.ToObject(discord.ApiClient.Serializer); + var newArchived = archivedModel.NewValue.ToObject(discord.ApiClient.Serializer); + var newAutoArchiveDuration = autoArchiveDurationModel.NewValue.ToObject(discord.ApiClient.Serializer); + var newLocked = lockedModel.NewValue.ToObject(discord.ApiClient.Serializer); + var newRateLimit = rateLimitPerUserModel?.NewValue?.ToObject(discord.ApiClient.Serializer); + var after = new ThreadInfo(newName, newArchived, newAutoArchiveDuration, newLocked, newRateLimit); + + var threadInfo = log.Threads.FirstOrDefault(x => x.Id == id); + var threadChannel = threadInfo == null ? null : RestThreadChannel.Create(discord, (IGuild)null, threadInfo); + + return new ThreadUpdateAuditLogData(threadChannel,type, before, after); + } + + // Doc Note: Corresponds to the *current* data + + /// + /// Gets the thread that was created if it still exists. + /// + /// + /// A thread object representing the thread that was created if it still exists, otherwise returns null. + /// + public IThreadChannel Thread { get; } + /// + /// Gets the type of the thread. + /// + /// + /// The type of thread. + /// + public ThreadType ThreadType { get; } + /// + /// Gets the thread information before the changes. + /// + /// + /// A thread information object representing the thread before the changes were made. + /// + public ThreadInfo Before { get; } + /// + /// Gets the thread information after the changes. + /// + /// + /// A thread information object representing the thread after the changes were made. + /// + public ThreadInfo After { get; } + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs index 57de0eb453..f97c536c82 100644 --- a/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs @@ -270,7 +270,7 @@ public virtual async Task> GetInvitesAsync( /// /// A task that represents the asynchronous create operation. The task result contains a /// - public async Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, + public virtual async Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, ThreadArchiveDuration autoArchiveDuration = ThreadArchiveDuration.OneDay, IMessage message = null, bool? invitable = null, int? slowmode = null, RequestOptions options = null) { var model = await ThreadHelper.CreateThreadAsync(Discord, this, name, type, autoArchiveDuration, message, invitable, slowmode, options); diff --git a/src/Discord.Net.Rest/Entities/Channels/ThreadHelper.cs b/src/Discord.Net.Rest/Entities/Channels/ThreadHelper.cs index 917410f98b..e0074ecff4 100644 --- a/src/Discord.Net.Rest/Entities/Channels/ThreadHelper.cs +++ b/src/Discord.Net.Rest/Entities/Channels/ThreadHelper.cs @@ -21,6 +21,9 @@ public static async Task CreateThreadAsync(BaseDiscordClient client, ITex if (type == ThreadType.PrivateThread && !features.HasFeature(GuildFeature.PrivateThreads)) throw new ArgumentException($"The guild {channel.Guild.Name} does not have the PRIVATE_THREADS feature!", nameof(type)); + if (channel is INewsChannel && type != ThreadType.NewsThread) + throw new ArgumentException($"{nameof(type)} must be a {ThreadType.NewsThread} in News channels"); + var args = new StartThreadParams { Name = name, diff --git a/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs b/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs index a9efb6de1b..338942a304 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs @@ -76,9 +76,9 @@ public override string Respond( bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, MessageComponent component = null, - Embed embed = null) + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -115,7 +115,7 @@ public override string Respond( Type = InteractionResponseType.ChannelMessageWithSource, Data = new API.InteractionCallbackData { - Content = text, + Content = text ?? Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, Embeds = embeds.Select(x => x.ToModel()).ToArray(), TTS = isTTS, @@ -132,37 +132,29 @@ public override string Respond( } } - lock (_lock) + try { - _hasResponded = true; + return SerializePayload(response); + } + finally + { + lock (_lock) + { + _hasResponded = true; + } } - - return SerializePayload(response); } - /// - /// Sends a followup message for this interaction. - /// - /// The text of the message to be sent. - /// A array of embeds to send with this response. Max 10. - /// if the message should be read out by a text-to-speech reader, otherwise . - /// if the response should be hidden to everyone besides the invoker of the command, otherwise . - /// The allowed mentions for this response. - /// The request options for this response. - /// A to be sent with this response. - /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. - /// - /// The sent message. - /// + /// public override async Task FollowupAsync( string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, MessageComponent component = null, - Embed embed = null) + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -190,23 +182,8 @@ public override async Task FollowupAsync( return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); } - /// - /// Sends a followup message for this interaction. - /// - /// The text of the message to be sent. - /// The file to upload. - /// The file name of the attachment. - /// A array of embeds to send with this response. Max 10. - /// if the message should be read out by a text-to-speech reader, otherwise . - /// if the response should be hidden to everyone besides the invoker of the command, otherwise . - /// The allowed mentions for this response. - /// The request options for this response. - /// A to be sent with this response. - /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. - /// - /// The sent message. - /// - public override async Task FollowupWithFileAsync( + /// + public override Task FollowupWithFileAsync( Stream fileStream, string fileName, string text = null, @@ -214,9 +191,9 @@ public override async Task FollowupWithFileAsync( bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, - MessageComponent component = null, - Embed embed = null) + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -225,55 +202,59 @@ public override async Task FollowupWithFileAsync( if (embed != null) embeds = new[] { embed }.Concat(embeds).ToArray(); - Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); - Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); - Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); Preconditions.NotNull(fileStream, nameof(fileStream), "File Stream must have data"); Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); - var args = new API.Rest.CreateWebhookMessageParams - { - Content = text, - AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, - IsTTS = isTTS, - Embeds = embeds.Select(x => x.ToModel()).ToArray(), - Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, - File = fileStream is not null ? new MultipartFile(fileStream, fileName) : Optional.Unspecified - }; + return FollowupWithFileAsync(new FileAttachment(fileStream, fileName), text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + } - if (ephemeral) - args.Flags = MessageFlags.Ephemeral; + /// + public override Task FollowupWithFileAsync( + string filePath, + string fileName = null, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + Preconditions.NotNullOrEmpty(filePath, nameof(filePath), "Path must exist"); - return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + fileName ??= Path.GetFileName(filePath); + Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); + + return FollowupWithFileAsync(new FileAttachment(File.OpenRead(filePath), fileName), text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); } - /// - /// Sends a followup message for this interaction. - /// - /// The text of the message to be sent. - /// The file to upload. - /// The file name of the attachment. - /// A array of embeds to send with this response. Max 10. - /// if the message should be read out by a text-to-speech reader, otherwise . - /// if the response should be hidden to everyone besides the invoker of the command, otherwise . - /// The allowed mentions for this response. - /// The request options for this response. - /// A to be sent with this response. - /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. - /// - /// The sent message. - /// - public override async Task FollowupWithFileAsync( - string filePath, + /// + public override Task FollowupWithFileAsync( + FileAttachment attachment, string text = null, - string fileName = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, - MessageComponent component = null, - Embed embed = null) + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + return FollowupWithFilesAsync(new FileAttachment[] { attachment }, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + } + + /// + public override async Task FollowupWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -285,25 +266,35 @@ public override async Task FollowupWithFileAsync( Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); - Preconditions.NotNullOrEmpty(filePath, nameof(filePath), "Path must exist"); - fileName ??= Path.GetFileName(filePath); - Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); + foreach (var attachment in attachments) + { + Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); + } - var args = new API.Rest.CreateWebhookMessageParams + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) { - Content = text, - AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, - IsTTS = isTTS, - Embeds = embeds.Select(x => x.ToModel()).ToArray(), - Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, - File = !string.IsNullOrEmpty(filePath) ? new MultipartFile(new MemoryStream(File.ReadAllBytes(filePath), false), fileName) : Optional.Unspecified - }; + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var flags = MessageFlags.None; if (ephemeral) - args.Flags = MessageFlags.Ephemeral; + flags |= MessageFlags.Ephemeral; - return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + var args = new API.Rest.UploadWebhookFileParams(attachments.ToArray()) { Flags = flags, Content = text, IsTTS = isTTS, Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified }; + return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options).ConfigureAwait(false); } /// diff --git a/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/MessageCommands/RestMessageCommand.cs b/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/MessageCommands/RestMessageCommand.cs index 53055cac3b..7a85d2e0a6 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/MessageCommands/RestMessageCommand.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/MessageCommands/RestMessageCommand.cs @@ -41,5 +41,8 @@ internal override async Task UpdateAsync(DiscordRestClient client, Model model) //IMessageCommandInteraction /// IMessageCommandInteractionData IMessageCommandInteraction.Data => Data; + //IApplicationCommandInteraction + /// + IApplicationCommandInteractionData IApplicationCommandInteraction.Data => Data; } } diff --git a/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/UserCommands/RestUserCommand.cs b/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/UserCommands/RestUserCommand.cs index 58f1ed375e..7f55fd61b7 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/UserCommands/RestUserCommand.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/UserCommands/RestUserCommand.cs @@ -44,5 +44,9 @@ internal override async Task UpdateAsync(DiscordRestClient client, Model model) //IUserCommandInteractionData /// IUserCommandInteractionData IUserCommandInteraction.Data => Data; + + //IApplicationCommandInteraction + /// + IApplicationCommandInteractionData IApplicationCommandInteraction.Data => Data; } } diff --git a/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs b/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs index 7cfc6a2ec0..b20cfe2ed1 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs @@ -34,17 +34,20 @@ public static Task DeleteAllGlobalCommandsAsync(BaseDiscordClient client, Reques return client.ApiClient.BulkOverwriteGlobalApplicationCommandsAsync(Array.Empty(), options); } - public static Task SendInteractionResponseAsync(BaseDiscordClient client, InteractionResponse response, - ulong interactionId, string interactionToken, RequestOptions options = null) + public static async Task SendInteractionResponseAsync(BaseDiscordClient client, InteractionResponse response, + IDiscordInteraction interaction, IMessageChannel channel = null, RequestOptions options = null) { - return client.ApiClient.CreateInteractionResponseAsync(response, interactionId, interactionToken, options); + await client.ApiClient.CreateInteractionResponseAsync(response, interaction.Id, interaction.Token, options).ConfigureAwait(false); + return RestInteractionMessage.Create(client, response, interaction, channel); } public static async Task GetOriginalResponseAsync(BaseDiscordClient client, IMessageChannel channel, IDiscordInteraction interaction, RequestOptions options = null) { var model = await client.ApiClient.GetInteractionResponseAsync(interaction.Token, options).ConfigureAwait(false); - return RestInteractionMessage.Create(client, model, interaction.Token, channel); + if(model != null) + return RestInteractionMessage.Create(client, model, interaction.Token, channel); + return null; } public static async Task SendFollowupAsync(BaseDiscordClient client, CreateWebhookMessageParams args, @@ -55,6 +58,15 @@ public static async Task SendFollowupAsync(BaseDiscordClien var entity = RestFollowupMessage.Create(client, model, token, channel); return entity; } + + public static async Task SendFollowupAsync(BaseDiscordClient client, UploadWebhookFileParams args, + string token, IMessageChannel channel, RequestOptions options = null) + { + var model = await client.ApiClient.CreateInteractionFollowupMessageAsync(args, token, options).ConfigureAwait(false); + + var entity = RestFollowupMessage.Create(client, model, token, channel); + return entity; + } #endregion #region Global commands @@ -427,7 +439,7 @@ public static Task SendAutocompleteResultAsync(BaseDiscordClient client, IEnumer { result ??= Array.Empty(); - Preconditions.AtMost(result.Count(), 20, nameof(result), "A maximum of 20 choices are allowed!"); + Preconditions.AtMost(result.Count(), 25, nameof(result), "A maximum of 25 choices are allowed!"); var apiArgs = new InteractionResponse { diff --git a/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs b/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs index eb47e15aab..9660bf7b05 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs @@ -64,9 +64,9 @@ internal override async Task UpdateAsync(DiscordRestClient discord, Model model) /// if the message should be read out by a text-to-speech reader, otherwise . /// if the response should be hidden to everyone besides the invoker of the command, otherwise . /// The allowed mentions for this response. - /// The request options for this response. /// A to be sent with this response. /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. /// /// A string that contains json to write back to the incoming http request. /// @@ -76,9 +76,9 @@ public override string Respond( bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, MessageComponent component = null, - Embed embed = null) + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -237,29 +237,16 @@ public string Update(Action func, RequestOptions options = nu return SerializePayload(response); } - /// - /// Sends a followup message for this interaction. - /// - /// The text of the message to be sent. - /// A array of embeds to send with this response. Max 10. - /// if the message should be read out by a text-to-speech reader, otherwise . - /// if the response should be hidden to everyone besides the invoker of the command, otherwise . - /// The allowed mentions for this response. - /// The request options for this response. - /// A to be sent with this response. - /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. - /// - /// The sent message. - /// + /// public override async Task FollowupAsync( string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, MessageComponent component = null, - Embed embed = null) + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -284,11 +271,11 @@ public override async Task FollowupAsync( if (ephemeral) args.Flags = MessageFlags.Ephemeral; - return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Message.Channel, options).ConfigureAwait(false); + return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); } /// - public override async Task FollowupWithFileAsync( + public override Task FollowupWithFileAsync( Stream fileStream, string fileName, string text = null, @@ -296,9 +283,9 @@ public override async Task FollowupWithFileAsync( bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, - MessageComponent component = null, - Embed embed = null) + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -307,40 +294,59 @@ public override async Task FollowupWithFileAsync( if (embed != null) embeds = new[] { embed }.Concat(embeds).ToArray(); - Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); - Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); - Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); Preconditions.NotNull(fileStream, nameof(fileStream), "File Stream must have data"); - Preconditions.NotNullOrWhitespace(fileName, nameof(fileName), "File Name must not be empty or null"); + Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); - var args = new API.Rest.CreateWebhookMessageParams - { - Content = text, - AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, - IsTTS = isTTS, - Embeds = embeds.Select(x => x.ToModel()).ToArray(), - Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, - File = fileStream is not null ? new MultipartFile(fileStream, fileName) : Optional.Unspecified - }; + return FollowupWithFileAsync(new FileAttachment(fileStream, fileName), text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + } - if (ephemeral) - args.Flags = MessageFlags.Ephemeral; + /// + public override Task FollowupWithFileAsync( + string filePath, + string fileName = null, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + Preconditions.NotNullOrEmpty(filePath, nameof(filePath), "Path must exist"); - return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Message.Channel, options).ConfigureAwait(false); + fileName ??= Path.GetFileName(filePath); + Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); + + return FollowupWithFileAsync(new FileAttachment(File.OpenRead(filePath), fileName), text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); } /// - public override async Task FollowupWithFileAsync( - string filePath, + public override Task FollowupWithFileAsync( + FileAttachment attachment, string text = null, - string fileName = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, - MessageComponent component = null, - Embed embed = null) + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + return FollowupWithFilesAsync(new FileAttachment[] { attachment }, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + } + + /// + public override async Task FollowupWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -352,22 +358,35 @@ public override async Task FollowupWithFileAsync( Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); - Preconditions.NotNullOrWhitespace(filePath, nameof(filePath), "Path must exist"); - var args = new API.Rest.CreateWebhookMessageParams + foreach (var attachment in attachments) { - Content = text, - AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, - IsTTS = isTTS, - Embeds = embeds.Select(x => x.ToModel()).ToArray(), - Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, - File = !string.IsNullOrEmpty(filePath) ? new MultipartFile(new MemoryStream(File.ReadAllBytes(filePath), false), fileName) : Optional.Unspecified - }; + Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); + } + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var flags = MessageFlags.None; if (ephemeral) - args.Flags = MessageFlags.Ephemeral; + flags |= MessageFlags.Ephemeral; - return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Message.Channel, options).ConfigureAwait(false); + var args = new API.Rest.UploadWebhookFileParams(attachments.ToArray()) { Flags = flags, Content = text, IsTTS = isTTS, Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified }; + return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options).ConfigureAwait(false); } /// diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs b/src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs index 103c43ffb5..35b3569974 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs @@ -139,8 +139,6 @@ internal string SerializePayload(object payload) /// public abstract string Defer(bool ephemeral = false, RequestOptions options = null); - /// - public abstract Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null); /// /// Gets the original response for this interaction. /// @@ -154,14 +152,36 @@ public Task GetOriginalResponseAsync(RequestOptions opti /// /// A delegate containing the properties to modify the message with. /// The request options for this request. - /// A that represents the initial response. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// public async Task ModifyOriginalResponseAsync(Action func, RequestOptions options = null) { var model = await InteractionHelper.ModifyInteractionResponseAsync(Discord, Token, func, options); return RestInteractionMessage.Create(Discord, model, Token, Channel); } /// - public abstract string Respond(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null); + public abstract string Respond(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent component = null, Embed embed = null, RequestOptions options = null); + + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public abstract Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + /// /// Sends a followup message for this interaction. /// @@ -172,14 +192,16 @@ public async Task ModifyOriginalResponseAsync(Action if the message should be read out by a text-to-speech reader, otherwise . /// if the response should be hidden to everyone besides the invoker of the command, otherwise . /// The allowed mentions for this response. - /// The request options for this response. - /// A to be sent with this response. + /// A to be sent with this response. /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. /// - /// The sent message. + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. /// public abstract Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, - AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null); + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + /// /// Sends a followup message for this interaction. /// @@ -190,35 +212,90 @@ public abstract Task FollowupWithFileAsync(Stream fileStrea /// if the message should be read out by a text-to-speech reader, otherwise . /// if the response should be hidden to everyone besides the invoker of the command, otherwise . /// The allowed mentions for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public abstract Task FollowupWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Sends a followup message for this interaction. + /// + /// The attachment containing the file and description. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. /// The request options for this response. - /// A to be sent with this response. + /// A to be sent with this response. /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. /// - /// The sent message. + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. /// - public abstract Task FollowupWithFileAsync(string filePath, string text = null, string fileName = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, - AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null); + public abstract Task FollowupWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Sends a followup message for this interaction. + /// + /// A collection of attachments to upload. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public abstract Task FollowupWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); #region IDiscordInteraction /// - Task IDiscordInteraction.RespondAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, RequestOptions options, MessageComponent component, Embed embed) - => Task.FromResult(Respond(text, embeds, isTTS, ephemeral, allowedMentions, options, component, embed)); + IUser IDiscordInteraction.User => User; + /// + Task IDiscordInteraction.RespondAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + { + return Task.FromResult(null); + } + /// Task IDiscordInteraction.DeferAsync(bool ephemeral, RequestOptions options) => Task.FromResult(Defer(ephemeral, options)); - /// async Task IDiscordInteraction.FollowupAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, - RequestOptions options, MessageComponent component, Embed embed) - => await FollowupAsync(text, embeds, isTTS, ephemeral, allowedMentions, options, component, embed).ConfigureAwait(false); - + MessageComponent component, Embed embed, RequestOptions options) + => await FollowupAsync(text, embeds, isTTS, ephemeral, allowedMentions, component, embed, options).ConfigureAwait(false); /// async Task IDiscordInteraction.GetOriginalResponseAsync(RequestOptions options) => await GetOriginalResponseAsync(options).ConfigureAwait(false); - /// async Task IDiscordInteraction.ModifyOriginalResponseAsync(Action func, RequestOptions options) => await ModifyOriginalResponseAsync(func, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupWithFileAsync(Stream fileStream, string fileName, string text, Embed[] embeds, bool isTTS, bool ephemeral, + AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFileAsync(fileStream, fileName, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupWithFileAsync(string filePath, string text, string fileName, Embed[] embeds, bool isTTS, bool ephemeral, + AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFileAsync(filePath, text, fileName, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupWithFileAsync(FileAttachment attachment, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFileAsync(attachment, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupWithFilesAsync(IEnumerable attachments, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFilesAsync(attachments, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); #endregion } } diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestPingInteraction.cs b/src/Discord.Net.Rest/Entities/Interactions/RestPingInteraction.cs index f979a4df21..5ed5e054aa 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/RestPingInteraction.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/RestPingInteraction.cs @@ -38,9 +38,11 @@ public string AcknowledgePing() } public override string Defer(bool ephemeral = false, RequestOptions options = null) => throw new NotSupportedException(); - public override Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null) => throw new NotSupportedException(); - public override Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null) => throw new NotSupportedException(); - public override Task FollowupWithFileAsync(string filePath, string text = null, string fileName = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null) => throw new NotSupportedException(); - public override string Respond(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null) => throw new NotSupportedException(); + public override string Respond(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent component = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); + public override Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); + public override Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); + public override Task FollowupWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); + public override Task FollowupWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); + public override Task FollowupWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); } } diff --git a/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteraction.cs b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteraction.cs index 3b879cd4e0..1abeb4b3fd 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteraction.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteraction.cs @@ -46,7 +46,7 @@ internal RestAutocompleteInteraction(DiscordRestClient client, Model model) /// /// The set of choices for the user to pick from. /// - /// A max of 20 choices are allowed. Passing for this argument will show the executing user that + /// A max of 25 choices are allowed. Passing for this argument will show the executing user that /// there is no choices for their autocompleted input. /// /// @@ -93,7 +93,7 @@ public string Respond(IEnumerable result, RequestOptions opt /// /// The set of choices for the user to pick from. /// - /// A max of 20 choices are allowed. Passing for this argument will show the executing user that + /// A max of 25 choices are allowed. Passing for this argument will show the executing user that /// there is no choices for their autocompleted input. /// /// @@ -102,31 +102,21 @@ public string Respond(IEnumerable result, RequestOptions opt /// public string Respond(RequestOptions options = null, params AutocompleteResult[] result) => Respond(result, options); - - /// - [Obsolete("Autocomplete interactions cannot be deferred!", true)] public override string Defer(bool ephemeral = false, RequestOptions options = null) - => throw new NotSupportedException("Autocomplete interactions cannot be deferred!"); + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override string Respond(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent component = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); - /// - [Obsolete("Autocomplete interactions cannot have followups!", true)] - public override Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null) - => throw new NotSupportedException("Autocomplete interactions cannot be deferred!"); - - /// - [Obsolete("Autocomplete interactions cannot have followups!", true)] - public override Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null) - => throw new NotSupportedException("Autocomplete interactions cannot be deferred!"); - - /// - [Obsolete("Autocomplete interactions cannot have followups!", true)] - public override Task FollowupWithFileAsync(string filePath, string text = null, string fileName = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null) - => throw new NotSupportedException("Autocomplete interactions cannot be deferred!"); - - /// - [Obsolete("Autocomplete interactions cannot have normal responses!", true)] - public override string Respond(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null) - => throw new NotSupportedException("Autocomplete interactions cannot be deferred!"); //IAutocompleteInteraction /// diff --git a/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestSlashCommand.cs b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestSlashCommand.cs index 785e39a128..21184fcf60 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestSlashCommand.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestSlashCommand.cs @@ -44,5 +44,9 @@ internal override async Task UpdateAsync(DiscordRestClient client, Model model) //ISlashCommandInteraction /// IApplicationCommandInteractionData ISlashCommandInteraction.Data => Data; + + //IApplicationCommandInteraction + /// + IApplicationCommandInteractionData IApplicationCommandInteraction.Data => Data; } } diff --git a/src/Discord.Net.Rest/Entities/Messages/RestFollowupMessage.cs b/src/Discord.Net.Rest/Entities/Messages/RestFollowupMessage.cs index 693d36e565..aa5dd5aeba 100644 --- a/src/Discord.Net.Rest/Entities/Messages/RestFollowupMessage.cs +++ b/src/Discord.Net.Rest/Entities/Messages/RestFollowupMessage.cs @@ -5,7 +5,7 @@ namespace Discord.Rest { /// - /// Represents a REST-based follow up message sent by a bot responding to a slash command. + /// Represents a REST-based follow up message sent by a bot responding to an interaction. /// public class RestFollowupMessage : RestUserMessage { diff --git a/src/Discord.Net.Rest/Entities/Messages/RestInteractionMessage.cs b/src/Discord.Net.Rest/Entities/Messages/RestInteractionMessage.cs index 26beb03b60..815f1953ff 100644 --- a/src/Discord.Net.Rest/Entities/Messages/RestInteractionMessage.cs +++ b/src/Discord.Net.Rest/Entities/Messages/RestInteractionMessage.cs @@ -1,15 +1,16 @@ using System; using System.Threading.Tasks; -using Model = Discord.API.Message; +using MessageModel = Discord.API.Message; +using Model = Discord.API.InteractionResponse; namespace Discord.Rest { /// - /// Represents the initial REST-based response to a slash command. + /// Represents the initial REST-based response to an interaction. /// public class RestInteractionMessage : RestUserMessage { - // Token used to delete/modify this followup message + public InteractionResponseType ResponseType { get; private set; } internal string Token { get; } internal RestInteractionMessage(BaseDiscordClient discord, ulong id, IUser author, string token, IMessageChannel channel) @@ -18,18 +19,31 @@ internal RestInteractionMessage(BaseDiscordClient discord, ulong id, IUser autho Token = token; } - internal static RestInteractionMessage Create(BaseDiscordClient discord, Model model, string token, IMessageChannel channel) + internal static RestInteractionMessage Create(BaseDiscordClient discord, MessageModel model, string token, IMessageChannel channel) { var entity = new RestInteractionMessage(discord, model.Id, model.Author.IsSpecified ? RestUser.Create(discord, model.Author.Value) : discord.CurrentUser, token, channel); entity.Update(model); return entity; } - internal new void Update(Model model) + internal static RestInteractionMessage Create(BaseDiscordClient discord, Model model, IDiscordInteraction interaction, IMessageChannel channel) + { + var entity = new RestInteractionMessage(discord, interaction.Id, discord.CurrentUser, interaction.Token, channel); + entity.Update(model, interaction); + return entity; + } + + internal new void Update(MessageModel model) { base.Update(model); } + internal void Update(Model model, IDiscordInteraction interaction) + { + ResponseType = model.Type; + base.Update(model.ToMessage(interaction)); + } + /// /// Deletes this object and all of it's children. /// diff --git a/src/Discord.Net.Rest/Extensions/EntityExtensions.cs b/src/Discord.Net.Rest/Extensions/EntityExtensions.cs index 9e1e5102f0..bca2e87158 100644 --- a/src/Discord.Net.Rest/Extensions/EntityExtensions.cs +++ b/src/Discord.Net.Rest/Extensions/EntityExtensions.cs @@ -39,7 +39,7 @@ public static RoleTags ToEntity(this API.RoleTags model) return new RoleTags( model.BotId.IsSpecified ? model.BotId.Value : null, model.IntegrationId.IsSpecified ? model.IntegrationId.Value : null, - model.IsPremiumSubscriber.GetValueOrDefault(false) ?? false); + model.IsPremiumSubscriber.IsSpecified); } public static API.Embed ToModel(this Embed entity) { @@ -170,5 +170,48 @@ public static Overwrite ToEntity(this API.Overwrite model) { return new Overwrite(model.TargetId, model.TargetType, new OverwritePermissions(model.Allow, model.Deny)); } + + public static API.Message ToMessage(this API.InteractionResponse model, IDiscordInteraction interaction) + { + if (model.Data.IsSpecified) + { + var data = model.Data.Value; + var messageModel = new API.Message + { + IsTextToSpeech = data.TTS, + Content = data.Content, + Embeds = data.Embeds, + AllowedMentions = data.AllowedMentions, + Components = data.Components, + Flags = data.Flags, + }; + + if(interaction is IApplicationCommandInteraction command) + { + messageModel.Interaction = new API.MessageInteraction + { + Id = command.Id, + Name = command.Data.Name, + Type = InteractionType.ApplicationCommand, + User = new API.User + { + Username = command.User.Username, + Avatar = command.User.AvatarId, + Bot = command.User.IsBot, + Discriminator = command.User.Discriminator, + PublicFlags = command.User.PublicFlags.HasValue ? command.User.PublicFlags.Value : Optional.Unspecified, + Id = command.User.Id, + } + }; + } + + return messageModel; + } + + return new API.Message + { + Id = interaction.Id, + }; + } } } diff --git a/src/Discord.Net.Rest/Interactions/RestInteractionContext.cs b/src/Discord.Net.Rest/Interactions/RestInteractionContext.cs new file mode 100644 index 0000000000..7e73caa4fc --- /dev/null +++ b/src/Discord.Net.Rest/Interactions/RestInteractionContext.cs @@ -0,0 +1,80 @@ +namespace Discord.Rest +{ + /// + /// Represents a Rest based context of an . + /// + public class RestInteractionContext : IInteractionContext + where TInteraction : RestInteraction + { + /// + /// Gets the that the command will be executed with. + /// + public DiscordRestClient Client { get; } + + /// + /// Gets the the command originated from. + /// + /// + /// Will be null if the command is from a DM Channel. + /// + public RestGuild Guild { get; } + + /// + /// Gets the the command originated from. + /// + public IRestMessageChannel Channel { get; } + + /// + /// Gets the who executed the command. + /// + public RestUser User { get; } + + /// + /// Gets the the command was recieved with. + /// + public TInteraction Interaction { get; } + + /// + /// Initializes a new . + /// + /// The underlying client. + /// The underlying interaction. + public RestInteractionContext(DiscordRestClient client, TInteraction interaction) + { + Client = client; + Guild = interaction.Guild; + Channel = interaction.Channel; + User = interaction.User; + Interaction = interaction; + } + + // IInterationContext + /// + IDiscordClient IInteractionContext.Client => Client; + + /// + IGuild IInteractionContext.Guild => Guild; + + /// + IMessageChannel IInteractionContext.Channel => Channel; + + /// + IUser IInteractionContext.User => User; + + /// + IDiscordInteraction IInteractionContext.Interaction => Interaction; + } + + /// + /// Represents a Rest based context of an + /// + public class RestInteractionContext : RestInteractionContext + { + /// + /// Initializes a new + /// + /// The underlying client + /// The underlying interaction + public RestInteractionContext(DiscordRestClient client, RestInteraction interaction) : base(client, interaction) { } + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/GuildFeaturesConverter.cs b/src/Discord.Net.Rest/Net/Converters/GuildFeaturesConverter.cs index 9f82b440b1..22363199dd 100644 --- a/src/Discord.Net.Rest/Net/Converters/GuildFeaturesConverter.cs +++ b/src/Discord.Net.Rest/Net/Converters/GuildFeaturesConverter.cs @@ -2,10 +2,6 @@ using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading.Tasks; namespace Discord.Net.Converters { @@ -18,9 +14,6 @@ public static GuildFeaturesConverter Instance public override bool CanWrite => false; public override bool CanRead => true; - - private Regex _readRegex = new Regex(@"_(\w)"); - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { var obj = JToken.Load(reader); @@ -31,20 +24,11 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist foreach(var item in arr) { - var name = _readRegex.Replace(item.ToLower(), (x) => - { - return x.Groups[1].Value.ToUpper(); - }); - - name = name[0].ToString().ToUpper() + new string(name.Skip(1).ToArray()); - - try + if (Enum.TryParse(string.Concat(item.Split('_')), true, out var result)) { - var result = (GuildFeature)Enum.Parse(typeof(GuildFeature), name); - features |= result; } - catch + else { experimental.Add(item); } diff --git a/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj b/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj index 4121e7d001..2ce89be5b4 100644 --- a/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj +++ b/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj @@ -1,16 +1,16 @@ - + - + Discord.Net.WebSocket Discord.WebSocket A core Discord.Net library containing the WebSocket client and models. - net461;netstandard2.0;netstandard2.1 - netstandard2.0;netstandard2.1 + net6.0;net5.0;net461;netstandard2.0;netstandard2.1 + net6.0;net5.0;netstandard2.0;netstandard2.1 true - \ No newline at end of file + diff --git a/src/Discord.Net.WebSocket/DiscordSocketClient.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.cs index 9ef8277789..eb64b5173f 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.cs @@ -1954,7 +1954,7 @@ private async Task ProcessMessageAsync(GatewayOpCode opCode, int? seq, string ty } } - var before = user.Presence.Clone(); + var before = user.Presence?.Clone(); user.Update(State, data.User); user.Update(data); await TimedInvokeAsync(_presenceUpdated, nameof(PresenceUpdated), user, before, user.Presence).ConfigureAwait(false); @@ -2627,7 +2627,7 @@ private async Task ProcessMessageAsync(GatewayOpCode opCode, int? seq, string ty var newEvent = guild.AddOrUpdateEvent(data); - await TimedInvokeAsync(_guildScheduledEventCancelled, nameof(GuildScheduledEventCreated), newEvent).ConfigureAwait(false); + await TimedInvokeAsync(_guildScheduledEventCreated, nameof(GuildScheduledEventCreated), newEvent).ConfigureAwait(false); } break; case "GUILD_SCHEDULED_EVENT_UPDATE": diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketNewsChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketNewsChannel.cs index 944dd2d7fb..eed8f93740 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketNewsChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketNewsChannel.cs @@ -35,5 +35,6 @@ internal SocketNewsChannel(DiscordSocketClient discord, ulong id, SocketGuild gu /// public override int SlowModeInterval => throw new NotSupportedException("News channels do not support Slow Mode."); + } } diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs index aea1bfda55..8dc8e9844b 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs @@ -112,7 +112,7 @@ public virtual Task ModifyAsync(Action func, RequestOptio /// /// A task that represents the asynchronous create operation. The task result contains a /// - public async Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, + public virtual async Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, ThreadArchiveDuration autoArchiveDuration = ThreadArchiveDuration.OneDay, IMessage message = null, bool? invitable = null, int? slowmode = null, RequestOptions options = null) { var model = await ThreadHelper.CreateThreadAsync(Discord, this, name, type, autoArchiveDuration, message, invitable, slowmode, options); diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/MessageCommands/SocketMessageCommand.cs b/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/MessageCommands/SocketMessageCommand.cs index 0aa0614392..fee33f8cb9 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/MessageCommands/SocketMessageCommand.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/MessageCommands/SocketMessageCommand.cs @@ -41,5 +41,9 @@ internal SocketMessageCommand(DiscordSocketClient client, Model model, ISocketMe //IDiscordInteraction /// IDiscordInteractionData IDiscordInteraction.Data => Data; + + //IApplicationCommandInteraction + /// + IApplicationCommandInteractionData IApplicationCommandInteraction.Data => Data; } } diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/UserCommands/SocketUserCommand.cs b/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/UserCommands/SocketUserCommand.cs index 40ee5b5377..75e8ebff9c 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/UserCommands/SocketUserCommand.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/UserCommands/SocketUserCommand.cs @@ -41,5 +41,9 @@ internal SocketUserCommand(DiscordSocketClient client, Model model, ISocketMessa //IDiscordInteraction /// IDiscordInteractionData IDiscordInteraction.Data => Data; + + //IApplicationCommandInteraction + /// + IApplicationCommandInteractionData IApplicationCommandInteraction.Data => Data; } } diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs b/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs index 928a4302a7..d5d5f959d7 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs @@ -72,15 +72,15 @@ internal override void Update(Model model) } } /// - public override async Task RespondAsync( + public override async Task RespondAsync( string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, MessageComponent component = null, - Embed embed = null) + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -136,11 +136,16 @@ public override async Task RespondAsync( } } - await InteractionHelper.SendInteractionResponseAsync(Discord, response, Id, Token, options).ConfigureAwait(false); - - lock (_lock) + try { - HasResponded = true; + return await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + } + finally + { + lock (_lock) + { + HasResponded = true; + } } } @@ -231,7 +236,7 @@ public async Task UpdateAsync(Action func, RequestOptions opt } } - await InteractionHelper.SendInteractionResponseAsync(Discord, response, Id, Token, options).ConfigureAwait(false); + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); lock (_lock) { @@ -246,9 +251,9 @@ public override async Task FollowupAsync( bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, MessageComponent component = null, - Embed embed = null) + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -273,11 +278,11 @@ public override async Task FollowupAsync( if (ephemeral) args.Flags = MessageFlags.Ephemeral; - return await InteractionHelper.SendFollowupAsync(Discord.Rest, args, Token, Channel, options).ConfigureAwait(false); + return await InteractionHelper.SendFollowupAsync(Discord.Rest, args, Token, Channel, options); } /// - public override async Task FollowupWithFileAsync( + public override Task FollowupWithFileAsync( Stream fileStream, string fileName, string text = null, @@ -285,9 +290,9 @@ public override async Task FollowupWithFileAsync( bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, - MessageComponent component = null, - Embed embed = null) + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -296,40 +301,59 @@ public override async Task FollowupWithFileAsync( if (embed != null) embeds = new[] { embed }.Concat(embeds).ToArray(); - Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); - Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); - Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); Preconditions.NotNull(fileStream, nameof(fileStream), "File Stream must have data"); - Preconditions.NotNullOrWhitespace(fileName, nameof(fileName), "File Name must not be empty or null"); + Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); - var args = new API.Rest.CreateWebhookMessageParams - { - Content = text, - AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, - IsTTS = isTTS, - Embeds = embeds.Select(x => x.ToModel()).ToArray(), - Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, - File = fileStream is not null ? new MultipartFile(fileStream, fileName) : Optional.Unspecified - }; + return FollowupWithFileAsync(new FileAttachment(fileStream, fileName), text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + } - if (ephemeral) - args.Flags = MessageFlags.Ephemeral; + /// + public override Task FollowupWithFileAsync( + string filePath, + string fileName = null, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + Preconditions.NotNullOrEmpty(filePath, nameof(filePath), "Path must exist"); - return await InteractionHelper.SendFollowupAsync(Discord.Rest, args, Token, Channel, options).ConfigureAwait(false); + fileName ??= Path.GetFileName(filePath); + Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); + + return FollowupWithFileAsync(new FileAttachment(File.OpenRead(filePath), fileName), text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); } /// - public override async Task FollowupWithFileAsync( - string filePath, + public override Task FollowupWithFileAsync( + FileAttachment attachment, string text = null, - string fileName = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, - MessageComponent component = null, - Embed embed = null) + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + return FollowupWithFilesAsync(new FileAttachment[] { attachment }, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + } + + /// + public override async Task FollowupWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -341,22 +365,35 @@ public override async Task FollowupWithFileAsync( Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); - Preconditions.NotNullOrWhitespace(filePath, nameof(filePath), "Path must exist"); - var args = new API.Rest.CreateWebhookMessageParams + foreach (var attachment in attachments) { - Content = text, - AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, - IsTTS = isTTS, - Embeds = embeds.Select(x => x.ToModel()).ToArray(), - Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, - File = !string.IsNullOrEmpty(filePath) ? new MultipartFile(new MemoryStream(File.ReadAllBytes(filePath), false), fileName) : Optional.Unspecified - }; + Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); + } + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var flags = MessageFlags.None; if (ephemeral) - args.Flags = MessageFlags.Ephemeral; + flags |= MessageFlags.Ephemeral; - return await InteractionHelper.SendFollowupAsync(Discord.Rest, args, Token, Channel, options).ConfigureAwait(false); + var args = new API.Rest.UploadWebhookFileParams(attachments.ToArray()) { Flags = flags, Content = text, IsTTS = isTTS, Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified }; + return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options).ConfigureAwait(false); } /// diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteraction.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteraction.cs index 5637cb6f0f..955d7d53fa 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteraction.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteraction.cs @@ -45,7 +45,7 @@ internal SocketAutocompleteInteraction(DiscordSocketClient client, Model model, /// /// The set of choices for the user to pick from. /// - /// A max of 20 choices are allowed. Passing for this argument will show the executing user that + /// A max of 25 choices are allowed. Passing for this argument will show the executing user that /// there is no choices for their autocompleted input. /// /// @@ -80,7 +80,7 @@ public async Task RespondAsync(IEnumerable result, RequestOp /// /// The set of choices for the user to pick from. /// - /// A max of 20 choices are allowed. Passing for this argument will show the executing user that + /// A max of 25 choices are allowed. Passing for this argument will show the executing user that /// there is no choices for their autocompleted input. /// /// @@ -89,31 +89,20 @@ public async Task RespondAsync(IEnumerable result, RequestOp /// public Task RespondAsync(RequestOptions options = null, params AutocompleteResult[] result) => RespondAsync(result, options); - - /// - [Obsolete("Autocomplete interactions cannot be deferred!", true)] + public override Task RespondAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); public override Task DeferAsync(bool ephemeral = false, RequestOptions options = null) - => throw new NotSupportedException("Autocomplete interactions cannot be deferred!"); - - /// - [Obsolete("Autocomplete interactions cannot have followups!", true)] - public override Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null) - => throw new NotSupportedException("Autocomplete interactions cannot be deferred!"); - - /// - [Obsolete("Autocomplete interactions cannot have followups!", true)] - public override Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null) - => throw new NotSupportedException("Autocomplete interactions cannot be deferred!"); - - /// - [Obsolete("Autocomplete interactions cannot have followups!", true)] - public override Task FollowupWithFileAsync(string filePath, string text = null, string fileName = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null) - => throw new NotSupportedException("Autocomplete interactions cannot be deferred!"); - - /// - [Obsolete("Autocomplete interactions cannot have normal responses!", true)] - public override Task RespondAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null) - => throw new NotSupportedException("Autocomplete interactions cannot be deferred!"); + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); //IAutocompleteInteraction /// diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketSlashCommand.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketSlashCommand.cs index 5343bb2252..5934a38643 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketSlashCommand.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketSlashCommand.cs @@ -41,5 +41,9 @@ internal SocketSlashCommand(DiscordSocketClient client, Model model, ISocketMess //IDiscordInteraction /// IDiscordInteractionData IDiscordInteraction.Data => Data; + + //IApplicationCommandInteraction + /// + IApplicationCommandInteractionData IApplicationCommandInteraction.Data => Data; } } diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs index 92303d488a..7c90878be1 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs @@ -1,6 +1,7 @@ using Discord.Net.Rest; using Discord.Rest; using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -68,15 +69,15 @@ internal override void Update(Model model) } /// - public override async Task RespondAsync( + public override async Task RespondAsync( string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, MessageComponent component = null, - Embed embed = null) + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -113,7 +114,7 @@ public override async Task RespondAsync( Type = InteractionResponseType.ChannelMessageWithSource, Data = new API.InteractionCallbackData { - Content = text, + Content = text ?? Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, Embeds = embeds.Select(x => x.ToModel()).ToArray(), TTS = isTTS, @@ -130,11 +131,16 @@ public override async Task RespondAsync( } } - await InteractionHelper.SendInteractionResponseAsync(Discord, response, Id, Token, options).ConfigureAwait(false); - - lock (_lock) + try { - HasResponded = true; + return await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + } + finally + { + lock (_lock) + { + HasResponded = true; + } } } @@ -145,9 +151,9 @@ public override async Task FollowupAsync( bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, MessageComponent component = null, - Embed embed = null) + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -162,7 +168,7 @@ public override async Task FollowupAsync( var args = new API.Rest.CreateWebhookMessageParams { - Content = text, + Content = text ?? Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, IsTTS = isTTS, Embeds = embeds.Select(x => x.ToModel()).ToArray(), @@ -176,7 +182,7 @@ public override async Task FollowupAsync( } /// - public override async Task FollowupWithFileAsync( + public override Task FollowupWithFileAsync( Stream fileStream, string fileName, string text = null, @@ -184,9 +190,9 @@ public override async Task FollowupWithFileAsync( bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, - MessageComponent component = null, - Embed embed = null) + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -195,40 +201,59 @@ public override async Task FollowupWithFileAsync( if (embed != null) embeds = new[] { embed }.Concat(embeds).ToArray(); - Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); - Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); - Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); Preconditions.NotNull(fileStream, nameof(fileStream), "File Stream must have data"); Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); - var args = new API.Rest.CreateWebhookMessageParams - { - Content = text, - AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, - IsTTS = isTTS, - Embeds = embeds.Select(x => x.ToModel()).ToArray(), - Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, - File = fileStream is not null ? new MultipartFile(fileStream, fileName) : Optional.Unspecified - }; + return FollowupWithFileAsync(new FileAttachment(fileStream, fileName), text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + } - if (ephemeral) - args.Flags = MessageFlags.Ephemeral; + /// + public override Task FollowupWithFileAsync( + string filePath, + string fileName = null, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + Preconditions.NotNullOrEmpty(filePath, nameof(filePath), "Path must exist"); - return await InteractionHelper.SendFollowupAsync(Discord.Rest, args, Token, Channel, options); + fileName ??= Path.GetFileName(filePath); + Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); + + return FollowupWithFileAsync(new FileAttachment(File.OpenRead(filePath), fileName), text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); } /// - public override async Task FollowupWithFileAsync( - string filePath, + public override Task FollowupWithFileAsync( + FileAttachment attachment, string text = null, - string fileName = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, - RequestOptions options = null, - MessageComponent component = null, - Embed embed = null) + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + return FollowupWithFilesAsync(new FileAttachment[] { attachment }, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + } + + /// + public override async Task FollowupWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) { if (!IsValidToken) throw new InvalidOperationException("Interaction token is no longer valid"); @@ -240,25 +265,35 @@ public override async Task FollowupWithFileAsync( Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); - Preconditions.NotNullOrEmpty(filePath, nameof(filePath), "Path must exist"); - fileName ??= Path.GetFileName(filePath); - Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); + foreach (var attachment in attachments) + { + Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); + } - var args = new API.Rest.CreateWebhookMessageParams + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) { - Content = text, - AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, - IsTTS = isTTS, - Embeds = embeds.Select(x => x.ToModel()).ToArray(), - Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, - File = !string.IsNullOrEmpty(filePath) ? new MultipartFile(new MemoryStream(File.ReadAllBytes(filePath), false), fileName) : Optional.Unspecified - }; + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var flags = MessageFlags.None; if (ephemeral) - args.Flags = MessageFlags.Ephemeral; + flags |= MessageFlags.Ephemeral; - return await InteractionHelper.SendFollowupAsync(Discord.Rest, args, Token, Channel, options); + var args = new API.Rest.UploadWebhookFileParams(attachments.ToArray()) { Flags = flags, Content = text, IsTTS = isTTS, Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified }; + return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options).ConfigureAwait(false); } /// diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs index f0465d3361..1bfd774797 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs @@ -4,6 +4,7 @@ using Model = Discord.API.Interaction; using DataModel = Discord.API.ApplicationCommandInteractionData; using System.IO; +using System.Collections.Generic; namespace Discord.WebSocket { @@ -130,13 +131,13 @@ internal virtual void Update(Model model) /// if the message should be read out by a text-to-speech reader, otherwise . /// if the response should be hidden to everyone besides the invoker of the command, otherwise . /// The allowed mentions for this response. - /// The request options for this response. - /// A to be sent with this response. + /// A to be sent with this response. /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. /// Message content is too long, length must be less or equal to . /// The parameters provided were invalid or the token was invalid. - public abstract Task RespondAsync(string text = null, Embed[] embeds = null, bool isTTS = false, - bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null); + public abstract Task RespondAsync(string text = null, Embed[] embeds = null, bool isTTS = false, + bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); /// /// Sends a followup message for this interaction. @@ -146,14 +147,14 @@ public abstract Task RespondAsync(string text = null, Embed[] embeds = null, boo /// if the message should be read out by a text-to-speech reader, otherwise . /// if the response should be hidden to everyone besides the invoker of the command, otherwise . /// The allowed mentions for this response. - /// The request options for this response. - /// A to be sent with this response. + /// A to be sent with this response. /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. /// /// The sent message. /// public abstract Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, - AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null); + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); /// /// Sends a followup message for this interaction. @@ -165,14 +166,14 @@ public abstract Task FollowupAsync(string text = null, Embe /// if the message should be read out by a text-to-speech reader, otherwise . /// if the response should be hidden to everyone besides the invoker of the command, otherwise . /// The allowed mentions for this response. - /// The request options for this response. - /// A to be sent with this response. + /// A to be sent with this response. /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. /// /// The sent message. /// public abstract Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, - AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null); + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); /// /// Sends a followup message for this interaction. @@ -184,14 +185,52 @@ public abstract Task FollowupWithFileAsync(Stream fileStrea /// if the message should be read out by a text-to-speech reader, otherwise . /// if the response should be hidden to everyone besides the invoker of the command, otherwise . /// The allowed mentions for this response. - /// The request options for this response. - /// A to be sent with this response. + /// A to be sent with this response. /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. /// /// The sent message. /// - public abstract Task FollowupWithFileAsync(string filePath, string text = null, string fileName = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, - AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent component = null, Embed embed = null); + public abstract Task FollowupWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Sends a followup message for this interaction. + /// + /// The attachment containing the file and description. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public abstract Task FollowupWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Sends a followup message for this interaction. + /// + /// A collection of attachments to upload. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public abstract Task FollowupWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); /// /// Gets the original response for this interaction. @@ -222,22 +261,37 @@ public async Task ModifyOriginalResponseAsync(Action public abstract Task DeferAsync(bool ephemeral = false, RequestOptions options = null); - + #endregion #region IDiscordInteraction /// - async Task IDiscordInteraction.FollowupAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, - RequestOptions options, MessageComponent component, Embed embed) - => await FollowupAsync(text, embeds, isTTS, ephemeral, allowedMentions, options, component, embed).ConfigureAwait(false); + IUser IDiscordInteraction.User => User; /// async Task IDiscordInteraction.GetOriginalResponseAsync(RequestOptions options) => await GetOriginalResponseAsync(options).ConfigureAwait(false); - /// async Task IDiscordInteraction.ModifyOriginalResponseAsync(Action func, RequestOptions options) => await ModifyOriginalResponseAsync(func, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.RespondAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await RespondAsync(text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupAsync(text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupWithFileAsync(Stream fileStream, string fileName, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFileAsync(fileStream, fileName, text, embeds, isTTS, ephemeral, allowedMentions, components, embed).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupWithFileAsync(string filePath, string fileName, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFileAsync(filePath, fileName, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupWithFileAsync(FileAttachment attachment, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFileAsync(attachment, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupWithFilesAsync(IEnumerable attachments, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFilesAsync(attachments, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); #endregion } } diff --git a/src/Discord.Net.WebSocket/Entities/Stickers/SocketSticker.cs b/src/Discord.Net.WebSocket/Entities/Stickers/SocketSticker.cs index ee45720b52..b9c122cce3 100644 --- a/src/Discord.Net.WebSocket/Entities/Stickers/SocketSticker.cs +++ b/src/Discord.Net.WebSocket/Entities/Stickers/SocketSticker.cs @@ -88,5 +88,11 @@ public override bool Equals(object obj) return base.Equals(obj); } + + /// + public override int GetHashCode() + { + return base.GetHashCode(); + } } } diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs index ae3319227c..d64597501c 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs @@ -176,6 +176,8 @@ internal void Update(ClientState state, PresenceModel model, bool updatePresence internal override void Update(PresenceModel model) { + Presence ??= new SocketPresence(); + Presence.Update(model); GlobalUser.Update(model); } diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs index b38bd8a4ad..b14993991c 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs @@ -59,6 +59,7 @@ internal SocketUser(DiscordSocketClient discord, ulong id) } internal virtual bool Update(ClientState state, Model model) { + Presence ??= new SocketPresence(); bool hasChanges = false; if (model.Avatar.IsSpecified && model.Avatar.Value != AvatarId) { @@ -94,6 +95,7 @@ internal virtual bool Update(ClientState state, Model model) internal virtual void Update(PresenceModel model) { + Presence ??= new SocketPresence(); Presence.Update(model); } diff --git a/src/Discord.Net.WebSocket/Interactions/ShardedInteractionContext.cs b/src/Discord.Net.WebSocket/Interactions/ShardedInteractionContext.cs new file mode 100644 index 0000000000..ac05241729 --- /dev/null +++ b/src/Discord.Net.WebSocket/Interactions/ShardedInteractionContext.cs @@ -0,0 +1,43 @@ +using Discord.WebSocket; + +namespace Discord.Interactions +{ + /// + /// The sharded variant of . + /// + public class ShardedInteractionContext : SocketInteractionContext, IInteractionContext + where TInteraction : SocketInteraction + { + /// + /// Gets the that the command will be executed with. + /// + public new DiscordShardedClient Client { get; } + + /// + /// Initializes a . + /// + /// The underlying client. + /// The underlying interaction. + public ShardedInteractionContext (DiscordShardedClient client, TInteraction interaction) + : base(client.GetShard(GetShardId(client, ( interaction.User as SocketGuildUser )?.Guild)), interaction) + { + Client = client; + } + + private static int GetShardId (DiscordShardedClient client, IGuild guild) + => guild == null ? 0 : client.GetShardIdFor(guild); + } + + /// + /// The sharded variant of . + /// + public class ShardedInteractionContext : ShardedInteractionContext + { + /// + /// Initializes a . + /// + /// The underlying client. + /// The underlying interaction. + public ShardedInteractionContext(DiscordShardedClient client, SocketInteraction interaction) : base(client, interaction) { } + } +} diff --git a/src/Discord.Net.WebSocket/Interactions/SocketInteractionContext.cs b/src/Discord.Net.WebSocket/Interactions/SocketInteractionContext.cs new file mode 100644 index 0000000000..4cd9ef2648 --- /dev/null +++ b/src/Discord.Net.WebSocket/Interactions/SocketInteractionContext.cs @@ -0,0 +1,82 @@ +using Discord.WebSocket; + +namespace Discord.Interactions +{ + /// + /// Represents a Web-Socket based context of an . + /// + public class SocketInteractionContext : IInteractionContext + where TInteraction : SocketInteraction + { + /// + /// Gets the that the command will be executed with. + /// + public DiscordSocketClient Client { get; } + + /// + /// Gets the the command originated from. + /// + /// + /// Will be null if the command is from a DM Channel. + /// + public SocketGuild Guild { get; } + + /// + /// Gets the the command originated from. + /// + public ISocketMessageChannel Channel { get; } + + /// + /// Gets the who executed the command. + /// + public SocketUser User { get; } + + /// + /// Gets the the command was recieved with. + /// + public TInteraction Interaction { get; } + + /// + /// Initializes a new . + /// + /// The underlying client. + /// The underlying interaction. + public SocketInteractionContext(DiscordSocketClient client, TInteraction interaction) + { + Client = client; + Channel = interaction.Channel; + Guild = (interaction.User as SocketGuildUser)?.Guild; + User = interaction.User; + Interaction = interaction; + } + + // IInteractionContext + /// + IDiscordClient IInteractionContext.Client => Client; + + /// + IGuild IInteractionContext.Guild => Guild; + + /// + IMessageChannel IInteractionContext.Channel => Channel; + + /// + IUser IInteractionContext.User => User; + + /// + IDiscordInteraction IInteractionContext.Interaction => Interaction; + } + + /// + /// Represents a Web-Socket based context of an + /// + public class SocketInteractionContext : SocketInteractionContext + { + /// + /// Initializes a new + /// + /// The underlying client + /// The underlying interaction + public SocketInteractionContext(DiscordSocketClient client, SocketInteraction interaction) : base(client, interaction) { } + } +} diff --git a/src/Discord.Net.Webhook/Discord.Net.Webhook.csproj b/src/Discord.Net.Webhook/Discord.Net.Webhook.csproj index 24ae442d70..175b486d2d 100644 --- a/src/Discord.Net.Webhook/Discord.Net.Webhook.csproj +++ b/src/Discord.Net.Webhook/Discord.Net.Webhook.csproj @@ -5,7 +5,7 @@ Discord.Net.Webhook Discord.Webhook A core Discord.Net library containing the Webhook client and models. - netstandard2.0;netstandard2.1 + net6.0;net5.0;netstandard2.0;netstandard2.1 diff --git a/test/Discord.Net.Analyzers.Tests/Discord.Net.Analyzers.Tests.csproj b/test/Discord.Net.Analyzers.Tests/Discord.Net.Analyzers.Tests.csproj index 1257041e4f..460a2c9e91 100644 --- a/test/Discord.Net.Analyzers.Tests/Discord.Net.Analyzers.Tests.csproj +++ b/test/Discord.Net.Analyzers.Tests/Discord.Net.Analyzers.Tests.csproj @@ -1,7 +1,7 @@ - + - netcoreapp3.1 + net6.0 false @@ -10,8 +10,8 @@ - + diff --git a/test/Discord.Net.Tests.Integration/Discord.Net.Tests.Integration.csproj b/test/Discord.Net.Tests.Integration/Discord.Net.Tests.Integration.csproj index 8b16b29713..0f399ab68d 100644 --- a/test/Discord.Net.Tests.Integration/Discord.Net.Tests.Integration.csproj +++ b/test/Discord.Net.Tests.Integration/Discord.Net.Tests.Integration.csproj @@ -1,7 +1,7 @@ - netcoreapp2.1 + net6.0 false @@ -10,7 +10,6 @@ - diff --git a/test/Discord.Net.Tests.Unit/Discord.Net.Tests.Unit.csproj b/test/Discord.Net.Tests.Unit/Discord.Net.Tests.Unit.csproj index 716c3ebc41..ec06c3c3df 100644 --- a/test/Discord.Net.Tests.Unit/Discord.Net.Tests.Unit.csproj +++ b/test/Discord.Net.Tests.Unit/Discord.Net.Tests.Unit.csproj @@ -1,7 +1,7 @@ - + - netcoreapp3.1 + net6.0 false @@ -9,7 +9,6 @@ -