diff --git a/src/Discord.Net.Interactions/Attributes/HideAttribute.cs b/src/Discord.Net.Interactions/Attributes/HideAttribute.cs new file mode 100644 index 0000000000..9e6f9a2835 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/HideAttribute.cs @@ -0,0 +1,12 @@ +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 +/// +[AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = true)] +public sealed class HideAttribute : Attribute { } diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalChannelSelectInputAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalChannelSelectInputAttribute.cs new file mode 100644 index 0000000000..263518e04c --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalChannelSelectInputAttribute.cs @@ -0,0 +1,10 @@ +namespace Discord.Interactions.Attributes.Modals; + +public class ModalChannelSelectInputAttribute : SelectInputAttribute +{ + public override ComponentType ComponentType => ComponentType.ChannelSelect; + + public ModalChannelSelectInputAttribute(string customId) : base(customId) + { + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalMentionableSelectInputAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalMentionableSelectInputAttribute.cs new file mode 100644 index 0000000000..f3088b53a6 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalMentionableSelectInputAttribute.cs @@ -0,0 +1,10 @@ +namespace Discord.Interactions.Attributes.Modals; + +public class ModalMentionableSelectInputAttribute : SelectInputAttribute +{ + public override ComponentType ComponentType => ComponentType.MentionableSelect; + + public ModalMentionableSelectInputAttribute(string customId) : base(customId) + { + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalRoleSelectInputAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalRoleSelectInputAttribute.cs new file mode 100644 index 0000000000..18e611b1f7 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalRoleSelectInputAttribute.cs @@ -0,0 +1,10 @@ +namespace Discord.Interactions.Attributes.Modals; + +public class ModalRoleSelectInputAttribute : SelectInputAttribute +{ + public override ComponentType ComponentType => ComponentType.RoleSelect; + + public ModalRoleSelectInputAttribute(string customId) : base(customId) + { + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalSelectMenuInputAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalSelectMenuInputAttribute.cs new file mode 100644 index 0000000000..d5a17799b1 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalSelectMenuInputAttribute.cs @@ -0,0 +1,11 @@ +namespace Discord.Interactions.Attributes.Modals; + +public sealed class ModalSelectMenuInputAttribute : SelectInputAttribute +{ + public override ComponentType ComponentType => ComponentType.SelectMenu; + + public ModalSelectMenuInputAttribute(string customId) : base(customId) + { + + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalUserSelectInputAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalUserSelectInputAttribute.cs new file mode 100644 index 0000000000..3299524cfc --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalUserSelectInputAttribute.cs @@ -0,0 +1,10 @@ +namespace Discord.Interactions.Attributes.Modals; + +public class ModalUserSelectInputAttribute : SelectInputAttribute +{ + public override ComponentType ComponentType => ComponentType.UserSelect; + + public ModalUserSelectInputAttribute(string customId) : base(customId) + { + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/SelectInputAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/SelectInputAttribute.cs new file mode 100644 index 0000000000..7106125eb5 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/SelectInputAttribute.cs @@ -0,0 +1,14 @@ +namespace Discord.Interactions.Attributes.Modals; + +public abstract class SelectInputAttribute : ModalInputAttribute +{ + public int MinValues { get; set; } = 1; + + public int MaxValues { get; set; } = 1; + + public string Placeholder { get; set; } + + public SelectInputAttribute(string customId) : base(customId) + { + } +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/ChannelSelectInputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/ChannelSelectInputComponentBuilder.cs new file mode 100644 index 0000000000..3a5c52426d --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/ChannelSelectInputComponentBuilder.cs @@ -0,0 +1,39 @@ +using Discord.Interactions.Info.InputComponents; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.Interactions.Builders.Modals.Inputs; + +public class ChannelSelectInputComponentBuilder : SnowflakeSelectInputComponentBuilder +{ + protected override ChannelSelectInputComponentBuilder Instance => this; + + public ChannelSelectInputComponentBuilder(ModalBuilder modal) : base(modal, ComponentType.ChannelSelect) { } + + public ChannelSelectInputComponentBuilder AddDefaulValue(IChannel channel) + { + _defaultValues.Add(new SelectMenuDefaultValue(channel.Id, SelectDefaultValueType.Channel)); + return this; + } + + public ChannelSelectInputComponentBuilder AddDefaulValue(ulong channelId) + { + _defaultValues.Add(new SelectMenuDefaultValue(channelId, SelectDefaultValueType.Channel)); + return this; + } + + public ChannelSelectInputComponentBuilder AddDefaultValues(params IChannel[] channels) + { + _defaultValues.AddRange(channels.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.Channel))); + return this; + } + + public ChannelSelectInputComponentBuilder AddDefaultValues(IEnumerable channels) + { + _defaultValues.AddRange(channels.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.Channel))); + return this; + } + + internal override ChannelSelectInputComponentInfo 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 index 68c26fd037..e46d4ca1b3 100644 --- a/src/Discord.Net.Interactions/Builders/Modals/Inputs/IInputComponentBuilder.cs +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/IInputComponentBuilder.cs @@ -1,3 +1,4 @@ +using Discord.Interactions.TypeConverters.ModalInputs; using System; using System.Collections.Generic; using System.Reflection; @@ -45,12 +46,12 @@ public interface IInputComponentBuilder PropertyInfo PropertyInfo { get; } /// - /// Get the assigned to this input. + /// Get the assigned to this input. /// - ComponentTypeConverter TypeConverter { get; } + ModalComponentTypeConverter TypeConverter { get; } /// - /// Gets the default value of this input component. + /// Gets the default value of this input component property. /// object DefaultValue { get; } diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/ISnowflakeSelectInputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/ISnowflakeSelectInputComponentBuilder.cs new file mode 100644 index 0000000000..817415dc93 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/ISnowflakeSelectInputComponentBuilder.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace Discord.Interactions.Builders.Modals.Inputs; + +public interface ISnowflakeSelectInputComponentBuilder : IInputComponentBuilder +{ + int MinValues { get; } + + int MaxValues { get; } + + string Placeholder { get; set; } + + IReadOnlyCollection DefaultValues { get; } + + SelectDefaultValueType? DefaultValuesType { get; } + + ISnowflakeSelectInputComponentBuilder AddDefaultValue(SelectMenuDefaultValue defaultValue); + + ISnowflakeSelectInputComponentBuilder WithMinValues(int minValues); + + ISnowflakeSelectInputComponentBuilder WithMaxValues(int maxValues); + + ISnowflakeSelectInputComponentBuilder WithPlaceholder(string placeholder); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/InputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/InputComponentBuilder.cs index af0ab3a70e..8da3d07177 100644 --- a/src/Discord.Net.Interactions/Builders/Modals/Inputs/InputComponentBuilder.cs +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/InputComponentBuilder.cs @@ -1,3 +1,4 @@ +using Discord.Interactions.TypeConverters.ModalInputs; using System; using System.Collections.Generic; using System.Reflection; @@ -38,7 +39,7 @@ public abstract class InputComponentBuilder : IInputComponentBu public PropertyInfo PropertyInfo { get; internal set; } /// - public ComponentTypeConverter TypeConverter { get; private set; } + public ModalComponentTypeConverter TypeConverter { get; private set; } /// public object DefaultValue { get; set; } @@ -102,7 +103,7 @@ public TBuilder SetIsRequired(bool isRequired) /// /// The builder instance. /// - public TBuilder WithComponentType(ComponentType componentType) + public virtual TBuilder WithComponentType(ComponentType componentType) { ComponentType = componentType; return Instance; @@ -118,7 +119,7 @@ public TBuilder WithComponentType(ComponentType componentType) public TBuilder WithType(Type type) { Type = type; - TypeConverter = Modal._interactionService.GetComponentTypeConverter(type); + TypeConverter = Modal._interactionService.GetModalInputTypeConverter(type); return Instance; } diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/MentionableSelectInputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/MentionableSelectInputComponentBuilder.cs new file mode 100644 index 0000000000..2122cef92b --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/MentionableSelectInputComponentBuilder.cs @@ -0,0 +1,37 @@ +using Discord.Interactions.Info.InputComponents; + +namespace Discord.Interactions.Builders.Modals.Inputs; + +public class MentionableSelectInputComponentBuilder : SnowflakeSelectInputComponentBuilder +{ + protected override MentionableSelectInputComponentBuilder Instance => this; + + public MentionableSelectInputComponentBuilder(ModalBuilder modal) : base(modal, ComponentType.MentionableSelect) { } + + public MentionableSelectInputComponentBuilder AddDefaultValue(ulong id, SelectDefaultValueType type) + { + _defaultValues.Add(new SelectMenuDefaultValue(id, type)); + return this; + } + + public MentionableSelectInputComponentBuilder AddDefaultValue(IUser user) + { + _defaultValues.Add(new SelectMenuDefaultValue(user.Id, SelectDefaultValueType.User)); + return this; + } + + public MentionableSelectInputComponentBuilder AddDefaultValue(IChannel channel) + { + _defaultValues.Add(new SelectMenuDefaultValue(channel.Id, SelectDefaultValueType.Channel)); + return this; + } + + public MentionableSelectInputComponentBuilder AddDefaulValue(IRole role) + { + _defaultValues.Add(new SelectMenuDefaultValue(role.Id, SelectDefaultValueType.Role)); + return this; + } + + internal override MentionableSelectInputComponentInfo Build(ModalInfo modal) + => new(this, modal); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/RoleSelectInputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/RoleSelectInputComponentBuilder.cs new file mode 100644 index 0000000000..e566ea3e6f --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/RoleSelectInputComponentBuilder.cs @@ -0,0 +1,39 @@ +using Discord.Interactions.Info.InputComponents; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.Interactions.Builders.Modals.Inputs; + +public class RoleSelectInputComponentBuilder : SnowflakeSelectInputComponentBuilder +{ + protected override RoleSelectInputComponentBuilder Instance => this; + + public RoleSelectInputComponentBuilder(ModalBuilder modal) : base(modal, ComponentType.RoleSelect) { } + + public RoleSelectInputComponentBuilder AddDefaulValue(IRole role) + { + _defaultValues.Add(new SelectMenuDefaultValue(role.Id, SelectDefaultValueType.Role)); + return this; + } + + public RoleSelectInputComponentBuilder AddDefaulValue(ulong roleId) + { + _defaultValues.Add(new SelectMenuDefaultValue(roleId, SelectDefaultValueType.Role)); + return this; + } + + public RoleSelectInputComponentBuilder AddDefaultValues(params IRole[] roles) + { + _defaultValues.AddRange(roles.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.Role))); + return this; + } + + public RoleSelectInputComponentBuilder AddDefaultValues(IEnumerable roles) + { + _defaultValues.AddRange(roles.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.Role))); + return this; + } + + internal override RoleSelectInputComponentInfo Build(ModalInfo modal) + => new(this, modal); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/SelectMenuInputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/SelectMenuInputComponentBuilder.cs new file mode 100644 index 0000000000..c75850b0fc --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/SelectMenuInputComponentBuilder.cs @@ -0,0 +1,42 @@ +using Discord.Interactions.Info.InputComponents; +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders; + +public class SelectMenuInputComponentBuilder : InputComponentBuilder +{ + private readonly List _options; + + protected override SelectMenuInputComponentBuilder Instance => this; + + public string Placeholder { get; set; } + + public int MinValues { get; set; } + + public int MaxValues { get; set; } + + public IReadOnlyCollection Options => _options; + + public SelectMenuInputComponentBuilder(ModalBuilder modal) : base(modal) + { + _options = new(); + } + + public SelectMenuInputComponentBuilder AddOption(SelectMenuOptionBuilder option) + { + _options.Add(option); + return this; + } + + public SelectMenuInputComponentBuilder AddOption(Action configure) + { + var builder = new SelectMenuOptionBuilder(); + configure(builder); + _options.Add(builder); + return this; + } + + internal override SelectMenuInputComponentInfo Build(ModalInfo modal) + => new(this, modal); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/SnowflakeSelectInputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/SnowflakeSelectInputComponentBuilder.cs new file mode 100644 index 0000000000..e7c508f510 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/SnowflakeSelectInputComponentBuilder.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders.Modals.Inputs; + +public abstract class SnowflakeSelectInputComponentBuilder : InputComponentBuilder, ISnowflakeSelectInputComponentBuilder + where TInfo : InputComponentInfo + where TBuilder : InputComponentBuilder, ISnowflakeSelectInputComponentBuilder +{ + 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."), + }; + } + } + + public SnowflakeSelectInputComponentBuilder(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)); + + } + + ISnowflakeSelectInputComponentBuilder ISnowflakeSelectInputComponentBuilder.AddDefaultValue(SelectMenuDefaultValue defaultValue) => AddDefaultValue(defaultValue); + + ISnowflakeSelectInputComponentBuilder ISnowflakeSelectInputComponentBuilder.WithMinValues(int minValues) => WithMinValues(minValues); + + ISnowflakeSelectInputComponentBuilder ISnowflakeSelectInputComponentBuilder.WithMaxValues(int maxValues) => WithMaxValues(maxValues); + + ISnowflakeSelectInputComponentBuilder ISnowflakeSelectInputComponentBuilder.WithPlaceholder(string placeholder) => WithPlaceholder(placeholder); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/UserSelectInputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/UserSelectInputComponentBuilder.cs new file mode 100644 index 0000000000..87180dbe53 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/UserSelectInputComponentBuilder.cs @@ -0,0 +1,39 @@ +using Discord.Interactions.Info.InputComponents; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.Interactions.Builders.Modals.Inputs; + +public class UserSelectInputComponentBuilder : SnowflakeSelectInputComponentBuilder +{ + protected override UserSelectInputComponentBuilder Instance => this; + + public UserSelectInputComponentBuilder(ModalBuilder modal) : base(modal, ComponentType.UserSelect) { } + + public UserSelectInputComponentBuilder AddDefaulValue(IUser user) + { + _defaultValues.Add(new SelectMenuDefaultValue(user.Id, SelectDefaultValueType.User)); + return this; + } + + public UserSelectInputComponentBuilder AddDefaulValue(ulong userId) + { + _defaultValues.Add(new SelectMenuDefaultValue(userId, SelectDefaultValueType.User)); + return this; + } + + public UserSelectInputComponentBuilder AddDefaultValues(params IUser[] users) + { + _defaultValues.AddRange(users.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.User))); + return this; + } + + public UserSelectInputComponentBuilder AddDefaultValues(IEnumerable users) + { + _defaultValues.AddRange(users.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.User))); + return this; + } + + internal override UserSelectInputComponentInfo Build(ModalInfo modal) + => new(this, modal); +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs index 66aeadf75b..1d90134915 100644 --- a/src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs +++ b/src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs @@ -1,3 +1,4 @@ +using Discord.Interactions.Builders.Modals.Inputs; using System; using System.Collections.Generic; using System.Linq; @@ -80,6 +81,53 @@ public ModalBuilder AddTextComponent(Action configure return this; } + /// + /// Adds a select menu component to . + /// + /// Select menu component builder factory. + /// + /// The builder instance. + /// + public ModalBuilder AddSelectMenuComponent(Action configure) + { + var builder = new SelectMenuInputComponentBuilder(this); + configure(builder); + _components.Add(builder); + return this; + } + + public ModalBuilder AddUserSelectComponent(Action configure) + { + var builder = new UserSelectInputComponentBuilder(this); + configure(builder); + _components.Add(builder); + return this; + } + + public ModalBuilder AddRoleSelectComponent(Action configure) + { + var builder = new RoleSelectInputComponentBuilder(this); + configure(builder); + _components.Add(builder); + return this; + } + + public ModalBuilder AddMentionableSelectComponent(Action configure) + { + var builder = new MentionableSelectInputComponentBuilder(this); + configure(builder); + _components.Add(builder); + return this; + } + + public ModalBuilder AddChannelSelectComponent(Action configure) + { + var builder = new ChannelSelectInputComponentBuilder(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..2c541ad1be 100644 --- a/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs +++ b/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs @@ -1,3 +1,6 @@ +using Discord.Interactions.Attributes.Modals; +using Discord.Interactions.Builders.Modals.Inputs; +using Discord.Interactions.Info.InputComponents; using System; using System.Collections.Generic; using System.Collections.Immutable; @@ -615,6 +618,21 @@ public static ModalInfo BuildModalInfo(Type modalType, InteractionService intera case ComponentType.TextInput: builder.AddTextComponent(x => BuildTextInput(x, prop, prop.GetValue(instance))); break; + case ComponentType.SelectMenu: + builder.AddSelectMenuComponent(x => BuildSelectMenuInput(x, prop, prop.GetValue(instance))); + break; + case ComponentType.UserSelect: + builder.AddUserSelectComponent(x => BuildSnowflakeSelectInput(x, prop, prop.GetValue(instance))); + break; + case ComponentType.RoleSelect: + builder.AddRoleSelectComponent(x => BuildSnowflakeSelectInput(x, prop, prop.GetValue(instance))); + break; + case ComponentType.MentionableSelect: + builder.AddMentionableSelectComponent(x => BuildSnowflakeSelectInput(x, prop, prop.GetValue(instance))); + break; + case ComponentType.ChannelSelect: + builder.AddChannelSelectComponent(x => BuildSnowflakeSelectInput(x, prop, prop.GetValue(instance))); + break; case null: throw new InvalidOperationException($"{prop.Name} of {prop.DeclaringType.Name} isn't a valid modal input field."); default: @@ -666,6 +684,74 @@ private static void BuildTextInput(TextInputComponentBuilder builder, PropertyIn } } } + + private static void BuildSelectMenuInput(SelectMenuInputComponentBuilder 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 ModalSelectMenuInputAttribute 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; + break; + default: + builder.WithAttributes(attribute); + break; + } + } + } + + private static void BuildSnowflakeSelectInput(SnowflakeSelectInputComponentBuilder builder, PropertyInfo propertyInfo, object defaultValue) + where TInfo: SnowflakeSelectInputComponentInfo + where TBuilder: SnowflakeSelectInputComponentBuilder + { + 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 SelectInputAttribute 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; + break; + default: + builder.WithAttributes(attribute); + break; + } + } + } #endregion internal static bool IsValidModuleDefinition(TypeInfo typeInfo) diff --git a/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs b/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs index 85f53af3f5..a2e9ca669c 100644 --- a/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs +++ b/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs @@ -1,4 +1,6 @@ +using Discord.Interactions.Info.InputComponents; using System; +using System.Linq; using System.Threading.Tasks; namespace Discord.Interactions @@ -54,7 +56,7 @@ public static Task RespondWithModalAsync(this IDiscordInteraction interaction /// The request options for this request. /// Delegate that can be used to modify the modal. /// - public static Task RespondWithModalAsync(this IDiscordInteraction interaction, string customId, T modal, RequestOptions options = null, + public static async Task RespondWithModalAsync(this IDiscordInteraction interaction, string customId, T modal, RequestOptions options = null, Action modifyModal = null) where T : class, IModal { @@ -68,13 +70,30 @@ public static Task RespondWithModalAsync(this IDiscordInteraction interaction { case TextInputComponentInfo textComponent: { - var boxedValue = textComponent.Getter(modal); - var value = textComponent.TypeOverridesToString - ? boxedValue?.ToString() - : boxedValue as string; + var inputBuilder = new TextInputBuilder(textComponent.Label, textComponent.CustomId, textComponent.Style, textComponent.Placeholder, textComponent.IsRequired ? textComponent.MinLength : null, + textComponent.MaxLength, textComponent.IsRequired); - builder.AddTextInput(textComponent.Label, textComponent.CustomId, textComponent.Style, textComponent.Placeholder, textComponent.IsRequired ? textComponent.MinLength : null, - textComponent.MaxLength, textComponent.IsRequired, value); + await textComponent.TypeConverter.WriteAsync(inputBuilder, textComponent, textComponent.Getter(modal)); + + builder.AddTextInput(inputBuilder); + } + break; + case SelectMenuInputComponentInfo selectMenuComponent: + { + var inputBuilder = new SelectMenuBuilder(selectMenuComponent.CustomId, selectMenuComponent.Options.Select(x => new SelectMenuOptionBuilder(x)).ToList(), selectMenuComponent.Placeholder, selectMenuComponent.MaxValues, selectMenuComponent.MinValues, false); + + await selectMenuComponent.TypeConverter.WriteAsync(inputBuilder, selectMenuComponent, selectMenuComponent.Getter(modal)); + + //todo: add to builder + } + break; + case SnowflakeSelectInputComponentInfo snowflakeSelectComponent: + { + var inputBuilder = new SelectMenuBuilder(snowflakeSelectComponent.CustomId, null, snowflakeSelectComponent.Placeholder, snowflakeSelectComponent.MaxValues, snowflakeSelectComponent.MinValues, false, snowflakeSelectComponent.ComponentType, null, snowflakeSelectComponent.DefaultValues.ToList()); + + await snowflakeSelectComponent.TypeConverter.WriteAsync(inputBuilder, snowflakeSelectComponent, snowflakeSelectComponent.Getter(modal)); + + //todo: add to builder } break; default: @@ -84,7 +103,7 @@ public static Task RespondWithModalAsync(this IDiscordInteraction interaction if (modifyModal is not null) modifyModal(builder); - return interaction.RespondWithModalAsync(builder.Build(), options); + await interaction.RespondWithModalAsync(builder.Build(), options); } private static Task SendModalResponseAsync(IDiscordInteraction interaction, string customId, ModalInfo modalInfo, RequestOptions options = null, Action modifyModal = null) diff --git a/src/Discord.Net.Interactions/Info/InputComponents/ChannelSelectInputComponentInfo.cs b/src/Discord.Net.Interactions/Info/InputComponents/ChannelSelectInputComponentInfo.cs new file mode 100644 index 0000000000..032a85f46f --- /dev/null +++ b/src/Discord.Net.Interactions/Info/InputComponents/ChannelSelectInputComponentInfo.cs @@ -0,0 +1,8 @@ +using Discord.Interactions.Builders.Modals.Inputs; + +namespace Discord.Interactions.Info.InputComponents; + +public class ChannelSelectInputComponentInfo : SnowflakeSelectInputComponentInfo +{ + public ChannelSelectInputComponentInfo(ChannelSelectInputComponentBuilder 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 index 23a0db8447..dc7fa02e08 100644 --- a/src/Discord.Net.Interactions/Info/InputComponents/InputComponentInfo.cs +++ b/src/Discord.Net.Interactions/Info/InputComponents/InputComponentInfo.cs @@ -1,3 +1,4 @@ +using Discord.Interactions.TypeConverters.ModalInputs; using System; using System.Collections.Generic; using System.Collections.Immutable; @@ -50,12 +51,12 @@ public abstract class InputComponentInfo public PropertyInfo PropertyInfo { get; } /// - /// Gets the assigned to this component. + /// Gets the assigned to this component. /// - public ComponentTypeConverter TypeConverter { get; } + public ModalComponentTypeConverter TypeConverter { get; } /// - /// Gets the default value of this component. + /// Gets the default value of this component property. /// public object DefaultValue { get; } diff --git a/src/Discord.Net.Interactions/Info/InputComponents/MentionableSelectInputComponentInfo.cs b/src/Discord.Net.Interactions/Info/InputComponents/MentionableSelectInputComponentInfo.cs new file mode 100644 index 0000000000..3924384b73 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/InputComponents/MentionableSelectInputComponentInfo.cs @@ -0,0 +1,8 @@ +using Discord.Interactions.Builders.Modals.Inputs; + +namespace Discord.Interactions.Info.InputComponents; + +public class MentionableSelectInputComponentInfo : SnowflakeSelectInputComponentInfo +{ + public MentionableSelectInputComponentInfo(MentionableSelectInputComponentBuilder builder, ModalInfo modal) : base(builder, modal) { } +} diff --git a/src/Discord.Net.Interactions/Info/InputComponents/RoleSelectInputComponentInfo.cs b/src/Discord.Net.Interactions/Info/InputComponents/RoleSelectInputComponentInfo.cs new file mode 100644 index 0000000000..676d3ee01b --- /dev/null +++ b/src/Discord.Net.Interactions/Info/InputComponents/RoleSelectInputComponentInfo.cs @@ -0,0 +1,8 @@ +using Discord.Interactions.Builders.Modals.Inputs; + +namespace Discord.Interactions.Info.InputComponents; + +public class RoleSelectInputComponentInfo : SnowflakeSelectInputComponentInfo +{ + public RoleSelectInputComponentInfo(RoleSelectInputComponentBuilder builder, ModalInfo modal) : base(builder, modal) { } +} diff --git a/src/Discord.Net.Interactions/Info/InputComponents/SelectMenuInputComponentInfo.cs b/src/Discord.Net.Interactions/Info/InputComponents/SelectMenuInputComponentInfo.cs new file mode 100644 index 0000000000..12bacf3ac2 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/InputComponents/SelectMenuInputComponentInfo.cs @@ -0,0 +1,24 @@ +using Discord.Interactions.Builders; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Interactions.Info.InputComponents; +public class SelectMenuInputComponentInfo : InputComponentInfo +{ + public string Placeholder { get; set; } + + public int MinValues { get; set; } + + public int MaxValues { get; set; } + + public IReadOnlyCollection Options { get; } + + internal SelectMenuInputComponentInfo(SelectMenuInputComponentBuilder 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/InputComponents/SnowflakeSelectInputComponentInfo.cs b/src/Discord.Net.Interactions/Info/InputComponents/SnowflakeSelectInputComponentInfo.cs new file mode 100644 index 0000000000..175e013f34 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/InputComponents/SnowflakeSelectInputComponentInfo.cs @@ -0,0 +1,27 @@ +using Discord.Interactions.Builders.Modals.Inputs; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Interactions.Info.InputComponents; + +public abstract class SnowflakeSelectInputComponentInfo : InputComponentInfo +{ + public int MinValues { get; } + + public int MaxValues { get; } + + public string Placeholder { get; } + + public IReadOnlyCollection DefaultValues { get; } + + public SelectDefaultValueType? DefaultValueType { get; } + + internal SnowflakeSelectInputComponentInfo(ISnowflakeSelectInputComponentBuilder 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/InputComponents/UserSelectInputComponentInfo.cs b/src/Discord.Net.Interactions/Info/InputComponents/UserSelectInputComponentInfo.cs new file mode 100644 index 0000000000..dd189084bb --- /dev/null +++ b/src/Discord.Net.Interactions/Info/InputComponents/UserSelectInputComponentInfo.cs @@ -0,0 +1,8 @@ +using Discord.Interactions.Builders.Modals.Inputs; + +namespace Discord.Interactions.Info.InputComponents; + +public class UserSelectInputComponentInfo : SnowflakeSelectInputComponentInfo +{ + public UserSelectInputComponentInfo(UserSelectInputComponentBuilder builder, ModalInfo modal) : base(builder, modal) { } +} diff --git a/src/Discord.Net.Interactions/Info/ModalInfo.cs b/src/Discord.Net.Interactions/Info/ModalInfo.cs index bef789ac9d..93e6c14449 100644 --- a/src/Discord.Net.Interactions/Info/ModalInfo.cs +++ b/src/Discord.Net.Interactions/Info/ModalInfo.cs @@ -1,3 +1,6 @@ +using Discord.Interactions.Builders; +using Discord.Interactions.Builders.Modals.Inputs; +using Discord.Interactions.Info.InputComponents; using System; using System.Collections.Generic; using System.Collections.Immutable; @@ -43,17 +46,40 @@ public class ModalInfo /// public IReadOnlyCollection TextComponents { get; } + /// + /// Get a collection of the select menu components of this modal. + /// + public IReadOnlyCollection SelectMenuComponents { get; } + + public IReadOnlyCollection UserSelectComponents { get; } + + public IReadOnlyCollection RoleSelectComponents { get; } + + public IReadOnlyCollection MentionableSelectComponents { get; } + + public IReadOnlyCollection ChannelSelectComponents { 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), + TextInputComponentBuilder textComponent => textComponent.Build(this), + SelectMenuInputComponentBuilder selectMenuComponent => selectMenuComponent.Build(this), + RoleSelectInputComponentBuilder roleSelectComponent => roleSelectComponent.Build(this), + ChannelSelectInputComponentBuilder channelSelectComponent => channelSelectComponent.Build(this), + UserSelectInputComponentBuilder userSelectComponent => userSelectComponent.Build(this), + MentionableSelectInputComponentBuilder mentionableSelectComponent => mentionableSelectComponent.Build(this), _ => throw new InvalidOperationException($"{x.GetType().FullName} isn't a supported modal input component builder type.") }).ToImmutableArray(); TextComponents = Components.OfType().ToImmutableArray(); + SelectMenuComponents = Components.OfType().ToImmutableArray(); + UserSelectComponents = Components.OfType().ToImmutableArray(); + RoleSelectComponents = Components.OfType().ToImmutableArray(); + MentionableSelectComponents = Components.OfType().ToImmutableArray(); + ChannelSelectComponents = Components.OfType().ToImmutableArray(); _interactionService = builder._interactionService; _initializer = builder.ModalInitializer; diff --git a/src/Discord.Net.Interactions/InteractionService.cs b/src/Discord.Net.Interactions/InteractionService.cs index 3089aa5846..c12cbe486a 100644 --- a/src/Discord.Net.Interactions/InteractionService.cs +++ b/src/Discord.Net.Interactions/InteractionService.cs @@ -1,7 +1,10 @@ using Discord.Interactions.Builders; +using Discord.Interactions.TypeConverters.ModalComponents; +using Discord.Interactions.TypeConverters.ModalInputs; using Discord.Logging; using Discord.Rest; using Discord.WebSocket; +using Newtonsoft.Json.Bson; using System; using System.Collections; using System.Collections.Concurrent; @@ -98,6 +101,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 +232,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 +1078,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..5e8b2c02a3 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/DefaultArrayModalComponentConverter.cs @@ -0,0 +1,170 @@ +using Discord.Interactions.TypeConverters.ModalInputs; +using Discord.Utils; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions.TypeConverters.ModalComponents; + +internal sealed class DefaultArrayModalComponentConverter : ModalComponentTypeConverter +{ + private readonly Type _underlyingType; + private readonly TypeReader _typeReader; + private readonly List _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) => null, + _ => interactionService.GetTypeReader(_underlyingType) + }; + + _channelTypes = true switch + { + _ when typeof(IStageChannel).IsAssignableFrom(type) + => new List { ChannelType.Stage }, + + _ when typeof(IVoiceChannel).IsAssignableFrom(type) + => new List { ChannelType.Voice }, + + _ when typeof(IDMChannel).IsAssignableFrom(type) + => new List { ChannelType.DM }, + + _ when typeof(IGroupChannel).IsAssignableFrom(type) + => new List { ChannelType.Group }, + + _ when typeof(ICategoryChannel).IsAssignableFrom(type) + => new List { ChannelType.Category }, + + _ when typeof(INewsChannel).IsAssignableFrom(type) + => new List { ChannelType.News }, + + _ when typeof(IThreadChannel).IsAssignableFrom(type) + => new List { ChannelType.PublicThread, ChannelType.PrivateThread, ChannelType.NewsThread }, + + _ when typeof(ITextChannel).IsAssignableFrom(type) + => new List { ChannelType.Text }, + + _ when typeof(IMediaChannel).IsAssignableFrom(type) + => new List { ChannelType.Media }, + + _ when typeof(IForumChannel).IsAssignableFrom(type) + => new List { ChannelType.Forum }, + + _ => null + }; + } + + 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 + { + var users = new Dictionary(); + + if (option.Users is not null) + foreach (var user in option.Users) + users[user.Id] = user; + + if (option.Members is not null) + foreach (var member in option.Members) + users[member.Id] = member; + + objs.AddRange(users.Values); + + if (option.Roles is not null) + objs.AddRange(option.Roles); + + if (option.Channels is not null) + objs.AddRange(option.Channels); + } + + 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, InputComponentInfo component, object value) + { + 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(x => SelectMenuDefaultValue.FromUser(x)).ToList(); + break; + case IEnumerable defaultRoles: + selectMenu.DefaultValues = defaultRoles.Select(x => SelectMenuDefaultValue.FromRole(x)).ToList(); + break; + case IEnumerable defaultChannels: + selectMenu.DefaultValues = defaultChannels.Select(x => SelectMenuDefaultValue.FromChannel(x)).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 is not null) + selectMenu.WithChannelTypes(_channelTypes); + + return Task.CompletedTask; + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/ModalComponents/DefaultEntityModalComponentConverter.cs b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/DefaultEntityModalComponentConverter.cs new file mode 100644 index 0000000000..952f43e6a7 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/DefaultEntityModalComponentConverter.cs @@ -0,0 +1,64 @@ +using Discord.Interactions.TypeConverters.ModalInputs; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions.TypeConverters.ModalComponents; + +internal sealed class DefaultEntityModalComponentConverter : ModalComponentTypeConverter + where T : class, ISnowflakeEntity +{ + public override Task ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services) + { + var objs = new List(); + + var users = new Dictionary(); + + if (option.Users is not null) + foreach (var user in option.Users) + users[user.Id] = user; + + if (option.Members is not null) + foreach (var member in option.Members) + users[member.Id] = member; + + objs.AddRange(users.Values); + + if (option.Roles is not null) + objs.AddRange(option.Roles); + + if (option.Channels is not null) + objs.AddRange(option.Channels); + + if (objs.Count > 1) + return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"Component input returned multiple entities, but {typeof(T).FullName} is not an array type.")); + + return Task.FromResult(TypeConverterResult.FromSuccess(objs.FirstOrDefault() as T)); + } + + public override Task WriteAsync(TBuilder builder, InputComponentInfo component, object value) + { + (ISnowflakeEntity Snowflake, SelectDefaultValueType Type) defaultValue = value switch + { + IUser user => (user, SelectDefaultValueType.User), + IRole role => (role, SelectDefaultValueType.Role), + IChannel channel => (channel, SelectDefaultValueType.Channel), + _ => throw new InvalidOperationException($"Only snowflake entities can be used to populate components using {nameof(DefaultEntityModalComponentConverter<>)}") + }; + + switch (builder) + { + case TextInputBuilder textInput: + textInput.WithValue(defaultValue.Snowflake.Id.ToString()); + break; + case SelectMenuBuilder selectMenu: + selectMenu.WithDefaultValues(new SelectMenuDefaultValue(defaultValue.Snowflake.Id, defaultValue.Type)); + break; + default: + throw new InvalidOperationException($"{builder.GetType().FullName} is not supported by {nameof(DefaultEntityModalComponentConverter<>)}"); + } + + 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..f655d794bf --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/DefaultValueModalComponentConverter.cs @@ -0,0 +1,46 @@ +using Discord.Interactions.TypeConverters.ModalInputs; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions.TypeConverters.ModalComponents; + +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, InputComponentInfo component, object value) + { + var strValue = Convert.ToString(value); + + 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($"{nameof(IConvertible)}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..1bf53e8a2b --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/EnumModalComponentConverter.cs @@ -0,0 +1,73 @@ +using Discord.Interactions.TypeConverters.ModalInputs; +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Discord.Interactions.TypeConverters.ModalComponents; + +internal sealed class EnumModalComponentConverter : ModalComponentTypeConverter + where T : struct, Enum +{ + private readonly bool _isFlags; + private readonly ImmutableArray _options; + + public EnumModalComponentConverter() + { + var names = Enum.GetNames(typeof(T)); + var members = names.SelectMany(x => typeof(T).GetMember(x)).Where(x => !x.IsDefined(typeof(HideAttribute), true)); + + if (members.Count() > SelectMenuBuilder.MaxOptionCount) + throw new InvalidOperationException($"Enum type {typeof(T).FullName} has too many visible members to be used in a select menu. Maximum visible members is {SelectMenuBuilder.MaxOptionCount}, but {members.Count()} are visible."); + + _isFlags = typeof(T).GetCustomAttribute() is not null; + + _options = members.Select(x => + { + var selectMenuOptionAttr = x.GetCustomAttribute(); + return new SelectMenuOptionBuilder(x.GetCustomAttribute()?.Name ?? x.Name, x.Name, selectMenuOptionAttr?.Description, Emote.Parse(selectMenuOptionAttr?.Emote), selectMenuOptionAttr?.IsDefault); + }).ToImmutableArray(); + } + + public override Task ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services) + { + if(option.Type is not ComponentType.SelectMenu or ComponentType.TextInput) + return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"{option.Type} input type cannot be converted to {typeof(T).FullName}")); + + var value = option.Type switch + { + ComponentType.SelectMenu => string.Join(",", option.Values), + ComponentType.TextInput => option.Value, + _ => null + }; + + if(Enum.TryParse(value, out var result)) + return Task.FromResult(TypeConverterResult.FromSuccess(result)); + + return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"Value {option.Value} cannot be converted to {typeof(T).FullName}")); + } + + public override Task WriteAsync(TBuilder builder, InputComponentInfo component, object value) + { + if (builder is not SelectMenuBuilder selectMenu || component.ComponentType is not ComponentType.SelectMenu) + throw new InvalidOperationException($"{nameof(EnumModalComponentConverter)} can only write to select menu components."); + + if(selectMenu.MaxValues > 1 && !_isFlags) + throw new InvalidOperationException($"Enum type {typeof(T).FullName} is not a [Flags] enum, so it cannot be used in a multi-select menu."); + + selectMenu.WithOptions(_options.ToList()); + + return Task.CompletedTask; + } +} + +[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] +public class SelectMenuOptionAttribute : Attribute +{ + public string Description { get; set; } + + public bool IsDefault { get; set; } + + public string Emote { get; set; } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/ModalComponents/ModalComponentTypeConverter.cs b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/ModalComponentTypeConverter.cs new file mode 100644 index 0000000000..485c7f5b10 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/ModalComponentTypeConverter.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions.TypeConverters.ModalInputs; +public abstract class ModalComponentTypeConverter : ITypeConverter +{ + public abstract bool CanConvertTo(Type type); + + public abstract Task ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services); + + public virtual Task WriteAsync(TBuilder builder, InputComponentInfo component, object value) + where TBuilder : class, IInteractableComponentBuilder + => Task.CompletedTask; +} + +public abstract class ModalComponentTypeConverter : ModalComponentTypeConverter +{ + /// + public sealed override bool CanConvertTo(Type type) => + typeof(T).IsAssignableFrom(type); +} diff --git a/src/Discord.Net.Interactions/TypeConverters/ModalComponents/NullableModalComponentConverter.cs b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/NullableModalComponentConverter.cs new file mode 100644 index 0000000000..64379f8ca3 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/NullableModalComponentConverter.cs @@ -0,0 +1,23 @@ +using Discord.Interactions.TypeConverters.ModalInputs; +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions.TypeConverters.ModalComponents; + +internal class NullableModalComponentConverter : ModalComponentTypeConverter +{ + private readonly ModalComponentTypeConverter _typeConverter; + + public NullableModalComponentConverter(InteractionService interactionService, IServiceProvider services) + { + var type = Nullable.GetUnderlyingType(typeof(T)); + + if (type is null) + throw new ArgumentException($"No type {nameof(TypeConverter)} is defined for this {type.FullName}", "type"); + + _typeConverter = interactionService.GetModalInputTypeConverter(type, services); + } + + public override Task ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services) + => string.IsNullOrEmpty(option.Value) ? Task.FromResult(TypeConverterResult.FromSuccess(null)) : _typeConverter.ReadAsync(context, option, services); +} diff --git a/src/Discord.Net.Interactions/TypeConverters/SlashCommands/EnumConverter.cs b/src/Discord.Net.Interactions/TypeConverters/SlashCommands/EnumConverter.cs index b95e859c03..3eb13b8321 100644 --- a/src/Discord.Net.Interactions/TypeConverters/SlashCommands/EnumConverter.cs +++ b/src/Discord.Net.Interactions/TypeConverters/SlashCommands/EnumConverter.cs @@ -43,13 +43,4 @@ public override void Write(ApplicationCommandOptionProperties properties, IParam } } } - - /// - /// Enum values tagged with this attribute will not be displayed as a parameter choice - /// - /// - /// This attribute must be used along with the default - /// - [AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = true)] - public sealed class HideAttribute : Attribute { } }