[Feature] Modal Select Components Support for IF (#3189)

* add interaction service attributes new modal input types

* add new modal component type converters

* relocate hide attribute outside of slash command enum converter for general purpose use

* refactor base inputcomponentbuilder types to use modal component typeconverters

* add builders for new modal input types

* add component insertion methods to modal builder for new input types

* refactor base inputcomponentinfo class to use modal component typeconverters

* add component info classes for newly added modal input types

* add build logic for new modal input metadata classes

* add componet collection properties for new modal input types to modalinfo

* implement convertion logic for new modal inputs to the respond with modal extension methods

* implement modal input typeconverters into interaction service

* add read logic to enum modal component typeconverter

* add default entity modal component typeconverter

* add write logic to default value modal component typeconverter

* add write logic to the nullable modal component typeconverter

* add description property to input label attribute

* add inline docs to modal attributes

* add modal file upload attribute

* add inline docs to input component infos

* add description property to input component builder

* add inline docs to modal component builders

* add modal file upload component info

* add modal file upload component builder

* rename select input attribute

* refactor select input attribute and add description property in moduleClassBuilder

* add inline docs to modalBuilder

* add description to inputComponentInfo

* file-scope namespace for commandBuilder and modal interfaces

* update respondWithModal logic to include new components

* add inline docs and file upload component to modalInfo

* add attachment modal typeconverter

* create base non-input modal component entities

* update modal component typeconverter namespaces and remove unused

* add default min/max values to select input attribute

* create text display builder and info classes

* add text display parsing logic

* add text display attribute

* add modal select menu option attribute

* add docs to text display component info

* fix text display parsing

* add isRequired mapping to select menus

* revert to block-scope to clear diff false-positives

* fix inline doc annotations

* fix build errors

* add interaction parameter to modal component typeconverter write method

* add null check to select menu option attribute

* add null check to default value modalTypeConverter write method

* make ctors of modal component base attributes internal

* implement predicate to hide attribute and enum modalcomponent typeconverter

* fix HideAttribute inline docs build errors

* simplify naming of the component classes and normalize namespaces

* fix build errors in module class builder

* add inline docs to modalComponentTypeConverter TryGetModalInteractionData

* add min/max values parameters to ModalChannelSelectAttribute

* fix defaultArrayModalTypeConverter chanell type write logic

* simplify addDefaultValue methods for channe, mentionable, role, and user selects

* add emoji support to select menu options

* add instance value parsing to enum modalComponentConverter
This commit is contained in:
Cenk Ergen
2025-11-24 19:08:05 +01:00
committed by GitHub
parent 06510e1b2b
commit e8c5436c40
52 changed files with 2327 additions and 333 deletions

View File

@@ -0,0 +1,25 @@
using System;
namespace Discord.Interactions;
/// <summary>
/// Enum values tagged with this attribute will not be displayed as a parameter choice
/// </summary>
/// <remarks>
/// This attribute must be used along with the default <see cref="EnumConverter{T}"/> and <see cref="DefaultEntityTypeConverter{T}"/>.
/// </remarks>
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = true)]
public class HideAttribute : Attribute
{
/// <summary>
/// Can be optionally implemented by inherited types to conditionally hide an enum value.
/// </summary>
/// <remarks>
/// Only runs on prior to modal construction. For slash command parameters, this method is ignored.
/// </remarks>
/// <param name="interaction">Interaction that <see cref="IDiscordInteractionExtentions.RespondWithModalAsync{T}(IDiscordInteraction, string, T, RequestOptions, Action{ModalBuilder})"/> is called on.</param>
/// <returns>
/// <see langword="true"/> if the attribute should be active and hide the value.
/// </returns>
public virtual bool Predicate(IDiscordInteraction interaction) => true;
}

View File

@@ -13,13 +13,20 @@ namespace Discord.Interactions
/// </summary>
public string Label { get; }
/// <summary>
/// Gets the label description of the input.
/// </summary>
public string Description { get; set; }
/// <summary>
/// Creates a custom label for an modal input.
/// </summary>
/// <param name="label">The label of the input.</param>
public InputLabelAttribute(string label)
/// <param name="description">The label description of the input.</param>
public InputLabelAttribute(string label, string description = null)
{
Label = label;
Description = description;
}
}
}

View File

@@ -0,0 +1,16 @@
namespace Discord.Interactions;
/// <summary>
/// Marks a <see cref="IModal"/> property as a channel select.
/// </summary>
public class ModalChannelSelectAttribute : ModalSelectComponentAttribute
{
/// <inheritdoc/>
public override ComponentType ComponentType => ComponentType.ChannelSelect;
/// <summary>
/// Create a new <see cref="ModalChannelSelectAttribute"/>.
/// </summary>
/// <param name="customId">Custom ID of the channel select component.</param>
public ModalChannelSelectAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId, minValues, maxValues) { }
}

View File

@@ -0,0 +1,17 @@
using System;
namespace Discord.Interactions;
/// <summary>
/// Mark an <see cref="IModal"/> property as a modal component field.
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
public abstract class ModalComponentAttribute : Attribute
{
/// <summary>
/// Gets the type of the component.
/// </summary>
public abstract ComponentType ComponentType { get; }
internal ModalComponentAttribute() { }
}

View File

@@ -0,0 +1,32 @@
namespace Discord.Interactions;
/// <summary>
/// Marks a <see cref="IModal"/> property as a file upload input.
/// </summary>
public class ModalFileUploadAttribute : ModalInputAttribute
{
/// <inheritdoc/>
public override ComponentType ComponentType => ComponentType.FileUpload;
/// <summary>
/// Get the minimum number of files that can be uploaded.
/// </summary>
public int MinValues { get; set; } = 1;
/// <summary>
/// Get the maximum number of files that can be uploaded.
/// </summary>
public int MaxValues { get; set; } = 1;
/// <summary>
/// Create a new <see cref="ModalFileUploadAttribute"/>.
/// </summary>
/// <param name="customId">Custom ID of the file upload component.</param>
/// <param name="minValues">Minimum number of files that can be uploaded.</param>
/// <param name="maxValues">Maximum number of files that can be uploaded.</param>
public ModalFileUploadAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId)
{
MinValues = minValues;
MaxValues = maxValues;
}
}

View File

@@ -6,23 +6,18 @@ namespace Discord.Interactions
/// Mark an <see cref="IModal"/> property as a modal input field.
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
public abstract class ModalInputAttribute : Attribute
public abstract class ModalInputAttribute : ModalComponentAttribute
{
/// <summary>
/// Gets the custom id of the text input.
/// </summary>
public string CustomId { get; }
/// <summary>
/// Gets the type of the component.
/// </summary>
public abstract ComponentType ComponentType { get; }
/// <summary>
/// Create a new <see cref="ModalInputAttribute"/>.
/// </summary>
/// <param name="customId">The custom id of the input.</param>
protected ModalInputAttribute(string customId)
internal ModalInputAttribute(string customId)
{
CustomId = customId;
}

View File

@@ -0,0 +1,18 @@
namespace Discord.Interactions;
/// <summary>
/// Marks a <see cref="IModal"/> property as a mentionable select input.
/// </summary>
public class ModalMentionableSelectAttribute : ModalSelectComponentAttribute
{
/// <inheritdoc />
public override ComponentType ComponentType => ComponentType.MentionableSelect;
/// <summary>
/// Create a new <see cref="ModalMentionableSelectAttribute"/>.
/// </summary>
/// <param name="customId">Custom ID of the mentionable select component.</param>
/// <param name="minValues">Minimum number of values that can be selected.</param>
/// <param name="maxValues">Maximum number of values that can be selected</param>
public ModalMentionableSelectAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId, minValues, maxValues) { }
}

View File

@@ -0,0 +1,18 @@
namespace Discord.Interactions;
/// <summary>
/// Marks a <see cref="IModal"/> property as a role select input.
/// </summary>
public class ModalRoleSelectAttribute : ModalSelectComponentAttribute
{
/// <inheritdoc/>
public override ComponentType ComponentType => ComponentType.RoleSelect;
/// <summary>
/// Create a new <see cref="ModalRoleSelectAttribute"/>.
/// </summary>
/// <param name="customId">Custom ID of the role select component.</param>
/// <param name="minValues">Minimum number of values that can be selected.</param>
/// <param name="maxValues">Maximum number of values that can be selected.</param>
public ModalRoleSelectAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId, minValues, maxValues) { }
}

View File

@@ -0,0 +1,28 @@
namespace Discord.Interactions;
/// <summary>
/// Base attribute for select-menu, user, channel, role, and mentionable select inputs in modals.
/// </summary>
public abstract class ModalSelectComponentAttribute : ModalInputAttribute
{
/// <summary>
/// Gets or sets the minimum number of values that can be selected.
/// </summary>
public int MinValues { get; set; } = 1;
/// <summary>
/// Gets or sets the maximum number of values that can be selected.
/// </summary>
public int MaxValues { get; set; } = 1;
/// <summary>
/// Gets or sets the placeholder text.
/// </summary>
public string Placeholder { get; set; }
internal ModalSelectComponentAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId)
{
MinValues = minValues;
MaxValues = maxValues;
}
}

View File

@@ -0,0 +1,18 @@
namespace Discord.Interactions;
/// <summary>
/// Marks a <see cref="IModal"/> property as a select menu input.
/// </summary>
public sealed class ModalSelectMenuAttribute : ModalSelectComponentAttribute
{
/// <inheritdoc />
public override ComponentType ComponentType => ComponentType.SelectMenu;
/// <summary>
/// Create a new <see cref="ModalSelectMenuAttribute"/>.
/// </summary>
/// <param name="customId">Custom ID of the select menu component.</param>
/// <param name="minValues">Minimum number of values that can be selected.</param>
/// <param name="maxValues">Maximum number of values that can be selected.</param>
public ModalSelectMenuAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId, minValues, maxValues) { }
}

