Browse Source

Merge branch 'dev' of https://github.com/discord-net/Discord.Net into dev

pull/2495/head
cat 2 years ago
parent
commit
b1527debb1
No known key found for this signature in database GPG Key ID: 8677E1B318109AB0
96 changed files with 2612 additions and 292 deletions
  1. +62
    -0
      CHANGELOG.md
  2. +1
    -1
      Discord.Net.targets
  3. +1
    -1
      docs/docfx.json
  4. +1
    -1
      docs/guides/dependency_injection/injection.md
  5. +0
    -3
      docs/guides/int_basics/application-commands/intro.md
  6. +47
    -2
      docs/guides/int_framework/intro.md
  7. +7
    -0
      docs/guides/text_commands/intro.md
  8. +1
    -0
      docs/guides/v2_v3_guide/v2_to_v3_guide.md
  9. BIN
      docs/marketing/logo/PackageLogo.png
  10. +8
    -1
      samples/BasicBot/Program.cs
  11. +2
    -2
      samples/BasicBot/_BasicBot.csproj
  12. +1
    -1
      samples/InteractionFramework/_InteractionFramework.csproj
  13. +2
    -1
      samples/ShardedClient/Program.cs
  14. +1
    -1
      samples/ShardedClient/_ShardedClient.csproj
  15. +4
    -0
      samples/TextCommandFramework/Program.cs
  16. +2
    -2
      samples/TextCommandFramework/_TextCommandFramework.csproj
  17. +1
    -1
      samples/WebhookClient/_WebhookClient.csproj
  18. +5
    -0
      src/Discord.Net.Commands/CommandService.cs
  19. +3
    -1
      src/Discord.Net.Core/Discord.Net.Core.csproj
  20. +1
    -1
      src/Discord.Net.Core/DiscordConfig.cs
  21. +26
    -1
      src/Discord.Net.Core/DiscordErrorCode.cs
  22. +14
    -0
      src/Discord.Net.Core/Entities/AuditLogs/ActionType.cs
  23. +6
    -1
      src/Discord.Net.Core/Entities/Guilds/IGuild.cs
  24. +83
    -17
      src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs
  25. +36
    -0
      src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOptionChoice.cs
  26. +61
    -0
      src/Discord.Net.Core/Entities/Interactions/ApplicationCommandProperties.cs
  27. +64
    -1
      src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs
  28. +65
    -2
      src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs
  29. +26
    -0
      src/Discord.Net.Core/Entities/Interactions/IApplicationCommand.cs
  30. +26
    -0
      src/Discord.Net.Core/Entities/Interactions/IApplicationCommandOption.cs
  31. +15
    -0
      src/Discord.Net.Core/Entities/Interactions/IApplicationCommandOptionChoice.cs
  32. +298
    -48
      src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs
  33. +39
    -0
      src/Discord.Net.Core/Entities/Messages/Embed.cs
  34. +31
    -0
      src/Discord.Net.Core/Entities/Messages/EmbedAuthor.cs
  35. +142
    -1
      src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs
  36. +31
    -0
      src/Discord.Net.Core/Entities/Messages/EmbedField.cs
  37. +31
    -0
      src/Discord.Net.Core/Entities/Messages/EmbedFooter.cs
  38. +31
    -0
      src/Discord.Net.Core/Entities/Messages/EmbedImage.cs
  39. +31
    -0
      src/Discord.Net.Core/Entities/Messages/EmbedProvider.cs
  40. +31
    -0
      src/Discord.Net.Core/Entities/Messages/EmbedThumbnail.cs
  41. +31
    -0
      src/Discord.Net.Core/Entities/Messages/EmbedVideo.cs
  42. +6
    -0
      src/Discord.Net.Core/Entities/Messages/IMessage.cs
  43. +54
    -25
      src/Discord.Net.Core/Entities/Messages/TimestampTag.cs
  44. +15
    -0
      src/Discord.Net.Core/Extensions/GenericCollectionExtensions.cs
  45. +9
    -2
      src/Discord.Net.Core/GatewayIntents.cs
  46. +3
    -1
      src/Discord.Net.Core/IDiscordClient.cs
  47. +7
    -3
      src/Discord.Net.Core/Net/Rest/IRestClient.cs
  48. +8
    -4
      src/Discord.Net.Core/RequestOptions.cs
  49. +5
    -5
      src/Discord.Net.Core/Utils/Preconditions.cs
  50. +3
    -3
      src/Discord.Net.Interactions/Attributes/MaxLengthAttribute.cs
  51. +3
    -3
      src/Discord.Net.Interactions/Attributes/MinLengthAttribute.cs
  52. +6
    -6
      src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs
  53. +6
    -0
      src/Discord.Net.Interactions/InteractionService.cs
  54. +5
    -0
      src/Discord.Net.Interactions/InteractionServiceConfig.cs
  55. +32
    -0
      src/Discord.Net.Interactions/LocalizationManagers/ILocalizationManager.cs
  56. +72
    -0
      src/Discord.Net.Interactions/LocalizationManagers/JsonLocalizationManager.cs
  57. +55
    -0
      src/Discord.Net.Interactions/LocalizationManagers/ResxLocalizationManager.cs
  58. +25
    -0
      src/Discord.Net.Interactions/LocalizationTarget.cs
  59. +2
    -21
      src/Discord.Net.Interactions/Map/CommandMap.cs
  60. +71
    -17
      src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs
  61. +53
    -0
      src/Discord.Net.Interactions/Utilities/CommandHierarchy.cs
  62. +13
    -0
      src/Discord.Net.Rest/API/Common/ApplicationCommand.cs
  63. +21
    -0
      src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs
  64. +7
    -0
      src/Discord.Net.Rest/API/Common/ApplicationCommandOptionChoice.cs
  65. +14
    -1
      src/Discord.Net.Rest/API/Rest/CreateApplicationCommandParams.cs
  66. +13
    -5
      src/Discord.Net.Rest/API/Rest/CreateStickerParams.cs
  67. +7
    -0
      src/Discord.Net.Rest/API/Rest/ModifyApplicationCommandParams.cs
  68. +1
    -1
      src/Discord.Net.Rest/BaseDiscordClient.cs
  69. +6
    -6
      src/Discord.Net.Rest/ClientHelper.cs
  70. +28
    -5
      src/Discord.Net.Rest/DiscordRestApiClient.cs
  71. +7
    -7
      src/Discord.Net.Rest/DiscordRestClient.cs
  72. +4
    -0
      src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs
  73. +149
    -0
      src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventCreateAuditLogData.cs
  74. +34
    -0
      src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventDeleteAuditLogData.cs
  75. +80
    -0
      src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventInfo.cs
  76. +99
    -0
      src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventUpdateAuditLogData.cs
  77. +4
    -4
      src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs
  78. +10
    -6
      src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs
  79. +15
    -3
      src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs
  80. +35
    -0
      src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommand.cs
  81. +17
    -0
      src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandChoice.cs
  82. +36
    -1
      src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs
  83. +7
    -7
      src/Discord.Net.Rest/Entities/Roles/RoleHelper.cs
  84. +16
    -4
      src/Discord.Net.Rest/Net/DefaultRestClient.cs
  85. +4
    -1
      src/Discord.Net.Rest/Net/Queue/Requests/RestRequest.cs
  86. +2
    -0
      src/Discord.Net.WebSocket/API/Gateway/ReadyEvent.cs
  87. +11
    -1
      src/Discord.Net.WebSocket/DiscordShardedClient.cs
  88. +53
    -7
      src/Discord.Net.WebSocket/DiscordSocketApiClient.cs
  89. +48
    -18
      src/Discord.Net.WebSocket/DiscordSocketClient.cs
  90. +7
    -4
      src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs
  91. +35
    -0
      src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommand.cs
  92. +17
    -0
      src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandChoice.cs
  93. +35
    -0
      src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandOption.cs
  94. +31
    -31
      src/Discord.Net/Discord.Net.nuspec
  95. +1
    -0
      test/Discord.Net.Tests.Unit/ColorTests.cs
  96. +37
    -0
      test/Discord.Net.Tests.Unit/CommandBuilderTests.cs

+ 62
- 0
CHANGELOG.md View File

@@ -1,4 +1,66 @@
# Changelog # Changelog

## [3.8.1] - 2022-09-12
### Added

- #2437 Added scheduled event types to AuditLog ActionTypes (fca9c6b)
- #2423 Added support for resume gateway url (d4c533a)

### Fixed

- #2443 Fixed typos of word length (adf012d)
- #2438 Fixed http query symbol in ModifyWebhookMessageAsync (0aa381d)
- #2444 Fixed BulkOverwriteCommands NRE (9feb703)
- #2417 Fixed CommandService RemoveModuleMethod not removing modules (fca9c6b)
- #2345 Fixed EmbedBuilder.Length NRE (11ece4b)
- #2453 Fixed NRE on SlashCommandBuilder.Build method (5073afa)
- #2457 Fixed typo in SlashCommandBuilder.AddNameLocalizationMethod (1b01fed)

### Misc

- #2462 Add additional checks for gateway event warnings (b45b152)
- #2448 Bump to Discord API V10 (fbc5ad4)
- #2451 Return a list instead of an array in GetModulePath and GetChoicePath methods (370bdfa)
- #2453 Update app commands regex and fix localization on app context commands (3dec99f)
- #2333 Update package logo (2b86a79)

## [3.8.0] - 2022-08-27
### Added
- #2384 Added support for the WEBHOOKS_UPDATED event (010e8e8)
- #2370 Add async callbacks for IModuleBase (503fa75)
- #2367 Added DeleteMessagesAsync for TIV and added remaining rate limit in client log (f178660)
- #2379 Added Max/Min length fields for ApplicationCommandOption (e551431)
- #2369 Added support for using `RespondWithModalAsync<IModal>()` without prior IModal declaration (500e7b4)
- #2347 Added Embed field comparison operators (89a8ea1)
- #2359 Added support for creating lottie stickers (32b03c8)
- #2395 Added App Command localization support and `ILocalizationManager` to IF (39bbd29)

### Fixed
- #2425 Fix missing Fact attribute in ColorTests (92215b1)
- #2424 Fix IGuild.GetBansAsync() (b7b7964)
- #2416 Fix role icon & emoji assignment (b6b5e95)
- #2414 Fix NRE on RestCommandBase Data (02bc3b7)
- #2421 Fix placeholder length being hardcoded (8dfe19f)
- #2352 Fix issues related to the absence of bot scope (1eb42c6)
- #2346 Fix IGuild.DisconnectAsync(IUser) not disconnecting users (ba02416)
- #2404 Fix range of issues presented by 3rd party analyzer (902326d)
- #2409 Removes GroupContext from requirecontext (b0b8167)

### Misc
- #2366 Fixed typo in ChannelUpdatedEvent's documentation (cfd2662)
- #2408 Fix sharding sample throwing at appcommand registration (519deda)
- #2420 Fix broken code snippet in dependency injection docs (ddcf68a)
- #2430 Add a note about DontAutoRegisterAttribute (917118d)
- #2418 Update xmldocs to reflect the ConnectedUsers split (65b98f8)
- #2415 Adds missing DI entries in TOC (c49d483)
- #2407 Introduces high quality dependency injection documentation (6fdcf98)
- #2348 Added `RequiredInput` attribute to example in int.framework intro (ee6e0ad)
- #2385 Add ServerStarter.Host to deployment.md (06ed995)
- #2405 Add a note about `IgnoreGroupNames` to IF docs (cf25acd)
- #2356 Makes voice section about precompiled binaries more visible (e0d68d4 )
- #2405 IF intro docs improvements (246282d)
- #2406 Labs deprecation & readme/docs edits (bf493ea)

## [3.7.2] - 2022-06-02 ## [3.7.2] - 2022-06-02
### Added ### Added
- #2328 Add method overloads to InteractionService (0fad3e8) - #2328 Add method overloads to InteractionService (0fad3e8)


+ 1
- 1
Discord.Net.targets View File

@@ -1,6 +1,6 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup> <PropertyGroup>
<VersionPrefix>3.7.2</VersionPrefix>
<VersionPrefix>3.8.1</VersionPrefix>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Authors>Discord.Net Contributors</Authors> <Authors>Discord.Net Contributors</Authors>
<PackageTags>discord;discordapp</PackageTags> <PackageTags>discord;discordapp</PackageTags>


+ 1
- 1
docs/docfx.json View File

@@ -60,7 +60,7 @@
"overwrite": "_overwrites/**/**.md", "overwrite": "_overwrites/**/**.md",
"globalMetadata": { "globalMetadata": {
"_appTitle": "Discord.Net Documentation", "_appTitle": "Discord.Net Documentation",
"_appFooter": "Discord.Net (c) 2015-2022 3.7.2",
"_appFooter": "Discord.Net (c) 2015-2022 3.8.1",
"_enableSearch": true, "_enableSearch": true,
"_appLogoPath": "marketing/logo/SVG/Logomark Purple.svg", "_appLogoPath": "marketing/logo/SVG/Logomark Purple.svg",
"_appFaviconPath": "favicon.ico" "_appFaviconPath": "favicon.ico"


+ 1
- 1
docs/guides/dependency_injection/injection.md View File

@@ -16,7 +16,7 @@ This can be done through property or constructor.
Services can be injected from the constructor of the class. Services can be injected from the constructor of the class.
This is the preferred approach, because it automatically locks the readonly field in place with the provided service and isn't accessible outside of the class. This is the preferred approach, because it automatically locks the readonly field in place with the provided service and isn't accessible outside of the class.


[!code-csharp[Property Injection(samples/property-injecting.cs)]]
[!code-csharp[Constructor Injection](samples/ctor-injecting.cs)]


## Injecting through properties ## Injecting through properties




+ 0
- 3
docs/guides/int_basics/application-commands/intro.md View File

@@ -18,9 +18,6 @@ The name and description help users find your command among many others, and the
Message and User commands are only a name, to the user. So try to make the name descriptive. Message and User commands are only a name, to the user. So try to make the name descriptive.
They're accessed by right clicking (or long press, on mobile) a user or a message, respectively. They're accessed by right clicking (or long press, on mobile) a user or a message, respectively.


> [!IMPORTANT]
> Context menu commands are currently not supported on mobile.

All three varieties of application commands have both Global and Guild variants. All three varieties of application commands have both Global and Guild variants.
Your global commands are available in every guild that adds your application. Your global commands are available in every guild that adds your application.
You can also make commands for a specific guild; they're only available in that guild. You can also make commands for a specific guild; they're only available in that guild.


+ 47
- 2
docs/guides/int_framework/intro.md View File

@@ -294,7 +294,7 @@ By nesting commands inside a module that is tagged with [GroupAttribute] you can
> [!NOTE] > [!NOTE]
> To not use the command group's name as a prefix for component or modal interaction's custom id set `ignoreGroupNames` parameter to `true` in classes with [GroupAttribute] > To not use the command group's name as a prefix for component or modal interaction's custom id set `ignoreGroupNames` parameter to `true` in classes with [GroupAttribute]
> >
> However, you have to be careful to prevent overlapping ids of buttons and modals
> However, you have to be careful to prevent overlapping ids of buttons and modals.


[!code-csharp[Command Group Example](samples/intro/groupmodule.cs)] [!code-csharp[Command Group Example](samples/intro/groupmodule.cs)]


@@ -346,10 +346,13 @@ Command registration methods can only be used after the gateway client is ready
Methods like `AddModulesToGuildAsync()`, `AddCommandsToGuildAsync()`, `AddModulesGloballyAsync()` and `AddCommandsGloballyAsync()` Methods like `AddModulesToGuildAsync()`, `AddCommandsToGuildAsync()`, `AddModulesGloballyAsync()` and `AddCommandsGloballyAsync()`
can be used to register cherry picked modules or commands to global/guild scopes. can be used to register cherry picked modules or commands to global/guild scopes.


> [!NOTE]
> [DontAutoRegisterAttribute] can be used on module classes to prevent `RegisterCommandsGloballyAsync()` and `RegisterCommandsToGuildAsync()` from registering them to the Discord.

> [!NOTE] > [!NOTE]
> In debug environment, since Global commands can take up to 1 hour to register/update, > In debug environment, since Global commands can take up to 1 hour to register/update,
> it is adviced to register your commands to a test guild for your changes to take effect immediately. > it is adviced to register your commands to a test guild for your changes to take effect immediately.
> You can use preprocessor directives to create a simple logic for registering commands as seen above
> You can use preprocessor directives to create a simple logic for registering commands as seen above.


## Interaction Utility ## Interaction Utility


@@ -373,10 +376,52 @@ respond to the Interactions within your command modules you need to perform the
delegate can be used to create HTTP responses from a deserialized json object string. delegate can be used to create HTTP responses from a deserialized json object string.
- Use the interaction endpoints of the module base instead of the interaction object (ie. `RespondAsync()`, `FollowupAsync()`...). - Use the interaction endpoints of the module base instead of the interaction object (ie. `RespondAsync()`, `FollowupAsync()`...).


## Localization

Discord Slash Commands support name/description localization. Localization is available for names and descriptions of Slash Command Groups ([GroupAttribute]), Slash Commands ([SlashCommandAttribute]), Slash Command parameters and Slash Command Parameter Choices. Interaction Service can be initialized with an `ILocalizationManager` instance in its config which is used to create the necessary localization dictionaries on command registration. Interaction Service has two built-in `ILocalizationManager` implementations: `ResxLocalizationManager` and `JsonLocalizationManager`.

### ResXLocalizationManager

`ResxLocalizationManager` uses `.` delimited key names to traverse the resource files and get the localized strings (`group1.group2.command.parameter.name`). A `ResxLocalizationManager` instance must be initialized with a base resource name, a target assembly and a collection of `CultureInfo`s. Every key path must end with either `.name` or `.description`, including parameter choice strings. [Discord.Tools.LocalizationTemplate.Resx](https://www.nuget.org/packages/Discord.Tools.LocalizationTemplate.Resx) dotnet tool can be used to create localization file templates.

### JsonLocalizationManager

`JsonLocaliationManager` uses a nested data structure similar to Discord's Application Commands schema. You can get the Json schema [here](https://gist.github.com/Cenngo/d46a881de24823302f66c3c7e2f7b254). `JsonLocalizationManager` accepts a base path and a base file name and automatically discovers every resource file ( \basePath\fileName.locale.json ). A Json resource file should have a structure similar to:

```json
{
"command_1":{
"name": "localized_name",
"description": "localized_description",
"parameter_1":{
"name": "localized_name",
"description": "localized_description"
}
},
"group_1":{
"name": "localized_name",
"description": "localized_description",
"command_1":{
"name": "localized_name",
"description": "localized_description",
"parameter_1":{
"name": "localized_name",
"description": "localized_description"
},
"parameter_2":{
"name": "localized_name",
"description": "localized_description"
}
}
}
}
```

[AutocompleteHandlers]: xref:Guides.IntFw.AutoCompletion [AutocompleteHandlers]: xref:Guides.IntFw.AutoCompletion
[DependencyInjection]: xref:Guides.DI.Intro [DependencyInjection]: xref:Guides.DI.Intro


[GroupAttribute]: xref:Discord.Interactions.GroupAttribute [GroupAttribute]: xref:Discord.Interactions.GroupAttribute
[DontAutoRegisterAttribute]: xref:Discord.Interactions.DontAutoRegisterAttribute
[InteractionService]: xref:Discord.Interactions.InteractionService [InteractionService]: xref:Discord.Interactions.InteractionService
[InteractionServiceConfig]: xref:Discord.Interactions.InteractionServiceConfig [InteractionServiceConfig]: xref:Discord.Interactions.InteractionServiceConfig
[InteractionModuleBase]: xref:Discord.Interactions.InteractionModuleBase [InteractionModuleBase]: xref:Discord.Interactions.InteractionModuleBase


+ 7
- 0
docs/guides/text_commands/intro.md View File

@@ -8,6 +8,13 @@ title: Introduction to the Chat Command Service
[Discord.Commands](xref:Discord.Commands) provides an attribute-based [Discord.Commands](xref:Discord.Commands) provides an attribute-based
command parser. command parser.


> [!IMPORTANT]
> The 'Message Content' intent, required for text commands, is now a
> privilleged intent. Please use [Slash commands](xref:Guides.SlashCommands.Intro)
> instead for making commands. For more information about this change
> please check [this announcement made by discord](https://support-dev.discord.com/hc/en-us/articles/4404772028055-Message-Content-Privileged-Intent-FAQ)


## Get Started ## Get Started


To use commands, you must create a [Command Service] and a command To use commands, you must create a [Command Service] and a command


+ 1
- 0
docs/guides/v2_v3_guide/v2_to_v3_guide.md View File

@@ -37,6 +37,7 @@ _client = new DiscordSocketClient(config);
- AllUnprivileged: This is a group of most common intents, that do NOT require any [developer portal] intents to be enabled. - AllUnprivileged: This is a group of most common intents, that do NOT require any [developer portal] intents to be enabled.
This includes intents that receive messages such as: `GatewayIntents.GuildMessages, GatewayIntents.DirectMessages` This includes intents that receive messages such as: `GatewayIntents.GuildMessages, GatewayIntents.DirectMessages`
- GuildMembers: An intent disabled by default, as you need to enable it in the [developer portal]. - GuildMembers: An intent disabled by default, as you need to enable it in the [developer portal].
- MessageContent: An intent also disabled by default as you also need to enable it in the [developer portal].
- GuildPresences: Also disabled by default, this intent together with `GuildMembers` are the only intents not included in `AllUnprivileged`. - GuildPresences: Also disabled by default, this intent together with `GuildMembers` are the only intents not included in `AllUnprivileged`.
- All: All intents, it is ill advised to use this without care, as it _can_ cause a memory leak from presence. - All: All intents, it is ill advised to use this without care, as it _can_ cause a memory leak from presence.
The library will give responsive warnings if you specify unnecessary intents. The library will give responsive warnings if you specify unnecessary intents.


BIN
docs/marketing/logo/PackageLogo.png View File

Before After
Width: 462  |  Height: 477  |  Size: 11 KiB Width: 1000  |  Height: 1000  |  Size: 76 KiB

+ 8
- 1
samples/BasicBot/Program.cs View File

@@ -34,9 +34,16 @@ namespace BasicBot


public Program() public Program()
{ {
// Config used by DiscordSocketClient
// Define intents for the client
var config = new DiscordSocketConfig
{
GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent
};

// It is recommended to Dispose of a client when you are finished // It is recommended to Dispose of a client when you are finished
// using it, at the end of your app's lifetime. // using it, at the end of your app's lifetime.
_client = new DiscordSocketClient();
_client = new DiscordSocketClient(config);


// Subscribing to client events, so that we may receive them whenever they're invoked. // Subscribing to client events, so that we may receive them whenever they're invoked.
_client.Log += LogAsync; _client.Log += LogAsync;


+ 2
- 2
samples/BasicBot/_BasicBot.csproj View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">


<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
@@ -6,7 +6,7 @@
</PropertyGroup> </PropertyGroup>


<ItemGroup> <ItemGroup>
<PackageReference Include="Discord.Net.WebSocket" Version="3.6.1"/>
<PackageReference Include="Discord.Net.WebSocket" Version="3.8.1"/>
</ItemGroup> </ItemGroup>


</Project> </Project>

+ 1
- 1
samples/InteractionFramework/_InteractionFramework.csproj View File

@@ -13,7 +13,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
<PackageReference Include="Discord.Net.Interactions" Version="3.6.1" />
<PackageReference Include="Discord.Net.Interactions" Version="3.8.1" />
</ItemGroup> </ItemGroup>


</Project> </Project>

+ 2
- 1
samples/ShardedClient/Program.cs View File

@@ -28,7 +28,8 @@ namespace ShardedClient
// have 1 shard per 1500-2000 guilds your bot is in. // have 1 shard per 1500-2000 guilds your bot is in.
var config = new DiscordSocketConfig var config = new DiscordSocketConfig
{ {
TotalShards = 2
TotalShards = 2,
GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent
}; };


// You should dispose a service provider created using ASP.NET // You should dispose a service provider created using ASP.NET


+ 1
- 1
samples/ShardedClient/_ShardedClient.csproj View File

@@ -8,7 +8,7 @@


<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
<PackageReference Include="Discord.Net" Version="3.6.1" />
<PackageReference Include="Discord.Net" Version="3.8.1" />
</ItemGroup> </ItemGroup>


</Project> </Project>

+ 4
- 0
samples/TextCommandFramework/Program.cs View File

@@ -60,6 +60,10 @@ namespace TextCommandFramework
private ServiceProvider ConfigureServices() private ServiceProvider ConfigureServices()
{ {
return new ServiceCollection() return new ServiceCollection()
.AddSingleton(new DiscordSocketConfig
{
GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent
})
.AddSingleton<DiscordSocketClient>() .AddSingleton<DiscordSocketClient>()
.AddSingleton<CommandService>() .AddSingleton<CommandService>()
.AddSingleton<CommandHandlingService>() .AddSingleton<CommandHandlingService>()


+ 2
- 2
samples/TextCommandFramework/_TextCommandFramework.csproj View File

@@ -8,8 +8,8 @@


<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
<PackageReference Include="Discord.Net.Commands" Version="3.6.1" />
<PackageReference Include="Discord.Net.Websocket" Version="3.6.1" />
<PackageReference Include="Discord.Net.Commands" Version="3.8.1" />
<PackageReference Include="Discord.Net.Websocket" Version="3.8.1" />
</ItemGroup> </ItemGroup>


</Project> </Project>

+ 1
- 1
samples/WebhookClient/_WebhookClient.csproj View File

@@ -7,7 +7,7 @@
</PropertyGroup> </PropertyGroup>


<ItemGroup> <ItemGroup>
<PackageReference Include="Discord.Net.Webhook" Version="3.6.1" />
<PackageReference Include="Discord.Net.Webhook" Version="3.8.1" />
</ItemGroup> </ItemGroup>


</Project> </Project>

+ 5
- 0
src/Discord.Net.Commands/CommandService.cs View File

@@ -270,6 +270,11 @@ namespace Discord.Commands
await _moduleLock.WaitAsync().ConfigureAwait(false); await _moduleLock.WaitAsync().ConfigureAwait(false);
try try
{ {
var typeModulePair = _typedModuleDefs.FirstOrDefault(x => x.Value.Equals(module));

if (!typeModulePair.Equals(default(KeyValuePair<Type, ModuleInfo>)))
_typedModuleDefs.TryRemove(typeModulePair.Key, out var _);

return RemoveModuleInternal(module); return RemoveModuleInternal(module);
} }
finally finally


+ 3
- 1
src/Discord.Net.Core/Discord.Net.Core.csproj View File

@@ -16,7 +16,6 @@
<PackageReference Include="IDisposableAnalyzers" Version="3.4.15"> <PackageReference Include="IDisposableAnalyzers" Version="3.4.15">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.1' "> <ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.1' ">
<PackageReference Include="System.Collections.Immutable" Version="1.3.1" /> <PackageReference Include="System.Collections.Immutable" Version="1.3.1" />
@@ -27,4 +26,7 @@
<ItemGroup Condition=" '$(TargetFramework)' == 'net461' "> <ItemGroup Condition=" '$(TargetFramework)' == 'net461' ">
<PackageReference Include="System.ValueTuple" Version="4.4.0" /> <PackageReference Include="System.ValueTuple" Version="4.4.0" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' != 'net461'">
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
</ItemGroup>
</Project> </Project>

+ 1
- 1
src/Discord.Net.Core/DiscordConfig.cs View File

@@ -18,7 +18,7 @@ namespace Discord
/// <see href="https://discord.com/developers/docs/reference#api-versioning">Discord API documentation</see> /// <see href="https://discord.com/developers/docs/reference#api-versioning">Discord API documentation</see>
/// .</para> /// .</para>
/// </returns> /// </returns>
public const int APIVersion = 9;
public const int APIVersion = 10;
/// <summary> /// <summary>
/// Returns the Voice API version Discord.Net uses. /// Returns the Voice API version Discord.Net uses.
/// </summary> /// </summary>


+ 26
- 1
src/Discord.Net.Core/DiscordErrorCode.cs View File

@@ -66,6 +66,7 @@ namespace Discord
ActionSlowmode = 20016, ActionSlowmode = 20016,
OnlyOwnerAction = 20018, OnlyOwnerAction = 20018,
AnnouncementEditRatelimit = 20022, AnnouncementEditRatelimit = 20022,
UnderMinimumAge = 20024,
ChannelWriteRatelimit = 20028, ChannelWriteRatelimit = 20028,
WriteRatelimitReached = 20029, WriteRatelimitReached = 20029,
WordsNotAllowed = 20031, WordsNotAllowed = 20031,
@@ -88,7 +89,9 @@ namespace Discord
MaximumServerMembersReached = 30019, MaximumServerMembersReached = 30019,
MaximumServerCategoriesReached = 30030, MaximumServerCategoriesReached = 30030,
GuildTemplateAlreadyExists = 30031, GuildTemplateAlreadyExists = 30031,
MaximumNumberOfApplicationCommandsReached = 30032,
MaximumThreadMembersReached = 30033, MaximumThreadMembersReached = 30033,
MaxNumberOfDailyApplicationCommandCreatesHasBeenReached = 30034,
MaximumBansForNonGuildMembersReached = 30035, MaximumBansForNonGuildMembersReached = 30035,
MaximumBanFetchesReached = 30037, MaximumBanFetchesReached = 30037,
MaximumUncompleteGuildScheduledEvents = 30038, MaximumUncompleteGuildScheduledEvents = 30038,
@@ -98,6 +101,7 @@ namespace Discord
#endregion #endregion


#region General Request Errors (40XXX) #region General Request Errors (40XXX)
BitrateIsTooHighForChannelOfThisType = 30052,
MaximumNumberOfEditsReached = 30046, MaximumNumberOfEditsReached = 30046,
MaximumNumberOfPinnedThreadsInAForumChannelReached = 30047, MaximumNumberOfPinnedThreadsInAForumChannelReached = 30047,
MaximumNumberOfTagsInAForumChannelReached = 30048, MaximumNumberOfTagsInAForumChannelReached = 30048,
@@ -108,12 +112,17 @@ namespace Discord
RequestEntityTooLarge = 40005, RequestEntityTooLarge = 40005,
FeatureDisabled = 40006, FeatureDisabled = 40006,
UserBanned = 40007, UserBanned = 40007,
ConnectionHasBeenRevoked = 40012,
TargetUserNotInVoice = 40032, TargetUserNotInVoice = 40032,
MessageAlreadyCrossposted = 40033, MessageAlreadyCrossposted = 40033,
ApplicationNameAlreadyExists = 40041, ApplicationNameAlreadyExists = 40041,
#endregion #endregion


#region Action Preconditions/Checks (50XXX) #region Action Preconditions/Checks (50XXX)
ApplicationInteractionFailedToSend = 40043,
CannotSendAMessageInAForumChannel = 40058,
ThereAreNoTagsAvailableThatCanBeSetByNonModerators = 40066,
ATagIsRequiredToCreateAForumPostInThisChannel = 40067,
InteractionHasAlreadyBeenAcknowledged = 40060, InteractionHasAlreadyBeenAcknowledged = 40060,
TagNamesMustBeUnique = 40061, TagNamesMustBeUnique = 40061,
MissingPermissions = 50001, MissingPermissions = 50001,
@@ -132,6 +141,7 @@ namespace Discord
InvalidAuthenticationToken = 50014, InvalidAuthenticationToken = 50014,
NoteTooLong = 50015, NoteTooLong = 50015,
ProvidedMessageDeleteCountOutOfBounds = 50016, ProvidedMessageDeleteCountOutOfBounds = 50016,
InvalidMFALevel = 50017,
InvalidPinChannel = 50019, InvalidPinChannel = 50019,
InvalidInvite = 50020, InvalidInvite = 50020,
CannotExecuteOnSystemMessage = 50021, CannotExecuteOnSystemMessage = 50021,
@@ -162,10 +172,14 @@ namespace Discord
ServerRequiresMonetization = 50097, ServerRequiresMonetization = 50097,
ServerRequiresBoosts = 50101, ServerRequiresBoosts = 50101,
RequestBodyContainsInvalidJSON = 50109, RequestBodyContainsInvalidJSON = 50109,
FailedToResizeAssetBelowTheMaximumSize = 50138,
OwnershipCannotBeTransferredToABotUser = 50132,
AssetResizeBelowTheMaximumSize= 50138,
UploadedFileNotFound = 50146,
MissingPermissionToSendThisSticker = 50600,
#endregion #endregion


#region 2FA (60XXX) #region 2FA (60XXX)
MissingPermissionToSendThisSticker = 50600,
Requires2FA = 60003, Requires2FA = 60003,
#endregion #endregion


@@ -178,6 +192,7 @@ namespace Discord
#endregion #endregion


#region API Status (130XXX) #region API Status (130XXX)
ApplicationNotYetAvailable = 110001,
APIOverloaded = 130000, APIOverloaded = 130000,
#endregion #endregion


@@ -207,5 +222,15 @@ namespace Discord
CannotUpdateFinishedEvent = 180000, CannotUpdateFinishedEvent = 180000,
FailedStageCreation = 180002, FailedStageCreation = 180002,
#endregion #endregion
#region Forum & Automod
MessageWasBlockedByAutomaticModeration = 200000,
TitleWasBlockedByAutomaticModeration = 200001,
WebhooksPostedToForumChannelsMustHaveAThreadNameOrThreadId = 220001,
WebhooksPostedToForumChannelsCannotHaveBothAThreadNameAndThreadId = 220002,
WebhooksCanOnlyCreateThreadsInForumChannels = 220003,
WebhookServicesCannotBeUsedInForumChannels = 220004,
MessageBlockedByHarmfulLinksFilter = 240000,
#endregion
} }
} }

+ 14
- 0
src/Discord.Net.Core/Entities/AuditLogs/ActionType.cs View File

@@ -180,6 +180,20 @@ namespace Discord
/// A sticker was deleted. /// A sticker was deleted.
/// </summary> /// </summary>
StickerDeleted = 92, StickerDeleted = 92,

/// <summary>
/// A scheduled event was created.
/// </summary>
EventCreate = 100,
/// <summary>
/// A scheduled event was created.
/// </summary>
EventUpdate = 101,
/// <summary>
/// A scheduled event was created.
/// </summary>
EventDelete = 102,

/// <summary> /// <summary>
/// A thread was created. /// A thread was created.
/// </summary> /// </summary>


+ 6
- 1
src/Discord.Net.Core/Entities/Guilds/IGuild.cs View File

@@ -1194,12 +1194,17 @@ namespace Discord
/// <summary> /// <summary>
/// Gets this guilds application commands. /// Gets this guilds application commands.
/// </summary> /// </summary>
/// <param name="withLocalizations">
/// Whether to include full localization dictionaries in the returned objects,
/// instead of the localized name and description fields.
/// </param>
/// <param name="locale">The target locale of the localized name and description fields. Sets the <c>X-Discord-Locale</c> header, which takes precedence over <c>Accept-Language</c>.</param>
/// <param name="options">The options to be used when sending the request.</param> /// <param name="options">The options to be used when sending the request.</param>
/// <returns> /// <returns>
/// A task that represents the asynchronous get operation. The task result contains a read-only collection /// A task that represents the asynchronous get operation. The task result contains a read-only collection
/// of application commands found within the guild. /// of application commands found within the guild.
/// </returns> /// </returns>
Task<IReadOnlyCollection<IApplicationCommand>> GetApplicationCommandsAsync(RequestOptions options = null);
Task<IReadOnlyCollection<IApplicationCommand>> GetApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null);


/// <summary> /// <summary>
/// Gets an application command within this guild with the specified id. /// Gets an application command within this guild with the specified id.


+ 83
- 17
src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@@ -12,6 +13,8 @@ namespace Discord
{ {
private string _name; private string _name;
private string _description; private string _description;
private IDictionary<string, string> _nameLocalizations = new Dictionary<string, string>();
private IDictionary<string, string> _descriptionLocalizations = new Dictionary<string, string>();


/// <summary> /// <summary>
/// Gets or sets the name of this option. /// Gets or sets the name of this option.
@@ -21,18 +24,7 @@ namespace Discord
get => _name; get => _name;
set set
{ {
if (value == null)
throw new ArgumentNullException(nameof(value), $"{nameof(Name)} cannot be null.");

if (value.Length > 32)
throw new ArgumentOutOfRangeException(nameof(value), "Name length must be less than or equal to 32.");

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.");

EnsureValidOptionName(value);
_name = value; _name = value;
} }
} }
@@ -43,12 +35,11 @@ namespace Discord
public string Description public string Description
{ {
get => _description; get => _description;
set => _description = value?.Length switch
set
{ {
> 100 => throw new ArgumentOutOfRangeException(nameof(value), "Description length must be less than or equal to 100."),
0 => throw new ArgumentOutOfRangeException(nameof(value), "Description length must be at least 1."),
_ => value
};
EnsureValidOptionDescription(value);
_description = value;
}
} }


/// <summary> /// <summary>
@@ -105,5 +96,80 @@ namespace Discord
/// Gets or sets the allowed channel types for this option. /// Gets or sets the allowed channel types for this option.
/// </summary> /// </summary>
public List<ChannelType> ChannelTypes { get; set; } public List<ChannelType> ChannelTypes { get; set; }

/// <summary>
/// Gets or sets the localization dictionary for the name field of this option.
/// </summary>
/// <exception cref="ArgumentException">Thrown when any of the dictionary keys is an invalid locale.</exception>
public IDictionary<string, string> NameLocalizations
{
get => _nameLocalizations;
set
{
if (value != null)
{
foreach (var (locale, name) in value)
{
if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

EnsureValidOptionName(name);
}
}

_nameLocalizations = value;
}
}

/// <summary>
/// Gets or sets the localization dictionary for the description field of this option.
/// </summary>
/// <exception cref="ArgumentException">Thrown when any of the dictionary keys is an invalid locale.</exception>
public IDictionary<string, string> DescriptionLocalizations
{
get => _descriptionLocalizations;
set
{
if (value != null)
{
foreach (var (locale, description) in value)
{
if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

EnsureValidOptionDescription(description);
}
}

_descriptionLocalizations = value;
}
}

private static void EnsureValidOptionName(string name)
{
if (name == null)
throw new ArgumentNullException(nameof(name), $"{nameof(Name)} cannot be null.");

if (name.Length > 32)
throw new ArgumentOutOfRangeException(nameof(name), "Name length must be less than or equal to 32.");

if (!Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$"))
throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name));

if (name.Any(char.IsUpper))
throw new FormatException("Name cannot contain any uppercase characters.");
}

private static void EnsureValidOptionDescription(string description)
{
switch (description.Length)
{
case > 100:
throw new ArgumentOutOfRangeException(nameof(description),
"Description length must be less than or equal to 100.");
case 0:
throw new ArgumentOutOfRangeException(nameof(description), "Description length must at least 1.");
}
}
} }
} }

