Browse Source

Interactions Command Localization (#2395)

* Request headers (#2394)

* add support for per-request headers

* remove unnecessary usings

* Revert "remove unnecessary usings"

This reverts commit 8d674fe4fa.

* remove nullable strings from RequestOptions

* Add Localization Support to Interaction Service (#2211)

* add json and resx localization managers

* add utils class for getting command paths

* update json regex to make langage code optional

* remove IServiceProvider from ILocalizationManager method params

* replace the command path method in command map

* add localization fields to rest and websocket application command entity implementations

* move deconstruct extensions method to extensions folder

* add withLocalizations parameter to rest methods

* fix build error

* add rest conversions to interaction service

* add localization to the rest methods

* add inline docs

* fix implementation bugs

* add missing inline docs

* inline docs correction (Name/Description Localized properties)

* add choice localization

* fix conflicts

* fix conflicts

* add missing command props fields to ToApplicationCommandProps methods

* add locale parameter to Get*ApplicationCommandsAsync methods for fetching localized command names/descriptions

* Apply suggestions from code review

Co-authored-by: Armano den Boef <68127614+Rozen4334@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Armano den Boef <68127614+Rozen4334@users.noreply.github.com>

* Update src/Discord.Net.Core/Entities/Guilds/IGuild.cs

Co-authored-by: Armano den Boef <68127614+Rozen4334@users.noreply.github.com>

* add inline docs to LocalizationTarget

* fix upstream merge errors

* fix command parsing for context command names with space char

* fix command parsing for context command names with space char

* fix failed to generate buket id

* fix get guild commands endpoint

* update rexs localization manager to use single-file pattern

* Upstream Merge Localization Branch (#2434)

* fix ci/cd error (#2428)

* Fix role icon & emoji assignment. (#2416)

* Fix IGuild.GetBansAsync() (#2424)

fix the problem of not being able to get more than 1000 bans

* [DOCS] Add a note about `DontAutoRegisterAttribute`  (#2430)

* add a note about `DontAutoRegisterAttribute`

* Remove "to to" and add punctuation

Co-authored-by: MrCakeSlayer <13650699+MrCakeSlayer@users.noreply.github.com>

* fix: Missing Fact attribute in ColorTests (#2425)

* feat: Embed comparison (#2347)

* Fix broken code snippet in dependency injection docs (#2420)

* Fixed markdown formatting to show code snippet

* Fixed constructor injection code snippet pointer

* Added support for lottie stickers (#2359)

Co-authored-by: Armano den Boef <68127614+Rozen4334@users.noreply.github.com>
Co-authored-by: BokuNoPasya <49203428+1NieR@users.noreply.github.com>
Co-authored-by: Misha133 <61027276+Misha-133@users.noreply.github.com>
Co-authored-by: MrCakeSlayer <13650699+MrCakeSlayer@users.noreply.github.com>
Co-authored-by: Ge <gehongyan1996@126.com>
Co-authored-by: Charlie U <52503242+cpurules@users.noreply.github.com>
Co-authored-by: Kuba_Z2 <77853483+KubaZ2@users.noreply.github.com>

* remove unnecassary fields from ResxLocalizationManager

* update int framework guides

* remove space character tokenization from ResxLocalizationManager

Co-authored-by: Armano den Boef <68127614+Rozen4334@users.noreply.github.com>
Co-authored-by: BokuNoPasya <49203428+1NieR@users.noreply.github.com>
Co-authored-by: Misha133 <61027276+Misha-133@users.noreply.github.com>
Co-authored-by: MrCakeSlayer <13650699+MrCakeSlayer@users.noreply.github.com>
Co-authored-by: Ge <gehongyan1996@126.com>
Co-authored-by: Charlie U <52503242+cpurules@users.noreply.github.com>
Co-authored-by: Kuba_Z2 <77853483+KubaZ2@users.noreply.github.com>
tags/3.8.0
Cenk Ergen GitHub 2 years ago
parent
commit
39bbd298c3
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 1403 additions and 152 deletions
  1. +41
    -0
      docs/guides/int_framework/intro.md
  2. +6
    -1
      src/Discord.Net.Core/Entities/Guilds/IGuild.cs
  3. +75
    -17
      src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs
  4. +33
    -0
      src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOptionChoice.cs
  5. +52
    -0
      src/Discord.Net.Core/Entities/Interactions/ApplicationCommandProperties.cs
  6. +70
    -0
      src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs
  7. +71
    -1
      src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs
  8. +26
    -0
      src/Discord.Net.Core/Entities/Interactions/IApplicationCommand.cs
  9. +26
    -0
      src/Discord.Net.Core/Entities/Interactions/IApplicationCommandOption.cs
  10. +15
    -0
      src/Discord.Net.Core/Entities/Interactions/IApplicationCommandOptionChoice.cs
  11. +288
    -37
      src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs
  12. +15
    -0
      src/Discord.Net.Core/Extensions/GenericCollectionExtensions.cs
  13. +3
    -1
      src/Discord.Net.Core/IDiscordClient.cs
  14. +7
    -3
      src/Discord.Net.Core/Net/Rest/IRestClient.cs
  15. +8
    -4
      src/Discord.Net.Core/RequestOptions.cs
  16. +5
    -5
      src/Discord.Net.Core/Utils/Preconditions.cs
  17. +6
    -0
      src/Discord.Net.Interactions/InteractionService.cs
  18. +5
    -0
      src/Discord.Net.Interactions/InteractionServiceConfig.cs
  19. +32
    -0
      src/Discord.Net.Interactions/LocalizationManagers/ILocalizationManager.cs
  20. +72
    -0
      src/Discord.Net.Interactions/LocalizationManagers/JsonLocalizationManager.cs
  21. +55
    -0
      src/Discord.Net.Interactions/LocalizationManagers/ResxLocalizationManager.cs
  22. +25
    -0
      src/Discord.Net.Interactions/LocalizationTarget.cs
  23. +2
    -21
      src/Discord.Net.Interactions/Map/CommandMap.cs
  24. +71
    -17
      src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs
  25. +53
    -0
      src/Discord.Net.Interactions/Utilities/CommandHierarchy.cs
  26. +13
    -0
      src/Discord.Net.Rest/API/Common/ApplicationCommand.cs
  27. +21
    -0
      src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs
  28. +7
    -0
      src/Discord.Net.Rest/API/Common/ApplicationCommandOptionChoice.cs
  29. +14
    -1
      src/Discord.Net.Rest/API/Rest/CreateApplicationCommandParams.cs
  30. +7
    -0
      src/Discord.Net.Rest/API/Rest/ModifyApplicationCommandParams.cs
  31. +1
    -1
      src/Discord.Net.Rest/BaseDiscordClient.cs
  32. +6
    -6
      src/Discord.Net.Rest/ClientHelper.cs
  33. +27
    -4
      src/Discord.Net.Rest/DiscordRestApiClient.cs
  34. +7
    -7
      src/Discord.Net.Rest/DiscordRestClient.cs
  35. +3
    -3
      src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs
  36. +10
    -6
      src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs
  37. +17
    -3
      src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs
  38. +35
    -0
      src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommand.cs
  39. +17
    -0
      src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandChoice.cs
  40. +36
    -1
      src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs
  41. +16
    -4
      src/Discord.Net.Rest/Net/DefaultRestClient.cs
  42. +4
    -1
      src/Discord.Net.Rest/Net/Queue/Requests/RestRequest.cs
  43. +6
    -4
      src/Discord.Net.WebSocket/DiscordSocketClient.cs
  44. +7
    -4
      src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs
  45. +35
    -0
      src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommand.cs
  46. +17
    -0
      src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandChoice.cs
  47. +35
    -0
      src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandOption.cs

+ 41
- 0
docs/guides/int_framework/intro.md View File

@@ -376,6 +376,47 @@ 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.
- 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
[DependencyInjection]: xref:Guides.DI.Intro



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

@@ -1194,12 +1194,17 @@ namespace Discord
/// <summary>
/// Gets this guilds application commands.
/// </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>
/// <returns>
/// A task that represents the asynchronous get operation. The task result contains a read-only collection
/// of application commands found within the guild.
/// </returns>
Task<IReadOnlyCollection<IApplicationCommand>> GetApplicationCommandsAsync(RequestOptions options = null);
Task<IReadOnlyCollection<IApplicationCommand>> GetApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null);

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


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

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

/// <summary>
/// Gets or sets the name of this option.
@@ -21,18 +24,7 @@ namespace Discord
get => _name;
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;
}
}
@@ -43,12 +35,11 @@ namespace Discord
public string 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>
@@ -105,5 +96,72 @@ namespace Discord
/// Gets or sets the allowed channel types for this option.
/// </summary>
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
{
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
{
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, @"^[\w-]{1,32}$"))
throw new FormatException($"{nameof(name)} must match the regex ^[\\w-]{{1,32}}$");

if (name.Any(x => char.IsUpper(x)))
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.");
}
}
}
}

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

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

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

/// <summary>
/// Gets or sets the name of this choice.
@@ -40,5 +45,33 @@ namespace Discord
_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
{
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;
}
}
}
}

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

internal abstract ApplicationCommandType Type { get; }

/// <summary>
@@ -17,6 +27,48 @@ namespace Discord
/// </summary>
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
{
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 (!Regex.IsMatch(name, @"^[\w-]{1,32}$"))
throw new ArgumentException("Option name cannot contain any special characters or whitespaces!", 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
{
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>
/// Gets or sets whether or not this command can be used in DMs.
/// </summary>


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

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

/// <summary>
/// Build the current builder into a <see cref="MessageCommandProperties"/> class.
@@ -86,6 +97,30 @@ namespace Discord
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>
/// Sets whether or not this command can be used in dms
/// </summary>
@@ -97,6 +132,41 @@ namespace Discord
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));

// Discord updated the docs, this regex prevents special characters like @!$%(... 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));

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

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


+ 71
- 1
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
{
/// <summary>
@@ -5,7 +10,7 @@ namespace Discord
/// </summary>
public class UserCommandBuilder
{
/// <summary>
/// <summary>
/// Returns the maximum length a commands name allowed by Discord.
/// </summary>
public const int MaxNameLength = 32;
@@ -31,6 +36,11 @@ namespace Discord
/// </summary>
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>
/// Gets or sets whether or not this command can be used in DMs.
/// </summary>
@@ -42,6 +52,7 @@ namespace Discord
public GuildPermission? DefaultMemberPermissions { get; set; }

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

/// <summary>
/// Build the current builder into a <see cref="UserCommandProperties"/> class.
@@ -84,6 +95,30 @@ namespace Discord
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>
/// Sets whether or not this command can be used in dms
/// </summary>
@@ -95,6 +130,41 @@ namespace Discord
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));

// Discord updated the docs, this regex prevents special characters like @!$%(... 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));

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

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


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

@@ -52,6 +52,32 @@ namespace Discord
/// </summary>
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>
/// Modifies the current application command.
/// </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.
/// </summary>
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
{
/// <summary>
@@ -14,5 +16,18 @@ namespace Discord
/// Gets the value of the choice.
/// </summary>
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; }
}
}

+ 288
- 37
src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs View File

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

namespace Discord
@@ -31,18 +34,7 @@ namespace Discord
get => _name;
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;
}
}
@@ -55,10 +47,7 @@ namespace Discord
get => _description;
set
{
Preconditions.NotNullOrEmpty(value, nameof(Description));
Preconditions.AtLeast(value.Length, 1, nameof(Description));
Preconditions.AtMost(value.Length, MaxDescriptionLength, nameof(Description));

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

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

/// <summary>
@@ -106,6 +107,8 @@ namespace Discord
Name = Name,
Description = Description,
IsDefaultPermission = IsDefaultPermission,
NameLocalizations = _nameLocalizations,
DescriptionLocalizations = _descriptionLocalizations,
IsDMEnabled = IsDMEnabled,
DefaultMemberPermissions = DefaultMemberPermissions ?? Optional<GuildPermission>.Unspecified
};
@@ -190,13 +193,17 @@ namespace Discord
/// <param name="isAutocomplete">If this option is set to autocomplete.</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="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="minValue">The smallest number value the user can input.</param>
/// <param name="maxValue">The largest number value the user can input.</param>
/// <returns>The current builder.</returns>
public SlashCommandBuilder AddOption(string name, ApplicationCommandOptionType type,
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);