View File

@@ -0,0 +1,58 @@
using System;
namespace Discord.Interactions;
/// <summary>
/// Adds a select menu option to the marked field.
/// </summary>
/// <remarks>
/// To add additional metadata to enum fields, use <see cref="SelectMenuOptionAttribute"/> instead.
/// </remarks>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public class ModalSelectMenuOptionAttribute : Attribute
{
/// <summary>
/// Gets the label of the option.
/// </summary>
public string Label { get; }
/// <summary>
/// Gets or sets the description of the option.
/// </summary>
public string Description { get; set; }
/// <summary>
/// Gets the value of the option.
/// </summary>
public string Value { get; }
/// <summary>
/// Gets or sets the emote of the option.
/// </summary>
/// <remarks>
/// Can be either an <see cref="Emoji"/> or an <see cref="Discord.Emote"/>
/// </remarks>
public string Emote { get; set; }
/// <summary>
/// Gets or sets whether the option is selected by default.
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// Create a new <see cref="ModalSelectComponentAttribute"/>.
/// </summary>
/// <param name="label">Label of the option.</param>
/// <param name="value">Value of the option.</param>
/// <param name="description">Description of the option.</param>
/// <param name="emote">Emote of the option. Can be either an <see cref="Emoji"/> or an <see cref="Discord.Emote"/></param>
/// <param name="isDefault">Whether the option is selected by default</param>
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;
}
}

View File

@@ -0,0 +1,24 @@
namespace Discord.Interactions;
/// <summary>
/// Marks a <see cref="IModal"/> property as a text input.
/// </summary>
public class ModalTextDisplayAttribute : ModalComponentAttribute
{
/// <inheritdoc/>
public override ComponentType ComponentType => ComponentType.TextDisplay;
/// <summary>
/// Gets the content of the text display.
/// </summary>
public string Content { get; }
/// <summary>
/// Create a new <see cref="ModalTextInputAttribute"/>.
/// </summary>
/// <param name="content">Content of the text display.</param>
public ModalTextDisplayAttribute(string content = null)
{
Content = content;
}
}

View File

@@ -0,0 +1,18 @@
namespace Discord.Interactions;
/// <summary>
/// Marks a <see cref="IModal"/> property as a user select input.
/// </summary>
public class ModalUserSelectAttribute : ModalSelectComponentAttribute
{
/// <inheritdoc/>
public override ComponentType ComponentType => ComponentType.UserSelect;
/// <summary>
/// Create a new <see cref="ModalUserSelectAttribute"/>.
/// </summary>
/// <param name="customId">Custom ID of the user select component.</param>
/// <param name="minValues">Minimum number of values that can be selected.</param>
/// <param name="maxValues">Maximum number of values that can be selected.</param>
public ModalUserSelectAttribute(string customId, int minValues = 1, int maxValues = 1) : base(customId, minValues, maxValues) { }
}

View File

@@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Linq;
namespace Discord.Interactions.Builders;
/// <summary>
/// Represents a builder for creating <see cref="ChannelSelectComponentInfo"/>.
/// </summary>
public class ChannelSelectComponentBuilder : SnowflakeSelectComponentBuilder<ChannelSelectComponentInfo, ChannelSelectComponentBuilder>
{
protected override ChannelSelectComponentBuilder Instance => this;
/// <summary>
/// Initializes a new <see cref="ChannelSelectComponentBuilder"/>.
/// </summary>
/// <param name="modal">Parent modal of this component.</param>
public ChannelSelectComponentBuilder(ModalBuilder modal) : base(modal, ComponentType.ChannelSelect) { }
/// <summary>
/// Adds a default value to <see cref="SnowflakeSelectComponentBuilder{TInfo, TBuilder}.DefaultValues"/>.
/// </summary>
/// <param name="channelId">The channel ID to add as a default value.</param>
/// <returns>
/// The builder instance.
/// </returns>
public ChannelSelectComponentBuilder AddDefaulValue(ulong channelId)
{
_defaultValues.Add(new SelectMenuDefaultValue(channelId, SelectDefaultValueType.Channel));
return this;
}
/// <summary>
/// Adds default values to <see cref="SnowflakeSelectComponentBuilder{TInfo, TBuilder}.DefaultValues"/>.
/// </summary>
/// <param name="channels">The channels to add as a default value.</param>
/// <returns>
/// The builder instance.
/// </returns>
public ChannelSelectComponentBuilder AddDefaultValues(params IEnumerable<IChannel> channels)
{
_defaultValues.AddRange(channels.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.Channel)));
return this;
}
internal override ChannelSelectComponentInfo Build(ModalInfo modal)
=> new(this, modal);
}

View File

@@ -0,0 +1,54 @@
namespace Discord.Interactions.Builders;
/// <summary>
/// Represents a builder for creating <see cref="FileUploadComponentInfo"/>.
/// </summary>
public class FileUploadComponentBuilder : InputComponentBuilder<FileUploadComponentInfo, FileUploadComponentBuilder>
{
protected override FileUploadComponentBuilder Instance => this;
/// <summary>
/// Gets and sets the minimum number of files that can be uploaded.
/// </summary>
public int MinValues { get; set; } = 1;
/// <summary>
/// Gets and sets the maximum number of files that can be uploaded.
/// </summary>
public int MaxValues { get; set; } = 1;
/// <summary>
/// Initializes a new <see cref="FileUploadComponentBuilder"/>.
/// </summary>
/// <param name="modal"></param>
public FileUploadComponentBuilder(ModalBuilder modal) : base(modal) { }
/// <summary>
/// Sets <see cref="MinValues"/>.
/// </summary>
/// <param name="minValues">New value of the <see cref="MinValues"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
public FileUploadComponentBuilder WithMinValues(int minValues)
{
MinValues = minValues;
return this;
}
/// <summary>
/// Sets <see cref="MinValues"/>.
/// </summary>
/// <param name="maxValues">New value of the <see cref="MaxValues"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
public FileUploadComponentBuilder WithMaxValues(int maxValues)
{
MaxValues = maxValues;
return this;
}
internal override FileUploadComponentInfo Build(ModalInfo modal)
=> new (this, modal);
}

View File

@@ -0,0 +1,68 @@
namespace Discord.Interactions.Builders
{
/// <summary>
/// Represent a builder for creating <see cref="InputComponentInfo"/>.
/// </summary>
public interface IInputComponentBuilder : IModalComponentBuilder
{
/// <summary>
/// Gets the custom id of this input component.
/// </summary>
string CustomId { get; }
/// <summary>
/// Gets the label of this input component.
/// </summary>
string Label { get; }
/// <summary>
/// Gets the label description of this input component.
/// </summary>
string Description { get; }
/// <summary>
/// Gets whether this input component is required.
/// </summary>
bool IsRequired { get; }
/// <summary>
/// Get the <see cref="ModalComponentTypeConverter"/> assigned to this input.
/// </summary>
ModalComponentTypeConverter TypeConverter { get; }
/// <summary>
/// Sets <see cref="CustomId"/>.
/// </summary>
/// <param name="customId">New value of the <see cref="CustomId"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
IInputComponentBuilder WithCustomId(string customId);
/// <summary>
/// Sets <see cref="Label"/>.
/// </summary>
/// <param name="label">New value of the <see cref="Label"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
IInputComponentBuilder WithLabel(string label);
/// <summary>
/// Sets <see cref="Description"/>.
/// </summary>
/// <param name="description">New value of the <see cref="Description"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
IInputComponentBuilder WithDescription(string description);
/// <summary>
/// Sets <see cref="IsRequired"/>.
/// </summary>
/// <param name="isRequired">New value of the <see cref="IsRequired"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
IInputComponentBuilder SetIsRequired(bool isRequired);
}
}

View File

@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Reflection;
namespace Discord.Interactions.Builders;
public interface IModalComponentBuilder
{
/// <summary>
/// Gets the parent modal of this input component.
/// </summary>
ModalBuilder Modal { get; }
/// <summary>
/// Gets the component type of this input component.
/// </summary>
ComponentType ComponentType { get; }
/// <summary>
/// Get the reference type of this input component.
/// </summary>
Type Type { get; }
/// <summary>
/// Get the <see cref="PropertyInfo"/> of this component's property.
/// </summary>
PropertyInfo PropertyInfo { get; }
/// <summary>
/// Gets the default value of this input component property.
/// </summary>
object DefaultValue { get; }
/// <summary>
/// Gets a collection of the attributes of this component.
/// </summary>
IReadOnlyCollection<Attribute> Attributes { get; }
/// <summary>
/// Sets <see cref="Type"/>.
/// </summary>
/// <param name="type">New value of the <see cref="Type"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
IModalComponentBuilder WithType(Type type);
/// <summary>
/// Sets <see cref="DefaultValue"/>.
/// </summary>
/// <param name="value">New value of the <see cref="DefaultValue"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
IModalComponentBuilder SetDefaultValue(object value);
/// <summary>
/// Adds attributes to <see cref="Attributes"/>.
/// </summary>
/// <param name="attributes">New attributes to be added to <see cref="Attributes"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
IModalComponentBuilder WithAttributes(params Attribute[] attributes);
}

View File

