[Fix] Modal Write invocation without instance and missing ChannelTypes (#3221)

* add channel types to channel select builder and info

* add channelTypes to select builder from IModal

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

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

* add inline docs to channelTypes props and method

* add property as a target for ChannelTypesAttribute

* move enum option building logic out of enum typeConverter

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

* move SelectMenuOptionAttribute to its own file

* add null forgiving operator to channel type mapping

* remove list initialization from enum modal typeConverter

* disallow channel default value assignment to mentionable selects

* add id property to modal components

* add component id assignment from attributes

* update component attribute ctor signatures and inline docs

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

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

* replace default values of component ids with 0

---------

Co-authored-by: Mihail Gribkov <61027276+Misha-133@users.noreply.github.com>
This commit is contained in:
Cenk Ergen
2026-01-22 15:50:27 +01:00
committed by GitHub
parent 4fdebdc6ed
commit 9c1db3f0f0
26 changed files with 387 additions and 200 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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