Compare commits

...

10 Commits

Author SHA1 Message Date
Quin Lynch
09e0fdb91f Add optional id parameter to ThumbnailBuilder constructor (#3233)
Some checks failed
Dotnet Build / Build and Test (push) Has been cancelled
Dotnet Build / Deploy (push) Has been cancelled
2026-02-10 21:15:56 -04:00
Quin Lynch
c3a30dbf95 Add optional id parameter to SeparatorBuilder constructor (#3234) 2026-02-10 21:15:43 -04:00
Quin Lynch
b169a116d2 Add constructor for SectionBuilder with id (#3235) 2026-02-10 21:15:29 -04:00
Quin Lynch
e05e849584 Add constructor for ActionRowBuilder with id (#3236) 2026-02-10 21:15:19 -04:00
Quin Lynch
e3cb507032 Add overloaded constructor to ContainerBuilder (#3237) 2026-02-10 21:15:02 -04:00
Quin Lynch
16ea091d20 Add overloaded constructor with id to MediaGalleryBuilder (#3238)
Added an overloaded constructor to MediaGalleryBuilder that accepts an ID.
2026-02-10 21:14:42 -04:00
Mihail Gribkov
0dea5bb4fd Add BypassSlowmode permission (#3228) 2026-01-23 22:28:58 +03:00
Cenk Ergen
9c1db3f0f0 [Fix] Modal Write invocation without instance and missing ChannelTypes (#3221)
* add channel types to channel select builder and info

* add channelTypes to select builder from IModal

* remove public setter requirement from modal component definition and add guard clause to inputs

* refactor modal building to run typeConverter writes even without modal instance

* add inline docs to channelTypes props and method

* add property as a target for ChannelTypesAttribute

* move enum option building logic out of enum typeConverter

* add channel type constraint mapping to channel single-select typeConverter

* move SelectMenuOptionAttribute to its own file

* add null forgiving operator to channel type mapping

* remove list initialization from enum modal typeConverter

* disallow channel default value assignment to mentionable selects

* add id property to modal components

* add component id assignment from attributes

* update component attribute ctor signatures and inline docs

* Update src/Discord.Net.Interactions/TypeConverters/ModalComponents/EnumModalComponentConverter.cs

Co-authored-by: Mihail Gribkov <61027276+Misha-133@users.noreply.github.com>

* replace default values of component ids with 0

---------

Co-authored-by: Mihail Gribkov <61027276+Misha-133@users.noreply.github.com>
2026-01-22 17:50:27 +03:00
Mihail Gribkov
4fdebdc6ed Meta/3.19.0 beta.1 (#3220)
* Bump version to 3.19.0 in nuspec file

* Update app footer year and version in docfx.json

* Bump version prefix to 3.19.0

* Update CHANGELOG for version 3.19.0-beta.1
2026-01-03 03:00:48 +03:00
Cenk Ergen
86b885b24b fix modal text display constructor initialization (#3218) 2026-01-03 00:12:15 +03:00
43 changed files with 528 additions and 234 deletions

View File

@@ -1,5 +1,42 @@
# Changelog
## [3.19.0-beta.1] - 2026-01-03
### Added
- #3172 Modal refactoring & select menu support (a476014)
- #3178 Add `PinMessages` permission (5273f1d)
- #3183 Update voice API to version 8 (927c905)
- #3189 Modal Select Components Support for IF (e8c5436)
- #3196 Add modify current guild member support (80fbbc2)
- #3198 Get Archived thread calls on text channels (ca6c9bc)
- #3199 Add select menu `IsRequired` property and fix usage of `DefaultValues` (0aff637)
- #3200 Add net10.0 build target, update deps (f205bba)
- #3193 Remove unsupported SDK targets (5ca29fd))
- #3195 Add `GetRoleUserCounts` REST method (dade9b2)
- #3207 Added UnknownComponent classes, and support to MessageComponentConverter and MessageComponentExtensions (ad8182f)
- #3209 Modal Components v2 Single-select Typeconverters, Rest, and Patches (1e27c99)
### Fixed
- #3167 Incorrect casts in the legacy component builder (958d286)
- #3173 Remove voice gateway port stripping (0b078d7)
- #3174 Fix duplicated flag value in ActivityProperties enum (ebc7db8)
- #3186 Fix missing SelectMenu Type, ChannelTypes, DefaultValues in ComponentBuilder.AddComponent (8883596)
- #3190 Fix voice receiving (a468e18)
- #3192 fixed error on changing role icon/emoji to an image. (06510e1)
- #3204 Fix user status update when speaking (11a56bc)
- #3206 Fix AutocompleteResult.Value having no length limit (161a91e)
- #3210 Fix NRE in Rest Interaction Guild User Resolution (fd6e3ad)
- #3218 fix modal text display constructor initialization (86b885b)
- #3197 Correct `IUserMessage.ModifyAsync` precondition expression (8668092)
### Misc
- #3169 Make ParameterChoice constructor public (9cb6ffd)
- #3171 Bump guild batch limit to 200 (fc0712d)
- #3176 Add better json type exception (ae6e7d5)
- #3177 Make Cacheable constructors public (e61eb51
- #3216 Improve .NET 9.0+ locking performance (4e95dd7)
- #3217 Switch lock backport package to #if defs instead (b386a0e)
## [3.18.0] - 2025-07-19
### Added
- #3145 add `ApproximateUserAuthorizationCount` (6e1f9c1)

View File

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

View File

@@ -59,7 +59,7 @@
"globalMetadata": {
"_appTitle": "Discord.Net Documentation",
"_appName": "Discord.Net",
"_appFooter": "Discord.Net © 2015-2025 3.18.0",
"_appFooter": "Discord.Net © 2015-2026 3.19.0",
"_enableSearch": true,
"_appLogoPath": "marketing/logo/SVG/Logomark Purple.svg",
"_appFaviconPath": "favicon.png"

View File

@@ -64,6 +64,16 @@ public class ActionRowBuilder : IMessageComponentBuilder, IInteractableComponent
Components = components?.ToList() ?? [];
}
/// <summary>
/// Initializes a new <see cref="ActionRowBuilder"/>.
/// </summary>
public ActionRowBuilder(IEnumerable<IMessageComponentBuilder> components, int? id)
{
Components = components?.ToList() ?? [];
Id = id;
}
/// <summary>
/// Initializes a new <see cref="ActionRowBuilder"/> from existing component.
/// </summary>

View File

@@ -62,6 +62,17 @@ public class ContainerBuilder : IMessageComponentBuilder, IStaticComponentContai
{
Components = components?.ToList();
}
/// <summary>
/// Initializes a new <see cref="ContainerBuilder"/>.
/// </summary>
public ContainerBuilder(Color? accentColor = null, bool? isSpoiler = null, int? id = null, params IEnumerable<IMessageComponentBuilder> components)
{
Components = components?.ToList();
AccentColor = accentColor;
IsSpoiler = IsSpoiler;
Id = id;
}
/// <summary>
/// Initializes a new <see cref="ContainerBuilder"/> from existing component.

View File

@@ -32,6 +32,14 @@ public class MediaGalleryBuilder : IMessageComponentBuilder
{
Items = items?.ToList();
}
/// <summary>
/// Initializes a new instance of the <see cref="MediaGalleryBuilder"/>.
/// </summary>
public MediaGalleryBuilder(IEnumerable<MediaGalleryItemProperties> items, int? id) : this(items)
{
Id = id;
}
/// <summary>
/// Initializes a new <see cref="MediaGalleryBuilder"/> from existing component.

View File

@@ -59,6 +59,16 @@ public class SectionBuilder : IMessageComponentBuilder, IStaticComponentContaine
Components = components?.ToList();
}
/// <summary>
/// Initializes a new <see cref="SectionBuilder"/>.
/// </summary>
public SectionBuilder(IMessageComponentBuilder accessory, IEnumerable<IMessageComponentBuilder> components, int? id)
{
Accessory = accessory;
Components = components?.ToList();
Id = id;
}
/// <summary>
/// Initializes a new <see cref="SectionBuilder"/> from existing component.
/// </summary>

View File

@@ -21,10 +21,11 @@ public class SeparatorBuilder : IMessageComponentBuilder
/// <summary>
/// Initializes a new <see cref="SeparatorBuilder"/>.
/// </summary>
public SeparatorBuilder(bool isDivider = true, SeparatorSpacingSize spacing = SeparatorSpacingSize.Small)
public SeparatorBuilder(bool isDivider = true, SeparatorSpacingSize spacing = SeparatorSpacingSize.Small, int? id = null)
{
IsDivider = isDivider;
Spacing = spacing;
Id = id;
}
/// <summary>

View File

@@ -38,11 +38,12 @@ public class ThumbnailBuilder : IMessageComponentBuilder
/// <summary>
/// Initializes a new instance of the <see cref="ThumbnailBuilder"/>.
/// </summary>
public ThumbnailBuilder(UnfurledMediaItemProperties media, string description = null, bool isSpoiler = false)
public ThumbnailBuilder(UnfurledMediaItemProperties media, string description = null, bool isSpoiler = false, int? id = null)
{
Media = media;
Description = description;
IsSpoiler = isSpoiler;
Id = id;
}
/// <summary>

View File

@@ -212,5 +212,10 @@ namespace Discord
/// Allows pinning and unpinning messages.
/// </summary>
PinMessages = 1L << 51,
/// <summary>
/// Allows bypassing slowmode restrictions.
/// </summary>
BypassSlowmode = 1L << 52,
}
}

View File

@@ -18,17 +18,17 @@ namespace Discord
/// <summary>
/// Gets a <see cref="ChannelPermissions"/> that grants all permissions for text channels.
/// </summary>
public static readonly ChannelPermissions Text = new(0b1110_110001_001111_110010_110011_111101_111111_111101_010001);
public static readonly ChannelPermissions Text = new(0b11110_110001_001111_110010_110011_111101_111111_111101_010001);
/// <summary>
/// Gets a <see cref="ChannelPermissions"/> that grants all permissions for voice channels.
/// </summary>
public static readonly ChannelPermissions Voice = new(0b0111_110101_001010_001010_110011_111101_111111_111101_010001);
public static readonly ChannelPermissions Voice = new(0b10111_110101_001010_001010_110011_111101_111111_111101_010001);
/// <summary>
/// Gets a <see cref="ChannelPermissions"/> that grants all permissions for stage channels.
/// </summary>
public static readonly ChannelPermissions Stage = new(0b0110_110100_000010_001110_010001_010101_111111_111001_010001);
public static readonly ChannelPermissions Stage = new(0b10110_110100_000010_001110_010001_010101_111111_111001_010001);
/// <summary>
/// Gets a <see cref="ChannelPermissions"/> that grants all permissions for category channels.
@@ -160,6 +160,8 @@ namespace Discord
public bool UseExternalSounds => Permissions.GetValue(RawValue, ChannelPermission.UseExternalSounds);
/// <summary> If <see langword="true"/>, a user can ping and unpin messages.</summary>
public bool PinMessages => Permissions.GetValue(RawValue, ChannelPermission.PinMessages);
/// <summary> If <see langword="true"/>, a user may bypass slowmode restrictions.</summary>
public bool BypassSlowmode => Permissions.GetValue(RawValue, ChannelPermission.BypassSlowmode);
/// <summary> Creates a new <see cref="ChannelPermissions"/> with the provided packed value.</summary>
public ChannelPermissions(ulong rawValue) { RawValue = rawValue; }
@@ -203,7 +205,8 @@ namespace Discord
bool? sendPolls = null,
bool? useExternalApps = null,
bool? useExternalSounds = null,
bool? pinMessages = null)
bool? pinMessages = null,
bool? bypassSlowmode = null)
{
ulong value = initialValue;
@@ -246,6 +249,7 @@ namespace Discord
Permissions.SetValue(ref value, useExternalApps, ChannelPermission.UseExternalApps);
Permissions.SetValue(ref value, useExternalSounds, ChannelPermission.UseExternalSounds);
Permissions.SetValue(ref value, pinMessages, ChannelPermission.PinMessages);
Permissions.SetValue(ref value, bypassSlowmode, ChannelPermission.BypassSlowmode);
RawValue = value;
}
@@ -290,13 +294,14 @@ namespace Discord
bool sendPolls = false,
bool useExternalApps = false,
bool useExternalSounds = false,
bool pinMessages = false)
bool pinMessages = false,
bool bypassSlowmode = false)
: this(0, createInstantInvite, manageChannel, addReactions, viewChannel, sendMessages, sendTTSMessages, manageMessages,
embedLinks, attachFiles, readMessageHistory, mentionEveryone, useExternalEmojis, connect,
speak, muteMembers, deafenMembers, moveMembers, useVoiceActivation, prioritySpeaker, stream, manageRoles, manageWebhooks,
useApplicationCommands, requestToSpeak, manageThreads, createPublicThreads, createPrivateThreads, useExternalStickers, sendMessagesInThreads,
startEmbeddedActivities, useSoundboard, createEvents, sendVoiceMessages, useClydeAI, setVoiceChannelStatus, sendPolls, useExternalApps,
useExternalSounds, pinMessages)
useExternalSounds, pinMessages, bypassSlowmode)
{ }
/// <summary> Creates a new <see cref="ChannelPermissions"/> from this one, changing the provided non-null permissions.</summary>
@@ -339,7 +344,8 @@ namespace Discord
bool? sendPolls = null,
bool? useExternalApps = null,
bool? useExternalSounds = null,
bool? pinMessages = null)
bool? pinMessages = null,
bool? bypassSlowmode = null)
=> new ChannelPermissions(RawValue,
createInstantInvite,
manageChannel,
@@ -379,7 +385,8 @@ namespace Discord
sendPolls,
useExternalApps,
useExternalSounds,
pinMessages);
pinMessages,
bypassSlowmode);
public bool Has(ChannelPermission permission) => Permissions.GetValue(RawValue, permission);