@@ -0,0 +1,62 @@
using System.Collections.Generic;
namespace Discord.Interactions.Builders;
/// <summary>
/// Represent a builder for creating <see cref="SnowflakeSelectComponentInfo"/>.
/// </summary>
public interface ISnowflakeSelectComponentBuilder : IInputComponentBuilder
{
/// <summary>
/// Gets the minimum number of values that can be selected.
/// </summary>
int MinValues { get; }
/// <summary>
/// Gets the maximum number of values that can be selected.
/// </summary>
int MaxValues { get; }
/// <summary>
/// Gets the placeholder text for this select component.
/// </summary>
string Placeholder { get; set; }
/// <summary>
/// Gets the default value collection for this select component.
/// </summary>
IReadOnlyCollection<SelectMenuDefaultValue> DefaultValues { get; }
/// <summary>
/// Gets the default value type of this select component.
/// </summary>
SelectDefaultValueType? DefaultValuesType { get; }
/// <summary>
/// Adds a default value to the <see cref="DefaultValues"/>.
/// </summary>
/// <param name="defaultValue">Default value to be added.</param>
/// <returns>The builder instance.</returns>
ISnowflakeSelectComponentBuilder AddDefaultValue(SelectMenuDefaultValue defaultValue);
/// <summary>
/// Sets <see cref="MinValues"/>.
/// </summary>
/// <param name="minValues">New value of the <see cref="MinValues"/></param>
/// <returns>The builder instance.</returns>
ISnowflakeSelectComponentBuilder WithMinValues(int minValues);
/// <summary>
/// Sets <see cref="MaxValues"/>.
/// </summary>
/// <param name="maxValues">New value of the <see cref="MaxValues"/></param>
/// <returns>The builder instance.</returns>
ISnowflakeSelectComponentBuilder WithMaxValues(int maxValues);
/// <summary>
/// Sets <see cref="Placeholder"/>.
/// </summary>
/// <param name="placeholder">New value of the <see cref="Placeholder"/></param>
/// <returns>The builder instance.</returns>
ISnowflakeSelectComponentBuilder WithPlaceholder(string placeholder);
}

View File

@@ -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
/// </summary>
/// <typeparam name="TInfo">The <see cref="InputComponentInfo"/> this builder yields when built.</typeparam>
/// <typeparam name="TBuilder">Inherited <see cref="InputComponentBuilder{TInfo, TBuilder}"/> type.</typeparam>
public abstract class InputComponentBuilder<TInfo, TBuilder> : IInputComponentBuilder
public abstract class InputComponentBuilder<TInfo, TBuilder> : ModalComponentBuilder<TInfo, TBuilder>, IInputComponentBuilder
where TInfo : InputComponentInfo
where TBuilder : InputComponentBuilder<TInfo, TBuilder>
{
private readonly List<Attribute> _attributes;
protected abstract TBuilder Instance { get; }
/// <inheritdoc/>
public ModalBuilder Modal { get; }
/// <inheritdoc/>
public string CustomId { get; set; }
@@ -25,34 +20,21 @@ namespace Discord.Interactions.Builders
/// <inheritdoc/>
public string Label { get; set; }
/// <inheritdoc/>
public string Description { get; set; }
/// <inheritdoc/>
public bool IsRequired { get; set; } = true;
/// <inheritdoc/>
public ComponentType ComponentType { get; internal set; }
/// <inheritdoc/>
public Type Type { get; private set; }
/// <inheritdoc/>
public PropertyInfo PropertyInfo { get; internal set; }
/// <inheritdoc/>
public ComponentTypeConverter TypeConverter { get; private set; }
/// <inheritdoc/>
public object DefaultValue { get; set; }
/// <inheritdoc/>
public IReadOnlyCollection<Attribute> Attributes => _attributes;
public ModalComponentTypeConverter TypeConverter { get; private set; }
/// <summary>
/// Creates an instance of <see cref="InputComponentBuilder{TInfo, TBuilder}"/>
/// </summary>
/// <param name="modal">Parent modal of this input component.</param>
public InputComponentBuilder(ModalBuilder modal)
internal InputComponentBuilder(ModalBuilder modal) : base(modal)
{
Modal = modal;
_attributes = new();
}
@@ -82,6 +64,19 @@ namespace Discord.Interactions.Builders
return Instance;
}
/// <summary>
/// Sets <see cref="Description"/>.
/// </summary>
/// <param name="description">New value of the <see cref="Description"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
public TBuilder WithDescription(string description)
{
Description = description;
return Instance;
}
/// <summary>
/// Sets <see cref="IsRequired"/>.
/// </summary>
@@ -95,19 +90,6 @@ namespace Discord.Interactions.Builders
return Instance;
}
/// <summary>
/// Sets <see cref="ComponentType"/>.
/// </summary>
/// <param name="componentType">New value of the <see cref="ComponentType"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
public TBuilder WithComponentType(ComponentType componentType)
{
ComponentType = componentType;
return Instance;
}
/// <summary>
/// Sets <see cref="Type"/>.
/// </summary>
@@ -115,56 +97,20 @@ namespace Discord.Interactions.Builders
/// <returns>
/// The builder instance.
/// </returns>
public TBuilder WithType(Type type)
public override TBuilder WithType(Type type)
{
Type = type;
TypeConverter = Modal._interactionService.GetComponentTypeConverter(type);
return Instance;
TypeConverter = Modal._interactionService.GetModalInputTypeConverter(type);
return base.WithType(type);
}
/// <summary>
/// Sets <see cref="DefaultValue"/>.
/// </summary>
/// <param name="value">New value of the <see cref="DefaultValue"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
public TBuilder SetDefaultValue(object value)
{
DefaultValue = value;
return Instance;
}
/// <summary>
/// Adds attributes to <see cref="Attributes"/>.
/// </summary>
/// <param name="attributes">New attributes to be added to <see cref="Attributes"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
public TBuilder WithAttributes(params Attribute[] attributes)
{
_attributes.AddRange(attributes);
return Instance;
}
internal abstract TInfo Build(ModalInfo modal);
//IInputComponentBuilder
/// <inheritdoc/>
IInputComponentBuilder IInputComponentBuilder.WithCustomId(string customId) => WithCustomId(customId);
/// <inheritdoc/>
IInputComponentBuilder IInputComponentBuilder.WithLabel(string label) => WithCustomId(label);
IInputComponentBuilder IInputComponentBuilder.WithLabel(string label) => WithLabel(label);
/// <inheritdoc/>
IInputComponentBuilder IInputComponentBuilder.WithType(Type type) => WithType(type);
/// <inheritdoc/>
IInputComponentBuilder IInputComponentBuilder.SetDefaultValue(object value) => SetDefaultValue(value);
/// <inheritdoc/>
IInputComponentBuilder IInputComponentBuilder.WithAttributes(params Attribute[] attributes) => WithAttributes(attributes);
IInputComponentBuilder IInputComponentBuilder.WithDescription(string description) => WithDescription(description);
/// <inheritdoc/>
IInputComponentBuilder IInputComponentBuilder.SetIsRequired(bool isRequired) => SetIsRequired(isRequired);

View File

@@ -0,0 +1,74 @@
using System.Collections.Generic;
using System.Linq;
namespace Discord.Interactions.Builders;
/// <summary>
/// Represents a builder for creating a <see cref="MentionableSelectComponentInfo"/>.
/// </summary>
public class MentionableSelectComponentBuilder : SnowflakeSelectComponentBuilder<MentionableSelectComponentInfo, MentionableSelectComponentBuilder>
{
protected override MentionableSelectComponentBuilder Instance => this;
/// <summary>
/// Initialize a new <see cref="MentionableSelectComponentBuilder"/>.
/// </summary>
/// <param name="modal">Parent modal of this input component.</param>
public MentionableSelectComponentBuilder(ModalBuilder modal) : base(modal, ComponentType.MentionableSelect) { }
/// <summary>
/// Adds a snowflake ID as a default value to <see cref="SnowflakeSelectComponentBuilder{TInfo, TBuilder}.DefaultValues"/>.
/// </summary>
/// <param name="id">The ID to add as a default value.</param>
/// <param name="type">Enitity type of the snowflake ID.</param>
/// <returns>
/// The builder instance.
/// </returns>
public MentionableSelectComponentBuilder AddDefaultValue(ulong id, SelectDefaultValueType type)
{
_defaultValues.Add(new SelectMenuDefaultValue(id, type));
return this;
}
/// <summary>
/// Add users as a default value to <see cref="SnowflakeSelectComponentBuilder{TInfo, TBuilder}.DefaultValues"/>.
/// </summary>
/// <param name="users">The users to add as a default value.</param>
/// <returns>
/// The builder instance.
/// </returns>
public MentionableSelectComponentBuilder AddDefaultValue(params IEnumerable<IUser> users)
{
_defaultValues.AddRange(users.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.User)));
return this;
}
/// <summary>
/// Adds channels as a default value to <see cref="SnowflakeSelectComponentBuilder{TInfo, TBuilder}.DefaultValues"/>.
/// </summary>
/// <param name="channels">The channel to add as a default value.</param>
/// <returns>
/// The builder instance.
/// </returns>
public MentionableSelectComponentBuilder AddDefaultValue(params IEnumerable<IChannel> channels)
{
_defaultValues.AddRange(channels.Select(x =>new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.Channel)));
return this;
}
/// <summary>
/// Adds roles as a default value to <see cref="SnowflakeSelectComponentBuilder{TInfo, TBuilder}.DefaultValues"/>.
/// </summary>
/// <param name="roles">The role to add as a default value.</param>
/// <returns>
/// The builder instance.
/// </returns>
public MentionableSelectComponentBuilder AddDefaulValue(params IEnumerable<IRole> roles)
{
_defaultValues.AddRange(roles.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.Role)));
return this;
}
internal override MentionableSelectComponentInfo Build(ModalInfo modal)
=> new(this, modal);
}

View File