+ 36
- 0
src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOptionChoice.cs View File

@@ -1,4 +1,8 @@
using System; using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;


namespace Discord namespace Discord
{ {
@@ -9,6 +13,7 @@ namespace Discord
{ {
private string _name; private string _name;
private object _value; private object _value;
private IDictionary<string, string> _nameLocalizations = new Dictionary<string, string>();


/// <summary> /// <summary>
/// Gets or sets the name of this choice. /// Gets or sets the name of this choice.
@@ -40,5 +45,36 @@ namespace Discord
_value = value; _value = value;
} }
} }

/// <summary>
/// Gets or sets the localization dictionary for the name field of this choice.
/// </summary>
/// <exception cref="ArgumentException">Thrown when any of the dictionary keys is an invalid locale.</exception>
public IDictionary<string, string> NameLocalizations
{
get => _nameLocalizations;
set
{
if (value != null)
{
foreach (var (locale, name) in value)
{
if (!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException("Key values of the dictionary must be valid language codes.");

switch (name.Length)
{
case > 100:
throw new ArgumentOutOfRangeException(nameof(value),
"Name length must be less than or equal to 100.");
case 0:
throw new ArgumentOutOfRangeException(nameof(value), "Name length must at least 1.");
}
}
}

_nameLocalizations = value;
}
}
} }
} }

+ 61
- 0
src/Discord.Net.Core/Entities/Interactions/ApplicationCommandProperties.cs View File

@@ -1,3 +1,10 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.RegularExpressions;

namespace Discord namespace Discord
{ {
/// <summary> /// <summary>
@@ -5,6 +12,9 @@ namespace Discord
/// </summary> /// </summary>
public abstract class ApplicationCommandProperties public abstract class ApplicationCommandProperties
{ {
private IReadOnlyDictionary<string, string> _nameLocalizations;
private IReadOnlyDictionary<string, string> _descriptionLocalizations;

internal abstract ApplicationCommandType Type { get; } internal abstract ApplicationCommandType Type { get; }


/// <summary> /// <summary>
@@ -17,6 +27,57 @@ namespace Discord
/// </summary> /// </summary>
public Optional<bool> IsDefaultPermission { get; set; } public Optional<bool> IsDefaultPermission { get; set; }


/// <summary>
/// Gets or sets the localization dictionary for the name field of this command.
/// </summary>
public IReadOnlyDictionary<string, string> NameLocalizations
{
get => _nameLocalizations;
set
{
if (value != null)
{
foreach (var (locale, name) in value)
{
if (!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

Preconditions.AtLeast(name.Length, 1, nameof(name));
Preconditions.AtMost(name.Length, SlashCommandBuilder.MaxNameLength, nameof(name));

if (Type == ApplicationCommandType.Slash && !Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$"))
throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name));
}
}

_nameLocalizations = value;
}
}

/// <summary>
/// Gets or sets the localization dictionary for the description field of this command.
/// </summary>
public IReadOnlyDictionary<string, string> DescriptionLocalizations
{
get => _descriptionLocalizations;
set
{
if (value != null)
{
foreach (var (locale, description) in value)
{
if (!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

Preconditions.AtLeast(description.Length, 1, nameof(description));
Preconditions.AtMost(description.Length, SlashCommandBuilder.MaxDescriptionLength, nameof(description));
}
}

_descriptionLocalizations = value;
}
}

/// <summary> /// <summary>
/// Gets or sets whether or not this command can be used in DMs. /// Gets or sets whether or not this command can be used in DMs.
/// </summary> /// </summary>


+ 64
- 1
src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs View File

@@ -1,3 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

namespace Discord namespace Discord
{ {
/// <summary> /// <summary>
@@ -31,6 +36,11 @@ namespace Discord
/// </summary> /// </summary>
public bool IsDefaultPermission { get; set; } = true; public bool IsDefaultPermission { get; set; } = true;


/// <summary>
/// Gets the localization dictionary for the name field of this command.
/// </summary>
public IReadOnlyDictionary<string, string> NameLocalizations => _nameLocalizations;

/// <summary> /// <summary>
/// Gets or sets whether or not this command can be used in DMs. /// Gets or sets whether or not this command can be used in DMs.
/// </summary> /// </summary>
@@ -42,6 +52,7 @@ namespace Discord
public GuildPermission? DefaultMemberPermissions { get; set; } public GuildPermission? DefaultMemberPermissions { get; set; }


private string _name; private string _name;
private Dictionary<string, string> _nameLocalizations;


/// <summary> /// <summary>
/// Build the current builder into a <see cref="MessageCommandProperties"/> class. /// Build the current builder into a <see cref="MessageCommandProperties"/> class.
@@ -56,7 +67,8 @@ namespace Discord
Name = Name, Name = Name,
IsDefaultPermission = IsDefaultPermission, IsDefaultPermission = IsDefaultPermission,
IsDMEnabled = IsDMEnabled, IsDMEnabled = IsDMEnabled,
DefaultMemberPermissions = DefaultMemberPermissions ?? Optional<GuildPermission>.Unspecified
DefaultMemberPermissions = DefaultMemberPermissions ?? Optional<GuildPermission>.Unspecified,
NameLocalizations = NameLocalizations
}; };


return props; return props;
@@ -86,6 +98,30 @@ namespace Discord
return this; return this;
} }


/// <summary>
/// Sets the <see cref="NameLocalizations"/> collection.
/// </summary>
/// <param name="nameLocalizations">The localization dictionary to use for the name field of this command.</param>
/// <returns></returns>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="nameLocalizations"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown if any dictionary key is an invalid locale string.</exception>
public MessageCommandBuilder WithNameLocalizations(IDictionary<string, string> nameLocalizations)
{
if (nameLocalizations is null)
throw new ArgumentNullException(nameof(nameLocalizations));

foreach (var (locale, name) in nameLocalizations)
{
if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

EnsureValidCommandName(name);
}

_nameLocalizations = new Dictionary<string, string>(nameLocalizations);
return this;
}

/// <summary> /// <summary>
/// Sets whether or not this command can be used in dms /// Sets whether or not this command can be used in dms
/// </summary> /// </summary>
@@ -97,6 +133,33 @@ namespace Discord
return this; return this;
} }


/// <summary>
/// Adds a new entry to the <see cref="NameLocalizations"/> collection.
/// </summary>
/// <param name="locale">Locale of the entry.</param>
/// <param name="name">Localized string for the name field.</param>
/// <returns>The current builder.</returns>
/// <exception cref="ArgumentException">Thrown if <paramref name="locale"/> is an invalid locale string.</exception>
public MessageCommandBuilder AddNameLocalization(string locale, string name)
{
if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

EnsureValidCommandName(name);

_nameLocalizations ??= new();
_nameLocalizations.Add(locale, name);

return this;
}

private static void EnsureValidCommandName(string name)
{
Preconditions.NotNullOrEmpty(name, nameof(name));
Preconditions.AtLeast(name.Length, 1, nameof(name));
Preconditions.AtMost(name.Length, MaxNameLength, nameof(name));
}

/// <summary> /// <summary>
/// Sets the default member permissions required to use this application command. /// Sets the default member permissions required to use this application command.
/// </summary> /// </summary>


+ 65
- 2
src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs View File

@@ -1,3 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

namespace Discord namespace Discord
{ {
/// <summary> /// <summary>
@@ -5,7 +10,7 @@ namespace Discord
/// </summary> /// </summary>
public class UserCommandBuilder public class UserCommandBuilder
{ {
/// <summary>
/// <summary>
/// Returns the maximum length a commands name allowed by Discord. /// Returns the maximum length a commands name allowed by Discord.
/// </summary> /// </summary>
public const int MaxNameLength = 32; public const int MaxNameLength = 32;
@@ -31,6 +36,11 @@ namespace Discord
/// </summary> /// </summary>
public bool IsDefaultPermission { get; set; } = true; public bool IsDefaultPermission { get; set; } = true;


/// <summary>
/// Gets the localization dictionary for the name field of this command.
/// </summary>
public IReadOnlyDictionary<string, string> NameLocalizations => _nameLocalizations;

/// <summary> /// <summary>
/// Gets or sets whether or not this command can be used in DMs. /// Gets or sets whether or not this command can be used in DMs.
/// </summary> /// </summary>
@@ -42,6 +52,7 @@ namespace Discord
public GuildPermission? DefaultMemberPermissions { get; set; } public GuildPermission? DefaultMemberPermissions { get; set; }


private string _name; private string _name;
private Dictionary<string, string> _nameLocalizations;


/// <summary> /// <summary>
/// Build the current builder into a <see cref="UserCommandProperties"/> class. /// Build the current builder into a <see cref="UserCommandProperties"/> class.
@@ -54,7 +65,8 @@ namespace Discord
Name = Name, Name = Name,
IsDefaultPermission = IsDefaultPermission, IsDefaultPermission = IsDefaultPermission,
IsDMEnabled = IsDMEnabled, IsDMEnabled = IsDMEnabled,
DefaultMemberPermissions = DefaultMemberPermissions ?? Optional<GuildPermission>.Unspecified
DefaultMemberPermissions = DefaultMemberPermissions ?? Optional<GuildPermission>.Unspecified,
NameLocalizations = NameLocalizations
}; };


return props; return props;
@@ -84,6 +96,30 @@ namespace Discord
return this; return this;
} }


/// <summary>
/// Sets the <see cref="NameLocalizations"/> collection.
/// </summary>
/// <param name="nameLocalizations">The localization dictionary to use for the name field of this command.</param>
/// <returns>The current builder.</returns>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="nameLocalizations"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown if any dictionary key is an invalid locale string.</exception>
public UserCommandBuilder WithNameLocalizations(IDictionary<string, string> nameLocalizations)
{
if (nameLocalizations is null)
throw new ArgumentNullException(nameof(nameLocalizations));

foreach (var (locale, name) in nameLocalizations)
{
if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

EnsureValidCommandName(name);
}

_nameLocalizations = new Dictionary<string, string>(nameLocalizations);
return this;
}
/// <summary> /// <summary>
/// Sets whether or not this command can be used in dms /// Sets whether or not this command can be used in dms
/// </summary> /// </summary>
@@ -95,6 +131,33 @@ namespace Discord
return this; return this;
} }


/// <summary>
/// Adds a new entry to the <see cref="NameLocalizations"/> collection.
/// </summary>
/// <param name="locale">Locale of the entry.</param>
/// <param name="name">Localized string for the name field.</param>
/// <returns>The current builder.</returns>
/// <exception cref="ArgumentException">Thrown if <paramref name="locale"/> is an invalid locale string.</exception>
public UserCommandBuilder AddNameLocalization(string locale, string name)
{
if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

EnsureValidCommandName(name);

_nameLocalizations ??= new();
_nameLocalizations.Add(locale, name);

return this;
}

private static void EnsureValidCommandName(string name)
{
Preconditions.NotNullOrEmpty(name, nameof(name));
Preconditions.AtLeast(name.Length, 1, nameof(name));
Preconditions.AtMost(name.Length, MaxNameLength, nameof(name));
}

/// <summary> /// <summary>
/// Sets the default member permissions required to use this application command. /// Sets the default member permissions required to use this application command.
/// </summary> /// </summary>


+ 26
- 0
src/Discord.Net.Core/Entities/Interactions/IApplicationCommand.cs View File

@@ -52,6 +52,32 @@ namespace Discord
/// </summary> /// </summary>
IReadOnlyCollection<IApplicationCommandOption> Options { get; } IReadOnlyCollection<IApplicationCommandOption> Options { get; }


/// <summary>
/// Gets the localization dictionary for the name field of this command.
/// </summary>
IReadOnlyDictionary<string, string> NameLocalizations { get; }

/// <summary>
/// Gets the localization dictionary for the description field of this command.
/// </summary>
IReadOnlyDictionary<string, string> DescriptionLocalizations { get; }

/// <summary>
/// Gets the localized name of this command.
/// </summary>
/// <remarks>
/// Only returned when the `withLocalizations` query parameter is set to <see langword="false"/> when requesting the command.
/// </remarks>
string NameLocalized { get; }

/// <summary>
/// Gets the localized description of this command.
/// </summary>
/// <remarks>
/// Only returned when the `withLocalizations` query parameter is set to <see langword="false"/> when requesting the command.
/// </remarks>
string DescriptionLocalized { get; }

/// <summary> /// <summary>
/// Modifies the current application command. /// Modifies the current application command.
/// </summary> /// </summary>


+ 26
- 0
src/Discord.Net.Core/Entities/Interactions/IApplicationCommandOption.cs View File

@@ -71,5 +71,31 @@ namespace Discord
/// Gets the allowed channel types for this option. /// Gets the allowed channel types for this option.
/// </summary> /// </summary>
IReadOnlyCollection<ChannelType> ChannelTypes { get; } IReadOnlyCollection<ChannelType> ChannelTypes { get; }

/// <summary>
/// Gets the localization dictionary for the name field of this command option.
/// </summary>
IReadOnlyDictionary<string, string> NameLocalizations { get; }

/// <summary>
/// Gets the localization dictionary for the description field of this command option.
/// </summary>
IReadOnlyDictionary<string, string> DescriptionLocalizations { get; }

/// <summary>
/// Gets the localized name of this command option.
/// </summary>
/// <remarks>
/// Only returned when the `withLocalizations` query parameter is set to <see langword="false"/> when requesting the command.
/// </remarks>
string NameLocalized { get; }

/// <summary>
/// Gets the localized description of this command option.
/// </summary>
/// <remarks>
/// Only returned when the `withLocalizations` query parameter is set to true when requesting the command.
/// </remarks>
string DescriptionLocalized { get; }
} }
} }

+ 15
- 0
src/Discord.Net.Core/Entities/Interactions/IApplicationCommandOptionChoice.cs View File

@@ -1,3 +1,5 @@
using System.Collections.Generic;

namespace Discord namespace Discord
{ {
/// <summary> /// <summary>
@@ -14,5 +16,18 @@ namespace Discord
/// Gets the value of the choice. /// Gets the value of the choice.
/// </summary> /// </summary>
object Value { get; } object Value { get; }

/// <summary>
/// Gets the localization dictionary for the name field of this command option.
/// </summary>
IReadOnlyDictionary<string, string> NameLocalizations { get; }

/// <summary>
/// Gets the localized name of this command option.
/// </summary>
/// <remarks>
/// Only returned when the `withLocalizations` query parameter is set to <see langword="false"/> when requesting the command.
/// </remarks>
string NameLocalized { get; }
} }
} }

+ 298
- 48
src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs View File

@@ -1,6 +1,9 @@
using System; using System;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq; using System.Linq;
using System.Net.Sockets;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;


namespace Discord namespace Discord
@@ -31,18 +34,7 @@ namespace Discord
get => _name; get => _name;
set set
{ {
Preconditions.NotNullOrEmpty(value, nameof(value));
Preconditions.AtLeast(value.Length, 1, nameof(value));
Preconditions.AtMost(value.Length, MaxNameLength, nameof(value));

// Discord updated the docs, this regex prevents special characters like @!$%(... etc,
// https://discord.com/developers/docs/interactions/slash-commands#applicationcommand
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.");

EnsureValidCommandName(value);
_name = value; _name = value;
} }
} }
@@ -55,10 +47,7 @@ namespace Discord
get => _description; get => _description;
set set
{ {
Preconditions.NotNullOrEmpty(value, nameof(Description));
Preconditions.AtLeast(value.Length, 1, nameof(Description));
Preconditions.AtMost(value.Length, MaxDescriptionLength, nameof(Description));

EnsureValidCommandDescription(value);
_description = value; _description = value;
} }
} }
@@ -76,6 +65,16 @@ namespace Discord
} }
} }


/// <summary>
/// Gets the localization dictionary for the name field of this command.
/// </summary>
public IReadOnlyDictionary<string, string> NameLocalizations => _nameLocalizations;

/// <summary>
/// Gets the localization dictionary for the description field of this command.
/// </summary>
public IReadOnlyDictionary<string, string> DescriptionLocalizations => _descriptionLocalizations;

/// <summary> /// <summary>
/// Gets or sets whether the command is enabled by default when the app is added to a guild /// Gets or sets whether the command is enabled by default when the app is added to a guild
/// </summary> /// </summary>
@@ -93,6 +92,8 @@ namespace Discord


private string _name; private string _name;
private string _description; private string _description;
private Dictionary<string, string> _nameLocalizations;
private Dictionary<string, string> _descriptionLocalizations;
private List<SlashCommandOptionBuilder> _options; private List<SlashCommandOptionBuilder> _options;


/// <summary> /// <summary>
@@ -106,6 +107,8 @@ namespace Discord
Name = Name, Name = Name,
Description = Description, Description = Description,
IsDefaultPermission = IsDefaultPermission, IsDefaultPermission = IsDefaultPermission,
NameLocalizations = _nameLocalizations,
DescriptionLocalizations = _descriptionLocalizations,
IsDMEnabled = IsDMEnabled, IsDMEnabled = IsDMEnabled,
DefaultMemberPermissions = DefaultMemberPermissions ?? Optional<GuildPermission>.Unspecified DefaultMemberPermissions = DefaultMemberPermissions ?? Optional<GuildPermission>.Unspecified
}; };
@@ -190,20 +193,23 @@ namespace Discord
/// <param name="isAutocomplete">If this option is set to autocomplete.</param> /// <param name="isAutocomplete">If this option is set to autocomplete.</param>
/// <param name="options">The options of the option to add.</param> /// <param name="options">The options of the option to add.</param>
/// <param name="channelTypes">The allowed channel types for this option.</param> /// <param name="channelTypes">The allowed channel types for this option.</param>
/// <param name="nameLocalizations">Localization dictionary for the name field of this command.</param>
/// <param name="descriptionLocalizations">Localization dictionary for the description field of this command.</param>
/// <param name="choices">The choices of this option.</param> /// <param name="choices">The choices of this option.</param>
/// <param name="minValue">The smallest number value the user can input.</param> /// <param name="minValue">The smallest number value the user can input.</param>
/// <param name="maxValue">The largest number value the user can input.</param> /// <param name="maxValue">The largest number value the user can input.</param>
/// <returns>The current builder.</returns> /// <returns>The current builder.</returns>
public SlashCommandBuilder AddOption(string name, ApplicationCommandOptionType type, public SlashCommandBuilder AddOption(string name, ApplicationCommandOptionType type,
string description, bool? isRequired = null, bool? isDefault = null, bool isAutocomplete = false, double? minValue = null, double? maxValue = null, string description, bool? isRequired = null, bool? isDefault = null, bool isAutocomplete = false, double? minValue = null, double? maxValue = null,
int? minLength = null, int? maxLength = null, List<SlashCommandOptionBuilder> options = null, List<ChannelType> channelTypes = null, params ApplicationCommandOptionChoiceProperties[] choices)
List<SlashCommandOptionBuilder> options = null, List<ChannelType> channelTypes = null, IDictionary<string, string> nameLocalizations = null,
IDictionary<string, string> descriptionLocalizations = null,
int? minLength = null, int? maxLength = null, params ApplicationCommandOptionChoiceProperties[] choices)
{ {
Preconditions.Options(name, description); Preconditions.Options(name, description);


// Discord updated the docs, this regex prevents special characters like @!$%( and s p a c e s.. etc,
// https://discord.com/developers/docs/interactions/slash-commands#applicationcommand
if (!Regex.IsMatch(name, @"^[\w-]{1,32}$"))
throw new ArgumentException("Command name cannot contain any special characters or whitespaces!", nameof(name));
// https://discord.com/developers/docs/interactions/application-commands
if (!Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$"))
throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name));


// make sure theres only one option with default set to true // make sure theres only one option with default set to true
if (isDefault == true && Options?.Any(x => x.IsDefault == true) == true) if (isDefault == true && Options?.Any(x => x.IsDefault == true) == true)
@@ -226,6 +232,12 @@ namespace Discord
MaxLength = maxLength, MaxLength = maxLength,
}; };