@@ -226,6 +233,12 @@ namespace Discord
MaxLength = maxLength,
};

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

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

return AddOption(option);
}

@@ -268,6 +281,116 @@ namespace Discord
Options.AddRange(options);
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));

// Discord updated the docs, this regex prevents special characters like @!$%(... 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));

if (name.Any(x => char.IsUpper(x)))
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>
@@ -287,6 +410,8 @@ namespace Discord

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

/// <summary>
/// Gets or sets the name of this option.
@@ -298,10 +423,7 @@ namespace Discord
{
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;
@@ -318,8 +440,7 @@ namespace Discord
{
if (value != null)
{
Preconditions.AtLeast(value.Length, 1, nameof(value));
Preconditions.AtMost(value.Length, SlashCommandBuilder.MaxDescriptionLength, nameof(value));
EnsureValidCommandOptionDescription(value);
}

_description = value;
@@ -381,6 +502,16 @@ namespace Discord
/// </summary>
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>
/// Builds the current option.
/// </summary>
@@ -424,6 +555,8 @@ namespace Discord
ChannelTypes = ChannelTypes,
MinValue = MinValue,
MaxValue = MaxValue,
NameLocalizations = _nameLocalizations,
DescriptionLocalizations = _descriptionLocalizations,
MinLength = MinLength,
MaxLength = MaxLength,
};
@@ -440,13 +573,17 @@ namespace Discord
/// <param name="isAutocomplete">If this option supports autocomplete.</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="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="minValue">The smallest number value the user can input.</param>
/// <param name="maxValue">The largest number value the user can input.</param>
/// <returns>The current builder.</returns>
public SlashCommandOptionBuilder AddOption(string name, ApplicationCommandOptionType type,
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);

@@ -473,9 +610,15 @@ namespace Discord
Options = options,
Type = type,
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);
}
/// <summary>
@@ -522,10 +665,11 @@ namespace Discord
/// </summary>
/// <param name="name">The name 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>
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>
@@ -533,10 +677,11 @@ namespace Discord
/// </summary>
/// <param name="name">The name 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>
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>
@@ -544,10 +689,11 @@ namespace Discord
/// </summary>
/// <param name="name">The name 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>
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>
@@ -555,10 +701,11 @@ namespace Discord
/// </summary>
/// <param name="name">The name 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>
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>
@@ -566,13 +713,14 @@ namespace Discord
/// </summary>
/// <param name="name">The name 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>
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>();

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