@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Reflection;
namespace Discord.Interactions.Builders;
public abstract class ModalComponentBuilder<TInfo, TBuilder> : IModalComponentBuilder
where TInfo : ModalComponentInfo
where TBuilder : ModalComponentBuilder<TInfo, TBuilder>
{
private readonly List<Attribute> _attributes;
protected abstract TBuilder Instance { get; }
/// <inheritdoc/>
public ModalBuilder Modal { get; }
/// <inheritdoc/>
public ComponentType ComponentType { get; internal set; }
/// <inheritdoc/>
public Type Type { get; private set; }
/// <inheritdoc/>
public PropertyInfo PropertyInfo { get; internal set; }
/// <inheritdoc/>
public object DefaultValue { get; set; }
/// <inheritdoc/>
public IReadOnlyCollection<Attribute> Attributes => _attributes;
internal ModalComponentBuilder(ModalBuilder modal)
{
Modal = modal;
_attributes = new();
}
/// <summary>
/// Sets <see cref="ComponentType"/>.
/// </summary>
/// <param name="componentType">New value of the <see cref="ComponentType"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
public virtual TBuilder WithComponentType(ComponentType componentType)
{
ComponentType = componentType;
return Instance;
}
/// <summary>
/// Sets <see cref="Type"/>.
/// </summary>
/// <param name="type">New value of the <see cref="Type"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
public virtual TBuilder WithType(Type type)
{
Type = type;
return Instance;
}
/// <summary>
/// Sets <see cref="DefaultValue"/>.
/// </summary>
/// <param name="value">New value of the <see cref="DefaultValue"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
public virtual TBuilder SetDefaultValue(object value)
{
DefaultValue = value;
return Instance;
}
/// <summary>
/// Adds attributes to <see cref="Attributes"/>.
/// </summary>
/// <param name="attributes">New attributes to be added to <see cref="Attributes"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
public virtual TBuilder WithAttributes(params Attribute[] attributes)
{
_attributes.AddRange(attributes);
return Instance;
}
internal abstract TInfo Build(ModalInfo modal);
/// <inheritdoc/>
IModalComponentBuilder IModalComponentBuilder.WithType(Type type) => WithType(type);
/// <inheritdoc/>
IModalComponentBuilder IModalComponentBuilder.SetDefaultValue(object value) => SetDefaultValue(value);
/// <inheritdoc/>
IModalComponentBuilder IModalComponentBuilder.WithAttributes(params Attribute[] attributes) => WithAttributes(attributes);
}

View File

@@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Linq;
namespace Discord.Interactions.Builders;
/// <summary>
/// Represents a builder for creating a <see cref="RoleSelectComponentInfo"/>.
/// </summary>
public class RoleSelectComponentBuilder : SnowflakeSelectComponentBuilder<RoleSelectComponentInfo, RoleSelectComponentBuilder>
{
protected override RoleSelectComponentBuilder Instance => this;
/// <summary>
/// Initialize a new <see cref="RoleSelectComponentBuilder"/>.
/// </summary>
/// <param name="modal">Parent modal of this input component.</param>
public RoleSelectComponentBuilder(ModalBuilder modal) : base(modal, ComponentType.RoleSelect) { }
/// <summary>
/// Adds a default value to <see cref="SnowflakeSelectComponentBuilder{TInfo, TBuilder}.DefaultValues"/>.
/// </summary>
/// <param name="roleId">The role ID to add as a default value.</param>
/// <returns>
/// The builder instance.
/// </returns>
public RoleSelectComponentBuilder AddDefaulValue(ulong roleId)
{
_defaultValues.Add(new SelectMenuDefaultValue(roleId, SelectDefaultValueType.Role));
return this;
}
/// <summary>
/// Adds default values to <see cref="SnowflakeSelectComponentBuilder{TInfo, TBuilder}.DefaultValues"/>.
/// </summary>
/// <param name="roles">The roles to add as a default value.</param>
/// <returns>
/// The builder instance.
/// </returns>
public RoleSelectComponentBuilder AddDefaultValues(params IEnumerable<IRole> roles)
{
_defaultValues.AddRange(roles.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.Role)));
return this;
}
internal override RoleSelectComponentInfo Build(ModalInfo modal)
=> new(this, modal);
}

View File

@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
namespace Discord.Interactions.Builders;
/// <summary>
/// Represents a builder for creating <see cref="SelectMenuComponentInfo"/>.
/// </summary>
public class SelectMenuComponentBuilder : InputComponentBuilder<SelectMenuComponentInfo, SelectMenuComponentBuilder>
{
private readonly List<SelectMenuOptionBuilder> _options;
protected override SelectMenuComponentBuilder Instance => this;
/// <summary>
/// Gets and sets the placeholder for the select menu iput.
/// </summary>
public string Placeholder { get; set; }
/// <summary>
/// Gets and sets the minimum number of values that can be selected.
/// </summary>
public int MinValues { get; set; }
/// <summary>
/// Gets or sets the maximum number of values that can be selected.
/// </summary>
public int MaxValues { get; set; }
/// <summary>
/// Gets the options of this select menu component.
/// </summary>
public IReadOnlyCollection<SelectMenuOptionBuilder> Options => _options;
/// <summary>
/// Initialize a new <see cref="SelectMenuComponentBuilder"/>.
/// </summary>
/// <param name="modal">Parent modal of this component.</param>
public SelectMenuComponentBuilder(ModalBuilder modal) : base(modal)
{
_options = new();
}
/// <summary>
/// Adds an option to <see cref="Options"/>.
/// </summary>
/// <param name="option">Option to be added to <see cref="Options"/>.</param>
/// <returns>The builder instance.</returns>
public SelectMenuComponentBuilder AddOption(SelectMenuOptionBuilder option)
{
_options.Add(option);
return this;
}
/// <summary>
/// Adds an option to <see cref="Options"/>.
/// </summary>
/// <param name="configure">Select menu option builder factory.</param>
/// <returns>The builder instance.</returns>
public SelectMenuComponentBuilder AddOption(Action<SelectMenuOptionBuilder> configure)
{
var builder = new SelectMenuOptionBuilder();
configure(builder);
_options.Add(builder);
return this;
}
internal override SelectMenuComponentInfo Build(ModalInfo modal)
=> new(this, modal);
}

View File

@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
namespace Discord.Interactions.Builders;
/// <summary>
/// Represents a builder for creating <see cref="SnowflakeSelectComponentInfo"/>.
/// </summary>
/// <typeparam name="TInfo">The <see cref="SnowflakeSelectComponentInfo"/> this builder yields when built.</typeparam>
/// <typeparam name="TBuilder">Inherited <see cref="SnowflakeSelectComponentBuilder{TInfo, TBuilder}"/> type.</typeparam>
public abstract class SnowflakeSelectComponentBuilder<TInfo, TBuilder> : InputComponentBuilder<TInfo, TBuilder>, ISnowflakeSelectComponentBuilder
where TInfo : InputComponentInfo
where TBuilder : InputComponentBuilder<TInfo, TBuilder>, ISnowflakeSelectComponentBuilder
{
protected readonly List<SelectMenuDefaultValue> _defaultValues;
/// <inheritdoc/>
public int MinValues { get; set; } = 1;
/// <inheritdoc/>
public int MaxValues { get; set; } = 1;
/// <inheritdoc/>
public string Placeholder { get; set; }
/// <inheritdoc/>
public IReadOnlyCollection<SelectMenuDefaultValue> DefaultValues => _defaultValues.AsReadOnly();
/// <inheritdoc/>
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."),
};
}
}
/// <summary>
/// Initialize a new <see cref="SnowflakeSelectComponentBuilder{TInfo, TBuilder}"/>.
/// </summary>
/// <param name="modal">Parent modal of this input component.</param>
/// <param name="componentType">Type of this component.</param>
public SnowflakeSelectComponentBuilder(ModalBuilder modal, ComponentType componentType) : base(modal)
{
ValidateComponentType(componentType);
ComponentType = componentType;
_defaultValues = new();
}
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
public override TBuilder WithComponentType(ComponentType componentType)
{
ValidateComponentType(componentType);
return base.WithComponentType(componentType);
}
/// <inheritdoc/>
public TBuilder WithMinValues(int minValues)
{
MinValues = minValues;
return Instance;
}
/// <inheritdoc/>
public TBuilder WithMaxValues(int maxValues)
{
MaxValues = maxValues;
return Instance;
}
/// <inheritdoc/>
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));
}
/// <inheritdoc/>
ISnowflakeSelectComponentBuilder ISnowflakeSelectComponentBuilder.AddDefaultValue(SelectMenuDefaultValue defaultValue) => AddDefaultValue(defaultValue);
/// <inheritdoc/>
ISnowflakeSelectComponentBuilder ISnowflakeSelectComponentBuilder.WithMinValues(int minValues) => WithMinValues(minValues);
/// <inheritdoc/>
ISnowflakeSelectComponentBuilder ISnowflakeSelectComponentBuilder.WithMaxValues(int maxValues) => WithMaxValues(maxValues);
/// <inheritdoc/>
ISnowflakeSelectComponentBuilder ISnowflakeSelectComponentBuilder.WithPlaceholder(string placeholder) => WithPlaceholder(placeholder);
}

View File

@@ -0,0 +1,50 @@
using System;
namespace Discord.Interactions.Builders;
/// <summary>
/// Represents a builder for creating <see cref="TextDisplayComponentInfo"/>.
/// </summary>
public class TextDisplayComponentBuilder : ModalComponentBuilder<TextDisplayComponentInfo, TextDisplayComponentBuilder>
{
protected override TextDisplayComponentBuilder Instance => this;
/// <summary>
/// Gets and sets the content of the text display.
/// </summary>
public string Content { get; set; }
/// <summary>
/// Initialize a new <see cref="TextDisplayComponentBuilder"/>.
/// </summary>
/// <param name="modal">Parent modal of this input component.</param>
public TextDisplayComponentBuilder(ModalBuilder modal) : base(modal)
{
}
/// <summary>
/// Sets <see cref="Content"/>.
/// </summary>
/// <param name="content">New value of the <see cref="Content"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
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);
}

View File