if (nameLocalizations is not null)
option.WithNameLocalizations(nameLocalizations);

if (descriptionLocalizations is not null)
option.WithDescriptionLocalizations(descriptionLocalizations);

return AddOption(option); return AddOption(option);
} }


@@ -268,6 +280,115 @@ namespace Discord
Options.AddRange(options); Options.AddRange(options);
return this; return this;
} }

/// <summary>
/// Sets the <see cref="NameLocalizations"/> collection.
/// </summary>
/// <param name="nameLocalizations">The localization dictionary to use for the name field of this command.</param>
/// <returns></returns>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="nameLocalizations"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown if any dictionary key is an invalid locale string.</exception>
public SlashCommandBuilder WithNameLocalizations(IDictionary<string, string> nameLocalizations)
{
if (nameLocalizations is null)
throw new ArgumentNullException(nameof(nameLocalizations));

foreach (var (locale, name) in nameLocalizations)
{
if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

EnsureValidCommandName(name);
}

_nameLocalizations = new Dictionary<string, string>(nameLocalizations);
return this;
}

/// <summary>
/// Sets the <see cref="DescriptionLocalizations"/> collection.
/// </summary>
/// <param name="descriptionLocalizations">The localization dictionary to use for the description field of this command.</param>
/// <returns></returns>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="descriptionLocalizations"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown if any dictionary key is an invalid locale string.</exception>
public SlashCommandBuilder WithDescriptionLocalizations(IDictionary<string, string> descriptionLocalizations)
{
if (descriptionLocalizations is null)
throw new ArgumentNullException(nameof(descriptionLocalizations));

foreach (var (locale, description) in descriptionLocalizations)
{
if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

EnsureValidCommandDescription(description);
}

_descriptionLocalizations = new Dictionary<string, string>(descriptionLocalizations);
return this;
}

/// <summary>
/// Adds a new entry to the <see cref="NameLocalizations"/> collection.
/// </summary>
/// <param name="locale">Locale of the entry.</param>
/// <param name="name">Localized string for the name field.</param>
/// <returns>The current builder.</returns>
/// <exception cref="ArgumentException">Thrown if <paramref name="locale"/> is an invalid locale string.</exception>
public SlashCommandBuilder AddNameLocalization(string locale, string name)
{
if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

EnsureValidCommandName(name);

_nameLocalizations ??= new();
_nameLocalizations.Add(locale, name);

return this;
}

/// <summary>
/// Adds a new entry to the <see cref="Description"/> collection.
/// </summary>
/// <param name="locale">Locale of the entry.</param>
/// <param name="description">Localized string for the description field.</param>
/// <returns>The current builder.</returns>
/// <exception cref="ArgumentException">Thrown if <paramref name="locale"/> is an invalid locale string.</exception>
public SlashCommandBuilder AddDescriptionLocalization(string locale, string description)
{
if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

EnsureValidCommandDescription(description);

_descriptionLocalizations ??= new();
_descriptionLocalizations.Add(locale, description);

return this;
}

internal static void EnsureValidCommandName(string name)
{
Preconditions.NotNullOrEmpty(name, nameof(name));
Preconditions.AtLeast(name.Length, 1, nameof(name));
Preconditions.AtMost(name.Length, MaxNameLength, nameof(name));

// https://discord.com/developers/docs/interactions/application-commands
if (!Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$"))
throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name));

if (name.Any(char.IsUpper))
throw new FormatException("Name cannot contain any uppercase characters.");
}

internal static void EnsureValidCommandDescription(string description)
{
Preconditions.NotNullOrEmpty(description, nameof(description));
Preconditions.AtLeast(description.Length, 1, nameof(description));
Preconditions.AtMost(description.Length, MaxDescriptionLength, nameof(description));
}
} }


/// <summary> /// <summary>
@@ -287,6 +408,8 @@ namespace Discord


private string _name; private string _name;
private string _description; private string _description;
private Dictionary<string, string> _nameLocalizations;
private Dictionary<string, string> _descriptionLocalizations;


/// <summary> /// <summary>
/// Gets or sets the name of this option. /// Gets or sets the name of this option.
@@ -298,10 +421,7 @@ namespace Discord
{ {
if (value != null) if (value != null)
{ {
Preconditions.AtLeast(value.Length, 1, nameof(value));
Preconditions.AtMost(value.Length, SlashCommandBuilder.MaxNameLength, nameof(value));
if (!Regex.IsMatch(value, @"^[\w-]{1,32}$"))
throw new ArgumentException("Option name cannot contain any special characters or whitespaces!", nameof(value));
EnsureValidCommandOptionName(value);
} }


_name = value; _name = value;
@@ -318,8 +438,7 @@ namespace Discord
{ {
if (value != null) if (value != null)
{ {
Preconditions.AtLeast(value.Length, 1, nameof(value));
Preconditions.AtMost(value.Length, SlashCommandBuilder.MaxDescriptionLength, nameof(value));
EnsureValidCommandOptionDescription(value);
} }


_description = value; _description = value;
@@ -381,6 +500,16 @@ namespace Discord
/// </summary> /// </summary>
public List<ChannelType> ChannelTypes { get; set; } public List<ChannelType> ChannelTypes { get; set; }


/// <summary>
/// Gets the localization dictionary for the name field of this command.
/// </summary>
public IReadOnlyDictionary<string, string> NameLocalizations => _nameLocalizations;

/// <summary>
/// Gets the localization dictionary for the description field of this command.
/// </summary>
public IReadOnlyDictionary<string, string> DescriptionLocalizations => _descriptionLocalizations;

/// <summary> /// <summary>
/// Builds the current option. /// Builds the current option.
/// </summary> /// </summary>
@@ -424,6 +553,8 @@ namespace Discord
ChannelTypes = ChannelTypes, ChannelTypes = ChannelTypes,
MinValue = MinValue, MinValue = MinValue,
MaxValue = MaxValue, MaxValue = MaxValue,
NameLocalizations = _nameLocalizations,
DescriptionLocalizations = _descriptionLocalizations,
MinLength = MinLength, MinLength = MinLength,
MaxLength = MaxLength, MaxLength = MaxLength,
}; };
@@ -440,20 +571,23 @@ namespace Discord
/// <param name="isAutocomplete">If this option supports autocomplete.</param> /// <param name="isAutocomplete">If this option supports autocomplete.</param>
/// <param name="options">The options of the option to add.</param> /// <param name="options">The options of the option to add.</param>
/// <param name="channelTypes">The allowed channel types for this option.</param> /// <param name="channelTypes">The allowed channel types for this option.</param>
/// <param name="nameLocalizations">Localization dictionary for the description field of this command.</param>
/// <param name="descriptionLocalizations">Localization dictionary for the description field of this command.</param>
/// <param name="choices">The choices of this option.</param> /// <param name="choices">The choices of this option.</param>
/// <param name="minValue">The smallest number value the user can input.</param> /// <param name="minValue">The smallest number value the user can input.</param>
/// <param name="maxValue">The largest number value the user can input.</param> /// <param name="maxValue">The largest number value the user can input.</param>
/// <returns>The current builder.</returns> /// <returns>The current builder.</returns>
public SlashCommandOptionBuilder AddOption(string name, ApplicationCommandOptionType type, public SlashCommandOptionBuilder AddOption(string name, ApplicationCommandOptionType type,
string description, bool? isRequired = null, bool isDefault = false, bool isAutocomplete = false, double? minValue = null, double? maxValue = null, string description, bool? isRequired = null, bool isDefault = false, bool isAutocomplete = false, double? minValue = null, double? maxValue = null,
int? minLength = null, int? maxLength = null, List<SlashCommandOptionBuilder> options = null, List<ChannelType> channelTypes = null, params ApplicationCommandOptionChoiceProperties[] choices)
List<SlashCommandOptionBuilder> options = null, List<ChannelType> channelTypes = null, IDictionary<string, string> nameLocalizations = null,
IDictionary<string, string> descriptionLocalizations = null,
int? minLength = null, int? maxLength = null, params ApplicationCommandOptionChoiceProperties[] choices)
{ {
Preconditions.Options(name, description); Preconditions.Options(name, description);


// Discord updated the docs, this regex prevents special characters like @!$%( and s p a c e s.. etc,
// https://discord.com/developers/docs/interactions/slash-commands#applicationcommand
if (!Regex.IsMatch(name, @"^[\w-]{1,32}$"))
throw new ArgumentException("Command name cannot contain any special characters or whitespaces!", nameof(name));
// https://discord.com/developers/docs/interactions/application-commands
if (!Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$"))
throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name));


// make sure theres only one option with default set to true // make sure theres only one option with default set to true
if (isDefault && Options?.Any(x => x.IsDefault == true) == true) if (isDefault && Options?.Any(x => x.IsDefault == true) == true)
@@ -473,9 +607,15 @@ namespace Discord
Options = options, Options = options,
Type = type, Type = type,
Choices = (choices ?? Array.Empty<ApplicationCommandOptionChoiceProperties>()).ToList(), Choices = (choices ?? Array.Empty<ApplicationCommandOptionChoiceProperties>()).ToList(),
ChannelTypes = channelTypes
ChannelTypes = channelTypes,
}; };


if(nameLocalizations is not null)
option.WithNameLocalizations(nameLocalizations);

if(descriptionLocalizations is not null)
option.WithDescriptionLocalizations(descriptionLocalizations);

return AddOption(option); return AddOption(option);
} }
/// <summary> /// <summary>
@@ -522,10 +662,11 @@ namespace Discord
/// </summary> /// </summary>
/// <param name="name">The name of the choice.</param> /// <param name="name">The name of the choice.</param>
/// <param name="value">The value of the choice.</param> /// <param name="value">The value of the choice.</param>
/// <param name="nameLocalizations">The localization dictionary for to use the name field of this command option choice.</param>
/// <returns>The current builder.</returns> /// <returns>The current builder.</returns>
public SlashCommandOptionBuilder AddChoice(string name, int value)
public SlashCommandOptionBuilder AddChoice(string name, int value, IDictionary<string, string> nameLocalizations = null)
{ {
return AddChoiceInternal(name, value);
return AddChoiceInternal(name, value, nameLocalizations);
} }


/// <summary> /// <summary>
@@ -533,10 +674,11 @@ namespace Discord
/// </summary> /// </summary>
/// <param name="name">The name of the choice.</param> /// <param name="name">The name of the choice.</param>
/// <param name="value">The value of the choice.</param> /// <param name="value">The value of the choice.</param>
/// <param name="nameLocalizations">The localization dictionary for to use the name field of this command option choice.</param>
/// <returns>The current builder.</returns> /// <returns>The current builder.</returns>
public SlashCommandOptionBuilder AddChoice(string name, string value)
public SlashCommandOptionBuilder AddChoice(string name, string value, IDictionary<string, string> nameLocalizations = null)
{ {
return AddChoiceInternal(name, value);
return AddChoiceInternal(name, value, nameLocalizations);
} }


/// <summary> /// <summary>
@@ -544,10 +686,11 @@ namespace Discord
/// </summary> /// </summary>
/// <param name="name">The name of the choice.</param> /// <param name="name">The name of the choice.</param>
/// <param name="value">The value of the choice.</param> /// <param name="value">The value of the choice.</param>
/// <param name="nameLocalizations">Localization dictionary for the description field of this command.</param>
/// <returns>The current builder.</returns> /// <returns>The current builder.</returns>
public SlashCommandOptionBuilder AddChoice(string name, double value)
public SlashCommandOptionBuilder AddChoice(string name, double value, IDictionary<string, string> nameLocalizations = null)
{ {
return AddChoiceInternal(name, value);
return AddChoiceInternal(name, value, nameLocalizations);
} }


/// <summary> /// <summary>
@@ -555,10 +698,11 @@ namespace Discord
/// </summary> /// </summary>
/// <param name="name">The name of the choice.</param> /// <param name="name">The name of the choice.</param>
/// <param name="value">The value of the choice.</param> /// <param name="value">The value of the choice.</param>
/// <param name="nameLocalizations">The localization dictionary to use for the name field of this command option choice.</param>
/// <returns>The current builder.</returns> /// <returns>The current builder.</returns>
public SlashCommandOptionBuilder AddChoice(string name, float value)
public SlashCommandOptionBuilder AddChoice(string name, float value, IDictionary<string, string> nameLocalizations = null)
{ {
return AddChoiceInternal(name, value);
return AddChoiceInternal(name, value, nameLocalizations);
} }


/// <summary> /// <summary>
@@ -566,13 +710,14 @@ namespace Discord
/// </summary> /// </summary>
/// <param name="name">The name of the choice.</param> /// <param name="name">The name of the choice.</param>
/// <param name="value">The value of the choice.</param> /// <param name="value">The value of the choice.</param>
/// <param name="nameLocalizations">The localization dictionary to use for the name field of this command option choice.</param>
/// <returns>The current builder.</returns> /// <returns>The current builder.</returns>
public SlashCommandOptionBuilder AddChoice(string name, long value)
public SlashCommandOptionBuilder AddChoice(string name, long value, IDictionary<string, string> nameLocalizations = null)
{ {
return AddChoiceInternal(name, value);
return AddChoiceInternal(name, value, nameLocalizations);
} }


private SlashCommandOptionBuilder AddChoiceInternal(string name, object value)
private SlashCommandOptionBuilder AddChoiceInternal(string name, object value, IDictionary<string, string> nameLocalizations = null)
{ {
Choices ??= new List<ApplicationCommandOptionChoiceProperties>(); Choices ??= new List<ApplicationCommandOptionChoiceProperties>();


@@ -594,7 +739,8 @@ namespace Discord
Choices.Add(new ApplicationCommandOptionChoiceProperties Choices.Add(new ApplicationCommandOptionChoiceProperties
{ {
Name = name, Name = name,
Value = value
Value = value,
NameLocalizations = nameLocalizations
}); });


return this; return this;
@@ -706,11 +852,11 @@ namespace Discord
/// <summary> /// <summary>
/// Sets the current builders max length field. /// Sets the current builders max length field.
/// </summary> /// </summary>
/// <param name="lenght">The value to set.</param>
/// <param name="length">The value to set.</param>
/// <returns>The current builder.</returns> /// <returns>The current builder.</returns>
public SlashCommandOptionBuilder WithMaxLength(int lenght)
public SlashCommandOptionBuilder WithMaxLength(int length)
{ {
MaxLength = lenght;
MaxLength = length;
return this; return this;
} }


@@ -724,5 +870,109 @@ namespace Discord
Type = type; Type = type;
return this; return this;
} }

/// <summary>
/// Sets the <see cref="NameLocalizations"/> collection.
/// </summary>
/// <param name="nameLocalizations">The localization dictionary to use for the name field of this command option.</param>
/// <returns>The current builder.</returns>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="nameLocalizations"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown if any dictionary key is an invalid locale string.</exception>
public SlashCommandOptionBuilder WithNameLocalizations(IDictionary<string, string> nameLocalizations)
{
if (nameLocalizations is null)
throw new ArgumentNullException(nameof(nameLocalizations));

foreach (var (locale, name) in nameLocalizations)
{
if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

EnsureValidCommandOptionName(name);
}

_nameLocalizations = new Dictionary<string, string>(nameLocalizations);
return this;
}

/// <summary>
/// Sets the <see cref="DescriptionLocalizations"/> collection.
/// </summary>
/// <param name="descriptionLocalizations">The localization dictionary to use for the description field of this command option.</param>
/// <returns>The current builder.</returns>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="descriptionLocalizations"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown if any dictionary key is an invalid locale string.</exception>
public SlashCommandOptionBuilder WithDescriptionLocalizations(IDictionary<string, string> descriptionLocalizations)
{
if (descriptionLocalizations is null)
throw new ArgumentNullException(nameof(descriptionLocalizations));

foreach (var (locale, description) in descriptionLocalizations)
{
if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

EnsureValidCommandOptionDescription(description);
}

_descriptionLocalizations = new Dictionary<string, string>(descriptionLocalizations);
return this;
}

/// <summary>
/// Adds a new entry to the <see cref="NameLocalizations"/> collection.
/// </summary>
/// <param name="locale">Locale of the entry.</param>
/// <param name="name">Localized string for the name field.</param>
/// <returns>The current builder.</returns>
/// <exception cref="ArgumentException">Thrown if <paramref name="locale"/> is an invalid locale string.</exception>
public SlashCommandOptionBuilder AddNameLocalization(string locale, string name)
{
if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

EnsureValidCommandOptionName(name);

_nameLocalizations ??= new();
_nameLocalizations.Add(locale, name);

return this;
}

/// <summary>
/// Adds a new entry to the <see cref="DescriptionLocalizations"/> collection.
/// </summary>
/// <param name="locale">Locale of the entry.</param>
/// <param name="description">Localized string for the description field.</param>
/// <returns>The current builder.</returns>
/// <exception cref="ArgumentException">Thrown if <paramref name="locale"/> is an invalid locale string.</exception>
public SlashCommandOptionBuilder AddDescriptionLocalization(string locale, string description)
{
if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"Invalid locale: {locale}", nameof(locale));

EnsureValidCommandOptionDescription(description);

_descriptionLocalizations ??= new();
_descriptionLocalizations.Add(locale, description);

return this;
}

private static void EnsureValidCommandOptionName(string name)
{
Preconditions.AtLeast(name.Length, 1, nameof(name));
Preconditions.AtMost(name.Length, SlashCommandBuilder.MaxNameLength, nameof(name));

// https://discord.com/developers/docs/interactions/application-commands
if (!Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$"))
throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name));
}

private static void EnsureValidCommandOptionDescription(string description)
{
Preconditions.AtLeast(description.Length, 1, nameof(description));
Preconditions.AtMost(description.Length, SlashCommandBuilder.MaxDescriptionLength, nameof(description));
}
} }
} }

+ 39
- 0
src/Discord.Net.Core/Entities/Messages/Embed.cs View File

@@ -94,5 +94,44 @@ namespace Discord
/// </summary> /// </summary>
public override string ToString() => Title; public override string ToString() => Title;
private string DebuggerDisplay => $"{Title} ({Type})"; private string DebuggerDisplay => $"{Title} ({Type})";

public static bool operator ==(Embed left, Embed right)
=> left is null ? right is null
: left.Equals(right);

public static bool operator !=(Embed left, Embed right)
=> !(left == right);

/// <summary>
/// Determines whether the specified object is equal to the current <see cref="Embed"/>.
/// </summary>
/// <remarks>
/// If the object passes is an <see cref="Embed"/>, <see cref="Equals(Embed)"/> will be called to compare the 2 instances
/// </remarks>
/// <param name="obj">The object to compare with the current <see cref="Embed"/></param>
/// <returns></returns>
public override bool Equals(object obj)
=> obj is Embed embed && Equals(embed);

/// <summary>
/// Determines whether the specified <see cref="Embed"/> is equal to the current <see cref="Embed"/>
/// </summary>
/// <param name="embed">The <see cref="Embed"/> to compare with the current <see cref="Embed"/></param>
/// <returns></returns>
public bool Equals(Embed embed)
=> GetHashCode() == embed?.GetHashCode();

/// <inheritdoc />
public override int GetHashCode()
{
unchecked
{
var hash = 17;
hash = hash * 23 + (Type, Title, Description, Timestamp, Color, Image, Video, Author, Footer, Provider, Thumbnail).GetHashCode();
foreach(var field in Fields)
hash = hash * 23 + field.GetHashCode();
return hash;
}
}
} }
} }

+ 31
- 0
src/Discord.Net.Core/Entities/Messages/EmbedAuthor.cs View File

@@ -1,3 +1,4 @@
using System;
using System.Diagnostics; using System.Diagnostics;


namespace Discord namespace Discord
@@ -41,5 +42,35 @@ namespace Discord
/// ///
/// </returns> /// </returns>
public override string ToString() => Name; public override string ToString() => Name;

public static bool operator ==(EmbedAuthor? left, EmbedAuthor? right)
=> left is null ? right is null
: left.Equals(right);

public static bool operator !=(EmbedAuthor? left, EmbedAuthor? right)
=> !(left == right);

/// <summary>
/// Determines whether the specified object is equal to the current <see cref="EmbedAuthor"/>.
/// </summary>
/// <remarks>
/// If the object passes is an <see cref="EmbedAuthor"/>, <see cref="Equals(EmbedAuthor?)"/> will be called to compare the 2 instances
/// </remarks>
/// <param name="obj">The object to compare with the current <see cref="EmbedAuthor"/></param>
/// <returns></returns>
public override bool Equals(object obj)
=> obj is EmbedAuthor embedAuthor && Equals(embedAuthor);

/// <summary>
/// Determines whether the specified <see cref="EmbedAuthor"/> is equal to the current <see cref="EmbedAuthor"/>
/// </summary>
/// <param name="embedAuthor">The <see cref="EmbedAuthor"/> to compare with the current <see cref="EmbedAuthor"/></param>
/// <returns></returns>
public bool Equals(EmbedAuthor? embedAuthor)
=> GetHashCode() == embedAuthor?.GetHashCode();

/// <inheritdoc />
public override int GetHashCode()
=> (Name, Url, IconUrl).GetHashCode();
} }
} }

+ 142
- 1
src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs View File

@@ -150,7 +150,7 @@ namespace Discord
int authorLength = Author?.Name?.Length ?? 0; int authorLength = Author?.Name?.Length ?? 0;
int descriptionLength = Description?.Length ?? 0; int descriptionLength = Description?.Length ?? 0;
int footerLength = Footer?.Text?.Length ?? 0; int footerLength = Footer?.Text?.Length ?? 0;
int fieldSum = Fields.Sum(f => f.Name.Length + f.Value.ToString().Length);
int fieldSum = Fields.Sum(f => f.Name.Length + (f.Value?.ToString()?.Length ?? 0));


return titleLength + authorLength + descriptionLength + footerLength + fieldSum; return titleLength + authorLength + descriptionLength + footerLength + fieldSum;
} }
@@ -481,6 +481,55 @@ namespace Discord


return new Embed(EmbedType.Rich, Title, Description, Url, Timestamp, Color, _image, null, Author?.Build(), Footer?.Build(), null, _thumbnail, fields.ToImmutable()); return new Embed(EmbedType.Rich, Title, Description, Url, Timestamp, Color, _image, null, Author?.Build(), Footer?.Build(), null, _thumbnail, fields.ToImmutable());
} }

public static bool operator ==(EmbedBuilder left, EmbedBuilder right)
=> left is null ? right is null
: left.Equals(right);

public static bool operator !=(EmbedBuilder left, EmbedBuilder right)
=> !(left == right);

/// <summary>
/// Determines whether the specified object is equal to the current <see cref="EmbedBuilder"/>.
/// </summary>
/// <remarks>
/// If the object passes is an <see cref="EmbedBuilder"/>, <see cref="Equals(EmbedBuilder)"/> will be called to compare the 2 instances
/// </remarks>
/// <param name="obj">The object to compare with the current <see cref="EmbedBuilder"/></param>
/// <returns></returns>
public override bool Equals(object obj)
=> obj is EmbedBuilder embedBuilder && Equals(embedBuilder);

/// <summary>
/// Determines whether the specified <see cref="EmbedBuilder"/> is equal to the current <see cref="EmbedBuilder"/>
/// </summary>
/// <param name="embedBuilder">The <see cref="EmbedBuilder"/> to compare with the current <see cref="EmbedBuilder"/></param>
/// <returns></returns>
public bool Equals(EmbedBuilder embedBuilder)
{
if (embedBuilder is null)
return false;

if (Fields.Count != embedBuilder.Fields.Count)
return false;

for (var i = 0; i < _fields.Count; i++)
if (_fields[i] != embedBuilder._fields[i])
return false;

return _title == embedBuilder?._title
&& _description == embedBuilder?._description
&& _image == embedBuilder?._image
&& _thumbnail == embedBuilder?._thumbnail
&& Timestamp == embedBuilder?.Timestamp
&& Color == embedBuilder?.Color
&& Author == embedBuilder?.Author
&& Footer == embedBuilder?.Footer
&& Url == embedBuilder?.Url;
}

/// <inheritdoc />
public override int GetHashCode() => base.GetHashCode();
} }


/// <summary> /// <summary>
@@ -597,6 +646,37 @@ namespace Discord
/// </exception> /// </exception>
public EmbedField Build() public EmbedField Build()
=> new EmbedField(Name, Value.ToString(), IsInline); => new EmbedField(Name, Value.ToString(), IsInline);

public static bool operator ==(EmbedFieldBuilder left, EmbedFieldBuilder right)
=> left is null ? right is null
: left.Equals(right);

public static bool operator !=(EmbedFieldBuilder left, EmbedFieldBuilder right)
=> !(left == right);

/// <summary>
/// Determines whether the specified object is equal to the current <see cref="EmbedFieldBuilder"/>.
/// </summary>
/// <remarks>
/// If the object passes is an <see cref="EmbedFieldBuilder"/>, <see cref="Equals(EmbedFieldBuilder)"/> will be called to compare the 2 instances
/// </remarks>
/// <param name="obj">The object to compare with the current <see cref="EmbedFieldBuilder"/></param>
/// <returns></returns>
public override bool Equals(object obj)
=> obj is EmbedFieldBuilder embedFieldBuilder && Equals(embedFieldBuilder);

/// <summary>
/// Determines whether the specified <see cref="EmbedFieldBuilder"/> is equal to the current <see cref="EmbedFieldBuilder"/>
/// </summary>
/// <param name="embedFieldBuilder">The <see cref="EmbedFieldBuilder"/> to compare with the current <see cref="EmbedFieldBuilder"/></param>
/// <returns></returns>
public bool Equals(EmbedFieldBuilder embedFieldBuilder)
=> _name == embedFieldBuilder?._name
&& _value == embedFieldBuilder?._value
&& IsInline == embedFieldBuilder?.IsInline;

/// <inheritdoc />
public override int GetHashCode() => base.GetHashCode();
} }


/// <summary> /// <summary>
@@ -697,6 +777,37 @@ namespace Discord
/// </returns> /// </returns>
public EmbedAuthor Build() public EmbedAuthor Build()
=> new EmbedAuthor(Name, Url, IconUrl, null); => new EmbedAuthor(Name, Url, IconUrl, null);

public static bool operator ==(EmbedAuthorBuilder left, EmbedAuthorBuilder right)
=> left is null ? right is null
: left.Equals(right);

public static bool operator !=(EmbedAuthorBuilder left, EmbedAuthorBuilder right)
=> !(left == right);

/// <summary>
/// Determines whether the specified object is equal to the current <see cref="EmbedAuthorBuilder"/>.
/// </summary>
/// <remarks>
/// If the object passes is an <see cref="EmbedAuthorBuilder"/>, <see cref="Equals(EmbedAuthorBuilder)"/> will be called to compare the 2 instances
/// </remarks>
/// <param name="obj">The object to compare with the current <see cref="EmbedAuthorBuilder"/></param>
/// <returns></returns>
public override bool Equals(object obj)
=> obj is EmbedAuthorBuilder embedAuthorBuilder && Equals(embedAuthorBuilder);

/// <summary>
/// Determines whether the specified <see cref="EmbedAuthorBuilder"/> is equals to the current <see cref="EmbedAuthorBuilder"/>
/// </summary>
/// <param name="embedAuthorBuilder">The <see cref="EmbedAuthorBuilder"/> to compare with the current <see cref="EmbedAuthorBuilder"/></param>
/// <returns></returns>
public bool Equals(EmbedAuthorBuilder embedAuthorBuilder)
=> _name == embedAuthorBuilder?._name
&& Url == embedAuthorBuilder?.Url
&& IconUrl == embedAuthorBuilder?.IconUrl;

/// <inheritdoc />
public override int GetHashCode() => base.GetHashCode();
} }


/// <summary> /// <summary>
@@ -777,5 +888,35 @@ namespace Discord
/// </returns> /// </returns>
public EmbedFooter Build() public EmbedFooter Build()
=> new EmbedFooter(Text, IconUrl, null); => new EmbedFooter(Text, IconUrl, null);

public static bool operator ==(EmbedFooterBuilder left, EmbedFooterBuilder right)
=> left is null ? right is null
: left.Equals(right);

public static bool operator !=(EmbedFooterBuilder left, EmbedFooterBuilder right)
=> !(left == right);

/// <summary>
/// Determines whether the specified object is equal to the current <see cref="EmbedFooterBuilder"/>.
/// </summary>
/// <remarks>
/// If the object passes is an <see cref="EmbedFooterBuilder"/>, <see cref="Equals(EmbedFooterBuilder)"/> will be called to compare the 2 instances
/// </remarks>
/// <param name="obj">The object to compare with the current <see cref="EmbedFooterBuilder"/></param>
/// <returns></returns>
public override bool Equals(object obj)
=> obj is EmbedFooterBuilder embedFooterBuilder && Equals(embedFooterBuilder);

/// <summary>
/// Determines whether the specified <see cref="EmbedFooterBuilder"/> is equal to the current <see cref="EmbedFooterBuilder"/>
/// </summary>
/// <param name="embedFooterBuilder">The <see cref="EmbedFooterBuilder"/> to compare with the current <see cref="EmbedFooterBuilder"/></param>
/// <returns></returns>
public bool Equals(EmbedFooterBuilder embedFooterBuilder)
=> _text == embedFooterBuilder?._text
&& IconUrl == embedFooterBuilder?.IconUrl;

/// <inheritdoc />
public override int GetHashCode() => base.GetHashCode();
} }
} }

+ 31
- 0
src/Discord.Net.Core/Entities/Messages/EmbedField.cs View File