return this;
@@ -724,5 +873,107 @@ namespace Discord
Type = type;
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);

_descriptionLocalizations ??= 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));
if (!Regex.IsMatch(name, @"^[\w-]{1,32}$"))
throw new ArgumentException("Option name cannot contain any special characters or whitespaces!", nameof(name));
}

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

+ 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);
}

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

@@ -155,12 +155,14 @@ namespace Discord
/// <summary>
/// Gets a collection of all global commands.
/// </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>
/// <returns>
/// A task that represents the asynchronous get operation. The task result contains a read-only collection of global
/// application commands.
/// </returns>
Task<IReadOnlyCollection<IApplicationCommand>> GetGlobalApplicationCommandsAsync(RequestOptions options = null);
Task<IReadOnlyCollection<IApplicationCommand>> GetGlobalApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null);

/// <summary>
/// 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="headerOnly">Indicates whether to send the header only.</param>
/// <param name="reason">The audit log reason.</param>
/// <param name="requestHeaders">Additional headers to be sent with the request.</param>
/// <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 System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

@@ -19,7 +20,7 @@ namespace Discord
/// Gets or sets the maximum time to wait for this request to complete.
/// </summary>
/// <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
/// and will not be unpaused in time, this request will fail immediately.
/// </remarks>
@@ -53,7 +54,7 @@ namespace Discord
/// </summary>
/// <remarks>
/// 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
/// hosting system is known to have a desynced clock.
/// </remarks>
@@ -70,8 +71,10 @@ namespace Discord
internal bool IsReactionBucket { get; set; }
internal bool IsGatewayBucket { get; set; }

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