View File

@@ -307,5 +307,10 @@ namespace Discord
/// Allows pinning and unpinning messages.
/// </summary>
PinMessages = 1L << 51,
/// <summary>
/// Allows bypassing slowmode restrictions.
/// </summary>
BypassSlowmode = 1L << 52,
}
}

View File

@@ -126,6 +126,8 @@ namespace Discord
public bool CreateEvents => Permissions.GetValue(RawValue, GuildPermission.CreateEvents);
/// <summary> If <see langword="true"/>, a user can ping and unpin messages.</summary>
public bool PinMessages => Permissions.GetValue(RawValue, GuildPermission.PinMessages);
/// <summary> If <see langword="true"/>, a user may bypass slowmode restrictions. </summary>
public bool BypassSlowmode => Permissions.GetValue(RawValue, GuildPermission.BypassSlowmode);
/// <summary> Creates a new <see cref="GuildPermissions"/> with the provided packed value. </summary>
public GuildPermissions(ulong rawValue) { RawValue = rawValue; }
@@ -185,7 +187,8 @@ namespace Discord
bool? useExternalApps = null,
bool? useExternalSounds = null,
bool? createEvents = null,
bool? pinMessages = null)
bool? pinMessages = null,
bool? bypassSlowmode = null)
{
ulong value = initialValue;
@@ -241,6 +244,7 @@ namespace Discord
Permissions.SetValue(ref value, useExternalSounds, GuildPermission.UseExternalSounds);
Permissions.SetValue(ref value, createEvents, GuildPermission.CreateEvents);
Permissions.SetValue(ref value, pinMessages, GuildPermission.PinMessages);
Permissions.SetValue(ref value, bypassSlowmode, GuildPermission.BypassSlowmode);
RawValue = value;
}
@@ -298,7 +302,8 @@ namespace Discord
bool useExternalApps = false,
bool useExternalSounds = false,
bool createEvents = false,
bool pinMessages = false)
bool pinMessages = false,
bool bypassSlowmode = false)
: this(0,
createInstantInvite: createInstantInvite,
manageRoles: manageRoles,
@@ -351,7 +356,8 @@ namespace Discord
useExternalApps: useExternalApps,
useExternalSounds: useExternalSounds,
createEvents: createEvents,
pinMessages: pinMessages)
pinMessages: pinMessages,
bypassSlowmode: bypassSlowmode)
{ }
/// <summary> Creates a new <see cref="GuildPermissions"/> from this one, changing the provided non-null permissions. </summary>
@@ -407,14 +413,15 @@ namespace Discord
bool? useExternalApps = null,
bool? useExternalSounds = null,
bool? createEvents = null,
bool? pinMessages = null)
bool? pinMessages = null,
bool? bypassSlowmode = null)
=> new GuildPermissions(RawValue, createInstantInvite, kickMembers, banMembers, administrator, manageChannels, manageGuild, addReactions,
viewAuditLog, viewGuildInsights, viewChannel, sendMessages, sendTTSMessages, manageMessages, embedLinks, attachFiles,
readMessageHistory, mentionEveryone, useExternalEmojis, connect, speak, muteMembers, deafenMembers, moveMembers,
useVoiceActivation, prioritySpeaker, stream, changeNickname, manageNicknames, manageRoles, manageWebhooks, manageEmojisAndStickers,
useApplicationCommands, requestToSpeak, manageEvents, manageThreads, createPublicThreads, createPrivateThreads, useExternalStickers, sendMessagesInThreads,
startEmbeddedActivities, moderateMembers, useSoundboard, viewMonetizationAnalytics, sendVoiceMessages, useClydeAI, createGuildExpressions, setVoiceChannelStatus,
sendPolls, useExternalApps, useExternalSounds, createEvents, pinMessages);
sendPolls, useExternalApps, useExternalSounds, createEvents, pinMessages, bypassSlowmode);
/// <summary>
/// Returns a value that indicates if a specific <see cref="GuildPermission"/> is enabled