@@ -1,3 +1,4 @@
using System;
using System.Diagnostics; using System.Diagnostics;


namespace Discord namespace Discord
@@ -36,5 +37,35 @@ namespace Discord
/// A string that resolves to <see cref="EmbedField.Name"/>. /// A string that resolves to <see cref="EmbedField.Name"/>.
/// </returns> /// </returns>
public override string ToString() => Name; public override string ToString() => Name;

public static bool operator ==(EmbedField? left, EmbedField? right)
=> left is null ? right is null
: left.Equals(right);

public static bool operator !=(EmbedField? left, EmbedField? right)
=> !(left == right);

/// <summary>
/// Determines whether the specified object is equal to the current <see cref="EmbedField"/>.
/// </summary>
/// <remarks>
/// If the object passes is an <see cref="EmbedField"/>, <see cref="Equals(EmbedField?)"/> will be called to compare the 2 instances
/// </remarks>
/// <param name="obj">The object to compare with the current object</param>
/// <returns></returns>
public override bool Equals(object obj)
=> obj is EmbedField embedField && Equals(embedField);

/// <summary>
/// Determines whether the specified <see cref="EmbedField"/> is equal to the current <see cref="EmbedField"/>
/// </summary>
/// <param name="embedField"></param>
/// <returns></returns>
public bool Equals(EmbedField? embedField)
=> GetHashCode() == embedField?.GetHashCode();

/// <inheritdoc />
public override int GetHashCode()
=> (Name, Value, Inline).GetHashCode();
} }
} }

+ 31
- 0
src/Discord.Net.Core/Entities/Messages/EmbedFooter.cs View File

@@ -1,3 +1,4 @@
using System;
using System.Diagnostics; using System.Diagnostics;


namespace Discord namespace Discord
@@ -43,5 +44,35 @@ namespace Discord
/// A string that resolves to <see cref="Discord.EmbedFooter.Text"/>. /// A string that resolves to <see cref="Discord.EmbedFooter.Text"/>.
/// </returns> /// </returns>
public override string ToString() => Text; public override string ToString() => Text;

public static bool operator ==(EmbedFooter? left, EmbedFooter? right)
=> left is null ? right is null
: left.Equals(right);

public static bool operator !=(EmbedFooter? left, EmbedFooter? right)
=> !(left == right);

/// <summary>
/// Determines whether the specified object is equal to the current <see cref="EmbedFooter"/>.
/// </summary>
/// <remarks>
/// If the object passes is an <see cref="EmbedFooter"/>, <see cref="Equals(EmbedFooter?)"/> will be called to compare the 2 instances
/// </remarks>
/// <param name="obj">The object to compare with the current <see cref="EmbedFooter"/></param>
/// <returns></returns>
public override bool Equals(object obj)
=> obj is EmbedFooter embedFooter && Equals(embedFooter);

/// <summary>
/// Determines whether the specified <see cref="EmbedFooter"/> is equal to the current <see cref="EmbedFooter"/>
/// </summary>
/// <param name="embedFooter">The <see cref="EmbedFooter"/> to compare with the current <see cref="EmbedFooter"/></param>
/// <returns></returns>
public bool Equals(EmbedFooter? embedFooter)
=> GetHashCode() == embedFooter?.GetHashCode();

/// <inheritdoc />
public override int GetHashCode()
=> (Text, IconUrl, ProxyUrl).GetHashCode();
} }
} }

+ 31
- 0
src/Discord.Net.Core/Entities/Messages/EmbedImage.cs View File

@@ -1,3 +1,4 @@
using System;
using System.Diagnostics; using System.Diagnostics;


namespace Discord namespace Discord
@@ -53,5 +54,35 @@ namespace Discord
/// A string that resolves to <see cref="Discord.EmbedImage.Url"/> . /// A string that resolves to <see cref="Discord.EmbedImage.Url"/> .
/// </returns> /// </returns>
public override string ToString() => Url; public override string ToString() => Url;

public static bool operator ==(EmbedImage? left, EmbedImage? right)
=> left is null ? right is null
: left.Equals(right);

public static bool operator !=(EmbedImage? left, EmbedImage? right)
=> !(left == right);

/// <summary>
/// Determines whether the specified object is equal to the current <see cref="EmbedImage"/>.
/// </summary>
/// <remarks>
/// If the object passes is an <see cref="EmbedImage"/>, <see cref="Equals(EmbedImage?)"/> will be called to compare the 2 instances
/// </remarks>
/// <param name="obj">The object to compare with the current <see cref="EmbedImage"/></param>
/// <returns></returns>
public override bool Equals(object obj)
=> obj is EmbedImage embedImage && Equals(embedImage);

/// <summary>
/// Determines whether the specified <see cref="EmbedImage"/> is equal to the current <see cref="EmbedImage"/>
/// </summary>
/// <param name="embedImage">The <see cref="EmbedImage"/> to compare with the current <see cref="EmbedImage"/></param>
/// <returns></returns>
public bool Equals(EmbedImage? embedImage)
=> GetHashCode() == embedImage?.GetHashCode();

/// <inheritdoc />
public override int GetHashCode()
=> (Height, Width, Url, ProxyUrl).GetHashCode();
} }
} }

+ 31
- 0
src/Discord.Net.Core/Entities/Messages/EmbedProvider.cs View File

@@ -1,3 +1,4 @@
using System;
using System.Diagnostics; using System.Diagnostics;


namespace Discord namespace Discord
@@ -35,5 +36,35 @@ namespace Discord
/// A string that resolves to <see cref="Discord.EmbedProvider.Name" />. /// A string that resolves to <see cref="Discord.EmbedProvider.Name" />.
/// </returns> /// </returns>
public override string ToString() => Name; public override string ToString() => Name;

public static bool operator ==(EmbedProvider? left, EmbedProvider? right)
=> left is null ? right is null
: left.Equals(right);

public static bool operator !=(EmbedProvider? left, EmbedProvider? right)
=> !(left == right);

/// <summary>
/// Determines whether the specified object is equal to the current <see cref="EmbedProvider"/>.
/// </summary>
/// <remarks>
/// If the object passes is an <see cref="EmbedProvider"/>, <see cref="Equals(EmbedProvider?)"/> will be called to compare the 2 instances
/// </remarks>
/// <param name="obj">The object to compare with the current <see cref="EmbedProvider"/></param>
/// <returns></returns>
public override bool Equals(object obj)
=> obj is EmbedProvider embedProvider && Equals(embedProvider);

/// <summary>
/// Determines whether the specified <see cref="EmbedProvider"/> is equal to the current <see cref="EmbedProvider"/>
/// </summary>
/// <param name="embedProvider">The <see cref="EmbedProvider"/> to compare with the current <see cref="EmbedProvider"/></param>
/// <returns></returns>
public bool Equals(EmbedProvider? embedProvider)
=> GetHashCode() == embedProvider?.GetHashCode();

/// <inheritdoc />
public override int GetHashCode()
=> (Name, Url).GetHashCode();
} }
} }

+ 31
- 0
src/Discord.Net.Core/Entities/Messages/EmbedThumbnail.cs View File

@@ -1,3 +1,4 @@
using System;
using System.Diagnostics; using System.Diagnostics;


namespace Discord namespace Discord
@@ -53,5 +54,35 @@ namespace Discord
/// A string that resolves to <see cref="Discord.EmbedThumbnail.Url" />. /// A string that resolves to <see cref="Discord.EmbedThumbnail.Url" />.
/// </returns> /// </returns>
public override string ToString() => Url; public override string ToString() => Url;

public static bool operator ==(EmbedThumbnail? left, EmbedThumbnail? right)
=> left is null ? right is null
: left.Equals(right);

public static bool operator !=(EmbedThumbnail? left, EmbedThumbnail? right)
=> !(left == right);

/// <summary>
/// Determines whether the specified object is equal to the current <see cref="EmbedThumbnail"/>.
/// </summary>
/// <remarks>
/// If the object passes is an <see cref="EmbedThumbnail"/>, <see cref="Equals(EmbedThumbnail?)"/> will be called to compare the 2 instances
/// </remarks>
/// <param name="obj">The object to compare with the current <see cref="EmbedThumbnail"/></param>
/// <returns></returns>
public override bool Equals(object obj)
=> obj is EmbedThumbnail embedThumbnail && Equals(embedThumbnail);

/// <summary>
/// Determines whether the specified <see cref="EmbedThumbnail"/> is equal to the current <see cref="EmbedThumbnail"/>
/// </summary>
/// <param name="embedThumbnail">The <see cref="EmbedThumbnail"/> to compare with the current <see cref="EmbedThumbnail"/></param>
/// <returns></returns>
public bool Equals(EmbedThumbnail? embedThumbnail)
=> GetHashCode() == embedThumbnail?.GetHashCode();

/// <inheritdoc />
public override int GetHashCode()
=> (Width, Height, Url, ProxyUrl).GetHashCode();
} }
} }

+ 31
- 0
src/Discord.Net.Core/Entities/Messages/EmbedVideo.cs View File

@@ -1,3 +1,4 @@
using System;
using System.Diagnostics; using System.Diagnostics;


namespace Discord namespace Discord
@@ -47,5 +48,35 @@ namespace Discord
/// A string that resolves to <see cref="Url"/>. /// A string that resolves to <see cref="Url"/>.
/// </returns> /// </returns>
public override string ToString() => Url; public override string ToString() => Url;

public static bool operator ==(EmbedVideo? left, EmbedVideo? right)
=> left is null ? right is null
: left.Equals(right);

public static bool operator !=(EmbedVideo? left, EmbedVideo? right)
=> !(left == right);

/// <summary>
/// Determines whether the specified object is equal to the current <see cref="EmbedVideo"/>.
/// </summary>
/// <remarks>
/// If the object passes is an <see cref="EmbedVideo"/>, <see cref="Equals(EmbedVideo?)"/> will be called to compare the 2 instances
/// </remarks>
/// <param name="obj">The object to compare with the current <see cref="EmbedVideo"/></param>
/// <returns></returns>
public override bool Equals(object obj)
=> obj is EmbedVideo embedVideo && Equals(embedVideo);

/// <summary>
/// Determines whether the specified <see cref="EmbedVideo"/> is equal to the current <see cref="EmbedVideo"/>
/// </summary>
/// <param name="embedVideo">The <see cref="EmbedVideo"/> to compare with the current <see cref="EmbedVideo"/></param>
/// <returns></returns>
public bool Equals(EmbedVideo? embedVideo)
=> GetHashCode() == embedVideo?.GetHashCode();

/// <inheritdoc />
public override int GetHashCode()
=> (Width, Height, Url).GetHashCode();
} }
} }

+ 6
- 0
src/Discord.Net.Core/Entities/Messages/IMessage.cs View File

@@ -48,6 +48,9 @@ namespace Discord
/// <summary> /// <summary>
/// Gets the content for this message. /// Gets the content for this message.
/// </summary> /// </summary>
/// <remarks>
/// This will be empty if the privileged <see cref="GatewayIntents.MessageContent"/> is disabled.
/// </remarks>
/// <returns> /// <returns>
/// A string that contains the body of the message; note that this field may be empty if there is an embed. /// A string that contains the body of the message; note that this field may be empty if there is an embed.
/// </returns> /// </returns>
@@ -55,6 +58,9 @@ namespace Discord
/// <summary> /// <summary>
/// Gets the clean content for this message. /// Gets the clean content for this message.
/// </summary> /// </summary>
/// <remarks>
/// This will be empty if the privileged <see cref="GatewayIntents.MessageContent"/> is disabled.
/// </remarks>
/// <returns> /// <returns>
/// A string that contains the body of the message stripped of mentions, markdown, emojis and pings; note that this field may be empty if there is an embed. /// A string that contains the body of the message stripped of mentions, markdown, emojis and pings; note that this field may be empty if there is an embed.
/// </returns> /// </returns>


+ 54
- 25
src/Discord.Net.Core/Entities/Messages/TimestampTag.cs View File

@@ -5,17 +5,28 @@ namespace Discord
/// <summary> /// <summary>
/// Represents a class used to make timestamps in messages. see <see href="https://discord.com/developers/docs/reference#message-formatting-timestamp-styles"/>. /// Represents a class used to make timestamps in messages. see <see href="https://discord.com/developers/docs/reference#message-formatting-timestamp-styles"/>.
/// </summary> /// </summary>
public class TimestampTag
public readonly struct TimestampTag
{ {
/// <summary> /// <summary>
/// Gets or sets the style of the timestamp tag.
/// Gets the time for this timestamp tag.
/// </summary> /// </summary>
public TimestampTagStyles Style { get; set; } = TimestampTagStyles.ShortDateTime;
public DateTimeOffset Time { get; }


/// <summary> /// <summary>
/// Gets or sets the time for this timestamp tag.
/// Gets the style of this tag. <see langword="null"/> if none was provided.
/// </summary> /// </summary>
public DateTimeOffset Time { get; set; }
public TimestampTagStyles? Style { get; }

/// <summary>
/// Creates a new <see cref="TimestampTag"/> from the provided time.
/// </summary>
/// <param name="time">The time for this timestamp tag.</param>
/// <param name="style">The style for this timestamp tag.</param>
public TimestampTag(DateTimeOffset time, TimestampTagStyles? style = null)
{
Time = time;
Style = style;
}


/// <summary> /// <summary>
/// Converts the current timestamp tag to the string representation supported by discord. /// Converts the current timestamp tag to the string representation supported by discord.
@@ -23,11 +34,23 @@ namespace Discord
/// If the <see cref="Time"/> is null then the default 0 will be used. /// If the <see cref="Time"/> is null then the default 0 will be used.
/// </para> /// </para>
/// </summary> /// </summary>
/// <remarks>
/// Will use the provided <see cref="Style"/> if provided. If this value is null, it will default to <see cref="TimestampTagStyles.ShortDateTime"/>.
/// </remarks>
/// <returns>A string that is compatible in a discord message, ex: <code>&lt;t:1625944201:f&gt;</code></returns> /// <returns>A string that is compatible in a discord message, ex: <code>&lt;t:1625944201:f&gt;</code></returns>
public override string ToString() public override string ToString()
{
return $"<t:{Time.ToUnixTimeSeconds()}:{(char)Style}>";
}
=> ToString(Style ?? TimestampTagStyles.ShortDateTime);

/// <summary>
/// Converts the current timestamp tag to the string representation supported by discord.
/// <para>
/// If the <see cref="Time"/> is null then the default 0 will be used.
/// </para>
/// </summary>
/// <param name="style">The formatting style for this tag.</param>
/// <returns>A string that is compatible in a discord message, ex: <code>&lt;t:1625944201:f&gt;</code></returns>
public string ToString(TimestampTagStyles style)
=> $"<t:{Time.ToUnixTimeSeconds()}:{(char)style}>";


/// <summary> /// <summary>
/// Creates a new timestamp tag with the specified <see cref="DateTime"/> object. /// Creates a new timestamp tag with the specified <see cref="DateTime"/> object.
@@ -35,14 +58,8 @@ namespace Discord
/// <param name="time">The time of this timestamp tag.</param> /// <param name="time">The time of this timestamp tag.</param>
/// <param name="style">The style for this timestamp tag.</param> /// <param name="style">The style for this timestamp tag.</param>
/// <returns>The newly create timestamp tag.</returns> /// <returns>The newly create timestamp tag.</returns>
public static TimestampTag FromDateTime(DateTime time, TimestampTagStyles style = TimestampTagStyles.ShortDateTime)
{
return new TimestampTag
{
Style = style,
Time = time
};
}
public static TimestampTag FromDateTime(DateTime time, TimestampTagStyles? style = null)
=> new(time, style);


/// <summary> /// <summary>
/// Creates a new timestamp tag with the specified <see cref="DateTimeOffset"/> object. /// Creates a new timestamp tag with the specified <see cref="DateTimeOffset"/> object.
@@ -50,13 +67,25 @@ namespace Discord
/// <param name="time">The time of this timestamp tag.</param> /// <param name="time">The time of this timestamp tag.</param>
/// <param name="style">The style for this timestamp tag.</param> /// <param name="style">The style for this timestamp tag.</param>
/// <returns>The newly create timestamp tag.</returns> /// <returns>The newly create timestamp tag.</returns>
public static TimestampTag FromDateTimeOffset(DateTimeOffset time, TimestampTagStyles style = TimestampTagStyles.ShortDateTime)
{
return new TimestampTag
{
Style = style,
Time = time
};
}
public static TimestampTag FromDateTimeOffset(DateTimeOffset time, TimestampTagStyles? style = null)
=> new(time, style);

/// <summary>
/// Immediately formats the provided time and style into a timestamp string.
/// </summary>
/// <param name="time">The time of this timestamp tag.</param>
/// <param name="style">The style for this timestamp tag.</param>
/// <returns>The newly create timestamp string.</returns>
public static string FormatFromDateTime(DateTime time, TimestampTagStyles style)
=> FormatFromDateTimeOffset(time, style);

/// <summary>
/// Immediately formats the provided time and style into a timestamp string.
/// </summary>
/// <param name="time">The time of this timestamp tag.</param>
/// <param name="style">The style for this timestamp tag.</param>
/// <returns>The newly create timestamp string.</returns>
public static string FormatFromDateTimeOffset(DateTimeOffset time, TimestampTagStyles style)
=> $"<t:{time.ToUnixTimeSeconds()}:{(char)style}>";
} }
}
}

+ 15
- 0
src/Discord.Net.Core/Extensions/GenericCollectionExtensions.cs View File

@@ -0,0 +1,15 @@
using System.Linq;

namespace System.Collections.Generic;

internal static class GenericCollectionExtensions
{
public static void Deconstruct<T1, T2>(this KeyValuePair<T1, T2> kvp, out T1 value1, out T2 value2)
{
value1 = kvp.Key;
value2 = kvp.Value;
}

public static Dictionary<T1, T2> ToDictionary<T1, T2>(this IEnumerable<KeyValuePair<T1, T2>> kvp) =>
kvp.ToDictionary(x => x.Key, x => x.Value);
}

+ 9
- 2
src/Discord.Net.Core/GatewayIntents.cs View File

@@ -39,7 +39,14 @@ namespace Discord
DirectMessageReactions = 1 << 13, DirectMessageReactions = 1 << 13,
/// <summary> This intent includes TYPING_START </summary> /// <summary> This intent includes TYPING_START </summary>
DirectMessageTyping = 1 << 14, DirectMessageTyping = 1 << 14,
/// <summary> This intent includes GUILD_SCHEDULED_EVENT_CREATE, GUILD_SCHEDULED_EVENT_UPDATE, GUILD_SCHEDULED_EVENT_DELETE, GUILD_SCHEDULED_EVENT_USER_ADD, GUILD_SCHEDULED_EVENT_USER_REMOVE </summary>
/// <summary>
/// This intent defines if the content within messages received by MESSAGE_CREATE is available or not.
/// This is a privileged intent and needs to be enabled in the developer portal.
/// </summary>
MessageContent = 1 << 15,
/// <summary>
/// This intent includes GUILD_SCHEDULED_EVENT_CREATE, GUILD_SCHEDULED_EVENT_UPDATE, GUILD_SCHEDULED_EVENT_DELETE, GUILD_SCHEDULED_EVENT_USER_ADD, GUILD_SCHEDULED_EVENT_USER_REMOVE
/// </summary>
GuildScheduledEvents = 1 << 16, GuildScheduledEvents = 1 << 16,
/// <summary> /// <summary>
/// This intent includes all but <see cref="GuildMembers"/> and <see cref="GuildPresences"/> /// This intent includes all but <see cref="GuildMembers"/> and <see cref="GuildPresences"/>
@@ -51,6 +58,6 @@ namespace Discord
/// <summary> /// <summary>
/// This intent includes all of them, including privileged ones. /// This intent includes all of them, including privileged ones.
/// </summary> /// </summary>
All = AllUnprivileged | GuildMembers | GuildPresences
All = AllUnprivileged | GuildMembers | GuildPresences | MessageContent
} }
} }

+ 3
- 1
src/Discord.Net.Core/IDiscordClient.cs View File

@@ -155,12 +155,14 @@ namespace Discord
/// <summary> /// <summary>
/// Gets a collection of all global commands. /// Gets a collection of all global commands.
/// </summary> /// </summary>
/// <param name="withLocalizations">Whether to include full localization dictionaries in the returned objects, instead of the name localized and description localized fields.</param>
/// <param name="locale">The target locale of the localized name and description fields. Sets <c>X-Discord-Locale</c> header, which takes precedence over <c>Accept-Language</c>.</param>
/// <param name="options">The options to be used when sending the request.</param> /// <param name="options">The options to be used when sending the request.</param>
/// <returns> /// <returns>
/// A task that represents the asynchronous get operation. The task result contains a read-only collection of global /// A task that represents the asynchronous get operation. The task result contains a read-only collection of global
/// application commands. /// application commands.
/// </returns> /// </returns>
Task<IReadOnlyCollection<IApplicationCommand>> GetGlobalApplicationCommandsAsync(RequestOptions options = null);
Task<IReadOnlyCollection<IApplicationCommand>> GetGlobalApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null);


/// <summary> /// <summary>
/// Creates a global application command. /// Creates a global application command.


+ 7
- 3
src/Discord.Net.Core/Net/Rest/IRestClient.cs View File

@@ -30,9 +30,13 @@ namespace Discord.Net.Rest
/// <param name="cancelToken">The cancellation token used to cancel the task.</param> /// <param name="cancelToken">The cancellation token used to cancel the task.</param>
/// <param name="headerOnly">Indicates whether to send the header only.</param> /// <param name="headerOnly">Indicates whether to send the header only.</param>
/// <param name="reason">The audit log reason.</param> /// <param name="reason">The audit log reason.</param>
/// <param name="requestHeaders">Additional headers to be sent with the request.</param>
/// <returns></returns> /// <returns></returns>
Task<RestResponse> SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly = false, string reason = null);
Task<RestResponse> SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly = false, string reason = null);
Task<RestResponse> SendAsync(string method, string endpoint, IReadOnlyDictionary<string, object> multipartParams, CancellationToken cancelToken, bool headerOnly = false, string reason = null);
Task<RestResponse> SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly = false, string reason = null,
IEnumerable<KeyValuePair<string, IEnumerable<string>>> requestHeaders = null);
Task<RestResponse> SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly = false, string reason = null,
IEnumerable<KeyValuePair<string, IEnumerable<string>>> requestHeaders = null);
Task<RestResponse> SendAsync(string method, string endpoint, IReadOnlyDictionary<string, object> multipartParams, CancellationToken cancelToken, bool headerOnly = false, string reason = null,
IEnumerable<KeyValuePair<string, IEnumerable<string>>> requestHeaders = null);
} }
} }

+ 8
- 4
src/Discord.Net.Core/RequestOptions.cs View File

@@ -1,5 +1,6 @@
using Discord.Net; using Discord.Net;
using System; using System;
using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;


@@ -19,7 +20,7 @@ namespace Discord
/// Gets or sets the maximum time to wait for this request to complete. /// Gets or sets the maximum time to wait for this request to complete.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Gets or set the max time, in milliseconds, to wait for this request to complete. If
/// Gets or set the max time, in milliseconds, to wait for this request to complete. If
/// <c>null</c>, a request will not time out. If a rate limit has been triggered for this request's bucket /// <c>null</c>, a request will not time out. If a rate limit has been triggered for this request's bucket
/// and will not be unpaused in time, this request will fail immediately. /// and will not be unpaused in time, this request will fail immediately.
/// </remarks> /// </remarks>
@@ -53,7 +54,7 @@ namespace Discord
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// This property can also be set in <see cref="DiscordConfig"/>. /// This property can also be set in <see cref="DiscordConfig"/>.
/// On a per-request basis, the system clock should only be disabled
/// On a per-request basis, the system clock should only be disabled
/// when millisecond precision is especially important, and the /// when millisecond precision is especially important, and the
/// hosting system is known to have a desynced clock. /// hosting system is known to have a desynced clock.
/// </remarks> /// </remarks>
@@ -70,8 +71,10 @@ namespace Discord
internal bool IsReactionBucket { get; set; } internal bool IsReactionBucket { get; set; }
internal bool IsGatewayBucket { get; set; } internal bool IsGatewayBucket { get; set; }


internal IDictionary<string, IEnumerable<string>> RequestHeaders { get; }

internal static RequestOptions CreateOrClone(RequestOptions options) internal static RequestOptions CreateOrClone(RequestOptions options)
{
{
if (options == null) if (options == null)
return new RequestOptions(); return new RequestOptions();
else else
@@ -96,8 +99,9 @@ namespace Discord
public RequestOptions() public RequestOptions()
{ {
Timeout = DiscordConfig.DefaultRequestTimeout; Timeout = DiscordConfig.DefaultRequestTimeout;
RequestHeaders = new Dictionary<string, IEnumerable<string>>();
} }
public RequestOptions Clone() => MemberwiseClone() as RequestOptions; public RequestOptions Clone() => MemberwiseClone() as RequestOptions;
} }
} }

+ 5
- 5
src/Discord.Net.Core/Utils/Preconditions.cs View File

@@ -55,7 +55,7 @@ namespace Discord
if (obj.Value == null) throw CreateNotNullException(name, msg); if (obj.Value == null) throw CreateNotNullException(name, msg);
if (obj.Value.Trim().Length == 0) throw CreateNotEmptyException(name, msg); if (obj.Value.Trim().Length == 0) throw CreateNotEmptyException(name, msg);
} }
}
}


private static ArgumentException CreateNotEmptyException(string name, string msg) private static ArgumentException CreateNotEmptyException(string name, string msg)
=> new ArgumentException(message: msg ?? "Argument cannot be blank.", paramName: name); => new ArgumentException(message: msg ?? "Argument cannot be blank.", paramName: name);
@@ -129,7 +129,7 @@ namespace Discord


private static ArgumentException CreateNotEqualException<T>(string name, string msg, T value) private static ArgumentException CreateNotEqualException<T>(string name, string msg, T value)
=> new ArgumentException(message: msg ?? $"Value may not be equal to {value}.", paramName: name); => new ArgumentException(message: msg ?? $"Value may not be equal to {value}.", paramName: name);
/// <exception cref="ArgumentException">Value must be at least <paramref name="value"/>.</exception> /// <exception cref="ArgumentException">Value must be at least <paramref name="value"/>.</exception>
public static void AtLeast(sbyte obj, sbyte value, string name, string msg = null) { if (obj < value) throw CreateAtLeastException(name, msg, value); } public static void AtLeast(sbyte obj, sbyte value, string name, string msg = null) { if (obj < value) throw CreateAtLeastException(name, msg, value); }
/// <exception cref="ArgumentException">Value must be at least <paramref name="value"/>.</exception> /// <exception cref="ArgumentException">Value must be at least <paramref name="value"/>.</exception>
@@ -165,7 +165,7 @@ namespace Discord


private static ArgumentException CreateAtLeastException<T>(string name, string msg, T value) private static ArgumentException CreateAtLeastException<T>(string name, string msg, T value)
=> new ArgumentException(message: msg ?? $"Value must be at least {value}.", paramName: name); => new ArgumentException(message: msg ?? $"Value must be at least {value}.", paramName: name);
/// <exception cref="ArgumentException">Value must be greater than <paramref name="value"/>.</exception> /// <exception cref="ArgumentException">Value must be greater than <paramref name="value"/>.</exception>
public static void GreaterThan(sbyte obj, sbyte value, string name, string msg = null) { if (obj <= value) throw CreateGreaterThanException(name, msg, value); } public static void GreaterThan(sbyte obj, sbyte value, string name, string msg = null) { if (obj <= value) throw CreateGreaterThanException(name, msg, value); }
/// <exception cref="ArgumentException">Value must be greater than <paramref name="value"/>.</exception> /// <exception cref="ArgumentException">Value must be greater than <paramref name="value"/>.</exception>
@@ -201,7 +201,7 @@ namespace Discord


private static ArgumentException CreateGreaterThanException<T>(string name, string msg, T value) private static ArgumentException CreateGreaterThanException<T>(string name, string msg, T value)
=> new ArgumentException(message: msg ?? $"Value must be greater than {value}.", paramName: name); => new ArgumentException(message: msg ?? $"Value must be greater than {value}.", paramName: name);
/// <exception cref="ArgumentException">Value must be at most <paramref name="value"/>.</exception> /// <exception cref="ArgumentException">Value must be at most <paramref name="value"/>.</exception>
public static void AtMost(sbyte obj, sbyte value, string name, string msg = null) { if (obj > value) throw CreateAtMostException(name, msg, value); } public static void AtMost(sbyte obj, sbyte value, string name, string msg = null) { if (obj > value) throw CreateAtMostException(name, msg, value); }
/// <exception cref="ArgumentException">Value must be at most <paramref name="value"/>.</exception> /// <exception cref="ArgumentException">Value must be at most <paramref name="value"/>.</exception>
@@ -237,7 +237,7 @@ namespace Discord


private static ArgumentException CreateAtMostException<T>(string name, string msg, T value) private static ArgumentException CreateAtMostException<T>(string name, string msg, T value)
=> new ArgumentException(message: msg ?? $"Value must be at most {value}.", paramName: name); => new ArgumentException(message: msg ?? $"Value must be at most {value}.", paramName: name);
/// <exception cref="ArgumentException">Value must be less than <paramref name="value"/>.</exception> /// <exception cref="ArgumentException">Value must be less than <paramref name="value"/>.</exception>
public static void LessThan(sbyte obj, sbyte value, string name, string msg = null) { if (obj >= value) throw CreateLessThanException(name, msg, value); } public static void LessThan(sbyte obj, sbyte value, string name, string msg = null) { if (obj >= value) throw CreateLessThanException(name, msg, value); }
/// <exception cref="ArgumentException">Value must be less than <paramref name="value"/>.</exception> /// <exception cref="ArgumentException">Value must be less than <paramref name="value"/>.</exception>