@@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Linq;
namespace Discord.Interactions.Builders;
/// <summary>
/// Represents a builder for creating <see cref="UserSelectComponentInfo"/>.
/// </summary>
public class UserSelectComponentBuilder : SnowflakeSelectComponentBuilder<UserSelectComponentInfo, UserSelectComponentBuilder>
{
protected override UserSelectComponentBuilder Instance => this;
/// <summary>
/// Initialize a new <see cref="UserSelectComponentBuilder"/>.
/// </summary>
/// <param name="modal">Parent modal of this input component.</param>
public UserSelectComponentBuilder(ModalBuilder modal) : base(modal, ComponentType.UserSelect) { }
/// <summary>
/// Adds a default value to <see cref="SnowflakeSelectComponentBuilder{TInfo, TBuilder}.DefaultValues"/>.
/// </summary>
/// <param name="userId">The user ID to add as a default value.</param>
/// <returns>
/// The builder instance.
/// </returns>
public UserSelectComponentBuilder AddDefaulValue(ulong userId)
{
_defaultValues.Add(new SelectMenuDefaultValue(userId, SelectDefaultValueType.User));
return this;
}
/// <summary>
/// Adds default values to <see cref="SnowflakeSelectComponentBuilder{TInfo, TBuilder}.DefaultValues"/>.
/// </summary>
/// <param name="users">The users to add as a default value.</param>
/// <returns>
/// The builder instance.
/// </returns>
public UserSelectComponentBuilder AddDefaultValues(params IEnumerable<IUser> users)
{
_defaultValues.AddRange(users.Select(x => new SelectMenuDefaultValue(x.Id, SelectDefaultValueType.User)));
return this;
}
internal override UserSelectComponentInfo Build(ModalInfo modal)
=> new(this, modal);
}

View File

@@ -1,116 +0,0 @@
using System;
using System.Collections.Generic;
using System.Reflection;
namespace Discord.Interactions.Builders
{
/// <summary>
/// Represent a builder for creating <see cref="InputComponentInfo"/>.
/// </summary>
public interface IInputComponentBuilder
{
/// <summary>
/// Gets the parent modal of this input component.
/// </summary>
ModalBuilder Modal { get; }
/// <summary>
/// Gets the custom id of this input component.
/// </summary>
string CustomId { get; }
/// <summary>
/// Gets the label of this input component.
/// </summary>
string Label { get; }
/// <summary>
/// Gets whether this input component is required.
/// </summary>
bool IsRequired { get; }
/// <summary>
/// Gets the component type of this input component.
/// </summary>
ComponentType ComponentType { get; }
/// <summary>
/// Get the reference type of this input component.
/// </summary>
Type Type { get; }
/// <summary>
/// Get the <see cref="PropertyInfo"/> of this component's property.
/// </summary>
PropertyInfo PropertyInfo { get; }
/// <summary>
/// Get the <see cref="ComponentTypeConverter"/> assigned to this input.
/// </summary>
ComponentTypeConverter TypeConverter { get; }
/// <summary>
/// Gets the default value of this input component.
/// </summary>
object DefaultValue { get; }
/// <summary>
/// Gets a collection of the attributes of this component.
/// </summary>
IReadOnlyCollection<Attribute> Attributes { get; }
/// <summary>
/// Sets <see cref="CustomId"/>.
/// </summary>
/// <param name="customId">New value of the <see cref="CustomId"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
IInputComponentBuilder WithCustomId(string customId);
/// <summary>
/// Sets <see cref="Label"/>.
/// </summary>
/// <param name="label">New value of the <see cref="Label"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
IInputComponentBuilder WithLabel(string label);
/// <summary>
/// Sets <see cref="IsRequired"/>.
/// </summary>
/// <param name="isRequired">New value of the <see cref="IsRequired"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
IInputComponentBuilder SetIsRequired(bool isRequired);
/// <summary>
/// Sets <see cref="Type"/>.
/// </summary>
/// <param name="type">New value of the <see cref="Type"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
IInputComponentBuilder WithType(Type type);
/// <summary>
/// Sets <see cref="DefaultValue"/>.
/// </summary>
/// <param name="value">New value of the <see cref="DefaultValue"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
IInputComponentBuilder SetDefaultValue(object value);
/// <summary>
/// Adds attributes to <see cref="Attributes"/>.
/// </summary>
/// <param name="attributes">New attributes to be added to <see cref="Attributes"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
IInputComponentBuilder WithAttributes(params Attribute[] attributes);
}
}

View File

@@ -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<IInputComponentBuilder> _components;
internal readonly List<IModalComponentBuilder> _components;
/// <summary>
/// Gets the initialization delegate for this modal.
@@ -30,7 +29,7 @@ namespace Discord.Interactions.Builders
/// <summary>
/// Gets a collection of the components of this modal.
/// </summary>
public IReadOnlyCollection<IInputComponentBuilder> Components => _components;
public IReadOnlyCollection<IModalComponentBuilder> Components => _components.AsReadOnly();
internal ModalBuilder(Type type, InteractionService interactionService)
{
@@ -72,7 +71,7 @@ namespace Discord.Interactions.Builders
/// <returns>
/// The builder instance.
/// </returns>
public ModalBuilder AddTextComponent(Action<TextInputComponentBuilder> configure)
public ModalBuilder AddTextInputComponent(Action<TextInputComponentBuilder> configure)
{
var builder = new TextInputComponentBuilder(this);
configure(builder);
@@ -80,6 +79,111 @@ namespace Discord.Interactions.Builders
return this;
}
/// <summary>
/// Adds a select menu component to <see cref="Components"/>.
/// </summary>
/// <param name="configure">Select menu component builder factory.</param>
/// <returns>
/// The builder instance.
/// </returns>
public ModalBuilder AddSelectMenuInputComponent(Action<SelectMenuComponentBuilder> configure)
{
var builder = new SelectMenuComponentBuilder(this);
configure(builder);
_components.Add(builder);
return this;
}
/// <summary>
/// Adds a user select component to <see cref="Components"/>.
/// </summary>
/// <param name="configure">User select component builder factory.</param>
/// <returns>
/// The builder instance.
/// </returns>
public ModalBuilder AddUserSelectInputComponent(Action<UserSelectComponentBuilder> configure)
{
var builder = new UserSelectComponentBuilder(this);
configure(builder);
_components.Add(builder);
return this;
}
/// <summary>
/// Adds a role select component to <see cref="Components"/>.
/// </summary>
/// <param name="configure">Role select component builder factory.</param>
/// <returns>
/// The builder instance.
/// </returns>
public ModalBuilder AddRoleSelectInputComponent(Action<RoleSelectComponentBuilder> configure)
{
var builder = new RoleSelectComponentBuilder(this);
configure(builder);
_components.Add(builder);
return this;
}
/// <summary>
/// Adds a mentionable select component to <see cref="Components"/>.
/// </summary>
/// <param name="configure">Mentionable select component builder factory.</param>
/// <returns>
/// The builder instance.
/// </returns>
public ModalBuilder AddMentionableSelectInputComponent(Action<MentionableSelectComponentBuilder> configure)
{
var builder = new MentionableSelectComponentBuilder(this);
configure(builder);
_components.Add(builder);
return this;
}
/// <summary>
/// Adds a channel select component to <see cref="Components"/>.
/// </summary>
/// <param name="configure">Channel select component builder factory.</param>
/// <returns>
/// The builder instance.
/// </returns>
public ModalBuilder AddChannelSelectInputComponent(Action<ChannelSelectComponentBuilder> configure)
{
var builder = new ChannelSelectComponentBuilder(this);
configure(builder);
_components.Add(builder);
return this;
}
/// <summary>
/// Adds a file upload component to <see cref="Components"/>.
/// </summary>
/// <param name="configure">File upload component builder factory.</param>
/// <returns>
/// The builder instance.
/// </returns>
public ModalBuilder AddFileUploadInputComponent(Action<FileUploadComponentBuilder> configure)
{
var builder = new FileUploadComponentBuilder(this);
configure(builder);
_components.Add(builder);
return this;
}
/// <summary>
/// Adds a text display component to <see cref="Components"/>.
/// </summary>
/// <param name="configure">Text display component builder factory.</param>
/// <returns>
/// The builder instance.
/// </returns>
public ModalBuilder AddTextDisplayComponent(Action<TextDisplayComponentBuilder> configure)
{
var builder = new TextDisplayComponentBuilder(this);
configure(builder);
_components.Add(builder);
return this;
}
internal ModalInfo Build() => new(this);
}
}

View File

@@ -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 @@ namespace Discord.Interactions.Builders
#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 @@ namespace Discord.Interactions.Builders
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<ModalInputAttribute>()?.ComponentType;
var componentType = prop.GetCustomAttribute<ModalComponentAttribute>()?.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 @@ namespace Discord.Interactions.Builders
}
}
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 @@ namespace Discord.Interactions.Builders
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<TInfo, TBuilder>(SnowflakeSelectComponentBuilder<TInfo, TBuilder> builder, PropertyInfo propertyInfo, object defaultValue)
where TInfo : SnowflakeSelectComponentInfo
where TBuilder : SnowflakeSelectComponentBuilder<TInfo, TBuilder>
{
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 @@ namespace Discord.Interactions.Builders
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)

View File