View File

@@ -7,7 +7,7 @@ namespace Discord.Interactions
/// <summary>
/// Specify the target channel types for a <see cref="ApplicationCommandOptionType.Channel"/> option.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class ChannelTypesAttribute : Attribute
{
/// <summary>

View File

@@ -12,5 +12,9 @@ public class ModalChannelSelectAttribute : ModalSelectComponentAttribute
/// Create a new <see cref="ModalChannelSelectAttribute"/>.
/// </summary>
/// <param name="customId">Custom ID of the channel select component.</param>
public ModalChannelSelectAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId, minValues, maxValues) { }
/// <param name="minValues">The minimum number of values that can be selected.</param>
/// <param name="maxValues">The maximum number of values that can be selected.</param>
/// <param name="id">Optional identifier for the component.</param>
public ModalChannelSelectAttribute(string customId, int minValues = 1, int maxValues = 1, int id = 0)
: base(customId, minValues, maxValues, id) { }
}

View File

@@ -13,5 +13,16 @@ public abstract class ModalComponentAttribute : Attribute
/// </summary>
public abstract ComponentType ComponentType { get; }
internal ModalComponentAttribute() { }
/// <summary>
/// Gets the optional identifier for component.
/// </summary>
/// <remarks>
/// Sending components with an id of 0 is allowed but will be treated as empty and replaced by the API.
/// </remarks>
public int Id { get; set; }
internal ModalComponentAttribute(int id = 0)
{
Id = id;
}
}

View File

@@ -24,7 +24,9 @@ public class ModalFileUploadAttribute : ModalInputAttribute
/// <param name="customId">Custom ID of the file upload component.</param>
/// <param name="minValues">Minimum number of files that can be uploaded.</param>
/// <param name="maxValues">Maximum number of files that can be uploaded.</param>
public ModalFileUploadAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId)
/// <param name="id">The optional identifier for the component.</param>
public ModalFileUploadAttribute(string customId, int minValues = 1, int maxValues = 1, int id = 0)
: base(customId, id)
{
MinValues = minValues;
MaxValues = maxValues;

View File

@@ -17,7 +17,8 @@ namespace Discord.Interactions
/// Create a new <see cref="ModalInputAttribute"/>.
/// </summary>
/// <param name="customId">The custom id of the input.</param>
internal ModalInputAttribute(string customId)
/// <param name="id">Optional identifier for component.</param>
internal ModalInputAttribute(string customId, int id) : base(id)
{
CustomId = customId;
}

View File

@@ -14,5 +14,7 @@ public class ModalMentionableSelectAttribute : ModalSelectComponentAttribute
/// <param name="customId">Custom ID of the mentionable select component.</param>
/// <param name="minValues">Minimum number of values that can be selected.</param>
/// <param name="maxValues">Maximum number of values that can be selected</param>
public ModalMentionableSelectAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId, minValues, maxValues) { }
/// <param name="id">The optional identifier for the component.</param>
public ModalMentionableSelectAttribute(string customId, int minValues = 1, int maxValues = 1, int id = 0)
: base(customId, minValues, maxValues, id) { }
}

View File

@@ -14,5 +14,7 @@ public class ModalRoleSelectAttribute : ModalSelectComponentAttribute
/// <param name="customId">Custom ID of the role select component.</param>
/// <param name="minValues">Minimum number of values that can be selected.</param>
/// <param name="maxValues">Maximum number of values that can be selected.</param>
public ModalRoleSelectAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId, minValues, maxValues) { }
/// <param name="id">The optional identifier for the component.</param>
public ModalRoleSelectAttribute(string customId, int minValues = 1, int maxValues = 1, int id = 0)
: base(customId, minValues, maxValues, id) { }
}

View File

@@ -20,7 +20,8 @@ public abstract class ModalSelectComponentAttribute : ModalInputAttribute
/// </summary>
public string Placeholder { get; set; }
internal ModalSelectComponentAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId)
internal ModalSelectComponentAttribute(string customId, int minValues = 1, int maxValues = 1, int id = 0)
: base(customId, id)
{
MinValues = minValues;
MaxValues = maxValues;

View File

@@ -14,5 +14,7 @@ public sealed class ModalSelectMenuAttribute : ModalSelectComponentAttribute
/// <param name="customId">Custom ID of the select menu component.</param>
/// <param name="minValues">Minimum number of values that can be selected.</param>
/// <param name="maxValues">Maximum number of values that can be selected.</param>
public ModalSelectMenuAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId, minValues, maxValues) { }
/// <param name="id">The optional identifier for the component.</param>
public ModalSelectMenuAttribute(string customId, int minValues = 1, int maxValues = 1, int id = 0)
: base(customId, minValues, maxValues, id) { }
}

View File

@@ -17,7 +17,9 @@ public class ModalTextDisplayAttribute : ModalComponentAttribute
/// Create a new <see cref="ModalTextInputAttribute"/>.
/// </summary>
/// <param name="content">Content of the text display.</param>
public ModalTextDisplayAttribute(string content = null)
/// <param name="id">Optional identifier for component.</param>
public ModalTextDisplayAttribute(string content = null, int id = 0)
: base(id)
{
Content = content;
}

View File

@@ -11,27 +11,27 @@ namespace Discord.Interactions
/// <summary>
/// Gets the style of the text input.
/// </summary>
public TextInputStyle Style { get; }
public TextInputStyle Style { get; set; }
/// <summary>
/// Gets the placeholder of the text input.
/// </summary>
public string Placeholder { get; }
public string Placeholder { get; set; }
/// <summary>
/// Gets the minimum length of the text input.
/// </summary>
public int MinLength { get; }
public int MinLength { get; set; }
/// <summary>
/// Gets the maximum length of the text input.
/// </summary>
public int MaxLength { get; }
public int MaxLength { get; set; }
/// <summary>
/// Gets the initial value to be displayed by this input.
/// </summary>
public string InitialValue { get; }
public string InitialValue { get; set; }
/// <summary>
/// Create a new <see cref="ModalTextInputAttribute"/>.
@@ -42,8 +42,9 @@ namespace Discord.Interactions
/// <param name="minLength">The minimum length of the text input's content.</param>
/// <param name="maxLength">The maximum length of the text input's content.</param>
/// <param name="initValue">The initial value to be displayed by this input.</param>
public ModalTextInputAttribute(string customId, TextInputStyle style = TextInputStyle.Short, string placeholder = null, int minLength = 1, int maxLength = 4000, string initValue = null)
: base(customId)
/// <param name="id">The optional identifier for the component.</param>
public ModalTextInputAttribute(string customId, TextInputStyle style = TextInputStyle.Short, string placeholder = null, int minLength = 1, int maxLength = 4000, string initValue = null, int id = 0)
: base(customId, id)
{
Style = style;
Placeholder = placeholder;

View File

@@ -14,5 +14,7 @@ public class ModalUserSelectAttribute : ModalSelectComponentAttribute
/// <param name="customId">Custom ID of the user select component.</param>
/// <param name="minValues">Minimum number of values that can be selected.</param>
/// <param name="maxValues">Maximum number of values that can be selected.</param>
public ModalUserSelectAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId, minValues, maxValues) { }
/// <param name="id">The optional identifier for the component.</param>
public ModalUserSelectAttribute(string customId, int minValues = 1, int maxValues = 1, int id = 0)
: base(customId, minValues, maxValues, id) { }
}

View File

@@ -0,0 +1,31 @@
using System;
namespace Discord.Interactions;
/// <summary>
/// Adds additional metadata to enum fields that are used for select-menus.
/// </summary>
/// <remarks>
/// To manually add select menu options to modal components, use <see cref="ModalSelectMenuOptionAttribute"/> instead.
/// </remarks>
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
public class SelectMenuOptionAttribute : Attribute
{
/// <summary>
/// Gets or sets the desription of the option.
/// </summary>
public string Description { get; set; }
/// <summary>
/// Gets or sets whether the option is selected by default.
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// Gets or sets the emote of the option.
/// </summary>
/// <remarks>
/// Can be either an <see cref="Emoji"/> or an <see cref="Discord.Emote"/>
/// </remarks>
public string Emote { get; set; }
}

View File

