diff --git a/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs b/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs index 5d2a0af3..900203f8 100644 --- a/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs +++ b/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs @@ -65,16 +65,16 @@ namespace Discord.Interactions if (!ModalUtils.TryGet(out var modalInfo)) throw new ArgumentException($"{typeof(T).FullName} isn't referenced by any registered Modal Interaction Command and doesn't have a cached {typeof(ModalInfo)}"); - return SendModalResponseAsync(interaction, customId, modalInfo, modal, options, modifyModal); + return SendModalResponseAsync(interaction, customId, modalInfo, modal, options, modifyModal); } - private static async Task SendModalResponseAsync(IDiscordInteraction interaction, string customId, ModalInfo modalInfo, T modalInstance = null, RequestOptions options = null, Action modifyModal = null) + public static async Task ToModalAsync(this IDiscordInteraction interaction, string customId, ModalInfo modalInfo, T modalInstance = null, RequestOptions options = null, Action modifyModal = null) where T : class, IModal { if (!modalInfo.Type.IsAssignableFrom(typeof(T))) throw new ArgumentException($"{modalInfo.Type.FullName} isn't assignable from {typeof(T).FullName}."); - var builder = new ModalBuilder(modalInstance.Title, customId); + var builder = new ModalBuilder(modalInstance?.Title ?? modalInfo.Title, customId); foreach (var input in modalInfo.Components) switch (input) @@ -134,7 +134,7 @@ namespace Discord.Interactions break; case TextDisplayComponentInfo textDisplayComponent: { - var content = textDisplayComponent.Getter(modalInstance).ToString() ?? textDisplayComponent.Content; + var content = modalInstance is not null ? textDisplayComponent.Getter(modalInstance).ToString() : (textDisplayComponent.DefaultValue as string) ?? textDisplayComponent.Content; var componentBuilder = new TextDisplayBuilder(content); builder.AddTextDisplay(componentBuilder); } @@ -145,7 +145,15 @@ namespace Discord.Interactions modifyModal?.Invoke(builder); - await interaction.RespondWithModalAsync(builder.Build(), options); + return builder.Build(); + } + + private static async Task SendModalResponseAsync(IDiscordInteraction interaction, string customId, ModalInfo modalInfo, T modalInstance = null, RequestOptions options = null, Action modifyModal = null) + where T : class, IModal + { + var modal = await interaction.ToModalAsync(customId, modalInfo, modalInstance, options, modifyModal); + + await interaction.RespondWithModalAsync(modal, options); } } } diff --git a/src/Discord.Net.Interactions/Extensions/RestExtensions.cs b/src/Discord.Net.Interactions/Extensions/RestExtensions.cs index 917da688..3514fb2f 100644 --- a/src/Discord.Net.Interactions/Extensions/RestExtensions.cs +++ b/src/Discord.Net.Interactions/Extensions/RestExtensions.cs @@ -1,5 +1,6 @@ using Discord.Interactions; using System; +using System.Threading.Tasks; namespace Discord.Rest { @@ -11,14 +12,16 @@ namespace Discord.Rest /// Type of the implementation. /// The interaction to respond to. /// The request options for this request. - /// Serialized payload to be used to create a HTTP response. - public static string RespondWithModal(this RestInteraction interaction, string customId, RequestOptions options = null, Action modifyModal = null) + /// + /// Task representing the asynchronous modal creation operation. The task result contains the serialized payload to be used to create a HTTP response. + /// + public static async Task RespondWithModalAsync(this RestInteraction interaction, string customId, RequestOptions options = null, Action modifyModal = null) where T : class, IModal { if (!ModalUtils.TryGet(out var modalInfo)) throw new ArgumentException($"{typeof(T).FullName} isn't referenced by any registered Modal Interaction Command and doesn't have a cached {typeof(ModalInfo)}"); - var modal = modalInfo.ToModal(customId, modifyModal); + var modal = await interaction.ToModalAsync(customId, modalInfo, null, options, modifyModal); return interaction.RespondWithModal(modal, options); } @@ -27,35 +30,20 @@ namespace Discord.Rest /// /// Type of the implementation. /// The interaction to respond to. - /// The instance to get field values from. + /// The instance to get field values from. /// The request options for this request. /// Delegate that can be used to modify the modal. - /// Serialized payload to be used to create a HTTP response. - public static string RespondWithModal(this RestInteraction interaction, string customId, T modal, RequestOptions options = null, Action modifyModal = null) + /// + /// Task representing the asynchronous modal creation operation. The task result contains the serialized payload to be used to create a HTTP response. + /// + public static async Task RespondWithModalAsync(this RestInteraction interaction, string customId, T modalInstance, RequestOptions options = null, Action modifyModal = null) where T : class, IModal { if (!ModalUtils.TryGet(out var modalInfo)) throw new ArgumentException($"{typeof(T).FullName} isn't referenced by any registered Modal Interaction Command and doesn't have a cached {typeof(ModalInfo)}"); - var builder = new ModalBuilder(modal.Title, customId); - - foreach (var input in modalInfo.Components) - switch (input) - { - case TextInputComponentInfo textComponent: - { - builder.AddTextInput(textComponent.Label, textComponent.CustomId, textComponent.Style, textComponent.Placeholder, textComponent.IsRequired ? textComponent.MinLength : null, - textComponent.MaxLength, textComponent.IsRequired, textComponent.Getter(modal) as string); - } - break; - default: - throw new InvalidOperationException($"{input.GetType().FullName} isn't a valid component info class"); - } - - if (modifyModal is not null) - modifyModal(builder); - - return interaction.RespondWithModal(builder.Build(), options); + var modal = await interaction.ToModalAsync(customId, modalInfo, null, options, modifyModal); + return interaction.RespondWithModal(modal, options); } } } diff --git a/src/Discord.Net.Interactions/InteractionService.cs b/src/Discord.Net.Interactions/InteractionService.cs index 82a7b4e7..d8f3bffc 100644 --- a/src/Discord.Net.Interactions/InteractionService.cs +++ b/src/Discord.Net.Interactions/InteractionService.cs @@ -236,7 +236,12 @@ namespace Discord.Interactions [typeof(IConvertible)] = typeof(DefaultValueModalComponentConverter<>), [typeof(Enum)] = typeof(EnumModalComponentConverter<>), [typeof(Nullable<>)] = typeof(NullableComponentConverter<>), - [typeof(Array)] = typeof(DefaultArrayModalComponentConverter<>) + [typeof(Array)] = typeof(DefaultArrayModalComponentConverter<>), + [typeof(IChannel)] = typeof(DefaultChannelModalComponentConverter<>), + [typeof(IUser)] = typeof(DefaultUserModalComponentConverter<>), + [typeof(IRole)] = typeof(DefaultRoleModalComponentConverter<>), + [typeof(IMentionable)] = typeof(DefaultMentionableModalComponentConverter<>), + [typeof(IAttachment)] = typeof(DefaultAttachmentModalComponentConverter<>) }); } diff --git a/src/Discord.Net.Interactions/RestInteractionModuleBase.cs b/src/Discord.Net.Interactions/RestInteractionModuleBase.cs index cdcc4c89..3e30df36 100644 --- a/src/Discord.Net.Interactions/RestInteractionModuleBase.cs +++ b/src/Discord.Net.Interactions/RestInteractionModuleBase.cs @@ -71,7 +71,7 @@ namespace Discord.Interactions /// /// Thrown if the interaction isn't a type of . protected override async Task RespondWithModalAsync(string customId, TModal modal, RequestOptions options = null, Action modifyModal = null) - => await HandleInteractionAsync(x => x.RespondWithModal(customId, modal, options, modifyModal)); + => await HandleInteractionAsync(x => RestExtensions.RespondWithModalAsync(x, customId, modal, options, modifyModal)); /// /// Responds to the interaction with a modal. @@ -85,7 +85,7 @@ namespace Discord.Interactions /// /// Thrown if the interaction isn't a type of . protected override Task RespondWithModalAsync(string customId, RequestOptions options = null, Action modifyModal = null) - => HandleInteractionAsync(x => x.RespondWithModal(customId, options, modifyModal)); + => HandleInteractionAsync(x => RestExtensions.RespondWithModalAsync(x, customId, options, modifyModal)); private Task HandleInteractionAsync(Func action) { @@ -99,5 +99,18 @@ namespace Discord.Interactions else return InteractionService._restResponseCallback(Context, payload); } + + private async Task HandleInteractionAsync(Func> action) + { + if (Context.Interaction is not RestInteraction restInteraction) + throw new InvalidOperationException($"Interaction must be a type of {nameof(RestInteraction)} in order to execute this method."); + + var payload = await action(restInteraction); + + if (Context is IRestInteractionContext restContext && restContext.InteractionResponseCallback != null) + await restContext.InteractionResponseCallback.Invoke(payload); + else + await InteractionService._restResponseCallback(Context, payload); + } } } diff --git a/src/Discord.Net.Interactions/TypeConverters/ModalComponents/DefaultSnowflakeModalComponentConverter.cs b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/DefaultSnowflakeModalComponentConverter.cs new file mode 100644 index 00000000..5223bd85 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/ModalComponents/DefaultSnowflakeModalComponentConverter.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions; + +internal abstract class DefaultSnowflakeModalComponentConverter : ModalComponentTypeConverter + where T : class +{ + protected bool TryGetPreemptiveResult(IInteractionContext context, IComponentInteractionData option, ComponentType componentType, out TypeConverterResult preemptiveResult, out IModalInteractionData modalData, out ulong id) + { + preemptiveResult = default; + modalData = null; + id = 0; + + if (!TryGetModalInteractionData(context, out modalData)) + { + preemptiveResult = TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"{typeof(IModalInteractionData).Name} cannot be accessed from the provided {typeof(IInteractionContext).Name} type."); + return true; + } + + if (option.Type != componentType) + { + preemptiveResult = TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"{typeof(DefaultSnowflakeModalComponentConverter).Name} cannot be used to convert component result other than {componentType} to {typeof(T).Name}"); + return true; + } + + if (option.Values.Count > 1) + { + preemptiveResult = TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Multiple values were provided for a single {option.Type} component."); + return true; + } + + if (option.Values.Count == 0) + { + preemptiveResult = TypeConverterResult.FromSuccess(null); + return true; + } + + if (!ulong.TryParse(option.Values.First(), out id)) + { + preemptiveResult = TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"{option.Type} contains invalid snowflake."); + return true; + } + + return false; + } +} + +internal class DefaultAttachmentModalComponentConverter : DefaultSnowflakeModalComponentConverter + where T : class, IAttachment +{ + public override async Task ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services) + { + if (TryGetPreemptiveResult(context, option, ComponentType.FileUpload, out var result, out var modalData, out var id)) + { + return result; + } + + var resolvedEntity = modalData.Attachments.FirstOrDefault(x => x.Id == id); + + if (resolvedEntity is null) + { + return TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Snowflake entity reference for the {option.Type} cannot be resolved."); + } + + return TypeConverterResult.FromSuccess(resolvedEntity); + } +} + +internal class DefaultUserModalComponentConverter : DefaultSnowflakeModalComponentConverter + where T : class, IUser +{ + public override async Task ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services) + { + if (TryGetPreemptiveResult(context, option, ComponentType.UserSelect, out var result, out var modalData, out var id)) + { + return result; + } + + var resolvedEntity = modalData.Members.UnionBy(modalData.Users, x => x.Id).FirstOrDefault(x => x.Id == id); + + if (resolvedEntity is null) + { + return TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Snowflake entity reference for the {option.Type} cannot be resolved."); + } + + return TypeConverterResult.FromSuccess(resolvedEntity); + } + + public override Task WriteAsync(TBuilder builder, IDiscordInteraction interaction, InputComponentInfo component, object value) + { + if (value is null) + { + return Task.CompletedTask; + } + + if (builder is not SelectMenuBuilder selectMenu || selectMenu.Type is not ComponentType.UserSelect) + { + throw new InvalidOperationException($"{typeof(DefaultUserModalComponentConverter).Name} can only be used with User Select components."); + } + + if (selectMenu.MaxValues > 1) + { + throw new InvalidOperationException($"Multi-select User Select cannot be used with a single {typeof(T).Name} entity."); + } + + 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}"); + } + + selectMenu.WithDefaultValues(SelectMenuDefaultValue.FromUser(user)); + + return Task.CompletedTask; + } +} + +internal class DefaultRoleModalComponentConverter : DefaultSnowflakeModalComponentConverter + where T : class, IRole +{ + public override async Task ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services) + { + if (TryGetPreemptiveResult(context, option, ComponentType.RoleSelect, out var result, out var modalData, out var id)) + { + return result; + } + + var resolvedEntity = modalData.Roles.FirstOrDefault(x => x.Id == id); + + if (resolvedEntity is null) + { + return TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Snowflake entity reference for the {option.Type} cannot be resolved."); + } + + return TypeConverterResult.FromSuccess(resolvedEntity); + } + + public override Task WriteAsync(TBuilder builder, IDiscordInteraction interaction, InputComponentInfo component, object value) + { + if(value is null) + { + return Task.CompletedTask; + } + + if (builder is not SelectMenuBuilder selectMenu || selectMenu.Type is not ComponentType.RoleSelect) + { + throw new InvalidOperationException($"{typeof(DefaultRoleModalComponentConverter).Name} can only be used with Role Select components."); + } + + if (selectMenu.MaxValues > 1) + { + throw new InvalidOperationException($"Multi-select Role Select cannot be used with a single {typeof(T).Name} entity."); + } + + 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}"); + } + + selectMenu.WithDefaultValues(SelectMenuDefaultValue.FromRole(role)); + + return Task.CompletedTask; + } +} + +internal class DefaultChannelModalComponentConverter : DefaultSnowflakeModalComponentConverter + where T : class, IChannel +{ + public override async Task ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services) + { + if (TryGetPreemptiveResult(context, option, ComponentType.ChannelSelect, out var result, out var modalData, out var id)) + { + return result; + } + + var resolvedEntity = modalData.Channels.FirstOrDefault(x => x.Id == id); + + if (resolvedEntity is null) + { + return TypeConverterResult.FromError(InteractionCommandError.ParseFailed, $"Snowflake entity reference for the {option.Type} cannot be resolved."); + } + + return TypeConverterResult.FromSuccess(resolvedEntity); + } + + public override Task WriteAsync(TBuilder builder, IDiscordInteraction interaction, InputComponentInfo component, object value) + { + if (value is null) + { + return Task.CompletedTask; + } + + if (builder is not SelectMenuBuilder selectMenu || selectMenu.Type is not ComponentType.ChannelSelect) + { + throw new InvalidOperationException($"{typeof(DefaultChannelModalComponentConverter).Name} can only be used with Channel Select components."); + } + + if(selectMenu.MaxValues > 1) + { + throw new InvalidOperationException($"Multi-select Channel Select cannot be used with a single {typeof(T).Name} entity."); + } + + 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}"); + } + + selectMenu.WithDefaultValues(SelectMenuDefaultValue.FromChannel(channel)); + + return Task.CompletedTask; + } +} + +internal class DefaultMentionableModalComponentConverter : DefaultSnowflakeModalComponentConverter + where T : class, IMentionable +{ + public override async Task ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services) + { + if (TryGetPreemptiveResult(context, option, ComponentType.MentionableSelect, out var result, out var modalData, out var id)) + { + return result; + } + + var resolvedMentionables = new Dictionary(); + + foreach (var user in modalData.Users) // should never be null in mentionable select + resolvedMentionables[user.Id] = user; + + foreach (var member in modalData.Members) + resolvedMentionables[member.Id] = member; + + foreach (var role in modalData.Roles) + resolvedMentionables[role.Id] = role; + + 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 TypeConverterResult.FromSuccess(entity); + } + + public override Task WriteAsync(TBuilder builder, IDiscordInteraction interaction, InputComponentInfo component, object value) + { + if (value is null) + { + return Task.CompletedTask; + } + + if (builder is not SelectMenuBuilder selectMenu || selectMenu.Type is not ComponentType.MentionableSelect) + { + throw new InvalidOperationException($"{typeof(DefaultMentionableModalComponentConverter).Name} can only be used with Mentionable Select components."); + } + + if (selectMenu.MaxValues > 1) + { + throw new InvalidOperationException($"Multi-select Mentionable Select cannot be used with a single {typeof(T).Name} entity."); + } + + var defaultValue = value switch + { + IRole role => SelectMenuDefaultValue.FromRole(role), + IUser user => SelectMenuDefaultValue.FromUser(user), + _ => throw new InvalidOperationException($"{typeof(T).Name} cannot be used to assign default Mentionable Select values. Expected {typeof(IUser).Name} or {typeof(IRole).Name}") + }; + + selectMenu.WithDefaultValues(defaultValue); + + return Task.CompletedTask; + } +} diff --git a/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs b/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs index 8cee68fd..0a7593ea 100644 --- a/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs +++ b/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Threading.Tasks; namespace Discord.Interactions { @@ -337,26 +338,6 @@ namespace Discord.Interactions MinLength = commandOption.MinLength, }; - public static Modal ToModal(this ModalInfo modalInfo, string customId, Action modifyModal = null) - { - var builder = new ModalBuilder(modalInfo.Title, customId); - - foreach (var input in modalInfo.Components) - switch (input) - { - case TextInputComponentInfo textComponent: - builder.AddTextInput(textComponent.Label, textComponent.CustomId, textComponent.Style, textComponent.Placeholder, textComponent.IsRequired ? textComponent.MinLength : null, - textComponent.MaxLength, textComponent.IsRequired, textComponent.InitialValue); - break; - default: - throw new InvalidOperationException($"{input.GetType().FullName} isn't a valid component info class"); - } - - modifyModal?.Invoke(builder); - - return builder.Build(); - } - public static GuildPermission? SanitizeGuildPermissions(this GuildPermission permissions) => permissions == 0 ? null : permissions; }