@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Discord.Interactions
@@ -20,7 +21,7 @@ namespace Discord.Interactions
if (!ModalUtils.TryGet<T>(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<T>(interaction, customId, modalInfo, null, options, modifyModal);
}
/// <summary>
@@ -43,7 +44,7 @@ namespace Discord.Interactions
{
var modalInfo = ModalUtils.GetOrAdd<T>(interactionService);
return SendModalResponseAsync(interaction, customId, modalInfo, options, modifyModal);
return SendModalResponseAsync<T>(interaction, customId, modalInfo, null, options, modifyModal);
}
/// <summary>
@@ -64,20 +65,78 @@ namespace Discord.Interactions
if (!ModalUtils.TryGet<T>(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<T>(interaction, customId, modalInfo, modal, options, modifyModal);
}
private static async Task SendModalResponseAsync<T>(IDiscordInteraction interaction, string customId, ModalInfo modalInfo, T modalInstance = null, RequestOptions options = null, Action<ModalBuilder> 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);
builder.AddTextInput(textComponent.Label, textComponent.CustomId, textComponent.Style, textComponent.Placeholder, textComponent.IsRequired ? textComponent.MinLength : null,
textComponent.MaxLength, textComponent.IsRequired, value);
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));
}
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 @@ namespace Discord.Interactions
modifyModal?.Invoke(builder);
return interaction.RespondWithModalAsync(builder.Build(), options);
}
private static Task SendModalResponseAsync(IDiscordInteraction interaction, string customId, ModalInfo modalInfo, RequestOptions options = null, Action<ModalBuilder> modifyModal = null)
{
var modal = modalInfo.ToModal(customId, modifyModal);
return interaction.RespondWithModalAsync(modal, options);
await interaction.RespondWithModalAsync(builder.Build(), options);
}
}
}

View File

@@ -0,0 +1,9 @@
namespace Discord.Interactions;
/// <summary>
/// Represents the <see cref="InputComponentInfo"/> class for <see cref="ComponentType.ChannelSelect"/> type.
/// </summary>
public class ChannelSelectComponentInfo : SnowflakeSelectComponentInfo
{
internal ChannelSelectComponentInfo(Builders.ChannelSelectComponentBuilder builder, ModalInfo modal) : base(builder, modal) { }
}

View File

@@ -0,0 +1,23 @@
namespace Discord.Interactions;
/// <summary>
/// Represents the <see cref="InputComponentInfo"/> class for <see cref="ComponentType.FileUpload"/> type.
/// </summary>
public class FileUploadComponentInfo : InputComponentInfo
{
/// <summary>
/// Gets the minimum number of values that can be selected.
/// </summary>
public int MinValues { get; }
/// <summary>
/// Gets the maximum number of values that can be selected.
/// </summary>
public int MaxValues { get; }
internal FileUploadComponentInfo(Builders.FileUploadComponentBuilder builder, ModalInfo modal) : base(builder, modal)
{
MinValues = builder.MinValues;
MaxValues = builder.MaxValues;
}
}

View File

@@ -0,0 +1,43 @@
namespace Discord.Interactions
{
/// <summary>
/// Represents the base info class for <see cref="IModal"/> input components.
/// </summary>
public abstract class InputComponentInfo : ModalComponentInfo
{
/// <summary>
/// Gets the custom id of this component.
/// </summary>
public string CustomId { get; }
/// <summary>
/// Gets the label of this component.
/// </summary>
public string Label { get; }
/// <summary>
/// Gets the description of this component.
/// </summary>
public string Description { get; }
/// <summary>
/// Gets whether or not this component requires a user input.
/// </summary>
public bool IsRequired { get; }
/// <summary>
/// Gets the <see cref="ModalComponentTypeConverter"/> assigned to this component.
/// </summary>
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;
}
}
}

View File

@@ -0,0 +1,9 @@
namespace Discord.Interactions;
/// <summary>
/// Represents the <see cref="InputComponentInfo"/> class for <see cref="ComponentType.MentionableSelect"/> type.
/// </summary>
public class MentionableSelectComponentInfo : SnowflakeSelectComponentInfo
{
internal MentionableSelectComponentInfo(Builders.MentionableSelectComponentBuilder builder, ModalInfo modal) : base(builder, modal) { }
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Reflection;
namespace Discord.Interactions;
/// <summary>
/// Represents the base info class for <see cref="IModal"/> components.
/// </summary>
public abstract class ModalComponentInfo
{
private Lazy<Func<object, object>> _getter;
internal Func<object, object> Getter => _getter.Value;
/// <summary>
/// Gets the parent modal of this component.
/// </summary>
public ModalInfo Modal { get; }
/// <summary>
/// Gets the type of this component.
/// </summary>
public ComponentType ComponentType { get; }
/// <summary>
/// Gets the reference type of this component.
/// </summary>
public Type Type { get; }
/// <summary>
/// Gets the property linked to this component.
/// </summary>
public PropertyInfo PropertyInfo { get; }
/// <summary>
/// Gets the default value of this component property.
/// </summary>
public object DefaultValue { get; }
/// <summary>
/// Gets a collection of the attributes of this command.
/// </summary>
public IReadOnlyCollection<Attribute> 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<object>.CreateLambdaPropertyGetter(Modal.Type, PropertyInfo));
}
}

View File

@@ -0,0 +1,9 @@
namespace Discord.Interactions;
/// <summary>
/// Represents the <see cref="InputComponentInfo"/> class for <see cref="ComponentType.RoleSelect"/> type.
/// </summary>
public class RoleSelectComponentInfo : SnowflakeSelectComponentInfo
{
internal RoleSelectComponentInfo(Builders.RoleSelectComponentBuilder builder, ModalInfo modal) : base(builder, modal) { }
}

View File

@@ -0,0 +1,39 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace Discord.Interactions;
/// <summary>
/// Represents the <see cref="InputComponentInfo"/> class for <see cref="ComponentType.SelectMenu"/> type.
/// </summary>
public class SelectMenuComponentInfo : InputComponentInfo
{
/// <summary>
/// Gets the placeholder of the select menu input.
/// </summary>
public string Placeholder { get; }
/// <summary>
/// Gets the minimum number of values that can be selected.
/// </summary>
public int MinValues { get; }
/// <summary>
/// Gets the maximum number of values that can be selected.
/// </summary>
public int MaxValues { get; }
/// <summary>
/// Gets the options of this select menu component.
/// </summary>
public IReadOnlyCollection<SelectMenuOption> 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();
}
}

View File

@@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Collections.Immutable;
namespace Discord.Interactions;
/// <summary>
/// Represents the base <see cref="InputComponentInfo"/> class for <see cref="ComponentType.UserSelect"/>, <see cref="ComponentType.ChannelSelect"/>, <see cref="ComponentType.RoleSelect"/>, <see cref="ComponentType.MentionableSelect"/> type.
/// </summary>
public abstract class SnowflakeSelectComponentInfo : InputComponentInfo
{
/// <summary>
/// Gets the minimum number of values that can be selected.
/// </summary>
public int MinValues { get; }
/// <summary>
/// Gets the maximum number of values that can be selected.
/// </summary>
public int MaxValues { get; }
/// <summary>
/// Gets the placeholder of this select input.
/// </summary>
public string Placeholder { get; }
/// <summary>
/// Gets the default values of this select input.
/// </summary>
public IReadOnlyCollection<SelectMenuDefaultValue> DefaultValues { get; }
/// <summary>
/// Gets the default value type of this select input.
/// </summary>
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;
}
}

View File

@@ -0,0 +1,19 @@
using Discord.Interactions.Builders;
namespace Discord.Interactions;
/// <summary>
/// Represents the <see cref="ModalComponentInfo"/> class for <see cref="ComponentType.TextDisplay"/> type.
/// </summary>
public class TextDisplayComponentInfo : ModalComponentInfo
{
/// <summary>
/// Gets the content of the text display.
/// </summary>
public string Content { get; }
internal TextDisplayComponentInfo(TextDisplayComponentBuilder builder, ModalInfo modal) : base(builder, modal)
{
Content = Content;
}
}

View File

@@ -8,7 +8,7 @@ namespace Discord.Interactions
public class TextInputComponentInfo : InputComponentInfo
{
/// <summary>
/// <c>true</c> when <see cref="InputComponentInfo.Type"/> overrides <see cref="object.ToString"/>.
/// <c>true</c> when <see cref="ModalComponentInfo.Type"/> overrides <see cref="object.ToString"/>.
/// </summary>
internal bool TypeOverridesToString => _typeOverridesToString.Value;
private readonly Lazy<bool> _typeOverridesToString;

View File

@@ -0,0 +1,9 @@
namespace Discord.Interactions;
/// <summary>
/// Represents the <see cref="InputComponentInfo"/> class for <see cref="ComponentType.UserSelect"/> type.
/// </summary>
public class UserSelectComponentInfo : SnowflakeSelectComponentInfo
{
internal UserSelectComponentInfo(Builders.UserSelectComponentBuilder builder, ModalInfo modal) : base(builder, modal) { }
}

View File

@@ -1,83 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Reflection;
namespace Discord.Interactions
{
/// <summary>
/// Represents the base info class for <see cref="IModal"/> input components.
/// </summary>
public abstract class InputComponentInfo
{
private Lazy<Func<object, object>> _getter;
internal Func<object, object> Getter => _getter.Value;
/// <summary>
/// Gets the parent modal of this component.
/// </summary>
public ModalInfo Modal { get; }
/// <summary>
/// Gets the custom id of this component.
/// </summary>
public string CustomId { get; }
/// <summary>
/// Gets the label of this component.
/// </summary>
public string Label { get; }
/// <summary>
/// Gets whether or not this component requires a user input.
/// </summary>
public bool IsRequired { get; }
/// <summary>
/// Gets the type of this component.
/// </summary>
public ComponentType ComponentType { get; }
/// <summary>
/// Gets the reference type of this component.
/// </summary>
public Type Type { get; }
/// <summary>
/// Gets the property linked to this component.
/// </summary>
public PropertyInfo PropertyInfo { get; }
/// <summary>
/// Gets the <see cref="ComponentTypeConverter"/> assigned to this component.
/// </summary>
public ComponentTypeConverter TypeConverter { get; }
/// <summary>
/// Gets the default value of this component.
/// </summary>
public object DefaultValue { get; }
/// <summary>
/// Gets a collection of the attributes of this command.
/// </summary>
public IReadOnlyCollection<Attribute> 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<object>.CreateLambdaPropertyGetter(Modal.Type, PropertyInfo));
}
}
}