internal static RequestOptions CreateOrClone(RequestOptions options)
{
{
if (options == null)
return new RequestOptions();
else
@@ -96,8 +99,9 @@ namespace Discord
public RequestOptions()
{
Timeout = DiscordConfig.DefaultRequestTimeout;
RequestHeaders = new Dictionary<string, IEnumerable<string>>();
}
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.Trim().Length == 0) throw CreateNotEmptyException(name, msg);
}
}
}

private static ArgumentException CreateNotEmptyException(string name, string msg)
=> 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)
=> 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>
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>
@@ -165,7 +165,7 @@ namespace Discord

private static ArgumentException CreateAtLeastException<T>(string name, string msg, T value)
=> new ArgumentException(message: msg ?? $"Value must be at least {value}.", paramName: name);
/// <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); }
/// <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)
=> new ArgumentException(message: msg ?? $"Value must be greater than {value}.", paramName: name);
/// <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); }
/// <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)
=> new ArgumentException(message: msg ?? $"Value must be at most {value}.", paramName: name);
/// <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); }
/// <exception cref="ArgumentException">Value must be less than <paramref name="value"/>.</exception>


+ 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); } }
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 CommandMap<SlashCommandInfo> _slashCommandMap;
private readonly ConcurrentDictionary<ApplicationCommandType, CommandMap<ContextCommandInfo>> _contextCommandMaps;
@@ -203,6 +208,7 @@ namespace Discord.Interactions
_enableAutocompleteHandlers = config.EnableAutocompleteHandlers;
_autoServiceScopes = config.AutoServiceScopes;
_restResponseCallback = config.RestResponseCallback;
LocalizationManager = config.LocalizationManager;

