diff --git a/CHANGELOG.md b/CHANGELOG.md index a4022e1b6..7385ab38a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,42 @@ # Changelog + +## [3.8.0] - 2022-08-27 +### Added +- #2384 Added support for the WEBHOOKS_UPDATED event (010e8e8) +- #2370 Add async callbacks for IModuleBase (503fa75) +- #2367 Added DeleteMessagesAsync for TIV and added remaining rate limit in client log (f178660) +- #2379 Added Max/Min length fields for ApplicationCommandOption (e551431) +- #2369 Added support for using `RespondWithModalAsync()` without prior IModal declaration (500e7b4) +- #2347 Added Embed field comparison operators (89a8ea1) +- #2359 Added support for creating lottie stickers (32b03c8) +- #2395 Added App Command localization support and `ILocalizationManager` to IF (39bbd29) + +### Fixed +- #2425 Fix missing Fact attribute in ColorTests (92215b1) +- #2424 Fix IGuild.GetBansAsync() (b7b7964) +- #2416 Fix role icon & emoji assignment (b6b5e95) +- #2414 Fix NRE on RestCommandBase Data (02bc3b7) +- #2421 Fix placeholder length being hardcoded (8dfe19f) +- #2352 Fix issues related to the absence of bot scope (1eb42c6) +- #2346 Fix IGuild.DisconnectAsync(IUser) not disconnecting users (ba02416) +- #2404 Fix range of issues presented by 3rd party analyzer (902326d) +- #2409 Removes GroupContext from requirecontext (b0b8167) + +### Misc +- #2366 Fixed typo in ChannelUpdatedEvent's documentation (cfd2662) +- #2408 Fix sharding sample throwing at appcommand registration (519deda) +- #2420 Fix broken code snippet in dependency injection docs (ddcf68a) +- #2430 Add a note about DontAutoRegisterAttribute (917118d) +- #2418 Update xmldocs to reflect the ConnectedUsers split (65b98f8) +- #2415 Adds missing DI entries in TOC (c49d483) +- #2407 Introduces high quality dependency injection documentation (6fdcf98) +- #2348 Added `RequiredInput` attribute to example in int.framework intro (ee6e0ad) +- #2385 Add ServerStarter.Host to deployment.md (06ed995) +- #2405 Add a note about `IgnoreGroupNames` to IF docs (cf25acd) +- #2356 Makes voice section about precompiled binaries more visible (e0d68d4 ) +- #2405 IF intro docs improvements (246282d) +- #2406 Labs deprecation & readme/docs edits (bf493ea) + ## [3.7.2] - 2022-06-02 ### Added - #2328 Add method overloads to InteractionService (0fad3e8) diff --git a/Discord.Net.targets b/Discord.Net.targets index 8cedb40e7..d9ec415f9 100644 --- a/Discord.Net.targets +++ b/Discord.Net.targets @@ -1,6 +1,6 @@ - 3.7.2 + 3.8.0 latest Discord.Net Contributors discord;discordapp diff --git a/docs/docfx.json b/docs/docfx.json index 5dd1e640d..64a20ecb2 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -60,7 +60,7 @@ "overwrite": "_overwrites/**/**.md", "globalMetadata": { "_appTitle": "Discord.Net Documentation", - "_appFooter": "Discord.Net (c) 2015-2022 3.7.2", + "_appFooter": "Discord.Net (c) 2015-2022 3.8.0", "_enableSearch": true, "_appLogoPath": "marketing/logo/SVG/Logomark Purple.svg", "_appFaviconPath": "favicon.ico" diff --git a/docs/guides/dependency_injection/injection.md b/docs/guides/dependency_injection/injection.md index c7d40c479..85a77476f 100644 --- a/docs/guides/dependency_injection/injection.md +++ b/docs/guides/dependency_injection/injection.md @@ -16,7 +16,7 @@ This can be done through property or constructor. Services can be injected from the constructor of the class. This is the preferred approach, because it automatically locks the readonly field in place with the provided service and isn't accessible outside of the class. -[!code-csharp[Property Injection(samples/property-injecting.cs)]] +[!code-csharp[Constructor Injection](samples/ctor-injecting.cs)] ## Injecting through properties diff --git a/docs/guides/int_framework/intro.md b/docs/guides/int_framework/intro.md index 5cf38bff1..21ea365de 100644 --- a/docs/guides/int_framework/intro.md +++ b/docs/guides/int_framework/intro.md @@ -294,7 +294,7 @@ By nesting commands inside a module that is tagged with [GroupAttribute] you can > [!NOTE] > To not use the command group's name as a prefix for component or modal interaction's custom id set `ignoreGroupNames` parameter to `true` in classes with [GroupAttribute] > -> However, you have to be careful to prevent overlapping ids of buttons and modals +> However, you have to be careful to prevent overlapping ids of buttons and modals. [!code-csharp[Command Group Example](samples/intro/groupmodule.cs)] @@ -346,10 +346,13 @@ Command registration methods can only be used after the gateway client is ready Methods like `AddModulesToGuildAsync()`, `AddCommandsToGuildAsync()`, `AddModulesGloballyAsync()` and `AddCommandsGloballyAsync()` can be used to register cherry picked modules or commands to global/guild scopes. +> [!NOTE] +> [DontAutoRegisterAttribute] can be used on module classes to prevent `RegisterCommandsGloballyAsync()` and `RegisterCommandsToGuildAsync()` from registering them to the Discord. + > [!NOTE] > In debug environment, since Global commands can take up to 1 hour to register/update, > it is adviced to register your commands to a test guild for your changes to take effect immediately. -> You can use preprocessor directives to create a simple logic for registering commands as seen above +> You can use preprocessor directives to create a simple logic for registering commands as seen above. ## Interaction Utility @@ -373,10 +376,52 @@ respond to the Interactions within your command modules you need to perform the delegate can be used to create HTTP responses from a deserialized json object string. - 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 [GroupAttribute]: xref:Discord.Interactions.GroupAttribute +[DontAutoRegisterAttribute]: xref:Discord.Interactions.DontAutoRegisterAttribute [InteractionService]: xref:Discord.Interactions.InteractionService [InteractionServiceConfig]: xref:Discord.Interactions.InteractionServiceConfig [InteractionModuleBase]: xref:Discord.Interactions.InteractionModuleBase diff --git a/src/Discord.Net.Core/Discord.Net.Core.csproj b/src/Discord.Net.Core/Discord.Net.Core.csproj index 41d83bbc8..005280c4d 100644 --- a/src/Discord.Net.Core/Discord.Net.Core.csproj +++ b/src/Discord.Net.Core/Discord.Net.Core.csproj @@ -16,7 +16,6 @@ all - @@ -27,4 +26,7 @@ + + + \ No newline at end of file diff --git a/src/Discord.Net.Core/DiscordConfig.cs b/src/Discord.Net.Core/DiscordConfig.cs index 2db802f1e..ebca0120c 100644 --- a/src/Discord.Net.Core/DiscordConfig.cs +++ b/src/Discord.Net.Core/DiscordConfig.cs @@ -18,7 +18,7 @@ namespace Discord /// Discord API documentation /// . /// - public const int APIVersion = 9; + public const int APIVersion = 10; /// /// Returns the Voice API version Discord.Net uses. /// diff --git a/src/Discord.Net.Core/DiscordErrorCode.cs b/src/Discord.Net.Core/DiscordErrorCode.cs index b444614e4..262252eab 100644 --- a/src/Discord.Net.Core/DiscordErrorCode.cs +++ b/src/Discord.Net.Core/DiscordErrorCode.cs @@ -66,6 +66,7 @@ namespace Discord ActionSlowmode = 20016, OnlyOwnerAction = 20018, AnnouncementEditRatelimit = 20022, + UnderMinimumAge = 20024, ChannelWriteRatelimit = 20028, WriteRatelimitReached = 20029, WordsNotAllowed = 20031, @@ -88,7 +89,9 @@ namespace Discord MaximumServerMembersReached = 30019, MaximumServerCategoriesReached = 30030, GuildTemplateAlreadyExists = 30031, + MaximumNumberOfApplicationCommandsReached = 30032, MaximumThreadMembersReached = 30033, + MaxNumberOfDailyApplicationCommandCreatesHasBeenReached = 30034, MaximumBansForNonGuildMembersReached = 30035, MaximumBanFetchesReached = 30037, MaximumUncompleteGuildScheduledEvents = 30038, @@ -98,6 +101,7 @@ namespace Discord #endregion #region General Request Errors (40XXX) + BitrateIsTooHighForChannelOfThisType = 30052, MaximumNumberOfEditsReached = 30046, MaximumNumberOfPinnedThreadsInAForumChannelReached = 30047, MaximumNumberOfTagsInAForumChannelReached = 30048, @@ -108,12 +112,17 @@ namespace Discord RequestEntityTooLarge = 40005, FeatureDisabled = 40006, UserBanned = 40007, + ConnectionHasBeenRevoked = 40012, TargetUserNotInVoice = 40032, MessageAlreadyCrossposted = 40033, ApplicationNameAlreadyExists = 40041, #endregion #region Action Preconditions/Checks (50XXX) + ApplicationInteractionFailedToSend = 40043, + CannotSendAMessageInAForumChannel = 40058, + ThereAreNoTagsAvailableThatCanBeSetByNonModerators = 40066, + ATagIsRequiredToCreateAForumPostInThisChannel = 40067, InteractionHasAlreadyBeenAcknowledged = 40060, TagNamesMustBeUnique = 40061, MissingPermissions = 50001, @@ -132,6 +141,7 @@ namespace Discord InvalidAuthenticationToken = 50014, NoteTooLong = 50015, ProvidedMessageDeleteCountOutOfBounds = 50016, + InvalidMFALevel = 50017, InvalidPinChannel = 50019, InvalidInvite = 50020, CannotExecuteOnSystemMessage = 50021, @@ -165,6 +175,9 @@ namespace Discord #endregion #region 2FA (60XXX) + OwnershipCannotBeTransferredToABotUser = 50132, + AssetResizeBelowTheMaximumSize= 50138, + UploadedFileNotFound = 50146, MissingPermissionToSendThisSticker = 50600, Requires2FA = 60003, #endregion @@ -178,6 +191,7 @@ namespace Discord #endregion #region API Status (130XXX) + ApplicationNotYetAvailable = 110001, APIOverloaded = 130000, #endregion @@ -207,5 +221,15 @@ namespace Discord CannotUpdateFinishedEvent = 180000, FailedStageCreation = 180002, #endregion + + #region Forum & Automod + MessageWasBlockedByAutomaticModeration = 200000, + TitleWasBlockedByAutomaticModeration = 200001, + WebhooksPostedToForumChannelsMustHaveAThreadNameOrThreadId = 220001, + WebhooksPostedToForumChannelsCannotHaveBothAThreadNameAndThreadId = 220002, + WebhooksCanOnlyCreateThreadsInForumChannels = 220003, + WebhookServicesCannotBeUsedInForumChannels = 220004, + MessageBlockedByHarmfulLinksFilter = 240000, + #endregion } } diff --git a/src/Discord.Net.Core/Entities/Guilds/IGuild.cs b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs index 775ff9e65..34a08f1e7 100644 --- a/src/Discord.Net.Core/Entities/Guilds/IGuild.cs +++ b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs @@ -1194,12 +1194,17 @@ namespace Discord /// /// Gets this guilds application commands. /// + /// + /// Whether to include full localization dictionaries in the returned objects, + /// instead of the localized name and description fields. + /// + /// The target locale of the localized name and description fields. Sets the X-Discord-Locale header, which takes precedence over Accept-Language. /// The options to be used when sending the request. /// /// A task that represents the asynchronous get operation. The task result contains a read-only collection /// of application commands found within the guild. /// - Task> GetApplicationCommandsAsync(RequestOptions options = null); + Task> GetApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null); /// /// Gets an application command within this guild with the specified id. diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs index 5e4f6a81d..bceefda32 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs @@ -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 _nameLocalizations = new Dictionary(); + private IDictionary _descriptionLocalizations = new Dictionary(); /// /// 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; + } } /// @@ -105,5 +96,72 @@ namespace Discord /// Gets or sets the allowed channel types for this option. /// public List ChannelTypes { get; set; } + + /// + /// Gets or sets the localization dictionary for the name field of this option. + /// + /// Thrown when any of the dictionary keys is an invalid locale. + public IDictionary 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; + } + } + + /// + /// Gets or sets the localization dictionary for the description field of this option. + /// + /// Thrown when any of the dictionary keys is an invalid locale. + public IDictionary 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."); + } + } } } diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOptionChoice.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOptionChoice.cs index 6a908b075..8f1ecc6d2 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOptionChoice.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOptionChoice.cs @@ -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 _nameLocalizations = new Dictionary(); /// /// Gets or sets the name of this choice. @@ -40,5 +45,33 @@ namespace Discord _value = value; } } + + /// + /// Gets or sets the localization dictionary for the name field of this choice. + /// + /// Thrown when any of the dictionary keys is an invalid locale. + public IDictionary 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; + } + } } } diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandProperties.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandProperties.cs index 9b3ac8453..7ca16a27d 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandProperties.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandProperties.cs @@ -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 { /// @@ -5,6 +12,9 @@ namespace Discord /// public abstract class ApplicationCommandProperties { + private IReadOnlyDictionary _nameLocalizations; + private IReadOnlyDictionary _descriptionLocalizations; + internal abstract ApplicationCommandType Type { get; } /// @@ -17,6 +27,48 @@ namespace Discord /// public Optional IsDefaultPermission { get; set; } + /// + /// Gets or sets the localization dictionary for the name field of this command. + /// + public IReadOnlyDictionary 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; + } + } + + /// + /// Gets or sets the localization dictionary for the description field of this command. + /// + public IReadOnlyDictionary 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; + } + } + /// /// Gets or sets whether or not this command can be used in DMs. /// diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs index 59040dd4e..ed49c685d 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs @@ -1,3 +1,8 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + namespace Discord { /// @@ -31,6 +36,11 @@ namespace Discord /// public bool IsDefaultPermission { get; set; } = true; + /// + /// Gets the localization dictionary for the name field of this command. + /// + public IReadOnlyDictionary NameLocalizations => _nameLocalizations; + /// /// Gets or sets whether or not this command can be used in DMs. /// @@ -42,6 +52,7 @@ namespace Discord public GuildPermission? DefaultMemberPermissions { get; set; } private string _name; + private Dictionary _nameLocalizations; /// /// Build the current builder into a class. @@ -86,6 +97,30 @@ namespace Discord return this; } + /// + /// Sets the collection. + /// + /// The localization dictionary to use for the name field of this command. + /// + /// Thrown if is null. + /// Thrown if any dictionary key is an invalid locale string. + public MessageCommandBuilder WithNameLocalizations(IDictionary 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(nameLocalizations); + return this; + } + /// /// Sets whether or not this command can be used in dms /// @@ -97,6 +132,41 @@ namespace Discord return this; } + /// + /// Adds a new entry to the collection. + /// + /// Locale of the entry. + /// Localized string for the name field. + /// The current builder. + /// Thrown if is an invalid locale string. + 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."); + } + /// /// Sets the default member permissions required to use this application command. /// diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs index 7c82dce55..d8bb2e056 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs @@ -1,3 +1,8 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + namespace Discord { /// @@ -5,7 +10,7 @@ namespace Discord /// public class UserCommandBuilder { - /// + /// /// Returns the maximum length a commands name allowed by Discord. /// public const int MaxNameLength = 32; @@ -31,6 +36,11 @@ namespace Discord /// public bool IsDefaultPermission { get; set; } = true; + /// + /// Gets the localization dictionary for the name field of this command. + /// + public IReadOnlyDictionary NameLocalizations => _nameLocalizations; + /// /// Gets or sets whether or not this command can be used in DMs. /// @@ -42,6 +52,7 @@ namespace Discord public GuildPermission? DefaultMemberPermissions { get; set; } private string _name; + private Dictionary _nameLocalizations; /// /// Build the current builder into a class. @@ -84,6 +95,30 @@ namespace Discord return this; } + /// + /// Sets the collection. + /// + /// The localization dictionary to use for the name field of this command. + /// The current builder. + /// Thrown if is null. + /// Thrown if any dictionary key is an invalid locale string. + public UserCommandBuilder WithNameLocalizations(IDictionary 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(nameLocalizations); + return this; + } + /// /// Sets whether or not this command can be used in dms /// @@ -95,6 +130,41 @@ namespace Discord return this; } + /// + /// Adds a new entry to the collection. + /// + /// Locale of the entry. + /// Localized string for the name field. + /// The current builder. + /// Thrown if is an invalid locale string. + 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."); + } + /// /// Sets the default member permissions required to use this application command. /// diff --git a/src/Discord.Net.Core/Entities/Interactions/IApplicationCommand.cs b/src/Discord.Net.Core/Entities/Interactions/IApplicationCommand.cs index 58a002649..6f9ce7a45 100644 --- a/src/Discord.Net.Core/Entities/Interactions/IApplicationCommand.cs +++ b/src/Discord.Net.Core/Entities/Interactions/IApplicationCommand.cs @@ -52,6 +52,32 @@ namespace Discord /// IReadOnlyCollection Options { get; } + /// + /// Gets the localization dictionary for the name field of this command. + /// + IReadOnlyDictionary NameLocalizations { get; } + + /// + /// Gets the localization dictionary for the description field of this command. + /// + IReadOnlyDictionary DescriptionLocalizations { get; } + + /// + /// Gets the localized name of this command. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + string NameLocalized { get; } + + /// + /// Gets the localized description of this command. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + string DescriptionLocalized { get; } + /// /// Modifies the current application command. /// diff --git a/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandOption.cs b/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandOption.cs index c0a752fdc..fb179b661 100644 --- a/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandOption.cs +++ b/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandOption.cs @@ -71,5 +71,31 @@ namespace Discord /// Gets the allowed channel types for this option. /// IReadOnlyCollection ChannelTypes { get; } + + /// + /// Gets the localization dictionary for the name field of this command option. + /// + IReadOnlyDictionary NameLocalizations { get; } + + /// + /// Gets the localization dictionary for the description field of this command option. + /// + IReadOnlyDictionary DescriptionLocalizations { get; } + + /// + /// Gets the localized name of this command option. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + string NameLocalized { get; } + + /// + /// Gets the localized description of this command option. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to true when requesting the command. + /// + string DescriptionLocalized { get; } } } diff --git a/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandOptionChoice.cs b/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandOptionChoice.cs index 631706c6f..3f76bae72 100644 --- a/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandOptionChoice.cs +++ b/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandOptionChoice.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace Discord { /// @@ -14,5 +16,18 @@ namespace Discord /// Gets the value of the choice. /// object Value { get; } + + /// + /// Gets the localization dictionary for the name field of this command option. + /// + IReadOnlyDictionary NameLocalizations { get; } + + /// + /// Gets the localized name of this command option. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + string NameLocalized { get; } } } diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs index 37342b039..fd8798ed3 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs @@ -1198,6 +1198,10 @@ namespace Discord public class TextInputBuilder { + /// + /// The max length of a . + /// + public const int MaxPlaceholderLength = 100; public const int LargestMaxLength = 4000; /// @@ -1229,13 +1233,13 @@ namespace Discord /// /// Gets or sets the placeholder of the current text input. /// - /// is longer than 100 characters + /// is longer than characters public string Placeholder { get => _placeholder; - set => _placeholder = (value?.Length ?? 0) <= 100 + set => _placeholder = (value?.Length ?? 0) <= MaxPlaceholderLength ? value - : throw new ArgumentException("Placeholder cannot have more than 100 characters."); + : throw new ArgumentException($"Placeholder cannot have more than {MaxPlaceholderLength} characters."); } /// diff --git a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs index bf22d4e3a..b443c4468 100644 --- a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs @@ -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 } } + /// + /// Gets the localization dictionary for the name field of this command. + /// + public IReadOnlyDictionary NameLocalizations => _nameLocalizations; + + /// + /// Gets the localization dictionary for the description field of this command. + /// + public IReadOnlyDictionary DescriptionLocalizations => _descriptionLocalizations; + /// /// Gets or sets whether the command is enabled by default when the app is added to a guild /// @@ -93,6 +92,8 @@ namespace Discord private string _name; private string _description; + private Dictionary _nameLocalizations; + private Dictionary _descriptionLocalizations; private List _options; /// @@ -106,6 +107,8 @@ namespace Discord Name = Name, Description = Description, IsDefaultPermission = IsDefaultPermission, + NameLocalizations = _nameLocalizations, + DescriptionLocalizations = _descriptionLocalizations, IsDMEnabled = IsDMEnabled, DefaultMemberPermissions = DefaultMemberPermissions ?? Optional.Unspecified }; @@ -190,13 +193,17 @@ namespace Discord /// If this option is set to autocomplete. /// The options of the option to add. /// The allowed channel types for this option. + /// Localization dictionary for the name field of this command. + /// Localization dictionary for the description field of this command. /// The choices of this option. /// The smallest number value the user can input. /// The largest number value the user can input. /// The current builder. 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 options = null, List channelTypes = null, params ApplicationCommandOptionChoiceProperties[] choices) + List options = null, List channelTypes = null, IDictionary nameLocalizations = null, + IDictionary 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; } + + /// + /// Sets the collection. + /// + /// The localization dictionary to use for the name field of this command. + /// + /// Thrown if is null. + /// Thrown if any dictionary key is an invalid locale string. + public SlashCommandBuilder WithNameLocalizations(IDictionary 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(nameLocalizations); + return this; + } + + /// + /// Sets the collection. + /// + /// The localization dictionary to use for the description field of this command. + /// + /// Thrown if is null. + /// Thrown if any dictionary key is an invalid locale string. + public SlashCommandBuilder WithDescriptionLocalizations(IDictionary 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(descriptionLocalizations); + return this; + } + + /// + /// Adds a new entry to the collection. + /// + /// Locale of the entry. + /// Localized string for the name field. + /// The current builder. + /// Thrown if is an invalid locale string. + 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; + } + + /// + /// Adds a new entry to the collection. + /// + /// Locale of the entry. + /// Localized string for the description field. + /// The current builder. + /// Thrown if is an invalid locale string. + 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)); + } } /// @@ -287,6 +410,8 @@ namespace Discord private string _name; private string _description; + private Dictionary _nameLocalizations; + private Dictionary _descriptionLocalizations; /// /// 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 /// public List ChannelTypes { get; set; } + /// + /// Gets the localization dictionary for the name field of this command. + /// + public IReadOnlyDictionary NameLocalizations => _nameLocalizations; + + /// + /// Gets the localization dictionary for the description field of this command. + /// + public IReadOnlyDictionary DescriptionLocalizations => _descriptionLocalizations; + /// /// Builds the current option. /// @@ -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 /// If this option supports autocomplete. /// The options of the option to add. /// The allowed channel types for this option. + /// Localization dictionary for the description field of this command. + /// Localization dictionary for the description field of this command. /// The choices of this option. /// The smallest number value the user can input. /// The largest number value the user can input. /// The current builder. 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 options = null, List channelTypes = null, params ApplicationCommandOptionChoiceProperties[] choices) + List options = null, List channelTypes = null, IDictionary nameLocalizations = null, + IDictionary 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()).ToList(), - ChannelTypes = channelTypes + ChannelTypes = channelTypes, }; + if(nameLocalizations is not null) + option.WithNameLocalizations(nameLocalizations); + + if(descriptionLocalizations is not null) + option.WithDescriptionLocalizations(descriptionLocalizations); + return AddOption(option); } /// @@ -522,10 +665,11 @@ namespace Discord /// /// The name of the choice. /// The value of the choice. + /// The localization dictionary for to use the name field of this command option choice. /// The current builder. - public SlashCommandOptionBuilder AddChoice(string name, int value) + public SlashCommandOptionBuilder AddChoice(string name, int value, IDictionary nameLocalizations = null) { - return AddChoiceInternal(name, value); + return AddChoiceInternal(name, value, nameLocalizations); } /// @@ -533,10 +677,11 @@ namespace Discord /// /// The name of the choice. /// The value of the choice. + /// The localization dictionary for to use the name field of this command option choice. /// The current builder. - public SlashCommandOptionBuilder AddChoice(string name, string value) + public SlashCommandOptionBuilder AddChoice(string name, string value, IDictionary nameLocalizations = null) { - return AddChoiceInternal(name, value); + return AddChoiceInternal(name, value, nameLocalizations); } /// @@ -544,10 +689,11 @@ namespace Discord /// /// The name of the choice. /// The value of the choice. + /// Localization dictionary for the description field of this command. /// The current builder. - public SlashCommandOptionBuilder AddChoice(string name, double value) + public SlashCommandOptionBuilder AddChoice(string name, double value, IDictionary nameLocalizations = null) { - return AddChoiceInternal(name, value); + return AddChoiceInternal(name, value, nameLocalizations); } /// @@ -555,10 +701,11 @@ namespace Discord /// /// The name of the choice. /// The value of the choice. + /// The localization dictionary to use for the name field of this command option choice. /// The current builder. - public SlashCommandOptionBuilder AddChoice(string name, float value) + public SlashCommandOptionBuilder AddChoice(string name, float value, IDictionary nameLocalizations = null) { - return AddChoiceInternal(name, value); + return AddChoiceInternal(name, value, nameLocalizations); } /// @@ -566,13 +713,14 @@ namespace Discord /// /// The name of the choice. /// The value of the choice. + /// The localization dictionary to use for the name field of this command option choice. /// The current builder. - public SlashCommandOptionBuilder AddChoice(string name, long value) + public SlashCommandOptionBuilder AddChoice(string name, long value, IDictionary 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 nameLocalizations = null) { Choices ??= new List(); @@ -594,7 +742,8 @@ namespace Discord Choices.Add(new ApplicationCommandOptionChoiceProperties { Name = name, - Value = value + Value = value, + NameLocalizations = nameLocalizations }); return this; @@ -706,11 +855,11 @@ namespace Discord /// /// Sets the current builders max length field. /// - /// The value to set. + /// The value to set. /// The current builder. - public SlashCommandOptionBuilder WithMaxLength(int lenght) + public SlashCommandOptionBuilder WithMaxLength(int length) { - MaxLength = lenght; + MaxLength = length; return this; } @@ -724,5 +873,107 @@ namespace Discord Type = type; return this; } + + /// + /// Sets the collection. + /// + /// The localization dictionary to use for the name field of this command option. + /// The current builder. + /// Thrown if is null. + /// Thrown if any dictionary key is an invalid locale string. + public SlashCommandOptionBuilder WithNameLocalizations(IDictionary 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(nameLocalizations); + return this; + } + + /// + /// Sets the collection. + /// + /// The localization dictionary to use for the description field of this command option. + /// The current builder. + /// Thrown if is null. + /// Thrown if any dictionary key is an invalid locale string. + public SlashCommandOptionBuilder WithDescriptionLocalizations(IDictionary 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(descriptionLocalizations); + return this; + } + + /// + /// Adds a new entry to the collection. + /// + /// Locale of the entry. + /// Localized string for the name field. + /// The current builder. + /// Thrown if is an invalid locale string. + 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; + } + + /// + /// Adds a new entry to the collection. + /// + /// Locale of the entry. + /// Localized string for the description field. + /// The current builder. + /// Thrown if is an invalid locale string. + 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)); + } } } diff --git a/src/Discord.Net.Core/Entities/Messages/Embed.cs b/src/Discord.Net.Core/Entities/Messages/Embed.cs index 7fa6f6f36..c1478f56c 100644 --- a/src/Discord.Net.Core/Entities/Messages/Embed.cs +++ b/src/Discord.Net.Core/Entities/Messages/Embed.cs @@ -94,5 +94,44 @@ namespace Discord /// public override string ToString() => Title; private string DebuggerDisplay => $"{Title} ({Type})"; + + public static bool operator ==(Embed left, Embed right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(Embed left, Embed right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is Embed embed && Equals(embed); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(Embed embed) + => GetHashCode() == embed?.GetHashCode(); + + /// + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 23 + (Type, Title, Description, Timestamp, Color, Image, Video, Author, Footer, Provider, Thumbnail).GetHashCode(); + foreach(var field in Fields) + hash = hash * 23 + field.GetHashCode(); + return hash; + } + } } } diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedAuthor.cs b/src/Discord.Net.Core/Entities/Messages/EmbedAuthor.cs index 3b11f6a8b..fdd51e6c9 100644 --- a/src/Discord.Net.Core/Entities/Messages/EmbedAuthor.cs +++ b/src/Discord.Net.Core/Entities/Messages/EmbedAuthor.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics; namespace Discord @@ -41,5 +42,35 @@ namespace Discord /// /// public override string ToString() => Name; + + public static bool operator ==(EmbedAuthor? left, EmbedAuthor? right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedAuthor? left, EmbedAuthor? right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedAuthor embedAuthor && Equals(embedAuthor); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedAuthor? embedAuthor) + => GetHashCode() == embedAuthor?.GetHashCode(); + + /// + public override int GetHashCode() + => (Name, Url, IconUrl).GetHashCode(); } } diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs b/src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs index 1e2a7b0d7..db38b9fb7 100644 --- a/src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs +++ b/src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs @@ -481,6 +481,55 @@ namespace Discord return new Embed(EmbedType.Rich, Title, Description, Url, Timestamp, Color, _image, null, Author?.Build(), Footer?.Build(), null, _thumbnail, fields.ToImmutable()); } + + public static bool operator ==(EmbedBuilder left, EmbedBuilder right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedBuilder left, EmbedBuilder right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedBuilder embedBuilder && Equals(embedBuilder); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedBuilder embedBuilder) + { + if (embedBuilder is null) + return false; + + if (Fields.Count != embedBuilder.Fields.Count) + return false; + + for (var i = 0; i < _fields.Count; i++) + if (_fields[i] != embedBuilder._fields[i]) + return false; + + return _title == embedBuilder?._title + && _description == embedBuilder?._description + && _image == embedBuilder?._image + && _thumbnail == embedBuilder?._thumbnail + && Timestamp == embedBuilder?.Timestamp + && Color == embedBuilder?.Color + && Author == embedBuilder?.Author + && Footer == embedBuilder?.Footer + && Url == embedBuilder?.Url; + } + + /// + public override int GetHashCode() => base.GetHashCode(); } /// @@ -597,6 +646,37 @@ namespace Discord /// public EmbedField Build() => new EmbedField(Name, Value.ToString(), IsInline); + + public static bool operator ==(EmbedFieldBuilder left, EmbedFieldBuilder right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedFieldBuilder left, EmbedFieldBuilder right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedFieldBuilder embedFieldBuilder && Equals(embedFieldBuilder); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedFieldBuilder embedFieldBuilder) + => _name == embedFieldBuilder?._name + && _value == embedFieldBuilder?._value + && IsInline == embedFieldBuilder?.IsInline; + + /// + public override int GetHashCode() => base.GetHashCode(); } /// @@ -697,6 +777,37 @@ namespace Discord /// public EmbedAuthor Build() => new EmbedAuthor(Name, Url, IconUrl, null); + + public static bool operator ==(EmbedAuthorBuilder left, EmbedAuthorBuilder right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedAuthorBuilder left, EmbedAuthorBuilder right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedAuthorBuilder embedAuthorBuilder && Equals(embedAuthorBuilder); + + /// + /// Determines whether the specified is equals to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedAuthorBuilder embedAuthorBuilder) + => _name == embedAuthorBuilder?._name + && Url == embedAuthorBuilder?.Url + && IconUrl == embedAuthorBuilder?.IconUrl; + + /// + public override int GetHashCode() => base.GetHashCode(); } /// @@ -777,5 +888,35 @@ namespace Discord /// public EmbedFooter Build() => new EmbedFooter(Text, IconUrl, null); + + public static bool operator ==(EmbedFooterBuilder left, EmbedFooterBuilder right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedFooterBuilder left, EmbedFooterBuilder right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedFooterBuilder embedFooterBuilder && Equals(embedFooterBuilder); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedFooterBuilder embedFooterBuilder) + => _text == embedFooterBuilder?._text + && IconUrl == embedFooterBuilder?.IconUrl; + + /// + public override int GetHashCode() => base.GetHashCode(); } } diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedField.cs b/src/Discord.Net.Core/Entities/Messages/EmbedField.cs index f6aa2af3b..1196869fe 100644 --- a/src/Discord.Net.Core/Entities/Messages/EmbedField.cs +++ b/src/Discord.Net.Core/Entities/Messages/EmbedField.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics; namespace Discord @@ -36,5 +37,35 @@ namespace Discord /// A string that resolves to . /// public override string ToString() => Name; + + public static bool operator ==(EmbedField? left, EmbedField? right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedField? left, EmbedField? right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current object + /// + public override bool Equals(object obj) + => obj is EmbedField embedField && Equals(embedField); + + /// + /// Determines whether the specified is equal to the current + /// + /// + /// + public bool Equals(EmbedField? embedField) + => GetHashCode() == embedField?.GetHashCode(); + + /// + public override int GetHashCode() + => (Name, Value, Inline).GetHashCode(); } } diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedFooter.cs b/src/Discord.Net.Core/Entities/Messages/EmbedFooter.cs index 4c507d017..5a1f13158 100644 --- a/src/Discord.Net.Core/Entities/Messages/EmbedFooter.cs +++ b/src/Discord.Net.Core/Entities/Messages/EmbedFooter.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics; namespace Discord @@ -43,5 +44,35 @@ namespace Discord /// A string that resolves to . /// public override string ToString() => Text; + + public static bool operator ==(EmbedFooter? left, EmbedFooter? right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedFooter? left, EmbedFooter? right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedFooter embedFooter && Equals(embedFooter); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedFooter? embedFooter) + => GetHashCode() == embedFooter?.GetHashCode(); + + /// + public override int GetHashCode() + => (Text, IconUrl, ProxyUrl).GetHashCode(); } } diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedImage.cs b/src/Discord.Net.Core/Entities/Messages/EmbedImage.cs index 9ce2bfe73..85a638dc8 100644 --- a/src/Discord.Net.Core/Entities/Messages/EmbedImage.cs +++ b/src/Discord.Net.Core/Entities/Messages/EmbedImage.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics; namespace Discord @@ -53,5 +54,35 @@ namespace Discord /// A string that resolves to . /// public override string ToString() => Url; + + public static bool operator ==(EmbedImage? left, EmbedImage? right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedImage? left, EmbedImage? right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedImage embedImage && Equals(embedImage); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedImage? embedImage) + => GetHashCode() == embedImage?.GetHashCode(); + + /// + public override int GetHashCode() + => (Height, Width, Url, ProxyUrl).GetHashCode(); } } diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedProvider.cs b/src/Discord.Net.Core/Entities/Messages/EmbedProvider.cs index 960fb3d78..f2ee74613 100644 --- a/src/Discord.Net.Core/Entities/Messages/EmbedProvider.cs +++ b/src/Discord.Net.Core/Entities/Messages/EmbedProvider.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics; namespace Discord @@ -35,5 +36,35 @@ namespace Discord /// A string that resolves to . /// public override string ToString() => Name; + + public static bool operator ==(EmbedProvider? left, EmbedProvider? right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedProvider? left, EmbedProvider? right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedProvider embedProvider && Equals(embedProvider); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedProvider? embedProvider) + => GetHashCode() == embedProvider?.GetHashCode(); + + /// + public override int GetHashCode() + => (Name, Url).GetHashCode(); } } diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedThumbnail.cs b/src/Discord.Net.Core/Entities/Messages/EmbedThumbnail.cs index 7f7b582dc..65c8139c3 100644 --- a/src/Discord.Net.Core/Entities/Messages/EmbedThumbnail.cs +++ b/src/Discord.Net.Core/Entities/Messages/EmbedThumbnail.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics; namespace Discord @@ -53,5 +54,35 @@ namespace Discord /// A string that resolves to . /// public override string ToString() => Url; + + public static bool operator ==(EmbedThumbnail? left, EmbedThumbnail? right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedThumbnail? left, EmbedThumbnail? right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedThumbnail embedThumbnail && Equals(embedThumbnail); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedThumbnail? embedThumbnail) + => GetHashCode() == embedThumbnail?.GetHashCode(); + + /// + public override int GetHashCode() + => (Width, Height, Url, ProxyUrl).GetHashCode(); } } diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedVideo.cs b/src/Discord.Net.Core/Entities/Messages/EmbedVideo.cs index ca0300e80..0762ed8e7 100644 --- a/src/Discord.Net.Core/Entities/Messages/EmbedVideo.cs +++ b/src/Discord.Net.Core/Entities/Messages/EmbedVideo.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics; namespace Discord @@ -47,5 +48,35 @@ namespace Discord /// A string that resolves to . /// public override string ToString() => Url; + + public static bool operator ==(EmbedVideo? left, EmbedVideo? right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedVideo? left, EmbedVideo? right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedVideo embedVideo && Equals(embedVideo); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedVideo? embedVideo) + => GetHashCode() == embedVideo?.GetHashCode(); + + /// + public override int GetHashCode() + => (Width, Height, Url).GetHashCode(); } } diff --git a/src/Discord.Net.Core/Entities/Messages/IMessage.cs b/src/Discord.Net.Core/Entities/Messages/IMessage.cs index f5f2ca007..48db4fdf0 100644 --- a/src/Discord.Net.Core/Entities/Messages/IMessage.cs +++ b/src/Discord.Net.Core/Entities/Messages/IMessage.cs @@ -48,6 +48,9 @@ namespace Discord /// /// Gets the content for this message. /// + /// + /// This will be empty if the privileged is disabled. + /// /// /// A string that contains the body of the message; note that this field may be empty if there is an embed. /// @@ -55,6 +58,9 @@ namespace Discord /// /// Gets the clean content for this message. /// + /// + /// This will be empty if the privileged is disabled. + /// /// /// A string that contains the body of the message stripped of mentions, markdown, emojis and pings; note that this field may be empty if there is an embed. /// diff --git a/src/Discord.Net.Core/Extensions/GenericCollectionExtensions.cs b/src/Discord.Net.Core/Extensions/GenericCollectionExtensions.cs new file mode 100644 index 000000000..75d81d292 --- /dev/null +++ b/src/Discord.Net.Core/Extensions/GenericCollectionExtensions.cs @@ -0,0 +1,15 @@ +using System.Linq; + +namespace System.Collections.Generic; + +internal static class GenericCollectionExtensions +{ + public static void Deconstruct(this KeyValuePair kvp, out T1 value1, out T2 value2) + { + value1 = kvp.Key; + value2 = kvp.Value; + } + + public static Dictionary ToDictionary(this IEnumerable> kvp) => + kvp.ToDictionary(x => x.Key, x => x.Value); +} diff --git a/src/Discord.Net.Core/GatewayIntents.cs b/src/Discord.Net.Core/GatewayIntents.cs index f2a99e44c..e9dd8f814 100644 --- a/src/Discord.Net.Core/GatewayIntents.cs +++ b/src/Discord.Net.Core/GatewayIntents.cs @@ -39,7 +39,14 @@ namespace Discord DirectMessageReactions = 1 << 13, /// This intent includes TYPING_START DirectMessageTyping = 1 << 14, - /// This intent includes GUILD_SCHEDULED_EVENT_CREATE, GUILD_SCHEDULED_EVENT_UPDATE, GUILD_SCHEDULED_EVENT_DELETE, GUILD_SCHEDULED_EVENT_USER_ADD, GUILD_SCHEDULED_EVENT_USER_REMOVE + /// + /// This intent defines if the content within messages received by MESSAGE_CREATE is available or not. + /// This is a privileged intent and needs to be enabled in the developer portal. + /// + MessageContent = 1 << 15, + /// + /// This intent includes GUILD_SCHEDULED_EVENT_CREATE, GUILD_SCHEDULED_EVENT_UPDATE, GUILD_SCHEDULED_EVENT_DELETE, GUILD_SCHEDULED_EVENT_USER_ADD, GUILD_SCHEDULED_EVENT_USER_REMOVE + /// GuildScheduledEvents = 1 << 16, /// /// This intent includes all but and @@ -51,6 +58,6 @@ namespace Discord /// /// This intent includes all of them, including privileged ones. /// - All = AllUnprivileged | GuildMembers | GuildPresences + All = AllUnprivileged | GuildMembers | GuildPresences | MessageContent } } diff --git a/src/Discord.Net.Core/IDiscordClient.cs b/src/Discord.Net.Core/IDiscordClient.cs index 14e156769..dd1da3ae3 100644 --- a/src/Discord.Net.Core/IDiscordClient.cs +++ b/src/Discord.Net.Core/IDiscordClient.cs @@ -155,12 +155,14 @@ namespace Discord /// /// Gets a collection of all global commands. /// + /// Whether to include full localization dictionaries in the returned objects, instead of the name localized and description localized fields. + /// The target locale of the localized name and description fields. Sets X-Discord-Locale header, which takes precedence over Accept-Language. /// The options to be used when sending the request. /// /// A task that represents the asynchronous get operation. The task result contains a read-only collection of global /// application commands. /// - Task> GetGlobalApplicationCommandsAsync(RequestOptions options = null); + Task> GetGlobalApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null); /// /// Creates a global application command. diff --git a/src/Discord.Net.Core/Net/Rest/IRestClient.cs b/src/Discord.Net.Core/Net/Rest/IRestClient.cs index 71010f70d..d28fb707e 100644 --- a/src/Discord.Net.Core/Net/Rest/IRestClient.cs +++ b/src/Discord.Net.Core/Net/Rest/IRestClient.cs @@ -30,9 +30,13 @@ namespace Discord.Net.Rest /// The cancellation token used to cancel the task. /// Indicates whether to send the header only. /// The audit log reason. + /// Additional headers to be sent with the request. /// - Task SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly = false, string reason = null); - Task SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly = false, string reason = null); - Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipartParams, CancellationToken cancelToken, bool headerOnly = false, string reason = null); + Task SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly = false, string reason = null, + IEnumerable>> requestHeaders = null); + Task SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly = false, string reason = null, + IEnumerable>> requestHeaders = null); + Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipartParams, CancellationToken cancelToken, bool headerOnly = false, string reason = null, + IEnumerable>> requestHeaders = null); } } diff --git a/src/Discord.Net.Core/RequestOptions.cs b/src/Discord.Net.Core/RequestOptions.cs index 46aa2681f..ef8dbf756 100644 --- a/src/Discord.Net.Core/RequestOptions.cs +++ b/src/Discord.Net.Core/RequestOptions.cs @@ -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. /// /// - /// 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 /// null, 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. /// @@ -53,7 +54,7 @@ namespace Discord /// /// /// This property can also be set in . - /// 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. /// @@ -70,8 +71,10 @@ namespace Discord internal bool IsReactionBucket { get; set; } internal bool IsGatewayBucket { get; set; } + internal IDictionary> 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>(); } - + public RequestOptions Clone() => MemberwiseClone() as RequestOptions; } } diff --git a/src/Discord.Net.Core/Utils/Preconditions.cs b/src/Discord.Net.Core/Utils/Preconditions.cs index 2f24e660d..fb855f925 100644 --- a/src/Discord.Net.Core/Utils/Preconditions.cs +++ b/src/Discord.Net.Core/Utils/Preconditions.cs @@ -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(string name, string msg, T value) => new ArgumentException(message: msg ?? $"Value may not be equal to {value}.", paramName: name); - + /// Value must be at least . public static void AtLeast(sbyte obj, sbyte value, string name, string msg = null) { if (obj < value) throw CreateAtLeastException(name, msg, value); } /// Value must be at least . @@ -165,7 +165,7 @@ namespace Discord private static ArgumentException CreateAtLeastException(string name, string msg, T value) => new ArgumentException(message: msg ?? $"Value must be at least {value}.", paramName: name); - + /// Value must be greater than . public static void GreaterThan(sbyte obj, sbyte value, string name, string msg = null) { if (obj <= value) throw CreateGreaterThanException(name, msg, value); } /// Value must be greater than . @@ -201,7 +201,7 @@ namespace Discord private static ArgumentException CreateGreaterThanException(string name, string msg, T value) => new ArgumentException(message: msg ?? $"Value must be greater than {value}.", paramName: name); - + /// Value must be at most . public static void AtMost(sbyte obj, sbyte value, string name, string msg = null) { if (obj > value) throw CreateAtMostException(name, msg, value); } /// Value must be at most . @@ -237,7 +237,7 @@ namespace Discord private static ArgumentException CreateAtMostException(string name, string msg, T value) => new ArgumentException(message: msg ?? $"Value must be at most {value}.", paramName: name); - + /// Value must be less than . public static void LessThan(sbyte obj, sbyte value, string name, string msg = null) { if (obj >= value) throw CreateLessThanException(name, msg, value); } /// Value must be less than . diff --git a/src/Discord.Net.Interactions/Attributes/MaxLengthAttribute.cs b/src/Discord.Net.Interactions/Attributes/MaxLengthAttribute.cs index 1099e7d92..2172886d2 100644 --- a/src/Discord.Net.Interactions/Attributes/MaxLengthAttribute.cs +++ b/src/Discord.Net.Interactions/Attributes/MaxLengthAttribute.cs @@ -16,10 +16,10 @@ namespace Discord.Interactions /// /// Sets the maximum length allowed for a string type parameter. /// - /// Maximum string length allowed. - public MaxLengthAttribute(int lenght) + /// Maximum string length allowed. + public MaxLengthAttribute(int length) { - Length = lenght; + Length = length; } } } diff --git a/src/Discord.Net.Interactions/Attributes/MinLengthAttribute.cs b/src/Discord.Net.Interactions/Attributes/MinLengthAttribute.cs index 7d0b0fd63..8050f992a 100644 --- a/src/Discord.Net.Interactions/Attributes/MinLengthAttribute.cs +++ b/src/Discord.Net.Interactions/Attributes/MinLengthAttribute.cs @@ -16,10 +16,10 @@ namespace Discord.Interactions /// /// Sets the minimum length allowed for a string type parameter. /// - /// Minimum string length allowed. - public MinLengthAttribute(int lenght) + /// Minimum string length allowed. + public MinLengthAttribute(int length) { - Length = lenght; + Length = length; } } } diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs index 8dd2c4004..728b97a7a 100644 --- a/src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs @@ -67,26 +67,26 @@ namespace Discord.Interactions.Builders /// /// Sets . /// - /// New value of the . + /// New value of the . /// /// The builder instance. /// - public TextInputComponentBuilder WithMinLenght(int minLenght) + public TextInputComponentBuilder WithMinLength(int minLength) { - MinLength = minLenght; + MinLength = minLength; return this; } /// /// Sets . /// - /// New value of the . + /// New value of the . /// /// The builder instance. /// - public TextInputComponentBuilder WithMaxLenght(int maxLenght) + public TextInputComponentBuilder WithMaxLength(int maxLength) { - MaxLength = maxLenght; + MaxLength = maxLength; return this; } diff --git a/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs b/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs index 8f0987661..d970b9930 100644 --- a/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs +++ b/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs @@ -19,9 +19,36 @@ namespace Discord.Interactions if (!ModalUtils.TryGet(out var modalInfo)) throw new ArgumentException($"{typeof(T).FullName} isn't referenced by any registered Modal Interaction Command and doesn't have a cached {typeof(ModalInfo)}"); + await SendModalResponseAsync(interaction, customId, modalInfo, options, modifyModal); + } + + /// + /// Respond to an interaction with a . + /// + /// + /// This method overload uses the parameter to create a new + /// if there isn't a built one already in cache. + /// + /// Type of the implementation. + /// The interaction to respond to. + /// Interaction service instance that should be used to build s. + /// The request options for this request. + /// Delegate that can be used to modify the modal. + /// + public static async Task RespondWithModalAsync(this IDiscordInteraction interaction, string customId, InteractionService interactionService, + RequestOptions options = null, Action modifyModal = null) + where T : class, IModal + { + var modalInfo = ModalUtils.GetOrAdd(interactionService); + + await SendModalResponseAsync(interaction, customId, modalInfo, options, modifyModal); + } + + private static async Task SendModalResponseAsync(IDiscordInteraction interaction, string customId, ModalInfo modalInfo, RequestOptions options = null, Action modifyModal = null) + { var builder = new ModalBuilder(modalInfo.Title, customId); - foreach(var input in modalInfo.Components) + foreach (var input in modalInfo.Components) switch (input) { case TextInputComponentInfo textComponent: diff --git a/src/Discord.Net.Interactions/InteractionService.cs b/src/Discord.Net.Interactions/InteractionService.cs index 793d89cdc..50c1f5546 100644 --- a/src/Discord.Net.Interactions/InteractionService.cs +++ b/src/Discord.Net.Interactions/InteractionService.cs @@ -83,6 +83,11 @@ namespace Discord.Interactions public event Func ModalCommandExecuted { add { _modalCommandExecutedEvent.Add(value); } remove { _modalCommandExecutedEvent.Remove(value); } } internal readonly AsyncEvent> _modalCommandExecutedEvent = new(); + /// + /// Get the used by this Interaction Service instance to localize strings. + /// + public ILocalizationManager LocalizationManager { get; set; } + private readonly ConcurrentDictionary _typedModuleDefs; private readonly CommandMap _slashCommandMap; private readonly ConcurrentDictionary> _contextCommandMaps; @@ -203,6 +208,7 @@ namespace Discord.Interactions _enableAutocompleteHandlers = config.EnableAutocompleteHandlers; _autoServiceScopes = config.AutoServiceScopes; _restResponseCallback = config.RestResponseCallback; + LocalizationManager = config.LocalizationManager; _typeConverterMap = new TypeMap(this, new ConcurrentDictionary { diff --git a/src/Discord.Net.Interactions/InteractionServiceConfig.cs b/src/Discord.Net.Interactions/InteractionServiceConfig.cs index b6576a49f..b9102bc5f 100644 --- a/src/Discord.Net.Interactions/InteractionServiceConfig.cs +++ b/src/Discord.Net.Interactions/InteractionServiceConfig.cs @@ -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. /// public bool ExitOnMissingModalField { get; set; } = false; + + /// + /// Localization provider to be used when registering application commands. + /// + public ILocalizationManager LocalizationManager { get; set; } } /// diff --git a/src/Discord.Net.Interactions/LocalizationManagers/ILocalizationManager.cs b/src/Discord.Net.Interactions/LocalizationManagers/ILocalizationManager.cs new file mode 100644 index 000000000..13b155292 --- /dev/null +++ b/src/Discord.Net.Interactions/LocalizationManagers/ILocalizationManager.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Respresents a localization provider for Discord Application Commands. + /// + public interface ILocalizationManager + { + /// + /// Get every the resource name for every available locale. + /// + /// Location of the resource. + /// Type of the resource. + /// + /// A dictionary containing every available locale and the resource name. + /// + IDictionary GetAllNames(IList key, LocalizationTarget destinationType); + + /// + /// Get every the resource description for every available locale. + /// + /// Location of the resource. + /// Type of the resource. + /// + /// A dictionary containing every available locale and the resource name. + /// + IDictionary GetAllDescriptions(IList key, LocalizationTarget destinationType); + } +} diff --git a/src/Discord.Net.Interactions/LocalizationManagers/JsonLocalizationManager.cs b/src/Discord.Net.Interactions/LocalizationManagers/JsonLocalizationManager.cs new file mode 100644 index 000000000..010fb3bdd --- /dev/null +++ b/src/Discord.Net.Interactions/LocalizationManagers/JsonLocalizationManager.cs @@ -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 +{ + /// + /// The default localization provider for Json resource files. + /// + 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+.(?\w{2}(?:-\w{2})?).json", RegexOptions.Compiled | RegexOptions.Singleline); + + /// + /// Initializes a new instance of the class. + /// + /// Base path of the Json file. + /// Name of the Json file. + public JsonLocalizationManager(string basePath, string fileName) + { + _basePath = basePath; + _fileName = fileName; + } + + /// + public IDictionary GetAllDescriptions(IList key, LocalizationTarget destinationType) => + GetValues(key, DescriptionIdentifier); + + /// + public IDictionary GetAllNames(IList key, LocalizationTarget destinationType) => + GetValues(key, NameIdentifier); + + private string[] GetAllFiles() => + Directory.GetFiles(_basePath, $"{_fileName}.*.json", SearchOption.TopDirectoryOnly); + + private IDictionary GetValues(IList key, string identifier) + { + var result = new Dictionary(); + 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; + } + } +} diff --git a/src/Discord.Net.Interactions/LocalizationManagers/ResxLocalizationManager.cs b/src/Discord.Net.Interactions/LocalizationManagers/ResxLocalizationManager.cs new file mode 100644 index 000000000..a110602f2 --- /dev/null +++ b/src/Discord.Net.Interactions/LocalizationManagers/ResxLocalizationManager.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Resources; + +namespace Discord.Interactions +{ + /// + /// The default localization provider for Resx files. + /// + public sealed class ResxLocalizationManager : ILocalizationManager + { + private const string NameIdentifier = "name"; + private const string DescriptionIdentifier = "description"; + + private readonly ResourceManager _resourceManager; + private readonly IEnumerable _supportedLocales; + + /// + /// Initializes a new instance of the class. + /// + /// Name of the base resource. + /// The main assembly for the resources. + /// Cultures the should search for. + public ResxLocalizationManager(string baseResource, Assembly assembly, params CultureInfo[] supportedLocales) + { + _supportedLocales = supportedLocales; + _resourceManager = new ResourceManager(baseResource, assembly); + } + + /// + public IDictionary GetAllDescriptions(IList key, LocalizationTarget destinationType) => + GetValues(key, DescriptionIdentifier); + + /// + public IDictionary GetAllNames(IList key, LocalizationTarget destinationType) => + GetValues(key, NameIdentifier); + + private IDictionary GetValues(IList key, string identifier) + { + var entryKey = (string.Join(".", key) + "." + identifier); + + var result = new Dictionary(); + + foreach (var locale in _supportedLocales) + { + var value = _resourceManager.GetString(entryKey, locale); + if (value is not null) + result[locale.Name] = value; + } + + return result; + } + } +} diff --git a/src/Discord.Net.Interactions/LocalizationTarget.cs b/src/Discord.Net.Interactions/LocalizationTarget.cs new file mode 100644 index 000000000..cf54d3375 --- /dev/null +++ b/src/Discord.Net.Interactions/LocalizationTarget.cs @@ -0,0 +1,25 @@ +namespace Discord.Interactions +{ + /// + /// Resource targets for localization. + /// + public enum LocalizationTarget + { + /// + /// Target is a tagged with a . + /// + Group, + /// + /// Target is an application command method. + /// + Command, + /// + /// Target is a Slash Command parameter. + /// + Parameter, + /// + /// Target is a Slash Command parameter choice. + /// + Choice + } +} diff --git a/src/Discord.Net.Interactions/Map/CommandMap.cs b/src/Discord.Net.Interactions/Map/CommandMap.cs index 2e7bf5368..336e2b1ec 100644 --- a/src/Discord.Net.Interactions/Map/CommandMap.cs +++ b/src/Discord.Net.Interactions/Map/CommandMap.cs @@ -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 ParseCommandName(T command) - { - var keywords = new List() { command.Name }; - - var currentParent = command.Module; - - while (currentParent != null) - { - if (!string.IsNullOrEmpty(currentParent.SlashGroupName)) - keywords.Add(currentParent.SlashGroupName); - - currentParent = currentParent.Parent; - } - - keywords.Reverse(); - - return keywords; - } } } diff --git a/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs b/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs index 409c0e796..9b507f1bb 100644 --- a/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs +++ b/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs @@ -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.Empty })?.ToList(), ChannelTypes = parameterInfo.ChannelTypes?.ToList(), IsAutocomplete = parameterInfo.IsAutocomplete, MaxValue = parameterInfo.MaxValue, MinValue = parameterInfo.MinValue, + NameLocalizations = localizationManager?.GetAllNames(parameterPath, LocalizationTarget.Parameter) ?? ImmutableDictionary.Empty, + DescriptionLocalizations = localizationManager?.GetAllDescriptions(parameterPath, LocalizationTarget.Parameter) ?? ImmutableDictionary.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.Empty) + .WithDescriptionLocalizations(localizationManager?.GetAllDescriptions(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary.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.Empty, + DescriptionLocalizations = localizationManager?.GetAllDescriptions(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary.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.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.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.Empty) + .WithDescriptionLocalizations(localizationManager?.GetAllDescriptions(modulePath, LocalizationTarget.Group) ?? ImmutableDictionary.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.Empty, + DescriptionLocalizations = moduleInfo.CommandService.LocalizationManager?.GetAllDescriptions(moduleInfo.GetModulePath(), LocalizationTarget.Group) + ?? ImmutableDictionary.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>.Unspecified + DefaultMemberPermissions = (GuildPermission)command.DefaultMemberPermissions.RawValue, + IsDMEnabled = command.IsEnabledInDm, + Options = command.Options?.Select(x => x.ToApplicationCommandOptionProps())?.ToList() ?? Optional>.Unspecified, + NameLocalizations = command.NameLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary.Empty, + DescriptionLocalizations = command.DescriptionLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary.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.Empty, + DescriptionLocalizations = command.DescriptionLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary.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.Empty, + DescriptionLocalizations = command.DescriptionLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary.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 modifyModal = null) diff --git a/src/Discord.Net.Interactions/Utilities/CommandHierarchy.cs b/src/Discord.Net.Interactions/Utilities/CommandHierarchy.cs new file mode 100644 index 000000000..da7ef22e0 --- /dev/null +++ b/src/Discord.Net.Interactions/Utilities/CommandHierarchy.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions +{ + internal static class CommandHierarchy + { + public const char EscapeChar = '$'; + + public static IList GetModulePath(this ModuleInfo moduleInfo) + { + var result = new List(); + + var current = moduleInfo; + while (current is not null) + { + if (current.IsSlashGroup) + result.Insert(0, current.SlashGroupName); + + current = current.Parent; + } + + return result; + } + + public static IList GetCommandPath(this ICommandInfo commandInfo) + { + if (commandInfo.IgnoreGroupNames) + return new List { commandInfo.Name }; + + var path = commandInfo.Module.GetModulePath(); + path.Add(commandInfo.Name); + return path; + } + + public static IList GetParameterPath(this IParameterInfo parameterInfo) + { + var path = parameterInfo.Command.GetCommandPath(); + path.Add(parameterInfo.Name); + return path; + } + + public static IList GetChoicePath(this IParameterInfo parameterInfo, ParameterChoice choice) + { + var path = parameterInfo.GetParameterPath(); + path.Add(choice.Name); + return path; + } + + public static IList GetTypePath(Type type) => + new List { EscapeChar + type.FullName }; + } +} diff --git a/src/Discord.Net.Rest/API/Common/ApplicationCommand.cs b/src/Discord.Net.Rest/API/Common/ApplicationCommand.cs index 8b84149dd..e46369277 100644 --- a/src/Discord.Net.Rest/API/Common/ApplicationCommand.cs +++ b/src/Discord.Net.Rest/API/Common/ApplicationCommand.cs @@ -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 DefaultPermissions { get; set; } + [JsonProperty("name_localizations")] + public Optional> NameLocalizations { get; set; } + + [JsonProperty("description_localizations")] + public Optional> DescriptionLocalizations { get; set; } + + [JsonProperty("name_localized")] + public Optional NameLocalized { get; set; } + + [JsonProperty("description_localized")] + public Optional DescriptionLocalized { get; set; } + // V2 Permissions [JsonProperty("dm_permission")] public Optional DmPermission { get; set; } diff --git a/src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs b/src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs index fff5730f4..fb64d5ebe 100644 --- a/src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs +++ b/src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs @@ -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 ChannelTypes { get; set; } + [JsonProperty("name_localizations")] + public Optional> NameLocalizations { get; set; } + + [JsonProperty("description_localizations")] + public Optional> DescriptionLocalizations { get; set; } + + [JsonProperty("name_localized")] + public Optional NameLocalized { get; set; } + + [JsonProperty("description_localized")] + public Optional DescriptionLocalized { get; set; } + [JsonProperty("min_length")] public Optional MinLength { get; set; } @@ -69,6 +82,11 @@ namespace Discord.API Name = cmd.Name; Type = cmd.Type; Description = cmd.Description; + + NameLocalizations = cmd.NameLocalizations?.ToDictionary() ?? Optional>.Unspecified; + DescriptionLocalizations = cmd.DescriptionLocalizations?.ToDictionary() ?? Optional>.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>.Unspecified; + DescriptionLocalizations = option.DescriptionLocalizations?.ToDictionary() ?? Optional>.Unspecified; } } } diff --git a/src/Discord.Net.Rest/API/Common/ApplicationCommandOptionChoice.cs b/src/Discord.Net.Rest/API/Common/ApplicationCommandOptionChoice.cs index 6f84437f6..966405cc9 100644 --- a/src/Discord.Net.Rest/API/Common/ApplicationCommandOptionChoice.cs +++ b/src/Discord.Net.Rest/API/Common/ApplicationCommandOptionChoice.cs @@ -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> NameLocalizations { get; set; } + + [JsonProperty("name_localized")] + public Optional NameLocalized { get; set; } } } diff --git a/src/Discord.Net.Rest/API/Rest/CreateApplicationCommandParams.cs b/src/Discord.Net.Rest/API/Rest/CreateApplicationCommandParams.cs index 7ae8718b6..2257d4b97 100644 --- a/src/Discord.Net.Rest/API/Rest/CreateApplicationCommandParams.cs +++ b/src/Discord.Net.Rest/API/Rest/CreateApplicationCommandParams.cs @@ -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 DefaultPermission { get; set; } + [JsonProperty("name_localizations")] + public Optional> NameLocalizations { get; set; } + + [JsonProperty("description_localizations")] + public Optional> DescriptionLocalizations { get; set; } + [JsonProperty("dm_permission")] public Optional DmPermission { get; set; } @@ -26,12 +36,15 @@ namespace Discord.API.Rest public Optional 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 nameLocalizations = null, IDictionary descriptionLocalizations = null) { Name = name; Description = description; Options = Optional.Create(options); Type = type; + NameLocalizations = nameLocalizations?.ToDictionary(x => x.Key, x => x.Value) ?? Optional>.Unspecified; + DescriptionLocalizations = descriptionLocalizations?.ToDictionary(x => x.Key, x => x.Value) ?? Optional>.Unspecified; } } } diff --git a/src/Discord.Net.Rest/API/Rest/CreateStickerParams.cs b/src/Discord.Net.Rest/API/Rest/CreateStickerParams.cs index b330a0111..a0871bc64 100644 --- a/src/Discord.Net.Rest/API/Rest/CreateStickerParams.cs +++ b/src/Discord.Net.Rest/API/Rest/CreateStickerParams.cs @@ -1,4 +1,5 @@ using Discord.Net.Rest; + using System.Collections.Generic; using System.IO; namespace Discord.API.Rest @@ -20,14 +21,21 @@ namespace Discord.API.Rest ["tags"] = Tags }; - string contentType = "image/png"; - + string contentType; if (File is FileStream fileStream) - contentType = $"image/{Path.GetExtension(fileStream.Name)}"; + { + var extension = Path.GetExtension(fileStream.Name).TrimStart('.'); + contentType = extension == "json" ? "application/json" : $"image/{extension}"; + } else if (FileName != null) - contentType = $"image/{Path.GetExtension(FileName)}"; + { + var extension = Path.GetExtension(FileName).TrimStart('.'); + contentType = extension == "json" ? "application/json" : $"image/{extension}"; + } + else + contentType = "image/png"; - d["file"] = new MultipartFile(File, FileName ?? "image", contentType.Replace(".", "")); + d["file"] = new MultipartFile(File, FileName ?? "image", contentType); return d; } diff --git a/src/Discord.Net.Rest/API/Rest/ModifyApplicationCommandParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyApplicationCommandParams.cs index 5891c2c28..f49a3f33d 100644 --- a/src/Discord.Net.Rest/API/Rest/ModifyApplicationCommandParams.cs +++ b/src/Discord.Net.Rest/API/Rest/ModifyApplicationCommandParams.cs @@ -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 DefaultPermission { get; set; } + + [JsonProperty("name_localizations")] + public Optional> NameLocalizations { get; set; } + + [JsonProperty("description_localizations")] + public Optional> DescriptionLocalizations { get; set; } } } diff --git a/src/Discord.Net.Rest/BaseDiscordClient.cs b/src/Discord.Net.Rest/BaseDiscordClient.cs index af43e9f4e..686c7b030 100644 --- a/src/Discord.Net.Rest/BaseDiscordClient.cs +++ b/src/Discord.Net.Rest/BaseDiscordClient.cs @@ -243,7 +243,7 @@ namespace Discord.Rest => Task.FromResult(null); /// - Task> IDiscordClient.GetGlobalApplicationCommandsAsync(RequestOptions options) + Task> IDiscordClient.GetGlobalApplicationCommandsAsync(bool withLocalizations, string locale, RequestOptions options) => Task.FromResult>(ImmutableArray.Create()); Task IDiscordClient.CreateGlobalApplicationCommand(ApplicationCommandProperties properties, RequestOptions options) => Task.FromResult(null); diff --git a/src/Discord.Net.Rest/ClientHelper.cs b/src/Discord.Net.Rest/ClientHelper.cs index c6ad6a9fb..0c8f8c42f 100644 --- a/src/Discord.Net.Rest/ClientHelper.cs +++ b/src/Discord.Net.Rest/ClientHelper.cs @@ -194,10 +194,10 @@ namespace Discord.Rest }; } - public static async Task> GetGlobalApplicationCommandsAsync(BaseDiscordClient client, - RequestOptions options = null) + public static async Task> 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(); @@ -212,10 +212,10 @@ namespace Discord.Rest return model != null ? RestGlobalCommand.Create(client, model) : null; } - public static async Task> GetGuildApplicationCommandsAsync(BaseDiscordClient client, ulong guildId, - RequestOptions options = null) + public static async Task> 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(); diff --git a/src/Discord.Net.Rest/DiscordRestApiClient.cs b/src/Discord.Net.Rest/DiscordRestApiClient.cs index c5b075103..615e5ac12 100644 --- a/src/Discord.Net.Rest/DiscordRestApiClient.cs +++ b/src/Discord.Net.Rest/DiscordRestApiClient.cs @@ -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; @@ -861,7 +862,7 @@ namespace Discord.API options = RequestOptions.CreateOrClone(options); var ids = new BucketIds(webhookId: webhookId); - await SendJsonAsync("PATCH", () => $"webhooks/{webhookId}/{AuthToken}/messages/{messageId}${WebhookQuery(false, threadId)}", args, ids, clientBucket: ClientBucketType.SendEdit, options: options).ConfigureAwait(false); + await SendJsonAsync("PATCH", () => $"webhooks/{webhookId}/{AuthToken}/messages/{messageId}?{WebhookQuery(false, threadId)}", args, ids, clientBucket: ClientBucketType.SendEdit, options: options).ConfigureAwait(false); } /// This operation may only be called with a token. @@ -1212,11 +1213,22 @@ namespace Discord.API #endregion #region Interactions - public async Task GetGlobalApplicationCommandsAsync(RequestOptions options = null) + public async Task GetGlobalApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null) { options = RequestOptions.CreateOrClone(options); - return await SendAsync("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("GET", () => $"applications/{CurrentApplicationId}/commands{query}", + new BucketIds(), options: options).ConfigureAwait(false); } public async Task GetGlobalApplicationCommandAsync(ulong id, RequestOptions options = null) @@ -1281,13 +1293,24 @@ namespace Discord.API return await SendJsonAsync("PUT", () => $"applications/{CurrentApplicationId}/commands", commands, new BucketIds(), options: options).ConfigureAwait(false); } - public async Task GetGuildApplicationCommandsAsync(ulong guildId, RequestOptions options = null) + public async Task 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("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("GET", () => $"applications/{CurrentApplicationId}/guilds/{guildId}/commands{query}", + bucket, options: options).ConfigureAwait(false); } public async Task GetGuildApplicationCommandAsync(ulong guildId, ulong commandId, RequestOptions options = null) diff --git a/src/Discord.Net.Rest/DiscordRestClient.cs b/src/Discord.Net.Rest/DiscordRestClient.cs index daf7287c7..ddd38c5be 100644 --- a/src/Discord.Net.Rest/DiscordRestClient.cs +++ b/src/Discord.Net.Rest/DiscordRestClient.cs @@ -25,7 +25,7 @@ namespace Discord.Rest /// Gets the logged-in user. /// public new RestSelfUser CurrentUser { get => base.CurrentUser as RestSelfUser; internal set => base.CurrentUser = value; } - + /// public DiscordRestClient() : this(new DiscordRestConfig()) { } /// @@ -205,10 +205,10 @@ namespace Discord.Rest => ClientHelper.CreateGlobalApplicationCommandAsync(this, properties, options); public Task CreateGuildCommand(ApplicationCommandProperties properties, ulong guildId, RequestOptions options = null) => ClientHelper.CreateGuildApplicationCommandAsync(this, guildId, properties, options); - public Task> GetGlobalApplicationCommands(RequestOptions options = null) - => ClientHelper.GetGlobalApplicationCommandsAsync(this, options); - public Task> GetGuildApplicationCommands(ulong guildId, RequestOptions options = null) - => ClientHelper.GetGuildApplicationCommandsAsync(this, guildId, options); + public Task> GetGlobalApplicationCommands(bool withLocalizations = false, string locale = null, RequestOptions options = null) + => ClientHelper.GetGlobalApplicationCommandsAsync(this, withLocalizations, locale, options); + public Task> GetGuildApplicationCommands(ulong guildId, bool withLocalizations = false, string locale = null, RequestOptions options = null) + => ClientHelper.GetGuildApplicationCommandsAsync(this, guildId, withLocalizations, locale, options); public Task> BulkOverwriteGlobalCommands(ApplicationCommandProperties[] commandProperties, RequestOptions options = null) => ClientHelper.BulkOverwriteGlobalApplicationCommandAsync(this, commandProperties, options); public Task> BulkOverwriteGuildCommands(ApplicationCommandProperties[] commandProperties, ulong guildId, RequestOptions options = null) @@ -319,8 +319,8 @@ namespace Discord.Rest => await GetWebhookAsync(id, options).ConfigureAwait(false); /// - async Task> IDiscordClient.GetGlobalApplicationCommandsAsync(RequestOptions options) - => await GetGlobalApplicationCommands(options).ConfigureAwait(false); + async Task> IDiscordClient.GetGlobalApplicationCommandsAsync(bool withLocalizations, string locale, RequestOptions options) + => await GetGlobalApplicationCommands(withLocalizations, locale, options).ConfigureAwait(false); /// async Task IDiscordClient.GetGlobalApplicationCommandAsync(ulong id, RequestOptions options) => await ClientHelper.GetGlobalApplicationCommandAsync(this, id, options).ConfigureAwait(false); diff --git a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs index 20140994f..c4e3764d1 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs @@ -180,7 +180,7 @@ namespace Discord.Rest }, nextPage: (info, lastPage) => { - if (lastPage.Count != DiscordConfig.MaxMessagesPerBatch) + if (lastPage.Count != DiscordConfig.MaxBansPerBatch) return false; if (dir == Direction.Before) info.Position = lastPage.Min(x => x.User.Id); @@ -362,10 +362,10 @@ namespace Discord.Rest #endregion #region Interactions - public static async Task> GetSlashCommandsAsync(IGuild guild, BaseDiscordClient client, - RequestOptions options) + public static async Task> 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 GetSlashCommandAsync(IGuild guild, ulong id, BaseDiscordClient client, diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs index 3e0ad1840..eb3254619 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs @@ -311,13 +311,15 @@ namespace Discord.Rest /// /// Gets a collection of slash commands created by the current user in this guild. /// + /// Whether to include full localization dictionaries in the returned objects, instead of the name localized and description localized fields. + /// The target locale of the localized name and description fields. Sets X-Discord-Locale header, which takes precedence over Accept-Language. /// The options to be used when sending the request. /// /// A task that represents the asynchronous get operation. The task result contains a read-only collection of /// slash commands created by the current user. /// - public Task> GetSlashCommandsAsync(RequestOptions options = null) - => GuildHelper.GetSlashCommandsAsync(this, Discord, options); + public Task> GetSlashCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null) + => GuildHelper.GetSlashCommandsAsync(this, Discord, withLocalizations, locale, options); /// /// Gets a slash command in the current guild. @@ -928,13 +930,15 @@ namespace Discord.Rest /// /// Gets this guilds slash commands /// + /// Whether to include full localization dictionaries in the returned objects, instead of the name localized and description localized fields. + /// The target locale of the localized name and description fields. Sets X-Discord-Locale header, which takes precedence over Accept-Language. /// The options to be used when sending the request. /// /// A task that represents the asynchronous get operation. The task result contains a read-only collection /// of application commands found within the guild. /// - public async Task> GetApplicationCommandsAsync (RequestOptions options = null) - => await ClientHelper.GetGuildApplicationCommandsAsync(Discord, Id, options).ConfigureAwait(false); + public async Task> GetApplicationCommandsAsync (bool withLocalizations = false, string locale = null, RequestOptions options = null) + => await ClientHelper.GetGuildApplicationCommandsAsync(Discord, Id, withLocalizations, locale, options).ConfigureAwait(false); /// /// Gets an application command within this guild with the specified id. /// @@ -1467,8 +1471,8 @@ namespace Discord.Rest async Task> IGuild.GetWebhooksAsync(RequestOptions options) => await GetWebhooksAsync(options).ConfigureAwait(false); /// - async Task> IGuild.GetApplicationCommandsAsync (RequestOptions options) - => await GetApplicationCommandsAsync(options).ConfigureAwait(false); + async Task> IGuild.GetApplicationCommandsAsync (bool withLocalizations, string locale, RequestOptions options) + => await GetApplicationCommandsAsync(withLocalizations, locale, options).ConfigureAwait(false); /// async Task IGuild.CreateStickerAsync(string name, string description, IEnumerable tags, Image image, RequestOptions options) => await CreateStickerAsync(name, description, tags, image, options); diff --git a/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs b/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs index 522c098e6..deca00b72 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs @@ -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.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,6 +142,8 @@ namespace Discord.Rest DefaultPermission = arg.IsDefaultPermission.IsSpecified ? arg.IsDefaultPermission.Value : Optional.Unspecified, + NameLocalizations = arg.NameLocalizations?.ToDictionary(), + DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary(), // TODO: better conversion to nullable optionals DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(), @@ -181,6 +185,8 @@ namespace Discord.Rest DefaultPermission = arg.IsDefaultPermission.IsSpecified ? arg.IsDefaultPermission.Value : Optional.Unspecified, + NameLocalizations = arg.NameLocalizations?.ToDictionary(), + DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary(), // TODO: better conversion to nullable optionals DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(), @@ -244,7 +250,9 @@ namespace Discord.Rest Name = args.Name, DefaultPermission = args.IsDefaultPermission.IsSpecified ? args.IsDefaultPermission.Value - : Optional.Unspecified + : Optional.Unspecified, + NameLocalizations = args.NameLocalizations?.ToDictionary(), + DescriptionLocalizations = args.DescriptionLocalizations?.ToDictionary() }; if (args is SlashCommandProperties slashProps) @@ -299,6 +307,8 @@ namespace Discord.Rest DefaultPermission = arg.IsDefaultPermission.IsSpecified ? arg.IsDefaultPermission.Value : Optional.Unspecified, + NameLocalizations = arg.NameLocalizations?.ToDictionary(), + DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary(), // TODO: better conversion to nullable optionals DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(), @@ -335,7 +345,9 @@ namespace Discord.Rest Name = arg.Name, DefaultPermission = arg.IsDefaultPermission.IsSpecified ? arg.IsDefaultPermission.Value - : Optional.Unspecified + : Optional.Unspecified, + NameLocalizations = arg.NameLocalizations?.ToDictionary(), + DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary() }; if (arg is SlashCommandProperties slashProps) diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommand.cs b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommand.cs index 667609ef4..468d10712 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommand.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommand.cs @@ -38,6 +38,32 @@ namespace Discord.Rest /// public IReadOnlyCollection Options { get; private set; } + /// + /// Gets the localization dictionary for the name field of this command. + /// + public IReadOnlyDictionary NameLocalizations { get; private set; } + + /// + /// Gets the localization dictionary for the description field of this command. + /// + public IReadOnlyDictionary DescriptionLocalizations { get; private set; } + + /// + /// Gets the localized name of this command. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string NameLocalized { get; private set; } + + /// + /// Gets the localized description of this command. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string DescriptionLocalized { get; private set; } + /// public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); @@ -64,6 +90,15 @@ namespace Discord.Rest ? model.Options.Value.Select(RestApplicationCommandOption.Create).ToImmutableArray() : ImmutableArray.Create(); + NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.Empty; + + DescriptionLocalizations = model.DescriptionLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.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)); } diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandChoice.cs b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandChoice.cs index a40491a2c..b736c435d 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandChoice.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandChoice.cs @@ -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 /// public object Value { get; } + /// + /// Gets the localization dictionary for the name field of this command option choice. + /// + public IReadOnlyDictionary NameLocalizations { get; } + + /// + /// Gets the localized name of this command option choice. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + 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); } } } diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs index c47080be7..3ac15e695 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs @@ -27,7 +27,7 @@ namespace Discord.Rest public bool? IsRequired { get; private set; } /// - public bool? IsAutocomplete { get; private set; } + public bool? IsAutocomplete { get; private set; } /// public double? MinValue { get; private set; } @@ -54,6 +54,32 @@ namespace Discord.Rest /// public IReadOnlyCollection ChannelTypes { get; private set; } + /// + /// Gets the localization dictionary for the name field of this command option. + /// + public IReadOnlyDictionary NameLocalizations { get; private set; } + + /// + /// Gets the localization dictionary for the description field of this command option. + /// + public IReadOnlyDictionary DescriptionLocalizations { get; private set; } + + /// + /// Gets the localized name of this command option. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string NameLocalized { get; private set; } + + /// + /// Gets the localized description of this command option. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + 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(); + + NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.Empty; + + DescriptionLocalizations = model.DescriptionLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.Empty; + + NameLocalized = model.NameLocalized.GetValueOrDefault(); + DescriptionLocalized = model.DescriptionLocalized.GetValueOrDefault(); } #endregion diff --git a/src/Discord.Net.Rest/Entities/Roles/RoleHelper.cs b/src/Discord.Net.Rest/Entities/Roles/RoleHelper.cs index 3b2946a0d..2f6d1f062 100644 --- a/src/Discord.Net.Rest/Entities/Roles/RoleHelper.cs +++ b/src/Discord.Net.Rest/Entities/Roles/RoleHelper.cs @@ -23,7 +23,7 @@ namespace Discord.Rest { role.Guild.Features.EnsureFeature(GuildFeature.RoleIcons); - if (args.Icon.IsSpecified && args.Emoji.IsSpecified) + if ((args.Icon.IsSpecified && args.Icon.Value != null) && (args.Emoji.IsSpecified && args.Emoji.Value != null)) { throw new ArgumentException("Emoji and Icon properties cannot be present on a role at the same time."); } @@ -36,18 +36,18 @@ namespace Discord.Rest Mentionable = args.Mentionable, Name = args.Name, Permissions = args.Permissions.IsSpecified ? args.Permissions.Value.RawValue.ToString() : Optional.Create(), - Icon = args.Icon.IsSpecified ? args.Icon.Value.Value.ToModel() : Optional.Unspecified, - Emoji = args.Emoji.GetValueOrDefault()?.Name ?? Optional.Unspecified + Icon = args.Icon.IsSpecified ? args.Icon.Value?.ToModel() ?? null : Optional.Unspecified, + Emoji = args.Emoji.IsSpecified ? args.Emoji.Value?.Name ?? "" : Optional.Create(), }; - if (args.Icon.IsSpecified && role.Emoji != null) + if ((args.Icon.IsSpecified && args.Icon.Value != null) && role.Emoji != null) { - apiArgs.Emoji = null; + apiArgs.Emoji = ""; } - if (args.Emoji.IsSpecified && !string.IsNullOrEmpty(role.Icon)) + if ((args.Emoji.IsSpecified && args.Emoji.Value != null) && !string.IsNullOrEmpty(role.Icon)) { - apiArgs.Icon = null; + apiArgs.Icon = Optional.Unspecified; } var model = await client.ApiClient.ModifyGuildRoleAsync(role.Guild.Id, role.Id, apiArgs, options).ConfigureAwait(false); diff --git a/src/Discord.Net.Rest/Net/DefaultRestClient.cs b/src/Discord.Net.Rest/Net/DefaultRestClient.cs index 721c7009d..97872ee6a 100644 --- a/src/Discord.Net.Rest/Net/DefaultRestClient.cs +++ b/src/Discord.Net.Rest/Net/DefaultRestClient.cs @@ -66,33 +66,45 @@ namespace Discord.Net.Rest _cancelToken = cancelToken; } - public async Task SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly, string reason = null) + public async Task SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly, string reason = null, + IEnumerable>> 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 SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly, string reason = null) + public async Task SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly, string reason = null, + IEnumerable>> 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); } } /// Unsupported param type. - public async Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipartParams, CancellationToken cancelToken, bool headerOnly, string reason = null) + public async Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipartParams, CancellationToken cancelToken, bool headerOnly, string reason = null, + IEnumerable>> 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: diff --git a/src/Discord.Net.Rest/Net/Queue/Requests/RestRequest.cs b/src/Discord.Net.Rest/Net/Queue/Requests/RestRequest.cs index bb5840ce2..e5cab831e 100644 --- a/src/Discord.Net.Rest/Net/Queue/Requests/RestRequest.cs +++ b/src/Discord.Net.Rest/Net/Queue/Requests/RestRequest.cs @@ -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 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); } } } diff --git a/src/Discord.Net.WebSocket/DiscordSocketClient.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.cs index f0b50aa8f..670ed4567 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.cs @@ -450,14 +450,16 @@ namespace Discord.WebSocket /// /// Gets a collection of all global commands. /// + /// Whether to include full localization dictionaries in the returned objects, instead of the name localized and description localized fields. + /// The target locale of the localized name and description fields. Sets X-Discord-Locale header, which takes precedence over Accept-Language. /// The options to be used when sending the request. /// /// A task that represents the asynchronous get operation. The task result contains a read-only collection of global /// application commands. /// - public async Task> GetGlobalApplicationCommandsAsync(RequestOptions options = null) + public async Task> 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 IDiscordClient.GetGlobalApplicationCommandAsync(ulong id, RequestOptions options) => await GetGlobalApplicationCommandAsync(id, options); /// - async Task> IDiscordClient.GetGlobalApplicationCommandsAsync(RequestOptions options) - => await GetGlobalApplicationCommandsAsync(options); + async Task> IDiscordClient.GetGlobalApplicationCommandsAsync(bool withLocalizations, string locale, RequestOptions options) + => await GetGlobalApplicationCommandsAsync(withLocalizations, locale, options); /// async Task IDiscordClient.StartAsync() diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketGuildChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketGuildChannel.cs index 16ed7b32d..808982785 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketGuildChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketGuildChannel.cs @@ -36,8 +36,8 @@ namespace Discord.WebSocket /// Gets a collection of users that are able to view the channel. /// /// - /// If this channel is a voice channel, a collection of users who are currently connected to this channel - /// is returned. + /// If this channel is a voice channel, use to retrieve a + /// collection of users who are currently connected to this channel. /// /// /// A read-only collection of users that can access the channel (i.e. the users seen in the user list). diff --git a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs index 78fb33206..55f098b2f 100644 --- a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs +++ b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs @@ -874,14 +874,17 @@ namespace Discord.WebSocket /// /// Gets a collection of slash commands created by the current user in this guild. /// + /// Whether to include full localization dictionaries in the returned objects, instead of the name localized and description localized fields. + /// The target locale of the localized name and description fields. Sets X-Discord-Locale header, which takes precedence over Accept-Language. /// The options to be used when sending the request. /// /// A task that represents the asynchronous get operation. The task result contains a read-only collection of /// slash commands created by the current user. /// - public async Task> GetApplicationCommandsAsync(RequestOptions options = null) + public async Task> 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> IGuild.GetWebhooksAsync(RequestOptions options) => await GetWebhooksAsync(options).ConfigureAwait(false); /// - async Task> IGuild.GetApplicationCommandsAsync (RequestOptions options) - => await GetApplicationCommandsAsync(options).ConfigureAwait(false); + async Task> IGuild.GetApplicationCommandsAsync (bool withLocalizations, string locale, RequestOptions options) + => await GetApplicationCommandsAsync(withLocalizations, locale, options).ConfigureAwait(false); /// async Task IGuild.CreateStickerAsync(string name, string description, IEnumerable tags, Image image, RequestOptions options) => await CreateStickerAsync(name, description, tags, image, options); diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommand.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommand.cs index f6b3f9699..b0ddd0012 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommand.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommand.cs @@ -50,6 +50,32 @@ namespace Discord.WebSocket /// public IReadOnlyCollection Options { get; private set; } + /// + /// Gets the localization dictionary for the name field of this command. + /// + public IReadOnlyDictionary NameLocalizations { get; private set; } + + /// + /// Gets the localization dictionary for the description field of this command. + /// + public IReadOnlyDictionary DescriptionLocalizations { get; private set; } + + /// + /// Gets the localized name of this command. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string NameLocalized { get; private set; } + + /// + /// Gets the localized description of this command. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string DescriptionLocalized { get; private set; } + /// public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); @@ -93,6 +119,15 @@ namespace Discord.WebSocket ? model.Options.Value.Select(SocketApplicationCommandOption.Create).ToImmutableArray() : ImmutableArray.Create(); + NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.Empty; + + DescriptionLocalizations = model.DescriptionLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.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)); } diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandChoice.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandChoice.cs index e70efa27b..4da1eaadb 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandChoice.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandChoice.cs @@ -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 /// public object Value { get; private set; } + /// + /// Gets the localization dictionary for the name field of this command option choice. + /// + public IReadOnlyDictionary NameLocalizations { get; private set; } + + /// + /// Gets the localized name of this command option choice. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + 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); } } } diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandOption.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandOption.cs index 478c7cb54..78bb45141 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandOption.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandOption.cs @@ -54,6 +54,32 @@ namespace Discord.WebSocket /// public IReadOnlyCollection ChannelTypes { get; private set; } + /// + /// Gets the localization dictionary for the name field of this command option. + /// + public IReadOnlyDictionary NameLocalizations { get; private set; } + + /// + /// Gets the localization dictionary for the description field of this command option. + /// + public IReadOnlyDictionary DescriptionLocalizations { get; private set; } + + /// + /// Gets the localized name of this command option. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string NameLocalized { get; private set; } + + /// + /// Gets the localized description of this command option. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + 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(); + + NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.Empty; + + DescriptionLocalizations = model.DescriptionLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.Empty; + + NameLocalized = model.NameLocalized.GetValueOrDefault(); + DescriptionLocalized = model.DescriptionLocalized.GetValueOrDefault(); } IReadOnlyCollection IApplicationCommandOption.Choices => Choices; diff --git a/src/Discord.Net/Discord.Net.nuspec b/src/Discord.Net/Discord.Net.nuspec index 1a61ff97a..63a288dc3 100644 --- a/src/Discord.Net/Discord.Net.nuspec +++ b/src/Discord.Net/Discord.Net.nuspec @@ -2,7 +2,7 @@ Discord.Net - 3.7.2$suffix$ + 3.8.0$suffix$ Discord.Net Discord.Net Contributors foxbot @@ -14,44 +14,44 @@ https://github.com/discord-net/Discord.Net/raw/dev/docs/marketing/logo/PackageLogo.png - - - - - - + + + + + + - - - - - - + + + + + + - - - - - - + + + + + + - - - - - - + + + + + + - - - - - - + + + + + + diff --git a/test/Discord.Net.Tests.Unit/ColorTests.cs b/test/Discord.Net.Tests.Unit/ColorTests.cs index 46d8feabb..48a6041e5 100644 --- a/test/Discord.Net.Tests.Unit/ColorTests.cs +++ b/test/Discord.Net.Tests.Unit/ColorTests.cs @@ -10,6 +10,7 @@ namespace Discord /// public class ColorTests { + [Fact] public void Color_New() { Assert.Equal(0u, new Color().RawValue);