+ 3
- 3
src/Discord.Net.Interactions/Attributes/MaxLengthAttribute.cs View File

@@ -16,10 +16,10 @@ namespace Discord.Interactions
/// <summary> /// <summary>
/// Sets the maximum length allowed for a string type parameter. /// Sets the maximum length allowed for a string type parameter.
/// </summary> /// </summary>
/// <param name="lenght">Maximum string length allowed.</param>
public MaxLengthAttribute(int lenght)
/// <param name="length">Maximum string length allowed.</param>
public MaxLengthAttribute(int length)
{ {
Length = lenght;
Length = length;
} }
} }
} }

+ 3
- 3
src/Discord.Net.Interactions/Attributes/MinLengthAttribute.cs View File

@@ -16,10 +16,10 @@ namespace Discord.Interactions
/// <summary> /// <summary>
/// Sets the minimum length allowed for a string type parameter. /// Sets the minimum length allowed for a string type parameter.
/// </summary> /// </summary>
/// <param name="lenght">Minimum string length allowed.</param>
public MinLengthAttribute(int lenght)
/// <param name="length">Minimum string length allowed.</param>
public MinLengthAttribute(int length)
{ {
Length = lenght;
Length = length;
} }
} }
} }

+ 6
- 6
src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs View File

@@ -67,26 +67,26 @@ namespace Discord.Interactions.Builders
/// <summary> /// <summary>
/// Sets <see cref="MinLength"/>. /// Sets <see cref="MinLength"/>.
/// </summary> /// </summary>
/// <param name="minLenght">New value of the <see cref="MinLength"/>.</param>
/// <param name="minLength">New value of the <see cref="MinLength"/>.</param>
/// <returns> /// <returns>
/// The builder instance. /// The builder instance.
/// </returns> /// </returns>
public TextInputComponentBuilder WithMinLenght(int minLenght)
public TextInputComponentBuilder WithMinLength(int minLength)
{ {
MinLength = minLenght;
MinLength = minLength;
return this; return this;
} }


/// <summary> /// <summary>
/// Sets <see cref="MaxLength"/>. /// Sets <see cref="MaxLength"/>.
/// </summary> /// </summary>
/// <param name="maxLenght">New value of the <see cref="MaxLength"/>.</param>
/// <param name="maxLength">New value of the <see cref="MaxLength"/>.</param>
/// <returns> /// <returns>
/// The builder instance. /// The builder instance.
/// </returns> /// </returns>
public TextInputComponentBuilder WithMaxLenght(int maxLenght)
public TextInputComponentBuilder WithMaxLength(int maxLength)
{ {
MaxLength = maxLenght;
MaxLength = maxLength;
return this; return this;
} }




+ 6
- 0
src/Discord.Net.Interactions/InteractionService.cs View File

@@ -83,6 +83,11 @@ namespace Discord.Interactions
public event Func<ModalCommandInfo, IInteractionContext, IResult, Task> ModalCommandExecuted { add { _modalCommandExecutedEvent.Add(value); } remove { _modalCommandExecutedEvent.Remove(value); } } public event Func<ModalCommandInfo, IInteractionContext, IResult, Task> ModalCommandExecuted { add { _modalCommandExecutedEvent.Add(value); } remove { _modalCommandExecutedEvent.Remove(value); } }
internal readonly AsyncEvent<Func<ModalCommandInfo, IInteractionContext, IResult, Task>> _modalCommandExecutedEvent = new(); internal readonly AsyncEvent<Func<ModalCommandInfo, IInteractionContext, IResult, Task>> _modalCommandExecutedEvent = new();


/// <summary>
/// Get the <see cref="ILocalizationManager"/> used by this Interaction Service instance to localize strings.
/// </summary>
public ILocalizationManager LocalizationManager { get; set; }

private readonly ConcurrentDictionary<Type, ModuleInfo> _typedModuleDefs; private readonly ConcurrentDictionary<Type, ModuleInfo> _typedModuleDefs;
private readonly CommandMap<SlashCommandInfo> _slashCommandMap; private readonly CommandMap<SlashCommandInfo> _slashCommandMap;
private readonly ConcurrentDictionary<ApplicationCommandType, CommandMap<ContextCommandInfo>> _contextCommandMaps; private readonly ConcurrentDictionary<ApplicationCommandType, CommandMap<ContextCommandInfo>> _contextCommandMaps;
@@ -203,6 +208,7 @@ namespace Discord.Interactions
_enableAutocompleteHandlers = config.EnableAutocompleteHandlers; _enableAutocompleteHandlers = config.EnableAutocompleteHandlers;
_autoServiceScopes = config.AutoServiceScopes; _autoServiceScopes = config.AutoServiceScopes;
_restResponseCallback = config.RestResponseCallback; _restResponseCallback = config.RestResponseCallback;
LocalizationManager = config.LocalizationManager;


_typeConverterMap = new TypeMap<TypeConverter, IApplicationCommandInteractionDataOption>(this, new ConcurrentDictionary<Type, TypeConverter> _typeConverterMap = new TypeMap<TypeConverter, IApplicationCommandInteractionDataOption>(this, new ConcurrentDictionary<Type, TypeConverter>
{ {


+ 5
- 0
src/Discord.Net.Interactions/InteractionServiceConfig.cs View File

@@ -64,6 +64,11 @@ namespace Discord.Interactions
/// Gets or sets whether a command execution should exit when a modal command encounters a missing modal component value. /// Gets or sets whether a command execution should exit when a modal command encounters a missing modal component value.
/// </summary> /// </summary>
public bool ExitOnMissingModalField { get; set; } = false; public bool ExitOnMissingModalField { get; set; } = false;

/// <summary>
/// Localization provider to be used when registering application commands.
/// </summary>
public ILocalizationManager LocalizationManager { get; set; }
} }


/// <summary> /// <summary>


+ 32
- 0
src/Discord.Net.Interactions/LocalizationManagers/ILocalizationManager.cs View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace Discord.Interactions
{
/// <summary>
/// Respresents a localization provider for Discord Application Commands.
/// </summary>
public interface ILocalizationManager
{
/// <summary>
/// Get every the resource name for every available locale.
/// </summary>
/// <param name="key">Location of the resource.</param>
/// <param name="destinationType">Type of the resource.</param>
/// <returns>
/// A dictionary containing every available locale and the resource name.
/// </returns>
IDictionary<string, string> GetAllNames(IList<string> key, LocalizationTarget destinationType);

/// <summary>
/// Get every the resource description for every available locale.
/// </summary>
/// <param name="key">Location of the resource.</param>
/// <param name="destinationType">Type of the resource.</param>
/// <returns>
/// A dictionary containing every available locale and the resource name.
/// </returns>
IDictionary<string, string> GetAllDescriptions(IList<string> key, LocalizationTarget destinationType);
}
}

+ 72
- 0
src/Discord.Net.Interactions/LocalizationManagers/JsonLocalizationManager.cs View File

@@ -0,0 +1,72 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;

namespace Discord.Interactions
{
/// <summary>
/// The default localization provider for Json resource files.
/// </summary>
public sealed class JsonLocalizationManager : ILocalizationManager
{
private const string NameIdentifier = "name";
private const string DescriptionIdentifier = "description";
private const string SpaceToken = "~";

private readonly string _basePath;
private readonly string _fileName;
private readonly Regex _localeParserRegex = new Regex(@"\w+.(?<locale>\w{2}(?:-\w{2})?).json", RegexOptions.Compiled | RegexOptions.Singleline);

/// <summary>
/// Initializes a new instance of the <see cref="JsonLocalizationManager"/> class.
/// </summary>
/// <param name="basePath">Base path of the Json file.</param>
/// <param name="fileName">Name of the Json file.</param>
public JsonLocalizationManager(string basePath, string fileName)
{
_basePath = basePath;
_fileName = fileName;
}

/// <inheritdoc />
public IDictionary<string, string> GetAllDescriptions(IList<string> key, LocalizationTarget destinationType) =>
GetValues(key, DescriptionIdentifier);

/// <inheritdoc />
public IDictionary<string, string> GetAllNames(IList<string> key, LocalizationTarget destinationType) =>
GetValues(key, NameIdentifier);

private string[] GetAllFiles() =>
Directory.GetFiles(_basePath, $"{_fileName}.*.json", SearchOption.TopDirectoryOnly);

private IDictionary<string, string> GetValues(IList<string> key, string identifier)
{
var result = new Dictionary<string, string>();
var files = GetAllFiles();

foreach (var file in files)
{
var match = _localeParserRegex.Match(Path.GetFileName(file));
if (!match.Success)
continue;

var locale = match.Groups["locale"].Value;

using var sr = new StreamReader(file);
using var jr = new JsonTextReader(sr);
var obj = JObject.Load(jr);
var token = string.Join(".", key.Select(x => $"['{x}']")) + $".{identifier}";
var value = (string)obj.SelectToken(token);
if (value is not null)
result[locale] = value;
}

return result;
}
}
}

+ 55
- 0
src/Discord.Net.Interactions/LocalizationManagers/ResxLocalizationManager.cs View File

@@ -0,0 +1,55 @@
using System.Collections.Generic;
using System.Globalization;
using System.Reflection;
using System.Resources;

namespace Discord.Interactions
{
/// <summary>
/// The default localization provider for Resx files.
/// </summary>
public sealed class ResxLocalizationManager : ILocalizationManager
{
private const string NameIdentifier = "name";
private const string DescriptionIdentifier = "description";

private readonly ResourceManager _resourceManager;
private readonly IEnumerable<CultureInfo> _supportedLocales;

/// <summary>
/// Initializes a new instance of the <see cref="ResxLocalizationManager"/> class.
/// </summary>
/// <param name="baseResource">Name of the base resource.</param>
/// <param name="assembly">The main assembly for the resources.</param>
/// <param name="supportedLocales">Cultures the <see cref="ResxLocalizationManager"/> should search for.</param>
public ResxLocalizationManager(string baseResource, Assembly assembly, params CultureInfo[] supportedLocales)
{
_supportedLocales = supportedLocales;
_resourceManager = new ResourceManager(baseResource, assembly);
}

/// <inheritdoc />
public IDictionary<string, string> GetAllDescriptions(IList<string> key, LocalizationTarget destinationType) =>
GetValues(key, DescriptionIdentifier);

/// <inheritdoc />
public IDictionary<string, string> GetAllNames(IList<string> key, LocalizationTarget destinationType) =>
GetValues(key, NameIdentifier);

private IDictionary<string, string> GetValues(IList<string> key, string identifier)
{
var entryKey = (string.Join(".", key) + "." + identifier);

var result = new Dictionary<string, string>();

foreach (var locale in _supportedLocales)
{
var value = _resourceManager.GetString(entryKey, locale);
if (value is not null)
result[locale.Name] = value;
}

return result;
}
}
}

+ 25
- 0
src/Discord.Net.Interactions/LocalizationTarget.cs View File

@@ -0,0 +1,25 @@
namespace Discord.Interactions
{
/// <summary>
/// Resource targets for localization.
/// </summary>
public enum LocalizationTarget
{
/// <summary>
/// Target is a <see cref="IInteractionModuleBase"/> tagged with a <see cref="GroupAttribute"/>.
/// </summary>
Group,
/// <summary>
/// Target is an application command method.
/// </summary>
Command,
/// <summary>
/// Target is a Slash Command parameter.
/// </summary>
Parameter,
/// <summary>
/// Target is a Slash Command parameter choice.
/// </summary>
Choice
}
}

+ 2
- 21
src/Discord.Net.Interactions/Map/CommandMap.cs View File

@@ -42,7 +42,7 @@ namespace Discord.Interactions


public void RemoveCommand(T command) public void RemoveCommand(T command)
{ {
var key = ParseCommandName(command);
var key = CommandHierarchy.GetCommandPath(command);


_root.RemoveCommand(key, 0); _root.RemoveCommand(key, 0);
} }
@@ -60,28 +60,9 @@ namespace Discord.Interactions


private void AddCommand(T command) private void AddCommand(T command)
{ {
var key = ParseCommandName(command);
var key = CommandHierarchy.GetCommandPath(command);


_root.AddCommand(key, 0, command); _root.AddCommand(key, 0, command);
} }

private IList<string> ParseCommandName(T command)
{
var keywords = new List<string>() { 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;
}
} }
} }

+ 71
- 17
src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq; using System.Linq;


namespace Discord.Interactions namespace Discord.Interactions
@@ -9,6 +10,9 @@ namespace Discord.Interactions
#region Parameters #region Parameters
public static ApplicationCommandOptionProperties ToApplicationCommandOptionProps(this SlashCommandParameterInfo parameterInfo) public static ApplicationCommandOptionProperties ToApplicationCommandOptionProps(this SlashCommandParameterInfo parameterInfo)
{ {
var localizationManager = parameterInfo.Command.Module.CommandService.LocalizationManager;
var parameterPath = parameterInfo.GetParameterPath();

var props = new ApplicationCommandOptionProperties var props = new ApplicationCommandOptionProperties
{ {
Name = parameterInfo.Name, Name = parameterInfo.Name,
@@ -18,12 +22,15 @@ namespace Discord.Interactions
Choices = parameterInfo.Choices?.Select(x => new ApplicationCommandOptionChoiceProperties Choices = parameterInfo.Choices?.Select(x => new ApplicationCommandOptionChoiceProperties
{ {
Name = x.Name, Name = x.Name,
Value = x.Value
Value = x.Value,
NameLocalizations = localizationManager?.GetAllNames(parameterInfo.GetChoicePath(x), LocalizationTarget.Choice) ?? ImmutableDictionary<string, string>.Empty
})?.ToList(), })?.ToList(),
ChannelTypes = parameterInfo.ChannelTypes?.ToList(), ChannelTypes = parameterInfo.ChannelTypes?.ToList(),
IsAutocomplete = parameterInfo.IsAutocomplete, IsAutocomplete = parameterInfo.IsAutocomplete,
MaxValue = parameterInfo.MaxValue, MaxValue = parameterInfo.MaxValue,
MinValue = parameterInfo.MinValue, MinValue = parameterInfo.MinValue,
NameLocalizations = localizationManager?.GetAllNames(parameterPath, LocalizationTarget.Parameter) ?? ImmutableDictionary<string, string>.Empty,
DescriptionLocalizations = localizationManager?.GetAllDescriptions(parameterPath, LocalizationTarget.Parameter) ?? ImmutableDictionary<string, string>.Empty,
MinLength = parameterInfo.MinLength, MinLength = parameterInfo.MinLength,
MaxLength = parameterInfo.MaxLength, MaxLength = parameterInfo.MaxLength,
}; };
@@ -38,13 +45,19 @@ namespace Discord.Interactions


public static SlashCommandProperties ToApplicationCommandProps(this SlashCommandInfo commandInfo) public static SlashCommandProperties ToApplicationCommandProps(this SlashCommandInfo commandInfo)
{ {
var commandPath = commandInfo.GetCommandPath();
var localizationManager = commandInfo.Module.CommandService.LocalizationManager;

var props = new SlashCommandBuilder() var props = new SlashCommandBuilder()
{ {
Name = commandInfo.Name, Name = commandInfo.Name,
Description = commandInfo.Description, Description = commandInfo.Description,
IsDefaultPermission = commandInfo.DefaultPermission,
IsDMEnabled = commandInfo.IsEnabledInDm, IsDMEnabled = commandInfo.IsEnabledInDm,
DefaultMemberPermissions = ((commandInfo.DefaultMemberPermissions ?? 0) | (commandInfo.Module.DefaultMemberPermissions ?? 0)).SanitizeGuildPermissions(), DefaultMemberPermissions = ((commandInfo.DefaultMemberPermissions ?? 0) | (commandInfo.Module.DefaultMemberPermissions ?? 0)).SanitizeGuildPermissions(),
}.Build();
}.WithNameLocalizations(localizationManager?.GetAllNames(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary<string, string>.Empty)
.WithDescriptionLocalizations(localizationManager?.GetAllDescriptions(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary<string, string>.Empty)
.Build();


if (commandInfo.Parameters.Count > SlashCommandBuilder.MaxOptionsCount) if (commandInfo.Parameters.Count > SlashCommandBuilder.MaxOptionsCount)
throw new InvalidOperationException($"Slash Commands cannot have more than {SlashCommandBuilder.MaxOptionsCount} command parameters"); throw new InvalidOperationException($"Slash Commands cannot have more than {SlashCommandBuilder.MaxOptionsCount} command parameters");
@@ -54,18 +67,30 @@ namespace Discord.Interactions
return props; return props;
} }


public static ApplicationCommandOptionProperties ToApplicationCommandOptionProps(this SlashCommandInfo commandInfo) =>
new ApplicationCommandOptionProperties
public static ApplicationCommandOptionProperties ToApplicationCommandOptionProps(this SlashCommandInfo commandInfo)
{
var localizationManager = commandInfo.Module.CommandService.LocalizationManager;
var commandPath = commandInfo.GetCommandPath();

return new ApplicationCommandOptionProperties
{ {
Name = commandInfo.Name, Name = commandInfo.Name,
Description = commandInfo.Description, Description = commandInfo.Description,
Type = ApplicationCommandOptionType.SubCommand, Type = ApplicationCommandOptionType.SubCommand,
IsRequired = false, IsRequired = false,
Options = commandInfo.FlattenedParameters?.Select(x => x.ToApplicationCommandOptionProps())?.ToList()
Options = commandInfo.FlattenedParameters?.Select(x => x.ToApplicationCommandOptionProps())
?.ToList(),
NameLocalizations = localizationManager?.GetAllNames(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary<string, string>.Empty,
DescriptionLocalizations = localizationManager?.GetAllDescriptions(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary<string, string>.Empty
}; };
}


public static ApplicationCommandProperties ToApplicationCommandProps(this ContextCommandInfo commandInfo) public static ApplicationCommandProperties ToApplicationCommandProps(this ContextCommandInfo commandInfo)
=> commandInfo.CommandType switch
{
var localizationManager = commandInfo.Module.CommandService.LocalizationManager;
var commandPath = commandInfo.GetCommandPath();

return commandInfo.CommandType switch
{ {
ApplicationCommandType.Message => new MessageCommandBuilder ApplicationCommandType.Message => new MessageCommandBuilder
{ {
@@ -73,16 +98,21 @@ namespace Discord.Interactions
IsDefaultPermission = commandInfo.DefaultPermission, IsDefaultPermission = commandInfo.DefaultPermission,
DefaultMemberPermissions = ((commandInfo.DefaultMemberPermissions ?? 0) | (commandInfo.Module.DefaultMemberPermissions ?? 0)).SanitizeGuildPermissions(), DefaultMemberPermissions = ((commandInfo.DefaultMemberPermissions ?? 0) | (commandInfo.Module.DefaultMemberPermissions ?? 0)).SanitizeGuildPermissions(),
IsDMEnabled = commandInfo.IsEnabledInDm IsDMEnabled = commandInfo.IsEnabledInDm
}.Build(),
}
.WithNameLocalizations(localizationManager?.GetAllNames(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary<string, string>.Empty)
.Build(),
ApplicationCommandType.User => new UserCommandBuilder ApplicationCommandType.User => new UserCommandBuilder
{ {
Name = commandInfo.Name, Name = commandInfo.Name,
IsDefaultPermission = commandInfo.DefaultPermission, IsDefaultPermission = commandInfo.DefaultPermission,
DefaultMemberPermissions = ((commandInfo.DefaultMemberPermissions ?? 0) | (commandInfo.Module.DefaultMemberPermissions ?? 0)).SanitizeGuildPermissions(), DefaultMemberPermissions = ((commandInfo.DefaultMemberPermissions ?? 0) | (commandInfo.Module.DefaultMemberPermissions ?? 0)).SanitizeGuildPermissions(),
IsDMEnabled = commandInfo.IsEnabledInDm IsDMEnabled = commandInfo.IsEnabledInDm
}.Build(),
}
.WithNameLocalizations(localizationManager?.GetAllNames(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary<string, string>.Empty)
.Build(),
_ => throw new InvalidOperationException($"{commandInfo.CommandType} isn't a supported command type.") _ => throw new InvalidOperationException($"{commandInfo.CommandType} isn't a supported command type.")
}; };
}
#endregion #endregion


#region Modules #region Modules
@@ -123,6 +153,9 @@ namespace Discord.Interactions


options.AddRange(moduleInfo.SubModules?.SelectMany(x => x.ParseSubModule(args, ignoreDontRegister))); options.AddRange(moduleInfo.SubModules?.SelectMany(x => x.ParseSubModule(args, ignoreDontRegister)));


var localizationManager = moduleInfo.CommandService.LocalizationManager;
var modulePath = moduleInfo.GetModulePath();

var props = new SlashCommandBuilder var props = new SlashCommandBuilder
{ {
Name = moduleInfo.SlashGroupName, Name = moduleInfo.SlashGroupName,
@@ -130,7 +163,10 @@ namespace Discord.Interactions
IsDefaultPermission = moduleInfo.DefaultPermission, IsDefaultPermission = moduleInfo.DefaultPermission,
IsDMEnabled = moduleInfo.IsEnabledInDm, IsDMEnabled = moduleInfo.IsEnabledInDm,
DefaultMemberPermissions = moduleInfo.DefaultMemberPermissions DefaultMemberPermissions = moduleInfo.DefaultMemberPermissions
}.Build();
}
.WithNameLocalizations(localizationManager?.GetAllNames(modulePath, LocalizationTarget.Group) ?? ImmutableDictionary<string, string>.Empty)
.WithDescriptionLocalizations(localizationManager?.GetAllDescriptions(modulePath, LocalizationTarget.Group) ?? ImmutableDictionary<string, string>.Empty)
.Build();


if (options.Count > SlashCommandBuilder.MaxOptionsCount) if (options.Count > SlashCommandBuilder.MaxOptionsCount)
throw new InvalidOperationException($"Slash Commands cannot have more than {SlashCommandBuilder.MaxOptionsCount} command parameters"); throw new InvalidOperationException($"Slash Commands cannot have more than {SlashCommandBuilder.MaxOptionsCount} command parameters");
@@ -168,7 +204,11 @@ namespace Discord.Interactions
Name = moduleInfo.SlashGroupName, Name = moduleInfo.SlashGroupName,
Description = moduleInfo.Description, Description = moduleInfo.Description,
Type = ApplicationCommandOptionType.SubCommandGroup, Type = ApplicationCommandOptionType.SubCommandGroup,
Options = options
Options = options,
NameLocalizations = moduleInfo.CommandService.LocalizationManager?.GetAllNames(moduleInfo.GetModulePath(), LocalizationTarget.Group)
?? ImmutableDictionary<string, string>.Empty,
DescriptionLocalizations = moduleInfo.CommandService.LocalizationManager?.GetAllDescriptions(moduleInfo.GetModulePath(), LocalizationTarget.Group)
?? ImmutableDictionary<string, string>.Empty,
} }; } };
} }


@@ -183,17 +223,29 @@ namespace Discord.Interactions
Name = command.Name, Name = command.Name,
Description = command.Description, Description = command.Description,
IsDefaultPermission = command.IsDefaultPermission, IsDefaultPermission = command.IsDefaultPermission,
Options = command.Options?.Select(x => x.ToApplicationCommandOptionProps())?.ToList() ?? Optional<List<ApplicationCommandOptionProperties>>.Unspecified
DefaultMemberPermissions = (GuildPermission)command.DefaultMemberPermissions.RawValue,
IsDMEnabled = command.IsEnabledInDm,
Options = command.Options?.Select(x => x.ToApplicationCommandOptionProps())?.ToList() ?? Optional<List<ApplicationCommandOptionProperties>>.Unspecified,
NameLocalizations = command.NameLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty,
DescriptionLocalizations = command.DescriptionLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty,
}, },
ApplicationCommandType.User => new UserCommandProperties ApplicationCommandType.User => new UserCommandProperties
{ {
Name = command.Name, Name = command.Name,
IsDefaultPermission = command.IsDefaultPermission
IsDefaultPermission = command.IsDefaultPermission,
DefaultMemberPermissions = (GuildPermission)command.DefaultMemberPermissions.RawValue,
IsDMEnabled = command.IsEnabledInDm,
NameLocalizations = command.NameLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty,
DescriptionLocalizations = command.DescriptionLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty
}, },
ApplicationCommandType.Message => new MessageCommandProperties ApplicationCommandType.Message => new MessageCommandProperties
{ {
Name = command.Name, Name = command.Name,
IsDefaultPermission = command.IsDefaultPermission
IsDefaultPermission = command.IsDefaultPermission,
DefaultMemberPermissions = (GuildPermission)command.DefaultMemberPermissions.RawValue,
IsDMEnabled = command.IsEnabledInDm,
NameLocalizations = command.NameLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty,
DescriptionLocalizations = command.DescriptionLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty
}, },
_ => throw new InvalidOperationException($"Cannot create command properties for command type {command.Type}"), _ => throw new InvalidOperationException($"Cannot create command properties for command type {command.Type}"),
}; };
@@ -206,18 +258,20 @@ namespace Discord.Interactions
Description = commandOption.Description, Description = commandOption.Description,
Type = commandOption.Type, Type = commandOption.Type,
IsRequired = commandOption.IsRequired, IsRequired = commandOption.IsRequired,
ChannelTypes = commandOption.ChannelTypes?.ToList(),
IsAutocomplete = commandOption.IsAutocomplete.GetValueOrDefault(),
MinValue = commandOption.MinValue,
MaxValue = commandOption.MaxValue,
Choices = commandOption.Choices?.Select(x => new ApplicationCommandOptionChoiceProperties Choices = commandOption.Choices?.Select(x => new ApplicationCommandOptionChoiceProperties
{ {
Name = x.Name, Name = x.Name,
Value = x.Value Value = x.Value
}).ToList(), }).ToList(),
Options = commandOption.Options?.Select(x => x.ToApplicationCommandOptionProps()).ToList(), Options = commandOption.Options?.Select(x => x.ToApplicationCommandOptionProps()).ToList(),
NameLocalizations = commandOption.NameLocalizations?.ToImmutableDictionary(),
DescriptionLocalizations = commandOption.DescriptionLocalizations?.ToImmutableDictionary(),
MaxLength = commandOption.MaxLength, MaxLength = commandOption.MaxLength,
MinLength = commandOption.MinLength, MinLength = commandOption.MinLength,
MaxValue = commandOption.MaxValue,
MinValue = commandOption.MinValue,
IsAutocomplete = commandOption.IsAutocomplete.GetValueOrDefault(),
ChannelTypes = commandOption.ChannelTypes.ToList(),
}; };


public static Modal ToModal(this ModalInfo modalInfo, string customId, Action<ModalBuilder> modifyModal = null) public static Modal ToModal(this ModalInfo modalInfo, string customId, Action<ModalBuilder> modifyModal = null)


+ 53
- 0
src/Discord.Net.Interactions/Utilities/CommandHierarchy.cs View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;

namespace Discord.Interactions
{
internal static class CommandHierarchy
{
public const char EscapeChar = '$';

public static IList<string> GetModulePath(this ModuleInfo moduleInfo)
{
var result = new List<string>();

var current = moduleInfo;
while (current is not null)
{
if (current.IsSlashGroup)
result.Insert(0, current.SlashGroupName);

current = current.Parent;
}

return result;
}

public static IList<string> GetCommandPath(this ICommandInfo commandInfo)
{
if (commandInfo.IgnoreGroupNames)
return new List<string> { commandInfo.Name };

var path = commandInfo.Module.GetModulePath();
path.Add(commandInfo.Name);
return path;
}

public static IList<string> GetParameterPath(this IParameterInfo parameterInfo)
{
var path = parameterInfo.Command.GetCommandPath();
path.Add(parameterInfo.Name);
return path;
}

public static IList<string> GetChoicePath(this IParameterInfo parameterInfo, ParameterChoice choice)
{
var path = parameterInfo.GetParameterPath();
path.Add(choice.Name);
return path;
}

public static IList<string> GetTypePath(Type type) =>
new List<string> { EscapeChar + type.FullName };
}
}

+ 13
- 0
src/Discord.Net.Rest/API/Common/ApplicationCommand.cs View File

@@ -1,4 +1,5 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Collections.Generic;