_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.
/// </summary>
public bool ExitOnMissingModalField { get; set; } = false;

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

/// <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)
{
var key = ParseCommandName(command);
var key = CommandHierarchy.GetCommandPath(command);

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

private void AddCommand(T command)
{
var key = ParseCommandName(command);
var key = CommandHierarchy.GetCommandPath(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.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;

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

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

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

var props = new SlashCommandBuilder()
{
Name = commandInfo.Name,
Description = commandInfo.Description,
IsDefaultPermission = commandInfo.DefaultPermission,
IsDMEnabled = commandInfo.IsEnabledInDm,
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)
throw new InvalidOperationException($"Slash Commands cannot have more than {SlashCommandBuilder.MaxOptionsCount} command parameters");
@@ -54,18 +67,30 @@ namespace Discord.Interactions
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,
Description = commandInfo.Description,
Type = ApplicationCommandOptionType.SubCommand,
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)
=> commandInfo.CommandType switch
{
var localizationManager = commandInfo.Module.CommandService.LocalizationManager;
var commandPath = commandInfo.GetCommandPath();

return commandInfo.CommandType switch
{
ApplicationCommandType.Message => new MessageCommandBuilder
{
@@ -73,16 +98,21 @@ namespace Discord.Interactions
IsDefaultPermission = commandInfo.DefaultPermission,
DefaultMemberPermissions = ((commandInfo.DefaultMemberPermissions ?? 0) | (commandInfo.Module.DefaultMemberPermissions ?? 0)).SanitizeGuildPermissions(),
IsDMEnabled = commandInfo.IsEnabledInDm
}.Build(),
}
.WithNameLocalizations(localizationManager?.GetAllNames(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary<string, string>.Empty)
.Build(),
ApplicationCommandType.User => new UserCommandBuilder
{
Name = commandInfo.Name,
IsDefaultPermission = commandInfo.DefaultPermission,
DefaultMemberPermissions = ((commandInfo.DefaultMemberPermissions ?? 0) | (commandInfo.Module.DefaultMemberPermissions ?? 0)).SanitizeGuildPermissions(),
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.")
};
}
#endregion

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

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

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

var props = new SlashCommandBuilder
{
Name = moduleInfo.SlashGroupName,
@@ -130,7 +163,10 @@ namespace Discord.Interactions
IsDefaultPermission = moduleInfo.DefaultPermission,
IsDMEnabled = moduleInfo.IsEnabledInDm,
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)
throw new InvalidOperationException($"Slash Commands cannot have more than {SlashCommandBuilder.MaxOptionsCount} command parameters");
@@ -168,7 +204,11 @@ namespace Discord.Interactions
Name = moduleInfo.SlashGroupName,
Description = moduleInfo.Description,
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,
Description = command.Description,
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
{
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
{
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}"),
};
@@ -206,18 +258,20 @@ namespace Discord.Interactions
Description = commandOption.Description,
Type = commandOption.Type,
IsRequired = commandOption.IsRequired,
ChannelTypes = commandOption.ChannelTypes?.ToList(),
IsAutocomplete = commandOption.IsAutocomplete.GetValueOrDefault(),
MinValue = commandOption.MinValue,
MaxValue = commandOption.MaxValue,
Choices = commandOption.Choices?.Select(x => new ApplicationCommandOptionChoiceProperties
{
Name = x.Name,
Value = x.Value
}).ToList(),
Options = commandOption.Options?.Select(x => x.ToApplicationCommandOptionProps()).ToList(),
NameLocalizations = commandOption.NameLocalizations?.ToImmutableDictionary(),
DescriptionLocalizations = commandOption.DescriptionLocalizations?.ToImmutableDictionary(),
MaxLength = commandOption.MaxLength,
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)


+ 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 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 string[] { EscapeChar + type.FullName };
}
}

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

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

