diff --git a/src/Discord.Net.Interactions/Attributes/HideAttribute.cs b/src/Discord.Net.Interactions/Attributes/HideAttribute.cs new file mode 100644 index 0000000000..4dcec6f5c0 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/HideAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions; + +/// +/// Enum values tagged with this attribute will not be displayed as a parameter choice +/// +/// +/// This attribute must be used along with the default and . +/// +[AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = true)] +public class HideAttribute : Attribute +{ + /// + /// Can be optionally implemented by inherited types to conditionally hide an enum value. + /// + /// + /// Only runs on prior to modal construction. For slash command parameters, this method is ignored. + /// + /// Interaction that is called on. + /// + /// if the attribute should be active and hide the value. + /// + public virtual bool Predicate(IDiscordInteraction interaction) => true; +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/InputLabelAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/InputLabelAttribute.cs index fdeb8c4144..0e018f745f 100644 --- a/src/Discord.Net.Interactions/Attributes/Modals/InputLabelAttribute.cs +++ b/src/Discord.Net.Interactions/Attributes/Modals/InputLabelAttribute.cs @@ -13,13 +13,20 @@ public class InputLabelAttribute : Attribute /// public string Label { get; } + /// + /// Gets the label description of the input. + /// + public string Description { get; set; } + /// /// Creates a custom label for an modal input. /// /// The label of the input. - public InputLabelAttribute(string label) + /// The label description of the input. + public InputLabelAttribute(string label, string description = null) { Label = label; + Description = description; } } } diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalChannelSelectAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalChannelSelectAttribute.cs new file mode 100644 index 0000000000..5e445f231e --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalChannelSelectAttribute.cs @@ -0,0 +1,16 @@ +namespace Discord.Interactions; + +/// +/// Marks a property as a channel select. +/// +public class ModalChannelSelectAttribute : ModalSelectComponentAttribute +{ + /// + public override ComponentType ComponentType => ComponentType.ChannelSelect; + + /// + /// Create a new . + /// + /// Custom ID of the channel select component. + public ModalChannelSelectAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId, minValues, maxValues) { } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalComponentAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalComponentAttribute.cs new file mode 100644 index 0000000000..7b6ce3314d --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalComponentAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace Discord.Interactions; + +/// +/// Mark an property as a modal component field. +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] +public abstract class ModalComponentAttribute : Attribute +{ + /// + /// Gets the type of the component. + /// + public abstract ComponentType ComponentType { get; } + + internal ModalComponentAttribute() { } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalFileUploadAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalFileUploadAttribute.cs new file mode 100644 index 0000000000..5271b64a24 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalFileUploadAttribute.cs @@ -0,0 +1,32 @@ +namespace Discord.Interactions; + +/// +/// Marks a property as a file upload input. +/// +public class ModalFileUploadAttribute : ModalInputAttribute +{ + /// + public override ComponentType ComponentType => ComponentType.FileUpload; + + /// + /// Get the minimum number of files that can be uploaded. + /// + public int MinValues { get; set; } = 1; + + /// + /// Get the maximum number of files that can be uploaded. + /// + public int MaxValues { get; set; } = 1; + + /// + /// Create a new . + /// + /// Custom ID of the file upload component. + /// Minimum number of files that can be uploaded. + /// Maximum number of files that can be uploaded. + public ModalFileUploadAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId) + { + MinValues = minValues; + MaxValues = maxValues; + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalInputAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalInputAttribute.cs index e9b877268a..5829b363f8 100644 --- a/src/Discord.Net.Interactions/Attributes/Modals/ModalInputAttribute.cs +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalInputAttribute.cs @@ -6,23 +6,18 @@ namespace Discord.Interactions /// Mark an property as a modal input field. /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] - public abstract class ModalInputAttribute : Attribute + public abstract class ModalInputAttribute : ModalComponentAttribute { /// /// Gets the custom id of the text input. /// public string CustomId { get; } - /// - /// Gets the type of the component. - /// - public abstract ComponentType ComponentType { get; } - /// /// Create a new . /// /// The custom id of the input. - protected ModalInputAttribute(string customId) + internal ModalInputAttribute(string customId) { CustomId = customId; } diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalMentionableSelectAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalMentionableSelectAttribute.cs new file mode 100644 index 0000000000..57a0b1b991 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalMentionableSelectAttribute.cs @@ -0,0 +1,18 @@ +namespace Discord.Interactions; + +/// +/// Marks a property as a mentionable select input. +/// +public class ModalMentionableSelectAttribute : ModalSelectComponentAttribute +{ + /// + public override ComponentType ComponentType => ComponentType.MentionableSelect; + + /// + /// Create a new . + /// + /// Custom ID of the mentionable select component. + /// Minimum number of values that can be selected. + /// Maximum number of values that can be selected + public ModalMentionableSelectAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId, minValues, maxValues) { } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalRoleSelectAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalRoleSelectAttribute.cs new file mode 100644 index 0000000000..3808be32c5 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalRoleSelectAttribute.cs @@ -0,0 +1,18 @@ +namespace Discord.Interactions; + +/// +/// Marks a property as a role select input. +/// +public class ModalRoleSelectAttribute : ModalSelectComponentAttribute +{ + /// + public override ComponentType ComponentType => ComponentType.RoleSelect; + + /// + /// Create a new . + /// + /// Custom ID of the role select component. + /// Minimum number of values that can be selected. + /// Maximum number of values that can be selected. + public ModalRoleSelectAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId, minValues, maxValues) { } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalSelectComponentAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalSelectComponentAttribute.cs new file mode 100644 index 0000000000..ccbc5891b8 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalSelectComponentAttribute.cs @@ -0,0 +1,28 @@ +namespace Discord.Interactions; + +/// +/// Base attribute for select-menu, user, channel, role, and mentionable select inputs in modals. +/// +public abstract class ModalSelectComponentAttribute : ModalInputAttribute +{ + /// + /// Gets or sets the minimum number of values that can be selected. + /// + public int MinValues { get; set; } = 1; + + /// + /// Gets or sets the maximum number of values that can be selected. + /// + public int MaxValues { get; set; } = 1; + + /// + /// Gets or sets the placeholder text. + /// + public string Placeholder { get; set; } + + internal ModalSelectComponentAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId) + { + MinValues = minValues; + MaxValues = maxValues; + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalSelectMenuAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalSelectMenuAttribute.cs new file mode 100644 index 0000000000..5d70eb0c8b --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalSelectMenuAttribute.cs @@ -0,0 +1,18 @@ +namespace Discord.Interactions; + +/// +/// Marks a property as a select menu input. +/// +public sealed class ModalSelectMenuAttribute : ModalSelectComponentAttribute +{ + /// + public override ComponentType ComponentType => ComponentType.SelectMenu; + + /// + /// Create a new . + /// + /// Custom ID of the select menu component. + /// Minimum number of values that can be selected. + /// Maximum number of values that can be selected. + public ModalSelectMenuAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId, minValues, maxValues) { } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalSelectMenuOptionAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalSelectMenuOptionAttribute.cs new file mode 100644 index 0000000000..2cf81fb659 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalSelectMenuOptionAttribute.cs @@ -0,0 +1,58 @@ +using System; + +namespace Discord.Interactions; + +/// +/// Adds a select menu option to the marked field. +/// +/// +/// To add additional metadata to enum fields, use instead. +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)] +public class ModalSelectMenuOptionAttribute : Attribute +{ + /// + /// Gets the label of the option. + /// + public string Label { get; } + + /// + /// Gets or sets the description of the option. + /// + public string Description { get; set; } + + /// + /// Gets the value of the option. + /// + public string Value { get; } + + /// + /// Gets or sets the emote of the option. + /// + /// + /// Can be either an or an + /// + public string Emote { get; set; } + + /// + /// Gets or sets whether the option is selected by default. + /// + public bool IsDefault { get; set; } + + /// + /// Create a new . + /// + /// Label of the option. + /// Value of the option. + /// Description of the option. + /// Emote of the option. Can be either an or an + /// Whether the option is selected by default + public ModalSelectMenuOptionAttribute(string label, string value, string description = null, string emote = null, bool isDefault = false) + { + Label = label; + Value = value; + Description = description; + Emote = emote; + IsDefault = isDefault; + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalTextDisplayAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalTextDisplayAttribute.cs new file mode 100644 index 0000000000..300db32b24 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalTextDisplayAttribute.cs @@ -0,0 +1,24 @@ +namespace Discord.Interactions; + +/// +/// Marks a property as a text input. +/// +public class ModalTextDisplayAttribute : ModalComponentAttribute +{ + /// + public override ComponentType ComponentType => ComponentType.TextDisplay; + + /// + /// Gets the content of the text display. + /// + public string Content { get; } + + /// + /// Create a new . + /// + /// Content of the text display. + public ModalTextDisplayAttribute(string content = null) + { + Content = content; + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalUserSelectAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalUserSelectAttribute.cs new file mode 100644 index 0000000000..cc3cf28802 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalUserSelectAttribute.cs @@ -0,0 +1,18 @@ +namespace Discord.Interactions; + +/// +/// Marks a property as a user select input. +/// +public class ModalUserSelectAttribute : ModalSelectComponentAttribute +{ + /// + public override ComponentType ComponentType => ComponentType.UserSelect; + + /// + /// Create a new . + /// + /// Custom ID of the user select component. + /// Minimum number of values that can be selected. + /// Maximum number of values that can be selected. + public ModalUserSelectAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId, minValues, maxValues) { } +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Components/ChannelSelectComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Components/ChannelSelectComponentBuilder.cs new file mode 100644 index 0000000000..0164245871 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Components/ChannelSelectComponentBuilder.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Discord.Interactions.Builders; + +/// +/// Represents a builder for creating . +/// +public class ChannelSelectComponentBuilder : SnowflakeSelectComponentBuilder +{ + protected override ChannelSelectComponentBuilder Instance => this; + + /// + /// Initializes a new . + /// + /// Parent modal of this component. + public ChannelSelectComponentBuilder(ModalBuilder modal) : base(modal, ComponentType.ChannelSelect) { } + + /// + /// Adds a default value to . + /// + /// The channel ID to add as a default value. + /// + /// The builder instance. + /// + public ChannelSelectComponentBuilder AddDefaulValue(ulong channelId) + { + _defaultValues.Add(new SelectMenuDefaultValue(channelId, SelectDefaultValueType.Channel)); + return this; + } + + /// + /// Adds default values to . + /// + /// The channels to add as a default value. + /// + /// The builder instance. + /// + public ChannelSelectComponentBuilder AddDefaultValues(params IEnumerable channels) + { + _defaultValues.AddRange(channels.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.Channel))); + return this; + } + + internal override ChannelSelectComponentInfo Build(ModalInfo modal) + => new(this, modal); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Components/FileUploadComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Components/FileUploadComponentBuilder.cs new file mode 100644 index 0000000000..e004f34f5c --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Components/FileUploadComponentBuilder.cs @@ -0,0 +1,54 @@ +namespace Discord.Interactions.Builders; + +/// +/// Represents a builder for creating . +/// +public class FileUploadComponentBuilder : InputComponentBuilder +{ + protected override FileUploadComponentBuilder Instance => this; + + /// + /// Gets and sets the minimum number of files that can be uploaded. + /// + public int MinValues { get; set; } = 1; + + /// + /// Gets and sets the maximum number of files that can be uploaded. + /// + public int MaxValues { get; set; } = 1; + + /// + /// Initializes a new . + /// + /// + public FileUploadComponentBuilder(ModalBuilder modal) : base(modal) { } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public FileUploadComponentBuilder WithMinValues(int minValues) + { + MinValues = minValues; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public FileUploadComponentBuilder WithMaxValues(int maxValues) + { + MaxValues = maxValues; + return this; + } + + internal override FileUploadComponentInfo Build(ModalInfo modal) + => new (this, modal); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Components/IInputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Components/IInputComponentBuilder.cs new file mode 100644 index 0000000000..4ab2578390 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Components/IInputComponentBuilder.cs @@ -0,0 +1,68 @@ +namespace Discord.Interactions.Builders +{ + /// + /// Represent a builder for creating . + /// + public interface IInputComponentBuilder : IModalComponentBuilder + { + /// + /// Gets the custom id of this input component. + /// + string CustomId { get; } + + /// + /// Gets the label of this input component. + /// + string Label { get; } + + /// + /// Gets the label description of this input component. + /// + string Description { get; } + + /// + /// Gets whether this input component is required. + /// + bool IsRequired { get; } + + /// + /// Get the assigned to this input. + /// + ModalComponentTypeConverter TypeConverter { get; } + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IInputComponentBuilder WithCustomId(string customId); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IInputComponentBuilder WithLabel(string label); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IInputComponentBuilder WithDescription(string description); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IInputComponentBuilder SetIsRequired(bool isRequired); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Components/IModalComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Components/IModalComponentBuilder.cs new file mode 100644 index 0000000000..9d869e4c57 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Components/IModalComponentBuilder.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Discord.Interactions.Builders; + +public interface IModalComponentBuilder +{ + /// + /// Gets the parent modal of this input component. + /// + ModalBuilder Modal { get; } + + /// + /// Gets the component type of this input component. + /// + ComponentType ComponentType { get; } + + /// + /// Get the reference type of this input component. + /// + Type Type { get; } + + /// + /// Get the of this component's property. + /// + PropertyInfo PropertyInfo { get; } + + /// + /// Gets the default value of this input component property. + /// + object DefaultValue { get; } + + /// + /// Gets a collection of the attributes of this component. + /// + IReadOnlyCollection Attributes { get; } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IModalComponentBuilder WithType(Type type); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IModalComponentBuilder SetDefaultValue(object value); + + /// + /// Adds attributes to . + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + IModalComponentBuilder WithAttributes(params Attribute[] attributes); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Components/ISnowflakeSelectComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Components/ISnowflakeSelectComponentBuilder.cs new file mode 100644 index 0000000000..f310e4bb71 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Components/ISnowflakeSelectComponentBuilder.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; + +namespace Discord.Interactions.Builders; + +/// +/// Represent a builder for creating . +/// +public interface ISnowflakeSelectComponentBuilder : IInputComponentBuilder +{ + /// + /// Gets the minimum number of values that can be selected. + /// + int MinValues { get; } + + /// + /// Gets the maximum number of values that can be selected. + /// + int MaxValues { get; } + + /// + /// Gets the placeholder text for this select component. + /// + string Placeholder { get; set; } + + /// + /// Gets the default value collection for this select component. + /// + IReadOnlyCollection DefaultValues { get; } + + /// + /// Gets the default value type of this select component. + /// + SelectDefaultValueType? DefaultValuesType { get; } + + /// + /// Adds a default value to the . + /// + /// Default value to be added. + /// The builder instance. + ISnowflakeSelectComponentBuilder AddDefaultValue(SelectMenuDefaultValue defaultValue); + + /// + /// Sets . + /// + /// New value of the + /// The builder instance. + ISnowflakeSelectComponentBuilder WithMinValues(int minValues); + + /// + /// Sets . + /// + /// New value of the + /// The builder instance. + ISnowflakeSelectComponentBuilder WithMaxValues(int maxValues); + + /// + /// Sets . + /// + /// New value of the + /// The builder instance. + ISnowflakeSelectComponentBuilder WithPlaceholder(string placeholder); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/InputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Components/InputComponentBuilder.cs similarity index 55% rename from src/Discord.Net.Interactions/Builders/Modals/Inputs/InputComponentBuilder.cs rename to src/Discord.Net.Interactions/Builders/Modals/Components/InputComponentBuilder.cs index af0ab3a70e..000df98ce5 100644 --- a/src/Discord.Net.Interactions/Builders/Modals/Inputs/InputComponentBuilder.cs +++ b/src/Discord.Net.Interactions/Builders/Modals/Components/InputComponentBuilder.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Reflection; namespace Discord.Interactions.Builders { @@ -9,15 +8,11 @@ namespace Discord.Interactions.Builders /// /// The this builder yields when built. /// Inherited type. - public abstract class InputComponentBuilder : IInputComponentBuilder + public abstract class InputComponentBuilder : ModalComponentBuilder, IInputComponentBuilder where TInfo : InputComponentInfo where TBuilder : InputComponentBuilder { private readonly List _attributes; - protected abstract TBuilder Instance { get; } - - /// - public ModalBuilder Modal { get; } /// public string CustomId { get; set; } @@ -26,33 +21,20 @@ public abstract class InputComponentBuilder : IInputComponentBu public string Label { get; set; } /// - public bool IsRequired { get; set; } = true; - - /// - public ComponentType ComponentType { get; internal set; } - - /// - public Type Type { get; private set; } - - /// - public PropertyInfo PropertyInfo { get; internal set; } - - /// - public ComponentTypeConverter TypeConverter { get; private set; } + public string Description { get; set; } /// - public object DefaultValue { get; set; } + public bool IsRequired { get; set; } = true; /// - public IReadOnlyCollection Attributes => _attributes; + public ModalComponentTypeConverter TypeConverter { get; private set; } /// /// Creates an instance of /// /// Parent modal of this input component. - public InputComponentBuilder(ModalBuilder modal) + internal InputComponentBuilder(ModalBuilder modal) : base(modal) { - Modal = modal; _attributes = new(); } @@ -83,28 +65,28 @@ public TBuilder WithLabel(string label) } /// - /// Sets . + /// Sets . /// - /// New value of the . + /// New value of the . /// /// The builder instance. /// - public TBuilder SetIsRequired(bool isRequired) + public TBuilder WithDescription(string description) { - IsRequired = isRequired; + Description = description; return Instance; } /// - /// Sets . + /// Sets . /// - /// New value of the . + /// New value of the . /// /// The builder instance. /// - public TBuilder WithComponentType(ComponentType componentType) + public TBuilder SetIsRequired(bool isRequired) { - ComponentType = componentType; + IsRequired = isRequired; return Instance; } @@ -115,56 +97,20 @@ public TBuilder WithComponentType(ComponentType componentType) /// /// The builder instance. /// - public TBuilder WithType(Type type) - { - Type = type; - TypeConverter = Modal._interactionService.GetComponentTypeConverter(type); - return Instance; - } - - /// - /// Sets . - /// - /// New value of the . - /// - /// The builder instance. - /// - public TBuilder SetDefaultValue(object value) - { - DefaultValue = value; - return Instance; - } - - /// - /// Adds attributes to . - /// - /// New attributes to be added to . - /// - /// The builder instance. - /// - public TBuilder WithAttributes(params Attribute[] attributes) + public override TBuilder WithType(Type type) { - _attributes.AddRange(attributes); - return Instance; + TypeConverter = Modal._interactionService.GetModalInputTypeConverter(type); + return base.WithType(type); } - internal abstract TInfo Build(ModalInfo modal); - - //IInputComponentBuilder /// IInputComponentBuilder IInputComponentBuilder.WithCustomId(string customId) => WithCustomId(customId); /// - IInputComponentBuilder IInputComponentBuilder.WithLabel(string label) => WithCustomId(label); - - /// - IInputComponentBuilder IInputComponentBuilder.WithType(Type type) => WithType(type); - - /// - IInputComponentBuilder IInputComponentBuilder.SetDefaultValue(object value) => SetDefaultValue(value); + IInputComponentBuilder IInputComponentBuilder.WithLabel(string label) => WithLabel(label); /// - IInputComponentBuilder IInputComponentBuilder.WithAttributes(params Attribute[] attributes) => WithAttributes(attributes); + IInputComponentBuilder IInputComponentBuilder.WithDescription(string description) => WithDescription(description); /// IInputComponentBuilder IInputComponentBuilder.SetIsRequired(bool isRequired) => SetIsRequired(isRequired); diff --git a/src/Discord.Net.Interactions/Builders/Modals/Components/MentionableSelectComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Components/MentionableSelectComponentBuilder.cs new file mode 100644 index 0000000000..86e1662063 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Components/MentionableSelectComponentBuilder.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Discord.Interactions.Builders; + +/// +/// Represents a builder for creating a . +/// +public class MentionableSelectComponentBuilder : SnowflakeSelectComponentBuilder +{ + protected override MentionableSelectComponentBuilder Instance => this; + + /// + /// Initialize a new . + /// + /// Parent modal of this input component. + public MentionableSelectComponentBuilder(ModalBuilder modal) : base(modal, ComponentType.MentionableSelect) { } + + /// + /// Adds a snowflake ID as a default value to . + /// + /// The ID to add as a default value. + /// Enitity type of the snowflake ID. + /// + /// The builder instance. + /// + public MentionableSelectComponentBuilder AddDefaultValue(ulong id, SelectDefaultValueType type) + { + _defaultValues.Add(new SelectMenuDefaultValue(id, type)); + return this; + } + + /// + /// Add users as a default value to . + /// + /// The users to add as a default value. + /// + /// The builder instance. + /// + public MentionableSelectComponentBuilder AddDefaultValue(params IEnumerable users) + { + _defaultValues.AddRange(users.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.User))); + return this; + } + + /// + /// Adds channels as a default value to . + /// + /// The channel to add as a default value. + /// + /// The builder instance. + /// + public MentionableSelectComponentBuilder AddDefaultValue(params IEnumerable channels) + { + _defaultValues.AddRange(channels.Select(x =>new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.Channel))); + return this; + } + + /// + /// Adds roles as a default value to . + /// + /// The role to add as a default value. + /// + /// The builder instance. + /// + public MentionableSelectComponentBuilder AddDefaulValue(params IEnumerable roles) + { + _defaultValues.AddRange(roles.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.Role))); + return this; + } + + internal override MentionableSelectComponentInfo Build(ModalInfo modal) + => new(this, modal); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Components/ModalComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Components/ModalComponentBuilder.cs new file mode 100644 index 0000000000..f7c41b3f82 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Components/ModalComponentBuilder.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Discord.Interactions.Builders; + +public abstract class ModalComponentBuilder : IModalComponentBuilder + where TInfo : ModalComponentInfo + where TBuilder : ModalComponentBuilder +{ + private readonly List _attributes; + protected abstract TBuilder Instance { get; } + + /// + public ModalBuilder Modal { get; } + + /// + public ComponentType ComponentType { get; internal set; } + + /// + public Type Type { get; private set; } + + /// + public PropertyInfo PropertyInfo { get; internal set; } + + /// + public object DefaultValue { get; set; } + + /// + public IReadOnlyCollection Attributes => _attributes; + + internal ModalComponentBuilder(ModalBuilder modal) + { + Modal = modal; + _attributes = new(); + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public virtual TBuilder WithComponentType(ComponentType componentType) + { + ComponentType = componentType; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public virtual TBuilder WithType(Type type) + { + Type = type; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public virtual TBuilder SetDefaultValue(object value) + { + DefaultValue = value; + return Instance; + } + + /// + /// Adds attributes to . + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + public virtual TBuilder WithAttributes(params Attribute[] attributes) + { + _attributes.AddRange(attributes); + return Instance; + } + + internal abstract TInfo Build(ModalInfo modal); + + /// + IModalComponentBuilder IModalComponentBuilder.WithType(Type type) => WithType(type); + + /// + IModalComponentBuilder IModalComponentBuilder.SetDefaultValue(object value) => SetDefaultValue(value); + + /// + IModalComponentBuilder IModalComponentBuilder.WithAttributes(params Attribute[] attributes) => WithAttributes(attributes); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Components/RoleSelectComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Components/RoleSelectComponentBuilder.cs new file mode 100644 index 0000000000..49e1a6dd0f --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Components/RoleSelectComponentBuilder.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Discord.Interactions.Builders; + +/// +/// Represents a builder for creating a . +/// +public class RoleSelectComponentBuilder : SnowflakeSelectComponentBuilder +{ + protected override RoleSelectComponentBuilder Instance => this; + + /// + /// Initialize a new . + /// + /// Parent modal of this input component. + public RoleSelectComponentBuilder(ModalBuilder modal) : base(modal, ComponentType.RoleSelect) { } + + /// + /// Adds a default value to . + /// + /// The role ID to add as a default value. + /// + /// The builder instance. + /// + public RoleSelectComponentBuilder AddDefaulValue(ulong roleId) + { + _defaultValues.Add(new SelectMenuDefaultValue(roleId, SelectDefaultValueType.Role)); + return this; + } + + /// + /// Adds default values to . + /// + /// The roles to add as a default value. + /// + /// The builder instance. + /// + public RoleSelectComponentBuilder AddDefaultValues(params IEnumerable roles) + { + _defaultValues.AddRange(roles.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.Role))); + return this; + } + + internal override RoleSelectComponentInfo Build(ModalInfo modal) + => new(this, modal); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Components/SelectMenuComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Components/SelectMenuComponentBuilder.cs new file mode 100644 index 0000000000..1826dec756 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Components/SelectMenuComponentBuilder.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders; + +/// +/// Represents a builder for creating . +/// +public class SelectMenuComponentBuilder : InputComponentBuilder +{ + private readonly List _options; + + protected override SelectMenuComponentBuilder Instance => this; + + /// + /// Gets and sets the placeholder for the select menu iput. + /// + public string Placeholder { get; set; } + + /// + /// Gets and sets the minimum number of values that can be selected. + /// + public int MinValues { get; set; } + + /// + /// Gets or sets the maximum number of values that can be selected. + /// + public int MaxValues { get; set; } + + /// + /// Gets the options of this select menu component. + /// + public IReadOnlyCollection Options => _options; + + /// + /// Initialize a new . + /// + /// Parent modal of this component. + public SelectMenuComponentBuilder(ModalBuilder modal) : base(modal) + { + _options = new(); + } + + /// + /// Adds an option to . + /// + /// Option to be added to . + /// The builder instance. + public SelectMenuComponentBuilder AddOption(SelectMenuOptionBuilder option) + { + _options.Add(option); + return this; + } + + /// + /// Adds an option to . + /// + /// Select menu option builder factory. + /// The builder instance. + public SelectMenuComponentBuilder AddOption(Action configure) + { + var builder = new SelectMenuOptionBuilder(); + configure(builder); + _options.Add(builder); + return this; + } + + internal override SelectMenuComponentInfo Build(ModalInfo modal) + => new(this, modal); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Components/SnowflakeSelectComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Components/SnowflakeSelectComponentBuilder.cs new file mode 100644 index 0000000000..8de18ad051 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Components/SnowflakeSelectComponentBuilder.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders; + +/// +/// Represents a builder for creating . +/// +/// The this builder yields when built. +/// Inherited type. +public abstract class SnowflakeSelectComponentBuilder : InputComponentBuilder, ISnowflakeSelectComponentBuilder + where TInfo : InputComponentInfo + where TBuilder : InputComponentBuilder, ISnowflakeSelectComponentBuilder +{ + protected readonly List _defaultValues; + + /// + public int MinValues { get; set; } = 1; + + /// + public int MaxValues { get; set; } = 1; + + /// + public string Placeholder { get; set; } + + /// + public IReadOnlyCollection DefaultValues => _defaultValues.AsReadOnly(); + + /// + public SelectDefaultValueType? DefaultValuesType + { + get + { + return ComponentType switch + { + ComponentType.UserSelect => SelectDefaultValueType.User, + ComponentType.RoleSelect => SelectDefaultValueType.Role, + ComponentType.ChannelSelect => SelectDefaultValueType.Channel, + ComponentType.MentionableSelect => null, + _ => throw new InvalidOperationException("Component type must be a snowflake select type."), + }; + } + } + + /// + /// Initialize a new . + /// + /// Parent modal of this input component. + /// Type of this component. + public SnowflakeSelectComponentBuilder(ModalBuilder modal, ComponentType componentType) : base(modal) + { + ValidateComponentType(componentType); + + ComponentType = componentType; + _defaultValues = new(); + } + + /// + public TBuilder AddDefaultValue(SelectMenuDefaultValue defaultValue) + { + if (DefaultValuesType.HasValue && defaultValue.Type != DefaultValuesType.Value) + throw new ArgumentException($"Only default values with {Enum.GetName(typeof(SelectDefaultValueType), DefaultValuesType.Value)} are support by {nameof(TInfo)} select type.", nameof(defaultValue)); + + _defaultValues.Add(defaultValue); + return Instance; + } + + /// + public override TBuilder WithComponentType(ComponentType componentType) + { + ValidateComponentType(componentType); + return base.WithComponentType(componentType); + } + + /// + public TBuilder WithMinValues(int minValues) + { + MinValues = minValues; + return Instance; + } + + /// + public TBuilder WithMaxValues(int maxValues) + { + MaxValues = maxValues; + return Instance; + } + + /// + public TBuilder WithPlaceholder(string placeholder) + { + Placeholder = placeholder; + return Instance; + } + + private void ValidateComponentType(ComponentType componentType) + { + if (componentType is not (ComponentType.UserSelect or ComponentType.RoleSelect or ComponentType.MentionableSelect or ComponentType.ChannelSelect)) + throw new ArgumentException("Component type must be a snowflake select type.", nameof(componentType)); + + } + + /// + ISnowflakeSelectComponentBuilder ISnowflakeSelectComponentBuilder.AddDefaultValue(SelectMenuDefaultValue defaultValue) => AddDefaultValue(defaultValue); + + /// + ISnowflakeSelectComponentBuilder ISnowflakeSelectComponentBuilder.WithMinValues(int minValues) => WithMinValues(minValues); + + /// + ISnowflakeSelectComponentBuilder ISnowflakeSelectComponentBuilder.WithMaxValues(int maxValues) => WithMaxValues(maxValues); + + /// + ISnowflakeSelectComponentBuilder ISnowflakeSelectComponentBuilder.WithPlaceholder(string placeholder) => WithPlaceholder(placeholder); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Components/TextDisplayComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Components/TextDisplayComponentBuilder.cs new file mode 100644 index 0000000000..7d6d1e3107 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Components/TextDisplayComponentBuilder.cs @@ -0,0 +1,50 @@ +using System; + +namespace Discord.Interactions.Builders; + +/// +/// Represents a builder for creating . +/// +public class TextDisplayComponentBuilder : ModalComponentBuilder +{ + protected override TextDisplayComponentBuilder Instance => this; + + /// + /// Gets and sets the content of the text display. + /// + public string Content { get; set; } + + /// + /// Initialize a new . + /// + /// Parent modal of this input component. + public TextDisplayComponentBuilder(ModalBuilder modal) : base(modal) + { + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TextDisplayComponentBuilder WithContent(string content) + { + Content = content; + return this; + } + + public override TextDisplayComponentBuilder WithType(Type type) + { + if(type != typeof(string)) + { + throw new ArgumentException($"Text display components can be only used with {typeof(string).Name} properties. {type.Name} provided instead."); + } + + return base.WithType(type); + } + + internal override TextDisplayComponentInfo Build(ModalInfo modal) + => new(this, modal); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Components/TextInputComponentBuilder.cs similarity index 100% rename from src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs rename to src/Discord.Net.Interactions/Builders/Modals/Components/TextInputComponentBuilder.cs diff --git a/src/Discord.Net.Interactions/Builders/Modals/Components/UserSelectComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Components/UserSelectComponentBuilder.cs new file mode 100644 index 0000000000..6189b1cfd4 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Components/UserSelectComponentBuilder.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Discord.Interactions.Builders; + +/// +/// Represents a builder for creating . +/// +public class UserSelectComponentBuilder : SnowflakeSelectComponentBuilder +{ + protected override UserSelectComponentBuilder Instance => this; + + /// + /// Initialize a new . + /// + /// Parent modal of this input component. + public UserSelectComponentBuilder(ModalBuilder modal) : base(modal, ComponentType.UserSelect) { } + + /// + /// Adds a default value to . + /// + /// The user ID to add as a default value. + /// + /// The builder instance. + /// + public UserSelectComponentBuilder AddDefaulValue(ulong userId) + { + _defaultValues.Add(new SelectMenuDefaultValue(userId, SelectDefaultValueType.User)); + return this; + } + + /// + /// Adds default values to . + /// + /// The users to add as a default value. + /// + /// The builder instance. + /// + public UserSelectComponentBuilder AddDefaultValues(params IEnumerable users) + { + _defaultValues.AddRange(users.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.User))); + return this; + } + + internal override UserSelectComponentInfo Build(ModalInfo modal) + => new(this, modal); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/IInputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/IInputComponentBuilder.cs deleted file mode 100644 index 68c26fd037..0000000000 --- a/src/Discord.Net.Interactions/Builders/Modals/Inputs/IInputComponentBuilder.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; - -namespace Discord.Interactions.Builders -{ - /// - /// Represent a builder for creating . - /// - public interface IInputComponentBuilder - { - /// - /// Gets the parent modal of this input component. - /// - ModalBuilder Modal { get; } - - /// - /// Gets the custom id of this input component. - /// - string CustomId { get; } - - /// - /// Gets the label of this input component. - /// - string Label { get; } - - /// - /// Gets whether this input component is required. - /// - bool IsRequired { get; } - - /// - /// Gets the component type of this input component. - /// - ComponentType ComponentType { get; } - - /// - /// Get the reference type of this input component. - /// - Type Type { get; } - - /// - /// Get the of this component's property. - /// - PropertyInfo PropertyInfo { get; } - - /// - /// Get the assigned to this input. - /// - ComponentTypeConverter TypeConverter { get; } - - /// - /// Gets the default value of this input component. - /// - object DefaultValue { get; } - - /// - /// Gets a collection of the attributes of this component. - /// - IReadOnlyCollection Attributes { get; } - - /// - /// Sets . - /// - /// New value of the . - /// - /// The builder instance. - /// - IInputComponentBuilder WithCustomId(string customId); - - /// - /// Sets . - /// - /// New value of the . - /// - /// The builder instance. - /// - IInputComponentBuilder WithLabel(string label); - - /// - /// Sets . - /// - /// New value of the . - /// - /// The builder instance. - /// - IInputComponentBuilder SetIsRequired(bool isRequired); - - /// - /// Sets . - /// - /// New value of the . - /// - /// The builder instance. - /// - IInputComponentBuilder WithType(Type type); - - /// - /// Sets . - /// - /// New value of the . - /// - /// The builder instance. - /// - IInputComponentBuilder SetDefaultValue(object value); - - /// - /// Adds attributes to . - /// - /// New attributes to be added to . - /// - /// The builder instance. - /// - IInputComponentBuilder WithAttributes(params Attribute[] attributes); - } -} diff --git a/src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs index 66aeadf75b..419ceadb36 100644 --- a/src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs +++ b/src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; namespace Discord.Interactions.Builders { @@ -10,7 +9,7 @@ namespace Discord.Interactions.Builders public class ModalBuilder { internal readonly InteractionService _interactionService; - internal readonly List _components; + internal readonly List _components; /// /// Gets the initialization delegate for this modal. @@ -30,7 +29,7 @@ public class ModalBuilder /// /// Gets a collection of the components of this modal. /// - public IReadOnlyCollection Components => _components; + public IReadOnlyCollection Components => _components.AsReadOnly(); internal ModalBuilder(Type type, InteractionService interactionService) { @@ -72,7 +71,7 @@ public ModalBuilder WithTitle(string title) /// /// The builder instance. /// - public ModalBuilder AddTextComponent(Action configure) + public ModalBuilder AddTextInputComponent(Action configure) { var builder = new TextInputComponentBuilder(this); configure(builder); @@ -80,6 +79,111 @@ public ModalBuilder AddTextComponent(Action configure return this; } + /// + /// Adds a select menu component to . + /// + /// Select menu component builder factory. + /// + /// The builder instance. + /// + public ModalBuilder AddSelectMenuInputComponent(Action configure) + { + var builder = new SelectMenuComponentBuilder(this); + configure(builder); + _components.Add(builder); + return this; + } + + /// + /// Adds a user select component to . + /// + /// User select component builder factory. + /// + /// The builder instance. + /// + public ModalBuilder AddUserSelectInputComponent(Action configure) + { + var builder = new UserSelectComponentBuilder(this); + configure(builder); + _components.Add(builder); + return this; + } + + /// + /// Adds a role select component to . + /// + /// Role select component builder factory. + /// + /// The builder instance. + /// + public ModalBuilder AddRoleSelectInputComponent(Action configure) + { + var builder = new RoleSelectComponentBuilder(this); + configure(builder); + _components.Add(builder); + return this; + } + + /// + /// Adds a mentionable select component to . + /// + /// Mentionable select component builder factory. + /// + /// The builder instance. + /// + public ModalBuilder AddMentionableSelectInputComponent(Action configure) + { + var builder = new MentionableSelectComponentBuilder(this); + configure(builder); + _components.Add(builder); + return this; + } + + /// + /// Adds a channel select component to . + /// + /// Channel select component builder factory. + /// + /// The builder instance. + /// + public ModalBuilder AddChannelSelectInputComponent(Action configure) + { + var builder = new ChannelSelectComponentBuilder(this); + configure(builder); + _components.Add(builder); + return this; + } + + /// + /// Adds a file upload component to . + /// + /// File upload component builder factory. + /// + /// The builder instance. + /// + public ModalBuilder AddFileUploadInputComponent(Action configure) + { + var builder = new FileUploadComponentBuilder(this); + configure(builder); + _components.Add(builder); + return this; + } + + /// + /// Adds a text display component to . + /// + /// Text display component builder factory. + /// + /// The builder instance. + /// + public ModalBuilder AddTextDisplayComponent(Action configure) + { + var builder = new TextDisplayComponentBuilder(this); + configure(builder); + _components.Add(builder); + return this; + } + internal ModalInfo Build() => new(this); } } diff --git a/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs b/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs index 87aefa8f24..c76552ef9c 100644 --- a/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs +++ b/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; @@ -95,7 +94,7 @@ private static void BuildModule(ModuleBuilder builder, TypeInfo typeInfo, Intera #pragma warning restore CS0618 // Type or member is obsolete #pragma warning disable CS0618 // Type or member is obsolete case EnabledInDmAttribute enabledInDm: - { + { builder.IsEnabledInDm = enabledInDm.IsEnabled; } break; @@ -604,16 +603,37 @@ public static ModalInfo BuildModalInfo(Type modalType, InteractionService intera Title = instance.Title }; - var inputs = modalType.GetProperties().Where(IsValidModalInputDefinition); + var components = modalType.GetProperties().Where(IsValidModalComponentDefinition); - foreach (var prop in inputs) + foreach (var prop in components) { - var componentType = prop.GetCustomAttribute()?.ComponentType; + var componentType = prop.GetCustomAttribute()?.ComponentType; switch (componentType) { case ComponentType.TextInput: - builder.AddTextComponent(x => BuildTextInput(x, prop, prop.GetValue(instance))); + builder.AddTextInputComponent(x => BuildTextInputComponent(x, prop, prop.GetValue(instance))); + break; + case ComponentType.SelectMenu: + builder.AddSelectMenuInputComponent(x => BuildSelectMenuComponent(x, prop, prop.GetValue(instance))); + break; + case ComponentType.UserSelect: + builder.AddUserSelectInputComponent(x => BuildSnowflakeSelectComponent(x, prop, prop.GetValue(instance))); + break; + case ComponentType.RoleSelect: + builder.AddRoleSelectInputComponent(x => BuildSnowflakeSelectComponent(x, prop, prop.GetValue(instance))); + break; + case ComponentType.MentionableSelect: + builder.AddMentionableSelectInputComponent(x => BuildSnowflakeSelectComponent(x, prop, prop.GetValue(instance))); + break; + case ComponentType.ChannelSelect: + builder.AddChannelSelectInputComponent(x => BuildSnowflakeSelectComponent(x, prop, prop.GetValue(instance))); + break; + case ComponentType.FileUpload: + builder.AddFileUploadInputComponent(x => BuildFileUploadComponent(x, prop, prop.GetValue(instance))); + break; + case ComponentType.TextDisplay: + builder.AddTextDisplayComponent(x => BuildTextDisplayComponent(x, prop, prop.GetValue(instance))); break; case null: throw new InvalidOperationException($"{prop.Name} of {prop.DeclaringType.Name} isn't a valid modal input field."); @@ -632,7 +652,7 @@ public static ModalInfo BuildModalInfo(Type modalType, InteractionService intera } } - private static void BuildTextInput(TextInputComponentBuilder builder, PropertyInfo propertyInfo, object defaultValue) + private static void BuildTextInputComponent(TextInputComponentBuilder builder, PropertyInfo propertyInfo, object defaultValue) { var attributes = propertyInfo.GetCustomAttributes(); @@ -659,6 +679,149 @@ private static void BuildTextInput(TextInputComponentBuilder builder, PropertyIn break; case InputLabelAttribute inputLabel: builder.Label = inputLabel.Label; + builder.Description = inputLabel.Description; + break; + default: + builder.WithAttributes(attribute); + break; + } + } + } + + private static void BuildSelectMenuComponent(SelectMenuComponentBuilder builder, PropertyInfo propertyInfo, object defaultValue) + { + var attributes = propertyInfo.GetCustomAttributes(); + + builder.Label = propertyInfo.Name; + builder.DefaultValue = defaultValue; + builder.WithType(propertyInfo.PropertyType); + builder.PropertyInfo = propertyInfo; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case ModalSelectMenuAttribute selectMenuInput: + builder.CustomId = selectMenuInput.CustomId; + builder.ComponentType = selectMenuInput.ComponentType; + builder.MinValues = selectMenuInput.MinValues; + builder.MaxValues = selectMenuInput.MaxValues; + builder.Placeholder = selectMenuInput.Placeholder; + break; + case RequiredInputAttribute requiredInput: + builder.IsRequired = requiredInput.IsRequired; + break; + case InputLabelAttribute inputLabel: + builder.Label = inputLabel.Label; + builder.Description = inputLabel.Description; + break; + case ModalSelectMenuOptionAttribute selectMenuOption: + Emoji emoji = null; + Emote emote = null; + + if (!string.IsNullOrEmpty(selectMenuOption?.Emote) && !(Emote.TryParse(selectMenuOption.Emote, out emote) || Emoji.TryParse(selectMenuOption.Emote, out emoji))) + throw new ArgumentException($"Unable to parse {selectMenuOption.Emote} of {propertyInfo.DeclaringType}.{propertyInfo.Name} into an {typeof(Emote).Name} or an {typeof(Emoji).Name}"); + + builder.AddOption(new SelectMenuOptionBuilder + { + Label = selectMenuOption.Label, + Description = selectMenuOption.Description, + Value = selectMenuOption.Value, + Emote = emote != null ? emote : emoji, + IsDefault = selectMenuOption.IsDefault + }); + break; + default: + builder.WithAttributes(attribute); + break; + } + } + } + + private static void BuildSnowflakeSelectComponent(SnowflakeSelectComponentBuilder builder, PropertyInfo propertyInfo, object defaultValue) + where TInfo : SnowflakeSelectComponentInfo + where TBuilder : SnowflakeSelectComponentBuilder + { + var attributes = propertyInfo.GetCustomAttributes(); + + builder.Label = propertyInfo.Name; + builder.DefaultValue = defaultValue; + builder.WithType(propertyInfo.PropertyType); + builder.PropertyInfo = propertyInfo; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case ModalSelectComponentAttribute selectInput: + builder.CustomId = selectInput.CustomId; + builder.ComponentType = selectInput.ComponentType; + builder.MinValues = selectInput.MinValues; + builder.MaxValues = selectInput.MaxValues; + builder.Placeholder = selectInput.Placeholder; + break; + case RequiredInputAttribute requiredInput: + builder.IsRequired = requiredInput.IsRequired; + break; + case InputLabelAttribute inputLabel: + builder.Label = inputLabel.Label; + builder.Description = inputLabel.Description; + break; + default: + builder.WithAttributes(attribute); + break; + } + } + } + + private static void BuildFileUploadComponent(FileUploadComponentBuilder builder, PropertyInfo propertyInfo, object defaultValue) + { + var attributes = propertyInfo.GetCustomAttributes(); + + builder.Label = propertyInfo.Name; + builder.DefaultValue = defaultValue; + builder.WithType(propertyInfo.PropertyType); + builder.PropertyInfo = propertyInfo; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case ModalFileUploadAttribute fileUploadInput: + builder.CustomId = fileUploadInput.CustomId; + builder.ComponentType = fileUploadInput.ComponentType; + builder.MinValues = fileUploadInput.MinValues; + builder.MaxValues = fileUploadInput.MaxValues; + break; + case RequiredInputAttribute requiredInput: + builder.IsRequired = requiredInput.IsRequired; + break; + case InputLabelAttribute inputLabel: + builder.Label = inputLabel.Label; + builder.Description = inputLabel.Description; + break; + default: + builder.WithAttributes(attribute); + break; + } + } + } + + private static void BuildTextDisplayComponent(TextDisplayComponentBuilder builder, PropertyInfo propertyInfo, object defaultValue) + { + var attributes = propertyInfo.GetCustomAttributes(); + + builder.DefaultValue = defaultValue; + builder.WithType(propertyInfo.PropertyType); + builder.PropertyInfo = propertyInfo; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case ModalTextDisplayAttribute textDisplay: + builder.ComponentType = textDisplay.ComponentType; + builder.Content = textDisplay.Content; break; default: builder.WithAttributes(attribute); @@ -717,11 +880,11 @@ private static bool IsValidModalCommandDefinition(MethodInfo methodInfo) typeof(IModal).IsAssignableFrom(methodInfo.GetParameters().Last().ParameterType); } - private static bool IsValidModalInputDefinition(PropertyInfo propertyInfo) + private static bool IsValidModalComponentDefinition(PropertyInfo propertyInfo) { return propertyInfo.SetMethod?.IsPublic == true && propertyInfo.SetMethod?.IsStatic == false && - propertyInfo.IsDefined(typeof(ModalInputAttribute)); + propertyInfo.IsDefined(typeof(ModalComponentAttribute)); } private static ConstructorInfo GetComplexParameterConstructor(TypeInfo typeInfo, ComplexParameterAttribute complexParameter) diff --git a/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs b/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs index c244640b45..5d2a0af335 100644 --- a/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs +++ b/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; namespace Discord.Interactions @@ -20,7 +21,7 @@ public static Task RespondWithModalAsync(this IDiscordInteraction interaction if (!ModalUtils.TryGet(out var modalInfo)) throw new ArgumentException($"{typeof(T).FullName} isn't referenced by any registered Modal Interaction Command and doesn't have a cached {typeof(ModalInfo)}"); - return SendModalResponseAsync(interaction, customId, modalInfo, options, modifyModal); + return SendModalResponseAsync(interaction, customId, modalInfo, null, options, modifyModal); } /// @@ -43,7 +44,7 @@ public static Task RespondWithModalAsync(this IDiscordInteraction interaction { var modalInfo = ModalUtils.GetOrAdd(interactionService); - return SendModalResponseAsync(interaction, customId, modalInfo, options, modifyModal); + return SendModalResponseAsync(interaction, customId, modalInfo, null, options, modifyModal); } /// @@ -64,20 +65,78 @@ public static Task RespondWithModalAsync(this IDiscordInteraction interaction if (!ModalUtils.TryGet(out var modalInfo)) throw new ArgumentException($"{typeof(T).FullName} isn't referenced by any registered Modal Interaction Command and doesn't have a cached {typeof(ModalInfo)}"); - var builder = new ModalBuilder(modal.Title, customId); + return SendModalResponseAsync(interaction, customId, modalInfo, modal, options, modifyModal); + } + + private static async Task SendModalResponseAsync(IDiscordInteraction interaction, string customId, ModalInfo modalInfo, T modalInstance = null, RequestOptions options = null, Action modifyModal = null) + where T : class, IModal + { + if (!modalInfo.Type.IsAssignableFrom(typeof(T))) + throw new ArgumentException($"{modalInfo.Type.FullName} isn't assignable from {typeof(T).FullName}."); + + var builder = new ModalBuilder(modalInstance.Title, customId); foreach (var input in modalInfo.Components) switch (input) { case TextInputComponentInfo textComponent: { - var boxedValue = textComponent.Getter(modal); - var value = textComponent.TypeOverridesToString - ? boxedValue?.ToString() - : boxedValue as string; + var inputBuilder = new TextInputBuilder(textComponent.CustomId, textComponent.Style, textComponent.Placeholder, textComponent.IsRequired ? textComponent.MinLength : null, + textComponent.MaxLength, textComponent.IsRequired); + + if (modalInstance != null) + { + await textComponent.TypeConverter.WriteAsync(inputBuilder, interaction, textComponent, textComponent.Getter(modalInstance)); + } + + var labelBuilder = new LabelBuilder(textComponent.Label, inputBuilder, textComponent.Description); + builder.AddLabel(labelBuilder); + } + 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); + + if (modalInstance != null) + { + await selectMenuComponent.TypeConverter.WriteAsync(inputBuilder, interaction, selectMenuComponent, selectMenuComponent.Getter(modalInstance)); + } - builder.AddTextInput(textComponent.Label, textComponent.CustomId, textComponent.Style, textComponent.Placeholder, textComponent.IsRequired ? textComponent.MinLength : null, - textComponent.MaxLength, textComponent.IsRequired, value); + var labelBuilder = new LabelBuilder(selectMenuComponent.Label, inputBuilder, selectMenuComponent.Description); + builder.AddLabel(labelBuilder); + } + 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); + + if (modalInstance != null) + { + await snowflakeSelectComponent.TypeConverter.WriteAsync(inputBuilder, interaction, snowflakeSelectComponent, snowflakeSelectComponent.Getter(modalInstance)); + } + + var labelBuilder = new LabelBuilder(snowflakeSelectComponent.Label, inputBuilder, snowflakeSelectComponent.Description); + builder.AddLabel(labelBuilder); + } + break; + case FileUploadComponentInfo fileUploadComponent: + { + var inputBuilder = new FileUploadComponentBuilder(fileUploadComponent.CustomId, fileUploadComponent.MinValues, fileUploadComponent.MaxValues, fileUploadComponent.IsRequired); + + if (modalInstance != null) + { + await fileUploadComponent.TypeConverter.WriteAsync(inputBuilder, interaction, fileUploadComponent, fileUploadComponent.Getter(modalInstance)); + } + + var labelBuilder = new LabelBuilder(fileUploadComponent.Label, inputBuilder, fileUploadComponent.Description); + builder.AddLabel(labelBuilder); + } + break; + case TextDisplayComponentInfo textDisplayComponent: + { + var content = textDisplayComponent.Getter(modalInstance).ToString() ?? textDisplayComponent.Content; + var componentBuilder = new TextDisplayBuilder(content); + builder.AddTextDisplay(componentBuilder); } break; default: @@ -86,13 +145,7 @@ public static Task RespondWithModalAsync(this IDiscordInteraction interaction modifyModal?.Invoke(builder); - return interaction.RespondWithModalAsync(builder.Build(), options); - } - - private static Task SendModalResponseAsync(IDiscordInteraction interaction, string customId, ModalInfo modalInfo, RequestOptions options = null, Action modifyModal = null) - { - var modal = modalInfo.ToModal(customId, modifyModal); - return interaction.RespondWithModalAsync(modal, options); + await interaction.RespondWithModalAsync(builder.Build(), options); } } } diff --git a/src/Discord.Net.Interactions/Info/Components/ChannelSelectComponentInfo.cs b/src/Discord.Net.Interactions/Info/Components/ChannelSelectComponentInfo.cs new file mode 100644 index 0000000000..6161a3c845 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Components/ChannelSelectComponentInfo.cs @@ -0,0 +1,9 @@ +namespace Discord.Interactions; + +/// +/// Represents the class for type. +/// +public class ChannelSelectComponentInfo : SnowflakeSelectComponentInfo +{ + internal ChannelSelectComponentInfo(Builders.ChannelSelectComponentBuilder builder, ModalInfo modal) : base(builder, modal) { } +} diff --git a/src/Discord.Net.Interactions/Info/Components/FileUploadComponentInfo.cs b/src/Discord.Net.Interactions/Info/Components/FileUploadComponentInfo.cs new file mode 100644 index 0000000000..41713e0c88 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Components/FileUploadComponentInfo.cs @@ -0,0 +1,23 @@ +namespace Discord.Interactions; + +/// +/// Represents the class for type. +/// +public class FileUploadComponentInfo : InputComponentInfo +{ + /// + /// Gets the minimum number of values that can be selected. + /// + public int MinValues { get; } + + /// + /// Gets the maximum number of values that can be selected. + /// + public int MaxValues { get; } + + internal FileUploadComponentInfo(Builders.FileUploadComponentBuilder builder, ModalInfo modal) : base(builder, modal) + { + MinValues = builder.MinValues; + MaxValues = builder.MaxValues; + } +} diff --git a/src/Discord.Net.Interactions/Info/Components/InputComponentInfo.cs b/src/Discord.Net.Interactions/Info/Components/InputComponentInfo.cs new file mode 100644 index 0000000000..7a5aa67d4b --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Components/InputComponentInfo.cs @@ -0,0 +1,43 @@ +namespace Discord.Interactions +{ + /// + /// Represents the base info class for input components. + /// + public abstract class InputComponentInfo : ModalComponentInfo + { + /// + /// Gets the custom id of this component. + /// + public string CustomId { get; } + + /// + /// Gets the label of this component. + /// + public string Label { get; } + + /// + /// Gets the description of this component. + /// + public string Description { get; } + + /// + /// Gets whether or not this component requires a user input. + /// + public bool IsRequired { get; } + + /// + /// Gets the assigned to this component. + /// + public ModalComponentTypeConverter TypeConverter { get; } + + internal InputComponentInfo(Builders.IInputComponentBuilder builder, ModalInfo modal) + : base(builder, modal) + { + CustomId = builder.CustomId; + Label = builder.Label; + Description = builder.Description; + IsRequired = builder.IsRequired; + TypeConverter = builder.TypeConverter; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Components/MentionableSelectComponentInfo.cs b/src/Discord.Net.Interactions/Info/Components/MentionableSelectComponentInfo.cs new file mode 100644 index 0000000000..498c500c0a --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Components/MentionableSelectComponentInfo.cs @@ -0,0 +1,9 @@ +namespace Discord.Interactions; + +/// +/// Represents the class for type. +/// +public class MentionableSelectComponentInfo : SnowflakeSelectComponentInfo +{ + internal MentionableSelectComponentInfo(Builders.MentionableSelectComponentBuilder builder, ModalInfo modal) : base(builder, modal) { } +} diff --git a/src/Discord.Net.Interactions/Info/Components/ModalComponentInfo.cs b/src/Discord.Net.Interactions/Info/Components/ModalComponentInfo.cs new file mode 100644 index 0000000000..b92455892d --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Components/ModalComponentInfo.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Reflection; + +namespace Discord.Interactions; + +/// +/// Represents the base info class for components. +/// +public abstract class ModalComponentInfo +{ + private Lazy> _getter; + internal Func Getter => _getter.Value; + + + /// + /// Gets the parent modal of this component. + /// + public ModalInfo Modal { get; } + + /// + /// Gets the type of this component. + /// + public ComponentType ComponentType { get; } + + /// + /// Gets the reference type of this component. + /// + public Type Type { get; } + + /// + /// Gets the property linked to this component. + /// + public PropertyInfo PropertyInfo { get; } + + /// + /// Gets the default value of this component property. + /// + public object DefaultValue { get; } + + /// + /// Gets a collection of the attributes of this command. + /// + public IReadOnlyCollection Attributes { get; } + + internal ModalComponentInfo(Builders.IModalComponentBuilder builder, ModalInfo modal) + { + Modal = modal; + ComponentType = builder.ComponentType; + Type = builder.Type; + PropertyInfo = builder.PropertyInfo; + DefaultValue = builder.DefaultValue; + Attributes = builder.Attributes.ToImmutableArray(); + + _getter = new(() => ReflectionUtils.CreateLambdaPropertyGetter(Modal.Type, PropertyInfo)); + } +} diff --git a/src/Discord.Net.Interactions/Info/Components/RoleSelectComponentInfo.cs b/src/Discord.Net.Interactions/Info/Components/RoleSelectComponentInfo.cs new file mode 100644 index 0000000000..1f08b03768 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Components/RoleSelectComponentInfo.cs @@ -0,0 +1,9 @@ +namespace Discord.Interactions; + +/// +/// Represents the class for type. +/// +public class RoleSelectComponentInfo : SnowflakeSelectComponentInfo +{ + internal RoleSelectComponentInfo(Builders.RoleSelectComponentBuilder builder, ModalInfo modal) : base(builder, modal) { } +} diff --git a/src/Discord.Net.Interactions/Info/Components/SelectMenuComponentInfo.cs b/src/Discord.Net.Interactions/Info/Components/SelectMenuComponentInfo.cs new file mode 100644 index 0000000000..4ca3432300 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Components/SelectMenuComponentInfo.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Interactions; + +/// +/// Represents the class for type. +/// +public class SelectMenuComponentInfo : InputComponentInfo +{ + /// + /// Gets the placeholder of the select menu input. + /// + public string Placeholder { get; } + + /// + /// Gets the minimum number of values that can be selected. + /// + public int MinValues { get; } + + /// + /// Gets the maximum number of values that can be selected. + /// + public int MaxValues { get; } + + /// + /// Gets the options of this select menu component. + /// + public IReadOnlyCollection Options { get; } + + internal SelectMenuComponentInfo(Builders.SelectMenuComponentBuilder builder, ModalInfo modal) : base(builder, modal) + { + Placeholder = builder.Placeholder; + MinValues = builder.MinValues; + MaxValues = builder.MaxValues; + Options = builder.Options.Select(x => x.Build()).ToImmutableArray(); + } +} diff --git a/src/Discord.Net.Interactions/Info/Components/SnowflakeSelectComponentInfo.cs b/src/Discord.Net.Interactions/Info/Components/SnowflakeSelectComponentInfo.cs new file mode 100644 index 0000000000..f5150954e2 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Components/SnowflakeSelectComponentInfo.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Interactions; + +/// +/// Represents the base class for , , , type. +/// +public abstract class SnowflakeSelectComponentInfo : InputComponentInfo +{ + /// + /// Gets the minimum number of values that can be selected. + /// + public int MinValues { get; } + + /// + /// Gets the maximum number of values that can be selected. + /// + public int MaxValues { get; } + + /// + /// Gets the placeholder of this select input. + /// + public string Placeholder { get; } + + /// + /// Gets the default values of this select input. + /// + public IReadOnlyCollection DefaultValues { get; } + + /// + /// Gets the default value type of this select input. + /// + public SelectDefaultValueType? DefaultValueType { get; } + + internal SnowflakeSelectComponentInfo(Builders.ISnowflakeSelectComponentBuilder builder, ModalInfo modal) : base(builder, modal) + { + MinValues = builder.MinValues; + MaxValues = builder.MaxValues; + Placeholder = builder.Placeholder; + DefaultValues = builder.DefaultValues.ToImmutableArray(); + DefaultValueType = builder.DefaultValuesType; + } +} diff --git a/src/Discord.Net.Interactions/Info/Components/TextDisplayComponentInfo.cs b/src/Discord.Net.Interactions/Info/Components/TextDisplayComponentInfo.cs new file mode 100644 index 0000000000..4869280620 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Components/TextDisplayComponentInfo.cs @@ -0,0 +1,19 @@ +using Discord.Interactions.Builders; + +namespace Discord.Interactions; + +/// +/// Represents the class for type. +/// +public class TextDisplayComponentInfo : ModalComponentInfo +{ + /// + /// Gets the content of the text display. + /// + public string Content { get; } + + internal TextDisplayComponentInfo(TextDisplayComponentBuilder builder, ModalInfo modal) : base(builder, modal) + { + Content = Content; + } +} diff --git a/src/Discord.Net.Interactions/Info/InputComponents/TextInputComponentInfo.cs b/src/Discord.Net.Interactions/Info/Components/TextInputComponentInfo.cs similarity index 96% rename from src/Discord.Net.Interactions/Info/InputComponents/TextInputComponentInfo.cs rename to src/Discord.Net.Interactions/Info/Components/TextInputComponentInfo.cs index 6831c7953d..f0c481c945 100644 --- a/src/Discord.Net.Interactions/Info/InputComponents/TextInputComponentInfo.cs +++ b/src/Discord.Net.Interactions/Info/Components/TextInputComponentInfo.cs @@ -8,7 +8,7 @@ namespace Discord.Interactions public class TextInputComponentInfo : InputComponentInfo { /// - /// true when overrides . + /// true when overrides . /// internal bool TypeOverridesToString => _typeOverridesToString.Value; private readonly Lazy _typeOverridesToString; diff --git a/src/Discord.Net.Interactions/Info/Components/UserSelectComponentInfo.cs b/src/Discord.Net.Interactions/Info/Components/UserSelectComponentInfo.cs new file mode 100644 index 0000000000..a1b49ec34d --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Components/UserSelectComponentInfo.cs @@ -0,0 +1,9 @@ +namespace Discord.Interactions; + +/// +/// Represents the class for type. +/// +public class UserSelectComponentInfo : SnowflakeSelectComponentInfo +{ + internal UserSelectComponentInfo(Builders.UserSelectComponentBuilder builder, ModalInfo modal) : base(builder, modal) { } +} diff --git a/src/Discord.Net.Interactions/Info/InputComponents/InputComponentInfo.cs b/src/Discord.Net.Interactions/Info/InputComponents/InputComponentInfo.cs deleted file mode 100644 index 23a0db8447..0000000000 --- a/src/Discord.Net.Interactions/Info/InputComponents/InputComponentInfo.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Reflection; - -namespace Discord.Interactions -{ - /// - /// Represents the base info class for input components. - /// - public abstract class InputComponentInfo - { - private Lazy> _getter; - internal Func Getter => _getter.Value; - - - /// - /// Gets the parent modal of this component. - /// - public ModalInfo Modal { get; } - - /// - /// Gets the custom id of this component. - /// - public string CustomId { get; } - - /// - /// Gets the label of this component. - /// - public string Label { get; } - - /// - /// Gets whether or not this component requires a user input. - /// - public bool IsRequired { get; } - - /// - /// Gets the type of this component. - /// - public ComponentType ComponentType { get; } - - /// - /// Gets the reference type of this component. - /// - public Type Type { get; } - - /// - /// Gets the property linked to this component. - /// - public PropertyInfo PropertyInfo { get; } - - /// - /// Gets the assigned to this component. - /// - public ComponentTypeConverter TypeConverter { get; } - - /// - /// Gets the default value of this component. - /// - public object DefaultValue { get; } - - /// - /// Gets a collection of the attributes of this command. - /// - public IReadOnlyCollection Attributes { get; } - - protected InputComponentInfo(Builders.IInputComponentBuilder builder, ModalInfo modal) - { - Modal = modal; - CustomId = builder.CustomId; - Label = builder.Label; - IsRequired = builder.IsRequired; - ComponentType = builder.ComponentType; - Type = builder.Type; - PropertyInfo = builder.PropertyInfo; - TypeConverter = builder.TypeConverter; - DefaultValue = builder.DefaultValue; - Attributes = builder.Attributes.ToImmutableArray(); - - _getter = new(() => ReflectionUtils.CreateLambdaPropertyGetter(Modal.Type, PropertyInfo)); - } - } -} diff --git a/src/Discord.Net.Interactions/Info/ModalInfo.cs b/src/Discord.Net.Interactions/Info/ModalInfo.cs index bef789ac9d..2f9581b1b4 100644 --- a/src/Discord.Net.Interactions/Info/ModalInfo.cs +++ b/src/Discord.Net.Interactions/Info/ModalInfo.cs @@ -1,3 +1,4 @@ +using Discord.Interactions.Builders; using System; using System.Collections.Generic; using System.Collections.Immutable; @@ -36,24 +37,80 @@ public class ModalInfo /// /// Gets a collection of the components of this modal. /// - public IReadOnlyCollection Components { get; } + public IReadOnlyCollection Components { get; } + + /// + /// Gets a collection of the input components of this modal. + /// + public IReadOnlyCollection InputComponents { get; } /// /// Gets a collection of the text components of this modal. /// - public IReadOnlyCollection TextComponents { get; } + public IReadOnlyCollection TextInputComponents { get; } + + /// + /// Get a collection of the select menu components of this modal. + /// + public IReadOnlyCollection SelectMenuComponents { get; } + + /// + /// Get a collection of the user select components of this modal. + /// + public IReadOnlyCollection UserSelectComponents { get; } + + /// + /// Get a collection of the role select components of this modal. + /// + public IReadOnlyCollection RoleSelectComponents { get; } + + /// + /// Get a collection of the mentionable select components of this modal. + /// + public IReadOnlyCollection MentionableSelectComponents { get; } + + /// + /// Get a collection of the channel select components of this modal. + /// + public IReadOnlyCollection ChannelSelectComponents { get; } + + /// + /// Get a collection of the file upload components of this modal. + /// + public IReadOnlyCollection FileUploadComponents { get; } + + /// + /// Gets a collection of the text display components of this modal. + /// + public IReadOnlyCollection TextDisplayComponents { get; } internal ModalInfo(Builders.ModalBuilder builder) { Title = builder.Title; Type = builder.Type; - Components = builder.Components.Select(x => x switch + Components = builder.Components.Select(x => x switch { Builders.TextInputComponentBuilder textComponent => textComponent.Build(this), + Builders.SelectMenuComponentBuilder selectMenuComponent => selectMenuComponent.Build(this), + Builders.RoleSelectComponentBuilder roleSelectComponent => roleSelectComponent.Build(this), + Builders.ChannelSelectComponentBuilder channelSelectComponent => channelSelectComponent.Build(this), + Builders.UserSelectComponentBuilder userSelectComponent => userSelectComponent.Build(this), + Builders.MentionableSelectComponentBuilder mentionableSelectComponent => mentionableSelectComponent.Build(this), + Builders.FileUploadComponentBuilder fileUploadComponent => fileUploadComponent.Build(this), + Builders.TextDisplayComponentBuilder textDisplayComponent => textDisplayComponent.Build(this), _ => throw new InvalidOperationException($"{x.GetType().FullName} isn't a supported modal input component builder type.") }).ToImmutableArray(); - TextComponents = Components.OfType().ToImmutableArray(); + InputComponents = Components.OfType().ToImmutableArray(); + + TextInputComponents = Components.OfType().ToImmutableArray(); + SelectMenuComponents = Components.OfType().ToImmutableArray(); + UserSelectComponents = Components.OfType().ToImmutableArray(); + RoleSelectComponents = Components.OfType().ToImmutableArray(); + MentionableSelectComponents = Components.OfType().ToImmutableArray(); + ChannelSelectComponents = Components.OfType().ToImmutableArray(); + FileUploadComponents = Components.OfType().ToImmutableArray(); + TextDisplayComponents = Components.OfType().ToImmutableArray(); _interactionService = builder._interactionService; _initializer = builder.ModalInitializer; @@ -74,7 +131,7 @@ public IModal CreateModal(IModalInteraction modalInteraction, bool throwOnMissin for (var i = 0; i < Components.Count; i++) { - var input = Components.ElementAt(i); + var input = InputComponents.ElementAt(i); var component = components.Find(x => x.CustomId == input.CustomId); if (component is null) @@ -107,12 +164,12 @@ public async Task CreateModalAsync(IInteractionContext context, IServic services ??= EmptyServiceProvider.Instance; - var args = new object[Components.Count]; + var args = new object[InputComponents.Count]; var components = modalInteraction.Data.Components.ToList(); - for (var i = 0; i < Components.Count; i++) + for (var i = 0; i < InputComponents.Count; i++) { - var input = Components.ElementAt(i); + var input = InputComponents.ElementAt(i); var component = components.Find(x => x.CustomId == input.CustomId); if (component is null) diff --git a/src/Discord.Net.Interactions/InteractionService.cs b/src/Discord.Net.Interactions/InteractionService.cs index 3089aa5846..82a7b4e7bb 100644 --- a/src/Discord.Net.Interactions/InteractionService.cs +++ b/src/Discord.Net.Interactions/InteractionService.cs @@ -3,7 +3,6 @@ using Discord.Rest; using Discord.WebSocket; using System; -using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -98,6 +97,7 @@ public event Func InteractionE private readonly TypeMap _typeConverterMap; private readonly TypeMap _compTypeConverterMap; private readonly TypeMap _typeReaderMap; + private readonly TypeMap _modalInputTypeConverterMap; private readonly ConcurrentDictionary _autocompleteHandlers = new(); private readonly ConcurrentDictionary _modalInfos = new(); private readonly SemaphoreSlim _lock; @@ -228,6 +228,16 @@ private InteractionService(Func getRestClient, InteractionSer [typeof(Enum)] = typeof(EnumReader<>), [typeof(Nullable<>)] = typeof(NullableReader<>) }); + + _modalInputTypeConverterMap = new TypeMap(this, new ConcurrentDictionary + { + }, new ConcurrentDictionary + { + [typeof(IConvertible)] = typeof(DefaultValueModalComponentConverter<>), + [typeof(Enum)] = typeof(EnumModalComponentConverter<>), + [typeof(Nullable<>)] = typeof(NullableComponentConverter<>), + [typeof(Array)] = typeof(DefaultArrayModalComponentConverter<>) + }); } /// @@ -1064,6 +1074,94 @@ public bool TryRemoveGenericTypeReader(out Type readerType) public bool TryRemoveGenericTypeReader(Type type, out Type readerType) => _typeReaderMap.TryRemoveGeneric(type, out readerType); + internal ModalComponentTypeConverter GetModalInputTypeConverter(Type type, IServiceProvider services = null) => + _modalInputTypeConverterMap.Get(type, services); + + /// + /// Add a concrete type . + /// + /// Primary target of the . + /// The instance. + public void AddModalComponentTypeConverter(ModalComponentTypeConverter converter) => + AddModalComponentTypeConverter(typeof(T), converter); + + /// + /// Add a concrete type . + /// + /// Primary target of the . + /// The instance. + public void AddModalComponentTypeConverter(Type type, ModalComponentTypeConverter converter) => + _modalInputTypeConverterMap.AddConcrete(type, converter); + + /// + /// Add a generic type . + /// + /// Generic Type constraint of the of the . + /// Type of the . + public void AddGenericModalComponentTypeConverter(Type converterType) => + AddGenericModalComponentTypeConverter(typeof(T), converterType); + + /// + /// Add a generic type . + /// + /// Generic Type constraint of the of the . + /// Type of the . + public void AddGenericModalComponentTypeConverter(Type targetType, Type converterType) => + _modalInputTypeConverterMap.AddGeneric(targetType, converterType); + + /// + /// Removes a for the type . + /// + /// + /// Removing a from the will not dereference the from the loaded module/command instances. + /// You need to reload the modules for the changes to take effect. + /// + /// The type to remove the converter from. + /// The converter if the resulting remove operation was successful. + /// if the remove operation was successful; otherwise . + public bool TryRemoveModalComponentTypeConverter(out ModalComponentTypeConverter converter) => + TryRemoveModalComponentTypeConverter(typeof(T), out converter); + + /// + /// Removes a for the type . + /// + /// + /// Removing a from the will not dereference the from the loaded module/command instances. + /// You need to reload the modules for the changes to take effect. + /// + /// The type to remove the converter from. + /// The converter if the resulting remove operation was successful. + /// if the remove operation was successful; otherwise . + public bool TryRemoveModalComponentTypeConverter(Type type, out ModalComponentTypeConverter converter) => + _modalInputTypeConverterMap.TryRemoveConcrete(type, out converter); + + /// + /// Removes a generic for the type . + /// + /// + /// Removing a from the will not dereference the from the loaded module/command instances. + /// You need to reload the modules for the changes to take effect. + /// + /// The type to remove the converter from. + /// The converter if the resulting remove operation was successful. + /// if the remove operation was successful; otherwise . + public bool TryRemoveGenericModalComponentTypeConverter(out Type converterType) => + TryRemoveGenericModalComponentTypeConverter(typeof(T), out converterType); + + /// + /// Removes a generic for the type . + /// + /// + /// Removing a from the will not dereference the from the loaded module/command instances. + /// You need to reload the modules for the changes to take effect. + /// + /// The type to remove the converter from. + /// The converter if the resulting remove operation was successful. + /// if the remove operation was successful; otherwise . + public bool TryRemoveGenericModalComponentTypeConverter(Type type, out Type converterType) => + _modalInputTypeConverterMap.TryRemoveGeneric(type, out converterType); + + /// /// Serialize an object using a into a to be placed in a Component CustomId. /// diff --git a/src/Discord.Net.Interactions/TypeConverters/ModalComponents/DefaultArrayModalComponentConverter.cs b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/DefaultArrayModalComponentConverter.cs new file mode 100644 index 0000000000..3fc145926d --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/DefaultArrayModalComponentConverter.cs @@ -0,0 +1,187 @@ +using Discord.Utils; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions; + +internal sealed class DefaultArrayModalComponentConverter : ModalComponentTypeConverter +{ + private readonly Type _underlyingType; + private readonly TypeReader _typeReader; + private readonly ImmutableArray _channelTypes; + + public DefaultArrayModalComponentConverter(InteractionService interactionService) + { + var type = typeof(T); + + if (!type.IsArray) + throw new InvalidOperationException($"{nameof(DefaultArrayComponentConverter)} cannot be used to convert a non-array type."); + + _underlyingType = typeof(T).GetElementType(); + + _typeReader = true switch + { + _ when typeof(IUser).IsAssignableFrom(_underlyingType) + || typeof(IChannel).IsAssignableFrom(_underlyingType) + || typeof(IMentionable).IsAssignableFrom(_underlyingType) + || typeof(IRole).IsAssignableFrom(_underlyingType) + || typeof(IAttachment).IsAssignableFrom(_underlyingType) => null, + _ => interactionService.GetTypeReader(_underlyingType) + }; + + _channelTypes = true switch + { + _ when typeof(IStageChannel).IsAssignableFrom(_underlyingType) + => [ChannelType.Stage], + _ when typeof(IVoiceChannel).IsAssignableFrom(_underlyingType) + => [ChannelType.Voice], + _ when typeof(IDMChannel).IsAssignableFrom(_underlyingType) + => [ChannelType.DM], + _ when typeof(IGroupChannel).IsAssignableFrom(_underlyingType) + => [ChannelType.Group], + _ when typeof(ICategoryChannel).IsAssignableFrom(_underlyingType) + => [ChannelType.Category], + _ when typeof(INewsChannel).IsAssignableFrom(_underlyingType) + => [ChannelType.News], + _ when typeof(IThreadChannel).IsAssignableFrom(_underlyingType) + => [ChannelType.PublicThread, ChannelType.PrivateThread, ChannelType.NewsThread], + _ when typeof(ITextChannel).IsAssignableFrom(_underlyingType) + => [ChannelType.Text], + _ when typeof(IMediaChannel).IsAssignableFrom(_underlyingType) + => [ChannelType.Media], + _ when typeof(IForumChannel).IsAssignableFrom(_underlyingType) + => [ChannelType.Forum], + _ => [] + }; + } + + public override async Task ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services) + { + var objs = new List(); + + + if (_typeReader is not null && option.Values.Count > 0) + foreach (var value in option.Values) + { + var result = await _typeReader.ReadAsync(context, value, services).ConfigureAwait(false); + + if (!result.IsSuccess) + return result; + + objs.Add(result.Value); + } + else + { + if (!TryGetModalInteractionData(context, out var modalData)) + { + return TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"{typeof(IModalInteractionData).Name} cannot be accessed from the provided {typeof(IInteractionContext).Name} type."); + } + + var resolvedSnowflakes = new Dictionary(); + + if (modalData.Users is not null) + foreach (var user in modalData.Users) + resolvedSnowflakes[user.Id] = user; + + if (modalData.Members is not null) + foreach (var member in modalData.Members) + resolvedSnowflakes[member.Id] = member; + + if (modalData.Roles is not null) + foreach (var role in modalData.Roles) + resolvedSnowflakes[role.Id] = role; + + if (modalData.Channels is not null) + foreach (var channel in modalData.Channels) + resolvedSnowflakes[channel.Id] = channel; + + if (modalData.Attachments is not null) + foreach (var attachment in modalData.Attachments) + resolvedSnowflakes[attachment.Id] = attachment; + + foreach (var value in option.Values) + { + if (!ulong.TryParse(value, out var id)) + { + return TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"{option.Type} contains invalid snowflake."); + } + + if (!resolvedSnowflakes.TryGetValue(id, out var snowflakeEntity)) + { + return TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Some snowflake entity references for the {option.Type} cannot be resolved."); + } + + objs.Add(snowflakeEntity); + } + } + + var destination = Array.CreateInstance(_underlyingType, objs.Count); + + for (var i = 0; i < objs.Count; i++) + destination.SetValue(objs[i], i); + + return TypeConverterResult.FromSuccess(destination); + } + + public override Task WriteAsync(TBuilder builder, IDiscordInteraction interaction, InputComponentInfo component, object value) + { + if (builder is FileUploadComponentBuilder) + return Task.CompletedTask; + + 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) + { + 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 + { + 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 defaultUsers: + selectMenu.DefaultValues = defaultUsers.Select(SelectMenuDefaultValue.FromUser).ToList(); + break; + case IEnumerable defaultRoles: + selectMenu.DefaultValues = defaultRoles.Select(SelectMenuDefaultValue.FromRole).ToList(); + break; + case IEnumerable defaultChannels: + selectMenu.DefaultValues = defaultChannels.Select(SelectMenuDefaultValue.FromChannel).ToList(); + break; + case IEnumerable defaultMentionables: + selectMenu.DefaultValues = defaultMentionables.Where(x => x is IUser or IRole or IChannel) + .Select(x => + { + 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; + }; + + if (component.ComponentType == ComponentType.ChannelSelect && _channelTypes.Length > 0) + selectMenu.WithChannelTypes(_channelTypes.ToList()); + + return Task.CompletedTask; + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/ModalComponents/DefaultValueModalComponentConverter.cs b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/DefaultValueModalComponentConverter.cs new file mode 100644 index 0000000000..d702a6e012 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/DefaultValueModalComponentConverter.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions; + +internal sealed class DefaultValueModalComponentConverter : ModalComponentTypeConverter + where T : IConvertible +{ + public override Task ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services) + { + try + { + return option.Type switch + { + ComponentType.SelectMenu when option.Values.Count == 1 => Task.FromResult(TypeConverterResult.FromSuccess(Convert.ChangeType(option.Values.First(), typeof(T)))), + ComponentType.TextInput => Task.FromResult(TypeConverterResult.FromSuccess(Convert.ChangeType(option.Value, typeof(T)))), + _ => Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"{option.Type} doesn't have a convertible value.")) + }; + } + catch (Exception ex) when (ex is FormatException or InvalidCastException) + { + return Task.FromResult(TypeConverterResult.FromError(ex)); + } + } + + public override Task WriteAsync(TBuilder builder, IDiscordInteraction interaction, InputComponentInfo component, object value) + { + var strValue = Convert.ToString(value); + + if(string.IsNullOrEmpty(strValue)) + return Task.CompletedTask; + + switch (builder) + { + case TextInputBuilder textInput: + textInput.WithValue(strValue); + break; + case SelectMenuBuilder selectMenu when component.ComponentType is ComponentType.SelectMenu: + selectMenu.Options.FirstOrDefault(x => x.Value == strValue)?.IsDefault = true; + break; + default: + throw new InvalidOperationException($"{typeof(IConvertible).Name}s cannot be used to populate components other than SelectMenu and TextInput."); + } + ; + + return Task.CompletedTask; + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/ModalComponents/EnumModalComponentConverter.cs b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/EnumModalComponentConverter.cs new file mode 100644 index 0000000000..75a1c1fd35 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/EnumModalComponentConverter.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Discord.Interactions; + +internal sealed class EnumModalComponentConverter : ModalComponentTypeConverter + where T : struct, Enum +{ + private record Option(SelectMenuOptionBuilder OptionBuilder, Predicate Predicate, T Value); + + private readonly bool _isFlags; + private readonly ImmutableArray