@@ -8,8 +8,15 @@ namespace Discord.Interactions.Builders;
/// </summary>
public class ChannelSelectComponentBuilder : SnowflakeSelectComponentBuilder<ChannelSelectComponentInfo, ChannelSelectComponentBuilder>
{
private readonly List<ChannelType> _channelTypes = new();
protected override ChannelSelectComponentBuilder Instance => this;
/// <summary>
/// Gets the presented channel types for this Channel Select.
/// </summary>
public IReadOnlyCollection<ChannelType> ChannelTypes => _channelTypes.AsReadOnly();
/// <summary>
/// Initializes a new <see cref="ChannelSelectComponentBuilder"/>.
/// </summary>
@@ -42,6 +49,19 @@ public class ChannelSelectComponentBuilder : SnowflakeSelectComponentBuilder<Cha
return this;
}
/// <summary>
/// Sets the value of <see cref="ChannelTypes"/>.
/// </summary>
/// <param name="channelTypes">the new value of <see cref="ChannelTypes"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
public ChannelSelectComponentBuilder WithChannelTypes(params IEnumerable<ChannelType> channelTypes)
{
_channelTypes.AddRange(channelTypes);
return this;
}
internal override ChannelSelectComponentInfo Build(ModalInfo modal)
=> new(this, modal);
}

View File

@@ -31,6 +31,14 @@ public interface IModalComponentBuilder
/// </summary>
object DefaultValue { get; }
/// <summary>
/// Gets the optional identifier for component.
/// </summary>
/// <remarks>
/// Sending components with an id of 0 is allowed but will be treated as empty and replaced by the API.
/// </remarks>
int Id { get; }
/// <summary>
/// Gets a collection of the attributes of this component.
/// </summary>
@@ -62,4 +70,13 @@ public interface IModalComponentBuilder
/// The builder instance.
/// </returns>
IModalComponentBuilder WithAttributes(params Attribute[] attributes);
/// <summary>
/// Sets <see cref="Id"/>.
/// </summary>
/// <param name="id">New value of the <see cref="Id"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
IModalComponentBuilder WithId(int id);
}

View File

@@ -26,6 +26,9 @@ public abstract class ModalComponentBuilder<TInfo, TBuilder> : IModalComponentBu
/// <inheritdoc/>
public object DefaultValue { get; set; }
/// <inheritdoc/>
public int Id { get; set; }
/// <inheritdoc/>
public IReadOnlyCollection<Attribute> Attributes => _attributes;
@@ -87,6 +90,19 @@ public abstract class ModalComponentBuilder<TInfo, TBuilder> : IModalComponentBu
return Instance;
}
/// <summary>
/// Sets <see cref="Id"/>.
/// </summary>
/// <param name="id">New value of the <see cref="Id"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
public virtual TBuilder WithId(int id)
{
Id = id;
return Instance;
}
internal abstract TInfo Build(ModalInfo modal);
/// <inheritdoc/>
@@ -97,4 +113,7 @@ public abstract class ModalComponentBuilder<TInfo, TBuilder> : IModalComponentBu
/// <inheritdoc/>
IModalComponentBuilder IModalComponentBuilder.WithAttributes(params Attribute[] attributes) => WithAttributes(attributes);
/// <inheritdoc/>
IModalComponentBuilder IModalComponentBuilder.WithId(int id) => WithId(id);
}

View File