namespace Discord.API
{
@@ -25,6 +26,18 @@ namespace Discord.API
[JsonProperty("default_permission")]
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
[JsonProperty("dm_permission")]
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 System.Collections.Generic;
using System.Linq;

namespace Discord.API
@@ -38,6 +39,18 @@ namespace Discord.API
[JsonProperty("channel_types")]
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")]
public Optional<int> MinLength { get; set; }

@@ -69,6 +82,11 @@ namespace Discord.API
Name = cmd.Name;
Type = cmd.Type;
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)
{
@@ -94,6 +112,9 @@ namespace Discord.API
Type = option.Type;
Description = option.Description;
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 System.Collections.Generic;

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

[JsonProperty("value")]
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 System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;

namespace Discord.API.Rest
{
@@ -19,6 +23,12 @@ namespace Discord.API.Rest
[JsonProperty("default_permission")]
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")]
public Optional<bool?> DmPermission { get; set; }

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

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;
Description = description;
Options = Optional.Create(options);
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;
}
}
}

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

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

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

[JsonProperty("default_permission")]
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);

/// <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<IApplicationCommand> IDiscordClient.CreateGlobalApplicationCommand(ApplicationCommandProperties properties, RequestOptions options)
=> 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())
return Array.Empty<RestGlobalCommand>();
@@ -212,10 +212,10 @@ namespace Discord.Rest
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())
return ImmutableArray.Create<RestGuildCommand>();


+ 27
- 4
src/Discord.Net.Rest/DiscordRestApiClient.cs View File

@@ -8,6 +8,7 @@ using Newtonsoft.Json;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel.Design;
using System.Diagnostics;
using System.Globalization;
using System.IO;
@@ -1212,11 +1213,22 @@ namespace Discord.API
#endregion

#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);

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)
@@ -1281,13 +1293,24 @@ namespace Discord.API
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);

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)


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

@@ -25,7 +25,7 @@ namespace Discord.Rest
/// Gets the logged-in user.
/// </summary>
public new RestSelfUser CurrentUser { get => base.CurrentUser as RestSelfUser; internal set => base.CurrentUser = value; }
/// <inheritdoc />
public DiscordRestClient() : this(new DiscordRestConfig()) { }
/// <summary>
@@ -205,10 +205,10 @@ namespace Discord.Rest
=> ClientHelper.CreateGlobalApplicationCommandAsync(this, properties, options);
public Task<RestGuildCommand> CreateGuildCommand(ApplicationCommandProperties properties, ulong guildId, RequestOptions options = null)
=> 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)
=> ClientHelper.BulkOverwriteGlobalApplicationCommandAsync(this, commandProperties, options);
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);

/// <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 />
async Task<IApplicationCommand> IDiscordClient.GetGlobalApplicationCommandAsync(ulong id, RequestOptions options)
=> await ClientHelper.GetGlobalApplicationCommandAsync(this, id, options).ConfigureAwait(false);


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

@@ -362,10 +362,10 @@ namespace Discord.Rest
#endregion

#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();
}
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>
/// Gets a collection of slash commands created by the current user in this guild.
/// </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>
/// <returns>
/// A task that represents the asynchronous get operation. The task result contains a read-only collection of
/// slash commands created by the current user.
/// </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>
/// Gets a slash command in the current guild.
@@ -928,13 +930,15 @@ namespace Discord.Rest
/// <summary>
/// Gets this guilds slash commands
/// </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>
/// <returns>
/// A task that represents the asynchronous get operation. The task result contains a read-only collection
/// of application commands found within the guild.
/// </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>
/// Gets an application command within this guild with the specified id.
/// </summary>
@@ -1467,8 +1471,8 @@ namespace Discord.Rest
async Task<IReadOnlyCollection<IWebhook>> IGuild.GetWebhooksAsync(RequestOptions options)
=> await GetWebhooksAsync(options).ConfigureAwait(false);
/// <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 />
async Task<ICustomSticker> IGuild.CreateStickerAsync(string name, string description, IEnumerable<string> tags, Image image, RequestOptions options)
=> await CreateStickerAsync(name, description, tags, image, options);


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

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

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

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

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

