From 6da595e07467c91db3e81e66f7e7bd2b2b9fc7c1 Mon Sep 17 00:00:00 2001 From: Cenk Ergen <57065323+Cenngo@users.noreply.github.com> Date: Sun, 21 Aug 2022 14:52:57 +0300 Subject: [PATCH 01/32] fix ci/cd error (#2428) --- src/Discord.Net.Core/Discord.Net.Core.csproj | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From b6b5e95f48af647531292f3c3c3c53af8f98ef7a Mon Sep 17 00:00:00 2001 From: Armano den Boef <68127614+Rozen4334@users.noreply.github.com> Date: Sun, 21 Aug 2022 13:53:14 +0200 Subject: [PATCH 02/32] Fix role icon & emoji assignment. (#2416) --- src/Discord.Net.Rest/Entities/Roles/RoleHelper.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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); From b7b7964de97b656579845435c6050104d2c9daf8 Mon Sep 17 00:00:00 2001 From: BokuNoPasya <49203428+1NieR@users.noreply.github.com> Date: Sun, 21 Aug 2022 16:54:19 +0500 Subject: [PATCH 03/32] Fix IGuild.GetBansAsync() (#2424) fix the problem of not being able to get more than 1000 bans --- src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs index 20140994f..8195a2cea 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); From 917118d094eb1969c7da6ff8c6e4583c450604f6 Mon Sep 17 00:00:00 2001 From: Misha133 <61027276+Misha-133@users.noreply.github.com> Date: Sun, 21 Aug 2022 14:56:02 +0300 Subject: [PATCH 04/32] [DOCS] Add a note about `DontAutoRegisterAttribute` (#2430) * add a note about `DontAutoRegisterAttribute` * Remove "to to" and add punctuation Co-authored-by: MrCakeSlayer <13650699+MrCakeSlayer@users.noreply.github.com> --- docs/guides/int_framework/intro.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/guides/int_framework/intro.md b/docs/guides/int_framework/intro.md index 5cf38bff1..37c579159 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 @@ -377,6 +380,7 @@ delegate can be used to create HTTP responses from a deserialized json object st [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 From 92215b1f746cdeda4fe53aa0568d078defc5afc9 Mon Sep 17 00:00:00 2001 From: Ge Date: Sun, 21 Aug 2022 19:57:00 +0800 Subject: [PATCH 05/32] fix: Missing Fact attribute in ColorTests (#2425) --- test/Discord.Net.Tests.Unit/ColorTests.cs | 1 + 1 file changed, 1 insertion(+) 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); From 89a8ea161fcc7540572e7d272dff8e2069b06195 Mon Sep 17 00:00:00 2001 From: Misha133 <61027276+Misha-133@users.noreply.github.com> Date: Sun, 21 Aug 2022 14:57:51 +0300 Subject: [PATCH 06/32] feat: Embed comparison (#2347) --- .../Entities/Messages/Embed.cs | 39 +++++ .../Entities/Messages/EmbedAuthor.cs | 31 ++++ .../Entities/Messages/EmbedBuilder.cs | 141 ++++++++++++++++++ .../Entities/Messages/EmbedField.cs | 31 ++++ .../Entities/Messages/EmbedFooter.cs | 31 ++++ .../Entities/Messages/EmbedImage.cs | 31 ++++ .../Entities/Messages/EmbedProvider.cs | 31 ++++ .../Entities/Messages/EmbedThumbnail.cs | 31 ++++ .../Entities/Messages/EmbedVideo.cs | 31 ++++ 9 files changed, 397 insertions(+) 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(); } } From ddcf68a29fce08bd59be9e39c5732e4eb1d3a1b2 Mon Sep 17 00:00:00 2001 From: Charlie U <52503242+cpurules@users.noreply.github.com> Date: Sun, 21 Aug 2022 07:58:51 -0400 Subject: [PATCH 07/32] Fix broken code snippet in dependency injection docs (#2420) * Fixed markdown formatting to show code snippet * Fixed constructor injection code snippet pointer --- docs/guides/dependency_injection/injection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 32b03c8063332d50c93bbf0eefa55044853b6ce1 Mon Sep 17 00:00:00 2001 From: Kuba_Z2 <77853483+KubaZ2@users.noreply.github.com> Date: Sun, 21 Aug 2022 16:14:55 +0200 Subject: [PATCH 08/32] Added support for lottie stickers (#2359) --- .../API/Rest/CreateStickerParams.cs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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; } From 39bbd298c37a7e2766f9eacb65e768930dee67a9 Mon Sep 17 00:00:00 2001 From: Cenk Ergen <57065323+Cenngo@users.noreply.github.com> Date: Fri, 26 Aug 2022 18:45:27 +0300 Subject: [PATCH 09/32] Interactions Command Localization (#2395) * Request headers (#2394) * add support for per-request headers * remove unnecessary usings * Revert "remove unnecessary usings" This reverts commit 8d674fe4faf985b117f143fae3877a1698170ad2. * remove nullable strings from RequestOptions * Add Localization Support to Interaction Service (#2211) * add json and resx localization managers * add utils class for getting command paths * update json regex to make langage code optional * remove IServiceProvider from ILocalizationManager method params * replace the command path method in command map * add localization fields to rest and websocket application command entity implementations * move deconstruct extensions method to extensions folder * add withLocalizations parameter to rest methods * fix build error * add rest conversions to interaction service * add localization to the rest methods * add inline docs * fix implementation bugs * add missing inline docs * inline docs correction (Name/Description Localized properties) * add choice localization * fix conflicts * fix conflicts * add missing command props fields to ToApplicationCommandProps methods * add locale parameter to Get*ApplicationCommandsAsync methods for fetching localized command names/descriptions * Apply suggestions from code review Co-authored-by: Armano den Boef <68127614+Rozen4334@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Armano den Boef <68127614+Rozen4334@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Guilds/IGuild.cs Co-authored-by: Armano den Boef <68127614+Rozen4334@users.noreply.github.com> * add inline docs to LocalizationTarget * fix upstream merge errors * fix command parsing for context command names with space char * fix command parsing for context command names with space char * fix failed to generate buket id * fix get guild commands endpoint * update rexs localization manager to use single-file pattern * Upstream Merge Localization Branch (#2434) * fix ci/cd error (#2428) * Fix role icon & emoji assignment. (#2416) * Fix IGuild.GetBansAsync() (#2424) fix the problem of not being able to get more than 1000 bans * [DOCS] Add a note about `DontAutoRegisterAttribute` (#2430) * add a note about `DontAutoRegisterAttribute` * Remove "to to" and add punctuation Co-authored-by: MrCakeSlayer <13650699+MrCakeSlayer@users.noreply.github.com> * fix: Missing Fact attribute in ColorTests (#2425) * feat: Embed comparison (#2347) * Fix broken code snippet in dependency injection docs (#2420) * Fixed markdown formatting to show code snippet * Fixed constructor injection code snippet pointer * Added support for lottie stickers (#2359) Co-authored-by: Armano den Boef <68127614+Rozen4334@users.noreply.github.com> Co-authored-by: BokuNoPasya <49203428+1NieR@users.noreply.github.com> Co-authored-by: Misha133 <61027276+Misha-133@users.noreply.github.com> Co-authored-by: MrCakeSlayer <13650699+MrCakeSlayer@users.noreply.github.com> Co-authored-by: Ge Co-authored-by: Charlie U <52503242+cpurules@users.noreply.github.com> Co-authored-by: Kuba_Z2 <77853483+KubaZ2@users.noreply.github.com> * remove unnecassary fields from ResxLocalizationManager * update int framework guides * remove space character tokenization from ResxLocalizationManager Co-authored-by: Armano den Boef <68127614+Rozen4334@users.noreply.github.com> Co-authored-by: BokuNoPasya <49203428+1NieR@users.noreply.github.com> Co-authored-by: Misha133 <61027276+Misha-133@users.noreply.github.com> Co-authored-by: MrCakeSlayer <13650699+MrCakeSlayer@users.noreply.github.com> Co-authored-by: Ge Co-authored-by: Charlie U <52503242+cpurules@users.noreply.github.com> Co-authored-by: Kuba_Z2 <77853483+KubaZ2@users.noreply.github.com> --- docs/guides/int_framework/intro.md | 41 +++ .../Entities/Guilds/IGuild.cs | 7 +- .../Interactions/ApplicationCommandOption.cs | 92 ++++- .../ApplicationCommandOptionChoice.cs | 33 ++ .../ApplicationCommandProperties.cs | 52 +++ .../ContextMenus/MessageCommandBuilder.cs | 70 ++++ .../ContextMenus/UserCommandBuilder.cs | 72 +++- .../Interactions/IApplicationCommand.cs | 26 ++ .../Interactions/IApplicationCommandOption.cs | 26 ++ .../IApplicationCommandOptionChoice.cs | 15 + .../SlashCommands/SlashCommandBuilder.cs | 325 ++++++++++++++++-- .../Extensions/GenericCollectionExtensions.cs | 15 + src/Discord.Net.Core/IDiscordClient.cs | 4 +- src/Discord.Net.Core/Net/Rest/IRestClient.cs | 10 +- src/Discord.Net.Core/RequestOptions.cs | 12 +- src/Discord.Net.Core/Utils/Preconditions.cs | 10 +- .../InteractionService.cs | 6 + .../InteractionServiceConfig.cs | 5 + .../ILocalizationManager.cs | 32 ++ .../JsonLocalizationManager.cs | 72 ++++ .../ResxLocalizationManager.cs | 55 +++ .../LocalizationTarget.cs | 25 ++ .../Map/CommandMap.cs | 23 +- .../Utilities/ApplicationCommandRestUtil.cs | 88 ++++- .../Utilities/CommandHierarchy.cs | 53 +++ .../API/Common/ApplicationCommand.cs | 13 + .../API/Common/ApplicationCommandOption.cs | 21 ++ .../Common/ApplicationCommandOptionChoice.cs | 7 + .../Rest/CreateApplicationCommandParams.cs | 15 +- .../Rest/ModifyApplicationCommandParams.cs | 7 + src/Discord.Net.Rest/BaseDiscordClient.cs | 2 +- src/Discord.Net.Rest/ClientHelper.cs | 12 +- src/Discord.Net.Rest/DiscordRestApiClient.cs | 31 +- src/Discord.Net.Rest/DiscordRestClient.cs | 14 +- .../Entities/Guilds/GuildHelper.cs | 6 +- .../Entities/Guilds/RestGuild.cs | 16 +- .../Interactions/InteractionHelper.cs | 20 +- .../Interactions/RestApplicationCommand.cs | 35 ++ .../RestApplicationCommandChoice.cs | 17 + .../RestApplicationCommandOption.cs | 37 +- src/Discord.Net.Rest/Net/DefaultRestClient.cs | 20 +- .../Net/Queue/Requests/RestRequest.cs | 5 +- .../DiscordSocketClient.cs | 10 +- .../Entities/Guilds/SocketGuild.cs | 11 +- .../SocketApplicationCommand.cs | 35 ++ .../SocketApplicationCommandChoice.cs | 17 + .../SocketApplicationCommandOption.cs | 35 ++ 47 files changed, 1403 insertions(+), 152 deletions(-) create mode 100644 src/Discord.Net.Core/Extensions/GenericCollectionExtensions.cs create mode 100644 src/Discord.Net.Interactions/LocalizationManagers/ILocalizationManager.cs create mode 100644 src/Discord.Net.Interactions/LocalizationManagers/JsonLocalizationManager.cs create mode 100644 src/Discord.Net.Interactions/LocalizationManagers/ResxLocalizationManager.cs create mode 100644 src/Discord.Net.Interactions/LocalizationTarget.cs create mode 100644 src/Discord.Net.Interactions/Utilities/CommandHierarchy.cs diff --git a/docs/guides/int_framework/intro.md b/docs/guides/int_framework/intro.md index 37c579159..21ea365de 100644 --- a/docs/guides/int_framework/intro.md +++ b/docs/guides/int_framework/intro.md @@ -376,6 +376,47 @@ respond to the Interactions within your command modules you need to perform the delegate can be used to create HTTP responses from a deserialized json object string. - Use the interaction endpoints of the module base instead of the interaction object (ie. `RespondAsync()`, `FollowupAsync()`...). +## Localization + +Discord Slash Commands support name/description localization. Localization is available for names and descriptions of Slash Command Groups ([GroupAttribute]), Slash Commands ([SlashCommandAttribute]), Slash Command parameters and Slash Command Parameter Choices. Interaction Service can be initialized with an `ILocalizationManager` instance in its config which is used to create the necessary localization dictionaries on command registration. Interaction Service has two built-in `ILocalizationManager` implementations: `ResxLocalizationManager` and `JsonLocalizationManager`. + +### ResXLocalizationManager + +`ResxLocalizationManager` uses `.` delimited key names to traverse the resource files and get the localized strings (`group1.group2.command.parameter.name`). A `ResxLocalizationManager` instance must be initialized with a base resource name, a target assembly and a collection of `CultureInfo`s. Every key path must end with either `.name` or `.description`, including parameter choice strings. [Discord.Tools.LocalizationTemplate.Resx](https://www.nuget.org/packages/Discord.Tools.LocalizationTemplate.Resx) dotnet tool can be used to create localization file templates. + +### JsonLocalizationManager + +`JsonLocaliationManager` uses a nested data structure similar to Discord's Application Commands schema. You can get the Json schema [here](https://gist.github.com/Cenngo/d46a881de24823302f66c3c7e2f7b254). `JsonLocalizationManager` accepts a base path and a base file name and automatically discovers every resource file ( \basePath\fileName.locale.json ). A Json resource file should have a structure similar to: + +```json +{ + "command_1":{ + "name": "localized_name", + "description": "localized_description", + "parameter_1":{ + "name": "localized_name", + "description": "localized_description" + } + }, + "group_1":{ + "name": "localized_name", + "description": "localized_description", + "command_1":{ + "name": "localized_name", + "description": "localized_description", + "parameter_1":{ + "name": "localized_name", + "description": "localized_description" + }, + "parameter_2":{ + "name": "localized_name", + "description": "localized_description" + } + } + } +} +``` + [AutocompleteHandlers]: xref:Guides.IntFw.AutoCompletion [DependencyInjection]: xref:Guides.DI.Intro 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/SlashCommands/SlashCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs index bf22d4e3a..579289304 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; @@ -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/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/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/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..a4554eaef --- /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 string[] { 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 string[] { 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/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..eb1737c6f 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; @@ -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 8195a2cea..c4e3764d1 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs @@ -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..de1ef3149 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,12 +142,16 @@ 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() }; + Console.WriteLine("Locales:" + string.Join(",", arg.NameLocalizations.Keys)); + if (arg is SlashCommandProperties slashProps) { Preconditions.NotNullOrEmpty(slashProps.Description, nameof(slashProps.Description)); @@ -181,6 +187,8 @@ namespace Discord.Rest DefaultPermission = arg.IsDefaultPermission.IsSpecified ? arg.IsDefaultPermission.Value : Optional.Unspecified, + NameLocalizations = arg.NameLocalizations?.ToDictionary(), + DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary(), // TODO: better conversion to nullable optionals DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(), @@ -244,7 +252,9 @@ namespace Discord.Rest Name = args.Name, DefaultPermission = args.IsDefaultPermission.IsSpecified ? args.IsDefaultPermission.Value - : Optional.Unspecified + : Optional.Unspecified, + NameLocalizations = args.NameLocalizations?.ToDictionary(), + DescriptionLocalizations = args.DescriptionLocalizations?.ToDictionary() }; if (args is SlashCommandProperties slashProps) @@ -299,6 +309,8 @@ namespace Discord.Rest DefaultPermission = arg.IsDefaultPermission.IsSpecified ? arg.IsDefaultPermission.Value : Optional.Unspecified, + NameLocalizations = arg.NameLocalizations?.ToDictionary(), + DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary(), // TODO: better conversion to nullable optionals DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(), @@ -335,7 +347,9 @@ namespace Discord.Rest Name = arg.Name, DefaultPermission = arg.IsDefaultPermission.IsSpecified ? arg.IsDefaultPermission.Value - : Optional.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/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/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; From adf012d1dd620ed2e4d78a7e6700bbce69a64d38 Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Sun, 28 Aug 2022 12:04:08 -0300 Subject: [PATCH 10/32] meta: 3.8.0 (#2441) --- CHANGELOG.md | 38 ++++++++++++++++++ Discord.Net.targets | 2 +- docs/docfx.json | 2 +- src/Discord.Net/Discord.Net.nuspec | 62 +++++++++++++++--------------- 4 files changed, 71 insertions(+), 33 deletions(-) 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/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 - - - - - - + + + + + + - - - - - - + + + + + + - - - - - - + + + + + + - - - - - - + + + + + + - - - - - - + + + + + + From 0aa381d4683694ab85e300337aef4be42a171e33 Mon Sep 17 00:00:00 2001 From: Kuba_Z2 <77853483+KubaZ2@users.noreply.github.com> Date: Mon, 29 Aug 2022 11:24:32 +0200 Subject: [PATCH 11/32] Fix typos of word `length` (#2443) --- .../SlashCommands/SlashCommandBuilder.cs | 6 +++--- .../Attributes/MaxLengthAttribute.cs | 6 +++--- .../Attributes/MinLengthAttribute.cs | 6 +++--- .../Modals/Inputs/TextInputComponentBuilder.cs | 12 ++++++------ 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs index 579289304..b443c4468 100644 --- a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs @@ -855,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; } 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; } From 9feb703a82d6bd3c15118237c260320c8e873269 Mon Sep 17 00:00:00 2001 From: Viktor Chernikov <96922685+Polybroo@users.noreply.github.com> Date: Mon, 29 Aug 2022 11:25:13 +0200 Subject: [PATCH 12/32] Wrong symbol fix (#2438) --- src/Discord.Net.Rest/DiscordRestApiClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord.Net.Rest/DiscordRestApiClient.cs b/src/Discord.Net.Rest/DiscordRestApiClient.cs index eb1737c6f..615e5ac12 100644 --- a/src/Discord.Net.Rest/DiscordRestApiClient.cs +++ b/src/Discord.Net.Rest/DiscordRestApiClient.cs @@ -862,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. From fbc5ad414f87b2d4678b697f05ed06d70d8b9b16 Mon Sep 17 00:00:00 2001 From: Cenk Ergen <57065323+Cenngo@users.noreply.github.com> Date: Mon, 29 Aug 2022 15:24:33 +0300 Subject: [PATCH 13/32] fix BulkOverwriteCommands NRE (#2444) --- src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs b/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs index de1ef3149..deca00b72 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs @@ -150,8 +150,6 @@ namespace Discord.Rest DmPermission = arg.IsDMEnabled.ToNullable() }; - Console.WriteLine("Locales:" + string.Join(",", arg.NameLocalizations.Keys)); - if (arg is SlashCommandProperties slashProps) { Preconditions.NotNullOrEmpty(slashProps.Description, nameof(slashProps.Description)); From 370bdfa3c630152bfcd851c918f5d49ab6a3116d Mon Sep 17 00:00:00 2001 From: Armano den Boef <68127614+Rozen4334@users.noreply.github.com> Date: Thu, 1 Sep 2022 20:48:00 +0200 Subject: [PATCH 14/32] Bump to Discord API v10 (#2448) * Bump, add messagecontent intent * Update comments Co-authored-by: Rozen --- src/Discord.Net.Core/DiscordConfig.cs | 2 +- src/Discord.Net.Core/Entities/Messages/IMessage.cs | 6 ++++++ src/Discord.Net.Core/GatewayIntents.cs | 11 +++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) 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/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/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 } } From 376a812b6a3b435566a2abbb011968dafb1db3d5 Mon Sep 17 00:00:00 2001 From: Damian Kraaijeveld Date: Fri, 2 Sep 2022 23:04:53 +0200 Subject: [PATCH 15/32] Return a list instead of an array (#2451) --- src/Discord.Net.Interactions/Utilities/CommandHierarchy.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Discord.Net.Interactions/Utilities/CommandHierarchy.cs b/src/Discord.Net.Interactions/Utilities/CommandHierarchy.cs index a4554eaef..da7ef22e0 100644 --- a/src/Discord.Net.Interactions/Utilities/CommandHierarchy.cs +++ b/src/Discord.Net.Interactions/Utilities/CommandHierarchy.cs @@ -26,7 +26,7 @@ namespace Discord.Interactions public static IList GetCommandPath(this ICommandInfo commandInfo) { if (commandInfo.IgnoreGroupNames) - return new string[] { commandInfo.Name }; + return new List { commandInfo.Name }; var path = commandInfo.Module.GetModulePath(); path.Add(commandInfo.Name); @@ -48,6 +48,6 @@ namespace Discord.Interactions } public static IList GetTypePath(Type type) => - new string[] { EscapeChar + type.FullName }; + new List { EscapeChar + type.FullName }; } } From b967e6907c3ff204705bd60e50f7e31d6071cc95 Mon Sep 17 00:00:00 2001 From: Discord-NET-Robot <95661365+Discord-NET-Robot@users.noreply.github.com> Date: Fri, 2 Sep 2022 18:14:16 -0300 Subject: [PATCH 16/32] [Robot] Add missing json error (#2447) * Add 20024, 30032, 30034, 30052, 40012, 40043, 40058, 40066, 40067, 50017, 50132, 50138, 50146, 110001, 200000, 200001, 220001, 220002, 220003, 220004, 240000 Error codes * Update src/Discord.Net.Core/DiscordErrorCode.cs * Apply suggestions from code review Co-authored-by: Discord.Net Robot Co-authored-by: Quin Lynch <49576606+quinchs@users.noreply.github.com> --- src/Discord.Net.Core/DiscordErrorCode.cs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) 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 } } From fca9c6b618715baaeeb5602d70fb8acfd91f6fe3 Mon Sep 17 00:00:00 2001 From: Julian <54598714+akaJuliaan@users.noreply.github.com> Date: Fri, 2 Sep 2022 23:31:51 +0200 Subject: [PATCH 17/32] Fix: remove Module from _typedModuleDefs (#2417) --- src/Discord.Net.Commands/CommandService.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Discord.Net.Commands/CommandService.cs b/src/Discord.Net.Commands/CommandService.cs index 57e0e430e..29bf6a428 100644 --- a/src/Discord.Net.Commands/CommandService.cs +++ b/src/Discord.Net.Commands/CommandService.cs @@ -270,6 +270,11 @@ namespace Discord.Commands await _moduleLock.WaitAsync().ConfigureAwait(false); try { + var typeModulePair = _typedModuleDefs.FirstOrDefault(x => x.Value.Equals(module)); + + if (!typeModulePair.Equals(default(KeyValuePair))) + _typedModuleDefs.TryRemove(typeModulePair.Key, out var _); + return RemoveModuleInternal(module); } finally From 3dec99f6dfb5902b823b6f1b101a7d7361b65efd Mon Sep 17 00:00:00 2001 From: SaculRennorb Date: Fri, 2 Sep 2022 23:56:19 +0200 Subject: [PATCH 18/32] adding scheduled events to audit log (#2437) * first draft * made changes be actually optional. not everything always changes * 'doc' text * more 'doc' stuff * more 'doc' stuff3 * 'doc' stuff --- .../Entities/AuditLogs/ActionType.cs | 14 ++ .../Entities/AuditLogs/AuditLogHelper.cs | 4 + .../ScheduledEventCreateAuditLogData.cs | 149 ++++++++++++++++++ .../ScheduledEventDeleteAuditLogData.cs | 34 ++++ .../AuditLogs/DataTypes/ScheduledEventInfo.cs | 80 ++++++++++ .../ScheduledEventUpdateAuditLogData.cs | 99 ++++++++++++ 6 files changed, 380 insertions(+) create mode 100644 src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventCreateAuditLogData.cs create mode 100644 src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventDeleteAuditLogData.cs create mode 100644 src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventInfo.cs create mode 100644 src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventUpdateAuditLogData.cs diff --git a/src/Discord.Net.Core/Entities/AuditLogs/ActionType.cs b/src/Discord.Net.Core/Entities/AuditLogs/ActionType.cs index 5092b4e7f..ad2d659ee 100644 --- a/src/Discord.Net.Core/Entities/AuditLogs/ActionType.cs +++ b/src/Discord.Net.Core/Entities/AuditLogs/ActionType.cs @@ -180,6 +180,20 @@ namespace Discord /// A sticker was deleted. /// StickerDeleted = 92, + + /// + /// A scheduled event was created. + /// + EventCreate = 100, + /// + /// A scheduled event was created. + /// + EventUpdate = 101, + /// + /// A scheduled event was created. + /// + EventDelete = 102, + /// /// A thread was created. /// diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs b/src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs index edbb2bea8..f071fc1f9 100644 --- a/src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs +++ b/src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs @@ -52,6 +52,10 @@ namespace Discord.Rest [ActionType.MessagePinned] = MessagePinAuditLogData.Create, [ActionType.MessageUnpinned] = MessageUnpinAuditLogData.Create, + [ActionType.EventCreate] = ScheduledEventCreateAuditLogData.Create, + [ActionType.EventUpdate] = ScheduledEventUpdateAuditLogData.Create, + [ActionType.EventDelete] = ScheduledEventDeleteAuditLogData.Create, + [ActionType.ThreadCreate] = ThreadCreateAuditLogData.Create, [ActionType.ThreadUpdate] = ThreadUpdateAuditLogData.Create, [ActionType.ThreadDelete] = ThreadDeleteAuditLogData.Create, diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventCreateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventCreateAuditLogData.cs new file mode 100644 index 000000000..11faa3371 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventCreateAuditLogData.cs @@ -0,0 +1,149 @@ +using System; +using System.Linq; +using Discord.API; + +using Model = Discord.API.AuditLog; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.Rest +{ + /// + /// Contains a piece of audit log data related to a scheduled event creation. + /// + public class ScheduledEventCreateAuditLogData : IAuditLogData + { + private ScheduledEventCreateAuditLogData(ulong id, ulong guildId, ulong? channelId, ulong? creatorId, string name, string description, DateTimeOffset scheduledStartTime, DateTimeOffset? scheduledEndTime, GuildScheduledEventPrivacyLevel privacyLevel, GuildScheduledEventStatus status, GuildScheduledEventType entityType, ulong? entityId, string location, RestUser creator, int userCount, string image) + { + Id = id ; + GuildId = guildId ; + ChannelId = channelId ; + CreatorId = creatorId ; + Name = name ; + Description = description ; + ScheduledStartTime = scheduledStartTime; + ScheduledEndTime = scheduledEndTime ; + PrivacyLevel = privacyLevel ; + Status = status ; + EntityType = entityType ; + EntityId = entityId ; + Location = location ; + Creator = creator ; + UserCount = userCount ; + Image = image ; + } + + internal static ScheduledEventCreateAuditLogData Create(BaseDiscordClient discord, Model log, EntryModel entry) + { + var changes = entry.Changes; + + var id = entry.TargetId.Value; + + var guildId = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "guild_id") + .NewValue.ToObject(discord.ApiClient.Serializer); + var channelId = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "channel_id") + .NewValue.ToObject(discord.ApiClient.Serializer); + var creatorId = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "channel_id") + .NewValue.ToObject>(discord.ApiClient.Serializer) + .GetValueOrDefault(); + var name = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "name") + .NewValue.ToObject(discord.ApiClient.Serializer); + var description = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "description") + .NewValue.ToObject>(discord.ApiClient.Serializer) + .GetValueOrDefault(); + var scheduledStartTime = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "scheduled_start_time") + .NewValue.ToObject(discord.ApiClient.Serializer); + var scheduledEndTime = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "scheduled_end_time") + .NewValue.ToObject(discord.ApiClient.Serializer); + var privacyLevel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "privacy_level") + .NewValue.ToObject(discord.ApiClient.Serializer); + var status = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "status") + .NewValue.ToObject(discord.ApiClient.Serializer); + var entityType = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "entity_type") + .NewValue.ToObject(discord.ApiClient.Serializer); + var entityId = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "entity_id") + .NewValue.ToObject(discord.ApiClient.Serializer); + var entityMetadata = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "entity_metadata") + .NewValue.ToObject(discord.ApiClient.Serializer); + var creator = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "creator") + .NewValue.ToObject>(discord.ApiClient.Serializer) + .GetValueOrDefault(); + var userCount = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "user_count") + .NewValue.ToObject>(discord.ApiClient.Serializer) + .GetValueOrDefault(); + var image = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "image") + .NewValue.ToObject>(discord.ApiClient.Serializer) + .GetValueOrDefault(); + + var creatorUser = creator == null ? null : RestUser.Create(discord, creator); + + return new ScheduledEventCreateAuditLogData(id, guildId, channelId, creatorId, name, description, scheduledStartTime, scheduledEndTime, privacyLevel, status, entityType, entityId, entityMetadata.Location.GetValueOrDefault(), creatorUser, userCount, image); + } + + // Doc Note: Corresponds to the *current* data + + /// + /// Gets the snowflake id of the event. + /// + public ulong Id { get; } + /// + /// Gets the snowflake id of the guild the event is associated with. + /// + public ulong GuildId { get; } + /// + /// Gets the snowflake id of the channel the event is associated with. + /// + public ulong? ChannelId { get; } + /// + /// Gets the snowflake id of the original creator of the event. + /// + public ulong? CreatorId { get; } + /// + /// Gets name of the event. + /// + public string Name { get; } + /// + /// Gets the description of the event. null if none is set. + /// + public string Description { get; } + /// + /// Gets the time the event was scheduled for. + /// + public DateTimeOffset ScheduledStartTime { get; } + /// + /// Gets the time the event was scheduled to end. + /// + public DateTimeOffset? ScheduledEndTime { get; } + /// + /// Gets the privacy level of the event. + /// + public GuildScheduledEventPrivacyLevel PrivacyLevel { get; } + /// + /// Gets the status of the event. + /// + public GuildScheduledEventStatus Status { get; } + /// + /// Gets the type of the entity associated with the event (stage / void / external). + /// + public GuildScheduledEventType EntityType { get; } + /// + /// Gets the snowflake id of the entity associated with the event (stage / void / external). + /// + public ulong? EntityId { get; } + /// + /// Gets the metadata for the entity associated with the event. + /// + public string Location { get; } + /// + /// Gets the user that originally created the event. + /// + public RestUser Creator { get; } + /// + /// Gets the count of users interested in this event. + /// + public int UserCount { get; } + /// + /// Gets the image hash of the image that was attached to the event. Null if not set. + /// + public string Image { get; } + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventDeleteAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventDeleteAuditLogData.cs new file mode 100644 index 000000000..34fa96225 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventDeleteAuditLogData.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq; +using Discord.API; + +using Model = Discord.API.AuditLog; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.Rest +{ + /// + /// Contains a piece of audit log data related to a scheduled event deleteion. + /// + public class ScheduledEventDeleteAuditLogData : IAuditLogData + { + private ScheduledEventDeleteAuditLogData(ulong id) + { + Id = id; + } + + internal static ScheduledEventDeleteAuditLogData Create(BaseDiscordClient discord, Model log, EntryModel entry) + { + var id = entry.TargetId.Value; + + return new ScheduledEventDeleteAuditLogData(id); + } + + // Doc Note: Corresponds to the *current* data + + /// + /// Gets the snowflake id of the event. + /// + public ulong Id { get; } + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventInfo.cs new file mode 100644 index 000000000..a45956546 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventInfo.cs @@ -0,0 +1,80 @@ +using System; + +namespace Discord.Rest +{ + /// + /// Represents information for a scheduled event. + /// + public class ScheduledEventInfo + { + /// + /// Gets the snowflake id of the guild the event is associated with. + /// + public ulong? GuildId { get; } + /// + /// Gets the snowflake id of the channel the event is associated with. + /// + public ulong? ChannelId { get; } + /// + /// Gets name of the event. + /// + public string Name { get; } + /// + /// Gets the description of the event. null if none is set. + /// + public string Description { get; } + /// + /// Gets the time the event was scheduled for. + /// + public DateTimeOffset? ScheduledStartTime { get; } + /// + /// Gets the time the event was scheduled to end. + /// + public DateTimeOffset? ScheduledEndTime { get; } + /// + /// Gets the privacy level of the event. + /// + public GuildScheduledEventPrivacyLevel? PrivacyLevel { get; } + /// + /// Gets the status of the event. + /// + public GuildScheduledEventStatus? Status { get; } + /// + /// Gets the type of the entity associated with the event (stage / void / external). + /// + public GuildScheduledEventType? EntityType { get; } + /// + /// Gets the snowflake id of the entity associated with the event (stage / void / external). + /// + public ulong? EntityId { get; } + /// + /// Gets the metadata for the entity associated with the event. + /// + public string Location { get; } + /// + /// Gets the count of users interested in this event. + /// + public int? UserCount { get; } + /// + /// Gets the image hash of the image that was attached to the event. Null if not set. + /// + public string Image { get; } + + internal ScheduledEventInfo(ulong? guildId, ulong? channelId, string name, string description, DateTimeOffset? scheduledStartTime, DateTimeOffset? scheduledEndTime, GuildScheduledEventPrivacyLevel? privacyLevel, GuildScheduledEventStatus? status, GuildScheduledEventType? entityType, ulong? entityId, string location, int? userCount, string image) + { + GuildId = guildId ; + ChannelId = channelId ; + Name = name ; + Description = description ; + ScheduledStartTime = scheduledStartTime; + ScheduledEndTime = scheduledEndTime ; + PrivacyLevel = privacyLevel ; + Status = status ; + EntityType = entityType ; + EntityId = entityId ; + Location = location ; + UserCount = userCount ; + Image = image ; + } + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventUpdateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventUpdateAuditLogData.cs new file mode 100644 index 000000000..2ef2ccff8 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventUpdateAuditLogData.cs @@ -0,0 +1,99 @@ +using System; +using System.Linq; +using Discord.API; + +using Model = Discord.API.AuditLog; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.Rest +{ + /// + /// Contains a piece of audit log data related to a scheduled event updates. + /// + public class ScheduledEventUpdateAuditLogData : IAuditLogData + { + private ScheduledEventUpdateAuditLogData(ulong id, ScheduledEventInfo before, ScheduledEventInfo after) + { + Id = id; + Before = before; + After = after; + } + + internal static ScheduledEventUpdateAuditLogData Create(BaseDiscordClient discord, Model log, EntryModel entry) + { + var changes = entry.Changes; + + var id = entry.TargetId.Value; + + var guildId = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "guild_id"); + var channelId = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "channel_id"); + var name = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "name"); + var description = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "description"); + var scheduledStartTime = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "scheduled_start_time"); + var scheduledEndTime = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "scheduled_end_time"); + var privacyLevel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "privacy_level"); + var status = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "status"); + var entityType = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "entity_type"); + var entityId = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "entity_id"); + var entityMetadata = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "entity_metadata"); + var userCount = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "user_count"); + var image = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "image"); + + var before = new ScheduledEventInfo( + guildId?.OldValue.ToObject(discord.ApiClient.Serializer), + channelId?.OldValue.ToObject(discord.ApiClient.Serializer), + name?.OldValue.ToObject(discord.ApiClient.Serializer), + description?.OldValue.ToObject>(discord.ApiClient.Serializer) + .GetValueOrDefault(), + scheduledStartTime?.OldValue.ToObject(discord.ApiClient.Serializer), + scheduledEndTime?.OldValue.ToObject(discord.ApiClient.Serializer), + privacyLevel?.OldValue.ToObject(discord.ApiClient.Serializer), + status?.OldValue.ToObject(discord.ApiClient.Serializer), + entityType?.OldValue.ToObject(discord.ApiClient.Serializer), + entityId?.OldValue.ToObject(discord.ApiClient.Serializer), + entityMetadata?.OldValue.ToObject(discord.ApiClient.Serializer) + ?.Location.GetValueOrDefault(), + userCount?.OldValue.ToObject>(discord.ApiClient.Serializer) + .GetValueOrDefault(), + image?.OldValue.ToObject>(discord.ApiClient.Serializer) + .GetValueOrDefault() + ); + var after = new ScheduledEventInfo( + guildId?.NewValue.ToObject(discord.ApiClient.Serializer), + channelId?.NewValue.ToObject(discord.ApiClient.Serializer), + name?.NewValue.ToObject(discord.ApiClient.Serializer), + description?.NewValue.ToObject>(discord.ApiClient.Serializer) + .GetValueOrDefault(), + scheduledStartTime?.NewValue.ToObject(discord.ApiClient.Serializer), + scheduledEndTime?.NewValue.ToObject(discord.ApiClient.Serializer), + privacyLevel?.NewValue.ToObject(discord.ApiClient.Serializer), + status?.NewValue.ToObject(discord.ApiClient.Serializer), + entityType?.NewValue.ToObject(discord.ApiClient.Serializer), + entityId?.NewValue.ToObject(discord.ApiClient.Serializer), + entityMetadata?.NewValue.ToObject(discord.ApiClient.Serializer) + ?.Location.GetValueOrDefault(), + userCount?.NewValue.ToObject>(discord.ApiClient.Serializer) + .GetValueOrDefault(), + image?.NewValue.ToObject>(discord.ApiClient.Serializer) + .GetValueOrDefault() + ); + + return new ScheduledEventUpdateAuditLogData(id, before, after); + } + + // Doc Note: Corresponds to the *current* data + + /// + /// Gets the snowflake id of the event. + /// + public ulong Id { get; } + /// + /// Gets the state before the change. + /// + public ScheduledEventInfo Before { get; } + /// + /// Gets the state after the change. + /// + public ScheduledEventInfo After { get; } + } +} From 11ece4bf169dd91cd13f49d2ce2f27b0c14bb6d6 Mon Sep 17 00:00:00 2001 From: d4n Date: Fri, 2 Sep 2022 21:47:13 -0500 Subject: [PATCH 19/32] Update app commands regex and fix localization on app context commands (#2452) Co-authored-by: Quin Lynch <49576606+quinchs@users.noreply.github.com> --- .../Interactions/ApplicationCommandOption.cs | 6 ++-- .../ApplicationCommandProperties.cs | 5 ++-- .../ContextMenus/MessageCommandBuilder.cs | 11 ++----- .../ContextMenus/UserCommandBuilder.cs | 11 ++----- .../SlashCommands/SlashCommandBuilder.cs | 29 +++++++++---------- 5 files changed, 24 insertions(+), 38 deletions(-) diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs index bceefda32..df33cfe1d 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs @@ -145,10 +145,10 @@ namespace Discord 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 (!Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$")) + throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name)); - if (name.Any(x => char.IsUpper(x))) + if (name.Any(char.IsUpper)) throw new FormatException("Name cannot contain any uppercase characters."); } diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandProperties.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandProperties.cs index 7ca16a27d..98e050df9 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandProperties.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandProperties.cs @@ -42,8 +42,9 @@ namespace Discord 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)); + + if (Type == ApplicationCommandType.Slash && !Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$")) + throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name)); } _nameLocalizations = value; } diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs index ed49c685d..613e30376 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs @@ -67,7 +67,8 @@ namespace Discord Name = Name, IsDefaultPermission = IsDefaultPermission, IsDMEnabled = IsDMEnabled, - DefaultMemberPermissions = DefaultMemberPermissions ?? Optional.Unspecified + DefaultMemberPermissions = DefaultMemberPermissions ?? Optional.Unspecified, + NameLocalizations = NameLocalizations }; return props; @@ -157,14 +158,6 @@ namespace Discord 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."); } /// diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs index d8bb2e056..8ac524582 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs @@ -65,7 +65,8 @@ namespace Discord Name = Name, IsDefaultPermission = IsDefaultPermission, IsDMEnabled = IsDMEnabled, - DefaultMemberPermissions = DefaultMemberPermissions ?? Optional.Unspecified + DefaultMemberPermissions = DefaultMemberPermissions ?? Optional.Unspecified, + NameLocalizations = NameLocalizations }; return props; @@ -155,14 +156,6 @@ namespace Discord 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."); } /// diff --git a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs index b443c4468..1df886abe 100644 --- a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs @@ -207,10 +207,9 @@ namespace Discord { Preconditions.Options(name, description); - // Discord updated the docs, this regex prevents special characters like @!$%( and s p a c e s.. etc, - // https://discord.com/developers/docs/interactions/slash-commands#applicationcommand - if (!Regex.IsMatch(name, @"^[\w-]{1,32}$")) - throw new ArgumentException("Command name cannot contain any special characters or whitespaces!", nameof(name)); + // https://discord.com/developers/docs/interactions/application-commands + if (!Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$")) + throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name)); // make sure theres only one option with default set to true if (isDefault == true && Options?.Any(x => x.IsDefault == true) == true) @@ -376,12 +375,11 @@ namespace Discord 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)); + // https://discord.com/developers/docs/interactions/application-commands + if (!Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$")) + throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name)); - if (name.Any(x => char.IsUpper(x))) + if (name.Any(char.IsUpper)) throw new FormatException("Name cannot contain any uppercase characters."); } @@ -587,10 +585,9 @@ namespace Discord { Preconditions.Options(name, description); - // Discord updated the docs, this regex prevents special characters like @!$%( and s p a c e s.. etc, - // https://discord.com/developers/docs/interactions/slash-commands#applicationcommand - if (!Regex.IsMatch(name, @"^[\w-]{1,32}$")) - throw new ArgumentException("Command name cannot contain any special characters or whitespaces!", nameof(name)); + // https://discord.com/developers/docs/interactions/application-commands + if (!Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$")) + throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name)); // make sure theres only one option with default set to true if (isDefault && Options?.Any(x => x.IsDefault == true) == true) @@ -966,8 +963,10 @@ namespace Discord { 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)); + + // https://discord.com/developers/docs/interactions/application-commands + if (!Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$")) + throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name)); } private static void EnsureValidCommandOptionDescription(string description) From 2b86a79f7008c4f4fef49cec144c95d5e3bde23f Mon Sep 17 00:00:00 2001 From: Proddy Date: Sun, 4 Sep 2022 07:46:50 +0100 Subject: [PATCH 20/32] Fix a bug in EmbedBuilder.Length when there is an EmbedField with no Value (#2345) * Update EmbedBuilder.cs Fixes a bug where 'EmbedBuilder.Length' will throw an exception of type 'System.NullReferenceException' when a field doesn't have a value. * Update EmbedBuilder.cs Fixed an incorrect assuption that `Value` was a `string?` * Update EmbedBuilder.cs Fixed one more null check * Update EmbedBuilder.cs Co-authored-by: Quin Lynch <49576606+quinchs@users.noreply.github.com> --- src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs b/src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs index db38b9fb7..9b2a6adb9 100644 --- a/src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs +++ b/src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs @@ -150,7 +150,7 @@ namespace Discord int authorLength = Author?.Name?.Length ?? 0; int descriptionLength = Description?.Length ?? 0; int footerLength = Footer?.Text?.Length ?? 0; - int fieldSum = Fields.Sum(f => f.Name.Length + f.Value.ToString().Length); + int fieldSum = Fields.Sum(f => f.Name.Length + (f.Value?.ToString()?.Length ?? 0)); return titleLength + authorLength + descriptionLength + footerLength + fieldSum; } From 5073afa3168b4aa31a7427e38bf698b5f45b0525 Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Sat, 3 Sep 2022 23:50:29 -0700 Subject: [PATCH 21/32] Update PackageLogo.png (#2333) --- docs/marketing/logo/PackageLogo.png | Bin 11639 -> 77377 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/marketing/logo/PackageLogo.png b/docs/marketing/logo/PackageLogo.png index 047d6ad64f279e7475a6b031d915f52c1143bc6b..6311e6eca797624c3af69862c80f8ab27faaee8a 100644 GIT binary patch literal 77377 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 From 4834b27f9021bfa75524ac889acb1e2948a604f6 Mon Sep 17 00:00:00 2001 From: essoperagma Date: Fri, 9 Sep 2022 10:20:19 +0000 Subject: [PATCH 22/32] Null check slash command localizations (#2453) --- .../Interactions/ApplicationCommandOption.cs | 24 ++++++++---- .../ApplicationCommandOptionChoice.cs | 23 +++++++----- .../ApplicationCommandProperties.cs | 32 ++++++++++------ .../SlashCommands/SlashCommandBuilder.cs | 2 +- .../CommandBuilderTests.cs | 37 +++++++++++++++++++ 5 files changed, 87 insertions(+), 31 deletions(-) create mode 100644 test/Discord.Net.Tests.Unit/CommandBuilderTests.cs diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs index df33cfe1d..17e836e21 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOption.cs @@ -106,13 +106,17 @@ namespace Discord get => _nameLocalizations; set { - foreach (var (locale, name) in value) + if (value != null) { - if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) - throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + foreach (var (locale, name) in value) + { + if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); - EnsureValidOptionName(name); + EnsureValidOptionName(name); + } } + _nameLocalizations = value; } } @@ -126,13 +130,17 @@ namespace Discord get => _descriptionLocalizations; set { - foreach (var (locale, description) in value) + if (value != null) { - if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) - throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + foreach (var (locale, description) in value) + { + if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); - EnsureValidOptionDescription(description); + EnsureValidOptionDescription(description); + } } + _descriptionLocalizations = value; } } diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOptionChoice.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOptionChoice.cs index 8f1ecc6d2..2289b412d 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOptionChoice.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOptionChoice.cs @@ -55,18 +55,21 @@ namespace Discord get => _nameLocalizations; set { - foreach (var (locale, name) in value) + if (value != null) { - if (!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) - throw new ArgumentException("Key values of the dictionary must be valid language codes."); - - switch (name.Length) + foreach (var (locale, name) in value) { - 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."); + 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."); + } } } diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandProperties.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandProperties.cs index 98e050df9..0c1c628cd 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandProperties.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandProperties.cs @@ -35,17 +35,21 @@ namespace Discord get => _nameLocalizations; set { - foreach (var (locale, name) in value) + if (value != null) { - if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) - throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + 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)); + Preconditions.AtLeast(name.Length, 1, nameof(name)); + Preconditions.AtMost(name.Length, SlashCommandBuilder.MaxNameLength, nameof(name)); - if (Type == ApplicationCommandType.Slash && !Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$")) - throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name)); + if (Type == ApplicationCommandType.Slash && !Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$")) + throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name)); + } } + _nameLocalizations = value; } } @@ -58,14 +62,18 @@ namespace Discord get => _descriptionLocalizations; set { - foreach (var (locale, description) in value) + if (value != null) { - if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) - throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + 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)); + Preconditions.AtLeast(description.Length, 1, nameof(description)); + Preconditions.AtMost(description.Length, SlashCommandBuilder.MaxDescriptionLength, nameof(description)); + } } + _descriptionLocalizations = value; } } diff --git a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs index 1df886abe..94f5956dd 100644 --- a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs @@ -907,7 +907,7 @@ namespace Discord if (descriptionLocalizations is null) throw new ArgumentNullException(nameof(descriptionLocalizations)); - foreach (var (locale, description) in _descriptionLocalizations) + foreach (var (locale, description) in descriptionLocalizations) { if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); diff --git a/test/Discord.Net.Tests.Unit/CommandBuilderTests.cs b/test/Discord.Net.Tests.Unit/CommandBuilderTests.cs new file mode 100644 index 000000000..e122f9cdd --- /dev/null +++ b/test/Discord.Net.Tests.Unit/CommandBuilderTests.cs @@ -0,0 +1,37 @@ +using System; +using Discord; +using Xunit; + +namespace Discord; + +public class CommandBuilderTests +{ + [Fact] + public void BuildSimpleSlashCommand() + { + var command = new SlashCommandBuilder() + .WithName("command") + .WithDescription("description") + .AddOption( + "option1", + ApplicationCommandOptionType.String, + "option1 description", + isRequired: true, + choices: new [] + { + new ApplicationCommandOptionChoiceProperties() + { + Name = "choice1", Value = "1" + } + }) + .AddOptions(new SlashCommandOptionBuilder() + .WithName("option2") + .WithDescription("option2 description") + .WithType(ApplicationCommandOptionType.String) + .WithRequired(true) + .AddChannelType(ChannelType.Text) + .AddChoice("choice1", "1") + .AddChoice("choice2", "2")); + command.Build(); + } +} From 525dd6048a5cd9232d87aadd5d3d36bcebd7668c Mon Sep 17 00:00:00 2001 From: Nikolay <59023595+kolya112@users.noreply.github.com> Date: Sat, 10 Sep 2022 11:03:14 +0300 Subject: [PATCH 23/32] [Docs] Context menu of slash commands supported on mobile (#2459) --- docs/guides/int_basics/application-commands/intro.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/guides/int_basics/application-commands/intro.md b/docs/guides/int_basics/application-commands/intro.md index f55d0a2fc..a59aca8f2 100644 --- a/docs/guides/int_basics/application-commands/intro.md +++ b/docs/guides/int_basics/application-commands/intro.md @@ -18,9 +18,6 @@ The name and description help users find your command among many others, and the Message and User commands are only a name, to the user. So try to make the name descriptive. They're accessed by right clicking (or long press, on mobile) a user or a message, respectively. -> [!IMPORTANT] -> Context menu commands are currently not supported on mobile. - All three varieties of application commands have both Global and Guild variants. Your global commands are available in every guild that adds your application. You can also make commands for a specific guild; they're only available in that guild. From 1b01fed86767ead46ef2fc74b44deaef8a49effd Mon Sep 17 00:00:00 2001 From: Discord-NET-Robot <95661365+Discord-NET-Robot@users.noreply.github.com> Date: Sat, 10 Sep 2022 05:08:22 -0300 Subject: [PATCH 24/32] Add 50138 Error code (#2456) Co-authored-by: Discord.Net Robot Co-authored-by: Quin Lynch <49576606+quinchs@users.noreply.github.com> --- src/Discord.Net.Core/DiscordErrorCode.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Discord.Net.Core/DiscordErrorCode.cs b/src/Discord.Net.Core/DiscordErrorCode.cs index 262252eab..60b7d20d8 100644 --- a/src/Discord.Net.Core/DiscordErrorCode.cs +++ b/src/Discord.Net.Core/DiscordErrorCode.cs @@ -172,13 +172,14 @@ namespace Discord ServerRequiresMonetization = 50097, ServerRequiresBoosts = 50101, RequestBodyContainsInvalidJSON = 50109, - #endregion - - #region 2FA (60XXX) + FailedToResizeAssetBelowTheMaximumSize = 50138, OwnershipCannotBeTransferredToABotUser = 50132, AssetResizeBelowTheMaximumSize= 50138, UploadedFileNotFound = 50146, MissingPermissionToSendThisSticker = 50600, + #endregion + + #region 2FA (60XXX) Requires2FA = 60003, #endregion From d1678d1e8f607a0852e496d1db3ee509c279efbb Mon Sep 17 00:00:00 2001 From: Proddy Date: Sat, 10 Sep 2022 21:07:49 +0100 Subject: [PATCH 25/32] Fixing localizations (#2457) * Fixing localizations * Fixed typo in `SlashCommandOptionBuilder.WithDescriptionLocalizations` * Fixed typo in `SlashCommandOptionBuilder.AddNameLocalization` * Changed `Build` method of both `ApplicationCommandOptionProperties` and `SlashCommandProperties` to not set the `NameLocalizations` and `DescriptionLocalizations` if null in the builder. Was causing an error in the setter. * Update SlashCommandBuilder.cs Fixing a missing `;` * Update SlashCommandBuilder.cs * Fixing _another_ missing `;` * Update SlashCommandBuilder.cs * Fixed `SlashCommandOptionBuilder.AddChoiceInternal` to not pass null `NameLocalizations` * Update SlashCommandBuilder.cs * Fecking semi-colons * Update SlashCommandBuilder.cs --- .../Entities/Interactions/SlashCommands/SlashCommandBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs index 94f5956dd..03fb24c8b 100644 --- a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs @@ -933,7 +933,7 @@ namespace Discord EnsureValidCommandOptionName(name); - _descriptionLocalizations ??= new(); + _nameLocalizations ??= new(); _nameLocalizations.Add(locale, name); return this; From 2a6fca6653c496e1be63ba92c69730a5f196f66e Mon Sep 17 00:00:00 2001 From: Marten Date: Wed, 14 Sep 2022 17:08:00 +0200 Subject: [PATCH 26/32] [Docs] Text Commands: Added a warning about the `message content` intent (#2458) * Added a warning about the `message content` intent * Improved line length * Warning -> Important --- docs/guides/text_commands/intro.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/guides/text_commands/intro.md b/docs/guides/text_commands/intro.md index 1113b0821..429bfc3a0 100644 --- a/docs/guides/text_commands/intro.md +++ b/docs/guides/text_commands/intro.md @@ -8,6 +8,13 @@ title: Introduction to the Chat Command Service [Discord.Commands](xref:Discord.Commands) provides an attribute-based command parser. +> [!IMPORTANT] +> The 'Message Content' intent, required for text commands, is now a +> privilleged intent. Please use [Slash commands](xref:Guides.SlashCommands.Intro) +> instead for making commands. For more information about this change +> please check [this announcement made by discord](https://support-dev.discord.com/hc/en-us/articles/4404772028055-Message-Content-Privileged-Intent-FAQ) + + ## Get Started To use commands, you must create a [Command Service] and a command From d4c533aed075c7407261d213c4a20cf81ddc888d Mon Sep 17 00:00:00 2001 From: Quahu Date: Wed, 14 Sep 2022 17:08:25 +0200 Subject: [PATCH 27/32] Implemented resume_gateway_url. (#2423) * Implemented resume_gateway_url. * Made the requested changes. * Implemented passing the gateway URL down from DiscordShardedClient. Co-authored-by: Quin Lynch <49576606+quinchs@users.noreply.github.com> --- .../API/Gateway/ReadyEvent.cs | 2 + .../DiscordShardedClient.cs | 12 +++- .../DiscordSocketApiClient.cs | 60 ++++++++++++++++--- .../DiscordSocketClient.cs | 7 ++- 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/Discord.Net.WebSocket/API/Gateway/ReadyEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/ReadyEvent.cs index e0b5fc0b5..fb6670a90 100644 --- a/src/Discord.Net.WebSocket/API/Gateway/ReadyEvent.cs +++ b/src/Discord.Net.WebSocket/API/Gateway/ReadyEvent.cs @@ -20,6 +20,8 @@ namespace Discord.API.Gateway public User User { get; set; } [JsonProperty("session_id")] public string SessionId { get; set; } + [JsonProperty("resume_gateway_url")] + public string ResumeGatewayUrl { get; set; } [JsonProperty("read_state")] public ReadState[] ReadStates { get; set; } [JsonProperty("guilds")] diff --git a/src/Discord.Net.WebSocket/DiscordShardedClient.cs b/src/Discord.Net.WebSocket/DiscordShardedClient.cs index 9fc717762..dcee36736 100644 --- a/src/Discord.Net.WebSocket/DiscordShardedClient.cs +++ b/src/Discord.Net.WebSocket/DiscordShardedClient.cs @@ -139,9 +139,9 @@ namespace Discord.WebSocket internal override async Task OnLoginAsync(TokenType tokenType, string token) { + var botGateway = await GetBotGatewayAsync().ConfigureAwait(false); if (_automaticShards) { - var botGateway = await GetBotGatewayAsync().ConfigureAwait(false); _shardIds = Enumerable.Range(0, botGateway.Shards).ToArray(); _totalShards = _shardIds.Length; _shards = new DiscordSocketClient[_shardIds.Length]; @@ -163,7 +163,12 @@ namespace Discord.WebSocket //Assume thread safe: already in a connection lock for (int i = 0; i < _shards.Length; i++) + { + // Set the gateway URL to the one returned by Discord, if a custom one isn't set. + _shards[i].ApiClient.GatewayUrl = botGateway.Url; + await _shards[i].LoginAsync(tokenType, token); + } if(_defaultStickers.Length == 0 && _baseConfig.AlwaysDownloadDefaultStickers) await DownloadDefaultStickersAsync().ConfigureAwait(false); @@ -175,7 +180,12 @@ namespace Discord.WebSocket if (_shards != null) { for (int i = 0; i < _shards.Length; i++) + { + // Reset the gateway URL set for the shard. + _shards[i].ApiClient.GatewayUrl = null; + await _shards[i].LogoutAsync(); + } } if (_automaticShards) diff --git a/src/Discord.Net.WebSocket/DiscordSocketApiClient.cs b/src/Discord.Net.WebSocket/DiscordSocketApiClient.cs index cca2de203..465c47a1d 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketApiClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketApiClient.cs @@ -28,6 +28,7 @@ namespace Discord.API private readonly bool _isExplicitUrl; private CancellationTokenSource _connectCancelToken; private string _gatewayUrl; + private string _resumeGatewayUrl; //Store our decompression streams for zlib shared state private MemoryStream _compressed; @@ -37,6 +38,32 @@ namespace Discord.API public ConnectionState ConnectionState { get; private set; } + /// + /// Sets the gateway URL used for identifies. + /// + /// + /// If a custom URL is set, setting this property does nothing. + /// + public string GatewayUrl + { + set + { + // Makes the sharded client not override the custom value. + if (_isExplicitUrl) + return; + + _gatewayUrl = FormatGatewayUrl(value); + } + } + + /// + /// Sets the gateway URL used for resumes. + /// + public string ResumeGatewayUrl + { + set => _resumeGatewayUrl = FormatGatewayUrl(value); + } + public DiscordSocketApiClient(RestClientProvider restClientProvider, WebSocketProvider webSocketProvider, string userAgent, string url = null, RetryMode defaultRetryMode = RetryMode.AlwaysRetry, JsonSerializer serializer = null, bool useSystemClock = true, Func defaultRatelimitCallback = null) @@ -157,6 +184,17 @@ namespace Discord.API #endif } + /// + /// Appends necessary query parameters to the specified gateway URL. + /// + private static string FormatGatewayUrl(string gatewayUrl) + { + if (gatewayUrl == null) + return null; + + return $"{gatewayUrl}?v={DiscordConfig.APIVersion}&encoding={DiscordSocketConfig.GatewayEncoding}&compress=zlib-stream"; + } + public async Task ConnectAsync() { await _stateLock.WaitAsync().ConfigureAwait(false); @@ -191,24 +229,32 @@ namespace Discord.API if (WebSocketClient != null) WebSocketClient.SetCancelToken(_connectCancelToken.Token); - if (!_isExplicitUrl) + string gatewayUrl; + if (_resumeGatewayUrl == null) + { + if (!_isExplicitUrl && _gatewayUrl == null) + { + var gatewayResponse = await GetBotGatewayAsync().ConfigureAwait(false); + _gatewayUrl = FormatGatewayUrl(gatewayResponse.Url); + } + + gatewayUrl = _gatewayUrl; + } + else { - var gatewayResponse = await GetGatewayAsync().ConfigureAwait(false); - _gatewayUrl = $"{gatewayResponse.Url}?v={DiscordConfig.APIVersion}&encoding={DiscordSocketConfig.GatewayEncoding}&compress=zlib-stream"; + gatewayUrl = _resumeGatewayUrl; } #if DEBUG_PACKETS - Console.WriteLine("Connecting to gateway: " + _gatewayUrl); + Console.WriteLine("Connecting to gateway: " + gatewayUrl); #endif - await WebSocketClient.ConnectAsync(_gatewayUrl).ConfigureAwait(false); + await WebSocketClient.ConnectAsync(gatewayUrl).ConfigureAwait(false); ConnectionState = ConnectionState.Connected; } catch { - if (!_isExplicitUrl) - _gatewayUrl = null; //Uncache in case the gateway url changed await DisconnectInternalAsync().ConfigureAwait(false); throw; } diff --git a/src/Discord.Net.WebSocket/DiscordSocketClient.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.cs index 670ed4567..1cc35f761 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.cs @@ -322,7 +322,6 @@ namespace Discord.WebSocket } private async Task OnDisconnectingAsync(Exception ex) { - await _gatewayLogger.DebugAsync("Disconnecting ApiClient").ConfigureAwait(false); await ApiClient.DisconnectAsync(ex).ConfigureAwait(false); @@ -353,6 +352,10 @@ namespace Discord.WebSocket if (guild.IsAvailable) await GuildUnavailableAsync(guild).ConfigureAwait(false); } + + _sessionId = null; + _lastSeq = 0; + ApiClient.ResumeGatewayUrl = null; } /// @@ -834,6 +837,7 @@ namespace Discord.WebSocket _sessionId = null; _lastSeq = 0; + ApiClient.ResumeGatewayUrl = null; if (_shardedClient != null) { @@ -891,6 +895,7 @@ namespace Discord.WebSocket AddPrivateChannel(data.PrivateChannels[i], state); _sessionId = data.SessionId; + ApiClient.ResumeGatewayUrl = data.ResumeGatewayUrl; _unavailableGuildCount = unavailableGuilds; CurrentUser = currentUser; _previousSessionUser = CurrentUser; From b45b1526c0b3ded96c763117d27e08982081e907 Mon Sep 17 00:00:00 2001 From: Misha133 <61027276+Misha-133@users.noreply.github.com> Date: Wed, 14 Sep 2022 18:09:12 +0300 Subject: [PATCH 28/32] add addtional checks for gateway events logging (#2462) --- .../DiscordSocketClient.cs | 53 ++++++++++++++----- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/src/Discord.Net.WebSocket/DiscordSocketClient.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.cs index 1cc35f761..f0f99933e 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.cs @@ -746,31 +746,49 @@ namespace Discord.WebSocket private async Task LogGatewayIntentsWarning() { - if(_gatewayIntents.HasFlag(GatewayIntents.GuildPresences) && !_presenceUpdated.HasSubscribers) + if (_gatewayIntents.HasFlag(GatewayIntents.GuildPresences) && + (_shardedClient is null && !_presenceUpdated.HasSubscribers || + (_shardedClient is not null && !_shardedClient._presenceUpdated.HasSubscribers))) { await _gatewayLogger.WarningAsync("You're using the GuildPresences intent without listening to the PresenceUpdate event, consider removing the intent from your config.").ConfigureAwait(false); } - if(!_gatewayIntents.HasFlag(GatewayIntents.GuildPresences) && _presenceUpdated.HasSubscribers) + if(!_gatewayIntents.HasFlag(GatewayIntents.GuildPresences) && + ((_shardedClient is null && _presenceUpdated.HasSubscribers) || + (_shardedClient is not null && _shardedClient._presenceUpdated.HasSubscribers))) { await _gatewayLogger.WarningAsync("You're using the PresenceUpdate event without specifying the GuildPresences intent. Discord wont send this event to your client without the intent set in your config.").ConfigureAwait(false); } bool hasGuildScheduledEventsSubscribers = _guildScheduledEventCancelled.HasSubscribers || - _guildScheduledEventUserRemove.HasSubscribers || - _guildScheduledEventCompleted.HasSubscribers || - _guildScheduledEventCreated.HasSubscribers || - _guildScheduledEventStarted.HasSubscribers || - _guildScheduledEventUpdated.HasSubscribers || - _guildScheduledEventUserAdd.HasSubscribers; - - if(_gatewayIntents.HasFlag(GatewayIntents.GuildScheduledEvents) && !hasGuildScheduledEventsSubscribers) + _guildScheduledEventUserRemove.HasSubscribers || + _guildScheduledEventCompleted.HasSubscribers || + _guildScheduledEventCreated.HasSubscribers || + _guildScheduledEventStarted.HasSubscribers || + _guildScheduledEventUpdated.HasSubscribers || + _guildScheduledEventUserAdd.HasSubscribers; + + bool shardedClientHasGuildScheduledEventsSubscribers = + _shardedClient is not null && + (_shardedClient._guildScheduledEventCancelled.HasSubscribers || + _shardedClient._guildScheduledEventUserRemove.HasSubscribers || + _shardedClient._guildScheduledEventCompleted.HasSubscribers || + _shardedClient._guildScheduledEventCreated.HasSubscribers || + _shardedClient._guildScheduledEventStarted.HasSubscribers || + _shardedClient._guildScheduledEventUpdated.HasSubscribers || + _shardedClient._guildScheduledEventUserAdd.HasSubscribers); + + if (_gatewayIntents.HasFlag(GatewayIntents.GuildScheduledEvents) && + ((_shardedClient is null && !hasGuildScheduledEventsSubscribers) || + (_shardedClient is not null && !shardedClientHasGuildScheduledEventsSubscribers))) { await _gatewayLogger.WarningAsync("You're using the GuildScheduledEvents gateway intent without listening to any events related to that intent, consider removing the intent from your config.").ConfigureAwait(false); } - if(!_gatewayIntents.HasFlag(GatewayIntents.GuildScheduledEvents) && hasGuildScheduledEventsSubscribers) + if(!_gatewayIntents.HasFlag(GatewayIntents.GuildScheduledEvents) && + ((_shardedClient is null && hasGuildScheduledEventsSubscribers) || + (_shardedClient is not null && shardedClientHasGuildScheduledEventsSubscribers))) { await _gatewayLogger.WarningAsync("You're using events related to the GuildScheduledEvents gateway intent without specifying the intent. Discord wont send this event to your client without the intent set in your config.").ConfigureAwait(false); } @@ -779,12 +797,21 @@ namespace Discord.WebSocket _inviteCreatedEvent.HasSubscribers || _inviteDeletedEvent.HasSubscribers; - if (_gatewayIntents.HasFlag(GatewayIntents.GuildInvites) && !hasInviteEventSubscribers) + bool shardedClientHasInviteEventSubscribers = + _shardedClient is not null && + (_shardedClient._inviteCreatedEvent.HasSubscribers || + _shardedClient._inviteDeletedEvent.HasSubscribers); + + if (_gatewayIntents.HasFlag(GatewayIntents.GuildInvites) && + ((_shardedClient is null && !hasInviteEventSubscribers) || + (_shardedClient is not null && !shardedClientHasInviteEventSubscribers))) { await _gatewayLogger.WarningAsync("You're using the GuildInvites gateway intent without listening to any events related to that intent, consider removing the intent from your config.").ConfigureAwait(false); } - if (!_gatewayIntents.HasFlag(GatewayIntents.GuildInvites) && hasInviteEventSubscribers) + if (!_gatewayIntents.HasFlag(GatewayIntents.GuildInvites) && + ((_shardedClient is null && hasInviteEventSubscribers) || + (_shardedClient is not null && shardedClientHasInviteEventSubscribers))) { await _gatewayLogger.WarningAsync("You're using events related to the GuildInvites gateway intent without specifying the intent. Discord wont send this event to your client without the intent set in your config.").ConfigureAwait(false); } From 9c5f1ecb0d1ea436e0983c80ee86809bbb3c22d3 Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Wed, 14 Sep 2022 15:09:54 -0300 Subject: [PATCH 29/32] meta: 3.8.1 (#2464) --- CHANGELOG.md | 24 ++++++++++++ Discord.Net.targets | 2 +- docs/docfx.json | 2 +- src/Discord.Net/Discord.Net.nuspec | 62 +++++++++++++++--------------- 4 files changed, 57 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7385ab38a..9608ae4eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [3.8.1] - 2022-09-12 +### Added + +- #2437 Added scheduled event types to AuditLog ActionTypes (fca9c6b) +- #2423 Added support for resume gateway url (d4c533a) + +### Fixed + +- #2443 Fixed typos of word length (adf012d) +- #2438 Fixed http query symbol in ModifyWebhookMessageAsync (0aa381d) +- #2444 Fixed BulkOverwriteCommands NRE (9feb703) +- #2417 Fixed CommandService RemoveModuleMethod not removing modules (fca9c6b) +- #2345 Fixed EmbedBuilder.Length NRE (11ece4b) +- #2453 Fixed NRE on SlashCommandBuilder.Build method (5073afa) +- #2457 Fixed typo in SlashCommandBuilder.AddNameLocalizationMethod (1b01fed) + +### Misc + +- #2462 Add additional checks for gateway event warnings (b45b152) +- #2448 Bump to Discord API V10 (fbc5ad4) +- #2451 Return a list instead of an array in GetModulePath and GetChoicePath methods (370bdfa) +- #2453 Update app commands regex and fix localization on app context commands (3dec99f) +- #2333 Update package logo (2b86a79) + ## [3.8.0] - 2022-08-27 ### Added - #2384 Added support for the WEBHOOKS_UPDATED event (010e8e8) diff --git a/Discord.Net.targets b/Discord.Net.targets index d9ec415f9..991f7c495 100644 --- a/Discord.Net.targets +++ b/Discord.Net.targets @@ -1,6 +1,6 @@ - 3.8.0 + 3.8.1 latest Discord.Net Contributors discord;discordapp diff --git a/docs/docfx.json b/docs/docfx.json index 64a20ecb2..2fc0d53b1 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.8.0", + "_appFooter": "Discord.Net (c) 2015-2022 3.8.1", "_enableSearch": true, "_appLogoPath": "marketing/logo/SVG/Logomark Purple.svg", "_appFaviconPath": "favicon.ico" diff --git a/src/Discord.Net/Discord.Net.nuspec b/src/Discord.Net/Discord.Net.nuspec index 63a288dc3..0ddd4af5e 100644 --- a/src/Discord.Net/Discord.Net.nuspec +++ b/src/Discord.Net/Discord.Net.nuspec @@ -2,7 +2,7 @@ Discord.Net - 3.8.0$suffix$ + 3.8.1$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 - - - - - - + + + + + + - - - - - - + + + + + + - - - - - - + + + + + + - - - - - - + + + + + + - - - - - - + + + + + + From bc89d3c4854be63c90899904a6e5dd14245d6659 Mon Sep 17 00:00:00 2001 From: Armano den Boef <68127614+Rozen4334@users.noreply.github.com> Date: Thu, 15 Sep 2022 06:59:21 +0200 Subject: [PATCH 30/32] Fix TimestampTag being sadge (#2468) * Im so sad * Im so sad v2 * oopsie uwu --- .../Entities/Messages/TimestampTag.cs | 79 +++++++++++++------ 1 file changed, 54 insertions(+), 25 deletions(-) diff --git a/src/Discord.Net.Core/Entities/Messages/TimestampTag.cs b/src/Discord.Net.Core/Entities/Messages/TimestampTag.cs index 3beffdbb6..1ca6dc41c 100644 --- a/src/Discord.Net.Core/Entities/Messages/TimestampTag.cs +++ b/src/Discord.Net.Core/Entities/Messages/TimestampTag.cs @@ -5,17 +5,28 @@ namespace Discord /// /// Represents a class used to make timestamps in messages. see . /// - public class TimestampTag + public readonly struct TimestampTag { /// - /// Gets or sets the style of the timestamp tag. + /// Gets the time for this timestamp tag. /// - public TimestampTagStyles Style { get; set; } = TimestampTagStyles.ShortDateTime; + public DateTimeOffset Time { get; } /// - /// Gets or sets the time for this timestamp tag. + /// Gets the style of this tag. if none was provided. /// - public DateTimeOffset Time { get; set; } + public TimestampTagStyles? Style { get; } + + /// + /// Creates a new from the provided time. + /// + /// The time for this timestamp tag. + /// The style for this timestamp tag. + public TimestampTag(DateTimeOffset time, TimestampTagStyles? style = null) + { + Time = time; + Style = style; + } /// /// Converts the current timestamp tag to the string representation supported by discord. @@ -23,11 +34,23 @@ namespace Discord /// If the is null then the default 0 will be used. /// /// + /// + /// Will use the provided if provided. If this value is null, it will default to . + /// /// A string that is compatible in a discord message, ex: <t:1625944201:f> public override string ToString() - { - return $""; - } + => ToString(Style ?? TimestampTagStyles.ShortDateTime); + + /// + /// Converts the current timestamp tag to the string representation supported by discord. + /// + /// If the is null then the default 0 will be used. + /// + /// + /// The formatting style for this tag. + /// A string that is compatible in a discord message, ex: <t:1625944201:f> + public string ToString(TimestampTagStyles style) + => $""; /// /// Creates a new timestamp tag with the specified object. @@ -35,14 +58,8 @@ namespace Discord /// The time of this timestamp tag. /// The style for this timestamp tag. /// The newly create timestamp tag. - public static TimestampTag FromDateTime(DateTime time, TimestampTagStyles style = TimestampTagStyles.ShortDateTime) - { - return new TimestampTag - { - Style = style, - Time = time - }; - } + public static TimestampTag FromDateTime(DateTime time, TimestampTagStyles? style = null) + => new(time, style); /// /// Creates a new timestamp tag with the specified object. @@ -50,13 +67,25 @@ namespace Discord /// The time of this timestamp tag. /// The style for this timestamp tag. /// The newly create timestamp tag. - public static TimestampTag FromDateTimeOffset(DateTimeOffset time, TimestampTagStyles style = TimestampTagStyles.ShortDateTime) - { - return new TimestampTag - { - Style = style, - Time = time - }; - } + public static TimestampTag FromDateTimeOffset(DateTimeOffset time, TimestampTagStyles? style = null) + => new(time, style); + + /// + /// Immediately formats the provided time and style into a timestamp string. + /// + /// The time of this timestamp tag. + /// The style for this timestamp tag. + /// The newly create timestamp string. + public static string FormatFromDateTime(DateTime time, TimestampTagStyles style) + => FormatFromDateTimeOffset(time, style); + + /// + /// Immediately formats the provided time and style into a timestamp string. + /// + /// The time of this timestamp tag. + /// The style for this timestamp tag. + /// The newly create timestamp string. + public static string FormatFromDateTimeOffset(DateTimeOffset time, TimestampTagStyles style) + => $""; } -} \ No newline at end of file +} From a4d34f69477bd546fd0b66724c9ff6a418fcdff2 Mon Sep 17 00:00:00 2001 From: Misha133 <61027276+Misha-133@users.noreply.github.com> Date: Sun, 25 Sep 2022 23:27:14 +0300 Subject: [PATCH 31/32] [Docs] Update samples to use `MessageContent` intent & update `v2 => v3 guide` (#2471) --- docs/guides/v2_v3_guide/v2_to_v3_guide.md | 1 + samples/BasicBot/Program.cs | 9 ++++++++- samples/BasicBot/_BasicBot.csproj | 4 ++-- .../InteractionFramework/_InteractionFramework.csproj | 2 +- samples/ShardedClient/Program.cs | 3 ++- samples/ShardedClient/_ShardedClient.csproj | 2 +- samples/TextCommandFramework/Program.cs | 4 ++++ .../TextCommandFramework/_TextCommandFramework.csproj | 4 ++-- samples/WebhookClient/_WebhookClient.csproj | 2 +- 9 files changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/guides/v2_v3_guide/v2_to_v3_guide.md b/docs/guides/v2_v3_guide/v2_to_v3_guide.md index a837f44d2..91fc1b43d 100644 --- a/docs/guides/v2_v3_guide/v2_to_v3_guide.md +++ b/docs/guides/v2_v3_guide/v2_to_v3_guide.md @@ -37,6 +37,7 @@ _client = new DiscordSocketClient(config); - AllUnprivileged: This is a group of most common intents, that do NOT require any [developer portal] intents to be enabled. This includes intents that receive messages such as: `GatewayIntents.GuildMessages, GatewayIntents.DirectMessages` - GuildMembers: An intent disabled by default, as you need to enable it in the [developer portal]. +- MessageContent: An intent also disabled by default as you also need to enable it in the [developer portal]. - GuildPresences: Also disabled by default, this intent together with `GuildMembers` are the only intents not included in `AllUnprivileged`. - All: All intents, it is ill advised to use this without care, as it _can_ cause a memory leak from presence. The library will give responsive warnings if you specify unnecessary intents. diff --git a/samples/BasicBot/Program.cs b/samples/BasicBot/Program.cs index 179dfce05..a71de9fc8 100644 --- a/samples/BasicBot/Program.cs +++ b/samples/BasicBot/Program.cs @@ -34,9 +34,16 @@ namespace BasicBot public Program() { + // Config used by DiscordSocketClient + // Define intents for the client + var config = new DiscordSocketConfig + { + GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent + }; + // It is recommended to Dispose of a client when you are finished // using it, at the end of your app's lifetime. - _client = new DiscordSocketClient(); + _client = new DiscordSocketClient(config); // Subscribing to client events, so that we may receive them whenever they're invoked. _client.Log += LogAsync; diff --git a/samples/BasicBot/_BasicBot.csproj b/samples/BasicBot/_BasicBot.csproj index e6245d340..7d3004ad9 100644 --- a/samples/BasicBot/_BasicBot.csproj +++ b/samples/BasicBot/_BasicBot.csproj @@ -1,4 +1,4 @@ - + Exe @@ -6,7 +6,7 @@ - + diff --git a/samples/InteractionFramework/_InteractionFramework.csproj b/samples/InteractionFramework/_InteractionFramework.csproj index 8892a65b7..a0fa14d74 100644 --- a/samples/InteractionFramework/_InteractionFramework.csproj +++ b/samples/InteractionFramework/_InteractionFramework.csproj @@ -13,7 +13,7 @@ - + diff --git a/samples/ShardedClient/Program.cs b/samples/ShardedClient/Program.cs index 2b8f49edb..cb7b0dbb3 100644 --- a/samples/ShardedClient/Program.cs +++ b/samples/ShardedClient/Program.cs @@ -28,7 +28,8 @@ namespace ShardedClient // have 1 shard per 1500-2000 guilds your bot is in. var config = new DiscordSocketConfig { - TotalShards = 2 + TotalShards = 2, + GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent }; // You should dispose a service provider created using ASP.NET diff --git a/samples/ShardedClient/_ShardedClient.csproj b/samples/ShardedClient/_ShardedClient.csproj index 68a43c7cd..5c1c6a20c 100644 --- a/samples/ShardedClient/_ShardedClient.csproj +++ b/samples/ShardedClient/_ShardedClient.csproj @@ -8,7 +8,7 @@ - + diff --git a/samples/TextCommandFramework/Program.cs b/samples/TextCommandFramework/Program.cs index 8a18daf72..ccd23436e 100644 --- a/samples/TextCommandFramework/Program.cs +++ b/samples/TextCommandFramework/Program.cs @@ -60,6 +60,10 @@ namespace TextCommandFramework private ServiceProvider ConfigureServices() { return new ServiceCollection() + .AddSingleton(new DiscordSocketConfig + { + GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent + }) .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/samples/TextCommandFramework/_TextCommandFramework.csproj b/samples/TextCommandFramework/_TextCommandFramework.csproj index 6e00625e8..5307303ce 100644 --- a/samples/TextCommandFramework/_TextCommandFramework.csproj +++ b/samples/TextCommandFramework/_TextCommandFramework.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/samples/WebhookClient/_WebhookClient.csproj b/samples/WebhookClient/_WebhookClient.csproj index 515fcf3a4..acea75d2c 100644 --- a/samples/WebhookClient/_WebhookClient.csproj +++ b/samples/WebhookClient/_WebhookClient.csproj @@ -7,7 +7,7 @@ - + From c7ac59d89225adf42bb270c55d04aa6440be16f2 Mon Sep 17 00:00:00 2001 From: Quahu Date: Wed, 28 Sep 2022 15:35:24 -0700 Subject: [PATCH 32/32] Fixed an oversight clearing session data upon any disconnect. (#2485) --- src/Discord.Net.WebSocket/DiscordSocketClient.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Discord.Net.WebSocket/DiscordSocketClient.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.cs index f0f99933e..a90ffff7a 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.cs @@ -352,10 +352,6 @@ namespace Discord.WebSocket if (guild.IsAvailable) await GuildUnavailableAsync(guild).ConfigureAwait(false); } - - _sessionId = null; - _lastSeq = 0; - ApiClient.ResumeGatewayUrl = null; } ///