@@ -654,6 +654,8 @@ namespace Discord.Interactions.Builders
private static void BuildTextInputComponent(TextInputComponentBuilder builder, PropertyInfo propertyInfo, object defaultValue)
{
EnsurePubliclySettable(propertyInfo);
var attributes = propertyInfo.GetCustomAttributes();
builder.Label = propertyInfo.Name;
@@ -673,6 +675,7 @@ namespace Discord.Interactions.Builders
builder.MaxLength = textInput.MaxLength;
builder.MinLength = textInput.MinLength;
builder.InitialValue = textInput.InitialValue;
builder.Id = textInput.Id;
break;
case RequiredInputAttribute requiredInput:
builder.IsRequired = requiredInput.IsRequired;
@@ -690,6 +693,8 @@ namespace Discord.Interactions.Builders
private static void BuildSelectMenuComponent(SelectMenuComponentBuilder builder, PropertyInfo propertyInfo, object defaultValue)
{
EnsurePubliclySettable(propertyInfo);
var attributes = propertyInfo.GetCustomAttributes();
builder.Label = propertyInfo.Name;
@@ -707,6 +712,7 @@ namespace Discord.Interactions.Builders
builder.MinValues = selectMenuInput.MinValues;
builder.MaxValues = selectMenuInput.MaxValues;
builder.Placeholder = selectMenuInput.Placeholder;
builder.Id = selectMenuInput.Id;
break;
case RequiredInputAttribute requiredInput:
builder.IsRequired = requiredInput.IsRequired;
@@ -742,6 +748,8 @@ namespace Discord.Interactions.Builders
where TInfo : SnowflakeSelectComponentInfo
where TBuilder : SnowflakeSelectComponentBuilder<TInfo, TBuilder>
{
EnsurePubliclySettable(propertyInfo);
var attributes = propertyInfo.GetCustomAttributes();
builder.Label = propertyInfo.Name;
@@ -759,6 +767,7 @@ namespace Discord.Interactions.Builders
builder.MinValues = selectInput.MinValues;
builder.MaxValues = selectInput.MaxValues;
builder.Placeholder = selectInput.Placeholder;
builder.Id = selectInput.Id;
break;
case RequiredInputAttribute requiredInput:
builder.IsRequired = requiredInput.IsRequired;
@@ -767,6 +776,9 @@ namespace Discord.Interactions.Builders
builder.Label = inputLabel.Label;
builder.Description = inputLabel.Description;
break;
case ChannelTypesAttribute channelTypes when builder is ChannelSelectComponentBuilder channelSelectBuilder:
channelSelectBuilder.WithChannelTypes(channelTypes.ChannelTypes);
break;
default:
builder.WithAttributes(attribute);
break;
@@ -776,6 +788,8 @@ namespace Discord.Interactions.Builders
private static void BuildFileUploadComponent(FileUploadComponentBuilder builder, PropertyInfo propertyInfo, object defaultValue)
{
EnsurePubliclySettable(propertyInfo);
var attributes = propertyInfo.GetCustomAttributes();
builder.Label = propertyInfo.Name;
@@ -792,6 +806,7 @@ namespace Discord.Interactions.Builders
builder.ComponentType = fileUploadInput.ComponentType;
builder.MinValues = fileUploadInput.MinValues;
builder.MaxValues = fileUploadInput.MaxValues;
builder.Id = fileUploadInput.Id;
break;
case RequiredInputAttribute requiredInput:
builder.IsRequired = requiredInput.IsRequired;
@@ -822,6 +837,7 @@ namespace Discord.Interactions.Builders
case ModalTextDisplayAttribute textDisplay:
builder.ComponentType = textDisplay.ComponentType;
builder.Content = textDisplay.Content;
builder.Id = textDisplay.Id;
break;
default:
builder.WithAttributes(attribute);
@@ -882,9 +898,18 @@ namespace Discord.Interactions.Builders
private static bool IsValidModalComponentDefinition(PropertyInfo propertyInfo)
{
return propertyInfo.SetMethod?.IsPublic == true &&
propertyInfo.SetMethod?.IsStatic == false &&
propertyInfo.IsDefined(typeof(ModalComponentAttribute));
return propertyInfo.IsDefined(typeof(ModalComponentAttribute));
}
private static bool IsPubliclySettable(PropertyInfo propertyInfo)
{
return propertyInfo.SetMethod is { IsPublic: true, IsStatic: false };
}
private static void EnsurePubliclySettable(PropertyInfo propertyInfo)
{
if(!IsPubliclySettable(propertyInfo))
throw new InvalidOperationException($"The property {propertyInfo.Name} must be publicly settable.");
}
private static ConstructorInfo GetComplexParameterConstructor(TypeInfo typeInfo, ComplexParameterAttribute complexParameter)

View File

@@ -82,12 +82,10 @@ namespace Discord.Interactions
case TextInputComponentInfo textComponent:
{
var inputBuilder = new TextInputBuilder(textComponent.CustomId, textComponent.Style, textComponent.Placeholder, textComponent.IsRequired ? textComponent.MinLength : null,
textComponent.MaxLength, textComponent.IsRequired);
textComponent.MaxLength, textComponent.IsRequired, id: textComponent.Id);
if (modalInstance != null)
{
await textComponent.TypeConverter.WriteAsync(inputBuilder, interaction, textComponent, textComponent.Getter(modalInstance));
}
var instanceValue = modalInstance is not null ? textComponent.Getter(modalInstance) : null;
await textComponent.TypeConverter.WriteAsync(inputBuilder, interaction, textComponent, instanceValue);
var labelBuilder = new LabelBuilder(textComponent.Label, inputBuilder, textComponent.Description);
builder.AddLabel(labelBuilder);
@@ -95,12 +93,10 @@ namespace Discord.Interactions
break;
case SelectMenuComponentInfo selectMenuComponent:
{
var inputBuilder = new SelectMenuBuilder(selectMenuComponent.CustomId, selectMenuComponent.Options.Select(x => new SelectMenuOptionBuilder(x)).ToList(), selectMenuComponent.Placeholder, selectMenuComponent.MaxValues, selectMenuComponent.MinValues, false, isRequired: selectMenuComponent.IsRequired);
var inputBuilder = new SelectMenuBuilder(selectMenuComponent.CustomId, selectMenuComponent.Options.Select(x => new SelectMenuOptionBuilder(x)).ToList(), selectMenuComponent.Placeholder, selectMenuComponent.MaxValues, selectMenuComponent.MinValues, false, isRequired: selectMenuComponent.IsRequired, id: selectMenuComponent.Id);
if (modalInstance != null)
{
await selectMenuComponent.TypeConverter.WriteAsync(inputBuilder, interaction, selectMenuComponent, selectMenuComponent.Getter(modalInstance));
}
var instanceValue = modalInstance is not null ? selectMenuComponent.Getter(modalInstance) : null;
await selectMenuComponent.TypeConverter.WriteAsync(inputBuilder, interaction, selectMenuComponent, instanceValue);
var labelBuilder = new LabelBuilder(selectMenuComponent.Label, inputBuilder, selectMenuComponent.Description);
builder.AddLabel(labelBuilder);
@@ -108,12 +104,11 @@ namespace Discord.Interactions
break;
case SnowflakeSelectComponentInfo snowflakeSelectComponent:
{
var inputBuilder = new SelectMenuBuilder(snowflakeSelectComponent.CustomId, null, snowflakeSelectComponent.Placeholder, snowflakeSelectComponent.MaxValues, snowflakeSelectComponent.MinValues, false, snowflakeSelectComponent.ComponentType, null, snowflakeSelectComponent.DefaultValues.ToList(), null, snowflakeSelectComponent.IsRequired);
var channelTypes = snowflakeSelectComponent is ChannelSelectComponentInfo channelSelectComponent ? channelSelectComponent.ChannelTypes : null;
var inputBuilder = new SelectMenuBuilder(snowflakeSelectComponent.CustomId, null, snowflakeSelectComponent.Placeholder, snowflakeSelectComponent.MaxValues, snowflakeSelectComponent.MinValues, false, snowflakeSelectComponent.ComponentType, channelTypes?.ToList(), snowflakeSelectComponent.DefaultValues.ToList(), snowflakeSelectComponent.Id, snowflakeSelectComponent.IsRequired);
if (modalInstance != null)
{
await snowflakeSelectComponent.TypeConverter.WriteAsync(inputBuilder, interaction, snowflakeSelectComponent, snowflakeSelectComponent.Getter(modalInstance));
}
var instanceValue = modalInstance is not null ? snowflakeSelectComponent.Getter(modalInstance) : null;
await snowflakeSelectComponent.TypeConverter.WriteAsync(inputBuilder, interaction, snowflakeSelectComponent, instanceValue);
var labelBuilder = new LabelBuilder(snowflakeSelectComponent.Label, inputBuilder, snowflakeSelectComponent.Description);
builder.AddLabel(labelBuilder);
@@ -121,12 +116,10 @@ namespace Discord.Interactions
break;
case FileUploadComponentInfo fileUploadComponent:
{
var inputBuilder = new FileUploadComponentBuilder(fileUploadComponent.CustomId, fileUploadComponent.MinValues, fileUploadComponent.MaxValues, fileUploadComponent.IsRequired);
var inputBuilder = new FileUploadComponentBuilder(fileUploadComponent.CustomId, fileUploadComponent.MinValues, fileUploadComponent.MaxValues, fileUploadComponent.IsRequired, fileUploadComponent.Id);
if (modalInstance != null)
{
await fileUploadComponent.TypeConverter.WriteAsync(inputBuilder, interaction, fileUploadComponent, fileUploadComponent.Getter(modalInstance));
}
var instanceValue = modalInstance is not null ? fileUploadComponent.Getter(modalInstance) : null;
await fileUploadComponent.TypeConverter.WriteAsync(inputBuilder, interaction, fileUploadComponent, instanceValue);
var labelBuilder = new LabelBuilder(fileUploadComponent.Label, inputBuilder, fileUploadComponent.Description);
builder.AddLabel(labelBuilder);
@@ -134,8 +127,10 @@ namespace Discord.Interactions
break;
case TextDisplayComponentInfo textDisplayComponent:
{
var content = modalInstance is not null ? textDisplayComponent.Getter(modalInstance).ToString() : (textDisplayComponent.DefaultValue as string) ?? textDisplayComponent.Content;
var componentBuilder = new TextDisplayBuilder(content);
var instanceValue = modalInstance is not null ? textDisplayComponent.Getter(modalInstance).ToString() : null;
var content = instanceValue ?? (textDisplayComponent.DefaultValue as string) ?? textDisplayComponent.Content;
var componentBuilder = new TextDisplayBuilder(content, textDisplayComponent.Id);
builder.AddTextDisplay(componentBuilder);
}
break;

View File

@@ -1,3 +1,6 @@
using System.Collections.Generic;
using System.Collections.Immutable;
namespace Discord.Interactions;
/// <summary>
@@ -5,5 +8,14 @@ namespace Discord.Interactions;
/// </summary>
public class ChannelSelectComponentInfo : SnowflakeSelectComponentInfo
{
internal ChannelSelectComponentInfo(Builders.ChannelSelectComponentBuilder builder, ModalInfo modal) : base(builder, modal) { }
/// <summary>
/// Gets the presented channel types for this Channel Select.
/// </summary>
public IReadOnlyCollection<ChannelType> ChannelTypes { get; }
internal ChannelSelectComponentInfo(Builders.ChannelSelectComponentBuilder builder, ModalInfo modal)
: base(builder, modal)
{
ChannelTypes = builder.ChannelTypes.ToImmutableArray();
}
}

View File

@@ -39,6 +39,14 @@ public abstract class ModalComponentInfo
/// </summary>
public object DefaultValue { get; }
/// <summary>
/// Gets the optional identifier for component.
/// </summary>
/// <remarks>
/// Sending components with an id of 0 is allowed but will be treated as empty and replaced by the API.
/// </remarks>
public int Id { get; }
/// <summary>
/// Gets a collection of the attributes of this command.
/// </summary>
@@ -51,6 +59,7 @@ public abstract class ModalComponentInfo
Type = builder.Type;
PropertyInfo = builder.PropertyInfo;
DefaultValue = builder.DefaultValue;
Id = builder.Id;
Attributes = builder.Attributes.ToImmutableArray();
_getter = new(() => ReflectionUtils<object>.CreateLambdaPropertyGetter(Modal.Type, PropertyInfo));

View File

@@ -14,6 +14,6 @@ public class TextDisplayComponentInfo : ModalComponentInfo
internal TextDisplayComponentInfo(TextDisplayComponentBuilder builder, ModalInfo modal) : base(builder, modal)
{
Content = Content;
Content = builder.Content;
}
}

View File

@@ -1219,7 +1219,7 @@ namespace Discord.Interactions
{
var type = typeof(T);
if (_modalInfos.ContainsKey(type))
if (ModalUtils.Contains(type))
throw new InvalidOperationException($"Modal type {type.FullName} already exists.");
return ModalUtils.GetOrAdd(type, this);

View File

@@ -1,5 +1,7 @@
using Discord.Interactions.Utilities;
using Discord.Utils;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
@@ -12,6 +14,7 @@ internal sealed class DefaultArrayModalComponentConverter<T> : ModalComponentTyp
private readonly Type _underlyingType;
private readonly TypeReader _typeReader;
private readonly ImmutableArray<ChannelType> _channelTypes;
private readonly ImmutableArray<EnumSelectMenuOption> _enumOptions;
public DefaultArrayModalComponentConverter(InteractionService interactionService)
{
@@ -56,13 +59,14 @@ internal sealed class DefaultArrayModalComponentConverter<T> : ModalComponentTyp
=> [ChannelType.Forum],
_ => []
};
_enumOptions = _underlyingType!.IsEnum ? [..EnumUtils.BuildSelectMenuOptions(_underlyingType)] : ImmutableArray<EnumSelectMenuOption>.Empty;
}
public override async Task<TypeConverterResult> ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services)
{
var objs = new List<object>();
if (_typeReader is not null && option.Values.Count > 0)
foreach (var value in option.Values)
{
@@ -77,7 +81,7 @@ internal sealed class DefaultArrayModalComponentConverter<T> : ModalComponentTyp
{
if (!TryGetModalInteractionData(context, out var modalData))
{
return TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"{typeof(IModalInteractionData).Name} cannot be accessed from the provided {typeof(IInteractionContext).Name} type.");
return TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"{nameof(IModalInteractionData)} cannot be accessed from the provided {nameof(IInteractionContext)} type.");
}
var resolvedSnowflakes = new Dictionary<ulong, ISnowflakeEntity>();
@@ -134,49 +138,44 @@ internal sealed class DefaultArrayModalComponentConverter<T> : ModalComponentTyp
if (builder is not SelectMenuBuilder selectMenu || !component.ComponentType.IsSelectType())
throw new InvalidOperationException($"Component type of the input {component.CustomId} of modal {component.Modal.Type.FullName} must be a select type.");
switch (value)
if (!_enumOptions.IsEmpty)
{
case IUser user:
selectMenu.WithDefaultValues(SelectMenuDefaultValue.FromUser(user));
break;
case IRole role:
selectMenu.WithDefaultValues(SelectMenuDefaultValue.FromRole(role));
break;
case IChannel channel:
selectMenu.WithDefaultValues(SelectMenuDefaultValue.FromChannel(channel));
break;
case IMentionable mentionable:
selectMenu.WithDefaultValues(mentionable switch
var visibleOptions = _enumOptions.Where(x => !x.Predicate?.Invoke(interaction) ?? true);
var enumValues = value is IEnumerable valueArr ? valueArr.Cast<Enum>().ToArray() : null;
foreach (var option in visibleOptions)
{
var optionBuilder = new SelectMenuOptionBuilder(option.MenuOption);
if (enumValues is not null)
optionBuilder.IsDefault = enumValues.Contains(option.Value);
selectMenu.AddOption(optionBuilder);
}
return Task.CompletedTask;
}
selectMenu.DefaultValues = value switch
{
IEnumerable<IUser> defaultUsers => defaultUsers.Select(SelectMenuDefaultValue.FromUser).ToList(),
IEnumerable<IRole> defaultRoles => defaultRoles.Select(SelectMenuDefaultValue.FromRole).ToList(),
IEnumerable<IChannel> defaultChannels =>
defaultChannels.Select(SelectMenuDefaultValue.FromChannel).ToList(),
IEnumerable<IMentionable> defaultMentionables => defaultMentionables
.Select(x =>
{
IUser user => SelectMenuDefaultValue.FromUser(user),
IRole role => SelectMenuDefaultValue.FromRole(role),
IChannel channel => SelectMenuDefaultValue.FromChannel(channel),
_ => throw new InvalidOperationException($"Mentionable select cannot be populated using an entity with type: {mentionable.GetType().FullName}")
});
break;
case IEnumerable<IUser> defaultUsers:
selectMenu.DefaultValues = defaultUsers.Select(SelectMenuDefaultValue.FromUser).ToList();
break;
case IEnumerable<IRole> defaultRoles:
selectMenu.DefaultValues = defaultRoles.Select(SelectMenuDefaultValue.FromRole).ToList();
break;
case IEnumerable<IChannel> defaultChannels:
selectMenu.DefaultValues = defaultChannels.Select(SelectMenuDefaultValue.FromChannel).ToList();
break;
case IEnumerable<IMentionable> defaultMentionables:
selectMenu.DefaultValues = defaultMentionables.Where(x => x is IUser or IRole or IChannel)
.Select(x =>
return x switch
{
return x switch
{
IUser user => SelectMenuDefaultValue.FromUser(user),
IRole role => SelectMenuDefaultValue.FromRole(role),
IChannel channel => SelectMenuDefaultValue.FromChannel(channel),
_ => throw new InvalidOperationException($"Mentionable select cannot be populated using an entity with type: {x.GetType().FullName}")
};
})
.ToList();
break;
IUser user => SelectMenuDefaultValue.FromUser(user),
IRole role => SelectMenuDefaultValue.FromRole(role),
_ => throw new InvalidOperationException(
$"Mentionable select cannot be populated using an entity with type: {x.GetType().FullName}")
};
})
.ToList(),
_ => selectMenu.DefaultValues
};
if (component.ComponentType == ComponentType.ChannelSelect && _channelTypes.Length > 0)

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
@@ -16,7 +17,7 @@ internal abstract class DefaultSnowflakeModalComponentConverter<T> : ModalCompon
if (!TryGetModalInteractionData(context, out modalData))
{
preemptiveResult = TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"{typeof(IModalInteractionData).Name} cannot be accessed from the provided {typeof(IInteractionContext).Name} type.");
preemptiveResult = TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"{nameof(IModalInteractionData)} cannot be accessed from the provided {nameof(IInteractionContext)} type.");
return true;
}
@@ -51,52 +52,47 @@ internal abstract class DefaultSnowflakeModalComponentConverter<T> : ModalCompon
internal class DefaultAttachmentModalComponentConverter<T> : DefaultSnowflakeModalComponentConverter<T>
where T : class, IAttachment
{
public override async Task<TypeConverterResult> ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services)
public override Task<TypeConverterResult> ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services)
{
if (TryGetPreemptiveResult(context, option, ComponentType.FileUpload, out var result, out var modalData, out var id))
{
return result;
return Task.FromResult(result);
}
var resolvedEntity = modalData.Attachments.FirstOrDefault(x => x.Id == id);
if (resolvedEntity is null)
{
return TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Snowflake entity reference for the {option.Type} cannot be resolved.");
return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Snowflake entity reference for the {option.Type} cannot be resolved."));
}
return TypeConverterResult.FromSuccess(resolvedEntity);
return Task.FromResult(TypeConverterResult.FromSuccess(resolvedEntity));
}
}
internal class DefaultUserModalComponentConverter<T> : DefaultSnowflakeModalComponentConverter<T>
where T : class, IUser
{
public override async Task<TypeConverterResult> ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services)
public override Task<TypeConverterResult> ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services)
{
if (TryGetPreemptiveResult(context, option, ComponentType.UserSelect, out var result, out var modalData, out var id))
{
return result;
return Task.FromResult(result);
}
var resolvedEntity = modalData.Members.UnionBy(modalData.Users, x => x.Id).FirstOrDefault(x => x.Id == id);
if (resolvedEntity is null)
{
return TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Snowflake entity reference for the {option.Type} cannot be resolved.");
return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Snowflake entity reference for the {option.Type} cannot be resolved."));
}
return TypeConverterResult.FromSuccess(resolvedEntity);
return Task.FromResult(TypeConverterResult.FromSuccess(resolvedEntity));
}
public override Task WriteAsync<TBuilder>(TBuilder builder, IDiscordInteraction interaction, InputComponentInfo component, object value)
{
if (value is null)
{
return Task.CompletedTask;
}
if (builder is not SelectMenuBuilder selectMenu || selectMenu.Type is not ComponentType.UserSelect)
if (builder is not SelectMenuBuilder { Type: ComponentType.UserSelect } selectMenu)
{
throw new InvalidOperationException($"{typeof(DefaultUserModalComponentConverter<T>).Name} can only be used with User Select components.");
}
@@ -106,9 +102,14 @@ internal class DefaultUserModalComponentConverter<T> : DefaultSnowflakeModalComp
throw new InvalidOperationException($"Multi-select User Select cannot be used with a single {typeof(T).Name} entity.");
}
if(value is null)
{
return Task.CompletedTask;
}
if (value is not IUser user)
{
throw new InvalidOperationException($"{typeof(T).Name} cannot be used to assign default User Select values. Expected {typeof(IUser).Name}");
throw new InvalidOperationException($"{typeof(T).Name} cannot be used to assign default User Select values. Expected {nameof(IUser)}");
}
selectMenu.WithDefaultValues(SelectMenuDefaultValue.FromUser(user));
@@ -120,31 +121,26 @@ internal class DefaultUserModalComponentConverter<T> : DefaultSnowflakeModalComp
internal class DefaultRoleModalComponentConverter<T> : DefaultSnowflakeModalComponentConverter<T>
where T : class, IRole
{
public override async Task<TypeConverterResult> ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services)
public override Task<TypeConverterResult> ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services)
{
if (TryGetPreemptiveResult(context, option, ComponentType.RoleSelect, out var result, out var modalData, out var id))
{
return result;
return Task.FromResult(result);
}
var resolvedEntity = modalData.Roles.FirstOrDefault(x => x.Id == id);
if (resolvedEntity is null)
{
return TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Snowflake entity reference for the {option.Type} cannot be resolved.");
return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Snowflake entity reference for the {option.Type} cannot be resolved."));
}
return TypeConverterResult.FromSuccess(resolvedEntity);
return Task.FromResult(TypeConverterResult.FromSuccess(resolvedEntity));
}
public override Task WriteAsync<TBuilder>(TBuilder builder, IDiscordInteraction interaction, InputComponentInfo component, object value)
{
if(value is null)
{
return Task.CompletedTask;
}
if (builder is not SelectMenuBuilder selectMenu || selectMenu.Type is not ComponentType.RoleSelect)
if (builder is not SelectMenuBuilder { Type: ComponentType.RoleSelect } selectMenu)
{
throw new InvalidOperationException($"{typeof(DefaultRoleModalComponentConverter<T>).Name} can only be used with Role Select components.");
}
@@ -154,9 +150,14 @@ internal class DefaultRoleModalComponentConverter<T> : DefaultSnowflakeModalComp
throw new InvalidOperationException($"Multi-select Role Select cannot be used with a single {typeof(T).Name} entity.");
}
if(value is null)
{
return Task.CompletedTask;
}
if (value is not IRole role)
{
throw new InvalidOperationException($"{typeof(T).Name} cannot be used to assign default Role Select values. Expected {typeof(IRole).Name}");
throw new InvalidOperationException($"{typeof(T).Name} cannot be used to assign default Role Select values. Expected {nameof(IRole)}");
}
selectMenu.WithDefaultValues(SelectMenuDefaultValue.FromRole(role));
@@ -168,43 +169,77 @@ internal class DefaultRoleModalComponentConverter<T> : DefaultSnowflakeModalComp
internal class DefaultChannelModalComponentConverter<T> : DefaultSnowflakeModalComponentConverter<T>
where T : class, IChannel
{
public override async Task<TypeConverterResult> ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services)
private readonly ImmutableArray<ChannelType> _channelTypes;
public DefaultChannelModalComponentConverter()
{
var type = typeof(T);
_channelTypes = true switch
{
_ when typeof(IStageChannel).IsAssignableFrom(type)
=> [ChannelType.Stage],
_ when typeof(IVoiceChannel).IsAssignableFrom(type)
=> [ChannelType.Voice],
_ when typeof(IDMChannel).IsAssignableFrom(type)
=> [ChannelType.DM],
_ when typeof(IGroupChannel).IsAssignableFrom(type)
=> [ChannelType.Group],
_ when typeof(ICategoryChannel).IsAssignableFrom(type)
=> [ChannelType.Category],
_ when typeof(INewsChannel).IsAssignableFrom(type)
=> [ChannelType.News],
_ when typeof(IThreadChannel).IsAssignableFrom(type)
=> [ChannelType.PublicThread, ChannelType.PrivateThread, ChannelType.NewsThread],
_ when typeof(ITextChannel).IsAssignableFrom(type)
=> [ChannelType.Text],
_ when typeof(IMediaChannel).IsAssignableFrom(type)
=> [ChannelType.Media],
_ when typeof(IForumChannel).IsAssignableFrom(type)
=> [ChannelType.Forum],
_ => []
};
}
public override Task<TypeConverterResult> ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services)
{
if (TryGetPreemptiveResult(context, option, ComponentType.ChannelSelect, out var result, out var modalData, out var id))
{
return result;
return Task.FromResult(result);
}
var resolvedEntity = modalData.Channels.FirstOrDefault(x => x.Id == id);
if (resolvedEntity is null)
{
return TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Snowflake entity reference for the {option.Type} cannot be resolved.");
return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Snowflake entity reference for the {option.Type} cannot be resolved."));
}
return TypeConverterResult.FromSuccess(resolvedEntity);
return Task.FromResult(TypeConverterResult.FromSuccess(resolvedEntity));
}
public override Task WriteAsync<TBuilder>(TBuilder builder, IDiscordInteraction interaction, InputComponentInfo component, object value)
{
if (value is null)
{
return Task.CompletedTask;
}
if (builder is not SelectMenuBuilder selectMenu || selectMenu.Type is not ComponentType.ChannelSelect)
if (builder is not SelectMenuBuilder { Type: ComponentType.ChannelSelect } selectMenu)
{
throw new InvalidOperationException($"{typeof(DefaultChannelModalComponentConverter<T>).Name} can only be used with Channel Select components.");
}
selectMenu.WithChannelTypes(_channelTypes.ToList());
if(selectMenu.MaxValues > 1)
{
throw new InvalidOperationException($"Multi-select Channel Select cannot be used with a single {typeof(T).Name} entity.");
}
if (value is null)
{
return Task.CompletedTask;
}
if(value is not IChannel channel)
{
throw new InvalidOperationException($"{typeof(T).Name} cannot be used to assign default Channel Select values. Expected {typeof(IChannel).Name}");
throw new InvalidOperationException($"{typeof(T).Name} cannot be used to assign default Channel Select values. Expected {nameof(IChannel)}");
}
selectMenu.WithDefaultValues(SelectMenuDefaultValue.FromChannel(channel));
@@ -216,11 +251,11 @@ internal class DefaultChannelModalComponentConverter<T> : DefaultSnowflakeModalC
internal class DefaultMentionableModalComponentConverter<T> : DefaultSnowflakeModalComponentConverter<T>
where T : class, IMentionable
{
public override async Task<TypeConverterResult> ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services)
public override Task<TypeConverterResult> ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services)
{
if (TryGetPreemptiveResult(context, option, ComponentType.MentionableSelect, out var result, out var modalData, out var id))
{
return result;
return Task.FromResult(result);
}
var resolvedMentionables = new Dictionary<ulong, IMentionable>();
@@ -236,20 +271,15 @@ internal class DefaultMentionableModalComponentConverter<T> : DefaultSnowflakeMo
if (resolvedMentionables.Count == 0 || !resolvedMentionables.TryGetValue(id, out var entity))
{
return TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Snowflake entity reference for the {option.Type} cannot be resolved.");
return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Snowflake entity reference for the {option.Type} cannot be resolved."));
}
return TypeConverterResult.FromSuccess(entity);
return Task.FromResult(TypeConverterResult.FromSuccess(entity));
}
public override Task WriteAsync<TBuilder>(TBuilder builder, IDiscordInteraction interaction, InputComponentInfo component, object value)
{
if (value is null)
{
return Task.CompletedTask;
}
if (builder is not SelectMenuBuilder selectMenu || selectMenu.Type is not ComponentType.MentionableSelect)
if (builder is not SelectMenuBuilder { Type: ComponentType.MentionableSelect } selectMenu)
{
throw new InvalidOperationException($"{typeof(DefaultMentionableModalComponentConverter<T>).Name} can only be used with Mentionable Select components.");
}
@@ -259,11 +289,16 @@ internal class DefaultMentionableModalComponentConverter<T> : DefaultSnowflakeMo
throw new InvalidOperationException($"Multi-select Mentionable Select cannot be used with a single {typeof(T).Name} entity.");
}
if (value is null)
{
return Task.CompletedTask;
}
var defaultValue = value switch
{
IRole role => SelectMenuDefaultValue.FromRole(role),
IUser user => SelectMenuDefaultValue.FromUser(user),
_ => throw new InvalidOperationException($"{typeof(T).Name} cannot be used to assign default Mentionable Select values. Expected {typeof(IUser).Name} or {typeof(IRole).Name}")
_ => throw new InvalidOperationException($"{typeof(T).Name} cannot be used to assign default Mentionable Select values. Expected {nameof(IUser)} or {nameof(IRole)}")
};
selectMenu.WithDefaultValues(defaultValue);