View File

@@ -1,3 +1,4 @@
using Discord.Interactions.Builders;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
@@ -36,24 +37,80 @@ namespace Discord.Interactions
/// <summary>
/// Gets a collection of the components of this modal.
/// </summary>
public IReadOnlyCollection<InputComponentInfo> Components { get; }
public IReadOnlyCollection<ModalComponentInfo> Components { get; }
/// <summary>
/// Gets a collection of the input components of this modal.
/// </summary>
public IReadOnlyCollection<InputComponentInfo> InputComponents { get; }
/// <summary>
/// Gets a collection of the text components of this modal.
/// </summary>
public IReadOnlyCollection<TextInputComponentInfo> TextComponents { get; }
public IReadOnlyCollection<TextInputComponentInfo> TextInputComponents { get; }
/// <summary>
/// Get a collection of the select menu components of this modal.
/// </summary>
public IReadOnlyCollection<SelectMenuComponentInfo> SelectMenuComponents { get; }
/// <summary>
/// Get a collection of the user select components of this modal.
/// </summary>
public IReadOnlyCollection<UserSelectComponentInfo> UserSelectComponents { get; }
/// <summary>
/// Get a collection of the role select components of this modal.
/// </summary>
public IReadOnlyCollection<RoleSelectComponentInfo> RoleSelectComponents { get; }
/// <summary>
/// Get a collection of the mentionable select components of this modal.
/// </summary>
public IReadOnlyCollection<MentionableSelectComponentInfo> MentionableSelectComponents { get; }
/// <summary>
/// Get a collection of the channel select components of this modal.
/// </summary>
public IReadOnlyCollection<ChannelSelectComponentInfo> ChannelSelectComponents { get; }
/// <summary>
/// Get a collection of the file upload components of this modal.
/// </summary>
public IReadOnlyCollection<FileUploadComponentInfo> FileUploadComponents { get; }
/// <summary>
/// Gets a collection of the text display components of this modal.
/// </summary>
public IReadOnlyCollection<TextDisplayComponentInfo> TextDisplayComponents { get; }
internal ModalInfo(Builders.ModalBuilder builder)
{
Title = builder.Title;
Type = builder.Type;
Components = builder.Components.Select(x => x switch
Components = builder.Components.Select<IModalComponentBuilder, ModalComponentInfo>(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<TextInputComponentInfo>().ToImmutableArray();
InputComponents = Components.OfType<InputComponentInfo>().ToImmutableArray();
TextInputComponents = Components.OfType<TextInputComponentInfo>().ToImmutableArray();
SelectMenuComponents = Components.OfType<SelectMenuComponentInfo>().ToImmutableArray();
UserSelectComponents = Components.OfType<UserSelectComponentInfo>().ToImmutableArray();
RoleSelectComponents = Components.OfType<RoleSelectComponentInfo>().ToImmutableArray();
MentionableSelectComponents = Components.OfType<MentionableSelectComponentInfo>().ToImmutableArray();
ChannelSelectComponents = Components.OfType<ChannelSelectComponentInfo>().ToImmutableArray();
FileUploadComponents = Components.OfType<FileUploadComponentInfo>().ToImmutableArray();
TextDisplayComponents = Components.OfType<TextDisplayComponentInfo>().ToImmutableArray();
_interactionService = builder._interactionService;
_initializer = builder.ModalInitializer;
@@ -74,7 +131,7 @@ namespace Discord.Interactions
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 @@ namespace Discord.Interactions
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)

View File

@@ -3,7 +3,6 @@ using Discord.Logging;
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 @@ namespace Discord.Interactions
private readonly TypeMap<TypeConverter, IApplicationCommandInteractionDataOption> _typeConverterMap;
private readonly TypeMap<ComponentTypeConverter, IComponentInteractionData> _compTypeConverterMap;
private readonly TypeMap<TypeReader, string> _typeReaderMap;
private readonly TypeMap<ModalComponentTypeConverter, IComponentInteractionData> _modalInputTypeConverterMap;
private readonly ConcurrentDictionary<Type, IAutocompleteHandler> _autocompleteHandlers = new();
private readonly ConcurrentDictionary<Type, ModalInfo> _modalInfos = new();
private readonly SemaphoreSlim _lock;
@@ -228,6 +228,16 @@ namespace Discord.Interactions
[typeof(Enum)] = typeof(EnumReader<>),
[typeof(Nullable<>)] = typeof(NullableReader<>)
});
_modalInputTypeConverterMap = new TypeMap<ModalComponentTypeConverter, IComponentInteractionData>(this, new ConcurrentDictionary<Type, ModalComponentTypeConverter>
{
}, new ConcurrentDictionary<Type, Type>
{
[typeof(IConvertible)] = typeof(DefaultValueModalComponentConverter<>),
[typeof(Enum)] = typeof(EnumModalComponentConverter<>),
[typeof(Nullable<>)] = typeof(NullableComponentConverter<>),
[typeof(Array)] = typeof(DefaultArrayModalComponentConverter<>)
});
}
/// <summary>
@@ -1064,6 +1074,94 @@ namespace Discord.Interactions
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);
/// <summary>
/// Add a concrete type <see cref="ModalComponentTypeConverter"/>.
/// </summary>
/// <typeparam name="T">Primary target <see cref="Type"/> of the <see cref="ModalComponentTypeConverter"/>.</typeparam>
/// <param name="converter">The <see cref="ModalComponentTypeConverter"/> instance.</param>
public void AddModalComponentTypeConverter<T>(ModalComponentTypeConverter converter) =>
AddModalComponentTypeConverter(typeof(T), converter);
/// <summary>
/// Add a concrete type <see cref="ModalComponentTypeConverter"/>.
/// </summary>
/// <param name="type">Primary target <see cref="Type"/> of the <see cref="ModalComponentTypeConverter"/>.</param>
/// <param name="converter">The <see cref="ModalComponentTypeConverter"/> instance.</param>
public void AddModalComponentTypeConverter(Type type, ModalComponentTypeConverter converter) =>
_modalInputTypeConverterMap.AddConcrete(type, converter);
/// <summary>
/// Add a generic type <see cref="ModalComponentTypeConverter{T}"/>.
/// </summary>
/// <typeparam name="T">Generic Type constraint of the <see cref="Type"/> of the <see cref="ModalComponentTypeConverter{T}"/>.</typeparam>
/// <param name="converterType">Type of the <see cref="ModalComponentTypeConverter{T}"/>.</param>
public void AddGenericModalComponentTypeConverter<T>(Type converterType) =>
AddGenericModalComponentTypeConverter(typeof(T), converterType);
/// <summary>
/// Add a generic type <see cref="ModalComponentTypeConverter{T}"/>.
/// </summary>
/// <param name="targetType">Generic Type constraint of the <see cref="Type"/> of the <see cref="ModalComponentTypeConverter{T}"/>.</param>
/// <param name="converterType">Type of the <see cref="ModalComponentTypeConverter{T}"/>.</param>
public void AddGenericModalComponentTypeConverter(Type targetType, Type converterType) =>
_modalInputTypeConverterMap.AddGeneric(targetType, converterType);
/// <summary>
/// Removes a <see cref="ModalComponentTypeConverter"/> for the type <typeparamref name="T"/>.
/// </summary>
/// <remarks>
/// Removing a <see cref="ModalComponentTypeConverter"/> from the <see cref="InteractionService"/> will not dereference the <see cref="ModalComponentTypeConverter"/> from the loaded module/command instances.
/// You need to reload the modules for the changes to take effect.
/// </remarks>
/// <typeparam name="T">The type to remove the converter from.</typeparam>
/// <param name="converter">The converter if the resulting remove operation was successful.</param>
/// <returns><see langword="true"/> if the remove operation was successful; otherwise <see langword="false"/>.</returns>
public bool TryRemoveModalComponentTypeConverter<T>(out ModalComponentTypeConverter converter) =>
TryRemoveModalComponentTypeConverter(typeof(T), out converter);
/// <summary>
/// Removes a <see cref="ModalComponentTypeConverter"/> for the type <paramref name="type"/>.
/// </summary>
/// <remarks>
/// Removing a <see cref="ModalComponentTypeConverter"/> from the <see cref="InteractionService"/> will not dereference the <see cref="ModalComponentTypeConverter"/> from the loaded module/command instances.
/// You need to reload the modules for the changes to take effect.
/// </remarks>
/// <param name="type">The type to remove the converter from.</param>
/// <param name="converter">The converter if the resulting remove operation was successful.</param>
/// <returns><see langword="true"/> if the remove operation was successful; otherwise <see langword="false"/>.</returns>
public bool TryRemoveModalComponentTypeConverter(Type type, out ModalComponentTypeConverter converter) =>
_modalInputTypeConverterMap.TryRemoveConcrete(type, out converter);
/// <summary>
/// Removes a generic <see cref="ModalComponentTypeConverter"/> for the type <typeparamref name="T"/>.
/// </summary>
/// <remarks>
/// Removing a <see cref="ModalComponentTypeConverter"/> from the <see cref="InteractionService"/> will not dereference the <see cref="ModalComponentTypeConverter"/> from the loaded module/command instances.
/// You need to reload the modules for the changes to take effect.
/// </remarks>
/// <typeparam name="T">The type to remove the converter from.</typeparam>
/// <param name="converterType">The converter if the resulting remove operation was successful.</param>
/// <returns><see langword="true"/> if the remove operation was successful; otherwise <see langword="false"/>.</returns>
public bool TryRemoveGenericModalComponentTypeConverter<T>(out Type converterType) =>
TryRemoveGenericModalComponentTypeConverter(typeof(T), out converterType);
/// <summary>
/// Removes a generic <see cref="ModalComponentTypeConverter"/> for the type <paramref name="type"/>.
/// </summary>
/// <remarks>
/// Removing a <see cref="ModalComponentTypeConverter"/> from the <see cref="InteractionService"/> will not dereference the <see cref="ModalComponentTypeConverter"/> from the loaded module/command instances.
/// You need to reload the modules for the changes to take effect.
/// </remarks>
/// <param name="type">The type to remove the converter from.</param>
/// <param name="converterType">The converter if the resulting remove operation was successful.</param>
/// <returns><see langword="true"/> if the remove operation was successful; otherwise <see langword="false"/>.</returns>
public bool TryRemoveGenericModalComponentTypeConverter(Type type, out Type converterType) =>
_modalInputTypeConverterMap.TryRemoveGeneric(type, out converterType);
/// <summary>
/// Serialize an object using a <see cref="TypeReader"/> into a <see cref="string"/> to be placed in a Component CustomId.
/// </summary>