namespace Discord.API namespace Discord.API
{ {
@@ -25,6 +26,18 @@ namespace Discord.API
[JsonProperty("default_permission")] [JsonProperty("default_permission")]
public Optional<bool> DefaultPermissions { get; set; } public Optional<bool> DefaultPermissions { get; set; }


[JsonProperty("name_localizations")]
public Optional<Dictionary<string, string>> NameLocalizations { get; set; }

[JsonProperty("description_localizations")]
public Optional<Dictionary<string, string>> DescriptionLocalizations { get; set; }

[JsonProperty("name_localized")]
public Optional<string> NameLocalized { get; set; }

[JsonProperty("description_localized")]
public Optional<string> DescriptionLocalized { get; set; }
// V2 Permissions // V2 Permissions
[JsonProperty("dm_permission")] [JsonProperty("dm_permission")]
public Optional<bool?> DmPermission { get; set; } public Optional<bool?> DmPermission { get; set; }


+ 21
- 0
src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs View File

@@ -1,4 +1,5 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Collections.Generic;
using System.Linq; using System.Linq;


namespace Discord.API namespace Discord.API
@@ -38,6 +39,18 @@ namespace Discord.API
[JsonProperty("channel_types")] [JsonProperty("channel_types")]
public Optional<ChannelType[]> ChannelTypes { get; set; } public Optional<ChannelType[]> ChannelTypes { get; set; }


[JsonProperty("name_localizations")]
public Optional<Dictionary<string, string>> NameLocalizations { get; set; }

[JsonProperty("description_localizations")]
public Optional<Dictionary<string, string>> DescriptionLocalizations { get; set; }

[JsonProperty("name_localized")]
public Optional<string> NameLocalized { get; set; }

[JsonProperty("description_localized")]
public Optional<string> DescriptionLocalized { get; set; }

[JsonProperty("min_length")] [JsonProperty("min_length")]
public Optional<int> MinLength { get; set; } public Optional<int> MinLength { get; set; }


@@ -69,6 +82,11 @@ namespace Discord.API
Name = cmd.Name; Name = cmd.Name;
Type = cmd.Type; Type = cmd.Type;
Description = cmd.Description; Description = cmd.Description;

NameLocalizations = cmd.NameLocalizations?.ToDictionary() ?? Optional<Dictionary<string, string>>.Unspecified;
DescriptionLocalizations = cmd.DescriptionLocalizations?.ToDictionary() ?? Optional<Dictionary<string, string>>.Unspecified;
NameLocalized = cmd.NameLocalized;
DescriptionLocalized = cmd.DescriptionLocalized;
} }
public ApplicationCommandOption(ApplicationCommandOptionProperties option) public ApplicationCommandOption(ApplicationCommandOptionProperties option)
{ {
@@ -94,6 +112,9 @@ namespace Discord.API
Type = option.Type; Type = option.Type;
Description = option.Description; Description = option.Description;
Autocomplete = option.IsAutocomplete; Autocomplete = option.IsAutocomplete;

NameLocalizations = option.NameLocalizations?.ToDictionary() ?? Optional<Dictionary<string, string>>.Unspecified;
DescriptionLocalizations = option.DescriptionLocalizations?.ToDictionary() ?? Optional<Dictionary<string, string>>.Unspecified;
} }
} }
} }

+ 7
- 0
src/Discord.Net.Rest/API/Common/ApplicationCommandOptionChoice.cs View File

@@ -1,4 +1,5 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Collections.Generic;


namespace Discord.API namespace Discord.API
{ {
@@ -9,5 +10,11 @@ namespace Discord.API


[JsonProperty("value")] [JsonProperty("value")]
public object Value { get; set; } public object Value { get; set; }

[JsonProperty("name_localizations")]
public Optional<Dictionary<string, string>> NameLocalizations { get; set; }

[JsonProperty("name_localized")]
public Optional<string> NameLocalized { get; set; }
} }
} }

+ 14
- 1
src/Discord.Net.Rest/API/Rest/CreateApplicationCommandParams.cs View File

@@ -1,4 +1,8 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;


namespace Discord.API.Rest namespace Discord.API.Rest
{ {
@@ -19,6 +23,12 @@ namespace Discord.API.Rest
[JsonProperty("default_permission")] [JsonProperty("default_permission")]
public Optional<bool> DefaultPermission { get; set; } public Optional<bool> DefaultPermission { get; set; }


[JsonProperty("name_localizations")]
public Optional<Dictionary<string, string>> NameLocalizations { get; set; }

[JsonProperty("description_localizations")]
public Optional<Dictionary<string, string>> DescriptionLocalizations { get; set; }

[JsonProperty("dm_permission")] [JsonProperty("dm_permission")]
public Optional<bool?> DmPermission { get; set; } public Optional<bool?> DmPermission { get; set; }


@@ -26,12 +36,15 @@ namespace Discord.API.Rest
public Optional<GuildPermission?> DefaultMemberPermission { get; set; } public Optional<GuildPermission?> DefaultMemberPermission { get; set; }


public CreateApplicationCommandParams() { } public CreateApplicationCommandParams() { }
public CreateApplicationCommandParams(string name, string description, ApplicationCommandType type, ApplicationCommandOption[] options = null)
public CreateApplicationCommandParams(string name, string description, ApplicationCommandType type, ApplicationCommandOption[] options = null,
IDictionary<string, string> nameLocalizations = null, IDictionary<string, string> descriptionLocalizations = null)
{ {
Name = name; Name = name;
Description = description; Description = description;
Options = Optional.Create(options); Options = Optional.Create(options);
Type = type; Type = type;
NameLocalizations = nameLocalizations?.ToDictionary(x => x.Key, x => x.Value) ?? Optional<Dictionary<string, string>>.Unspecified;
DescriptionLocalizations = descriptionLocalizations?.ToDictionary(x => x.Key, x => x.Value) ?? Optional<Dictionary<string, string>>.Unspecified;
} }
} }
} }

+ 13
- 5
src/Discord.Net.Rest/API/Rest/CreateStickerParams.cs View File

@@ -1,4 +1,5 @@
using Discord.Net.Rest; using Discord.Net.Rest;

using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
namespace Discord.API.Rest namespace Discord.API.Rest
@@ -20,14 +21,21 @@ namespace Discord.API.Rest
["tags"] = Tags ["tags"] = Tags
}; };


string contentType = "image/png";

string contentType;
if (File is FileStream fileStream) if (File is FileStream fileStream)
contentType = $"image/{Path.GetExtension(fileStream.Name)}";
{
var extension = Path.GetExtension(fileStream.Name).TrimStart('.');
contentType = extension == "json" ? "application/json" : $"image/{extension}";
}
else if (FileName != null) else if (FileName != null)
contentType = $"image/{Path.GetExtension(FileName)}";
{
var extension = Path.GetExtension(FileName).TrimStart('.');
contentType = extension == "json" ? "application/json" : $"image/{extension}";
}
else
contentType = "image/png";


d["file"] = new MultipartFile(File, FileName ?? "image", contentType.Replace(".", ""));
d["file"] = new MultipartFile(File, FileName ?? "image", contentType);


return d; return d;
} }


+ 7
- 0
src/Discord.Net.Rest/API/Rest/ModifyApplicationCommandParams.cs View File

@@ -1,4 +1,5 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Collections.Generic;


namespace Discord.API.Rest namespace Discord.API.Rest
{ {
@@ -15,5 +16,11 @@ namespace Discord.API.Rest


[JsonProperty("default_permission")] [JsonProperty("default_permission")]
public Optional<bool> DefaultPermission { get; set; } public Optional<bool> DefaultPermission { get; set; }

[JsonProperty("name_localizations")]
public Optional<Dictionary<string, string>> NameLocalizations { get; set; }

[JsonProperty("description_localizations")]
public Optional<Dictionary<string, string>> DescriptionLocalizations { get; set; }
} }
} }

+ 1
- 1
src/Discord.Net.Rest/BaseDiscordClient.cs View File

@@ -243,7 +243,7 @@ namespace Discord.Rest
=> Task.FromResult<IApplicationCommand>(null); => Task.FromResult<IApplicationCommand>(null);


/// <inheritdoc /> /// <inheritdoc />
Task<IReadOnlyCollection<IApplicationCommand>> IDiscordClient.GetGlobalApplicationCommandsAsync(RequestOptions options)
Task<IReadOnlyCollection<IApplicationCommand>> IDiscordClient.GetGlobalApplicationCommandsAsync(bool withLocalizations, string locale, RequestOptions options)
=> Task.FromResult<IReadOnlyCollection<IApplicationCommand>>(ImmutableArray.Create<IApplicationCommand>()); => Task.FromResult<IReadOnlyCollection<IApplicationCommand>>(ImmutableArray.Create<IApplicationCommand>());
Task<IApplicationCommand> IDiscordClient.CreateGlobalApplicationCommand(ApplicationCommandProperties properties, RequestOptions options) Task<IApplicationCommand> IDiscordClient.CreateGlobalApplicationCommand(ApplicationCommandProperties properties, RequestOptions options)
=> Task.FromResult<IApplicationCommand>(null); => Task.FromResult<IApplicationCommand>(null);


+ 6
- 6
src/Discord.Net.Rest/ClientHelper.cs View File

@@ -194,10 +194,10 @@ namespace Discord.Rest
}; };
} }


public static async Task<IReadOnlyCollection<RestGlobalCommand>> GetGlobalApplicationCommandsAsync(BaseDiscordClient client,
RequestOptions options = null)
public static async Task<IReadOnlyCollection<RestGlobalCommand>> GetGlobalApplicationCommandsAsync(BaseDiscordClient client, bool withLocalizations = false,
string locale = null, RequestOptions options = null)
{ {
var response = await client.ApiClient.GetGlobalApplicationCommandsAsync(options).ConfigureAwait(false);
var response = await client.ApiClient.GetGlobalApplicationCommandsAsync(withLocalizations, locale, options).ConfigureAwait(false);


if (!response.Any()) if (!response.Any())
return Array.Empty<RestGlobalCommand>(); return Array.Empty<RestGlobalCommand>();
@@ -212,10 +212,10 @@ namespace Discord.Rest
return model != null ? RestGlobalCommand.Create(client, model) : null; return model != null ? RestGlobalCommand.Create(client, model) : null;
} }


public static async Task<IReadOnlyCollection<RestGuildCommand>> GetGuildApplicationCommandsAsync(BaseDiscordClient client, ulong guildId,
RequestOptions options = null)
public static async Task<IReadOnlyCollection<RestGuildCommand>> GetGuildApplicationCommandsAsync(BaseDiscordClient client, ulong guildId, bool withLocalizations = false,
string locale = null, RequestOptions options = null)
{ {
var response = await client.ApiClient.GetGuildApplicationCommandsAsync(guildId, options).ConfigureAwait(false);
var response = await client.ApiClient.GetGuildApplicationCommandsAsync(guildId, withLocalizations, locale, options).ConfigureAwait(false);


if (!response.Any()) if (!response.Any())
return ImmutableArray.Create<RestGuildCommand>(); return ImmutableArray.Create<RestGuildCommand>();


+ 28
- 5
src/Discord.Net.Rest/DiscordRestApiClient.cs View File

@@ -8,6 +8,7 @@ using Newtonsoft.Json;
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.Design;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
@@ -861,7 +862,7 @@ namespace Discord.API
options = RequestOptions.CreateOrClone(options); options = RequestOptions.CreateOrClone(options);


var ids = new BucketIds(webhookId: webhookId); var ids = new BucketIds(webhookId: webhookId);
await SendJsonAsync<Message>("PATCH", () => $"webhooks/{webhookId}/{AuthToken}/messages/{messageId}${WebhookQuery(false, threadId)}", args, ids, clientBucket: ClientBucketType.SendEdit, options: options).ConfigureAwait(false);
await SendJsonAsync<Message>("PATCH", () => $"webhooks/{webhookId}/{AuthToken}/messages/{messageId}?{WebhookQuery(false, threadId)}", args, ids, clientBucket: ClientBucketType.SendEdit, options: options).ConfigureAwait(false);
} }


/// <exception cref="InvalidOperationException">This operation may only be called with a <see cref="TokenType.Webhook"/> token.</exception> /// <exception cref="InvalidOperationException">This operation may only be called with a <see cref="TokenType.Webhook"/> token.</exception>
@@ -1212,11 +1213,22 @@ namespace Discord.API
#endregion #endregion


#region Interactions #region Interactions
public async Task<ApplicationCommand[]> GetGlobalApplicationCommandsAsync(RequestOptions options = null)
public async Task<ApplicationCommand[]> GetGlobalApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null)
{ {
options = RequestOptions.CreateOrClone(options); options = RequestOptions.CreateOrClone(options);


return await SendAsync<ApplicationCommand[]>("GET", () => $"applications/{CurrentApplicationId}/commands", new BucketIds(), options: options).ConfigureAwait(false);
if (locale is not null)
{
if (!System.Text.RegularExpressions.Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"{locale} is not a valid locale.", nameof(locale));

options.RequestHeaders["X-Discord-Locale"] = new[] { locale };
}

//with_localizations=false doesnt return localized names and descriptions
var query = withLocalizations ? "?with_localizations=true" : string.Empty;
return await SendAsync<ApplicationCommand[]>("GET", () => $"applications/{CurrentApplicationId}/commands{query}",
new BucketIds(), options: options).ConfigureAwait(false);
} }


public async Task<ApplicationCommand> GetGlobalApplicationCommandAsync(ulong id, RequestOptions options = null) public async Task<ApplicationCommand> GetGlobalApplicationCommandAsync(ulong id, RequestOptions options = null)
@@ -1281,13 +1293,24 @@ namespace Discord.API
return await SendJsonAsync<ApplicationCommand[]>("PUT", () => $"applications/{CurrentApplicationId}/commands", commands, new BucketIds(), options: options).ConfigureAwait(false); return await SendJsonAsync<ApplicationCommand[]>("PUT", () => $"applications/{CurrentApplicationId}/commands", commands, new BucketIds(), options: options).ConfigureAwait(false);
} }


public async Task<ApplicationCommand[]> GetGuildApplicationCommandsAsync(ulong guildId, RequestOptions options = null)
public async Task<ApplicationCommand[]> GetGuildApplicationCommandsAsync(ulong guildId, bool withLocalizations = false, string locale = null, RequestOptions options = null)
{ {
options = RequestOptions.CreateOrClone(options); options = RequestOptions.CreateOrClone(options);


var bucket = new BucketIds(guildId: guildId); var bucket = new BucketIds(guildId: guildId);


return await SendAsync<ApplicationCommand[]>("GET", () => $"applications/{CurrentApplicationId}/guilds/{guildId}/commands", bucket, options: options).ConfigureAwait(false);
if (locale is not null)
{
if (!System.Text.RegularExpressions.Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$"))
throw new ArgumentException($"{locale} is not a valid locale.", nameof(locale));

options.RequestHeaders["X-Discord-Locale"] = new[] { locale };
}

//with_localizations=false doesnt return localized names and descriptions
var query = withLocalizations ? "?with_localizations=true" : string.Empty;
return await SendAsync<ApplicationCommand[]>("GET", () => $"applications/{CurrentApplicationId}/guilds/{guildId}/commands{query}",
bucket, options: options).ConfigureAwait(false);
} }


public async Task<ApplicationCommand> GetGuildApplicationCommandAsync(ulong guildId, ulong commandId, RequestOptions options = null) public async Task<ApplicationCommand> GetGuildApplicationCommandAsync(ulong guildId, ulong commandId, RequestOptions options = null)


+ 7
- 7
src/Discord.Net.Rest/DiscordRestClient.cs View File

@@ -25,7 +25,7 @@ namespace Discord.Rest
/// Gets the logged-in user. /// Gets the logged-in user.
/// </summary> /// </summary>
public new RestSelfUser CurrentUser { get => base.CurrentUser as RestSelfUser; internal set => base.CurrentUser = value; } public new RestSelfUser CurrentUser { get => base.CurrentUser as RestSelfUser; internal set => base.CurrentUser = value; }
/// <inheritdoc /> /// <inheritdoc />
public DiscordRestClient() : this(new DiscordRestConfig()) { } public DiscordRestClient() : this(new DiscordRestConfig()) { }
/// <summary> /// <summary>
@@ -205,10 +205,10 @@ namespace Discord.Rest
=> ClientHelper.CreateGlobalApplicationCommandAsync(this, properties, options); => ClientHelper.CreateGlobalApplicationCommandAsync(this, properties, options);
public Task<RestGuildCommand> CreateGuildCommand(ApplicationCommandProperties properties, ulong guildId, RequestOptions options = null) public Task<RestGuildCommand> CreateGuildCommand(ApplicationCommandProperties properties, ulong guildId, RequestOptions options = null)
=> ClientHelper.CreateGuildApplicationCommandAsync(this, guildId, properties, options); => ClientHelper.CreateGuildApplicationCommandAsync(this, guildId, properties, options);
public Task<IReadOnlyCollection<RestGlobalCommand>> GetGlobalApplicationCommands(RequestOptions options = null)
=> ClientHelper.GetGlobalApplicationCommandsAsync(this, options);
public Task<IReadOnlyCollection<RestGuildCommand>> GetGuildApplicationCommands(ulong guildId, RequestOptions options = null)
=> ClientHelper.GetGuildApplicationCommandsAsync(this, guildId, options);
public Task<IReadOnlyCollection<RestGlobalCommand>> GetGlobalApplicationCommands(bool withLocalizations = false, string locale = null, RequestOptions options = null)
=> ClientHelper.GetGlobalApplicationCommandsAsync(this, withLocalizations, locale, options);
public Task<IReadOnlyCollection<RestGuildCommand>> GetGuildApplicationCommands(ulong guildId, bool withLocalizations = false, string locale = null, RequestOptions options = null)
=> ClientHelper.GetGuildApplicationCommandsAsync(this, guildId, withLocalizations, locale, options);
public Task<IReadOnlyCollection<RestGlobalCommand>> BulkOverwriteGlobalCommands(ApplicationCommandProperties[] commandProperties, RequestOptions options = null) public Task<IReadOnlyCollection<RestGlobalCommand>> BulkOverwriteGlobalCommands(ApplicationCommandProperties[] commandProperties, RequestOptions options = null)
=> ClientHelper.BulkOverwriteGlobalApplicationCommandAsync(this, commandProperties, options); => ClientHelper.BulkOverwriteGlobalApplicationCommandAsync(this, commandProperties, options);
public Task<IReadOnlyCollection<RestGuildCommand>> BulkOverwriteGuildCommands(ApplicationCommandProperties[] commandProperties, ulong guildId, RequestOptions options = null) public Task<IReadOnlyCollection<RestGuildCommand>> BulkOverwriteGuildCommands(ApplicationCommandProperties[] commandProperties, ulong guildId, RequestOptions options = null)
@@ -319,8 +319,8 @@ namespace Discord.Rest
=> await GetWebhookAsync(id, options).ConfigureAwait(false); => await GetWebhookAsync(id, options).ConfigureAwait(false);


/// <inheritdoc /> /// <inheritdoc />
async Task<IReadOnlyCollection<IApplicationCommand>> IDiscordClient.GetGlobalApplicationCommandsAsync(RequestOptions options)
=> await GetGlobalApplicationCommands(options).ConfigureAwait(false);
async Task<IReadOnlyCollection<IApplicationCommand>> IDiscordClient.GetGlobalApplicationCommandsAsync(bool withLocalizations, string locale, RequestOptions options)
=> await GetGlobalApplicationCommands(withLocalizations, locale, options).ConfigureAwait(false);
/// <inheritdoc /> /// <inheritdoc />
async Task<IApplicationCommand> IDiscordClient.GetGlobalApplicationCommandAsync(ulong id, RequestOptions options) async Task<IApplicationCommand> IDiscordClient.GetGlobalApplicationCommandAsync(ulong id, RequestOptions options)
=> await ClientHelper.GetGlobalApplicationCommandAsync(this, id, options).ConfigureAwait(false); => await ClientHelper.GetGlobalApplicationCommandAsync(this, id, options).ConfigureAwait(false);


+ 4
- 0
src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs View File

@@ -52,6 +52,10 @@ namespace Discord.Rest
[ActionType.MessagePinned] = MessagePinAuditLogData.Create, [ActionType.MessagePinned] = MessagePinAuditLogData.Create,
[ActionType.MessageUnpinned] = MessageUnpinAuditLogData.Create, [ActionType.MessageUnpinned] = MessageUnpinAuditLogData.Create,


[ActionType.EventCreate] = ScheduledEventCreateAuditLogData.Create,
[ActionType.EventUpdate] = ScheduledEventUpdateAuditLogData.Create,
[ActionType.EventDelete] = ScheduledEventDeleteAuditLogData.Create,

[ActionType.ThreadCreate] = ThreadCreateAuditLogData.Create, [ActionType.ThreadCreate] = ThreadCreateAuditLogData.Create,
[ActionType.ThreadUpdate] = ThreadUpdateAuditLogData.Create, [ActionType.ThreadUpdate] = ThreadUpdateAuditLogData.Create,
[ActionType.ThreadDelete] = ThreadDeleteAuditLogData.Create, [ActionType.ThreadDelete] = ThreadDeleteAuditLogData.Create,


+ 149
- 0
src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventCreateAuditLogData.cs View File

@@ -0,0 +1,149 @@
using System;
using System.Linq;
using Discord.API;

using Model = Discord.API.AuditLog;
using EntryModel = Discord.API.AuditLogEntry;

namespace Discord.Rest
{
/// <summary>
/// Contains a piece of audit log data related to a scheduled event creation.
/// </summary>
public class ScheduledEventCreateAuditLogData : IAuditLogData
{
private ScheduledEventCreateAuditLogData(ulong id, ulong guildId, ulong? channelId, ulong? creatorId, string name, string description, DateTimeOffset scheduledStartTime, DateTimeOffset? scheduledEndTime, GuildScheduledEventPrivacyLevel privacyLevel, GuildScheduledEventStatus status, GuildScheduledEventType entityType, ulong? entityId, string location, RestUser creator, int userCount, string image)
{
Id = id ;
GuildId = guildId ;
ChannelId = channelId ;
CreatorId = creatorId ;
Name = name ;
Description = description ;
ScheduledStartTime = scheduledStartTime;
ScheduledEndTime = scheduledEndTime ;
PrivacyLevel = privacyLevel ;
Status = status ;
EntityType = entityType ;
EntityId = entityId ;
Location = location ;
Creator = creator ;
UserCount = userCount ;
Image = image ;
}

internal static ScheduledEventCreateAuditLogData Create(BaseDiscordClient discord, Model log, EntryModel entry)
{
var changes = entry.Changes;

var id = entry.TargetId.Value;

var guildId = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "guild_id")
.NewValue.ToObject<ulong>(discord.ApiClient.Serializer);
var channelId = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "channel_id")
.NewValue.ToObject<ulong?>(discord.ApiClient.Serializer);
var creatorId = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "channel_id")
.NewValue.ToObject<Optional<ulong?>>(discord.ApiClient.Serializer)
.GetValueOrDefault();
var name = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "name")
.NewValue.ToObject<string>(discord.ApiClient.Serializer);
var description = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "description")
.NewValue.ToObject<Optional<string>>(discord.ApiClient.Serializer)
.GetValueOrDefault();
var scheduledStartTime = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "scheduled_start_time")
.NewValue.ToObject<DateTimeOffset>(discord.ApiClient.Serializer);
var scheduledEndTime = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "scheduled_end_time")
.NewValue.ToObject<DateTimeOffset?>(discord.ApiClient.Serializer);
var privacyLevel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "privacy_level")
.NewValue.ToObject<GuildScheduledEventPrivacyLevel>(discord.ApiClient.Serializer);
var status = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "status")
.NewValue.ToObject<GuildScheduledEventStatus>(discord.ApiClient.Serializer);
var entityType = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "entity_type")
.NewValue.ToObject<GuildScheduledEventType>(discord.ApiClient.Serializer);
var entityId = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "entity_id")
.NewValue.ToObject<ulong?>(discord.ApiClient.Serializer);
var entityMetadata = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "entity_metadata")
.NewValue.ToObject<GuildScheduledEventEntityMetadata>(discord.ApiClient.Serializer);
var creator = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "creator")
.NewValue.ToObject<Optional<User>>(discord.ApiClient.Serializer)
.GetValueOrDefault();
var userCount = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "user_count")
.NewValue.ToObject<Optional<int>>(discord.ApiClient.Serializer)
.GetValueOrDefault();
var image = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "image")
.NewValue.ToObject<Optional<string>>(discord.ApiClient.Serializer)
.GetValueOrDefault();

var creatorUser = creator == null ? null : RestUser.Create(discord, creator);

return new ScheduledEventCreateAuditLogData(id, guildId, channelId, creatorId, name, description, scheduledStartTime, scheduledEndTime, privacyLevel, status, entityType, entityId, entityMetadata.Location.GetValueOrDefault(), creatorUser, userCount, image);
}

// Doc Note: Corresponds to the *current* data

/// <summary>
/// Gets the snowflake id of the event.
/// </summary>
public ulong Id { get; }
/// <summary>
/// Gets the snowflake id of the guild the event is associated with.
/// </summary>
public ulong GuildId { get; }
/// <summary>
/// Gets the snowflake id of the channel the event is associated with.
/// </summary>
public ulong? ChannelId { get; }
/// <summary>
/// Gets the snowflake id of the original creator of the event.
/// </summary>
public ulong? CreatorId { get; }
/// <summary>
/// Gets name of the event.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the description of the event. null if none is set.
/// </summary>
public string Description { get; }
/// <summary>
/// Gets the time the event was scheduled for.
/// </summary>
public DateTimeOffset ScheduledStartTime { get; }
/// <summary>
/// Gets the time the event was scheduled to end.
/// </summary>
public DateTimeOffset? ScheduledEndTime { get; }
/// <summary>
/// Gets the privacy level of the event.
/// </summary>
public GuildScheduledEventPrivacyLevel PrivacyLevel { get; }
/// <summary>
/// Gets the status of the event.
/// </summary>
public GuildScheduledEventStatus Status { get; }
/// <summary>
/// Gets the type of the entity associated with the event (stage / void / external).
/// </summary>
public GuildScheduledEventType EntityType { get; }
/// <summary>
/// Gets the snowflake id of the entity associated with the event (stage / void / external).
/// </summary>
public ulong? EntityId { get; }
/// <summary>
/// Gets the metadata for the entity associated with the event.
/// </summary>
public string Location { get; }
/// <summary>
/// Gets the user that originally created the event.
/// </summary>
public RestUser Creator { get; }
/// <summary>
/// Gets the count of users interested in this event.
/// </summary>
public int UserCount { get; }
/// <summary>
/// Gets the image hash of the image that was attached to the event. Null if not set.
/// </summary>
public string Image { get; }
}
}

+ 34
- 0
src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventDeleteAuditLogData.cs View File

@@ -0,0 +1,34 @@
using System;
using System.Linq;
using Discord.API;

using Model = Discord.API.AuditLog;
using EntryModel = Discord.API.AuditLogEntry;

namespace Discord.Rest
{
/// <summary>
/// Contains a piece of audit log data related to a scheduled event deleteion.
/// </summary>
public class ScheduledEventDeleteAuditLogData : IAuditLogData
{
private ScheduledEventDeleteAuditLogData(ulong id)
{
Id = id;
}

internal static ScheduledEventDeleteAuditLogData Create(BaseDiscordClient discord, Model log, EntryModel entry)
{
var id = entry.TargetId.Value;

return new ScheduledEventDeleteAuditLogData(id);
}

// Doc Note: Corresponds to the *current* data

/// <summary>
/// Gets the snowflake id of the event.
/// </summary>
public ulong Id { get; }
}
}

+ 80
- 0
src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventInfo.cs View File

@@ -0,0 +1,80 @@
using System;

namespace Discord.Rest
{
/// <summary>
/// Represents information for a scheduled event.
/// </summary>
public class ScheduledEventInfo
{
/// <summary>
/// Gets the snowflake id of the guild the event is associated with.
/// </summary>
public ulong? GuildId { get; }
/// <summary>
/// Gets the snowflake id of the channel the event is associated with.
/// </summary>
public ulong? ChannelId { get; }
/// <summary>
/// Gets name of the event.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the description of the event. null if none is set.
/// </summary>
public string Description { get; }
/// <summary>
/// Gets the time the event was scheduled for.
/// </summary>
public DateTimeOffset? ScheduledStartTime { get; }
/// <summary>
/// Gets the time the event was scheduled to end.
/// </summary>
public DateTimeOffset? ScheduledEndTime { get; }
/// <summary>
/// Gets the privacy level of the event.
/// </summary>
public GuildScheduledEventPrivacyLevel? PrivacyLevel { get; }
/// <summary>
/// Gets the status of the event.
/// </summary>
public GuildScheduledEventStatus? Status { get; }
/// <summary>
/// Gets the type of the entity associated with the event (stage / void / external).
/// </summary>
public GuildScheduledEventType? EntityType { get; }
/// <summary>
/// Gets the snowflake id of the entity associated with the event (stage / void / external).
/// </summary>
public ulong? EntityId { get; }
/// <summary>
/// Gets the metadata for the entity associated with the event.
/// </summary>
public string Location { get; }
/// <summary>
/// Gets the count of users interested in this event.
/// </summary>
public int? UserCount { get; }
/// <summary>
/// Gets the image hash of the image that was attached to the event. Null if not set.
/// </summary>
public string Image { get; }

internal ScheduledEventInfo(ulong? guildId, ulong? channelId, string name, string description, DateTimeOffset? scheduledStartTime, DateTimeOffset? scheduledEndTime, GuildScheduledEventPrivacyLevel? privacyLevel, GuildScheduledEventStatus? status, GuildScheduledEventType? entityType, ulong? entityId, string location, int? userCount, string image)
{
GuildId = guildId ;
ChannelId = channelId ;
Name = name ;
Description = description ;
ScheduledStartTime = scheduledStartTime;
ScheduledEndTime = scheduledEndTime ;
PrivacyLevel = privacyLevel ;
Status = status ;
EntityType = entityType ;
EntityId = entityId ;
Location = location ;
UserCount = userCount ;
Image = image ;
}
}
}

+ 99
- 0
src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventUpdateAuditLogData.cs View File