Console.WriteLine("Locales:" + string.Join(",", arg.NameLocalizations.Keys));

if (arg is SlashCommandProperties slashProps)
{
Preconditions.NotNullOrEmpty(slashProps.Description, nameof(slashProps.Description));
@@ -181,6 +187,8 @@ namespace Discord.Rest
DefaultPermission = arg.IsDefaultPermission.IsSpecified
? arg.IsDefaultPermission.Value
: Optional<bool>.Unspecified,
NameLocalizations = arg.NameLocalizations?.ToDictionary(),
DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary(),

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

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

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

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

namespace Discord.Rest
@@ -13,10 +15,25 @@ namespace Discord.Rest
/// <inheritdoc/>
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)
{
Name = model.Name;
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; }

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

/// <inheritdoc/>
public double? MinValue { get; private set; }
@@ -54,6 +54,32 @@ namespace Discord.Rest
/// <inheritdoc/>
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 static RestApplicationCommandOption Create(Model model)
@@ -98,6 +124,15 @@ namespace Discord.Rest
ChannelTypes = model.ChannelTypes.IsSpecified
? model.ChannelTypes.Value.ToImmutableArray()
: 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



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

@@ -66,33 +66,45 @@ namespace Discord.Net.Rest
_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);
using (var restRequest = new HttpRequestMessage(GetMethod(method), uri))
{
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);
}
}
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);
using (var restRequest = new HttpRequestMessage(GetMethod(method), uri))
{
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");
return await SendInternalAsync(restRequest, cancelToken, headerOnly).ConfigureAwait(false);
}
}

/// <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);
using (var restRequest = new HttpRequestMessage(GetMethod(method), uri))
{
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));
MemoryStream memoryStream = null;
if (multipartParams != null)
@@ -126,7 +138,7 @@ namespace Discord.Net.Rest

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


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

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

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

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);
}
}
}

+ 6
- 4
src/Discord.Net.WebSocket/DiscordSocketClient.cs View File

@@ -450,14 +450,16 @@ namespace Discord.WebSocket
/// <summary>
/// Gets a collection of all global commands.
/// </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>
/// <returns>
/// A task that represents the asynchronous get operation. The task result contains a read-only collection of global
/// application commands.
/// </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)
{
@@ -3236,8 +3238,8 @@ namespace Discord.WebSocket
async Task<IApplicationCommand> IDiscordClient.GetGlobalApplicationCommandAsync(ulong id, RequestOptions options)
=> await GetGlobalApplicationCommandAsync(id, options);
/// <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 />
async Task IDiscordClient.StartAsync()


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

@@ -874,14 +874,17 @@ namespace Discord.WebSocket
/// <summary>
/// Gets a collection of slash commands created by the current user in this guild.
/// </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>
/// <returns>
/// A task that represents the asynchronous get operation. The task result contains a read-only collection of
/// slash commands created by the current user.
/// </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)
{
@@ -1977,8 +1980,8 @@ namespace Discord.WebSocket
async Task<IReadOnlyCollection<IWebhook>> IGuild.GetWebhooksAsync(RequestOptions options)
=> await GetWebhooksAsync(options).ConfigureAwait(false);
/// <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 />
async Task<ICustomSticker> IGuild.CreateStickerAsync(string name, string description, IEnumerable<string> tags, Image image, RequestOptions 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>
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/>
public DateTimeOffset CreatedAt
=> SnowflakeUtils.FromSnowflake(Id);
@@ -93,6 +119,15 @@ namespace Discord.WebSocket
? model.Options.Value.Select(SocketApplicationCommandOption.Create).ToImmutableArray()
: 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);
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;

namespace Discord.WebSocket
@@ -13,6 +15,19 @@ namespace Discord.WebSocket
/// <inheritdoc/>
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 static SocketApplicationCommandChoice Create(Model model)
{
@@ -24,6 +39,8 @@ namespace Discord.WebSocket
{
Name = model.Name;
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>
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 static SocketApplicationCommandOption Create(Model model)
{
@@ -92,6 +118,15 @@ namespace Discord.WebSocket
ChannelTypes = model.ChannelTypes.IsSpecified
? model.ChannelTypes.Value.ToImmutableArray()
: 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;


Loading…
Cancel
Save