View File

@@ -1,3 +1,4 @@
using Discord.Interactions.Utilities;
using System;
using System.Collections.Immutable;
using System.Linq;
@@ -9,41 +10,18 @@ namespace Discord.Interactions;
internal sealed class EnumModalComponentConverter<T> : ModalComponentTypeConverter<T>
where T : struct, Enum
{
private record Option(SelectMenuOptionBuilder OptionBuilder, Predicate<IDiscordInteraction> Predicate, T Value);
private readonly bool _isFlags;
private readonly ImmutableArray<Option> _options;
private readonly ImmutableArray<EnumSelectMenuOption> _options;
public EnumModalComponentConverter()
{
var names = Enum.GetNames(typeof(T));
var members = names.SelectMany(x => typeof(T).GetMember(x));
_isFlags = typeof(T).IsDefined(typeof(FlagsAttribute));
_options = members.Select<MemberInfo, Option>(x =>
{
var selectMenuOptionAttr = x.GetCustomAttribute<SelectMenuOptionAttribute>();
Emoji emoji = null;
Emote emote = null;
if (!string.IsNullOrEmpty(selectMenuOptionAttr?.Emote) && !(Emote.TryParse(selectMenuOptionAttr.Emote, out emote) || Emoji.TryParse(selectMenuOptionAttr.Emote, out emoji)))
throw new ArgumentException($"Unable to parse {selectMenuOptionAttr.Emote} of {x.DeclaringType.Name}.{x.Name} into an {typeof(Emote).Name} or an {typeof(Emoji).Name}");
var hideAttr = x.GetCustomAttribute<HideAttribute>();
Predicate<IDiscordInteraction> predicate = hideAttr != null ? hideAttr.Predicate : null;
var value = Enum.Parse<T>(x.Name);
var optionBuilder = new SelectMenuOptionBuilder(x.GetCustomAttribute<ChoiceDisplayAttribute>()?.Name ?? x.Name, x.Name, selectMenuOptionAttr?.Description, emote != null ? emote : emoji, selectMenuOptionAttr?.IsDefault);
return new(optionBuilder, predicate, value);
}).ToImmutableArray();
_options = EnumUtils.BuildSelectMenuOptions(typeof(T)).ToImmutableArray();
}
public override Task<TypeConverterResult> ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services)
{
if (option.Type is not ComponentType.SelectMenu or ComponentType.TextInput)
if (option.Type is not ComponentType.SelectMenu and not ComponentType.TextInput)
return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"{option.Type} input type cannot be converted to {typeof(T).FullName}"));
var value = option.Type switch
@@ -69,44 +47,16 @@ internal sealed class EnumModalComponentConverter<T> : ModalComponentTypeConvert
var visibleOptions = _options.Where(x => !x.Predicate?.Invoke(interaction) ?? true);
if (value is T enumValue)
foreach (var option in visibleOptions)
{
foreach(var option in visibleOptions)
{
option.OptionBuilder.IsDefault = _isFlags ? enumValue.HasFlag(option.Value) : enumValue.Equals(option.Value);
}
}
var optionBuilder = new SelectMenuOptionBuilder(option.MenuOption);
selectMenu.WithOptions([.. visibleOptions.Select(x => x.OptionBuilder)]);
if(value is T enumValue && option.Value is T optionValue)
optionBuilder.IsDefault = _isFlags ? enumValue.HasFlag(optionValue) : enumValue.Equals(option.Value);
selectMenu.AddOption(optionBuilder);
}
return Task.CompletedTask;
}
}
/// <summary>
/// Adds additional metadata to enum fields that are used for select-menus.
/// </summary>
/// <remarks>
/// To manually add select menu options to modal components, use <see cref="ModalSelectMenuOptionAttribute"/> instead.
/// </remarks>
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
public class SelectMenuOptionAttribute : Attribute
{
/// <summary>
/// Gets or sets the desription of the option.
/// </summary>
public string Description { get; set; }
/// <summary>
/// Gets or sets whether the option is selected by default.
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// Gets or sets the emote of the option.
/// </summary>
/// <remarks>
/// Can be either an <see cref="Emoji"/> or an <see cref="Discord.Emote"/>
/// </remarks>
public string Emote { get; set; }
}