@@ -0,0 +1,99 @@
using System;
using System.Linq;
using Discord.API;

using Model = Discord.API.AuditLog;
using EntryModel = Discord.API.AuditLogEntry;

namespace Discord.Rest
{
/// <summary>
/// Contains a piece of audit log data related to a scheduled event updates.
/// </summary>
public class ScheduledEventUpdateAuditLogData : IAuditLogData
{
private ScheduledEventUpdateAuditLogData(ulong id, ScheduledEventInfo before, ScheduledEventInfo after)
{
Id = id;
Before = before;
After = after;
}

internal static ScheduledEventUpdateAuditLogData Create(BaseDiscordClient discord, Model log, EntryModel entry)
{
var changes = entry.Changes;

var id = entry.TargetId.Value;

var guildId = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "guild_id");
var channelId = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "channel_id");
var name = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "name");
var description = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "description");
var scheduledStartTime = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "scheduled_start_time");
var scheduledEndTime = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "scheduled_end_time");
var privacyLevel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "privacy_level");
var status = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "status");
var entityType = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "entity_type");
var entityId = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "entity_id");
var entityMetadata = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "entity_metadata");
var userCount = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "user_count");
var image = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "image");

var before = new ScheduledEventInfo(
guildId?.OldValue.ToObject<ulong>(discord.ApiClient.Serializer),
channelId?.OldValue.ToObject<ulong?>(discord.ApiClient.Serializer),
name?.OldValue.ToObject<string>(discord.ApiClient.Serializer),
description?.OldValue.ToObject<Optional<string>>(discord.ApiClient.Serializer)
.GetValueOrDefault(),
scheduledStartTime?.OldValue.ToObject<DateTimeOffset>(discord.ApiClient.Serializer),
scheduledEndTime?.OldValue.ToObject<DateTimeOffset?>(discord.ApiClient.Serializer),
privacyLevel?.OldValue.ToObject<GuildScheduledEventPrivacyLevel>(discord.ApiClient.Serializer),
status?.OldValue.ToObject<GuildScheduledEventStatus>(discord.ApiClient.Serializer),
entityType?.OldValue.ToObject<GuildScheduledEventType>(discord.ApiClient.Serializer),
entityId?.OldValue.ToObject<ulong?>(discord.ApiClient.Serializer),
entityMetadata?.OldValue.ToObject<GuildScheduledEventEntityMetadata>(discord.ApiClient.Serializer)
?.Location.GetValueOrDefault(),
userCount?.OldValue.ToObject<Optional<int>>(discord.ApiClient.Serializer)
.GetValueOrDefault(),
image?.OldValue.ToObject<Optional<string>>(discord.ApiClient.Serializer)
.GetValueOrDefault()
);
var after = new ScheduledEventInfo(
guildId?.NewValue.ToObject<ulong>(discord.ApiClient.Serializer),
channelId?.NewValue.ToObject<ulong?>(discord.ApiClient.Serializer),
name?.NewValue.ToObject<string>(discord.ApiClient.Serializer),
description?.NewValue.ToObject<Optional<string>>(discord.ApiClient.Serializer)
.GetValueOrDefault(),
scheduledStartTime?.NewValue.ToObject<DateTimeOffset>(discord.ApiClient.Serializer),
scheduledEndTime?.NewValue.ToObject<DateTimeOffset?>(discord.ApiClient.Serializer),
privacyLevel?.NewValue.ToObject<GuildScheduledEventPrivacyLevel>(discord.ApiClient.Serializer),
status?.NewValue.ToObject<GuildScheduledEventStatus>(discord.ApiClient.Serializer),
entityType?.NewValue.ToObject<GuildScheduledEventType>(discord.ApiClient.Serializer),
entityId?.NewValue.ToObject<ulong?>(discord.ApiClient.Serializer),
entityMetadata?.NewValue.ToObject<GuildScheduledEventEntityMetadata>(discord.ApiClient.Serializer)
?.Location.GetValueOrDefault(),
userCount?.NewValue.ToObject<Optional<int>>(discord.ApiClient.Serializer)
.GetValueOrDefault(),
image?.NewValue.ToObject<Optional<string>>(discord.ApiClient.Serializer)
.GetValueOrDefault()
);

return new ScheduledEventUpdateAuditLogData(id, before, after);
}

// Doc Note: Corresponds to the *current* data

/// <summary>
/// Gets the snowflake id of the event.
/// </summary>
public ulong Id { get; }
/// <summary>
/// Gets the state before the change.
/// </summary>
public ScheduledEventInfo Before { get; }
/// <summary>
/// Gets the state after the change.
/// </summary>
public ScheduledEventInfo After { get; }
}
}

+ 4
- 4
src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs View File

@@ -180,7 +180,7 @@ namespace Discord.Rest
}, },
nextPage: (info, lastPage) => nextPage: (info, lastPage) =>
{ {
if (lastPage.Count != DiscordConfig.MaxMessagesPerBatch)
if (lastPage.Count != DiscordConfig.MaxBansPerBatch)
return false; return false;
if (dir == Direction.Before) if (dir == Direction.Before)
info.Position = lastPage.Min(x => x.User.Id); info.Position = lastPage.Min(x => x.User.Id);
@@ -362,10 +362,10 @@ namespace Discord.Rest
#endregion #endregion


#region Interactions #region Interactions
public static async Task<IReadOnlyCollection<RestGuildCommand>> GetSlashCommandsAsync(IGuild guild, BaseDiscordClient client,
RequestOptions options)
public static async Task<IReadOnlyCollection<RestGuildCommand>> GetSlashCommandsAsync(IGuild guild, BaseDiscordClient client, bool withLocalizations,
string locale, RequestOptions options)
{ {
var models = await client.ApiClient.GetGuildApplicationCommandsAsync(guild.Id, options);
var models = await client.ApiClient.GetGuildApplicationCommandsAsync(guild.Id, withLocalizations, locale, options);
return models.Select(x => RestGuildCommand.Create(client, x, guild.Id)).ToImmutableArray(); return models.Select(x => RestGuildCommand.Create(client, x, guild.Id)).ToImmutableArray();
} }
public static async Task<RestGuildCommand> GetSlashCommandAsync(IGuild guild, ulong id, BaseDiscordClient client, public static async Task<RestGuildCommand> GetSlashCommandAsync(IGuild guild, ulong id, BaseDiscordClient client,


+ 10
- 6
src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs View File

@@ -311,13 +311,15 @@ namespace Discord.Rest
/// <summary> /// <summary>
/// Gets a collection of slash commands created by the current user in this guild. /// Gets a collection of slash commands created by the current user in this guild.
/// </summary> /// </summary>
/// <param name="withLocalizations">Whether to include full localization dictionaries in the returned objects, instead of the name localized and description localized fields.</param>
/// <param name="locale">The target locale of the localized name and description fields. Sets <c>X-Discord-Locale</c> header, which takes precedence over <c>Accept-Language</c>.</param>
/// <param name="options">The options to be used when sending the request.</param> /// <param name="options">The options to be used when sending the request.</param>
/// <returns> /// <returns>
/// A task that represents the asynchronous get operation. The task result contains a read-only collection of /// A task that represents the asynchronous get operation. The task result contains a read-only collection of
/// slash commands created by the current user. /// slash commands created by the current user.
/// </returns> /// </returns>
public Task<IReadOnlyCollection<RestGuildCommand>> GetSlashCommandsAsync(RequestOptions options = null)
=> GuildHelper.GetSlashCommandsAsync(this, Discord, options);
public Task<IReadOnlyCollection<RestGuildCommand>> GetSlashCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null)
=> GuildHelper.GetSlashCommandsAsync(this, Discord, withLocalizations, locale, options);


/// <summary> /// <summary>
/// Gets a slash command in the current guild. /// Gets a slash command in the current guild.
@@ -928,13 +930,15 @@ namespace Discord.Rest
/// <summary> /// <summary>
/// Gets this guilds slash commands /// Gets this guilds slash commands
/// </summary> /// </summary>
/// <param name="withLocalizations">Whether to include full localization dictionaries in the returned objects, instead of the name localized and description localized fields.</param>
/// <param name="locale">The target locale of the localized name and description fields. Sets <c>X-Discord-Locale</c> header, which takes precedence over <c>Accept-Language</c>.</param>
/// <param name="options">The options to be used when sending the request.</param> /// <param name="options">The options to be used when sending the request.</param>
/// <returns> /// <returns>
/// A task that represents the asynchronous get operation. The task result contains a read-only collection /// A task that represents the asynchronous get operation. The task result contains a read-only collection
/// of application commands found within the guild. /// of application commands found within the guild.
/// </returns> /// </returns>
public async Task<IReadOnlyCollection<RestGuildCommand>> GetApplicationCommandsAsync (RequestOptions options = null)
=> await ClientHelper.GetGuildApplicationCommandsAsync(Discord, Id, options).ConfigureAwait(false);
public async Task<IReadOnlyCollection<RestGuildCommand>> GetApplicationCommandsAsync (bool withLocalizations = false, string locale = null, RequestOptions options = null)
=> await ClientHelper.GetGuildApplicationCommandsAsync(Discord, Id, withLocalizations, locale, options).ConfigureAwait(false);
/// <summary> /// <summary>
/// Gets an application command within this guild with the specified id. /// Gets an application command within this guild with the specified id.
/// </summary> /// </summary>
@@ -1467,8 +1471,8 @@ namespace Discord.Rest
async Task<IReadOnlyCollection<IWebhook>> IGuild.GetWebhooksAsync(RequestOptions options) async Task<IReadOnlyCollection<IWebhook>> IGuild.GetWebhooksAsync(RequestOptions options)
=> await GetWebhooksAsync(options).ConfigureAwait(false); => await GetWebhooksAsync(options).ConfigureAwait(false);
/// <inheritdoc /> /// <inheritdoc />
async Task<IReadOnlyCollection<IApplicationCommand>> IGuild.GetApplicationCommandsAsync (RequestOptions options)
=> await GetApplicationCommandsAsync(options).ConfigureAwait(false);
async Task<IReadOnlyCollection<IApplicationCommand>> IGuild.GetApplicationCommandsAsync (bool withLocalizations, string locale, RequestOptions options)
=> await GetApplicationCommandsAsync(withLocalizations, locale, options).ConfigureAwait(false);
/// <inheritdoc /> /// <inheritdoc />
async Task<ICustomSticker> IGuild.CreateStickerAsync(string name, string description, IEnumerable<string> tags, Image image, RequestOptions options) async Task<ICustomSticker> IGuild.CreateStickerAsync(string name, string description, IEnumerable<string> tags, Image image, RequestOptions options)
=> await CreateStickerAsync(name, description, tags, image, options); => await CreateStickerAsync(name, description, tags, image, options);


+ 15
- 3
src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs View File

@@ -3,6 +3,7 @@ using Discord.API.Rest;
using Discord.Net; using Discord.Net;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -101,11 +102,12 @@ namespace Discord.Rest
DefaultPermission = arg.IsDefaultPermission.IsSpecified DefaultPermission = arg.IsDefaultPermission.IsSpecified
? arg.IsDefaultPermission.Value ? arg.IsDefaultPermission.Value
: Optional<bool>.Unspecified, : Optional<bool>.Unspecified,
NameLocalizations = arg.NameLocalizations?.ToDictionary(),
DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary(),


// TODO: better conversion to nullable optionals // TODO: better conversion to nullable optionals
DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(), DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(),
DmPermission = arg.IsDMEnabled.ToNullable() DmPermission = arg.IsDMEnabled.ToNullable()
}; };


if (arg is SlashCommandProperties slashProps) if (arg is SlashCommandProperties slashProps)
@@ -140,6 +142,8 @@ namespace Discord.Rest
DefaultPermission = arg.IsDefaultPermission.IsSpecified DefaultPermission = arg.IsDefaultPermission.IsSpecified
? arg.IsDefaultPermission.Value ? arg.IsDefaultPermission.Value
: Optional<bool>.Unspecified, : Optional<bool>.Unspecified,
NameLocalizations = arg.NameLocalizations?.ToDictionary(),
DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary(),


// TODO: better conversion to nullable optionals // TODO: better conversion to nullable optionals
DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(), DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(),
@@ -181,6 +185,8 @@ namespace Discord.Rest
DefaultPermission = arg.IsDefaultPermission.IsSpecified DefaultPermission = arg.IsDefaultPermission.IsSpecified
? arg.IsDefaultPermission.Value ? arg.IsDefaultPermission.Value
: Optional<bool>.Unspecified, : Optional<bool>.Unspecified,
NameLocalizations = arg.NameLocalizations?.ToDictionary(),
DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary(),


// TODO: better conversion to nullable optionals // TODO: better conversion to nullable optionals
DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(), DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(),
@@ -244,7 +250,9 @@ namespace Discord.Rest
Name = args.Name, Name = args.Name,
DefaultPermission = args.IsDefaultPermission.IsSpecified DefaultPermission = args.IsDefaultPermission.IsSpecified
? args.IsDefaultPermission.Value ? args.IsDefaultPermission.Value
: Optional<bool>.Unspecified
: Optional<bool>.Unspecified,
NameLocalizations = args.NameLocalizations?.ToDictionary(),
DescriptionLocalizations = args.DescriptionLocalizations?.ToDictionary()
}; };


if (args is SlashCommandProperties slashProps) if (args is SlashCommandProperties slashProps)
@@ -299,6 +307,8 @@ namespace Discord.Rest
DefaultPermission = arg.IsDefaultPermission.IsSpecified DefaultPermission = arg.IsDefaultPermission.IsSpecified
? arg.IsDefaultPermission.Value ? arg.IsDefaultPermission.Value
: Optional<bool>.Unspecified, : Optional<bool>.Unspecified,
NameLocalizations = arg.NameLocalizations?.ToDictionary(),
DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary(),


// TODO: better conversion to nullable optionals // TODO: better conversion to nullable optionals
DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(), DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(),
@@ -335,7 +345,9 @@ namespace Discord.Rest
Name = arg.Name, Name = arg.Name,
DefaultPermission = arg.IsDefaultPermission.IsSpecified DefaultPermission = arg.IsDefaultPermission.IsSpecified
? arg.IsDefaultPermission.Value ? arg.IsDefaultPermission.Value
: Optional<bool>.Unspecified
: Optional<bool>.Unspecified,
NameLocalizations = arg.NameLocalizations?.ToDictionary(),
DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary()
}; };


if (arg is SlashCommandProperties slashProps) if (arg is SlashCommandProperties slashProps)


+ 35
- 0
src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommand.cs View File

@@ -38,6 +38,32 @@ namespace Discord.Rest
/// </summary> /// </summary>
public IReadOnlyCollection<RestApplicationCommandOption> Options { get; private set; } public IReadOnlyCollection<RestApplicationCommandOption> Options { get; private set; }


/// <summary>
/// Gets the localization dictionary for the name field of this command.
/// </summary>
public IReadOnlyDictionary<string, string> NameLocalizations { get; private set; }

/// <summary>
/// Gets the localization dictionary for the description field of this command.
/// </summary>
public IReadOnlyDictionary<string, string> DescriptionLocalizations { get; private set; }

/// <summary>
/// Gets the localized name of this command.
/// </summary>
/// <remarks>
/// Only returned when the `withLocalizations` query parameter is set to <see langword="false"/> when requesting the command.
/// </remarks>
public string NameLocalized { get; private set; }

/// <summary>
/// Gets the localized description of this command.
/// </summary>
/// <remarks>
/// Only returned when the `withLocalizations` query parameter is set to <see langword="false"/> when requesting the command.
/// </remarks>
public string DescriptionLocalized { get; private set; }

/// <inheritdoc/> /// <inheritdoc/>
public DateTimeOffset CreatedAt public DateTimeOffset CreatedAt
=> SnowflakeUtils.FromSnowflake(Id); => SnowflakeUtils.FromSnowflake(Id);
@@ -64,6 +90,15 @@ namespace Discord.Rest
? model.Options.Value.Select(RestApplicationCommandOption.Create).ToImmutableArray() ? model.Options.Value.Select(RestApplicationCommandOption.Create).ToImmutableArray()
: ImmutableArray.Create<RestApplicationCommandOption>(); : ImmutableArray.Create<RestApplicationCommandOption>();


NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ??
ImmutableDictionary<string, string>.Empty;

DescriptionLocalizations = model.DescriptionLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ??
ImmutableDictionary<string, string>.Empty;

NameLocalized = model.NameLocalized.GetValueOrDefault();
DescriptionLocalized = model.DescriptionLocalized.GetValueOrDefault();
IsEnabledInDm = model.DmPermission.GetValueOrDefault(true).GetValueOrDefault(true); IsEnabledInDm = model.DmPermission.GetValueOrDefault(true).GetValueOrDefault(true);
DefaultMemberPermissions = new GuildPermissions((ulong)model.DefaultMemberPermission.GetValueOrDefault(0).GetValueOrDefault(0)); DefaultMemberPermissions = new GuildPermissions((ulong)model.DefaultMemberPermission.GetValueOrDefault(0).GetValueOrDefault(0));
} }


+ 17
- 0
src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandChoice.cs View File

@@ -1,3 +1,5 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using Model = Discord.API.ApplicationCommandOptionChoice; using Model = Discord.API.ApplicationCommandOptionChoice;


namespace Discord.Rest namespace Discord.Rest
@@ -13,10 +15,25 @@ namespace Discord.Rest
/// <inheritdoc/> /// <inheritdoc/>
public object Value { get; } public object Value { get; }


/// <summary>
/// Gets the localization dictionary for the name field of this command option choice.
/// </summary>
public IReadOnlyDictionary<string, string> NameLocalizations { get; }

/// <summary>
/// Gets the localized name of this command option choice.
/// </summary>
/// <remarks>
/// Only returned when the `withLocalizations` query parameter is set to <see langword="false"/> when requesting the command.
/// </remarks>
public string NameLocalized { get; }

internal RestApplicationCommandChoice(Model model) internal RestApplicationCommandChoice(Model model)
{ {
Name = model.Name; Name = model.Name;
Value = model.Value; Value = model.Value;
NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary();
NameLocalized = model.NameLocalized.GetValueOrDefault(null);
} }
} }
} }

+ 36
- 1
src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs View File

@@ -27,7 +27,7 @@ namespace Discord.Rest
public bool? IsRequired { get; private set; } public bool? IsRequired { get; private set; }


/// <inheritdoc/> /// <inheritdoc/>
public bool? IsAutocomplete { get; private set; }
public bool? IsAutocomplete { get; private set; }


/// <inheritdoc/> /// <inheritdoc/>
public double? MinValue { get; private set; } public double? MinValue { get; private set; }
@@ -54,6 +54,32 @@ namespace Discord.Rest
/// <inheritdoc/> /// <inheritdoc/>
public IReadOnlyCollection<ChannelType> ChannelTypes { get; private set; } public IReadOnlyCollection<ChannelType> ChannelTypes { get; private set; }


/// <summary>
/// Gets the localization dictionary for the name field of this command option.
/// </summary>
public IReadOnlyDictionary<string, string> NameLocalizations { get; private set; }

/// <summary>
/// Gets the localization dictionary for the description field of this command option.
/// </summary>
public IReadOnlyDictionary<string, string> DescriptionLocalizations { get; private set; }

/// <summary>
/// Gets the localized name of this command option.
/// </summary>
/// <remarks>
/// Only returned when the `withLocalizations` query parameter is set to <see langword="false"/> when requesting the command.
/// </remarks>
public string NameLocalized { get; private set; }

/// <summary>
/// Gets the localized description of this command option.
/// </summary>
/// <remarks>
/// Only returned when the `withLocalizations` query parameter is set to <see langword="false"/> when requesting the command.
/// </remarks>
public string DescriptionLocalized { get; private set; }

internal RestApplicationCommandOption() { } internal RestApplicationCommandOption() { }


internal static RestApplicationCommandOption Create(Model model) internal static RestApplicationCommandOption Create(Model model)
@@ -98,6 +124,15 @@ namespace Discord.Rest
ChannelTypes = model.ChannelTypes.IsSpecified ChannelTypes = model.ChannelTypes.IsSpecified
? model.ChannelTypes.Value.ToImmutableArray() ? model.ChannelTypes.Value.ToImmutableArray()
: ImmutableArray.Create<ChannelType>(); : ImmutableArray.Create<ChannelType>();

NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ??
ImmutableDictionary<string, string>.Empty;

DescriptionLocalizations = model.DescriptionLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ??
ImmutableDictionary<string, string>.Empty;

NameLocalized = model.NameLocalized.GetValueOrDefault();
DescriptionLocalized = model.DescriptionLocalized.GetValueOrDefault();
} }
#endregion #endregion




+ 7
- 7
src/Discord.Net.Rest/Entities/Roles/RoleHelper.cs View File

@@ -23,7 +23,7 @@ namespace Discord.Rest
{ {
role.Guild.Features.EnsureFeature(GuildFeature.RoleIcons); role.Guild.Features.EnsureFeature(GuildFeature.RoleIcons);


if (args.Icon.IsSpecified && args.Emoji.IsSpecified)
if ((args.Icon.IsSpecified && args.Icon.Value != null) && (args.Emoji.IsSpecified && args.Emoji.Value != null))
{ {
throw new ArgumentException("Emoji and Icon properties cannot be present on a role at the same time."); throw new ArgumentException("Emoji and Icon properties cannot be present on a role at the same time.");
} }
@@ -36,18 +36,18 @@ namespace Discord.Rest
Mentionable = args.Mentionable, Mentionable = args.Mentionable,
Name = args.Name, Name = args.Name,
Permissions = args.Permissions.IsSpecified ? args.Permissions.Value.RawValue.ToString() : Optional.Create<string>(), Permissions = args.Permissions.IsSpecified ? args.Permissions.Value.RawValue.ToString() : Optional.Create<string>(),
Icon = args.Icon.IsSpecified ? args.Icon.Value.Value.ToModel() : Optional<API.Image?>.Unspecified,
Emoji = args.Emoji.GetValueOrDefault()?.Name ?? Optional<string>.Unspecified
Icon = args.Icon.IsSpecified ? args.Icon.Value?.ToModel() ?? null : Optional<API.Image?>.Unspecified,
Emoji = args.Emoji.IsSpecified ? args.Emoji.Value?.Name ?? "" : Optional.Create<string>(),
}; };


if (args.Icon.IsSpecified && role.Emoji != null)
if ((args.Icon.IsSpecified && args.Icon.Value != null) && role.Emoji != null)
{ {
apiArgs.Emoji = null;
apiArgs.Emoji = "";
} }


if (args.Emoji.IsSpecified && !string.IsNullOrEmpty(role.Icon))
if ((args.Emoji.IsSpecified && args.Emoji.Value != null) && !string.IsNullOrEmpty(role.Icon))
{ {
apiArgs.Icon = null;
apiArgs.Icon = Optional<API.Image?>.Unspecified;
} }


var model = await client.ApiClient.ModifyGuildRoleAsync(role.Guild.Id, role.Id, apiArgs, options).ConfigureAwait(false); var model = await client.ApiClient.ModifyGuildRoleAsync(role.Guild.Id, role.Id, apiArgs, options).ConfigureAwait(false);


+ 16
- 4
src/Discord.Net.Rest/Net/DefaultRestClient.cs View File

@@ -66,33 +66,45 @@ namespace Discord.Net.Rest
_cancelToken = cancelToken; _cancelToken = cancelToken;
} }


public async Task<RestResponse> SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly, string reason = null)
public async Task<RestResponse> SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly, string reason = null,
IEnumerable<KeyValuePair<string, IEnumerable<string>>> requestHeaders = null)
{ {
string uri = Path.Combine(_baseUrl, endpoint); string uri = Path.Combine(_baseUrl, endpoint);
using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) using (var restRequest = new HttpRequestMessage(GetMethod(method), uri))
{ {
if (reason != null) restRequest.Headers.Add("X-Audit-Log-Reason", Uri.EscapeDataString(reason)); if (reason != null) restRequest.Headers.Add("X-Audit-Log-Reason", Uri.EscapeDataString(reason));
if (requestHeaders != null)
foreach (var header in requestHeaders)
restRequest.Headers.Add(header.Key, header.Value);
return await SendInternalAsync(restRequest, cancelToken, headerOnly).ConfigureAwait(false); return await SendInternalAsync(restRequest, cancelToken, headerOnly).ConfigureAwait(false);
} }
} }
public async Task<RestResponse> SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly, string reason = null)
public async Task<RestResponse> SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly, string reason = null,
IEnumerable<KeyValuePair<string, IEnumerable<string>>> requestHeaders = null)
{ {
string uri = Path.Combine(_baseUrl, endpoint); string uri = Path.Combine(_baseUrl, endpoint);
using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) using (var restRequest = new HttpRequestMessage(GetMethod(method), uri))
{ {
if (reason != null) restRequest.Headers.Add("X-Audit-Log-Reason", Uri.EscapeDataString(reason)); if (reason != null) restRequest.Headers.Add("X-Audit-Log-Reason", Uri.EscapeDataString(reason));
if (requestHeaders != null)
foreach (var header in requestHeaders)
restRequest.Headers.Add(header.Key, header.Value);
restRequest.Content = new StringContent(json, Encoding.UTF8, "application/json"); restRequest.Content = new StringContent(json, Encoding.UTF8, "application/json");
return await SendInternalAsync(restRequest, cancelToken, headerOnly).ConfigureAwait(false); return await SendInternalAsync(restRequest, cancelToken, headerOnly).ConfigureAwait(false);
} }
} }


/// <exception cref="InvalidOperationException">Unsupported param type.</exception> /// <exception cref="InvalidOperationException">Unsupported param type.</exception>
public async Task<RestResponse> SendAsync(string method, string endpoint, IReadOnlyDictionary<string, object> multipartParams, CancellationToken cancelToken, bool headerOnly, string reason = null)
public async Task<RestResponse> SendAsync(string method, string endpoint, IReadOnlyDictionary<string, object> multipartParams, CancellationToken cancelToken, bool headerOnly, string reason = null,
IEnumerable<KeyValuePair<string, IEnumerable<string>>> requestHeaders = null)
{ {
string uri = Path.Combine(_baseUrl, endpoint); string uri = Path.Combine(_baseUrl, endpoint);
using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) using (var restRequest = new HttpRequestMessage(GetMethod(method), uri))
{ {
if (reason != null) restRequest.Headers.Add("X-Audit-Log-Reason", Uri.EscapeDataString(reason)); if (reason != null) restRequest.Headers.Add("X-Audit-Log-Reason", Uri.EscapeDataString(reason));
if (requestHeaders != null)
foreach (var header in requestHeaders)
restRequest.Headers.Add(header.Key, header.Value);
var content = new MultipartFormDataContent("Upload----" + DateTime.Now.ToString(CultureInfo.InvariantCulture)); var content = new MultipartFormDataContent("Upload----" + DateTime.Now.ToString(CultureInfo.InvariantCulture));
MemoryStream memoryStream = null; MemoryStream memoryStream = null;
if (multipartParams != null) if (multipartParams != null)
@@ -126,7 +138,7 @@ namespace Discord.Net.Rest


content.Add(streamContent, p.Key, fileValue.Filename); content.Add(streamContent, p.Key, fileValue.Filename);
#pragma warning restore IDISP004 #pragma warning restore IDISP004
continue; continue;
} }
default: default:


+ 4
- 1
src/Discord.Net.Rest/Net/Queue/Requests/RestRequest.cs View File

@@ -1,5 +1,8 @@
using Discord.Net.Rest; using Discord.Net.Rest;
using System; using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;


@@ -28,7 +31,7 @@ namespace Discord.Net.Queue


public virtual async Task<RestResponse> SendAsync() public virtual async Task<RestResponse> SendAsync()
{ {
return await Client.SendAsync(Method, Endpoint, Options.CancelToken, Options.HeaderOnly, Options.AuditLogReason).ConfigureAwait(false);
return await Client.SendAsync(Method, Endpoint, Options.CancelToken, Options.HeaderOnly, Options.AuditLogReason, Options.RequestHeaders).ConfigureAwait(false);
} }
} }
} }

+ 2
- 0
src/Discord.Net.WebSocket/API/Gateway/ReadyEvent.cs View File

@@ -20,6 +20,8 @@ namespace Discord.API.Gateway
public User User { get; set; } public User User { get; set; }
[JsonProperty("session_id")] [JsonProperty("session_id")]
public string SessionId { get; set; } public string SessionId { get; set; }
[JsonProperty("resume_gateway_url")]
public string ResumeGatewayUrl { get; set; }
[JsonProperty("read_state")] [JsonProperty("read_state")]
public ReadState[] ReadStates { get; set; } public ReadState[] ReadStates { get; set; }
[JsonProperty("guilds")] [JsonProperty("guilds")]


+ 11
- 1
src/Discord.Net.WebSocket/DiscordShardedClient.cs View File

@@ -139,9 +139,9 @@ namespace Discord.WebSocket


