diff --git a/Discord.Net.code-workspace b/Discord.Net.code-workspace index 709eb0e95..b40453473 100644 --- a/Discord.Net.code-workspace +++ b/Discord.Net.code-workspace @@ -8,16 +8,16 @@ "editor.rulers": [ 120 ], + "editor.insertSpaces": true, "files.exclude": { "**/.git": true, "**/.svn": true, "**/.hg": true, "**/CVS": true, "**/.DS_Store": true, - "docs/": true, "**/obj": true, "**/bin": true, "samples/": true, } } -} \ No newline at end of file +} diff --git a/docs/guides/int_basics/message-components/images/image7.png b/docs/guides/int_basics/message-components/images/image7.png new file mode 100644 index 000000000..5ff55a550 Binary files /dev/null and b/docs/guides/int_basics/message-components/images/image7.png differ diff --git a/docs/guides/int_basics/message-components/images/image8.png b/docs/guides/int_basics/message-components/images/image8.png new file mode 100644 index 000000000..0268313d5 Binary files /dev/null and b/docs/guides/int_basics/message-components/images/image8.png differ diff --git a/docs/guides/int_basics/message-components/images/image9.png b/docs/guides/int_basics/message-components/images/image9.png new file mode 100644 index 000000000..6a9850fb3 Binary files /dev/null and b/docs/guides/int_basics/message-components/images/image9.png differ diff --git a/docs/guides/int_basics/message-components/text-input.md b/docs/guides/int_basics/message-components/text-input.md new file mode 100644 index 000000000..37f5b4937 --- /dev/null +++ b/docs/guides/int_basics/message-components/text-input.md @@ -0,0 +1,46 @@ +--- +uid: Guides.MessageComponents.TextInputs +title: Text Input Components +--- + +# Text Input Components + +> [!WARNING] +> Text input components can only be used in +> [modals](../modals/intro.md). + +Text input components are a type of MessageComponents that can only be +used in modals. Texts inputs can be longer (the `Paragraph`) style or +shorter (the `Short` style). Text inputs have a variable min and max +length. + +![A modal with short and paragraph text inputs](images/image7.png) + +## Creating text inputs +Text input components can be built using the `TextInputBuilder`. +The simplest text input can built with: +```cs +var tb = new TextInputBuilder() + .WithLabel("My Text") + .WithCustomId("text_input"); +``` + +and would produce a component that looks like: + +![basic text input component](images/image8.png) + +Additional options can be specified to control the placeholder, style, +and min/max length of the input: +```cs +var tb = new TextInputBuilder() + .WithLabel("Labeled") + .WithCustomId("text_input") + .WithStyle(TextInputStyle.Paragraph) + .WithMinLength(6); + .WithMaxLength(42) + .WithRequired(true) + .WithPlaceholder("Consider this place held."); +``` + +![more advanced text input](images/image9.png) + diff --git a/docs/guides/int_basics/modals/images/image1.png b/docs/guides/int_basics/modals/images/image1.png new file mode 100644 index 000000000..779bf78b5 Binary files /dev/null and b/docs/guides/int_basics/modals/images/image1.png differ diff --git a/docs/guides/int_basics/modals/images/image2.png b/docs/guides/int_basics/modals/images/image2.png new file mode 100644 index 000000000..7c1c325d3 Binary files /dev/null and b/docs/guides/int_basics/modals/images/image2.png differ diff --git a/docs/guides/int_basics/modals/images/image3.png b/docs/guides/int_basics/modals/images/image3.png new file mode 100644 index 000000000..49ca61cbd Binary files /dev/null and b/docs/guides/int_basics/modals/images/image3.png differ diff --git a/docs/guides/int_basics/modals/images/image4.png b/docs/guides/int_basics/modals/images/image4.png new file mode 100644 index 000000000..453b2eee5 Binary files /dev/null and b/docs/guides/int_basics/modals/images/image4.png differ diff --git a/docs/guides/int_basics/modals/intro.md b/docs/guides/int_basics/modals/intro.md new file mode 100644 index 000000000..3212019ae --- /dev/null +++ b/docs/guides/int_basics/modals/intro.md @@ -0,0 +1,135 @@ +--- +uid: Guides.Modals.Intro +title: Getting Started with Modals +--- +# Modals + +## Getting started with modals +This guide will show you how to use modals and give a few examples of +valid use cases. If your question is not covered by this guide ask in the +[Discord.Net Discord Server](https://discord.gg/dnet). + +### What is a modal? +Modals are forms bots can send when responding to interactions. Modals +are sent to Discord as an array of message components and converted +into the form layout by user's clients. Modals are required to have a +custom id, title, and at least one component. + +![Screenshot of a modal](images/image2.png) + +When users submit modals, your client fires the ModalSubmitted event. +You can get the components of the modal from the `Data.Components` property +on the SocketModal: + +![Screenshot of modal data](images/image1.png) + +### Using modals + +Lets create a simple modal with an entry field for users to +tell us their favorite food. We can start by creating a slash +command that will respond with the modal. +```cs +[SlashCommand("food", "Tell us about your favorite food!")] +public async Task FoodPreference() +{ + // send a modal +} +``` + +Now that we have our command set up, we need to build a modal. +We can use the aptly named `ModalBuilder` for that: + +| Method | Description | +| --------------- | ----------------------------------------- | +| `WithTitle` | Sets the modal's title. | +| `WithCustomId` | Sets the modal's custom id. | +| `AddTextInput` | Adds a `TextInputBuilder` to the modal. | +| `AddComponents` | Adds multiple components to the modal. | +| `Build` | Builds the `ModalBuilder` into a `Modal`. | + +We know we need to add a text input to the modal, so let's look at that +method's parameters. + +| Parameter | Description | +| ------------- | ------------------------------------------ | +| `label` | Sets the input's label. | +| `customId` | Sets the input's custom id. | +| `style` | Sets the input's style. | +| `placeholder` | Sets the input's placeholder. | +| `minLength` | Sets the minimum input length. | +| `maxLength` | Sets the maximum input length. | +| `required` | Sets whether or not the modal is required. | +| `value` | Sets the input's default value. | + +To make a basic text input we would only need to set the `label` and +`customId`, but in this example we will also use the `placeholder` +parameter. Next we can build our modal: + +```cs +var mb = new ModalBuilder() + .WithTitle("Fav Food") + .WithCustomId("food_menu") + .AddTextInput("What??", "food_name", placeholder:"Pizza") + .AddTextInput("Why??", "food_reason", TextInputStyle.Paragraph, + "Kus it's so tasty"); +``` + +Now that we have a ModalBuilder we can update our command to respond +with the modal. + +```cs +[SlashCommand("food", "Tell us about your favorite food!")] +public async Task FoodPreference() +{ + var mb = new ModalBuilder() + .WithTitle("Fav Food") + .WithCustomId("food_menu") + .AddTextInput("What??", "food_name", placeholder:"Pizza") + .AddTextInput("Why??", "food_reason", TextInputStyle.Paragraph, + "Kus it's so tasty"); + + await Context.Interaction.RespondWithModalAsync(mb.Build()); +} +``` + +When we run the command, our modal should pop up: + +![screenshot of the above modal](images/image3.png) + +### Respond to modals + +> [!WARNING] +> Modals can not be sent when respoding to a modal. + +Once a user has submitted the modal, we need to let everyone know what +their favorite food is. We can start by hooking a task to the client's +`ModalSubmitted` event. +```cs +_client.ModalSubmitted += async modal => +{ + // Get the values of components. + List components = + modal.Data.Components.ToList(); + string food = components + .Where(x => x.CustomId == "food_name").First().Value; + string reason = components + .Where(x => x.CustomId == "food_reason").First().Value; + + // Build the message to send. + string message = "hey @everyone; I just learned " + + $"{modal.User.Mention}'s favorite food is " + + $"{food} because {reason}."; + + // Specify the AllowedMentions so we don't actually ping everyone. + AllowedMentions mentions = new AllowedMentions(); + mentions.AllowedTypes = AllowedMentionTypes.Users; + + // Respond to the modal. + await modal.RespondAsync(message, allowedMentions:mentions); +} +``` + +Now responding to the modal should inform everyone of our tasty +choices. + +![Response of the modal submitted event](images/image4.png) diff --git a/docs/guides/int_framework/intro.md b/docs/guides/int_framework/intro.md index 7dfd7ac6e..0a5cc19f1 100644 --- a/docs/guides/int_framework/intro.md +++ b/docs/guides/int_framework/intro.md @@ -198,6 +198,18 @@ Autocomplete commands must be parameterless methods. A valid Autocomplete comman Alternatively, you can use the [AutocompleteHandlers] to simplify this workflow. +## Modals + +Modal commands last parameter must be an implementation of `IModal`. +A Modal implementation would look like this: + +[!code-csharp[Modal Command](samples/intro/modal.cs)] + +> [!NOTE] +> If you are using Modals in the interaction service it is **highly +> recommended** that you enable `PreCompiledLambdas` in your config +> to prevent performance issues. + ## Interaction Context Every command module provides its commands with an execution context. diff --git a/docs/guides/int_framework/samples/intro/modal.cs b/docs/guides/int_framework/samples/intro/modal.cs new file mode 100644 index 000000000..af72fe04e --- /dev/null +++ b/docs/guides/int_framework/samples/intro/modal.cs @@ -0,0 +1,36 @@ +// Registers a command that will respond with a modal. +[SlashCommand("food", "Tell us about your favorite food.")] +public async Task Command() + => await Context.Interaction.RespondWithModalAsync("food_menu"); + +// Defines the modal that will be sent. +public class FoodModal : IModal +{ + public string Title => "Fav Food"; + // Strings with the ModalTextInput attribute will automatically become components. + [InputLabel("What??")] + [ModalTextInput("food_name", placeholder: "Pizza", maxLength: 20)] + public string Food { get; set; } + + // Additional paremeters can be specified to further customize the input. + [InputLabel("Why??")] + [ModalTextInput("food_reason", TextInputStyle.Paragraph, "Kuz it's tasty", maxLength: 500)] + public string Reason { get; set; } +} + +// Responds to the modal. +[ModalInteraction("food_menu")] +public async Task ModalResponce(FoodModal modal) +{ + // Build the message to send. + string message = "hey @everyone, I just learned " + + $"{Context.User.Mention}'s favorite food is " + + $"{modal.Food} because {modal.Reason}."; + + // Specify the AllowedMentions so we don't actually ping everyone. + AllowedMentions mentions = new(); + mentions.AllowedTypes = AllowedMentionTypes.Users; + + // Respond to the modal. + await RespondAsync(message, allowedMentions: mentions, ephemeral: true); +} \ No newline at end of file diff --git a/docs/guides/toc.yml b/docs/guides/toc.yml index d4f2984f8..1616363b7 100644 --- a/docs/guides/toc.yml +++ b/docs/guides/toc.yml @@ -91,8 +91,14 @@ topicUid: Guides.MessageComponents.Buttons - name: Select menus topicUid: Guides.MessageComponents.SelectMenus + - name: Text Input + topicUid: Guides.MessageComponents.TextInputs - name: Advanced Concepts topicUid: Guides.MessageComponents.Advanced +- name: Modal Basics + items: + - name: Introduction + topicUid: Guides.Modals.Intro - name: Guild Events items: - name: Introduction diff --git a/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs index 8ac08f842..66ff6c6d0 100644 --- a/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs +++ b/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs @@ -332,5 +332,13 @@ namespace Discord /// A task that represents the asynchronous operation of deferring the interaction. /// Task DeferAsync(bool ephemeral = false, RequestOptions options = null); + + /// + /// Responds to the interaction with a modal. + /// + /// The modal to respond with. + /// The request options for this request. + /// A task that represents the asynchronous operation of responding to the interaction. + Task RespondWithModalAsync(Modal modal, RequestOptions options = null); } } diff --git a/src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs b/src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs index ebdf29781..b0c2384e7 100644 --- a/src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs +++ b/src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs @@ -41,6 +41,11 @@ namespace Discord /// /// Respond with a set of choices to a autocomplete interaction. /// - ApplicationCommandAutocompleteResult = 8 + ApplicationCommandAutocompleteResult = 8, + + /// + /// Respond by showing the user a modal. + /// + Modal = 9, } } diff --git a/src/Discord.Net.Core/Entities/Interactions/InteractionType.cs b/src/Discord.Net.Core/Entities/Interactions/InteractionType.cs index e09c906b5..811c8c7c7 100644 --- a/src/Discord.Net.Core/Entities/Interactions/InteractionType.cs +++ b/src/Discord.Net.Core/Entities/Interactions/InteractionType.cs @@ -23,6 +23,11 @@ namespace Discord /// /// An autocomplete request sent from discord. /// - ApplicationCommandAutocomplete = 4 + ApplicationCommandAutocomplete = 4, + + /// + /// A modal sent from discord. + /// + ModalSubmit = 5, } } diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs index b086535f7..0fa8189c1 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs @@ -276,6 +276,11 @@ namespace Discord /// A that can be sent with . public MessageComponent Build() { + if (_actionRows?.SelectMany(x => x.Components)?.Any(x => x.Type == ComponentType.TextInput) ?? false) + throw new ArgumentException("TextInputComponents are not allowed in messages.", nameof(ActionRows)); + if (_actionRows?.SelectMany(x => x.Components)?.Any(x => x.Type == ComponentType.ModalSubmit) ?? false) + throw new ArgumentException("ModalSubmit components are not allowed in messages.", nameof(ActionRows)); + return _actionRows != null ? new MessageComponent(_actionRows.Select(x => x.Build()).ToList()) : MessageComponent.Empty; @@ -1093,4 +1098,248 @@ namespace Discord return new SelectMenuOption(Label, Value, Description, Emote, IsDefault); } } + + public class TextInputBuilder + { + public const int LargestMaxLength = 4000; + + /// + /// Gets or sets the custom id of the current text input. + /// + /// length exceeds + /// length subceeds 1. + public string CustomId + { + get => _customId; + set => _customId = value?.Length switch + { + > ComponentBuilder.MaxCustomIdLength => throw new ArgumentOutOfRangeException(nameof(value), $"Custom Id length must be less or equal to {ComponentBuilder.MaxCustomIdLength}."), + 0 => throw new ArgumentOutOfRangeException(nameof(value), "Custom Id length must be at least 1."), + _ => value + }; + } + + /// + /// Gets or sets the style of the current text input. + /// + public TextInputStyle Style { get; set; } = TextInputStyle.Short; + + /// + /// Gets or sets the label of the current text input. + /// + public string Label { get; set; } + + /// + /// Gets or sets the placeholder of the current text input. + /// + /// is longer than 100 characters + public string Placeholder + { + get => _placeholder; + set => _placeholder = (value?.Length ?? 0) <= 100 + ? value + : throw new ArgumentException("Placeholder cannot have more than 100 characters."); + } + + /// + /// Gets or sets the minimum length of the current text input. + /// + /// is less than 0. + /// is greater than . + /// is greater than . + public int? MinLength + { + get => _minLength; + set + { + if (value < 0) + throw new ArgumentOutOfRangeException(nameof(value), $"MinLength must not be less than 0"); + if (value > LargestMaxLength) + throw new ArgumentOutOfRangeException(nameof(value), $"MinLength must not be greater than {LargestMaxLength}"); + if (value > (MaxLength ?? LargestMaxLength)) + throw new ArgumentOutOfRangeException(nameof(value), $"MinLength must be less than MaxLength"); + _minLength = value; + } + } + + /// + /// Gets or sets the maximum length of the current text input. + /// + /// is less than 0. + /// is greater than . + /// is less than . + public int? MaxLength + { + get => _maxLength; + set + { + if (value < 0) + throw new ArgumentOutOfRangeException(nameof(value), $"MaxLength must not be less than 0"); + if (value > LargestMaxLength) + throw new ArgumentOutOfRangeException(nameof(value), $"MaxLength most not be greater than {LargestMaxLength}"); + if (value < (MinLength ?? -1)) + throw new ArgumentOutOfRangeException(nameof(value), $"MaxLength must be greater than MinLength ({MinLength})"); + _maxLength = value; + } + } + + /// + /// Gets or sets whether the user is required to input text. + /// + public bool? Required { get; set; } + + /// + /// Gets or sets the default value of the text input. + /// + /// is less than 0. + /// + /// is greater than or . + /// + public string Value + { + get => _value; + set + { + if (value?.Length > (MaxLength ?? LargestMaxLength)) + throw new ArgumentOutOfRangeException(nameof(value), $"Value must not be longer than {MaxLength ?? LargestMaxLength}."); + if (value?.Length < (MinLength ?? 0)) + throw new ArgumentOutOfRangeException(nameof(value), $"Value must not be shorter than {MinLength}"); + _value = value; + } + } + + private string _customId; + private int? _maxLength; + private int? _minLength; + private string _placeholder; + private string _value; + + /// + /// Creates a new instance of a . + /// + /// The text input's label. + /// The text input's style. + /// The text input's custom id. + /// The text input's placeholder. + /// The text input's minimum length. + /// The text input's maximum length. + /// The text input's required value. + public TextInputBuilder (string label, string customId, TextInputStyle style = TextInputStyle.Short, string placeholder = null, + int? minLength = null, int? maxLength = null, bool? required = null, string value = null) + { + Label = label; + Style = style; + CustomId = customId; + Placeholder = placeholder; + MinLength = minLength; + MaxLength = maxLength; + Required = required; + Value = value; + } + + /// + /// Creates a new instance of a . + /// + public TextInputBuilder() + { + + } + + /// + /// Sets the label of the current builder. + /// + /// The value to set. + /// The current builder. + public TextInputBuilder WithLabel(string label) + { + Label = label; + return this; + } + + /// + /// Sets the style of the current builder. + /// + /// The value to set. + /// The current builder. + public TextInputBuilder WithStyle(TextInputStyle style) + { + Style = style; + return this; + } + + /// + /// Sets the custom id of the current builder. + /// + /// The value to set. + /// The current builder. + public TextInputBuilder WithCustomId(string customId) + { + CustomId = customId; + return this; + } + + /// + /// Sets the placeholder of the current builder. + /// + /// The value to set. + /// The current builder. + public TextInputBuilder WithPlaceholder(string placeholder) + { + Placeholder = placeholder; + return this; + } + + /// + /// Sets the value of the current builder. + /// + /// The value to set + /// The current builder. + public TextInputBuilder WithValue(string value) + { + Value = value; + return this; + } + + /// + /// Sets the minimum length of the current builder. + /// + /// The value to set. + /// The current builder. + public TextInputBuilder WithMinLength(int minLength) + { + MinLength = minLength; + return this; + } + + /// + /// Sets the maximum length of the current builder. + /// + /// The value to set. + /// The current builder. + public TextInputBuilder WithMaxLength(int maxLength) + { + MaxLength = maxLength; + return this; + } + + /// + /// Sets the required value of the current builder. + /// + /// The value to set. + /// The current builder. + public TextInputBuilder WithRequired(bool required) + { + Required = required; + return this; + } + + public TextInputComponent Build() + { + if (string.IsNullOrEmpty(CustomId)) + throw new ArgumentException("TextInputComponents must have a custom id.", nameof(CustomId)); + if (string.IsNullOrWhiteSpace(Label)) + throw new ArgumentException("TextInputComponents must have a label.", nameof(Label)); + return new TextInputComponent(CustomId, Label, Placeholder, MinLength, MaxLength, Style, Required, Value); + } + } } diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentType.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentType.cs index 70bc1f301..1d63ee829 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentType.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentType.cs @@ -18,6 +18,16 @@ namespace Discord /// /// A select menu for picking from choices. /// - SelectMenu = 3 + SelectMenu = 3, + + /// + /// A box for entering text. + /// + TextInput = 4, + + /// + /// An interaction sent when a model is submitted. + /// + ModalSubmit = 5, } } diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IComponentInteractionData.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IComponentInteractionData.cs index 99b9b6f6c..039b6b41f 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IComponentInteractionData.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IComponentInteractionData.cs @@ -8,7 +8,7 @@ namespace Discord public interface IComponentInteractionData : IDiscordInteractionData { /// - /// Gets the components Custom Id that was clicked. + /// Gets the component's Custom Id that was clicked. /// string CustomId { get; } @@ -21,5 +21,10 @@ namespace Discord /// Gets the value(s) of a interaction response. /// IReadOnlyCollection Values { get; } + + /// + /// Gets the value of a interaction response. + /// + public string Value { get; } } } diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputComponent.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputComponent.cs new file mode 100644 index 000000000..d159df071 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputComponent.cs @@ -0,0 +1,62 @@ +namespace Discord +{ + /// + /// Respresents a text input. + /// + public class TextInputComponent : IMessageComponent + { + /// + public ComponentType Type => ComponentType.TextInput; + + /// + public string CustomId { get; } + + /// + /// Gets the label of the component; this is the text shown above it. + /// + public string Label { get; } + + /// + /// Gets the placeholder of the component. + /// + public string Placeholder { get; } + + /// + /// Gets the minimum length of the inputted text. + /// + public int? MinLength { get; } + + /// + /// Gets the maximum length of the inputted text. + /// + public int? MaxLength { get; } + + /// + /// Gets the style of the component. + /// + public TextInputStyle Style { get; } + + /// + /// Gets whether users are required to input text. + /// + public bool? Required { get; } + + /// + /// Gets the default value of the component. + /// + public string Value { get; } + + internal TextInputComponent(string customId, string label, string placeholder, int? minLength, int? maxLength, + TextInputStyle style, bool? required, string value) + { + CustomId = customId; + Label = label; + Placeholder = placeholder; + MinLength = minLength; + MaxLength = maxLength; + Style = style; + Required = required; + Value = value; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputStyle.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputStyle.cs new file mode 100644 index 000000000..72ea59b22 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputStyle.cs @@ -0,0 +1,14 @@ +namespace Discord +{ + public enum TextInputStyle + { + /// + /// Intended for short, single-line text. + /// + Short = 1, + /// + /// Intended for longer or multiline text. + /// + Paragraph = 2, + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteraction.cs new file mode 100644 index 000000000..5ce153845 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteraction.cs @@ -0,0 +1,13 @@ +namespace Discord +{ + /// + /// Represents an interaction type for Modals. + /// + public interface IModalInteraction : IDiscordInteraction + { + /// + /// Gets the data received with this interaction; contains the clicked button. + /// + new IModalInteractionData Data { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteractionData.cs b/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteractionData.cs new file mode 100644 index 000000000..767dd5df7 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteractionData.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents the data sent with the . + /// + public interface IModalInteractionData : IDiscordInteractionData + { + /// + /// Gets the 's Custom Id. + /// + string CustomId { get; } + + /// + /// Gets the components submitted by the user. + /// + IReadOnlyCollection Components { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/Modals/Modal.cs b/src/Discord.Net.Core/Entities/Interactions/Modals/Modal.cs new file mode 100644 index 000000000..a0fde5ea3 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/Modals/Modal.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a modal interaction. + /// + public class Modal : IMessageComponent + { + /// + public ComponentType Type => ComponentType.ModalSubmit; + + /// + /// Gets the title of the modal. + /// + public string Title { get; set; } + + /// + public string CustomId { get; set; } + + /// + /// Gets the components in the modal. + /// + public ModalComponent Component { get; set; } + + internal Modal(string title, string customId, ModalComponent components) + { + Title = title; + CustomId = customId; + Component = components; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs new file mode 100644 index 000000000..3a3e3cc49 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + public class ModalBuilder + { + /// + /// Gets or sets the components of the current modal. + /// + public ModalComponentBuilder Components { get; set; } = new(); + + /// + /// Gets or sets the title of the current modal. + /// + public string Title { get; set; } + + /// + /// Gets or sets the custom id of the current modal. + /// + public string CustomId + { + get => _customId; + set => _customId = value?.Length switch + { + > ComponentBuilder.MaxCustomIdLength => throw new ArgumentOutOfRangeException(nameof(value), $"Custom Id length must be less or equal to {ComponentBuilder.MaxCustomIdLength}."), + 0 => throw new ArgumentOutOfRangeException(nameof(value), "Custom Id length must be at least 1."), + _ => value + }; + } + + private string _customId; + + public ModalBuilder() { } + + /// + /// Creates a new instance of a + /// + /// The modal's title. + /// The modal's customId. + /// The modal's components. + /// Only TextInputComponents are allowed. + public ModalBuilder(string title, string customId, ModalComponentBuilder components = null) + { + Title = title; + CustomId = customId; + Components = components ?? new(); + } + + /// + /// Sets the title of the current modal. + /// + /// The value to set the title to. + /// The current builder. + public ModalBuilder WithTitle(string title) + { + Title = title; + return this; + } + + /// + /// Sets the custom id of the current modal. + /// + /// The value to set the custom id to. + /// The current builder. + public ModalBuilder WithCustomId(string customId) + { + CustomId = customId; + return this; + } + + /// + /// Adds a component to the current builder. + /// + /// The component to add. + /// The current builder. + public ModalBuilder AddTextInput(TextInputBuilder component) + { + Components.WithTextInput(component); + return this; + } + + /// + /// Adds a to the current builder. + /// + /// The input's custom id. + /// The input's label. + /// The input's placeholder text. + /// The input's minimum length. + /// The input's maximum length. + /// The input's style. + /// The current builder. + public ModalBuilder AddTextInput(string label, string customId, TextInputStyle style = TextInputStyle.Short, + string placeholder = "", int? minLength = null, int? maxLength = null, bool? required = null, string value = null) + => AddTextInput(new(label, customId, style, placeholder, minLength, maxLength, required, value)); + + /// + /// Adds multiple components to the current builder. + /// + /// The components to add. + /// The current builder + public ModalBuilder AddComponents(List components, int row) + { + components.ForEach(x => Components.AddComponent(x, row)); + return this; + } + + /// + /// Builds this builder into a . + /// + /// A with the same values as this builder. + /// Only TextInputComponents are allowed. + /// Modals must have a custom id. + /// Modals must have a title. + public Modal Build() + { + if (string.IsNullOrEmpty(CustomId)) + throw new ArgumentException("Modals must have a custom id.", nameof(CustomId)); + if (string.IsNullOrWhiteSpace(Title)) + throw new ArgumentException("Modals must have a title.", nameof(Title)); + if (Components.ActionRows?.SelectMany(x => x.Components).Any(x => x.Type != ComponentType.TextInput) ?? false) + throw new ArgumentException($"Only TextInputComponents are allowed.", nameof(Components)); + + return new(Title, CustomId, Components.Build()); + } + } + + /// + /// Represents a builder for creating a . + /// + public class ModalComponentBuilder + { + /// + /// The max length of a . + /// + public const int MaxCustomIdLength = 100; + + /// + /// The max amount of rows a can have. + /// + public const int MaxActionRowCount = 5; + + /// + /// Gets or sets the Action Rows for this Component Builder. + /// + /// cannot be null. + /// count exceeds . + public List ActionRows + { + get => _actionRows; + set + { + if (value == null) + throw new ArgumentNullException(nameof(value), $"{nameof(ActionRows)} cannot be null."); + if (value.Count > MaxActionRowCount) + throw new ArgumentOutOfRangeException(nameof(value), $"Action row count must be less than or equal to {MaxActionRowCount}."); + _actionRows = value; + } + } + + private List _actionRows; + + /// + /// Creates a new builder from the provided list of components. + /// + /// The components to create the builder from. + /// The newly created builder. + public static ComponentBuilder FromComponents(IReadOnlyCollection components) + { + var builder = new ComponentBuilder(); + for (int i = 0; i != components.Count; i++) + { + var component = components.ElementAt(i); + builder.AddComponent(component, i); + } + return builder; + } + + internal void AddComponent(IMessageComponent component, int row) + { + switch (component) + { + case TextInputComponent text: + WithTextInput(text.Label, text.CustomId, text.Style, text.Placeholder, text.MinLength, text.MaxLength, row); + break; + case ActionRowComponent actionRow: + foreach (var cmp in actionRow.Components) + AddComponent(cmp, row); + break; + } + } + + /// + /// Adds a to the at the specific row. + /// If the row cannot accept the component then it will add it to a row that can. + /// + /// The input's custom id. + /// The input's label. + /// The input's placeholder text. + /// The input's minimum length. + /// The input's maximum length. + /// The input's style. + /// The current builder. + public ModalComponentBuilder WithTextInput(string label, string customId, TextInputStyle style = TextInputStyle.Short, + string placeholder = null, int? minLength = null, int? maxLength = null, int row = 0, bool? required = null, + string value = null) + => WithTextInput(new(label, customId, style, placeholder, minLength, maxLength, required, value), row); + + /// + /// Adds a to the at the specific row. + /// If the row cannot accept the component then it will add it to a row that can. + /// + /// The to add. + /// The row to add the text input. + /// There are no more rows to add a text input to. + /// must be less than . + /// The current builder. + public ModalComponentBuilder WithTextInput(TextInputBuilder text, int row = 0) + { + Preconditions.LessThan(row, MaxActionRowCount, nameof(row)); + + var builtButton = text.Build(); + + if (_actionRows == null) + { + _actionRows = new List + { + new ActionRowBuilder().AddComponent(builtButton) + }; + } + else + { + if (_actionRows.Count == row) + _actionRows.Add(new ActionRowBuilder().AddComponent(builtButton)); + else + { + ActionRowBuilder actionRow; + if (_actionRows.Count > row) + actionRow = _actionRows.ElementAt(row); + else + { + actionRow = new ActionRowBuilder(); + _actionRows.Add(actionRow); + } + + if (actionRow.CanTakeComponent(builtButton)) + actionRow.AddComponent(builtButton); + else if (row < MaxActionRowCount) + WithTextInput(text, row + 1); + else + throw new InvalidOperationException($"There are no more rows to add {nameof(text)} to."); + } + } + + return this; + } + + /// + /// Get a representing the builder. + /// + /// A representing the builder. + public ModalComponent Build() + => new (ActionRows?.Select(x => x.Build()).ToList()); + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/Modals/ModalComponent.cs b/src/Discord.Net.Core/Entities/Interactions/Modals/ModalComponent.cs new file mode 100644 index 000000000..ecc90720f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/Modals/ModalComponent.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents a component object used in s. + /// + public class ModalComponent + { + /// + /// Gets the components to be used in a modal. + /// + public IReadOnlyCollection Components { get; } + + internal ModalComponent(List components) + { + Components = components; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Commands/ModalInteractionAttribute.cs b/src/Discord.Net.Interactions/Attributes/Commands/ModalInteractionAttribute.cs new file mode 100644 index 000000000..a0ce91cda --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Commands/ModalInteractionAttribute.cs @@ -0,0 +1,44 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Create a Modal interaction handler. CustomId represents + /// the CustomId of the Modal that will be handled. + /// + /// + /// s will add prefixes to this command if is set to + /// CustomID supports a Wild Card pattern where you can use the to match a set of CustomIDs. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public sealed class ModalInteractionAttribute : Attribute + { + /// + /// Gets the string to compare the Modal CustomIDs with. + /// + public string CustomId { get; } + + /// + /// Gets if s will be ignored while creating this command and this method will be treated as a top level command. + /// + public bool IgnoreGroupNames { get; } + + /// + /// Gets the run mode this command gets executed with. + /// + public RunMode RunMode { get; } + + /// + /// Create a command for modal interaction handling. + /// + /// String to compare the modal CustomIDs with. + /// If s will be ignored while creating this command and this method will be treated as a top level command. + /// Set the run mode of the command. + public ModalInteractionAttribute(string customId, bool ignoreGroupNames = false, RunMode runMode = RunMode.Default) + { + CustomId = customId; + IgnoreGroupNames = ignoreGroupNames; + RunMode = runMode; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/InputLabelAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/InputLabelAttribute.cs new file mode 100644 index 000000000..fdeb8c414 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/InputLabelAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Creates a custom label for an modal input. + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public class InputLabelAttribute : Attribute + { + /// + /// Gets the label of the input. + /// + public string Label { get; } + + /// + /// Creates a custom label for an modal input. + /// + /// The label of the input. + public InputLabelAttribute(string label) + { + Label = label; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalInputAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalInputAttribute.cs new file mode 100644 index 000000000..d611b574d --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalInputAttribute.cs @@ -0,0 +1,32 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Mark an property as a modal input field. + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] + public abstract class ModalInputAttribute : Attribute + { + /// + /// Gets the custom id of the text input. + /// + public string CustomId { get; } + + /// + /// Gets the type of the component. + /// + public abstract ComponentType ComponentType { get; } + + /// + /// Create a new . + /// + /// The label of the input. + /// The custom id of the input. + /// Whether the user is required to input a value.> + protected ModalInputAttribute(string customId) + { + CustomId = customId; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalTextInputAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalTextInputAttribute.cs new file mode 100644 index 000000000..35121cd6b --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalTextInputAttribute.cs @@ -0,0 +1,55 @@ +namespace Discord.Interactions +{ + /// + /// Marks a property as a text input. + /// + public sealed class ModalTextInputAttribute : ModalInputAttribute + { + /// + public override ComponentType ComponentType => ComponentType.TextInput; + + /// + /// Gets the style of the text input. + /// + public TextInputStyle Style { get; } + + /// + /// Gets the placeholder of the text input. + /// + public string Placeholder { get; } + + /// + /// Gets the minimum length of the text input. + /// + public int MinLength { get; } + + /// + /// Gets the maximum length of the text input. + /// + public int MaxLength { get; } + + /// + /// Gets the initial value to be displayed by this input. + /// + public string InitialValue { get; } + + /// + /// Create a new . + /// + /// + /// The style of the text input. + /// The placeholder of the text input. + /// The minimum length of the text input's content. + /// The maximum length of the text input's content. + /// The initial value to be displayed by this input. + public ModalTextInputAttribute(string customId, TextInputStyle style = TextInputStyle.Short, string placeholder = null, int minLength = 1, int maxLength = 4000, string initValue = null) + : base(customId) + { + Style = style; + Placeholder = placeholder; + MinLength = minLength; + MaxLength = maxLength; + InitialValue = initValue; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/RequiredInputAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/RequiredInputAttribute.cs new file mode 100644 index 000000000..e3cab3340 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/RequiredInputAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Sets the input as required or optional. + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public class RequiredInputAttribute : Attribute + { + /// + /// Gets whether or not user input is required for this input. + /// + public bool IsRequired { get; } + + /// + /// Sets the input as required or optinal. + /// + /// Whether or not user input is required for this input. + public RequiredInputAttribute(bool isRequired = true) + { + IsRequired = isRequired; + } + } +} diff --git a/src/Discord.Net.Interactions/Builders/Commands/ModalCommandBuilder.cs b/src/Discord.Net.Interactions/Builders/Commands/ModalCommandBuilder.cs new file mode 100644 index 000000000..dfc76c686 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Commands/ModalCommandBuilder.cs @@ -0,0 +1,44 @@ +using System; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating a . + /// + public class ModalCommandBuilder : CommandBuilder + { + protected override ModalCommandBuilder Instance => this; + + /// + /// Initializes a new . + /// + /// Parent module of this modal. + public ModalCommandBuilder(ModuleBuilder module) : base(module) { } + + /// + /// Initializes a new . + /// + /// Parent module of this modal. + /// Name of this modal. + /// Execution callback of this modal. + public ModalCommandBuilder(ModuleBuilder module, string name, ExecuteCallback callback) : base(module, name, callback) { } + + /// + /// Adds a modal parameter to the parameters collection. + /// + /// factory. + /// + /// The builder instance. + /// + public override ModalCommandBuilder AddParameter(Action configure) + { + var parameter = new ModalCommandParameterBuilder(this); + configure(parameter); + AddParameters(parameter); + return this; + } + + internal override ModalCommandInfo Build(ModuleInfo module, InteractionService commandService) => + new(this, module, commandService); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/IInputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/IInputComponentBuilder.cs new file mode 100644 index 000000000..37cd861c4 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/IInputComponentBuilder.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders +{ + /// + /// Represent a builder for creating . + /// + public interface IInputComponentBuilder + { + /// + /// Gets the parent modal of this input component. + /// + ModalBuilder Modal { get; } + + /// + /// Gets the custom id of this input component. + /// + string CustomId { get; } + + /// + /// Gets the label of this input component. + /// + string Label { get; } + + /// + /// Gets whether this input component is required. + /// + bool IsRequired { get; } + + /// + /// Gets the component type of this input component. + /// + ComponentType ComponentType { get; } + + /// + /// Get the reference type of this input component. + /// + Type Type { get; } + + /// + /// Gets the default value of this input component. + /// + object DefaultValue { get; } + + /// + /// Gets a collection of the attributes of this component. + /// + IReadOnlyCollection Attributes { get; } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IInputComponentBuilder WithCustomId(string customId); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IInputComponentBuilder WithLabel(string label); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IInputComponentBuilder SetIsRequired(bool isRequired); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IInputComponentBuilder WithType(Type type); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IInputComponentBuilder SetDefaultValue(object value); + + /// + /// Adds attributes to . + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + IInputComponentBuilder WithAttributes(params Attribute[] attributes); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/InputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/InputComponentBuilder.cs new file mode 100644 index 000000000..c2b9b0645 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/InputComponentBuilder.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents the base builder class for creating . + /// + /// The this builder yields when built. + /// Inherited type. + public abstract class InputComponentBuilder : IInputComponentBuilder + where TInfo : InputComponentInfo + where TBuilder : InputComponentBuilder + { + private readonly List _attributes; + protected abstract TBuilder Instance { get; } + + /// + public ModalBuilder Modal { get; } + + /// + public string CustomId { get; set; } + + /// + public string Label { get; set; } + + /// + public bool IsRequired { get; set; } = true; + + /// + public ComponentType ComponentType { get; internal set; } + + /// + public Type Type { get; private set; } + + /// + public object DefaultValue { get; set; } + + /// + public IReadOnlyCollection Attributes => _attributes; + + /// + /// Creates an instance of + /// + /// Parent modal of this input component. + public InputComponentBuilder(ModalBuilder modal) + { + Modal = modal; + _attributes = new(); + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder WithCustomId(string customId) + { + CustomId = customId; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder WithLabel(string label) + { + Label = label; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder SetIsRequired(bool isRequired) + { + IsRequired = isRequired; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder WithComponentType(ComponentType componentType) + { + ComponentType = componentType; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder WithType(Type type) + { + Type = type; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder SetDefaultValue(object value) + { + DefaultValue = value; + return Instance; + } + + /// + /// Adds attributes to . + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + public TBuilder WithAttributes(params Attribute[] attributes) + { + _attributes.AddRange(attributes); + return Instance; + } + + internal abstract TInfo Build(ModalInfo modal); + + //IInputComponentBuilder + /// + IInputComponentBuilder IInputComponentBuilder.WithCustomId(string customId) => WithCustomId(customId); + + /// + IInputComponentBuilder IInputComponentBuilder.WithLabel(string label) => WithCustomId(label); + + /// + IInputComponentBuilder IInputComponentBuilder.WithType(Type type) => WithType(type); + + /// + IInputComponentBuilder IInputComponentBuilder.SetDefaultValue(object value) => SetDefaultValue(value); + + /// + IInputComponentBuilder IInputComponentBuilder.WithAttributes(params Attribute[] attributes) => WithAttributes(attributes); + + /// + IInputComponentBuilder IInputComponentBuilder.SetIsRequired(bool isRequired) => SetIsRequired(isRequired); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs new file mode 100644 index 000000000..340119ddd --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs @@ -0,0 +1,109 @@ +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public class TextInputComponentBuilder : InputComponentBuilder + { + protected override TextInputComponentBuilder Instance => this; + + /// + /// Gets and sets the style of the text input. + /// + public TextInputStyle Style { get; set; } + + /// + /// Gets and sets the placeholder of the text input. + /// + public string Placeholder { get; set; } + + /// + /// Gets and sets the minimum length of the text input. + /// + public int MinLength { get; set; } + + /// + /// Gets and sets the maximum length of the text input. + /// + public int MaxLength { get; set; } + + /// + /// Gets and sets the initial value to be displayed by this input. + /// + public string InitialValue { get; set; } + + /// + /// Initializes a new . + /// + /// Parent modal of this component. + public TextInputComponentBuilder(ModalBuilder modal) : base(modal) { } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TextInputComponentBuilder WithStyle(TextInputStyle style) + { + Style = style; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TextInputComponentBuilder WithPlaceholder(string placeholder) + { + Placeholder = placeholder; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TextInputComponentBuilder WithMinLenght(int minLenght) + { + MinLength = minLenght; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TextInputComponentBuilder WithMaxLenght(int maxLenght) + { + MaxLength = maxLenght; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TextInputComponentBuilder WithInitialValue(string value) + { + InitialValue = value; + return this; + } + + internal override TextInputComponentInfo Build(ModalInfo modal) => + new(this, modal); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs new file mode 100644 index 000000000..e120e78be --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public class ModalBuilder + { + internal readonly List _components; + + /// + /// Gets the initialization delegate for this modal. + /// + public ModalInitializer ModalInitializer { get; internal set; } + + /// + /// Gets the title of this modal. + /// + public string Title { get; set; } + + /// + /// Gets the implementation used to initialize this object. + /// + public Type Type { get; } + + /// + /// Gets a collection of the components of this modal. + /// + public IReadOnlyCollection Components => _components; + + internal ModalBuilder(Type type) + { + if (!typeof(IModal).IsAssignableFrom(type)) + throw new ArgumentException($"Must be an implementation of {nameof(IModal)}", nameof(type)); + + _components = new(); + } + + /// + /// Initializes a new + /// + /// The initialization delegate for this modal. + public ModalBuilder(Type type, ModalInitializer modalInitializer) : this(type) + { + ModalInitializer = modalInitializer; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ModalBuilder WithTitle(string title) + { + Title = title; + return this; + } + + /// + /// Adds text components to . + /// + /// Text Component builder factory. + /// + /// The builder instance. + /// + public ModalBuilder AddTextComponent(Action configure) + { + var builder = new TextInputComponentBuilder(this); + configure(builder); + _components.Add(builder); + return this; + } + + internal ModalInfo Build() => new(this); + } +} diff --git a/src/Discord.Net.Interactions/Builders/ModuleBuilder.cs b/src/Discord.Net.Interactions/Builders/ModuleBuilder.cs index 036964778..40c263643 100644 --- a/src/Discord.Net.Interactions/Builders/ModuleBuilder.cs +++ b/src/Discord.Net.Interactions/Builders/ModuleBuilder.cs @@ -16,6 +16,7 @@ namespace Discord.Interactions.Builders private readonly List _contextCommands; private readonly List _componentCommands; private readonly List _autocompleteCommands; + private readonly List _modalCommands; /// /// Gets the underlying Interaction Service. @@ -92,6 +93,11 @@ namespace Discord.Interactions.Builders /// public IReadOnlyList AutocompleteCommands => _autocompleteCommands; + /// + /// Gets a collection of the Modal Commands of this module. + /// + public IReadOnlyList ModalCommands => _modalCommands; + internal TypeInfo TypeInfo { get; set; } internal ModuleBuilder (InteractionService interactionService, ModuleBuilder parent = null) @@ -105,6 +111,7 @@ namespace Discord.Interactions.Builders _contextCommands = new List(); _componentCommands = new List(); _autocompleteCommands = new List(); + _modalCommands = new List (); _preconditions = new List(); } @@ -152,7 +159,7 @@ namespace Discord.Interactions.Builders /// /// The builder instance. /// - public ModuleBuilder WithDefaultPermision (bool permission) + public ModuleBuilder WithDefaultPermission (bool permission) { DefaultPermission = permission; return this; @@ -310,6 +317,21 @@ namespace Discord.Interactions.Builders configure(command); _autocompleteCommands.Add(command); return this; + + } + + /// Adds a modal command builder to . + /// + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddModalCommand(Action configure) + { + var command = new ModalCommandBuilder(this); + configure(command); + _modalCommands.Add(command); + return this; } /// diff --git a/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs b/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs index 071c68349..6615f131c 100644 --- a/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs +++ b/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs @@ -103,6 +103,7 @@ namespace Discord.Interactions.Builders var validContextCommands = methods.Where(IsValidContextCommandDefinition); var validInteractions = methods.Where(IsValidComponentCommandDefinition); var validAutocompleteCommands = methods.Where(IsValidAutocompleteCommandDefinition); + var validModalCommands = methods.Where(IsValidModalCommanDefinition); Func createInstance = commandService._useCompiledLambda ? ReflectionUtils.CreateLambdaBuilder(typeInfo, commandService) : ReflectionUtils.CreateBuilder(typeInfo, commandService); @@ -118,6 +119,9 @@ namespace Discord.Interactions.Builders foreach(var method in validAutocompleteCommands) builder.AddAutocompleteCommand(x => BuildAutocompleteCommand(x, createInstance, method, commandService, services)); + + foreach(var method in validModalCommands) + builder.AddModalCommand(x => BuildModalCommand(x, createInstance, method, commandService, services)); } private static void BuildSubModules (ModuleBuilder parent, IEnumerable subModules, IList builtTypes, InteractionService commandService, @@ -298,6 +302,47 @@ namespace Discord.Interactions.Builders builder.Callback = CreateCallback(createInstance, methodInfo, commandService); } + private static void BuildModalCommand(ModalCommandBuilder builder, Func createInstance, MethodInfo methodInfo, + InteractionService commandService, IServiceProvider services) + { + var parameters = methodInfo.GetParameters(); + + if (parameters.Count(x => typeof(IModal).IsAssignableFrom(x.ParameterType)) > 1) + throw new InvalidOperationException($"A modal command can only have one {nameof(IModal)} parameter."); + + if (!parameters.All(x => x.ParameterType == typeof(string) || typeof(IModal).IsAssignableFrom(x.ParameterType))) + throw new InvalidOperationException($"All parameters of a modal command must be either a string or an implementation of {nameof(IModal)}"); + + var attributes = methodInfo.GetCustomAttributes(); + + builder.MethodName = methodInfo.Name; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case ModalInteractionAttribute modal: + { + builder.Name = modal.CustomId; + builder.RunMode = modal.RunMode; + builder.IgnoreGroupNames = modal.IgnoreGroupNames; + } + break; + case PreconditionAttribute precondition: + builder.WithPreconditions(precondition); + break; + default: + builder.WithAttributes(attribute); + break; + } + } + + foreach (var parameter in parameters) + builder.AddParameter(x => BuildParameter(x, parameter)); + + builder.Callback = CreateCallback(createInstance, methodInfo, commandService); + } + private static ExecuteCallback CreateCallback (Func createInstance, MethodInfo methodInfo, InteractionService commandService) { @@ -400,7 +445,9 @@ namespace Discord.Interactions.Builders builder.Name = Regex.Replace(builder.Name, "(?<=[a-z])(?=[A-Z])", "-").ToLower(); } - private static void BuildParameter (CommandParameterBuilder builder, ParameterInfo paramInfo) + private static void BuildParameter (ParameterBuilder builder, ParameterInfo paramInfo) + where TInfo : class, IParameterInfo + where TBuilder : ParameterBuilder { var attributes = paramInfo.GetCustomAttributes(); var paramType = paramInfo.ParameterType; @@ -428,6 +475,84 @@ namespace Discord.Interactions.Builders } #endregion + #region Modals + public static ModalInfo BuildModalInfo(Type modalType) + { + if (!typeof(IModal).IsAssignableFrom(modalType)) + throw new InvalidOperationException($"{modalType.FullName} isn't an implementation of {typeof(IModal).FullName}"); + + var instance = Activator.CreateInstance(modalType, false) as IModal; + + try + { + var builder = new ModalBuilder(modalType) + { + Title = instance.Title + }; + + var inputs = modalType.GetProperties().Where(IsValidModalInputDefinition); + + foreach (var prop in inputs) + { + var componentType = prop.GetCustomAttribute()?.ComponentType; + + switch (componentType) + { + case ComponentType.TextInput: + builder.AddTextComponent(x => BuildTextInput(x, prop, prop.GetValue(instance))); + break; + case null: + throw new InvalidOperationException($"{prop.Name} of {prop.DeclaringType.Name} isn't a valid modal input field."); + default: + throw new InvalidOperationException($"Component type {componentType} cannot be used in modals."); + } + } + + var memberInit = ReflectionUtils.CreateLambdaMemberInit(modalType.GetTypeInfo(), modalType.GetConstructor(Type.EmptyTypes), x => x.IsDefined(typeof(ModalInputAttribute))); + builder.ModalInitializer = (args) => memberInit(Array.Empty(), args); + return builder.Build(); + } + finally + { + (instance as IDisposable)?.Dispose(); + } + } + + private static void BuildTextInput(TextInputComponentBuilder builder, PropertyInfo propertyInfo, object defaultValue) + { + var attributes = propertyInfo.GetCustomAttributes(); + + builder.Label = propertyInfo.Name; + builder.DefaultValue = defaultValue; + builder.WithType(propertyInfo.PropertyType); + + foreach(var attribute in attributes) + { + switch (attribute) + { + case ModalTextInputAttribute textInput: + builder.CustomId = textInput.CustomId; + builder.ComponentType = textInput.ComponentType; + builder.Style = textInput.Style; + builder.Placeholder = textInput.Placeholder; + builder.MaxLength = textInput.MaxLength; + builder.MinLength = textInput.MinLength; + builder.InitialValue = textInput.InitialValue; + break; + case RequiredInputAttribute requiredInput: + builder.IsRequired = requiredInput.IsRequired; + break; + case InputLabelAttribute inputLabel: + builder.Label = inputLabel.Label; + break; + default: + builder.WithAttributes(attribute); + break; + } + } + } + #endregion + internal static bool IsValidModuleDefinition (TypeInfo typeInfo) { return ModuleTypeInfo.IsAssignableFrom(typeInfo) && @@ -467,5 +592,21 @@ namespace Discord.Interactions.Builders !methodInfo.IsGenericMethod && methodInfo.GetParameters().Length == 0; } + + private static bool IsValidModalCommanDefinition(MethodInfo methodInfo) + { + return methodInfo.IsDefined(typeof(ModalInteractionAttribute)) && + (methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task)) && + !methodInfo.IsStatic && + !methodInfo.IsGenericMethod && + typeof(IModal).IsAssignableFrom(methodInfo.GetParameters().Last().ParameterType); + } + + private static bool IsValidModalInputDefinition(PropertyInfo propertyInfo) + { + return propertyInfo.SetMethod?.IsPublic == true && + propertyInfo.SetMethod?.IsStatic == false && + propertyInfo.IsDefined(typeof(ModalInputAttribute)); + } } } diff --git a/src/Discord.Net.Interactions/Builders/Parameters/ModalCommandParameterBuilder.cs b/src/Discord.Net.Interactions/Builders/Parameters/ModalCommandParameterBuilder.cs new file mode 100644 index 000000000..a0315e1ea --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Parameters/ModalCommandParameterBuilder.cs @@ -0,0 +1,45 @@ +using System; + +namespace Discord.Interactions.Builders +{ + + /// + /// Represents a builder for creating . + /// + public class ModalCommandParameterBuilder : ParameterBuilder + { + protected override ModalCommandParameterBuilder Instance => this; + + /// + /// Gets the built class for this parameter, if is . + /// + public ModalInfo Modal { get; private set; } + + /// + /// Gets whether or not this parameter is an . + /// + public bool IsModalParameter => Modal is not null; + + internal ModalCommandParameterBuilder(ICommandBuilder command) : base(command) { } + + /// + /// Initializes a new . + /// + /// Parent command of this parameter. + /// Name of this command. + /// Type of this parameter. + public ModalCommandParameterBuilder(ICommandBuilder command, string name, Type type) : base(command, name, type) { } + + /// + public override ModalCommandParameterBuilder SetParameterType(Type type) + { + if (typeof(IModal).IsAssignableFrom(type)) + Modal = ModalUtils.GetOrAdd(type); + + return base.SetParameterType(type); + } + + internal override ModalCommandParameterInfo Build(ICommandInfo command) => + new(this, command); + } +} diff --git a/src/Discord.Net.Interactions/Entities/IModal.cs b/src/Discord.Net.Interactions/Entities/IModal.cs new file mode 100644 index 000000000..572a88033 --- /dev/null +++ b/src/Discord.Net.Interactions/Entities/IModal.cs @@ -0,0 +1,13 @@ +namespace Discord.Interactions +{ + /// + /// Represents a generic for use with the interaction service. + /// + public interface IModal + { + /// + /// Gets the modal's title. + /// + string Title { get; } + } +} diff --git a/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs b/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs new file mode 100644 index 000000000..5c379cf42 --- /dev/null +++ b/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + public static class IDiscordInteractionExtentions + { + /// + /// Respond to an interaction with a . + /// + /// Type of the implementation. + /// The interaction to respond to. + /// The request options for this request. + /// A task that represents the asynchronous operation of responding to the interaction. + public static async Task RespondWithModalAsync(this IDiscordInteraction interaction, string customId, RequestOptions options = 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(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"); + } + + await interaction.RespondWithModalAsync(builder.Build(), options).ConfigureAwait(false); + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs index 91fe2dbf9..0e43af3a8 100644 --- a/src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs +++ b/src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs @@ -35,7 +35,7 @@ namespace Discord.Interactions /// Services that will be used while initializing the . /// Provide additional string parameters to the method along with the auto generated parameters. /// - /// A task representing the asyncronous command execution process. + /// A task representing the asynchronous command execution process. /// public async Task ExecuteAsync(IInteractionContext context, IServiceProvider services, params string[] additionalArgs) { diff --git a/src/Discord.Net.Interactions/Info/Commands/ModalCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/ModalCommandInfo.cs new file mode 100644 index 000000000..a750603fc --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/ModalCommandInfo.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +namespace Discord.Interactions +{ + /// + /// Represents the info class of an attribute based method for handling Modal Interaction events. + /// + public class ModalCommandInfo : CommandInfo + { + /// + /// Gets the class for this commands parameter. + /// + public ModalInfo Modal { get; } + + /// + public override bool SupportsWildCards => true; + + /// + public override IReadOnlyCollection Parameters { get; } + + internal ModalCommandInfo(Builders.ModalCommandBuilder builder, ModuleInfo module, InteractionService commandService) : base(builder, module, commandService) + { + Parameters = builder.Parameters.Select(x => x.Build(this)).ToImmutableArray(); + Modal = Parameters.Last().Modal; + } + + /// + public override async Task ExecuteAsync(IInteractionContext context, IServiceProvider services) + => await ExecuteAsync(context, services, null).ConfigureAwait(false); + + /// + /// Execute this command using dependency injection. + /// + /// Context that will be injected to the . + /// Services that will be used while initializing the . + /// Provide additional string parameters to the method along with the auto generated parameters. + /// + /// A task representing the asynchronous command execution process. + /// + public async Task ExecuteAsync(IInteractionContext context, IServiceProvider services, params string[] additionalArgs) + { + if (context.Interaction is not IModalInteraction modalInteraction) + return ExecuteResult.FromError(InteractionCommandError.ParseFailed, $"Provided {nameof(IInteractionContext)} doesn't belong to a Modal Interaction."); + + try + { + var args = new List(); + + if (additionalArgs is not null) + args.AddRange(additionalArgs); + + var modal = Modal.CreateModal(modalInteraction, Module.CommandService._exitOnMissingModalField); + args.Add(modal); + + return await RunAsync(context, args.ToArray(), services); + } + catch (Exception ex) + { + var result = ExecuteResult.FromError(ex); + await InvokeModuleEvent(context, result).ConfigureAwait(false); + return result; + } + } + + /// + protected override Task InvokeModuleEvent(IInteractionContext context, IResult result) + => CommandService._modalCommandExecutedEvent.InvokeAsync(this, context, result); + + /// + protected override string GetLogString(IInteractionContext context) + { + if (context.Guild != null) + return $"Modal Command: \"{base.ToString()}\" for {context.User} in {context.Guild}/{context.Channel}"; + else + return $"Modal Command: \"{base.ToString()}\" for {context.User} in {context.Channel}"; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/InputComponents/InputComponentInfo.cs b/src/Discord.Net.Interactions/Info/InputComponents/InputComponentInfo.cs new file mode 100644 index 000000000..790838ad9 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/InputComponents/InputComponentInfo.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Interactions +{ + /// + /// Represents the base info class for input components. + /// + public abstract class InputComponentInfo + { + /// + /// Gets the parent modal of this component. + /// + public ModalInfo Modal { get; } + + /// + /// Gets the custom id of this component. + /// + public string CustomId { get; } + + /// + /// Gets the label of this component. + /// + public string Label { get; } + + /// + /// Gets whether or not this component requires a user input. + /// + public bool IsRequired { get; } + + /// + /// Gets the type of this component. + /// + public ComponentType ComponentType { get; } + + /// + /// Gets the reference type of this component. + /// + public Type Type { get; } + + /// + /// Gets the default value of this component. + /// + public object DefaultValue { get; } + + /// + /// Gets a collection of the attributes of this command. + /// + public IReadOnlyCollection Attributes { get; } + + protected InputComponentInfo(Builders.IInputComponentBuilder builder, ModalInfo modal) + { + Modal = modal; + CustomId = builder.CustomId; + Label = builder.Label; + IsRequired = builder.IsRequired; + ComponentType = builder.ComponentType; + Type = builder.Type; + DefaultValue = builder.DefaultValue; + Attributes = builder.Attributes.ToImmutableArray(); + } + } +} diff --git a/src/Discord.Net.Interactions/Info/InputComponents/TextInputComponentInfo.cs b/src/Discord.Net.Interactions/Info/InputComponents/TextInputComponentInfo.cs new file mode 100644 index 000000000..613549fe8 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/InputComponents/TextInputComponentInfo.cs @@ -0,0 +1,42 @@ +namespace Discord.Interactions +{ + /// + /// Represents the class for type. + /// + public class TextInputComponentInfo : InputComponentInfo + { + /// + /// Gets the style of the text input. + /// + public TextInputStyle Style { get; } + + /// + /// Gets the placeholder of the text input. + /// + public string Placeholder { get; } + + /// + /// Gets the minimum length of the text input. + /// + public int MinLength { get; } + + /// + /// Gets the maximum length of the text input. + /// + public int MaxLength { get; } + + /// + /// Gets the initial value to be displayed by this input. + /// + public string InitialValue { get; } + + internal TextInputComponentInfo(Builders.TextInputComponentBuilder builder, ModalInfo modal) : base(builder, modal) + { + Style = builder.Style; + Placeholder = builder.Placeholder; + MinLength = builder.MinLength; + MaxLength = builder.MaxLength; + InitialValue = builder.InitialValue; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/ModalInfo.cs b/src/Discord.Net.Interactions/Info/ModalInfo.cs new file mode 100644 index 000000000..edc31373e --- /dev/null +++ b/src/Discord.Net.Interactions/Info/ModalInfo.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Interactions +{ + /// + /// Represents a cached object initialization delegate. + /// + /// Property arguments array. + /// + /// Returns the constructed object. + /// + public delegate IModal ModalInitializer(object[] args); + + /// + /// Represents the info class of an form. + /// + public class ModalInfo + { + internal readonly ModalInitializer _initializer; + + /// + /// Gets the title of this modal. + /// + public string Title { get; } + + /// + /// Gets the implementation used to initialize this object. + /// + public Type Type { get; } + + /// + /// Gets a collection of the components of this modal. + /// + public IReadOnlyCollection Components { get; } + + /// + /// Gets a collection of the text components of this modal. + /// + public IReadOnlyCollection TextComponents { get; } + + internal ModalInfo(Builders.ModalBuilder builder) + { + Title = builder.Title; + Type = builder.Type; + Components = builder.Components.Select(x => x switch + { + Builders.TextInputComponentBuilder textComponent => textComponent.Build(this), + _ => throw new InvalidOperationException($"{x.GetType().FullName} isn't a supported modal input component builder type.") + }).ToImmutableArray(); + + TextComponents = Components.OfType().ToImmutableArray(); + + _initializer = builder.ModalInitializer; + } + + /// + /// Creates an and fills it with provided message components. + /// + /// that will be injected into the modal. + /// + /// A filled with the provided components. + /// + public IModal CreateModal(IModalInteraction modalInteraction, bool throwOnMissingField = false) + { + var args = new object[Components.Count]; + var components = modalInteraction.Data.Components.ToList(); + + for (var i = 0; i < Components.Count; i++) + { + var input = Components.ElementAt(i); + var component = components.Find(x => x.CustomId == input.CustomId); + + if (component is null) + { + if (!throwOnMissingField) + args[i] = input.DefaultValue; + else + throw new InvalidOperationException($"Modal interaction is missing the required field: {input.CustomId}"); + } + else + args[i] = component.Value; + } + + return _initializer(args); + } + } +} diff --git a/src/Discord.Net.Interactions/Info/ModuleInfo.cs b/src/Discord.Net.Interactions/Info/ModuleInfo.cs index 4388ea722..321e0bfa9 100644 --- a/src/Discord.Net.Interactions/Info/ModuleInfo.cs +++ b/src/Discord.Net.Interactions/Info/ModuleInfo.cs @@ -68,6 +68,8 @@ namespace Discord.Interactions /// public IReadOnlyCollection AutocompleteCommands { get; } + public IReadOnlyCollection ModalCommands { get; } + /// /// Gets the declaring type of this module, if is . /// @@ -112,6 +114,7 @@ namespace Discord.Interactions ContextCommands = BuildContextCommands(builder).ToImmutableArray(); ComponentCommands = BuildComponentCommands(builder).ToImmutableArray(); AutocompleteCommands = BuildAutocompleteCommands(builder).ToImmutableArray(); + ModalCommands = BuildModalCommands(builder).ToImmutableArray(); SubModules = BuildSubModules(builder, commandService, services).ToImmutableArray(); Attributes = BuildAttributes(builder).ToImmutableArray(); Preconditions = BuildPreconditions(builder).ToImmutableArray(); @@ -171,6 +174,16 @@ namespace Discord.Interactions return result; } + private IEnumerable BuildModalCommands(ModuleBuilder builder) + { + var result = new List(); + + foreach (var commandBuilder in builder.ModalCommands) + result.Add(commandBuilder.Build(this, CommandService)); + + return result; + } + private IEnumerable BuildAttributes (ModuleBuilder builder) { var result = new List(); diff --git a/src/Discord.Net.Interactions/Info/Parameters/ModalCommandParameterInfo.cs b/src/Discord.Net.Interactions/Info/Parameters/ModalCommandParameterInfo.cs new file mode 100644 index 000000000..28162e109 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Parameters/ModalCommandParameterInfo.cs @@ -0,0 +1,28 @@ +using Discord.Interactions.Builders; + +namespace Discord.Interactions +{ + /// + /// Represents the base parameter info class for modals. + /// + public class ModalCommandParameterInfo : CommandParameterInfo + { + /// + /// Gets the class for this parameter if is true. + /// + public ModalInfo Modal { get; private set; } + + /// + /// Gets whether this parameter is an + /// + public bool IsModalParameter => Modal is not null; + + /// + public new ModalCommandInfo Command => base.Command as ModalCommandInfo; + + internal ModalCommandParameterInfo(ModalCommandParameterBuilder builder, ICommandInfo command) : base(builder, command) + { + Modal = builder.Modal; + } + } +} diff --git a/src/Discord.Net.Interactions/InteractionModuleBase.cs b/src/Discord.Net.Interactions/InteractionModuleBase.cs index 997542a2e..873f4c173 100644 --- a/src/Discord.Net.Interactions/InteractionModuleBase.cs +++ b/src/Discord.Net.Interactions/InteractionModuleBase.cs @@ -114,6 +114,13 @@ namespace Discord.Interactions var response = await Context.Interaction.GetOriginalResponseAsync().ConfigureAwait(false); await response.DeleteAsync().ConfigureAwait(false); } + + /// + protected virtual async Task RespondWithModalAsync(Modal modal, RequestOptions options = null) => await Context.Interaction.RespondWithModalAsync(modal); + + /// + protected virtual async Task RespondWithModalAsync(string customId, RequestOptions options = null) where T : class, IModal + => await Context.Interaction.RespondWithModalAsync(customId, options); //IInteractionModuleBase diff --git a/src/Discord.Net.Interactions/InteractionService.cs b/src/Discord.Net.Interactions/InteractionService.cs index 475622f0b..c1291bd6b 100644 --- a/src/Discord.Net.Interactions/InteractionService.cs +++ b/src/Discord.Net.Interactions/InteractionService.cs @@ -53,21 +53,29 @@ namespace Discord.Interactions public event Func AutocompleteHandlerExecuted { add { _autocompleteHandlerExecutedEvent.Add(value); } remove { _autocompleteHandlerExecutedEvent.Remove(value); } } internal readonly AsyncEvent> _autocompleteHandlerExecutedEvent = new(); + /// + /// Occurs when a Modal command is executed. + /// + public event Func ModalCommandExecuted { add { _modalCommandExecutedEvent.Add(value); } remove { _modalCommandExecutedEvent.Remove(value); } } + internal readonly AsyncEvent> _modalCommandExecutedEvent = new(); + private readonly ConcurrentDictionary _typedModuleDefs; private readonly CommandMap _slashCommandMap; private readonly ConcurrentDictionary> _contextCommandMaps; private readonly CommandMap _componentCommandMap; private readonly CommandMap _autocompleteCommandMap; + private readonly CommandMap _modalCommandMap; private readonly HashSet _moduleDefs; private readonly ConcurrentDictionary _typeConverters; private readonly ConcurrentDictionary _genericTypeConverters; private readonly ConcurrentDictionary _autocompleteHandlers = new(); + private readonly ConcurrentDictionary _modalInfos = new(); private readonly SemaphoreSlim _lock; internal readonly Logger _cmdLogger; internal readonly LogManager _logManager; internal readonly Func _getRestClient; - internal readonly bool _throwOnError, _useCompiledLambda, _enableAutocompleteHandlers, _autoServiceScopes; + internal readonly bool _throwOnError, _useCompiledLambda, _enableAutocompleteHandlers, _autoServiceScopes, _exitOnMissingModalField; internal readonly string _wildCardExp; internal readonly RunMode _runMode; internal readonly RestResponseCallback _restResponseCallback; @@ -97,6 +105,16 @@ namespace Discord.Interactions /// public IReadOnlyCollection ComponentCommands => _moduleDefs.SelectMany(x => x.ComponentCommands).ToList(); + /// + /// Represents all Modal Commands loaded within . + /// + public IReadOnlyCollection ModalCommands => _moduleDefs.SelectMany(x => x.ModalCommands).ToList(); + + /// + /// Gets a collection of the cached classes that are referenced in registered s. + /// + public IReadOnlyCollection Modals => ModalUtils.Modals; + /// /// Initialize a with provided configurations. /// @@ -145,6 +163,7 @@ namespace Discord.Interactions _contextCommandMaps = new ConcurrentDictionary>(); _componentCommandMap = new CommandMap(this, config.InteractionCustomIdDelimiters); _autocompleteCommandMap = new CommandMap(this); + _modalCommandMap = new CommandMap(this, config.InteractionCustomIdDelimiters); _getRestClient = getRestClient; @@ -155,6 +174,7 @@ namespace Discord.Interactions _throwOnError = config.ThrowOnError; _wildCardExp = config.WildCardExpression; _useCompiledLambda = config.UseCompiledLambda; + _exitOnMissingModalField = config.ExitOnMissingModalField; _enableAutocompleteHandlers = config.EnableAutocompleteHandlers; _autoServiceScopes = config.AutoServiceScopes; _restResponseCallback = config.RestResponseCallback; @@ -509,6 +529,9 @@ namespace Discord.Interactions foreach (var command in module.AutocompleteCommands) _autocompleteCommandMap.AddCommand(command.GetCommandKeywords(), command); + foreach (var command in module.ModalCommands) + _modalCommandMap.AddCommand(command, command.IgnoreGroupNames); + foreach (var subModule in module.SubModules) LoadModuleInternal(subModule); } @@ -654,7 +677,7 @@ namespace Discord.Interactions public async Task ExecuteCommandAsync (IInteractionContext context, IServiceProvider services) { var interaction = context.Interaction; - + return interaction switch { ISlashCommandInteraction slashCommand => await ExecuteSlashCommandAsync(context, slashCommand, services).ConfigureAwait(false), @@ -662,6 +685,7 @@ namespace Discord.Interactions IUserCommandInteraction userCommand => await ExecuteContextCommandAsync(context, userCommand.Data.Name, ApplicationCommandType.User, services).ConfigureAwait(false), IMessageCommandInteraction messageCommand => await ExecuteContextCommandAsync(context, messageCommand.Data.Name, ApplicationCommandType.Message, services).ConfigureAwait(false), IAutocompleteInteraction autocomplete => await ExecuteAutocompleteAsync(context, autocomplete, services).ConfigureAwait(false), + IModalInteraction modalCommand => await ExecuteModalCommandAsync(context, modalCommand.Data.CustomId, services).ConfigureAwait(false), _ => throw new InvalidOperationException($"{interaction.Type} interaction type cannot be executed by the Interaction service"), }; } @@ -745,6 +769,20 @@ namespace Discord.Interactions return await commandResult.Command.ExecuteAsync(context, services).ConfigureAwait(false); } + private async Task ExecuteModalCommandAsync(IInteractionContext context, string input, IServiceProvider services) + { + var result = _modalCommandMap.GetCommand(input); + + if (!result.IsSuccess) + { + await _cmdLogger.DebugAsync($"Unknown custom interaction id, skipping execution ({input.ToUpper()})"); + + await _componentCommandExecutedEvent.InvokeAsync(null, context, result).ConfigureAwait(false); + return result; + } + return await result.Command.ExecuteAsync(context, services, result.RegexCaptureGroups).ConfigureAwait(false); + } + internal TypeConverter GetTypeConverter (Type type, IServiceProvider services = null) { if (_typeConverters.TryGetValue(type, out var specific)) @@ -819,6 +857,24 @@ namespace Discord.Interactions _genericTypeConverters[targetType] = converterType; } + /// + /// Loads and caches an for the provided . + /// + /// Type of to be loaded. + /// + /// The built instance. + /// + /// + public ModalInfo AddModalInfo() where T : class, IModal + { + var type = typeof(T); + + if (_modalInfos.ContainsKey(type)) + throw new InvalidOperationException($"Modal type {type.FullName} already exists."); + + return ModalUtils.GetOrAdd(type); + } + internal IAutocompleteHandler GetAutocompleteHandler(Type autocompleteHandlerType, IServiceProvider services = null) { services ??= EmptyServiceProvider.Instance; diff --git a/src/Discord.Net.Interactions/InteractionServiceConfig.cs b/src/Discord.Net.Interactions/InteractionServiceConfig.cs index a1583a124..136cba24c 100644 --- a/src/Discord.Net.Interactions/InteractionServiceConfig.cs +++ b/src/Discord.Net.Interactions/InteractionServiceConfig.cs @@ -36,6 +36,9 @@ namespace Discord.Interactions /// /// Gets or sets the option to use compiled lambda expressions to create module instances and execute commands. This method improves performance at the cost of memory. /// + /// + /// For performance reasons, if you frequently use s with the service, it is highly recommended that you enable compiled lambdas. + /// public bool UseCompiledLambda { get; set; } = false; /// @@ -56,6 +59,11 @@ namespace Discord.Interactions /// Gets or sets delegate to be used by the when responding to a Rest based interaction. /// public RestResponseCallback RestResponseCallback { get; set; } = (ctx, str) => Task.CompletedTask; + + /// + /// Gets or sets whether a command execution should exit when a modal command encounters a missing modal component value. + /// + public bool ExitOnMissingModalField { get; set; } = false; } /// diff --git a/src/Discord.Net.Interactions/Utilities/ModalUtils.cs b/src/Discord.Net.Interactions/Utilities/ModalUtils.cs new file mode 100644 index 000000000..d42cc2fe9 --- /dev/null +++ b/src/Discord.Net.Interactions/Utilities/ModalUtils.cs @@ -0,0 +1,51 @@ +using Discord.Interactions.Builders; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace Discord.Interactions +{ + internal static class ModalUtils + { + private static ConcurrentDictionary _modalInfos = new(); + + public static IReadOnlyCollection Modals => _modalInfos.Values.ToReadOnlyCollection(); + + public static ModalInfo GetOrAdd(Type type) + { + if (!typeof(IModal).IsAssignableFrom(type)) + throw new ArgumentException($"Must be an implementation of {nameof(IModal)}", nameof(type)); + + return _modalInfos.GetOrAdd(type, ModuleClassBuilder.BuildModalInfo(type)); + } + + public static ModalInfo GetOrAdd() where T : class, IModal + => GetOrAdd(typeof(T)); + + public static bool TryGet(Type type, out ModalInfo modalInfo) + { + if (!typeof(IModal).IsAssignableFrom(type)) + throw new ArgumentException($"Must be an implementation of {nameof(IModal)}", nameof(type)); + + return _modalInfos.TryGetValue(type, out modalInfo); + } + + public static bool TryGet(out ModalInfo modalInfo) where T : class, IModal + => TryGet(typeof(T), out modalInfo); + + public static bool TryRemove(Type type, out ModalInfo modalInfo) + { + if (!typeof(IModal).IsAssignableFrom(type)) + throw new ArgumentException($"Must be an implementation of {nameof(IModal)}", nameof(type)); + + return _modalInfos.TryRemove(type, out modalInfo); + } + + public static bool TryRemove(out ModalInfo modalInfo) where T : class, IModal + => TryRemove(typeof(T), out modalInfo); + + public static void Clear() => _modalInfos.Clear(); + + public static int Count() => _modalInfos.Count; + } +} diff --git a/src/Discord.Net.Interactions/Utilities/ReflectionUtils.cs b/src/Discord.Net.Interactions/Utilities/ReflectionUtils.cs index b15662bfb..5d3da4c5c 100644 --- a/src/Discord.Net.Interactions/Utilities/ReflectionUtils.cs +++ b/src/Discord.Net.Interactions/Utilities/ReflectionUtils.cs @@ -112,6 +112,67 @@ namespace Discord.Interactions var parameters = constructor.GetParameters(); var properties = GetProperties(typeInfo); + var lambda = CreateLambdaMemberInit(typeInfo, constructor); + + return (services) => + { + var args = new object[parameters.Length]; + var props = new object[properties.Length]; + + for (int i = 0; i < parameters.Length; i++) + args[i] = GetMember(commandService, services, parameters[i].ParameterType, typeInfo); + + for (int i = 0; i < properties.Length; i++) + props[i] = GetMember(commandService, services, properties[i].PropertyType, typeInfo); + + var instance = lambda(args, props); + + return instance; + }; + } + + internal static Func CreateLambdaConstructorInvoker(TypeInfo typeInfo) + { + var constructor = GetConstructor(typeInfo); + var parameters = constructor.GetParameters(); + + var argsExp = Expression.Parameter(typeof(object[]), "args"); + + var parameterExps = new Expression[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var indexExp = Expression.Constant(i); + var accessExp = Expression.ArrayIndex(argsExp, indexExp); + parameterExps[i] = Expression.Convert(accessExp, parameters[i].ParameterType); + } + + var newExp = Expression.New(constructor, parameterExps); + + return Expression.Lambda>(newExp, argsExp).Compile(); + } + + /// + /// Create a compiled lambda property setter. + /// + internal static Action CreateLambdaPropertySetter(PropertyInfo propertyInfo) + { + var instanceParam = Expression.Parameter(typeof(T), "instance"); + var valueParam = Expression.Parameter(typeof(object), "value"); + + var prop = Expression.Property(instanceParam, propertyInfo); + var assign = Expression.Assign(prop, Expression.Convert(valueParam, propertyInfo.PropertyType)); + + return Expression.Lambda>(assign, instanceParam, valueParam).Compile(); + } + + internal static Func CreateLambdaMemberInit(TypeInfo typeInfo, ConstructorInfo constructor, Predicate propertySelect = null) + { + propertySelect ??= x => true; + + var parameters = constructor.GetParameters(); + var properties = GetProperties(typeInfo).Where(x => propertySelect(x)).ToArray(); + var argsExp = Expression.Parameter(typeof(object[]), "args"); var propsExp = Expression.Parameter(typeof(object[]), "props"); @@ -137,17 +198,8 @@ namespace Discord.Interactions var memberInit = Expression.MemberInit(newExp, memberExps); var lambda = Expression.Lambda>(memberInit, argsExp, propsExp).Compile(); - return (services) => + return (args, props) => { - var args = new object[parameters.Length]; - var props = new object[properties.Length]; - - for (int i = 0; i < parameters.Length; i++) - args[i] = GetMember(commandService, services, parameters[i].ParameterType, typeInfo); - - for (int i = 0; i < properties.Length; i++) - props[i] = GetMember(commandService, services, properties[i].PropertyType, typeInfo); - var instance = lambda(args, props); return instance; diff --git a/src/Discord.Net.Rest/API/Common/ActionRowComponent.cs b/src/Discord.Net.Rest/API/Common/ActionRowComponent.cs index 9dede7e03..9a7eb80dd 100644 --- a/src/Discord.Net.Rest/API/Common/ActionRowComponent.cs +++ b/src/Discord.Net.Rest/API/Common/ActionRowComponent.cs @@ -21,6 +21,7 @@ namespace Discord.API { ComponentType.Button => new ButtonComponent(x as Discord.ButtonComponent), ComponentType.SelectMenu => new SelectMenuComponent(x as Discord.SelectMenuComponent), + ComponentType.TextInput => new TextInputComponent(x as Discord.TextInputComponent), _ => null }; }).ToArray(); diff --git a/src/Discord.Net.Rest/API/Common/InteractionCallbackData.cs b/src/Discord.Net.Rest/API/Common/InteractionCallbackData.cs index b07ebff49..3685d7a99 100644 --- a/src/Discord.Net.Rest/API/Common/InteractionCallbackData.cs +++ b/src/Discord.Net.Rest/API/Common/InteractionCallbackData.cs @@ -24,5 +24,11 @@ namespace Discord.API [JsonProperty("choices")] public Optional Choices { get; set; } + + [JsonProperty("title")] + public Optional Title { get; set; } + + [JsonProperty("custom_id")] + public Optional CustomId { get; set; } } } diff --git a/src/Discord.Net.Rest/API/Common/MessageComponentInteractionData.cs b/src/Discord.Net.Rest/API/Common/MessageComponentInteractionData.cs index a7760911c..4633fc25a 100644 --- a/src/Discord.Net.Rest/API/Common/MessageComponentInteractionData.cs +++ b/src/Discord.Net.Rest/API/Common/MessageComponentInteractionData.cs @@ -12,5 +12,8 @@ namespace Discord.API [JsonProperty("values")] public Optional Values { get; set; } + + [JsonProperty("value")] + public Optional Value { get; set; } } } diff --git a/src/Discord.Net.Rest/API/Common/ModalInteractionData.cs b/src/Discord.Net.Rest/API/Common/ModalInteractionData.cs new file mode 100644 index 000000000..182fa53b2 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ModalInteractionData.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class ModalInteractionData : IDiscordInteractionData + { + [JsonProperty("custom_id")] + public string CustomId { get; set; } + + [JsonProperty("components")] + public API.ActionRowComponent[] Components { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/SelectMenuComponent.cs b/src/Discord.Net.Rest/API/Common/SelectMenuComponent.cs index 0886a8fe9..25ac476c5 100644 --- a/src/Discord.Net.Rest/API/Common/SelectMenuComponent.cs +++ b/src/Discord.Net.Rest/API/Common/SelectMenuComponent.cs @@ -26,6 +26,8 @@ namespace Discord.API [JsonProperty("disabled")] public bool Disabled { get; set; } + [JsonProperty("values")] + public Optional Values { get; set; } public SelectMenuComponent() { } public SelectMenuComponent(Discord.SelectMenuComponent component) diff --git a/src/Discord.Net.Rest/API/Common/TextInputComponent.cs b/src/Discord.Net.Rest/API/Common/TextInputComponent.cs new file mode 100644 index 000000000..a475345fc --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/TextInputComponent.cs @@ -0,0 +1,49 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class TextInputComponent : IMessageComponent + { + [JsonProperty("type")] + public ComponentType Type { get; set; } + + [JsonProperty("style")] + public TextInputStyle Style { get; set; } + + [JsonProperty("custom_id")] + public string CustomId { get; set; } + + [JsonProperty("label")] + public string Label { get; set; } + + [JsonProperty("placeholder")] + public Optional Placeholder { get; set; } + + [JsonProperty("min_length")] + public Optional MinLength { get; set; } + + [JsonProperty("max_length")] + public Optional MaxLength { get; set; } + + [JsonProperty("value")] + public Optional Value { get; set; } + + [JsonProperty("required")] + public Optional Required { get; set; } + + public TextInputComponent() { } + + public TextInputComponent(Discord.TextInputComponent component) + { + Type = component.Type; + Style = component.Style; + CustomId = component.CustomId; + Label = component.Label; + Placeholder = component.Placeholder; + MinLength = component.MinLength ?? Optional.Unspecified; + MaxLength = component.MaxLength ?? Optional.Unspecified; + Required = component.Required ?? Optional.Unspecified; + Value = component.Value ?? Optional.Unspecified; + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs b/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs index 2069b9913..bb2e2c27d 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs @@ -316,5 +316,45 @@ namespace Discord.Rest return SerializePayload(response); } + + /// + /// Responds to the interaction with a modal. + /// + /// The modal to respond with. + /// The request options for this request. + /// A string that contains json to write back to the incoming http request. + /// + /// + public override string RespondWithModal(Modal modal, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds of no response/acknowledgement"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.Modal, + Data = new API.InteractionCallbackData + { + CustomId = modal.CustomId, + Title = modal.Title, + Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); + } + } + + lock (_lock) + { + HasResponded = true; + } + + return SerializePayload(response); + } } } diff --git a/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs b/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs index d9643079e..359b92249 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs @@ -446,6 +446,46 @@ namespace Discord.Rest return SerializePayload(response); } + /// + /// Responds to the interaction with a modal. + /// + /// The modal to respond with. + /// The request options for this request. + /// A string that contains json to write back to the incoming http request. + /// + /// + public override string RespondWithModal(Modal modal, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds of no response/acknowledgement"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.Modal, + Data = new API.InteractionCallbackData + { + CustomId = modal.CustomId, + Title = modal.Title, + Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction."); + } + } + + lock (_lock) + { + HasResponded = true; + } + + return SerializePayload(response); + } + //IComponentInteraction /// IComponentInteractionData IComponentInteraction.Data => Data; diff --git a/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponentData.cs b/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponentData.cs index e865c208c..d065b258f 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponentData.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponentData.cs @@ -27,11 +27,26 @@ namespace Discord.Rest /// public IReadOnlyCollection Values { get; } + /// + public string Value { get; } + internal RestMessageComponentData(Model model) { CustomId = model.CustomId; Type = model.ComponentType; Values = model.Values.GetValueOrDefault(); } + + internal RestMessageComponentData(IMessageComponent component) + { + CustomId = component.CustomId; + Type = component.Type; + + if (component is API.TextInputComponent textInput) + Value = textInput.Value.Value; + + if (component is API.SelectMenuComponent select) + Values = select.Values.Value; + } } } diff --git a/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModal.cs b/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModal.cs new file mode 100644 index 000000000..5f54fe051 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModal.cs @@ -0,0 +1,402 @@ +using Discord.Net.Rest; +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using DataModel = Discord.API.ModalInteractionData; +using ModelBase = Discord.API.Interaction; + +namespace Discord.Rest +{ + /// + /// Represents a user submitted . + /// + public class RestModal : RestInteraction, IDiscordInteraction, IModalInteraction + { + internal RestModal(DiscordRestClient client, ModelBase model) + : base(client, model.Id) + { + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + Data = new RestModalData(dataModel); + } + + internal new static async Task CreateAsync(DiscordRestClient client, ModelBase model) + { + var entity = new RestModal(client, model); + await entity.UpdateAsync(client, model); + return entity; + } + + private object _lock = new object(); + + /// + /// Acknowledges this interaction with the . + /// + /// + /// A string that contains json to write back to the incoming http request. + /// + public override string Defer(bool ephemeral = false, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.DeferredChannelMessageWithSource, + Data = new API.InteractionCallbackData + { + Flags = ephemeral ? MessageFlags.Ephemeral : Optional.Unspecified + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); + } + } + + lock (_lock) + { + HasResponded = true; + } + + return SerializePayload(response); + } + + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// The sent message. + /// + public override async Task FollowupAsync( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent component = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + var args = new API.Rest.CreateWebhookMessageParams + { + Content = text, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + IsTTS = isTTS, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified + }; + + if (ephemeral) + args.Flags = MessageFlags.Ephemeral; + + return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + } + + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// The file to upload. + /// The file name of the attachment. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// The sent message. + /// + public override async Task FollowupWithFileAsync( + Stream fileStream, + string fileName, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent component = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + Preconditions.NotNull(fileStream, nameof(fileStream), "File Stream must have data"); + Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); + + var args = new API.Rest.CreateWebhookMessageParams + { + Content = text, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + IsTTS = isTTS, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, + File = fileStream is not null ? new MultipartFile(fileStream, fileName) : Optional.Unspecified + }; + + if (ephemeral) + args.Flags = MessageFlags.Ephemeral; + + return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + } + + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// The file to upload. + /// The file name of the attachment. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// The sent message. + /// + public override async Task FollowupWithFileAsync( + string filePath, + string text = null, + string fileName = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent component = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + Preconditions.NotNullOrEmpty(filePath, nameof(filePath), "Path must exist"); + + fileName ??= Path.GetFileName(filePath); + Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); + + var args = new API.Rest.CreateWebhookMessageParams + { + Content = text, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + IsTTS = isTTS, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, + File = !string.IsNullOrEmpty(filePath) ? new MultipartFile(new MemoryStream(File.ReadAllBytes(filePath), false), fileName) : Optional.Unspecified + }; + + if (ephemeral) + args.Flags = MessageFlags.Ephemeral; + + return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + } + + /// + /// Responds to an Interaction with type . + /// + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// Message content is too long, length must be less or equal to . + /// The parameters provided were invalid or the token was invalid. + /// + /// A string that contains json to write back to the incoming http request. + /// + public override string Respond( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent component = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.ChannelMessageWithSource, + Data = new API.InteractionCallbackData + { + Content = text, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + TTS = isTTS, + Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, + Flags = ephemeral ? MessageFlags.Ephemeral : Optional.Unspecified + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond twice to the same interaction"); + } + } + + lock (_lock) + { + HasResponded = true; + } + + return SerializePayload(response); + } + + /// + public override async Task FollowupWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + foreach (var attachment in attachments) + { + Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); + } + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var flags = MessageFlags.None; + + if (ephemeral) + flags |= MessageFlags.Ephemeral; + + var args = new API.Rest.UploadWebhookFileParams(attachments.ToArray()) { Flags = flags, Content = text, IsTTS = isTTS, Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified }; + return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options).ConfigureAwait(false); + } + + /// + public override Task FollowupWithFileAsync( + FileAttachment attachment, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + return FollowupWithFilesAsync(new FileAttachment[] { attachment }, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + } + + /// + public override string RespondWithModal(Modal modal, RequestOptions requestOptions = null) + => throw new NotSupportedException("Modal interactions cannot have modal responces!"); + + public new RestModalData Data { get; set; } + + IModalInteractionData IModalInteraction.Data => Data; + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModalData.cs b/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModalData.cs new file mode 100644 index 000000000..22460ae51 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModalData.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; +using System; +using Model = Discord.API.ModalInteractionData; +using InterationModel = Discord.API.Interaction; +using DataModel = Discord.API.MessageComponentInteractionData; + +namespace Discord.Rest +{ + /// + /// Represents data sent from a Interaction. + /// + public class RestModalData : IComponentInteractionData, IModalInteractionData + { + /// + public string CustomId { get; } + + /// + /// Represents the s components submitted by the user. + /// + public IReadOnlyCollection Components { get; } + + /// + public ComponentType Type => ComponentType.ModalSubmit; + + /// + public IReadOnlyCollection Values + => throw new NotSupportedException("Modal interactions do not have values!"); + + /// + public string Value + => throw new NotSupportedException("Modal interactions do not have value!"); + + IReadOnlyCollection IModalInteractionData.Components => Components; + + internal RestModalData(Model model) + { + CustomId = model.CustomId; + Components = model.Components + .SelectMany(x => x.Components) + .Select(x => new RestMessageComponentData(x)) + .ToArray(); + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs b/src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs index 566d60d14..5894ee264 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs @@ -100,6 +100,9 @@ namespace Discord.Rest if (model.Type == InteractionType.ApplicationCommandAutocomplete) return await RestAutocompleteInteraction.CreateAsync(client, model).ConfigureAwait(false); + if (model.Type == InteractionType.ModalSubmit) + return await RestModal.CreateAsync(client, model).ConfigureAwait(false); + return null; } @@ -180,6 +183,9 @@ namespace Discord.Rest var model = await InteractionHelper.ModifyInteractionResponseAsync(Discord, Token, func, options); return RestInteractionMessage.Create(Discord, model, Token, Channel); } + /// + public abstract string RespondWithModal(Modal modal, RequestOptions options = null); + /// public abstract string Respond(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); @@ -294,6 +300,9 @@ namespace Discord.Rest Task IDiscordInteraction.DeferAsync(bool ephemeral, RequestOptions options) => Task.FromResult(Defer(ephemeral, options)); /// + Task IDiscordInteraction.RespondWithModalAsync(Modal modal, RequestOptions options) + => Task.FromResult(RespondWithModal(modal, options)); + /// async Task IDiscordInteraction.FollowupAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) => await FollowupAsync(text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestPingInteraction.cs b/src/Discord.Net.Rest/Entities/Interactions/RestPingInteraction.cs index 71d5a588c..bd15bc2d3 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/RestPingInteraction.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/RestPingInteraction.cs @@ -36,6 +36,7 @@ namespace Discord.Rest } public override string Defer(bool ephemeral = false, RequestOptions options = null) => throw new NotSupportedException(); + public override string RespondWithModal(Modal modal, RequestOptions options = null) => throw new NotSupportedException(); public override string Respond(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); public override Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); public override Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); diff --git a/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteraction.cs b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteraction.cs index 44d0dc6ff..24dbae37a 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteraction.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteraction.cs @@ -112,7 +112,8 @@ namespace Discord.Rest => throw new NotSupportedException("Autocomplete interactions don't support this method!"); public override Task FollowupWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException("Autocomplete interactions don't support this method!"); - + public override string RespondWithModal(Modal modal, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); //IAutocompleteInteraction /// diff --git a/src/Discord.Net.Rest/Net/Converters/InteractionConverter.cs b/src/Discord.Net.Rest/Net/Converters/InteractionConverter.cs index f7235841d..4c4e3444d 100644 --- a/src/Discord.Net.Rest/Net/Converters/InteractionConverter.cs +++ b/src/Discord.Net.Rest/Net/Converters/InteractionConverter.cs @@ -56,6 +56,13 @@ namespace Discord.Net.Converters interaction.Data = autocompleteData; } break; + case InteractionType.ModalSubmit: + { + var modalData = new API.ModalInteractionData(); + serializer.Populate(result.CreateReader(), modalData); + interaction.Data = modalData; + } + break; } } else diff --git a/src/Discord.Net.Rest/Net/Converters/MessageComponentConverter.cs b/src/Discord.Net.Rest/Net/Converters/MessageComponentConverter.cs index 0bf11a369..36542d83b 100644 --- a/src/Discord.Net.Rest/Net/Converters/MessageComponentConverter.cs +++ b/src/Discord.Net.Rest/Net/Converters/MessageComponentConverter.cs @@ -32,6 +32,9 @@ namespace Discord.Net.Converters case ComponentType.SelectMenu: messageComponent = new API.SelectMenuComponent(); break; + case ComponentType.TextInput: + messageComponent = new API.TextInputComponent(); + break; } serializer.Populate(jsonObject.CreateReader(), messageComponent); return messageComponent; diff --git a/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs b/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs index 29e13a2a1..134f8136b 100644 --- a/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs +++ b/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs @@ -634,6 +634,15 @@ namespace Discord.WebSocket remove => _autocompleteExecuted.Remove(value); } internal readonly AsyncEvent> _autocompleteExecuted = new AsyncEvent>(); + /// + /// Fired when a modal is submitted. + /// + public event Func ModalSubmitted + { + add => _modalSubmitted.Add(value); + remove => _modalSubmitted.Remove(value); + } + internal readonly AsyncEvent> _modalSubmitted = new AsyncEvent>(); /// /// Fired when a guild application command is created. diff --git a/src/Discord.Net.WebSocket/DiscordShardedClient.cs b/src/Discord.Net.WebSocket/DiscordShardedClient.cs index e573a2593..51c6d3c34 100644 --- a/src/Discord.Net.WebSocket/DiscordShardedClient.cs +++ b/src/Discord.Net.WebSocket/DiscordShardedClient.cs @@ -468,6 +468,7 @@ namespace Discord.WebSocket client.UserCommandExecuted += (arg) => _userCommandExecuted.InvokeAsync(arg); client.MessageCommandExecuted += (arg) => _messageCommandExecuted.InvokeAsync(arg); client.AutocompleteExecuted += (arg) => _autocompleteExecuted.InvokeAsync(arg); + client.ModalSubmitted += (arg) => _modalSubmitted.InvokeAsync(arg); client.ThreadUpdated += (thread1, thread2) => _threadUpdated.InvokeAsync(thread1, thread2); client.ThreadCreated += (thread) => _threadCreated.InvokeAsync(thread); diff --git a/src/Discord.Net.WebSocket/DiscordSocketApiClient.cs b/src/Discord.Net.WebSocket/DiscordSocketApiClient.cs index cad6e5daa..21594fed7 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketApiClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketApiClient.cs @@ -78,7 +78,7 @@ namespace Discord.API if (msg != null) { #if DEBUG_PACKETS - Console.WriteLine($"<- {(GatewayOpCode)msg.Operation} [{msg.Type ?? "none"}] : {(msg.Payload as Newtonsoft.Json.Linq.JToken)?.ToString().Length}"); + Console.WriteLine($"<- {(GatewayOpCode)msg.Operation} [{msg.Type ?? "none"}] : {(msg.Payload as Newtonsoft.Json.Linq.JToken)}"); #endif await _receivedGatewayEvent.InvokeAsync((GatewayOpCode)msg.Operation, msg.Sequence, msg.Type, msg.Payload).ConfigureAwait(false); @@ -95,7 +95,7 @@ namespace Discord.API if (msg != null) { #if DEBUG_PACKETS - Console.WriteLine($"<- {(GatewayOpCode)msg.Operation} [{msg.Type ?? "none"}] : {(msg.Payload as Newtonsoft.Json.Linq.JToken)?.ToString().Length}"); + Console.WriteLine($"<- {(GatewayOpCode)msg.Operation} [{msg.Type ?? "none"}] : {(msg.Payload as Newtonsoft.Json.Linq.JToken)}"); #endif await _receivedGatewayEvent.InvokeAsync((GatewayOpCode)msg.Operation, msg.Sequence, msg.Type, msg.Payload).ConfigureAwait(false); diff --git a/src/Discord.Net.WebSocket/DiscordSocketClient.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.cs index dab07d3e2..e7f9b10ee 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.cs @@ -2274,6 +2274,9 @@ namespace Discord.WebSocket case SocketAutocompleteInteraction autocomplete: await TimedInvokeAsync(_autocompleteExecuted, nameof(AutocompleteExecuted), autocomplete).ConfigureAwait(false); break; + case SocketModal modal: + await TimedInvokeAsync(_modalSubmitted, nameof(ModalSubmitted), modal).ConfigureAwait(false); + break; } } break; diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs b/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs index 862c792a8..17a5e0209 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs @@ -438,6 +438,41 @@ namespace Discord.WebSocket HasResponded = true; } + /// + public override async Task RespondWithModalAsync(Modal modal, RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.Modal, + Data = new API.InteractionCallbackData + { + CustomId = modal.CustomId, + Title = modal.Title, + Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond twice to the same interaction"); + } + } + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + + lock (_lock) + { + HasResponded = true; + } + } //IComponentInteraction /// IComponentInteractionData IComponentInteraction.Data => Data; diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponentData.cs b/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponentData.cs index 71e1d0395..c7f6c5106 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponentData.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponentData.cs @@ -23,11 +23,31 @@ namespace Discord.WebSocket /// public IReadOnlyCollection Values { get; } + /// + /// Gets the value of a interaction response. + /// + public string Value { get; } + internal SocketMessageComponentData(Model model) { CustomId = model.CustomId; Type = model.ComponentType; Values = model.Values.GetValueOrDefault(); + Value = model.Value.GetValueOrDefault(); + } + + internal SocketMessageComponentData(IMessageComponent component) + { + CustomId = component.CustomId; + Type = component.Type; + + Value = component.Type == ComponentType.TextInput + ? (component as API.TextInputComponent).Value.Value + : null; + + Values = component.Type == ComponentType.SelectMenu + ? (component as API.SelectMenuComponent).Values.Value + : null; } } } diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModal.cs b/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModal.cs new file mode 100644 index 000000000..197882dae --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModal.cs @@ -0,0 +1,302 @@ +using Discord.Net.Rest; +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using DataModel = Discord.API.ModalInteractionData; +using ModelBase = Discord.API.Interaction; + +namespace Discord.WebSocket +{ + /// + /// Represents a user submitted received via GateWay. + /// + public class SocketModal : SocketInteraction, IDiscordInteraction, IModalInteraction + { + /// + /// The data for this interaction. + /// + /// + public new SocketModalData Data { get; set; } + + internal SocketModal(DiscordSocketClient client, ModelBase model, ISocketMessageChannel channel) + : base(client, model.Id, channel) + { + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + Data = new SocketModalData(dataModel); + } + + internal new static SocketModal Create(DiscordSocketClient client, ModelBase model, ISocketMessageChannel channel) + { + var entity = new SocketModal(client, model, channel); + entity.Update(model); + return entity; + } + + /// + public override bool HasResponded { get; internal set; } + private object _lock = new object(); + + /// + public override async Task RespondWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var response = new API.Rest.UploadInteractionFileParams(attachments?.ToArray()) + { + Type = InteractionResponseType.ChannelMessageWithSource, + Content = text ?? Optional.Unspecified, + AllowedMentions = allowedMentions != null ? allowedMentions?.ToModel() : Optional.Unspecified, + Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, + IsTTS = isTTS, + MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, + Flags = ephemeral ? MessageFlags.Ephemeral : Optional.Unspecified + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond, update, or defer the same interaction twice"); + } + } + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + HasResponded = true; + } + + /// + public override async Task RespondAsync( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.ChannelMessageWithSource, + Data = new API.InteractionCallbackData + { + Content = text ?? Optional.Unspecified, + AllowedMentions = allowedMentions?.ToModel(), + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + TTS = isTTS, + Flags = ephemeral ? MessageFlags.Ephemeral : Optional.Unspecified, + Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond, update, or defer twice to the same interaction"); + } + } + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + HasResponded = true; + } + + /// + public override async Task FollowupAsync( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + var args = new API.Rest.CreateWebhookMessageParams + { + Content = text, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + IsTTS = isTTS, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified + }; + + if (ephemeral) + args.Flags = MessageFlags.Ephemeral; + + return await InteractionHelper.SendFollowupAsync(Discord.Rest, args, Token, Channel, options); + } + + /// + public override async Task FollowupWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + foreach (var attachment in attachments) + { + Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); + } + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var flags = MessageFlags.None; + + if (ephemeral) + flags |= MessageFlags.Ephemeral; + + var args = new API.Rest.UploadWebhookFileParams(attachments.ToArray()) { Flags = flags, Content = text, IsTTS = isTTS, Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified }; + return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options).ConfigureAwait(false); + } + + /// + public override async Task DeferAsync(bool ephemeral = false, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds of no response/acknowledgement"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.DeferredUpdateMessage, + Data = ephemeral ? new API.InteractionCallbackData { Flags = MessageFlags.Ephemeral } : Optional.Unspecified + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); + } + } + + await Discord.Rest.ApiClient.CreateInteractionResponseAsync(response, Id, Token, options).ConfigureAwait(false); + + lock (_lock) + { + HasResponded = true; + } + } + + /// + public override Task RespondWithModalAsync(Modal modal, RequestOptions options = null) + => throw new NotSupportedException("You cannot respond to a modal with a modal!"); + + IModalInteractionData IModalInteraction.Data => Data; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModalData.cs b/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModalData.cs new file mode 100644 index 000000000..df8be2fe8 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModalData.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; +using System; +using Model = Discord.API.ModalInteractionData; +using InterationModel = Discord.API.Interaction; +using DataModel = Discord.API.MessageComponentInteractionData; + +namespace Discord.WebSocket +{ + /// + /// Represents data sent from a . + /// + public class SocketModalData : IDiscordInteractionData, IModalInteractionData + { + /// + /// Gets the 's Custom Id. + /// + public string CustomId { get; } + + /// + /// Gets the 's components submitted by the user. + /// + public IReadOnlyCollection Components { get; } + + internal SocketModalData(Model model) + { + CustomId = model.CustomId; + Components = model.Components + .SelectMany(x => x.Components) + .Select(x => new SocketMessageComponentData(x)) + .ToArray(); + } + + IReadOnlyCollection IModalInteractionData.Components => Components; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteraction.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteraction.cs index 6058bdafd..d4cdc9cc1 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteraction.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteraction.cs @@ -100,6 +100,10 @@ namespace Discord.WebSocket public override Task RespondWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + /// + public override Task RespondWithModalAsync(Modal modal, RequestOptions requestOptions = null) + => throw new NotSupportedException("Autocomplete interactions cannot have normal responces!"); + //IAutocompleteInteraction /// IAutocompleteInteractionData IAutocompleteInteraction.Data => Data; diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs index 330d6d7a4..bc3ece20c 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs @@ -1,4 +1,3 @@ -using Discord.Net.Rest; using Discord.Rest; using System; using System.Collections.Generic; @@ -135,6 +134,42 @@ namespace Discord.WebSocket HasResponded = true; } + /// + public override async Task RespondWithModalAsync(Modal modal, RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.Modal, + Data = new API.InteractionCallbackData + { + CustomId = modal.CustomId, + Title = modal.Title, + Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond twice to the same interaction"); + } + } + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + + lock (_lock) + { + HasResponded = true; + } + } + public override async Task RespondWithFilesAsync( IEnumerable attachments, string text = null, diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs index 985e8e0d9..1c3563ab0 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs @@ -108,6 +108,9 @@ namespace Discord.WebSocket if (model.Type == InteractionType.ApplicationCommandAutocomplete) return SocketAutocompleteInteraction.Create(client, model, channel); + if (model.Type == InteractionType.ModalSubmit) + return SocketModal.Create(client, model, channel); + return null; } @@ -387,6 +390,13 @@ namespace Discord.WebSocket /// public abstract Task DeferAsync(bool ephemeral = false, RequestOptions options = null); + /// + /// Responds to this interaction with a . + /// + /// The to respond with. + /// The request options for this request. + /// A task that represents the asynchronous operation of responding to the interaction. + public abstract Task RespondWithModalAsync(Modal modal, RequestOptions options = null); #endregion #region IDiscordInteraction