View File

@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace Discord.Interactions.Utilities;
internal record EnumSelectMenuOption(
SelectMenuOption MenuOption,
Predicate<IDiscordInteraction> Predicate,
object Value);
internal class EnumUtils
{
public static IEnumerable<EnumSelectMenuOption> BuildSelectMenuOptions(Type enumType)
{
if(!enumType.IsEnum)
throw new ArgumentException($"Type {enumType} is not an enum");
var names = Enum.GetNames(enumType);
var members = names.SelectMany(x => enumType.GetMember(x));
foreach (var member in members)
{
var selectMenuOptionAttr = member.GetCustomAttribute<SelectMenuOptionAttribute>();
Emoji emoji = null;
Emote emote = null;
if (!string.IsNullOrEmpty(selectMenuOptionAttr?.Emote) && !(Emote.TryParse(selectMenuOptionAttr.Emote, out emote) || Emoji.TryParse(selectMenuOptionAttr.Emote, out emoji)))
throw new ArgumentException($"Unable to parse {selectMenuOptionAttr.Emote} of {member.DeclaringType.Name}.{member.Name} into an {nameof(Emote)} or an {nameof(Emoji)}");
var hideAttr = member.GetCustomAttribute<HideAttribute>();
Predicate<IDiscordInteraction> predicate = hideAttr != null ? hideAttr.Predicate : null;
var value = Enum.Parse(enumType, member.Name);
var optionBuilder = new SelectMenuOptionBuilder(member.GetCustomAttribute<ChoiceDisplayAttribute>()?.Name ?? member.Name,
member.Name, selectMenuOptionAttr?.Description, emote != null ? emote : emoji, selectMenuOptionAttr?.IsDefault);
yield return new EnumSelectMenuOption(optionBuilder.Build(), predicate, value);
}
}
}