View File

@@ -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<T> : ModalComponentTypeConverter<T>
{
private readonly Type _underlyingType;
private readonly TypeReader _typeReader;
private readonly ImmutableArray<ChannelType> _channelTypes;
public DefaultArrayModalComponentConverter(InteractionService interactionService)
{
var type = typeof(T);
if (!type.IsArray)
throw new InvalidOperationException($"{nameof(DefaultArrayComponentConverter<T>)} 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<TypeConverterResult> ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services)
{
var objs = new List<object>();
if (_typeReader is not null && option.Values.Count > 0)
foreach (var value in option.Values)
{
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<ulong, ISnowflakeEntity>();
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>(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<IUser> defaultUsers:
selectMenu.DefaultValues = defaultUsers.Select(SelectMenuDefaultValue.FromUser).ToList();
break;
case IEnumerable<IRole> defaultRoles:
selectMenu.DefaultValues = defaultRoles.Select(SelectMenuDefaultValue.FromRole).ToList();
break;
case IEnumerable<IChannel> defaultChannels:
selectMenu.DefaultValues = defaultChannels.Select(SelectMenuDefaultValue.FromChannel).ToList();
break;
case IEnumerable<IMentionable> defaultMentionables:
selectMenu.DefaultValues = defaultMentionables.Where(x => x is IUser or IRole or IChannel)
.Select(x =>
{
return x switch
{
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;
}
}

View File

@@ -0,0 +1,49 @@
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Discord.Interactions;
internal sealed class DefaultValueModalComponentConverter<T> : ModalComponentTypeConverter<T>
where T : IConvertible
{
public override Task<TypeConverterResult> 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>(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;
}
}

View File

@@ -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<T> : ModalComponentTypeConverter<T>
where T : struct, Enum
{
private record Option(SelectMenuOptionBuilder OptionBuilder, Predicate<IDiscordInteraction> Predicate, T Value);
private readonly bool _isFlags;
private readonly ImmutableArray<Option> _options;
public EnumModalComponentConverter()
{
var names = Enum.GetNames(typeof(T));
var members = names.SelectMany(x => typeof(T).GetMember(x));
_isFlags = typeof(T).IsDefined(typeof(FlagsAttribute));
_options = members.Select<MemberInfo, Option>(x =>
{
var selectMenuOptionAttr = x.GetCustomAttribute<SelectMenuOptionAttribute>();
Emoji emoji = null;
Emote emote = null;
if (!string.IsNullOrEmpty(selectMenuOptionAttr?.Emote) && !(Emote.TryParse(selectMenuOptionAttr.Emote, out emote) || Emoji.TryParse(selectMenuOptionAttr.Emote, out emoji)))
throw new ArgumentException($"Unable to parse {selectMenuOptionAttr.Emote} of {x.DeclaringType.Name}.{x.Name} into an {typeof(Emote).Name} or an {typeof(Emoji).Name}");
var hideAttr = x.GetCustomAttribute<HideAttribute>();
Predicate<IDiscordInteraction> predicate = hideAttr != null ? hideAttr.Predicate : null;
var value = Enum.Parse<T>(x.Name);
var optionBuilder = new SelectMenuOptionBuilder(x.GetCustomAttribute<ChoiceDisplayAttribute>()?.Name ?? x.Name, x.Name, selectMenuOptionAttr?.Description, emote != null ? emote : emoji, selectMenuOptionAttr?.IsDefault);
return new(optionBuilder, predicate, value);
}).ToImmutableArray();
}
public override Task<TypeConverterResult> 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<T>(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>(TBuilder builder, IDiscordInteraction interaction, InputComponentInfo component, object value)
{
if (builder is not SelectMenuBuilder selectMenu || component.ComponentType is not ComponentType.SelectMenu)
throw new InvalidOperationException($"{nameof(EnumModalComponentConverter<T>)} 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.");
var visibleOptions = _options.Where(x => !x.Predicate?.Invoke(interaction) ?? true);
if (value is T enumValue)
{
foreach(var option in visibleOptions)
{
option.OptionBuilder.IsDefault = _isFlags ? enumValue.HasFlag(option.Value) : enumValue.Equals(option.Value);
}
}
selectMenu.WithOptions([.. visibleOptions.Select(x => x.OptionBuilder)]);
return Task.CompletedTask;
}
}
/// <summary>
/// Adds additional metadata to enum fields that are used for select-menus.
/// </summary>
/// <remarks>
/// To manually add select menu options to modal components, use <see cref="ModalSelectMenuOptionAttribute"/> instead.
/// </remarks>
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
public class SelectMenuOptionAttribute : Attribute
{
/// <summary>
/// Gets or sets the desription of the option.
/// </summary>
public string Description { get; set; }
/// <summary>
/// Gets or sets whether the option is selected by default.
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// Gets or sets the emote of the option.
/// </summary>
/// <remarks>
/// Can be either an <see cref="Emoji"/> or an <see cref="Discord.Emote"/>
/// </remarks>
public string Emote { get; set; }
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Threading.Tasks;
namespace Discord.Interactions;
/// <summary>
/// Base class for creating ModalComponentTypeConverters. <see cref="InteractionService"/> uses ModalComponentTypeConverters to interface with Modal component parameters.
/// </summary>
public abstract class ModalComponentTypeConverter : ITypeConverter<IComponentInteractionData>
{
/// <summary>
/// Will be used to search for alternative ModalComponentTypeConverters whenever the Interaction Service encounters an unknown parameter type.
/// </summary>
/// <param name="type">Type of the modal property.</param>
/// <returns>Whether this converter can be used to handle the given type.</returns>
public abstract bool CanConvertTo(Type type);
/// <summary>
/// Will be used to read the incoming payload before building the modal instance.
/// </summary>
/// <param name="context">Command execution context.</param>
/// <param name="option">Received option payload.</param>
/// <param name="services">Service provider that will be used to initialize the command module.</param>
/// <returns>The result of the read process.</returns>
public abstract Task<TypeConverterResult> ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services);
/// <summary>
/// Will be used to manipulate the outgoing modal component, before the modal gets sent to Discord.
/// </summary>
public virtual Task WriteAsync<TBuilder>(TBuilder builder, IDiscordInteraction interaction, InputComponentInfo component, object value)
where TBuilder : class, IInteractableComponentBuilder
=> Task.CompletedTask;
/// <summary>
/// Tries to get the <see cref="IModalInteractionData"/> from the provided <see cref="IInteractionContext"/>.
/// </summary>
/// <param name="context">Context containing the <see cref="IModalInteractionData"/>.</param>
/// <param name="modalData"><see cref="IModalInteractionData"/> found in the context if successful, <see langword="null"/> otherwise.</param>
/// <returns><see langword="true"/> when successful.</returns>
protected bool TryGetModalInteractionData(IInteractionContext context, out IModalInteractionData modalData)
{
if(context.Interaction is IModalInteraction modalInteraction)
{
modalData = modalInteraction.Data;
return true;
}
modalData = null;
return false;
}
}
/// <inheritdoc/>
public abstract class ModalComponentTypeConverter<T> : ModalComponentTypeConverter
{
/// <inheritdoc/>
public sealed override bool CanConvertTo(Type type) =>
typeof(T).IsAssignableFrom(type);
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Threading.Tasks;
namespace Discord.Interactions;
internal class NullableModalComponentConverter<T> : ModalComponentTypeConverter<T>
{
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<TypeConverterResult> ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services)
=> string.IsNullOrEmpty(option.Value) ? Task.FromResult(TypeConverterResult.FromSuccess(null)) : _typeConverter.ReadAsync(context, option, services);
public override Task WriteAsync<TBuilder>(TBuilder builder, IDiscordInteraction interaction, InputComponentInfo component, object value)
=> _typeConverter.WriteAsync(builder, interaction, component, value);
}

View File

@@ -43,13 +43,4 @@ namespace Discord.Interactions
}
}
}
/// <summary>
/// Enum values tagged with this attribute will not be displayed as a parameter choice
/// </summary>
/// <remarks>
/// This attribute must be used along with the default <see cref="EnumConverter{T}"/>
/// </remarks>
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = true)]
public sealed class HideAttribute : Attribute { }
}

View File

@@ -0,0 +1,15 @@
using System;
using System.Threading.Tasks;
namespace Discord.Interactions.TypeReaders;
internal class DateTimeTypeReader : TypeReader<DateTime>
{
public override Task<TypeConverterResult> ReadAsync(IInteractionContext context, string option, IServiceProvider services)
{
if (DateTime.TryParse(option, out var dateTime))
return Task.FromResult(TypeConverterResult.FromSuccess(dateTime));
return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"{option} is not a valid date time."));
}
}