internal override async Task OnLoginAsync(TokenType tokenType, string token) internal override async Task OnLoginAsync(TokenType tokenType, string token)
{ {
var botGateway = await GetBotGatewayAsync().ConfigureAwait(false);
if (_automaticShards) if (_automaticShards)
{ {
var botGateway = await GetBotGatewayAsync().ConfigureAwait(false);
_shardIds = Enumerable.Range(0, botGateway.Shards).ToArray(); _shardIds = Enumerable.Range(0, botGateway.Shards).ToArray();
_totalShards = _shardIds.Length; _totalShards = _shardIds.Length;
_shards = new DiscordSocketClient[_shardIds.Length]; _shards = new DiscordSocketClient[_shardIds.Length];
@@ -163,7 +163,12 @@ namespace Discord.WebSocket


//Assume thread safe: already in a connection lock //Assume thread safe: already in a connection lock
for (int i = 0; i < _shards.Length; i++) for (int i = 0; i < _shards.Length; i++)
{
// Set the gateway URL to the one returned by Discord, if a custom one isn't set.
_shards[i].ApiClient.GatewayUrl = botGateway.Url;

await _shards[i].LoginAsync(tokenType, token); await _shards[i].LoginAsync(tokenType, token);
}


if(_defaultStickers.Length == 0 && _baseConfig.AlwaysDownloadDefaultStickers) if(_defaultStickers.Length == 0 && _baseConfig.AlwaysDownloadDefaultStickers)
await DownloadDefaultStickersAsync().ConfigureAwait(false); await DownloadDefaultStickersAsync().ConfigureAwait(false);
@@ -175,7 +180,12 @@ namespace Discord.WebSocket
if (_shards != null) if (_shards != null)
{ {
for (int i = 0; i < _shards.Length; i++) for (int i = 0; i < _shards.Length; i++)
{
// Reset the gateway URL set for the shard.
_shards[i].ApiClient.GatewayUrl = null;

await _shards[i].LogoutAsync(); await _shards[i].LogoutAsync();
}
} }


if (_automaticShards) if (_automaticShards)


+ 53
- 7
src/Discord.Net.WebSocket/DiscordSocketApiClient.cs View File

@@ -30,6 +30,7 @@ namespace Discord.API
private readonly bool _isExplicitUrl; private readonly bool _isExplicitUrl;
private CancellationTokenSource _connectCancelToken; private CancellationTokenSource _connectCancelToken;
private string _gatewayUrl; private string _gatewayUrl;
private string _resumeGatewayUrl;


//Store our decompression streams for zlib shared state //Store our decompression streams for zlib shared state
private MemoryStream _compressed; private MemoryStream _compressed;
@@ -39,6 +40,32 @@ namespace Discord.API


public ConnectionState ConnectionState { get; private set; } public ConnectionState ConnectionState { get; private set; }


/// <summary>
/// Sets the gateway URL used for identifies.
/// </summary>
/// <remarks>
/// If a custom URL is set, setting this property does nothing.
/// </remarks>
public string GatewayUrl
{
set
{
// Makes the sharded client not override the custom value.
if (_isExplicitUrl)
return;

_gatewayUrl = FormatGatewayUrl(value);
}
}

/// <summary>
/// Sets the gateway URL used for resumes.
/// </summary>
public string ResumeGatewayUrl
{
set => _resumeGatewayUrl = FormatGatewayUrl(value);
}

public DiscordSocketApiClient(RestClientProvider restClientProvider, WebSocketProvider webSocketProvider, string userAgent, public DiscordSocketApiClient(RestClientProvider restClientProvider, WebSocketProvider webSocketProvider, string userAgent,
string url = null, RetryMode defaultRetryMode = RetryMode.AlwaysRetry, JsonSerializer serializer = null, string url = null, RetryMode defaultRetryMode = RetryMode.AlwaysRetry, JsonSerializer serializer = null,
bool useSystemClock = true, Func<IRateLimitInfo, Task> defaultRatelimitCallback = null) bool useSystemClock = true, Func<IRateLimitInfo, Task> defaultRatelimitCallback = null)
@@ -159,6 +186,17 @@ namespace Discord.API
#endif #endif
} }


/// <summary>
/// Appends necessary query parameters to the specified gateway URL.
/// </summary>
private static string FormatGatewayUrl(string gatewayUrl)
{
if (gatewayUrl == null)
return null;

return $"{gatewayUrl}?v={DiscordConfig.APIVersion}&encoding={DiscordSocketConfig.GatewayEncoding}&compress=zlib-stream";
}

public async Task ConnectAsync() public async Task ConnectAsync()
{ {
await _stateLock.WaitAsync().ConfigureAwait(false); await _stateLock.WaitAsync().ConfigureAwait(false);
@@ -193,24 +231,32 @@ namespace Discord.API
if (WebSocketClient != null) if (WebSocketClient != null)
WebSocketClient.SetCancelToken(_connectCancelToken.Token); WebSocketClient.SetCancelToken(_connectCancelToken.Token);


if (!_isExplicitUrl)
string gatewayUrl;
if (_resumeGatewayUrl == null)
{
if (!_isExplicitUrl && _gatewayUrl == null)
{
var gatewayResponse = await GetBotGatewayAsync().ConfigureAwait(false);
_gatewayUrl = FormatGatewayUrl(gatewayResponse.Url);
}

gatewayUrl = _gatewayUrl;
}
else
{ {
var gatewayResponse = await GetGatewayAsync().ConfigureAwait(false);
_gatewayUrl = $"{gatewayResponse.Url}?v={DiscordConfig.APIVersion}&encoding={DiscordSocketConfig.GatewayEncoding}&compress=zlib-stream";
gatewayUrl = _resumeGatewayUrl;
} }


#if DEBUG_PACKETS #if DEBUG_PACKETS
Console.WriteLine("Connecting to gateway: " + _gatewayUrl);
Console.WriteLine("Connecting to gateway: " + gatewayUrl);
#endif #endif


await WebSocketClient.ConnectAsync(_gatewayUrl).ConfigureAwait(false);
await WebSocketClient.ConnectAsync(gatewayUrl).ConfigureAwait(false);


ConnectionState = ConnectionState.Connected; ConnectionState = ConnectionState.Connected;
} }
catch catch
{ {
if (!_isExplicitUrl)
_gatewayUrl = null; //Uncache in case the gateway url changed
await DisconnectInternalAsync().ConfigureAwait(false); await DisconnectInternalAsync().ConfigureAwait(false);
throw; throw;
} }


+ 48
- 18
src/Discord.Net.WebSocket/DiscordSocketClient.cs View File

@@ -323,7 +323,6 @@ namespace Discord.WebSocket
} }
private async Task OnDisconnectingAsync(Exception ex) private async Task OnDisconnectingAsync(Exception ex)
{ {

await _gatewayLogger.DebugAsync("Disconnecting ApiClient").ConfigureAwait(false); await _gatewayLogger.DebugAsync("Disconnecting ApiClient").ConfigureAwait(false);
await ApiClient.DisconnectAsync(ex).ConfigureAwait(false); await ApiClient.DisconnectAsync(ex).ConfigureAwait(false);


@@ -451,14 +450,16 @@ namespace Discord.WebSocket
/// <summary> /// <summary>
/// Gets a collection of all global commands. /// Gets a collection of all global commands.
/// </summary> /// </summary>
/// <param name="withLocalizations">Whether to include full localization dictionaries in the returned objects, instead of the name localized and description localized fields.</param>
/// <param name="locale">The target locale of the localized name and description fields. Sets <c>X-Discord-Locale</c> header, which takes precedence over <c>Accept-Language</c>.</param>
/// <param name="options">The options to be used when sending the request.</param> /// <param name="options">The options to be used when sending the request.</param>
/// <returns> /// <returns>
/// A task that represents the asynchronous get operation. The task result contains a read-only collection of global /// A task that represents the asynchronous get operation. The task result contains a read-only collection of global
/// application commands. /// application commands.
/// </returns> /// </returns>
public async Task<IReadOnlyCollection<SocketApplicationCommand>> GetGlobalApplicationCommandsAsync(RequestOptions options = null)
public async Task<IReadOnlyCollection<SocketApplicationCommand>> GetGlobalApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null)
{ {
var commands = (await ApiClient.GetGlobalApplicationCommandsAsync(options)).Select(x => SocketApplicationCommand.Create(this, x));
var commands = (await ApiClient.GetGlobalApplicationCommandsAsync(withLocalizations, locale, options)).Select(x => SocketApplicationCommand.Create(this, x));


foreach(var command in commands) foreach(var command in commands)
{ {
@@ -742,31 +743,49 @@ namespace Discord.WebSocket


private async Task LogGatewayIntentsWarning() private async Task LogGatewayIntentsWarning()
{ {
if(_gatewayIntents.HasFlag(GatewayIntents.GuildPresences) && !_presenceUpdated.HasSubscribers)
if (_gatewayIntents.HasFlag(GatewayIntents.GuildPresences) &&
(_shardedClient is null && !_presenceUpdated.HasSubscribers ||
(_shardedClient is not null && !_shardedClient._presenceUpdated.HasSubscribers)))
{ {
await _gatewayLogger.WarningAsync("You're using the GuildPresences intent without listening to the PresenceUpdate event, consider removing the intent from your config.").ConfigureAwait(false); await _gatewayLogger.WarningAsync("You're using the GuildPresences intent without listening to the PresenceUpdate event, consider removing the intent from your config.").ConfigureAwait(false);
} }


if(!_gatewayIntents.HasFlag(GatewayIntents.GuildPresences) && _presenceUpdated.HasSubscribers)
if(!_gatewayIntents.HasFlag(GatewayIntents.GuildPresences) &&
((_shardedClient is null && _presenceUpdated.HasSubscribers) ||
(_shardedClient is not null && _shardedClient._presenceUpdated.HasSubscribers)))
{ {
await _gatewayLogger.WarningAsync("You're using the PresenceUpdate event without specifying the GuildPresences intent. Discord wont send this event to your client without the intent set in your config.").ConfigureAwait(false); await _gatewayLogger.WarningAsync("You're using the PresenceUpdate event without specifying the GuildPresences intent. Discord wont send this event to your client without the intent set in your config.").ConfigureAwait(false);
} }


bool hasGuildScheduledEventsSubscribers = bool hasGuildScheduledEventsSubscribers =
_guildScheduledEventCancelled.HasSubscribers || _guildScheduledEventCancelled.HasSubscribers ||
_guildScheduledEventUserRemove.HasSubscribers ||
_guildScheduledEventCompleted.HasSubscribers ||
_guildScheduledEventCreated.HasSubscribers ||
_guildScheduledEventStarted.HasSubscribers ||
_guildScheduledEventUpdated.HasSubscribers ||
_guildScheduledEventUserAdd.HasSubscribers;

if(_gatewayIntents.HasFlag(GatewayIntents.GuildScheduledEvents) && !hasGuildScheduledEventsSubscribers)
_guildScheduledEventUserRemove.HasSubscribers ||
_guildScheduledEventCompleted.HasSubscribers ||
_guildScheduledEventCreated.HasSubscribers ||
_guildScheduledEventStarted.HasSubscribers ||
_guildScheduledEventUpdated.HasSubscribers ||
_guildScheduledEventUserAdd.HasSubscribers;

bool shardedClientHasGuildScheduledEventsSubscribers =
_shardedClient is not null &&
(_shardedClient._guildScheduledEventCancelled.HasSubscribers ||
_shardedClient._guildScheduledEventUserRemove.HasSubscribers ||
_shardedClient._guildScheduledEventCompleted.HasSubscribers ||
_shardedClient._guildScheduledEventCreated.HasSubscribers ||
_shardedClient._guildScheduledEventStarted.HasSubscribers ||
_shardedClient._guildScheduledEventUpdated.HasSubscribers ||
_shardedClient._guildScheduledEventUserAdd.HasSubscribers);

if (_gatewayIntents.HasFlag(GatewayIntents.GuildScheduledEvents) &&
((_shardedClient is null && !hasGuildScheduledEventsSubscribers) ||
(_shardedClient is not null && !shardedClientHasGuildScheduledEventsSubscribers)))
{ {
await _gatewayLogger.WarningAsync("You're using the GuildScheduledEvents gateway intent without listening to any events related to that intent, consider removing the intent from your config.").ConfigureAwait(false); await _gatewayLogger.WarningAsync("You're using the GuildScheduledEvents gateway intent without listening to any events related to that intent, consider removing the intent from your config.").ConfigureAwait(false);
} }


if(!_gatewayIntents.HasFlag(GatewayIntents.GuildScheduledEvents) && hasGuildScheduledEventsSubscribers)
if(!_gatewayIntents.HasFlag(GatewayIntents.GuildScheduledEvents) &&
((_shardedClient is null && hasGuildScheduledEventsSubscribers) ||
(_shardedClient is not null && shardedClientHasGuildScheduledEventsSubscribers)))
{ {
await _gatewayLogger.WarningAsync("You're using events related to the GuildScheduledEvents gateway intent without specifying the intent. Discord wont send this event to your client without the intent set in your config.").ConfigureAwait(false); await _gatewayLogger.WarningAsync("You're using events related to the GuildScheduledEvents gateway intent without specifying the intent. Discord wont send this event to your client without the intent set in your config.").ConfigureAwait(false);
} }
@@ -775,12 +794,21 @@ namespace Discord.WebSocket
_inviteCreatedEvent.HasSubscribers || _inviteCreatedEvent.HasSubscribers ||
_inviteDeletedEvent.HasSubscribers; _inviteDeletedEvent.HasSubscribers;


if (_gatewayIntents.HasFlag(GatewayIntents.GuildInvites) && !hasInviteEventSubscribers)
bool shardedClientHasInviteEventSubscribers =
_shardedClient is not null &&
(_shardedClient._inviteCreatedEvent.HasSubscribers ||
_shardedClient._inviteDeletedEvent.HasSubscribers);

if (_gatewayIntents.HasFlag(GatewayIntents.GuildInvites) &&
((_shardedClient is null && !hasInviteEventSubscribers) ||
(_shardedClient is not null && !shardedClientHasInviteEventSubscribers)))
{ {
await _gatewayLogger.WarningAsync("You're using the GuildInvites gateway intent without listening to any events related to that intent, consider removing the intent from your config.").ConfigureAwait(false); await _gatewayLogger.WarningAsync("You're using the GuildInvites gateway intent without listening to any events related to that intent, consider removing the intent from your config.").ConfigureAwait(false);
} }


if (!_gatewayIntents.HasFlag(GatewayIntents.GuildInvites) && hasInviteEventSubscribers)
if (!_gatewayIntents.HasFlag(GatewayIntents.GuildInvites) &&
((_shardedClient is null && hasInviteEventSubscribers) ||
(_shardedClient is not null && shardedClientHasInviteEventSubscribers)))
{ {
await _gatewayLogger.WarningAsync("You're using events related to the GuildInvites gateway intent without specifying the intent. Discord wont send this event to your client without the intent set in your config.").ConfigureAwait(false); await _gatewayLogger.WarningAsync("You're using events related to the GuildInvites gateway intent without specifying the intent. Discord wont send this event to your client without the intent set in your config.").ConfigureAwait(false);
} }
@@ -833,6 +861,7 @@ namespace Discord.WebSocket


_sessionId = null; _sessionId = null;
_lastSeq = 0; _lastSeq = 0;
ApiClient.ResumeGatewayUrl = null;


if (_shardedClient != null) if (_shardedClient != null)
{ {
@@ -890,6 +919,7 @@ namespace Discord.WebSocket
AddPrivateChannel(data.PrivateChannels[i], state); AddPrivateChannel(data.PrivateChannels[i], state);


_sessionId = data.SessionId; _sessionId = data.SessionId;
ApiClient.ResumeGatewayUrl = data.ResumeGatewayUrl;
_unavailableGuildCount = unavailableGuilds; _unavailableGuildCount = unavailableGuilds;
CurrentUser = currentUser; CurrentUser = currentUser;
_previousSessionUser = CurrentUser; _previousSessionUser = CurrentUser;
@@ -3237,8 +3267,8 @@ namespace Discord.WebSocket
async Task<IApplicationCommand> IDiscordClient.GetGlobalApplicationCommandAsync(ulong id, RequestOptions options) async Task<IApplicationCommand> IDiscordClient.GetGlobalApplicationCommandAsync(ulong id, RequestOptions options)
=> await GetGlobalApplicationCommandAsync(id, options); => await GetGlobalApplicationCommandAsync(id, options);
/// <inheritdoc /> /// <inheritdoc />
async Task<IReadOnlyCollection<IApplicationCommand>> IDiscordClient.GetGlobalApplicationCommandsAsync(RequestOptions options)
=> await GetGlobalApplicationCommandsAsync(options);
async Task<IReadOnlyCollection<IApplicationCommand>> IDiscordClient.GetGlobalApplicationCommandsAsync(bool withLocalizations, string locale, RequestOptions options)
=> await GetGlobalApplicationCommandsAsync(withLocalizations, locale, options);


/// <inheritdoc /> /// <inheritdoc />
async Task IDiscordClient.StartAsync() async Task IDiscordClient.StartAsync()


+ 7
- 4
src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs View File

@@ -874,14 +874,17 @@ namespace Discord.WebSocket
/// <summary> /// <summary>
/// Gets a collection of slash commands created by the current user in this guild. /// Gets a collection of slash commands created by the current user in this guild.
/// </summary> /// </summary>
/// <param name="withLocalizations">Whether to include full localization dictionaries in the returned objects, instead of the name localized and description localized fields.</param>
/// <param name="locale">The target locale of the localized name and description fields. Sets <c>X-Discord-Locale</c> header, which takes precedence over <c>Accept-Language</c>.</param>
/// <param name="options">The options to be used when sending the request.</param> /// <param name="options">The options to be used when sending the request.</param>
/// <returns> /// <returns>
/// A task that represents the asynchronous get operation. The task result contains a read-only collection of /// A task that represents the asynchronous get operation. The task result contains a read-only collection of
/// slash commands created by the current user. /// slash commands created by the current user.
/// </returns> /// </returns>
public async Task<IReadOnlyCollection<SocketApplicationCommand>> GetApplicationCommandsAsync(RequestOptions options = null)
public async Task<IReadOnlyCollection<SocketApplicationCommand>> GetApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null)
{ {
var commands = (await Discord.ApiClient.GetGuildApplicationCommandsAsync(Id, options)).Select(x => SocketApplicationCommand.Create(Discord, x, Id));
var commands = (await Discord.ApiClient.GetGuildApplicationCommandsAsync(Id, withLocalizations, locale, options))
.Select(x => SocketApplicationCommand.Create(Discord, x, Id));


foreach (var command in commands) foreach (var command in commands)
{ {
@@ -1977,8 +1980,8 @@ namespace Discord.WebSocket
async Task<IReadOnlyCollection<IWebhook>> IGuild.GetWebhooksAsync(RequestOptions options) async Task<IReadOnlyCollection<IWebhook>> IGuild.GetWebhooksAsync(RequestOptions options)
=> await GetWebhooksAsync(options).ConfigureAwait(false); => await GetWebhooksAsync(options).ConfigureAwait(false);
/// <inheritdoc /> /// <inheritdoc />
async Task<IReadOnlyCollection<IApplicationCommand>> IGuild.GetApplicationCommandsAsync (RequestOptions options)
=> await GetApplicationCommandsAsync(options).ConfigureAwait(false);
async Task<IReadOnlyCollection<IApplicationCommand>> IGuild.GetApplicationCommandsAsync (bool withLocalizations, string locale, RequestOptions options)
=> await GetApplicationCommandsAsync(withLocalizations, locale, options).ConfigureAwait(false);
/// <inheritdoc /> /// <inheritdoc />
async Task<ICustomSticker> IGuild.CreateStickerAsync(string name, string description, IEnumerable<string> tags, Image image, RequestOptions options) async Task<ICustomSticker> IGuild.CreateStickerAsync(string name, string description, IEnumerable<string> tags, Image image, RequestOptions options)
=> await CreateStickerAsync(name, description, tags, image, options); => await CreateStickerAsync(name, description, tags, image, options);


+ 35
- 0
src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommand.cs View File

@@ -50,6 +50,32 @@ namespace Discord.WebSocket
/// </remarks> /// </remarks>
public IReadOnlyCollection<SocketApplicationCommandOption> Options { get; private set; } public IReadOnlyCollection<SocketApplicationCommandOption> Options { get; private set; }


/// <summary>
/// Gets the localization dictionary for the name field of this command.
/// </summary>
public IReadOnlyDictionary<string, string> NameLocalizations { get; private set; }

/// <summary>
/// Gets the localization dictionary for the description field of this command.
/// </summary>
public IReadOnlyDictionary<string, string> DescriptionLocalizations { get; private set; }

/// <summary>
/// Gets the localized name of this command.
/// </summary>
/// <remarks>
/// Only returned when the `withLocalizations` query parameter is set to <see langword="false"/> when requesting the command.
/// </remarks>
public string NameLocalized { get; private set; }

/// <summary>
/// Gets the localized description of this command.
/// </summary>
/// <remarks>
/// Only returned when the `withLocalizations` query parameter is set to <see langword="false"/> when requesting the command.
/// </remarks>
public string DescriptionLocalized { get; private set; }

/// <inheritdoc/> /// <inheritdoc/>
public DateTimeOffset CreatedAt public DateTimeOffset CreatedAt
=> SnowflakeUtils.FromSnowflake(Id); => SnowflakeUtils.FromSnowflake(Id);
@@ -93,6 +119,15 @@ namespace Discord.WebSocket
? model.Options.Value.Select(SocketApplicationCommandOption.Create).ToImmutableArray() ? model.Options.Value.Select(SocketApplicationCommandOption.Create).ToImmutableArray()
: ImmutableArray.Create<SocketApplicationCommandOption>(); : ImmutableArray.Create<SocketApplicationCommandOption>();


NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ??
ImmutableDictionary<string, string>.Empty;

DescriptionLocalizations = model.DescriptionLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ??
ImmutableDictionary<string, string>.Empty;

NameLocalized = model.NameLocalized.GetValueOrDefault();
DescriptionLocalized = model.DescriptionLocalized.GetValueOrDefault();

IsEnabledInDm = model.DmPermission.GetValueOrDefault(true).GetValueOrDefault(true); IsEnabledInDm = model.DmPermission.GetValueOrDefault(true).GetValueOrDefault(true);
DefaultMemberPermissions = new GuildPermissions((ulong)model.DefaultMemberPermission.GetValueOrDefault(0).GetValueOrDefault(0)); DefaultMemberPermissions = new GuildPermissions((ulong)model.DefaultMemberPermission.GetValueOrDefault(0).GetValueOrDefault(0));
} }


+ 17
- 0
src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandChoice.cs View File

@@ -1,3 +1,5 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using Model = Discord.API.ApplicationCommandOptionChoice; using Model = Discord.API.ApplicationCommandOptionChoice;


namespace Discord.WebSocket namespace Discord.WebSocket
@@ -13,6 +15,19 @@ namespace Discord.WebSocket
/// <inheritdoc/> /// <inheritdoc/>
public object Value { get; private set; } public object Value { get; private set; }


/// <summary>
/// Gets the localization dictionary for the name field of this command option choice.
/// </summary>
public IReadOnlyDictionary<string, string> NameLocalizations { get; private set; }

/// <summary>
/// Gets the localized name of this command option choice.
/// </summary>
/// <remarks>
/// Only returned when the `withLocalizations` query parameter is set to <see langword="false"/> when requesting the command.
/// </remarks>
public string NameLocalized { get; private set; }

internal SocketApplicationCommandChoice() { } internal SocketApplicationCommandChoice() { }
internal static SocketApplicationCommandChoice Create(Model model) internal static SocketApplicationCommandChoice Create(Model model)
{ {
@@ -24,6 +39,8 @@ namespace Discord.WebSocket
{ {
Name = model.Name; Name = model.Name;
Value = model.Value; Value = model.Value;
NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary();
NameLocalized = model.NameLocalized.GetValueOrDefault(null);
} }
} }
} }

+ 35
- 0
src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandOption.cs View File

@@ -54,6 +54,32 @@ namespace Discord.WebSocket
/// </summary> /// </summary>
public IReadOnlyCollection<ChannelType> ChannelTypes { get; private set; } public IReadOnlyCollection<ChannelType> ChannelTypes { get; private set; }


/// <summary>
/// Gets the localization dictionary for the name field of this command option.
/// </summary>
public IReadOnlyDictionary<string, string> NameLocalizations { get; private set; }

/// <summary>
/// Gets the localization dictionary for the description field of this command option.
/// </summary>
public IReadOnlyDictionary<string, string> DescriptionLocalizations { get; private set; }

/// <summary>
/// Gets the localized name of this command option.
/// </summary>
/// <remarks>
/// Only returned when the `withLocalizations` query parameter is set to <see langword="false"/> when requesting the command.
/// </remarks>
public string NameLocalized { get; private set; }

/// <summary>
/// Gets the localized description of this command option.
/// </summary>
/// <remarks>
/// Only returned when the `withLocalizations` query parameter is set to <see langword="false"/> when requesting the command.
/// </remarks>
public string DescriptionLocalized { get; private set; }

internal SocketApplicationCommandOption() { } internal SocketApplicationCommandOption() { }
internal static SocketApplicationCommandOption Create(Model model) internal static SocketApplicationCommandOption Create(Model model)
{ {
@@ -92,6 +118,15 @@ namespace Discord.WebSocket
ChannelTypes = model.ChannelTypes.IsSpecified ChannelTypes = model.ChannelTypes.IsSpecified
? model.ChannelTypes.Value.ToImmutableArray() ? model.ChannelTypes.Value.ToImmutableArray()
: ImmutableArray.Create<ChannelType>(); : ImmutableArray.Create<ChannelType>();

NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ??
ImmutableDictionary<string, string>.Empty;

DescriptionLocalizations = model.DescriptionLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ??
ImmutableDictionary<string, string>.Empty;

NameLocalized = model.NameLocalized.GetValueOrDefault();
DescriptionLocalized = model.DescriptionLocalized.GetValueOrDefault();
} }


IReadOnlyCollection<IApplicationCommandOptionChoice> IApplicationCommandOption.Choices => Choices; IReadOnlyCollection<IApplicationCommandOptionChoice> IApplicationCommandOption.Choices => Choices;


+ 31
- 31
src/Discord.Net/Discord.Net.nuspec View File

@@ -2,7 +2,7 @@
<package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd"> <package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd">
<metadata> <metadata>
<id>Discord.Net</id> <id>Discord.Net</id>
<version>3.7.2$suffix$</version>
<version>3.8.1$suffix$</version>
<title>Discord.Net</title> <title>Discord.Net</title>
<authors>Discord.Net Contributors</authors> <authors>Discord.Net Contributors</authors>
<owners>foxbot</owners> <owners>foxbot</owners>
@@ -14,44 +14,44 @@
<iconUrl>https://github.com/discord-net/Discord.Net/raw/dev/docs/marketing/logo/PackageLogo.png</iconUrl> <iconUrl>https://github.com/discord-net/Discord.Net/raw/dev/docs/marketing/logo/PackageLogo.png</iconUrl>
<dependencies> <dependencies>
<group targetFramework="net6.0"> <group targetFramework="net6.0">
<dependency id="Discord.Net.Core" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Rest" version="3.7.2$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Commands" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Core" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Rest" version="3.8.1$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Commands" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.8.1$suffix$" />
</group> </group>
<group targetFramework="net5.0"> <group targetFramework="net5.0">
<dependency id="Discord.Net.Core" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Rest" version="3.7.2$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Commands" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Core" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Rest" version="3.8.1$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Commands" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.8.1$suffix$" />
</group> </group>
<group targetFramework="net461"> <group targetFramework="net461">
<dependency id="Discord.Net.Core" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Rest" version="3.7.2$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Commands" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Core" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Rest" version="3.8.1$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Commands" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.8.1$suffix$" />
</group> </group>
<group targetFramework="netstandard2.0"> <group targetFramework="netstandard2.0">
<dependency id="Discord.Net.Core" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Rest" version="3.7.2$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Commands" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Core" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Rest" version="3.8.1$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Commands" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.8.1$suffix$" />
</group> </group>
<group targetFramework="netstandard2.1"> <group targetFramework="netstandard2.1">
<dependency id="Discord.Net.Core" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Rest" version="3.7.2$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Commands" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.7.2$suffix$" />
<dependency id="Discord.Net.Core" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Rest" version="3.8.1$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Commands" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.8.1$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.8.1$suffix$" />
</group> </group>
</dependencies> </dependencies>
</metadata> </metadata>


+ 1
- 0
test/Discord.Net.Tests.Unit/ColorTests.cs View File

@@ -10,6 +10,7 @@ namespace Discord
/// </summary> /// </summary>
public class ColorTests public class ColorTests
{ {
[Fact]
public void Color_New() public void Color_New()
{ {
Assert.Equal(0u, new Color().RawValue); Assert.Equal(0u, new Color().RawValue);


+ 37
- 0
test/Discord.Net.Tests.Unit/CommandBuilderTests.cs View File

@@ -0,0 +1,37 @@
using System;
using Discord;
using Xunit;

namespace Discord;

public class CommandBuilderTests
{
[Fact]
public void BuildSimpleSlashCommand()
{
var command = new SlashCommandBuilder()
.WithName("command")
.WithDescription("description")
.AddOption(
"option1",
ApplicationCommandOptionType.String,
"option1 description",
isRequired: true,
choices: new []
{
new ApplicationCommandOptionChoiceProperties()
{
Name = "choice1", Value = "1"
}
})
.AddOptions(new SlashCommandOptionBuilder()
.WithName("option2")
.WithDescription("option2 description")
.WithType(ApplicationCommandOptionType.String)
.WithRequired(true)
.AddChannelType(ChannelType.Text)
.AddChoice("choice1", "1")
.AddChoice("choice2", "2"));
command.Build();
}
}

Loading…
Cancel
Save