View File

@@ -44,6 +44,9 @@ namespace Discord.Interactions
public static bool TryRemove<T>(out ModalInfo modalInfo) where T : class, IModal
=> TryRemove(typeof(T), out modalInfo);
public static bool Contains(Type type)
=> _modalInfos.ContainsKey(type);
public static void Clear() => _modalInfos.Clear();
public static int Count() => _modalInfos.Count;

View File

@@ -2,7 +2,7 @@
<package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd">
<metadata>
<id>Discord.Net</id>
<version>3.18.0$suffix$</version>
<version>3.19.0$suffix$</version>
<title>Discord.Net</title>
<authors>Discord.Net Contributors</authors>
<owners>foxbot</owners>
@@ -15,28 +15,28 @@
<readme>NUGET_README.md</readme>
<dependencies>
<group targetFramework="net10.0">
<dependency id="Discord.Net.Core" version="3.18.0$suffix$" />
<dependency id="Discord.Net.Rest" version="3.18.0$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.18.0$suffix$" />
<dependency id="Discord.Net.Commands" version="3.18.0$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.18.0$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.18.0$suffix$" />
<dependency id="Discord.Net.Core" version="3.19.0$suffix$" />
<dependency id="Discord.Net.Rest" version="3.19.0$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.19.0$suffix$" />
<dependency id="Discord.Net.Commands" version="3.19.0$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.19.0$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.19.0$suffix$" />
</group>
<group targetFramework="net9.0">
<dependency id="Discord.Net.Core" version="3.18.0$suffix$" />
<dependency id="Discord.Net.Rest" version="3.18.0$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.18.0$suffix$" />
<dependency id="Discord.Net.Commands" version="3.18.0$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.18.0$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.18.0$suffix$" />
<dependency id="Discord.Net.Core" version="3.19.0$suffix$" />
<dependency id="Discord.Net.Rest" version="3.19.0$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.19.0$suffix$" />
<dependency id="Discord.Net.Commands" version="3.19.0$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.19.0$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.19.0$suffix$" />
</group>
<group targetFramework="net8.0">
<dependency id="Discord.Net.Core" version="3.18.0$suffix$" />
<dependency id="Discord.Net.Rest" version="3.18.0$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.18.0$suffix$" />
<dependency id="Discord.Net.Commands" version="3.18.0$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.18.0$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.18.0$suffix$" />
<dependency id="Discord.Net.Core" version="3.19.0$suffix$" />
<dependency id="Discord.Net.Rest" version="3.19.0$suffix$" />
<dependency id="Discord.Net.WebSocket" version="3.19.0$suffix$" />
<dependency id="Discord.Net.Commands" version="3.19.0$suffix$" />
<dependency id="Discord.Net.Webhook" version="3.19.0$suffix$" />
<dependency id="Discord.Net.Interactions" version="3.19.0$suffix$" />
</group>
</dependencies>
</metadata>

View File

@@ -98,6 +98,7 @@ namespace Discord
AssertFlag(() => new ChannelPermissions(sendPolls: true), ChannelPermission.SendPolls);
AssertFlag(() => new ChannelPermissions(useExternalApps: true), ChannelPermission.UseExternalApps);
AssertFlag(() => new ChannelPermissions(pinMessages: true), ChannelPermission.PinMessages);
AssertFlag(() => new ChannelPermissions(bypassSlowmode: true), ChannelPermission.BypassSlowmode);
}
/// <summary>
@@ -169,6 +170,7 @@ namespace Discord
AssertUtil(ChannelPermission.UseExternalApps, x => x.UserExternalApps, (p, enable) => p.Modify(useExternalApps: enable));
AssertUtil(ChannelPermission.UseExternalSounds, x => x.UseExternalSounds, (p, enable) => p.Modify(useExternalSounds: enable));
AssertUtil(ChannelPermission.PinMessages, x => x.PinMessages, (p, enable) => p.Modify(pinMessages: enable));
AssertUtil(ChannelPermission.BypassSlowmode, x => x.BypassSlowmode, (p, enable) => p.Modify(bypassSlowmode: enable));
}
/// <summary>

View File

@@ -110,6 +110,7 @@ namespace Discord
AssertFlag(() => new GuildPermissions(useExternalSounds: true), GuildPermission.UseExternalSounds);
AssertFlag(() => new GuildPermissions(createEvents: true), GuildPermission.CreateEvents);
AssertFlag(() => new GuildPermissions(pinMessages: true), GuildPermission.PinMessages);
AssertFlag(() => new GuildPermissions(bypassSlowmode: true), GuildPermission.BypassSlowmode);
}
/// <summary>
@@ -199,6 +200,7 @@ namespace Discord
AssertUtil(GuildPermission.UseExternalSounds, x => x.UserExternalSounds, (p, enable) => p.Modify(useExternalSounds: enable));
AssertUtil(GuildPermission.CreateEvents, x => x.CreateEvents, (p, enable) => p.Modify(createEvents: enable));
AssertUtil(GuildPermission.PinMessages, x => x.PinMessages, (p, enable) => p.Modify(pinMessages: enable));
AssertUtil(GuildPermission.BypassSlowmode, x => x.BypassSlowmode, (p, enable) => p.Modify(bypassSlowmode: enable));
}
}
}