* Implement Modals (#428) * Socket Modal Support * fix shareded client support * Properly use `HasResponded` instead of `_hasResponded` * `ModalBuilder` and `TextInputBuilder` validation. * make orginisation more consistant. * Rest Modals. * Docs + add missing methods * fix message signatures and missing abstract members * modal changes * um????? * update modal docs * update docs - again for some reason * cleanup * fix message signatures * add modal commands support to interaction service * Fix _hasResponded * update to new unsupported standard. * Sending modals with Interaction service. * fix spelling in ComponentBuilder * sending IModals when responding to interactions * interaction service modals * fix rest modals * spelling and minor improvements. * improve interaction service modal proformance * use precompiled lambda for interaction service modals * respect user compiled lambda choice * changes to modals in the interaction service (more) * support compiled lambdas in modal properties. * modal interactions tweaks * fix inline doc * more modal docs * configure responce to faild modal component * init * solve runtime errors * solve build errors * add default value parsing * make modal info caching static * make ModalUtils static * add inline docs * fix build errors * code cleanup * Introduce Required and Label properties as seperate attributes. * replace internal dictionary of ModalInfo with a list * change input building logic of modals * update RespondWithModalAsync method * add initial value parameter back to ModalTextInput and fix optional modal field * add missing inline docs * dispose the reference modal instance after building * code cleanup on modalcommandbuilder * Update docs/guides/int_basics/message-components/text-input.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/message-components/text-input.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/modals/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/modals/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/modals/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/modals/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/modals/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/modals/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/modals/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_framework/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_framework/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_framework/samples/intro/modal.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/MessageComponents/IComponentInteractionData.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputComponent.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteraction.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Interactions/Attributes/Commands/ModalInteractionAttribute.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Interactions/Attributes/Modals/RequiredInputAttribute.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Interactions/InteractionServiceConfig.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponentData.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModalData.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * update interaction service modal docs * implements ExitOnMissingmModalField config option and adds Type field to modal info * Add WithValue to text input builders * Fix rare NRE on component enumeration * Fix RequestOptions being required in some methods * Use 'OfType' instead of 'Where' * Remove android unsported warning * Change publicity of properties in IInputComponeontBuilder.cs Co-authored-by: Cenk Ergen <57065323+Cenngo@users.noreply.github.com> Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Remove complex parameter ref Co-authored-by: CottageDwellingCat <80918250+CottageDwellingCat@users.noreply.github.com> Co-authored-by: Cenk Ergen <57065323+Cenngo@users.noreply.github.com> Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com>tags/3.3.0
| @@ -8,16 +8,16 @@ | |||||
| "editor.rulers": [ | "editor.rulers": [ | ||||
| 120 | 120 | ||||
| ], | ], | ||||
| "editor.insertSpaces": true, | |||||
| "files.exclude": { | "files.exclude": { | ||||
| "**/.git": true, | "**/.git": true, | ||||
| "**/.svn": true, | "**/.svn": true, | ||||
| "**/.hg": true, | "**/.hg": true, | ||||
| "**/CVS": true, | "**/CVS": true, | ||||
| "**/.DS_Store": true, | "**/.DS_Store": true, | ||||
| "docs/": true, | |||||
| "**/obj": true, | "**/obj": true, | ||||
| "**/bin": true, | "**/bin": true, | ||||
| "samples/": true, | "samples/": true, | ||||
| } | } | ||||
| } | } | ||||
| } | |||||
| } | |||||
| @@ -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. | |||||
|  | |||||
| ## 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: | |||||
|  | |||||
| 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."); | |||||
| ``` | |||||
|  | |||||
| @@ -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. | |||||
|  | |||||
| 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: | |||||
|  | |||||
| ### 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: | |||||
|  | |||||
| ### 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<SocketMessageComponentData> 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. | |||||
|  | |||||
| @@ -198,6 +198,18 @@ Autocomplete commands must be parameterless methods. A valid Autocomplete comman | |||||
| Alternatively, you can use the [AutocompleteHandlers] to simplify this workflow. | 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 | ## Interaction Context | ||||
| Every command module provides its commands with an execution context. | Every command module provides its commands with an execution context. | ||||
| @@ -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<FoodModal>("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); | |||||
| } | |||||
| @@ -91,8 +91,14 @@ | |||||
| topicUid: Guides.MessageComponents.Buttons | topicUid: Guides.MessageComponents.Buttons | ||||
| - name: Select menus | - name: Select menus | ||||
| topicUid: Guides.MessageComponents.SelectMenus | topicUid: Guides.MessageComponents.SelectMenus | ||||
| - name: Text Input | |||||
| topicUid: Guides.MessageComponents.TextInputs | |||||
| - name: Advanced Concepts | - name: Advanced Concepts | ||||
| topicUid: Guides.MessageComponents.Advanced | topicUid: Guides.MessageComponents.Advanced | ||||
| - name: Modal Basics | |||||
| items: | |||||
| - name: Introduction | |||||
| topicUid: Guides.Modals.Intro | |||||
| - name: Guild Events | - name: Guild Events | ||||
| items: | items: | ||||
| - name: Introduction | - name: Introduction | ||||
| @@ -332,5 +332,13 @@ namespace Discord | |||||
| /// A task that represents the asynchronous operation of deferring the interaction. | /// A task that represents the asynchronous operation of deferring the interaction. | ||||
| /// </returns> | /// </returns> | ||||
| Task DeferAsync(bool ephemeral = false, RequestOptions options = null); | Task DeferAsync(bool ephemeral = false, RequestOptions options = null); | ||||
| /// <summary> | |||||
| /// Responds to the interaction with a modal. | |||||
| /// </summary> | |||||
| /// <param name="modal">The modal to respond with.</param> | |||||
| /// <param name="options">The request options for this <see langword="async"/> request.</param> | |||||
| /// <returns>A task that represents the asynchronous operation of responding to the interaction.</returns> | |||||
| Task RespondWithModalAsync(Modal modal, RequestOptions options = null); | |||||
| } | } | ||||
| } | } | ||||
| @@ -41,6 +41,11 @@ namespace Discord | |||||
| /// <summary> | /// <summary> | ||||
| /// Respond with a set of choices to a autocomplete interaction. | /// Respond with a set of choices to a autocomplete interaction. | ||||
| /// </summary> | /// </summary> | ||||
| ApplicationCommandAutocompleteResult = 8 | |||||
| ApplicationCommandAutocompleteResult = 8, | |||||
| /// <summary> | |||||
| /// Respond by showing the user a modal. | |||||
| /// </summary> | |||||
| Modal = 9, | |||||
| } | } | ||||
| } | } | ||||
| @@ -23,6 +23,11 @@ namespace Discord | |||||
| /// <summary> | /// <summary> | ||||
| /// An autocomplete request sent from discord. | /// An autocomplete request sent from discord. | ||||
| /// </summary> | /// </summary> | ||||
| ApplicationCommandAutocomplete = 4 | |||||
| ApplicationCommandAutocomplete = 4, | |||||
| /// <summary> | |||||
| /// A modal sent from discord. | |||||
| /// </summary> | |||||
| ModalSubmit = 5, | |||||
| } | } | ||||
| } | } | ||||
| @@ -276,6 +276,11 @@ namespace Discord | |||||
| /// <returns>A <see cref="MessageComponent"/> that can be sent with <see cref="IMessageChannel.SendMessageAsync"/>.</returns> | /// <returns>A <see cref="MessageComponent"/> that can be sent with <see cref="IMessageChannel.SendMessageAsync"/>.</returns> | ||||
| public MessageComponent Build() | 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 | return _actionRows != null | ||||
| ? new MessageComponent(_actionRows.Select(x => x.Build()).ToList()) | ? new MessageComponent(_actionRows.Select(x => x.Build()).ToList()) | ||||
| : MessageComponent.Empty; | : MessageComponent.Empty; | ||||
| @@ -1093,4 +1098,248 @@ namespace Discord | |||||
| return new SelectMenuOption(Label, Value, Description, Emote, IsDefault); | return new SelectMenuOption(Label, Value, Description, Emote, IsDefault); | ||||
| } | } | ||||
| } | } | ||||
| public class TextInputBuilder | |||||
| { | |||||
| public const int LargestMaxLength = 4000; | |||||
| /// <summary> | |||||
| /// Gets or sets the custom id of the current text input. | |||||
| /// </summary> | |||||
| /// <exception cref="ArgumentException" accessor="set"><see cref="CustomId"/> length exceeds <see cref="ComponentBuilder.MaxCustomIdLength"/></exception> | |||||
| /// <exception cref="ArgumentException" accessor="set"><see cref="CustomId"/> length subceeds 1.</exception> | |||||
| 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 | |||||
| }; | |||||
| } | |||||
| /// <summary> | |||||
| /// Gets or sets the style of the current text input. | |||||
| /// </summary> | |||||
| public TextInputStyle Style { get; set; } = TextInputStyle.Short; | |||||
| /// <summary> | |||||
| /// Gets or sets the label of the current text input. | |||||
| /// </summary> | |||||
| public string Label { get; set; } | |||||
| /// <summary> | |||||
| /// Gets or sets the placeholder of the current text input. | |||||
| /// </summary> | |||||
| /// <exception cref="ArgumentException"><see cref="Placeholder"/> is longer than 100 characters</exception> | |||||
| public string Placeholder | |||||
| { | |||||
| get => _placeholder; | |||||
| set => _placeholder = (value?.Length ?? 0) <= 100 | |||||
| ? value | |||||
| : throw new ArgumentException("Placeholder cannot have more than 100 characters."); | |||||
| } | |||||
| /// <summary> | |||||
| /// Gets or sets the minimum length of the current text input. | |||||
| /// </summary> | |||||
| /// <exception cref="ArgumentOutOfRangeException"><see cref="MinLength"/> is less than 0.</exception> | |||||
| /// <exception cref="ArgumentOutOfRangeException"><see cref="MinLength"/> is greater than <see cref="LargestMaxLength"/>.</exception> | |||||
| /// <exception cref="ArgumentOutOfRangeException"><see cref="MinLength"/> is greater than <see cref="MaxLength"/>.</exception> | |||||
| 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; | |||||
| } | |||||
| } | |||||
| /// <summary> | |||||
| /// Gets or sets the maximum length of the current text input. | |||||
| /// </summary> | |||||
| /// <exception cref="ArgumentOutOfRangeException"><see cref="MaxLength"/> is less than 0.</exception> | |||||
| /// <exception cref="ArgumentOutOfRangeException"><see cref="MaxLength"/> is greater than <see cref="LargestMaxLength"/>.</exception> | |||||
| /// <exception cref="ArgumentOutOfRangeException"><see cref="MaxLength"/> is less than <see cref="MinLength"/>.</exception> | |||||
| 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; | |||||
| } | |||||
| } | |||||
| /// <summary> | |||||
| /// Gets or sets whether the user is required to input text. | |||||
| /// </summary> | |||||
| public bool? Required { get; set; } | |||||
| /// <summary> | |||||
| /// Gets or sets the default value of the text input. | |||||
| /// </summary> | |||||
| /// <exception cref="ArgumentOutOfRangeException"><see cref="Value.Length"/> is less than 0.</exception> | |||||
| /// <exception cref="ArgumentOutOfRangeException"> | |||||
| /// <see cref="Value.Length"/> is greater than <see cref="LargestMaxLength"/> or <see cref="MaxLength"/>. | |||||
| /// </exception> | |||||
| 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; | |||||
| /// <summary> | |||||
| /// Creates a new instance of a <see cref="TextInputBuilder"/>. | |||||
| /// </summary> | |||||
| /// <param name="label">The text input's label.</param> | |||||
| /// <param name="style">The text input's style.</param> | |||||
| /// <param name="customId">The text input's custom id.</param> | |||||
| /// <param name="placeholder">The text input's placeholder.</param> | |||||
| /// <param name="minLength">The text input's minimum length.</param> | |||||
| /// <param name="maxLength">The text input's maximum length.</param> | |||||
| /// <param name="required">The text input's required value.</param> | |||||
| 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; | |||||
| } | |||||
| /// <summary> | |||||
| /// Creates a new instance of a <see cref="TextInputBuilder"/>. | |||||
| /// </summary> | |||||
| public TextInputBuilder() | |||||
| { | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets the label of the current builder. | |||||
| /// </summary> | |||||
| /// <param name="label">The value to set.</param> | |||||
| /// <returns>The current builder. </returns> | |||||
| public TextInputBuilder WithLabel(string label) | |||||
| { | |||||
| Label = label; | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets the style of the current builder. | |||||
| /// </summary> | |||||
| /// <param name="style">The value to set.</param> | |||||
| /// <returns>The current builder. </returns> | |||||
| public TextInputBuilder WithStyle(TextInputStyle style) | |||||
| { | |||||
| Style = style; | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets the custom id of the current builder. | |||||
| /// </summary> | |||||
| /// <param name="customId">The value to set.</param> | |||||
| /// <returns>The current builder. </returns> | |||||
| public TextInputBuilder WithCustomId(string customId) | |||||
| { | |||||
| CustomId = customId; | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets the placeholder of the current builder. | |||||
| /// </summary> | |||||
| /// <param name="placeholder">The value to set.</param> | |||||
| /// <returns>The current builder. </returns> | |||||
| public TextInputBuilder WithPlaceholder(string placeholder) | |||||
| { | |||||
| Placeholder = placeholder; | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets the value of the current builder. | |||||
| /// </summary> | |||||
| /// <param name="value">The value to set</param> | |||||
| /// <returns>The current builder.</returns> | |||||
| public TextInputBuilder WithValue(string value) | |||||
| { | |||||
| Value = value; | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets the minimum length of the current builder. | |||||
| /// </summary> | |||||
| /// <param name="placeholder">The value to set.</param> | |||||
| /// <returns>The current builder. </returns> | |||||
| public TextInputBuilder WithMinLength(int minLength) | |||||
| { | |||||
| MinLength = minLength; | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets the maximum length of the current builder. | |||||
| /// </summary> | |||||
| /// <param name="placeholder">The value to set.</param> | |||||
| /// <returns>The current builder. </returns> | |||||
| public TextInputBuilder WithMaxLength(int maxLength) | |||||
| { | |||||
| MaxLength = maxLength; | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets the required value of the current builder. | |||||
| /// </summary> | |||||
| /// <param name="required">The value to set.</param> | |||||
| /// <returns>The current builder. </returns> | |||||
| 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); | |||||
| } | |||||
| } | |||||
| } | } | ||||
| @@ -18,6 +18,16 @@ namespace Discord | |||||
| /// <summary> | /// <summary> | ||||
| /// A select menu for picking from choices. | /// A select menu for picking from choices. | ||||
| /// </summary> | /// </summary> | ||||
| SelectMenu = 3 | |||||
| SelectMenu = 3, | |||||
| /// <summary> | |||||
| /// A box for entering text. | |||||
| /// </summary> | |||||
| TextInput = 4, | |||||
| /// <summary> | |||||
| /// An interaction sent when a model is submitted. | |||||
| /// </summary> | |||||
| ModalSubmit = 5, | |||||
| } | } | ||||
| } | } | ||||
| @@ -8,7 +8,7 @@ namespace Discord | |||||
| public interface IComponentInteractionData : IDiscordInteractionData | public interface IComponentInteractionData : IDiscordInteractionData | ||||
| { | { | ||||
| /// <summary> | /// <summary> | ||||
| /// Gets the components Custom Id that was clicked. | |||||
| /// Gets the component's Custom Id that was clicked. | |||||
| /// </summary> | /// </summary> | ||||
| string CustomId { get; } | string CustomId { get; } | ||||
| @@ -21,5 +21,10 @@ namespace Discord | |||||
| /// Gets the value(s) of a <see cref="SelectMenuComponent"/> interaction response. | /// Gets the value(s) of a <see cref="SelectMenuComponent"/> interaction response. | ||||
| /// </summary> | /// </summary> | ||||
| IReadOnlyCollection<string> Values { get; } | IReadOnlyCollection<string> Values { get; } | ||||
| /// <summary> | |||||
| /// Gets the value of a <see cref="TextInputComponent"/> interaction response. | |||||
| /// </summary> | |||||
| public string Value { get; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -0,0 +1,62 @@ | |||||
| namespace Discord | |||||
| { | |||||
| /// <summary> | |||||
| /// Respresents a <see cref="IMessageComponent"/> text input. | |||||
| /// </summary> | |||||
| public class TextInputComponent : IMessageComponent | |||||
| { | |||||
| /// <inheritdoc/> | |||||
| public ComponentType Type => ComponentType.TextInput; | |||||
| /// <inheritdoc/> | |||||
| public string CustomId { get; } | |||||
| /// <summary> | |||||
| /// Gets the label of the component; this is the text shown above it. | |||||
| /// </summary> | |||||
| public string Label { get; } | |||||
| /// <summary> | |||||
| /// Gets the placeholder of the component. | |||||
| /// </summary> | |||||
| public string Placeholder { get; } | |||||
| /// <summary> | |||||
| /// Gets the minimum length of the inputted text. | |||||
| /// </summary> | |||||
| public int? MinLength { get; } | |||||
| /// <summary> | |||||
| /// Gets the maximum length of the inputted text. | |||||
| /// </summary> | |||||
| public int? MaxLength { get; } | |||||
| /// <summary> | |||||
| /// Gets the style of the component. | |||||
| /// </summary> | |||||
| public TextInputStyle Style { get; } | |||||
| /// <summary> | |||||
| /// Gets whether users are required to input text. | |||||
| /// </summary> | |||||
| public bool? Required { get; } | |||||
| /// <summary> | |||||
| /// Gets the default value of the component. | |||||
| /// </summary> | |||||
| 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; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,14 @@ | |||||
| namespace Discord | |||||
| { | |||||
| public enum TextInputStyle | |||||
| { | |||||
| /// <summary> | |||||
| /// Intended for short, single-line text. | |||||
| /// </summary> | |||||
| Short = 1, | |||||
| /// <summary> | |||||
| /// Intended for longer or multiline text. | |||||
| /// </summary> | |||||
| Paragraph = 2, | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,13 @@ | |||||
| namespace Discord | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents an interaction type for Modals. | |||||
| /// </summary> | |||||
| public interface IModalInteraction : IDiscordInteraction | |||||
| { | |||||
| /// <summary> | |||||
| /// Gets the data received with this interaction; contains the clicked button. | |||||
| /// </summary> | |||||
| new IModalInteractionData Data { get; } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,20 @@ | |||||
| using System.Collections.Generic; | |||||
| namespace Discord | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents the data sent with the <see cref="IModalInteraction"/>. | |||||
| /// </summary> | |||||
| public interface IModalInteractionData : IDiscordInteractionData | |||||
| { | |||||
| /// <summary> | |||||
| /// Gets the <see cref="Modal"/>'s Custom Id. | |||||
| /// </summary> | |||||
| string CustomId { get; } | |||||
| /// <summary> | |||||
| /// Gets the <see cref="Modal"/> components submitted by the user. | |||||
| /// </summary> | |||||
| IReadOnlyCollection<IComponentInteractionData> Components { get; } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,37 @@ | |||||
| using System; | |||||
| using System.Collections.Generic; | |||||
| using System.Linq; | |||||
| using System.Text; | |||||
| using System.Threading.Tasks; | |||||
| namespace Discord | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents a modal interaction. | |||||
| /// </summary> | |||||
| public class Modal : IMessageComponent | |||||
| { | |||||
| /// <inheritdoc/> | |||||
| public ComponentType Type => ComponentType.ModalSubmit; | |||||
| /// <summary> | |||||
| /// Gets the title of the modal. | |||||
| /// </summary> | |||||
| public string Title { get; set; } | |||||
| /// <inheritdoc/> | |||||
| public string CustomId { get; set; } | |||||
| /// <summary> | |||||
| /// Gets the components in the modal. | |||||
| /// </summary> | |||||
| public ModalComponent Component { get; set; } | |||||
| internal Modal(string title, string customId, ModalComponent components) | |||||
| { | |||||
| Title = title; | |||||
| CustomId = customId; | |||||
| Component = components; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| { | |||||
| /// <summary> | |||||
| /// Gets or sets the components of the current modal. | |||||
| /// </summary> | |||||
| public ModalComponentBuilder Components { get; set; } = new(); | |||||
| /// <summary> | |||||
| /// Gets or sets the title of the current modal. | |||||
| /// </summary> | |||||
| public string Title { get; set; } | |||||
| /// <summary> | |||||
| /// Gets or sets the custom id of the current modal. | |||||
| /// </summary> | |||||
| 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() { } | |||||
| /// <summary> | |||||
| /// Creates a new instance of a <see cref="ModalBuilder"/> | |||||
| /// </summary> | |||||
| /// <param name="title">The modal's title.</param> | |||||
| /// <param name="customId">The modal's customId.</param> | |||||
| /// <param name="components">The modal's components.</param> | |||||
| /// <exception cref="ArgumentException">Only TextInputComponents are allowed.</exception> | |||||
| public ModalBuilder(string title, string customId, ModalComponentBuilder components = null) | |||||
| { | |||||
| Title = title; | |||||
| CustomId = customId; | |||||
| Components = components ?? new(); | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets the title of the current modal. | |||||
| /// </summary> | |||||
| /// <param name="title">The value to set the title to.</param> | |||||
| /// <returns>The current builder.</returns> | |||||
| public ModalBuilder WithTitle(string title) | |||||
| { | |||||
| Title = title; | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets the custom id of the current modal. | |||||
| /// </summary> | |||||
| /// <param name="title">The value to set the custom id to.</param> | |||||
| /// <returns>The current builder.</returns> | |||||
| public ModalBuilder WithCustomId(string customId) | |||||
| { | |||||
| CustomId = customId; | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Adds a component to the current builder. | |||||
| /// </summary> | |||||
| /// <param name="title">The component to add.</param> | |||||
| /// <returns>The current builder.</returns> | |||||
| public ModalBuilder AddTextInput(TextInputBuilder component) | |||||
| { | |||||
| Components.WithTextInput(component); | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Adds a <see cref="TextInputBuilder"/> to the current builder. | |||||
| /// </summary> | |||||
| /// <param name="customId">The input's custom id.</param> | |||||
| /// <param name="label">The input's label.</param> | |||||
| /// <param name="placeholder">The input's placeholder text.</param> | |||||
| /// <param name="minLength">The input's minimum length.</param> | |||||
| /// <param name="maxLength">The input's maximum length.</param> | |||||
| /// <param name="style">The input's style.</param> | |||||
| /// <returns>The current builder.</returns> | |||||
| 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)); | |||||
| /// <summary> | |||||
| /// Adds multiple components to the current builder. | |||||
| /// </summary> | |||||
| /// <param name="components">The components to add.</param> | |||||
| /// <returns>The current builder</returns> | |||||
| public ModalBuilder AddComponents(List<IMessageComponent> components, int row) | |||||
| { | |||||
| components.ForEach(x => Components.AddComponent(x, row)); | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Builds this builder into a <see cref="Modal"/>. | |||||
| /// </summary> | |||||
| /// <returns>A <see cref="Modal"/> with the same values as this builder.</returns> | |||||
| /// <exception cref="ArgumentException">Only TextInputComponents are allowed.</exception> | |||||
| /// <exception cref="ArgumentException">Modals must have a custom id.</exception> | |||||
| /// <exception cref="ArgumentException">Modals must have a title.</exception> | |||||
| 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()); | |||||
| } | |||||
| } | |||||
| /// <summary> | |||||
| /// Represents a builder for creating a <see cref="ModalComponent"/>. | |||||
| /// </summary> | |||||
| public class ModalComponentBuilder | |||||
| { | |||||
| /// <summary> | |||||
| /// The max length of a <see cref="IMessageComponent.CustomId"/>. | |||||
| /// </summary> | |||||
| public const int MaxCustomIdLength = 100; | |||||
| /// <summary> | |||||
| /// The max amount of rows a <see cref="ModalComponent"/> can have. | |||||
| /// </summary> | |||||
| public const int MaxActionRowCount = 5; | |||||
| /// <summary> | |||||
| /// Gets or sets the Action Rows for this Component Builder. | |||||
| /// </summary> | |||||
| /// <exception cref="ArgumentNullException" accessor="set"><see cref="ActionRows"/> cannot be null.</exception> | |||||
| /// <exception cref="ArgumentException" accessor="set"><see cref="ActionRows"/> count exceeds <see cref="MaxActionRowCount"/>.</exception> | |||||
| public List<ActionRowBuilder> 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<ActionRowBuilder> _actionRows; | |||||
| /// <summary> | |||||
| /// Creates a new builder from the provided list of components. | |||||
| /// </summary> | |||||
| /// <param name="components">The components to create the builder from.</param> | |||||
| /// <returns>The newly created builder.</returns> | |||||
| public static ComponentBuilder FromComponents(IReadOnlyCollection<IMessageComponent> 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; | |||||
| } | |||||
| } | |||||
| /// <summary> | |||||
| /// Adds a <see cref="TextInputBuilder"/> to the <see cref="ComponentBuilder"/> at the specific row. | |||||
| /// If the row cannot accept the component then it will add it to a row that can. | |||||
| /// </summary> | |||||
| /// <param name="customId">The input's custom id.</param> | |||||
| /// <param name="label">The input's label.</param> | |||||
| /// <param name="placeholder">The input's placeholder text.</param> | |||||
| /// <param name="minLength">The input's minimum length.</param> | |||||
| /// <param name="maxLength">The input's maximum length.</param> | |||||
| /// <param name="style">The input's style.</param> | |||||
| /// <returns>The current builder.</returns> | |||||
| 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); | |||||
| /// <summary> | |||||
| /// Adds a <see cref="TextInputBuilder"/> to the <see cref="ModalComponentBuilder"/> at the specific row. | |||||
| /// If the row cannot accept the component then it will add it to a row that can. | |||||
| /// </summary> | |||||
| /// <param name="text">The <see cref="TextInputBuilder"> to add.</param> | |||||
| /// <param name="row">The row to add the text input.</param> | |||||
| /// <exception cref="InvalidOperationException">There are no more rows to add a text input to.</exception> | |||||
| /// <exception cref="ArgumentException"><paramref name="row"/> must be less than <see cref="MaxActionRowCount"/>.</exception> | |||||
| /// <returns>The current builder.</returns> | |||||
| public ModalComponentBuilder WithTextInput(TextInputBuilder text, int row = 0) | |||||
| { | |||||
| Preconditions.LessThan(row, MaxActionRowCount, nameof(row)); | |||||
| var builtButton = text.Build(); | |||||
| if (_actionRows == null) | |||||
| { | |||||
| _actionRows = new List<ActionRowBuilder> | |||||
| { | |||||
| 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; | |||||
| } | |||||
| /// <summary> | |||||
| /// Get a <see cref="ModalComponent"/> representing the builder. | |||||
| /// </summary> | |||||
| /// <returns>A <see cref="ModalComponent"/> representing the builder.</returns> | |||||
| public ModalComponent Build() | |||||
| => new (ActionRows?.Select(x => x.Build()).ToList()); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,20 @@ | |||||
| using System.Collections.Generic; | |||||
| namespace Discord | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents a component object used in <see cref="Modal"/>s. | |||||
| /// </summary> | |||||
| public class ModalComponent | |||||
| { | |||||
| /// <summary> | |||||
| /// Gets the components to be used in a modal. | |||||
| /// </summary> | |||||
| public IReadOnlyCollection<ActionRowComponent> Components { get; } | |||||
| internal ModalComponent(List<ActionRowComponent> components) | |||||
| { | |||||
| Components = components; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,44 @@ | |||||
| using System; | |||||
| namespace Discord.Interactions | |||||
| { | |||||
| /// <summary> | |||||
| /// Create a Modal interaction handler. CustomId represents | |||||
| /// the CustomId of the Modal that will be handled. | |||||
| /// </summary> | |||||
| /// <remarks> | |||||
| /// <see cref="GroupAttribute"/>s will add prefixes to this command if <see cref="IgnoreGroupNames"/> is set to <see langword="false"/> | |||||
| /// CustomID supports a Wild Card pattern where you can use the <see cref="InteractionServiceConfig.WildCardExpression"/> to match a set of CustomIDs. | |||||
| /// </remarks> | |||||
| [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] | |||||
| public sealed class ModalInteractionAttribute : Attribute | |||||
| { | |||||
| /// <summary> | |||||
| /// Gets the string to compare the Modal CustomIDs with. | |||||
| /// </summary> | |||||
| public string CustomId { get; } | |||||
| /// <summary> | |||||
| /// Gets <see langword="true"/> if <see cref="GroupAttribute"/>s will be ignored while creating this command and this method will be treated as a top level command. | |||||
| /// </summary> | |||||
| public bool IgnoreGroupNames { get; } | |||||
| /// <summary> | |||||
| /// Gets the run mode this command gets executed with. | |||||
| /// </summary> | |||||
| public RunMode RunMode { get; } | |||||
| /// <summary> | |||||
| /// Create a command for modal interaction handling. | |||||
| /// </summary> | |||||
| /// <param name="customId">String to compare the modal CustomIDs with.</param> | |||||
| /// <param name="ignoreGroupNames">If <see langword="true"/> <see cref="GroupAttribute"/>s will be ignored while creating this command and this method will be treated as a top level command.</param> | |||||
| /// <param name="runMode">Set the run mode of the command.</param> | |||||
| public ModalInteractionAttribute(string customId, bool ignoreGroupNames = false, RunMode runMode = RunMode.Default) | |||||
| { | |||||
| CustomId = customId; | |||||
| IgnoreGroupNames = ignoreGroupNames; | |||||
| RunMode = runMode; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,25 @@ | |||||
| using System; | |||||
| namespace Discord.Interactions | |||||
| { | |||||
| /// <summary> | |||||
| /// Creates a custom label for an modal input. | |||||
| /// </summary> | |||||
| [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] | |||||
| public class InputLabelAttribute : Attribute | |||||
| { | |||||
| /// <summary> | |||||
| /// Gets the label of the input. | |||||
| /// </summary> | |||||
| public string Label { get; } | |||||
| /// <summary> | |||||
| /// Creates a custom label for an modal input. | |||||
| /// </summary> | |||||
| /// <param name="label">The label of the input.</param> | |||||
| public InputLabelAttribute(string label) | |||||
| { | |||||
| Label = label; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,32 @@ | |||||
| using System; | |||||
| namespace Discord.Interactions | |||||
| { | |||||
| /// <summary> | |||||
| /// Mark an <see cref="IModal"/> property as a modal input field. | |||||
| /// </summary> | |||||
| [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] | |||||
| public abstract class ModalInputAttribute : Attribute | |||||
| { | |||||
| /// <summary> | |||||
| /// Gets the custom id of the text input. | |||||
| /// </summary> | |||||
| public string CustomId { get; } | |||||
| /// <summary> | |||||
| /// Gets the type of the component. | |||||
| /// </summary> | |||||
| public abstract ComponentType ComponentType { get; } | |||||
| /// <summary> | |||||
| /// Create a new <see cref="ModalInputAttribute"/>. | |||||
| /// </summary> | |||||
| /// <param name="label">The label of the input.</param> | |||||
| /// <param name="customId">The custom id of the input.</param> | |||||
| /// <param name="required">Whether the user is required to input a value.></param> | |||||
| protected ModalInputAttribute(string customId) | |||||
| { | |||||
| CustomId = customId; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,55 @@ | |||||
| namespace Discord.Interactions | |||||
| { | |||||
| /// <summary> | |||||
| /// Marks a <see cref="IModal"/> property as a text input. | |||||
| /// </summary> | |||||
| public sealed class ModalTextInputAttribute : ModalInputAttribute | |||||
| { | |||||
| /// <inheritdoc/> | |||||
| public override ComponentType ComponentType => ComponentType.TextInput; | |||||
| /// <summary> | |||||
| /// Gets the style of the text input. | |||||
| /// </summary> | |||||
| public TextInputStyle Style { get; } | |||||
| /// <summary> | |||||
| /// Gets the placeholder of the text input. | |||||
| /// </summary> | |||||
| public string Placeholder { get; } | |||||
| /// <summary> | |||||
| /// Gets the minimum length of the text input. | |||||
| /// </summary> | |||||
| public int MinLength { get; } | |||||
| /// <summary> | |||||
| /// Gets the maximum length of the text input. | |||||
| /// </summary> | |||||
| public int MaxLength { get; } | |||||
| /// <summary> | |||||
| /// Gets the initial value to be displayed by this input. | |||||
| /// </summary> | |||||
| public string InitialValue { get; } | |||||
| /// <summary> | |||||
| /// Create a new <see cref="ModalTextInputAttribute"/>. | |||||
| /// </summary> | |||||
| /// <param name="customId"The custom id of the text input.></param> | |||||
| /// <param name="style">The style of the text input.</param> | |||||
| /// <param name="placeholder">The placeholder of the text input.</param> | |||||
| /// <param name="minLength">The minimum length of the text input's content.</param> | |||||
| /// <param name="maxLength">The maximum length of the text input's content.</param> | |||||
| /// <param name="initValue">The initial value to be displayed by this input.</param> | |||||
| public ModalTextInputAttribute(string customId, TextInputStyle style = TextInputStyle.Short, string placeholder = null, int minLength = 1, int maxLength = 4000, string initValue = null) | |||||
| : base(customId) | |||||
| { | |||||
| Style = style; | |||||
| Placeholder = placeholder; | |||||
| MinLength = minLength; | |||||
| MaxLength = maxLength; | |||||
| InitialValue = initValue; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,25 @@ | |||||
| using System; | |||||
| namespace Discord.Interactions | |||||
| { | |||||
| /// <summary> | |||||
| /// Sets the input as required or optional. | |||||
| /// </summary> | |||||
| [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] | |||||
| public class RequiredInputAttribute : Attribute | |||||
| { | |||||
| /// <summary> | |||||
| /// Gets whether or not user input is required for this input. | |||||
| /// </summary> | |||||
| public bool IsRequired { get; } | |||||
| /// <summary> | |||||
| /// Sets the input as required or optinal. | |||||
| /// </summary> | |||||
| /// <param name="isRequired">Whether or not user input is required for this input.</param> | |||||
| public RequiredInputAttribute(bool isRequired = true) | |||||
| { | |||||
| IsRequired = isRequired; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,44 @@ | |||||
| using System; | |||||
| namespace Discord.Interactions.Builders | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents a builder for creating a <see cref="ModalCommandInfo"/>. | |||||
| /// </summary> | |||||
| public class ModalCommandBuilder : CommandBuilder<ModalCommandInfo, ModalCommandBuilder, ModalCommandParameterBuilder> | |||||
| { | |||||
| protected override ModalCommandBuilder Instance => this; | |||||
| /// <summary> | |||||
| /// Initializes a new <see cref="ModalCommandBuilder"/>. | |||||
| /// </summary> | |||||
| /// <param name="module">Parent module of this modal.</param> | |||||
| public ModalCommandBuilder(ModuleBuilder module) : base(module) { } | |||||
| /// <summary> | |||||
| /// Initializes a new <see cref="ModalCommandBuilder"/>. | |||||
| /// </summary> | |||||
| /// <param name="module">Parent module of this modal.</param> | |||||
| /// <param name="name">Name of this modal.</param> | |||||
| /// <param name="callback">Execution callback of this modal.</param> | |||||
| public ModalCommandBuilder(ModuleBuilder module, string name, ExecuteCallback callback) : base(module, name, callback) { } | |||||
| /// <summary> | |||||
| /// Adds a modal parameter to the parameters collection. | |||||
| /// </summary> | |||||
| /// <param name="configure"><see cref="ModalCommandParameterBuilder"/> factory.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public override ModalCommandBuilder AddParameter(Action<ModalCommandParameterBuilder> configure) | |||||
| { | |||||
| var parameter = new ModalCommandParameterBuilder(this); | |||||
| configure(parameter); | |||||
| AddParameters(parameter); | |||||
| return this; | |||||
| } | |||||
| internal override ModalCommandInfo Build(ModuleInfo module, InteractionService commandService) => | |||||
| new(this, module, commandService); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,105 @@ | |||||
| using System; | |||||
| using System.Collections.Generic; | |||||
| namespace Discord.Interactions.Builders | |||||
| { | |||||
| /// <summary> | |||||
| /// Represent a builder for creating <see cref="InputComponentInfo"/>. | |||||
| /// </summary> | |||||
| public interface IInputComponentBuilder | |||||
| { | |||||
| /// <summary> | |||||
| /// Gets the parent modal of this input component. | |||||
| /// </summary> | |||||
| ModalBuilder Modal { get; } | |||||
| /// <summary> | |||||
| /// Gets the custom id of this input component. | |||||
| /// </summary> | |||||
| string CustomId { get; } | |||||
| /// <summary> | |||||
| /// Gets the label of this input component. | |||||
| /// </summary> | |||||
| string Label { get; } | |||||
| /// <summary> | |||||
| /// Gets whether this input component is required. | |||||
| /// </summary> | |||||
| bool IsRequired { get; } | |||||
| /// <summary> | |||||
| /// Gets the component type of this input component. | |||||
| /// </summary> | |||||
| ComponentType ComponentType { get; } | |||||
| /// <summary> | |||||
| /// Get the reference type of this input component. | |||||
| /// </summary> | |||||
| Type Type { get; } | |||||
| /// <summary> | |||||
| /// Gets the default value of this input component. | |||||
| /// </summary> | |||||
| object DefaultValue { get; } | |||||
| /// <summary> | |||||
| /// Gets a collection of the attributes of this component. | |||||
| /// </summary> | |||||
| IReadOnlyCollection<Attribute> Attributes { get; } | |||||
| /// <summary> | |||||
| /// Sets <see cref="CustomId"/>. | |||||
| /// </summary> | |||||
| /// <param name="customId">New value of the <see cref="CustomId"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| IInputComponentBuilder WithCustomId(string customId); | |||||
| /// <summary> | |||||
| /// Sets <see cref="Label"/>. | |||||
| /// </summary> | |||||
| /// <param name="label">New value of the <see cref="Label"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| IInputComponentBuilder WithLabel(string label); | |||||
| /// <summary> | |||||
| /// Sets <see cref="IsRequired"/>. | |||||
| /// </summary> | |||||
| /// <param name="isRequired">New value of the <see cref="IsRequired"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| IInputComponentBuilder SetIsRequired(bool isRequired); | |||||
| /// <summary> | |||||
| /// Sets <see cref="Type"/>. | |||||
| /// </summary> | |||||
| /// <param name="type">New value of the <see cref="Type"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| IInputComponentBuilder WithType(Type type); | |||||
| /// <summary> | |||||
| /// Sets <see cref="DefaultValue"/>. | |||||
| /// </summary> | |||||
| /// <param name="value">New value of the <see cref="DefaultValue"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| IInputComponentBuilder SetDefaultValue(object value); | |||||
| /// <summary> | |||||
| /// Adds attributes to <see cref="Attributes"/>. | |||||
| /// </summary> | |||||
| /// <param name="attributes">New attributes to be added to <see cref="Attributes"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| IInputComponentBuilder WithAttributes(params Attribute[] attributes); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,164 @@ | |||||
| using System; | |||||
| using System.Collections.Generic; | |||||
| namespace Discord.Interactions.Builders | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents the base builder class for creating <see cref="InputComponentInfo"/>. | |||||
| /// </summary> | |||||
| /// <typeparam name="TInfo">The <see cref="InputComponentInfo"/> this builder yields when built.</typeparam> | |||||
| /// <typeparam name="TBuilder">Inherited <see cref="InputComponentBuilder{TInfo, TBuilder}"/> type.</typeparam> | |||||
| public abstract class InputComponentBuilder<TInfo, TBuilder> : IInputComponentBuilder | |||||
| where TInfo : InputComponentInfo | |||||
| where TBuilder : InputComponentBuilder<TInfo, TBuilder> | |||||
| { | |||||
| private readonly List<Attribute> _attributes; | |||||
| protected abstract TBuilder Instance { get; } | |||||
| /// <inheritdoc/> | |||||
| public ModalBuilder Modal { get; } | |||||
| /// <inheritdoc/> | |||||
| public string CustomId { get; set; } | |||||
| /// <inheritdoc/> | |||||
| public string Label { get; set; } | |||||
| /// <inheritdoc/> | |||||
| public bool IsRequired { get; set; } = true; | |||||
| /// <inheritdoc/> | |||||
| public ComponentType ComponentType { get; internal set; } | |||||
| /// <inheritdoc/> | |||||
| public Type Type { get; private set; } | |||||
| /// <inheritdoc/> | |||||
| public object DefaultValue { get; set; } | |||||
| /// <inheritdoc/> | |||||
| public IReadOnlyCollection<Attribute> Attributes => _attributes; | |||||
| /// <summary> | |||||
| /// Creates an instance of <see cref="InputComponentBuilder{TInfo, TBuilder}"/> | |||||
| /// </summary> | |||||
| /// <param name="modal">Parent modal of this input component.</param> | |||||
| public InputComponentBuilder(ModalBuilder modal) | |||||
| { | |||||
| Modal = modal; | |||||
| _attributes = new(); | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets <see cref="CustomId"/>. | |||||
| /// </summary> | |||||
| /// <param name="customId">New value of the <see cref="CustomId"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public TBuilder WithCustomId(string customId) | |||||
| { | |||||
| CustomId = customId; | |||||
| return Instance; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets <see cref="Label"/>. | |||||
| /// </summary> | |||||
| /// <param name="label">New value of the <see cref="Label"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public TBuilder WithLabel(string label) | |||||
| { | |||||
| Label = label; | |||||
| return Instance; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets <see cref="IsRequired"/>. | |||||
| /// </summary> | |||||
| /// <param name="isRequired">New value of the <see cref="IsRequired"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public TBuilder SetIsRequired(bool isRequired) | |||||
| { | |||||
| IsRequired = isRequired; | |||||
| return Instance; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets <see cref="ComponentType"/>. | |||||
| /// </summary> | |||||
| /// <param name="componentType">New value of the <see cref="ComponentType"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public TBuilder WithComponentType(ComponentType componentType) | |||||
| { | |||||
| ComponentType = componentType; | |||||
| return Instance; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets <see cref="Type"/>. | |||||
| /// </summary> | |||||
| /// <param name="type">New value of the <see cref="Type"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public TBuilder WithType(Type type) | |||||
| { | |||||
| Type = type; | |||||
| return Instance; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets <see cref="DefaultValue"/>. | |||||
| /// </summary> | |||||
| /// <param name="value">New value of the <see cref="DefaultValue"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public TBuilder SetDefaultValue(object value) | |||||
| { | |||||
| DefaultValue = value; | |||||
| return Instance; | |||||
| } | |||||
| /// <summary> | |||||
| /// Adds attributes to <see cref="Attributes"/>. | |||||
| /// </summary> | |||||
| /// <param name="attributes">New attributes to be added to <see cref="Attributes"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public TBuilder WithAttributes(params Attribute[] attributes) | |||||
| { | |||||
| _attributes.AddRange(attributes); | |||||
| return Instance; | |||||
| } | |||||
| internal abstract TInfo Build(ModalInfo modal); | |||||
| //IInputComponentBuilder | |||||
| /// <inheritdoc/> | |||||
| IInputComponentBuilder IInputComponentBuilder.WithCustomId(string customId) => WithCustomId(customId); | |||||
| /// <inheritdoc/> | |||||
| IInputComponentBuilder IInputComponentBuilder.WithLabel(string label) => WithCustomId(label); | |||||
| /// <inheritdoc/> | |||||
| IInputComponentBuilder IInputComponentBuilder.WithType(Type type) => WithType(type); | |||||
| /// <inheritdoc/> | |||||
| IInputComponentBuilder IInputComponentBuilder.SetDefaultValue(object value) => SetDefaultValue(value); | |||||
| /// <inheritdoc/> | |||||
| IInputComponentBuilder IInputComponentBuilder.WithAttributes(params Attribute[] attributes) => WithAttributes(attributes); | |||||
| /// <inheritdoc/> | |||||
| IInputComponentBuilder IInputComponentBuilder.SetIsRequired(bool isRequired) => SetIsRequired(isRequired); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,109 @@ | |||||
| namespace Discord.Interactions.Builders | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents a builder for creating <see cref="TextInputComponentInfo"/>. | |||||
| /// </summary> | |||||
| public class TextInputComponentBuilder : InputComponentBuilder<TextInputComponentInfo, TextInputComponentBuilder> | |||||
| { | |||||
| protected override TextInputComponentBuilder Instance => this; | |||||
| /// <summary> | |||||
| /// Gets and sets the style of the text input. | |||||
| /// </summary> | |||||
| public TextInputStyle Style { get; set; } | |||||
| /// <summary> | |||||
| /// Gets and sets the placeholder of the text input. | |||||
| /// </summary> | |||||
| public string Placeholder { get; set; } | |||||
| /// <summary> | |||||
| /// Gets and sets the minimum length of the text input. | |||||
| /// </summary> | |||||
| public int MinLength { get; set; } | |||||
| /// <summary> | |||||
| /// Gets and sets the maximum length of the text input. | |||||
| /// </summary> | |||||
| public int MaxLength { get; set; } | |||||
| /// <summary> | |||||
| /// Gets and sets the initial value to be displayed by this input. | |||||
| /// </summary> | |||||
| public string InitialValue { get; set; } | |||||
| /// <summary> | |||||
| /// Initializes a new <see cref="TextInputComponentBuilder"/>. | |||||
| /// </summary> | |||||
| /// <param name="modal">Parent modal of this component.</param> | |||||
| public TextInputComponentBuilder(ModalBuilder modal) : base(modal) { } | |||||
| /// <summary> | |||||
| /// Sets <see cref="Style"/>. | |||||
| /// </summary> | |||||
| /// <param name="style">New value of the <see cref="SetValue(string)"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public TextInputComponentBuilder WithStyle(TextInputStyle style) | |||||
| { | |||||
| Style = style; | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets <see cref="Placeholder"/>. | |||||
| /// </summary> | |||||
| /// <param name="placeholder">New value of the <see cref="Placeholder"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public TextInputComponentBuilder WithPlaceholder(string placeholder) | |||||
| { | |||||
| Placeholder = placeholder; | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets <see cref="MinLength"/>. | |||||
| /// </summary> | |||||
| /// <param name="minLenght">New value of the <see cref="MinLength"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public TextInputComponentBuilder WithMinLenght(int minLenght) | |||||
| { | |||||
| MinLength = minLenght; | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets <see cref="MaxLength"/>. | |||||
| /// </summary> | |||||
| /// <param name="maxLenght">New value of the <see cref="MaxLength"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public TextInputComponentBuilder WithMaxLenght(int maxLenght) | |||||
| { | |||||
| MaxLength = maxLenght; | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets <see cref="InitialValue"/>. | |||||
| /// </summary> | |||||
| /// <param name="value">New value of the <see cref="InitialValue"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public TextInputComponentBuilder WithInitialValue(string value) | |||||
| { | |||||
| InitialValue = value; | |||||
| return this; | |||||
| } | |||||
| internal override TextInputComponentInfo Build(ModalInfo modal) => | |||||
| new(this, modal); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,81 @@ | |||||
| using System; | |||||
| using System.Collections.Generic; | |||||
| using System.Linq; | |||||
| namespace Discord.Interactions.Builders | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents a builder for creating <see cref="ModalInfo"/>. | |||||
| /// </summary> | |||||
| public class ModalBuilder | |||||
| { | |||||
| internal readonly List<IInputComponentBuilder> _components; | |||||
| /// <summary> | |||||
| /// Gets the initialization delegate for this modal. | |||||
| /// </summary> | |||||
| public ModalInitializer ModalInitializer { get; internal set; } | |||||
| /// <summary> | |||||
| /// Gets the title of this modal. | |||||
| /// </summary> | |||||
| public string Title { get; set; } | |||||
| /// <summary> | |||||
| /// Gets the <see cref="IModal"/> implementation used to initialize this object. | |||||
| /// </summary> | |||||
| public Type Type { get; } | |||||
| /// <summary> | |||||
| /// Gets a collection of the components of this modal. | |||||
| /// </summary> | |||||
| public IReadOnlyCollection<IInputComponentBuilder> 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(); | |||||
| } | |||||
| /// <summary> | |||||
| /// Initializes a new <see cref="ModalBuilder"/> | |||||
| /// </summary> | |||||
| /// <param name="modalInitializer">The initialization delegate for this modal.</param> | |||||
| public ModalBuilder(Type type, ModalInitializer modalInitializer) : this(type) | |||||
| { | |||||
| ModalInitializer = modalInitializer; | |||||
| } | |||||
| /// <summary> | |||||
| /// Sets <see cref="Title"/>. | |||||
| /// </summary> | |||||
| /// <param name="title">New value of the <see cref="Title"/>.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public ModalBuilder WithTitle(string title) | |||||
| { | |||||
| Title = title; | |||||
| return this; | |||||
| } | |||||
| /// <summary> | |||||
| /// Adds text components to <see cref="TextComponents"/>. | |||||
| /// </summary> | |||||
| /// <param name="configure">Text Component builder factory.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public ModalBuilder AddTextComponent(Action<TextInputComponentBuilder> configure) | |||||
| { | |||||
| var builder = new TextInputComponentBuilder(this); | |||||
| configure(builder); | |||||
| _components.Add(builder); | |||||
| return this; | |||||
| } | |||||
| internal ModalInfo Build() => new(this); | |||||
| } | |||||
| } | |||||
| @@ -16,6 +16,7 @@ namespace Discord.Interactions.Builders | |||||
| private readonly List<ContextCommandBuilder> _contextCommands; | private readonly List<ContextCommandBuilder> _contextCommands; | ||||
| private readonly List<ComponentCommandBuilder> _componentCommands; | private readonly List<ComponentCommandBuilder> _componentCommands; | ||||
| private readonly List<AutocompleteCommandBuilder> _autocompleteCommands; | private readonly List<AutocompleteCommandBuilder> _autocompleteCommands; | ||||
| private readonly List<ModalCommandBuilder> _modalCommands; | |||||
| /// <summary> | /// <summary> | ||||
| /// Gets the underlying Interaction Service. | /// Gets the underlying Interaction Service. | ||||
| @@ -92,6 +93,11 @@ namespace Discord.Interactions.Builders | |||||
| /// </summary> | /// </summary> | ||||
| public IReadOnlyList<AutocompleteCommandBuilder> AutocompleteCommands => _autocompleteCommands; | public IReadOnlyList<AutocompleteCommandBuilder> AutocompleteCommands => _autocompleteCommands; | ||||
| /// <summary> | |||||
| /// Gets a collection of the Modal Commands of this module. | |||||
| /// </summary> | |||||
| public IReadOnlyList<ModalCommandBuilder> ModalCommands => _modalCommands; | |||||
| internal TypeInfo TypeInfo { get; set; } | internal TypeInfo TypeInfo { get; set; } | ||||
| internal ModuleBuilder (InteractionService interactionService, ModuleBuilder parent = null) | internal ModuleBuilder (InteractionService interactionService, ModuleBuilder parent = null) | ||||
| @@ -105,6 +111,7 @@ namespace Discord.Interactions.Builders | |||||
| _contextCommands = new List<ContextCommandBuilder>(); | _contextCommands = new List<ContextCommandBuilder>(); | ||||
| _componentCommands = new List<ComponentCommandBuilder>(); | _componentCommands = new List<ComponentCommandBuilder>(); | ||||
| _autocompleteCommands = new List<AutocompleteCommandBuilder>(); | _autocompleteCommands = new List<AutocompleteCommandBuilder>(); | ||||
| _modalCommands = new List<ModalCommandBuilder> (); | |||||
| _preconditions = new List<PreconditionAttribute>(); | _preconditions = new List<PreconditionAttribute>(); | ||||
| } | } | ||||
| @@ -152,7 +159,7 @@ namespace Discord.Interactions.Builders | |||||
| /// <returns> | /// <returns> | ||||
| /// The builder instance. | /// The builder instance. | ||||
| /// </returns> | /// </returns> | ||||
| public ModuleBuilder WithDefaultPermision (bool permission) | |||||
| public ModuleBuilder WithDefaultPermission (bool permission) | |||||
| { | { | ||||
| DefaultPermission = permission; | DefaultPermission = permission; | ||||
| return this; | return this; | ||||
| @@ -310,6 +317,21 @@ namespace Discord.Interactions.Builders | |||||
| configure(command); | configure(command); | ||||
| _autocompleteCommands.Add(command); | _autocompleteCommands.Add(command); | ||||
| return this; | return this; | ||||
| } | |||||
| /// Adds a modal command builder to <see cref="ModalCommands"/>. | |||||
| /// </summary> | |||||
| /// <param name="configure"><see cref="ModalCommands"/> factory.</param> | |||||
| /// <returns> | |||||
| /// The builder instance. | |||||
| /// </returns> | |||||
| public ModuleBuilder AddModalCommand(Action<ModalCommandBuilder> configure) | |||||
| { | |||||
| var command = new ModalCommandBuilder(this); | |||||
| configure(command); | |||||
| _modalCommands.Add(command); | |||||
| return this; | |||||
| } | } | ||||
| /// <summary> | /// <summary> | ||||
| @@ -103,6 +103,7 @@ namespace Discord.Interactions.Builders | |||||
| var validContextCommands = methods.Where(IsValidContextCommandDefinition); | var validContextCommands = methods.Where(IsValidContextCommandDefinition); | ||||
| var validInteractions = methods.Where(IsValidComponentCommandDefinition); | var validInteractions = methods.Where(IsValidComponentCommandDefinition); | ||||
| var validAutocompleteCommands = methods.Where(IsValidAutocompleteCommandDefinition); | var validAutocompleteCommands = methods.Where(IsValidAutocompleteCommandDefinition); | ||||
| var validModalCommands = methods.Where(IsValidModalCommanDefinition); | |||||
| Func<IServiceProvider, IInteractionModuleBase> createInstance = commandService._useCompiledLambda ? | Func<IServiceProvider, IInteractionModuleBase> createInstance = commandService._useCompiledLambda ? | ||||
| ReflectionUtils<IInteractionModuleBase>.CreateLambdaBuilder(typeInfo, commandService) : ReflectionUtils<IInteractionModuleBase>.CreateBuilder(typeInfo, commandService); | ReflectionUtils<IInteractionModuleBase>.CreateLambdaBuilder(typeInfo, commandService) : ReflectionUtils<IInteractionModuleBase>.CreateBuilder(typeInfo, commandService); | ||||
| @@ -118,6 +119,9 @@ namespace Discord.Interactions.Builders | |||||
| foreach(var method in validAutocompleteCommands) | foreach(var method in validAutocompleteCommands) | ||||
| builder.AddAutocompleteCommand(x => BuildAutocompleteCommand(x, createInstance, method, commandService, services)); | 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<TypeInfo> subModules, IList<TypeInfo> builtTypes, InteractionService commandService, | private static void BuildSubModules (ModuleBuilder parent, IEnumerable<TypeInfo> subModules, IList<TypeInfo> builtTypes, InteractionService commandService, | ||||
| @@ -298,6 +302,47 @@ namespace Discord.Interactions.Builders | |||||
| builder.Callback = CreateCallback(createInstance, methodInfo, commandService); | builder.Callback = CreateCallback(createInstance, methodInfo, commandService); | ||||
| } | } | ||||
| private static void BuildModalCommand(ModalCommandBuilder builder, Func<IServiceProvider, IInteractionModuleBase> 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<IServiceProvider, IInteractionModuleBase> createInstance, | private static ExecuteCallback CreateCallback (Func<IServiceProvider, IInteractionModuleBase> createInstance, | ||||
| MethodInfo methodInfo, InteractionService commandService) | MethodInfo methodInfo, InteractionService commandService) | ||||
| { | { | ||||
| @@ -400,7 +445,9 @@ namespace Discord.Interactions.Builders | |||||
| builder.Name = Regex.Replace(builder.Name, "(?<=[a-z])(?=[A-Z])", "-").ToLower(); | builder.Name = Regex.Replace(builder.Name, "(?<=[a-z])(?=[A-Z])", "-").ToLower(); | ||||
| } | } | ||||
| private static void BuildParameter (CommandParameterBuilder builder, ParameterInfo paramInfo) | |||||
| private static void BuildParameter<TInfo, TBuilder> (ParameterBuilder<TInfo, TBuilder> builder, ParameterInfo paramInfo) | |||||
| where TInfo : class, IParameterInfo | |||||
| where TBuilder : ParameterBuilder<TInfo, TBuilder> | |||||
| { | { | ||||
| var attributes = paramInfo.GetCustomAttributes(); | var attributes = paramInfo.GetCustomAttributes(); | ||||
| var paramType = paramInfo.ParameterType; | var paramType = paramInfo.ParameterType; | ||||
| @@ -428,6 +475,84 @@ namespace Discord.Interactions.Builders | |||||
| } | } | ||||
| #endregion | #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<ModalInputAttribute>()?.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<IModal>.CreateLambdaMemberInit(modalType.GetTypeInfo(), modalType.GetConstructor(Type.EmptyTypes), x => x.IsDefined(typeof(ModalInputAttribute))); | |||||
| builder.ModalInitializer = (args) => memberInit(Array.Empty<object>(), 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) | internal static bool IsValidModuleDefinition (TypeInfo typeInfo) | ||||
| { | { | ||||
| return ModuleTypeInfo.IsAssignableFrom(typeInfo) && | return ModuleTypeInfo.IsAssignableFrom(typeInfo) && | ||||
| @@ -467,5 +592,21 @@ namespace Discord.Interactions.Builders | |||||
| !methodInfo.IsGenericMethod && | !methodInfo.IsGenericMethod && | ||||
| methodInfo.GetParameters().Length == 0; | methodInfo.GetParameters().Length == 0; | ||||
| } | } | ||||
| private static bool IsValidModalCommanDefinition(MethodInfo methodInfo) | |||||
| { | |||||
| return methodInfo.IsDefined(typeof(ModalInteractionAttribute)) && | |||||
| (methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task<RuntimeResult>)) && | |||||
| !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)); | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| @@ -0,0 +1,45 @@ | |||||
| using System; | |||||
| namespace Discord.Interactions.Builders | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents a builder for creating <see cref="ModalCommandBuilder"/>. | |||||
| /// </summary> | |||||
| public class ModalCommandParameterBuilder : ParameterBuilder<ModalCommandParameterInfo, ModalCommandParameterBuilder> | |||||
| { | |||||
| protected override ModalCommandParameterBuilder Instance => this; | |||||
| /// <summary> | |||||
| /// Gets the built <see cref="ModalInfo"/> class for this parameter, if <see cref="IsModalParameter"/> is <see langword="true"/>. | |||||
| /// </summary> | |||||
| public ModalInfo Modal { get; private set; } | |||||
| /// <summary> | |||||
| /// Gets whether or not this parameter is an <see cref="IModal"/>. | |||||
| /// </summary> | |||||
| public bool IsModalParameter => Modal is not null; | |||||
| internal ModalCommandParameterBuilder(ICommandBuilder command) : base(command) { } | |||||
| /// <summary> | |||||
| /// Initializes a new <see cref="ModalCommandParameterBuilder"/>. | |||||
| /// </summary> | |||||
| /// <param name="command">Parent command of this parameter.</param> | |||||
| /// <param name="name">Name of this command.</param> | |||||
| /// <param name="type">Type of this parameter.</param> | |||||
| public ModalCommandParameterBuilder(ICommandBuilder command, string name, Type type) : base(command, name, type) { } | |||||
| /// <inheritdoc/> | |||||
| 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); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,13 @@ | |||||
| namespace Discord.Interactions | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents a generic <see cref="Modal"/> for use with the interaction service. | |||||
| /// </summary> | |||||
| public interface IModal | |||||
| { | |||||
| /// <summary> | |||||
| /// Gets the modal's title. | |||||
| /// </summary> | |||||
| string Title { get; } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,37 @@ | |||||
| using System; | |||||
| using System.Threading.Tasks; | |||||
| namespace Discord.Interactions | |||||
| { | |||||
| public static class IDiscordInteractionExtentions | |||||
| { | |||||
| /// <summary> | |||||
| /// Respond to an interaction with a <see cref="IModal"/>. | |||||
| /// </summary> | |||||
| /// <typeparam name="T">Type of the <see cref="IModal"/> implementation.</typeparam> | |||||
| /// <param name="interaction">The interaction to respond to.</param> | |||||
| /// <param name="options">The request options for this <see langword="async"/> request.</param> | |||||
| /// <returns>A task that represents the asynchronous operation of responding to the interaction.</returns> | |||||
| public static async Task RespondWithModalAsync<T>(this IDiscordInteraction interaction, string customId, RequestOptions options = null) | |||||
| where T : class, IModal | |||||
| { | |||||
| if (!ModalUtils.TryGet<T>(out var modalInfo)) | |||||
| throw new ArgumentException($"{typeof(T).FullName} isn't referenced by any registered Modal Interaction Command and doesn't have a cached {typeof(ModalInfo)}"); | |||||
| var builder = new ModalBuilder(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); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -35,7 +35,7 @@ namespace Discord.Interactions | |||||
| /// <param name="services">Services that will be used while initializing the <see cref="InteractionModuleBase{T}"/>.</param> | /// <param name="services">Services that will be used while initializing the <see cref="InteractionModuleBase{T}"/>.</param> | ||||
| /// <param name="additionalArgs">Provide additional string parameters to the method along with the auto generated parameters.</param> | /// <param name="additionalArgs">Provide additional string parameters to the method along with the auto generated parameters.</param> | ||||
| /// <returns> | /// <returns> | ||||
| /// A task representing the asyncronous command execution process. | |||||
| /// A task representing the asynchronous command execution process. | |||||
| /// </returns> | /// </returns> | ||||
| public async Task<IResult> ExecuteAsync(IInteractionContext context, IServiceProvider services, params string[] additionalArgs) | public async Task<IResult> ExecuteAsync(IInteractionContext context, IServiceProvider services, params string[] additionalArgs) | ||||
| { | { | ||||
| @@ -0,0 +1,81 @@ | |||||
| using System; | |||||
| using System.Collections.Generic; | |||||
| using System.Collections.Immutable; | |||||
| using System.Linq; | |||||
| using System.Threading.Tasks; | |||||
| namespace Discord.Interactions | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents the info class of an attribute based method for handling Modal Interaction events. | |||||
| /// </summary> | |||||
| public class ModalCommandInfo : CommandInfo<ModalCommandParameterInfo> | |||||
| { | |||||
| /// <summary> | |||||
| /// Gets the <see cref="ModalInfo"/> class for this commands <see cref="IModal"/> parameter. | |||||
| /// </summary> | |||||
| public ModalInfo Modal { get; } | |||||
| /// <inheritdoc/> | |||||
| public override bool SupportsWildCards => true; | |||||
| /// <inheritdoc/> | |||||
| public override IReadOnlyCollection<ModalCommandParameterInfo> 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; | |||||
| } | |||||
| /// <inheritdoc/> | |||||
| public override async Task<IResult> ExecuteAsync(IInteractionContext context, IServiceProvider services) | |||||
| => await ExecuteAsync(context, services, null).ConfigureAwait(false); | |||||
| /// <summary> | |||||
| /// Execute this command using dependency injection. | |||||
| /// </summary> | |||||
| /// <param name="context">Context that will be injected to the <see cref="InteractionModuleBase{T}"/>.</param> | |||||
| /// <param name="services">Services that will be used while initializing the <see cref="InteractionModuleBase{T}"/>.</param> | |||||
| /// <param name="additionalArgs">Provide additional string parameters to the method along with the auto generated parameters.</param> | |||||
| /// <returns> | |||||
| /// A task representing the asynchronous command execution process. | |||||
| /// </returns> | |||||
| public async Task<IResult> 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<object>(); | |||||
| 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; | |||||
| } | |||||
| } | |||||
| /// <inheritdoc/> | |||||
| protected override Task InvokeModuleEvent(IInteractionContext context, IResult result) | |||||
| => CommandService._modalCommandExecutedEvent.InvokeAsync(this, context, result); | |||||
| /// <inheritdoc/> | |||||
| 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}"; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,64 @@ | |||||
| using System; | |||||
| using System.Collections.Generic; | |||||
| using System.Collections.Immutable; | |||||
| namespace Discord.Interactions | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents the base info class for <see cref="IModal"/> input components. | |||||
| /// </summary> | |||||
| public abstract class InputComponentInfo | |||||
| { | |||||
| /// <summary> | |||||
| /// Gets the parent modal of this component. | |||||
| /// </summary> | |||||
| public ModalInfo Modal { get; } | |||||
| /// <summary> | |||||
| /// Gets the custom id of this component. | |||||
| /// </summary> | |||||
| public string CustomId { get; } | |||||
| /// <summary> | |||||
| /// Gets the label of this component. | |||||
| /// </summary> | |||||
| public string Label { get; } | |||||
| /// <summary> | |||||
| /// Gets whether or not this component requires a user input. | |||||
| /// </summary> | |||||
| public bool IsRequired { get; } | |||||
| /// <summary> | |||||
| /// Gets the type of this component. | |||||
| /// </summary> | |||||
| public ComponentType ComponentType { get; } | |||||
| /// <summary> | |||||
| /// Gets the reference type of this component. | |||||
| /// </summary> | |||||
| public Type Type { get; } | |||||
| /// <summary> | |||||
| /// Gets the default value of this component. | |||||
| /// </summary> | |||||
| public object DefaultValue { get; } | |||||
| /// <summary> | |||||
| /// Gets a collection of the attributes of this command. | |||||
| /// </summary> | |||||
| public IReadOnlyCollection<Attribute> Attributes { get; } | |||||
| protected InputComponentInfo(Builders.IInputComponentBuilder builder, ModalInfo modal) | |||||
| { | |||||
| Modal = modal; | |||||
| CustomId = builder.CustomId; | |||||
| Label = builder.Label; | |||||
| IsRequired = builder.IsRequired; | |||||
| ComponentType = builder.ComponentType; | |||||
| Type = builder.Type; | |||||
| DefaultValue = builder.DefaultValue; | |||||
| Attributes = builder.Attributes.ToImmutableArray(); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,42 @@ | |||||
| namespace Discord.Interactions | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents the <see cref="InputComponentInfo"/> class for <see cref="ComponentType.TextInput"/> type. | |||||
| /// </summary> | |||||
| public class TextInputComponentInfo : InputComponentInfo | |||||
| { | |||||
| /// <summary> | |||||
| /// Gets the style of the text input. | |||||
| /// </summary> | |||||
| public TextInputStyle Style { get; } | |||||
| /// <summary> | |||||
| /// Gets the placeholder of the text input. | |||||
| /// </summary> | |||||
| public string Placeholder { get; } | |||||
| /// <summary> | |||||
| /// Gets the minimum length of the text input. | |||||
| /// </summary> | |||||
| public int MinLength { get; } | |||||
| /// <summary> | |||||
| /// Gets the maximum length of the text input. | |||||
| /// </summary> | |||||
| public int MaxLength { get; } | |||||
| /// <summary> | |||||
| /// Gets the initial value to be displayed by this input. | |||||
| /// </summary> | |||||
| 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; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,90 @@ | |||||
| using System; | |||||
| using System.Collections.Generic; | |||||
| using System.Collections.Immutable; | |||||
| using System.Linq; | |||||
| namespace Discord.Interactions | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents a cached object initialization delegate. | |||||
| /// </summary> | |||||
| /// <param name="args">Property arguments array.</param> | |||||
| /// <returns> | |||||
| /// Returns the constructed object. | |||||
| /// </returns> | |||||
| public delegate IModal ModalInitializer(object[] args); | |||||
| /// <summary> | |||||
| /// Represents the info class of an <see cref="IModal"/> form. | |||||
| /// </summary> | |||||
| public class ModalInfo | |||||
| { | |||||
| internal readonly ModalInitializer _initializer; | |||||
| /// <summary> | |||||
| /// Gets the title of this modal. | |||||
| /// </summary> | |||||
| public string Title { get; } | |||||
| /// <summary> | |||||
| /// Gets the <see cref="IModal"/> implementation used to initialize this object. | |||||
| /// </summary> | |||||
| public Type Type { get; } | |||||
| /// <summary> | |||||
| /// Gets a collection of the components of this modal. | |||||
| /// </summary> | |||||
| public IReadOnlyCollection<InputComponentInfo> Components { get; } | |||||
| /// <summary> | |||||
| /// Gets a collection of the text components of this modal. | |||||
| /// </summary> | |||||
| public IReadOnlyCollection<TextInputComponentInfo> 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<TextInputComponentInfo>().ToImmutableArray(); | |||||
| _initializer = builder.ModalInitializer; | |||||
| } | |||||
| /// <summary> | |||||
| /// Creates an <see cref="IModal"/> and fills it with provided message components. | |||||
| /// </summary> | |||||
| /// <param name="components"><see cref="IModalInteraction"/> that will be injected into the modal.</param> | |||||
| /// <returns> | |||||
| /// A <see cref="IModal"/> filled with the provided components. | |||||
| /// </returns> | |||||
| 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); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -68,6 +68,8 @@ namespace Discord.Interactions | |||||
| /// </summary> | /// </summary> | ||||
| public IReadOnlyCollection<AutocompleteCommandInfo> AutocompleteCommands { get; } | public IReadOnlyCollection<AutocompleteCommandInfo> AutocompleteCommands { get; } | ||||
| public IReadOnlyCollection<ModalCommandInfo> ModalCommands { get; } | |||||
| /// <summary> | /// <summary> | ||||
| /// Gets the declaring type of this module, if <see cref="IsSubModule"/> is <see langword="true"/>. | /// Gets the declaring type of this module, if <see cref="IsSubModule"/> is <see langword="true"/>. | ||||
| /// </summary> | /// </summary> | ||||
| @@ -112,6 +114,7 @@ namespace Discord.Interactions | |||||
| ContextCommands = BuildContextCommands(builder).ToImmutableArray(); | ContextCommands = BuildContextCommands(builder).ToImmutableArray(); | ||||
| ComponentCommands = BuildComponentCommands(builder).ToImmutableArray(); | ComponentCommands = BuildComponentCommands(builder).ToImmutableArray(); | ||||
| AutocompleteCommands = BuildAutocompleteCommands(builder).ToImmutableArray(); | AutocompleteCommands = BuildAutocompleteCommands(builder).ToImmutableArray(); | ||||
| ModalCommands = BuildModalCommands(builder).ToImmutableArray(); | |||||
| SubModules = BuildSubModules(builder, commandService, services).ToImmutableArray(); | SubModules = BuildSubModules(builder, commandService, services).ToImmutableArray(); | ||||
| Attributes = BuildAttributes(builder).ToImmutableArray(); | Attributes = BuildAttributes(builder).ToImmutableArray(); | ||||
| Preconditions = BuildPreconditions(builder).ToImmutableArray(); | Preconditions = BuildPreconditions(builder).ToImmutableArray(); | ||||
| @@ -171,6 +174,16 @@ namespace Discord.Interactions | |||||
| return result; | return result; | ||||
| } | } | ||||
| private IEnumerable<ModalCommandInfo> BuildModalCommands(ModuleBuilder builder) | |||||
| { | |||||
| var result = new List<ModalCommandInfo>(); | |||||
| foreach (var commandBuilder in builder.ModalCommands) | |||||
| result.Add(commandBuilder.Build(this, CommandService)); | |||||
| return result; | |||||
| } | |||||
| private IEnumerable<Attribute> BuildAttributes (ModuleBuilder builder) | private IEnumerable<Attribute> BuildAttributes (ModuleBuilder builder) | ||||
| { | { | ||||
| var result = new List<Attribute>(); | var result = new List<Attribute>(); | ||||
| @@ -0,0 +1,28 @@ | |||||
| using Discord.Interactions.Builders; | |||||
| namespace Discord.Interactions | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents the base parameter info class for <see cref="InteractionService"/> modals. | |||||
| /// </summary> | |||||
| public class ModalCommandParameterInfo : CommandParameterInfo | |||||
| { | |||||
| /// <summary> | |||||
| /// Gets the <see cref="ModalInfo"/> class for this parameter if <see cref="IsModalParameter"/> is true. | |||||
| /// </summary> | |||||
| public ModalInfo Modal { get; private set; } | |||||
| /// <summary> | |||||
| /// Gets whether this parameter is an <see cref="IModal"/> | |||||
| /// </summary> | |||||
| public bool IsModalParameter => Modal is not null; | |||||
| /// <inheritdoc/> | |||||
| public new ModalCommandInfo Command => base.Command as ModalCommandInfo; | |||||
| internal ModalCommandParameterInfo(ModalCommandParameterBuilder builder, ICommandInfo command) : base(builder, command) | |||||
| { | |||||
| Modal = builder.Modal; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -114,6 +114,13 @@ namespace Discord.Interactions | |||||
| var response = await Context.Interaction.GetOriginalResponseAsync().ConfigureAwait(false); | var response = await Context.Interaction.GetOriginalResponseAsync().ConfigureAwait(false); | ||||
| await response.DeleteAsync().ConfigureAwait(false); | await response.DeleteAsync().ConfigureAwait(false); | ||||
| } | } | ||||
| /// <inheritdoc cref="IDiscordInteraction.RespondWithModalAsync(Modal, RequestOptions)"/> | |||||
| protected virtual async Task RespondWithModalAsync(Modal modal, RequestOptions options = null) => await Context.Interaction.RespondWithModalAsync(modal); | |||||
| /// <inheritdoc cref="IDiscordInteractionExtentions.RespondWithModalAsync(IDiscordInteraction, IModal, RequestOptions)"/> | |||||
| protected virtual async Task RespondWithModalAsync<T>(string customId, RequestOptions options = null) where T : class, IModal | |||||
| => await Context.Interaction.RespondWithModalAsync<T>(customId, options); | |||||
| //IInteractionModuleBase | //IInteractionModuleBase | ||||
| @@ -53,21 +53,29 @@ namespace Discord.Interactions | |||||
| public event Func<IAutocompleteHandler, IInteractionContext, IResult, Task> AutocompleteHandlerExecuted { add { _autocompleteHandlerExecutedEvent.Add(value); } remove { _autocompleteHandlerExecutedEvent.Remove(value); } } | public event Func<IAutocompleteHandler, IInteractionContext, IResult, Task> AutocompleteHandlerExecuted { add { _autocompleteHandlerExecutedEvent.Add(value); } remove { _autocompleteHandlerExecutedEvent.Remove(value); } } | ||||
| internal readonly AsyncEvent<Func<IAutocompleteHandler, IInteractionContext, IResult, Task>> _autocompleteHandlerExecutedEvent = new(); | internal readonly AsyncEvent<Func<IAutocompleteHandler, IInteractionContext, IResult, Task>> _autocompleteHandlerExecutedEvent = new(); | ||||
| /// <summary> | |||||
| /// Occurs when a Modal command is executed. | |||||
| /// </summary> | |||||
| public event Func<ModalCommandInfo, IInteractionContext, IResult, Task> ModalCommandExecuted { add { _modalCommandExecutedEvent.Add(value); } remove { _modalCommandExecutedEvent.Remove(value); } } | |||||
| internal readonly AsyncEvent<Func<ModalCommandInfo, IInteractionContext, IResult, Task>> _modalCommandExecutedEvent = new(); | |||||
| private readonly ConcurrentDictionary<Type, ModuleInfo> _typedModuleDefs; | private readonly ConcurrentDictionary<Type, ModuleInfo> _typedModuleDefs; | ||||
| private readonly CommandMap<SlashCommandInfo> _slashCommandMap; | private readonly CommandMap<SlashCommandInfo> _slashCommandMap; | ||||
| private readonly ConcurrentDictionary<ApplicationCommandType, CommandMap<ContextCommandInfo>> _contextCommandMaps; | private readonly ConcurrentDictionary<ApplicationCommandType, CommandMap<ContextCommandInfo>> _contextCommandMaps; | ||||
| private readonly CommandMap<ComponentCommandInfo> _componentCommandMap; | private readonly CommandMap<ComponentCommandInfo> _componentCommandMap; | ||||
| private readonly CommandMap<AutocompleteCommandInfo> _autocompleteCommandMap; | private readonly CommandMap<AutocompleteCommandInfo> _autocompleteCommandMap; | ||||
| private readonly CommandMap<ModalCommandInfo> _modalCommandMap; | |||||
| private readonly HashSet<ModuleInfo> _moduleDefs; | private readonly HashSet<ModuleInfo> _moduleDefs; | ||||
| private readonly ConcurrentDictionary<Type, TypeConverter> _typeConverters; | private readonly ConcurrentDictionary<Type, TypeConverter> _typeConverters; | ||||
| private readonly ConcurrentDictionary<Type, Type> _genericTypeConverters; | private readonly ConcurrentDictionary<Type, Type> _genericTypeConverters; | ||||
| private readonly ConcurrentDictionary<Type, IAutocompleteHandler> _autocompleteHandlers = new(); | private readonly ConcurrentDictionary<Type, IAutocompleteHandler> _autocompleteHandlers = new(); | ||||
| private readonly ConcurrentDictionary<Type, ModalInfo> _modalInfos = new(); | |||||
| private readonly SemaphoreSlim _lock; | private readonly SemaphoreSlim _lock; | ||||
| internal readonly Logger _cmdLogger; | internal readonly Logger _cmdLogger; | ||||
| internal readonly LogManager _logManager; | internal readonly LogManager _logManager; | ||||
| internal readonly Func<DiscordRestClient> _getRestClient; | internal readonly Func<DiscordRestClient> _getRestClient; | ||||
| internal readonly bool _throwOnError, _useCompiledLambda, _enableAutocompleteHandlers, _autoServiceScopes; | |||||
| internal readonly bool _throwOnError, _useCompiledLambda, _enableAutocompleteHandlers, _autoServiceScopes, _exitOnMissingModalField; | |||||
| internal readonly string _wildCardExp; | internal readonly string _wildCardExp; | ||||
| internal readonly RunMode _runMode; | internal readonly RunMode _runMode; | ||||
| internal readonly RestResponseCallback _restResponseCallback; | internal readonly RestResponseCallback _restResponseCallback; | ||||
| @@ -97,6 +105,16 @@ namespace Discord.Interactions | |||||
| /// </summary> | /// </summary> | ||||
| public IReadOnlyCollection<ComponentCommandInfo> ComponentCommands => _moduleDefs.SelectMany(x => x.ComponentCommands).ToList(); | public IReadOnlyCollection<ComponentCommandInfo> ComponentCommands => _moduleDefs.SelectMany(x => x.ComponentCommands).ToList(); | ||||
| /// <summary> | |||||
| /// Represents all Modal Commands loaded within <see cref="InteractionService"/>. | |||||
| /// </summary> | |||||
| public IReadOnlyCollection<ModalCommandInfo> ModalCommands => _moduleDefs.SelectMany(x => x.ModalCommands).ToList(); | |||||
| /// <summary> | |||||
| /// Gets a collection of the cached <see cref="ModalInfo"/> classes that are referenced in registered <see cref="ModalCommandInfo"/>s. | |||||
| /// </summary> | |||||
| public IReadOnlyCollection<ModalInfo> Modals => ModalUtils.Modals; | |||||
| /// <summary> | /// <summary> | ||||
| /// Initialize a <see cref="InteractionService"/> with provided configurations. | /// Initialize a <see cref="InteractionService"/> with provided configurations. | ||||
| /// </summary> | /// </summary> | ||||
| @@ -145,6 +163,7 @@ namespace Discord.Interactions | |||||
| _contextCommandMaps = new ConcurrentDictionary<ApplicationCommandType, CommandMap<ContextCommandInfo>>(); | _contextCommandMaps = new ConcurrentDictionary<ApplicationCommandType, CommandMap<ContextCommandInfo>>(); | ||||
| _componentCommandMap = new CommandMap<ComponentCommandInfo>(this, config.InteractionCustomIdDelimiters); | _componentCommandMap = new CommandMap<ComponentCommandInfo>(this, config.InteractionCustomIdDelimiters); | ||||
| _autocompleteCommandMap = new CommandMap<AutocompleteCommandInfo>(this); | _autocompleteCommandMap = new CommandMap<AutocompleteCommandInfo>(this); | ||||
| _modalCommandMap = new CommandMap<ModalCommandInfo>(this, config.InteractionCustomIdDelimiters); | |||||
| _getRestClient = getRestClient; | _getRestClient = getRestClient; | ||||
| @@ -155,6 +174,7 @@ namespace Discord.Interactions | |||||
| _throwOnError = config.ThrowOnError; | _throwOnError = config.ThrowOnError; | ||||
| _wildCardExp = config.WildCardExpression; | _wildCardExp = config.WildCardExpression; | ||||
| _useCompiledLambda = config.UseCompiledLambda; | _useCompiledLambda = config.UseCompiledLambda; | ||||
| _exitOnMissingModalField = config.ExitOnMissingModalField; | |||||
| _enableAutocompleteHandlers = config.EnableAutocompleteHandlers; | _enableAutocompleteHandlers = config.EnableAutocompleteHandlers; | ||||
| _autoServiceScopes = config.AutoServiceScopes; | _autoServiceScopes = config.AutoServiceScopes; | ||||
| _restResponseCallback = config.RestResponseCallback; | _restResponseCallback = config.RestResponseCallback; | ||||
| @@ -509,6 +529,9 @@ namespace Discord.Interactions | |||||
| foreach (var command in module.AutocompleteCommands) | foreach (var command in module.AutocompleteCommands) | ||||
| _autocompleteCommandMap.AddCommand(command.GetCommandKeywords(), command); | _autocompleteCommandMap.AddCommand(command.GetCommandKeywords(), command); | ||||
| foreach (var command in module.ModalCommands) | |||||
| _modalCommandMap.AddCommand(command, command.IgnoreGroupNames); | |||||
| foreach (var subModule in module.SubModules) | foreach (var subModule in module.SubModules) | ||||
| LoadModuleInternal(subModule); | LoadModuleInternal(subModule); | ||||
| } | } | ||||
| @@ -654,7 +677,7 @@ namespace Discord.Interactions | |||||
| public async Task<IResult> ExecuteCommandAsync (IInteractionContext context, IServiceProvider services) | public async Task<IResult> ExecuteCommandAsync (IInteractionContext context, IServiceProvider services) | ||||
| { | { | ||||
| var interaction = context.Interaction; | var interaction = context.Interaction; | ||||
| return interaction switch | return interaction switch | ||||
| { | { | ||||
| ISlashCommandInteraction slashCommand => await ExecuteSlashCommandAsync(context, slashCommand, services).ConfigureAwait(false), | 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), | 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), | IMessageCommandInteraction messageCommand => await ExecuteContextCommandAsync(context, messageCommand.Data.Name, ApplicationCommandType.Message, services).ConfigureAwait(false), | ||||
| IAutocompleteInteraction autocomplete => await ExecuteAutocompleteAsync(context, autocomplete, 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"), | _ => 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); | return await commandResult.Command.ExecuteAsync(context, services).ConfigureAwait(false); | ||||
| } | } | ||||
| private async Task<IResult> 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) | internal TypeConverter GetTypeConverter (Type type, IServiceProvider services = null) | ||||
| { | { | ||||
| if (_typeConverters.TryGetValue(type, out var specific)) | if (_typeConverters.TryGetValue(type, out var specific)) | ||||
| @@ -819,6 +857,24 @@ namespace Discord.Interactions | |||||
| _genericTypeConverters[targetType] = converterType; | _genericTypeConverters[targetType] = converterType; | ||||
| } | } | ||||
| /// <summary> | |||||
| /// Loads and caches an <see cref="ModalInfo"/> for the provided <see cref="IModal"/>. | |||||
| /// </summary> | |||||
| /// <typeparam name="T">Type of <see cref="IModal"/> to be loaded.</typeparam> | |||||
| /// <returns> | |||||
| /// The built <see cref="ModalInfo"/> instance. | |||||
| /// </returns> | |||||
| /// <exception cref="InvalidOperationException"></exception> | |||||
| public ModalInfo AddModalInfo<T>() 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) | internal IAutocompleteHandler GetAutocompleteHandler(Type autocompleteHandlerType, IServiceProvider services = null) | ||||
| { | { | ||||
| services ??= EmptyServiceProvider.Instance; | services ??= EmptyServiceProvider.Instance; | ||||
| @@ -36,6 +36,9 @@ namespace Discord.Interactions | |||||
| /// <summary> | /// <summary> | ||||
| /// 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. | /// 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. | ||||
| /// </summary> | /// </summary> | ||||
| /// <remarks> | |||||
| /// For performance reasons, if you frequently use <see cref="Modal"/>s with the service, it is highly recommended that you enable compiled lambdas. | |||||
| /// </remarks> | |||||
| public bool UseCompiledLambda { get; set; } = false; | public bool UseCompiledLambda { get; set; } = false; | ||||
| /// <summary> | /// <summary> | ||||
| @@ -56,6 +59,11 @@ namespace Discord.Interactions | |||||
| /// Gets or sets delegate to be used by the <see cref="InteractionService"/> when responding to a Rest based interaction. | /// Gets or sets delegate to be used by the <see cref="InteractionService"/> when responding to a Rest based interaction. | ||||
| /// </summary> | /// </summary> | ||||
| public RestResponseCallback RestResponseCallback { get; set; } = (ctx, str) => Task.CompletedTask; | public RestResponseCallback RestResponseCallback { get; set; } = (ctx, str) => Task.CompletedTask; | ||||
| /// <summary> | |||||
| /// Gets or sets whether a command execution should exit when a modal command encounters a missing modal component value. | |||||
| /// </summary> | |||||
| public bool ExitOnMissingModalField { get; set; } = false; | |||||
| } | } | ||||
| /// <summary> | /// <summary> | ||||
| @@ -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<Type, ModalInfo> _modalInfos = new(); | |||||
| public static IReadOnlyCollection<ModalInfo> 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<T>() 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<T>(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<T>(out ModalInfo modalInfo) where T : class, IModal | |||||
| => TryRemove(typeof(T), out modalInfo); | |||||
| public static void Clear() => _modalInfos.Clear(); | |||||
| public static int Count() => _modalInfos.Count; | |||||
| } | |||||
| } | |||||
| @@ -112,6 +112,67 @@ namespace Discord.Interactions | |||||
| var parameters = constructor.GetParameters(); | var parameters = constructor.GetParameters(); | ||||
| var properties = GetProperties(typeInfo); | 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<object[], T> 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<Func<object[], T>>(newExp, argsExp).Compile(); | |||||
| } | |||||
| /// <summary> | |||||
| /// Create a compiled lambda property setter. | |||||
| /// </summary> | |||||
| internal static Action<T, object> 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<Action<T, object>>(assign, instanceParam, valueParam).Compile(); | |||||
| } | |||||
| internal static Func<object[], object[], T> CreateLambdaMemberInit(TypeInfo typeInfo, ConstructorInfo constructor, Predicate<PropertyInfo> 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 argsExp = Expression.Parameter(typeof(object[]), "args"); | ||||
| var propsExp = Expression.Parameter(typeof(object[]), "props"); | var propsExp = Expression.Parameter(typeof(object[]), "props"); | ||||
| @@ -137,17 +198,8 @@ namespace Discord.Interactions | |||||
| var memberInit = Expression.MemberInit(newExp, memberExps); | var memberInit = Expression.MemberInit(newExp, memberExps); | ||||
| var lambda = Expression.Lambda<Func<object[], object[], T>>(memberInit, argsExp, propsExp).Compile(); | var lambda = Expression.Lambda<Func<object[], object[], T>>(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); | var instance = lambda(args, props); | ||||
| return instance; | return instance; | ||||
| @@ -21,6 +21,7 @@ namespace Discord.API | |||||
| { | { | ||||
| ComponentType.Button => new ButtonComponent(x as Discord.ButtonComponent), | ComponentType.Button => new ButtonComponent(x as Discord.ButtonComponent), | ||||
| ComponentType.SelectMenu => new SelectMenuComponent(x as Discord.SelectMenuComponent), | ComponentType.SelectMenu => new SelectMenuComponent(x as Discord.SelectMenuComponent), | ||||
| ComponentType.TextInput => new TextInputComponent(x as Discord.TextInputComponent), | |||||
| _ => null | _ => null | ||||
| }; | }; | ||||
| }).ToArray(); | }).ToArray(); | ||||
| @@ -24,5 +24,11 @@ namespace Discord.API | |||||
| [JsonProperty("choices")] | [JsonProperty("choices")] | ||||
| public Optional<ApplicationCommandOptionChoice[]> Choices { get; set; } | public Optional<ApplicationCommandOptionChoice[]> Choices { get; set; } | ||||
| [JsonProperty("title")] | |||||
| public Optional<string> Title { get; set; } | |||||
| [JsonProperty("custom_id")] | |||||
| public Optional<string> CustomId { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -12,5 +12,8 @@ namespace Discord.API | |||||
| [JsonProperty("values")] | [JsonProperty("values")] | ||||
| public Optional<string[]> Values { get; set; } | public Optional<string[]> Values { get; set; } | ||||
| [JsonProperty("value")] | |||||
| public Optional<string> Value { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -26,6 +26,8 @@ namespace Discord.API | |||||
| [JsonProperty("disabled")] | [JsonProperty("disabled")] | ||||
| public bool Disabled { get; set; } | public bool Disabled { get; set; } | ||||
| [JsonProperty("values")] | |||||
| public Optional<string[]> Values { get; set; } | |||||
| public SelectMenuComponent() { } | public SelectMenuComponent() { } | ||||
| public SelectMenuComponent(Discord.SelectMenuComponent component) | public SelectMenuComponent(Discord.SelectMenuComponent component) | ||||
| @@ -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<string> Placeholder { get; set; } | |||||
| [JsonProperty("min_length")] | |||||
| public Optional<int> MinLength { get; set; } | |||||
| [JsonProperty("max_length")] | |||||
| public Optional<int> MaxLength { get; set; } | |||||
| [JsonProperty("value")] | |||||
| public Optional<string> Value { get; set; } | |||||
| [JsonProperty("required")] | |||||
| public Optional<bool> 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<int>.Unspecified; | |||||
| MaxLength = component.MaxLength ?? Optional<int>.Unspecified; | |||||
| Required = component.Required ?? Optional<bool>.Unspecified; | |||||
| Value = component.Value ?? Optional<string>.Unspecified; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -316,5 +316,45 @@ namespace Discord.Rest | |||||
| return SerializePayload(response); | return SerializePayload(response); | ||||
| } | } | ||||
| /// <summary> | |||||
| /// Responds to the interaction with a modal. | |||||
| /// </summary> | |||||
| /// <param name="modal">The modal to respond with.</param> | |||||
| /// <param name="options">The request options for this <see langword="async"/> request.</param> | |||||
| /// <returns>A string that contains json to write back to the incoming http request.</returns> | |||||
| /// <exception cref="TimeoutException"></exception> | |||||
| /// <exception cref="InvalidOperationException"></exception> | |||||
| 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); | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| @@ -446,6 +446,46 @@ namespace Discord.Rest | |||||
| return SerializePayload(response); | return SerializePayload(response); | ||||
| } | } | ||||
| /// <summary> | |||||
| /// Responds to the interaction with a modal. | |||||
| /// </summary> | |||||
| /// <param name="modal">The modal to respond with.</param> | |||||
| /// <param name="options">The request options for this <see langword="async"/> request.</param> | |||||
| /// <returns>A string that contains json to write back to the incoming http request.</returns> | |||||
| /// <exception cref="TimeoutException"></exception> | |||||
| /// <exception cref="InvalidOperationException"></exception> | |||||
| 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 | //IComponentInteraction | ||||
| /// <inheritdoc/> | /// <inheritdoc/> | ||||
| IComponentInteractionData IComponentInteraction.Data => Data; | IComponentInteractionData IComponentInteraction.Data => Data; | ||||
| @@ -27,11 +27,26 @@ namespace Discord.Rest | |||||
| /// </summary> | /// </summary> | ||||
| public IReadOnlyCollection<string> Values { get; } | public IReadOnlyCollection<string> Values { get; } | ||||
| /// <inheritdoc/> | |||||
| public string Value { get; } | |||||
| internal RestMessageComponentData(Model model) | internal RestMessageComponentData(Model model) | ||||
| { | { | ||||
| CustomId = model.CustomId; | CustomId = model.CustomId; | ||||
| Type = model.ComponentType; | Type = model.ComponentType; | ||||
| Values = model.Values.GetValueOrDefault(); | 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; | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| @@ -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 | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents a user submitted <see cref="Modal"/>. | |||||
| /// </summary> | |||||
| 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<RestModal> CreateAsync(DiscordRestClient client, ModelBase model) | |||||
| { | |||||
| var entity = new RestModal(client, model); | |||||
| await entity.UpdateAsync(client, model); | |||||
| return entity; | |||||
| } | |||||
| private object _lock = new object(); | |||||
| /// <summary> | |||||
| /// Acknowledges this interaction with the <see cref="InteractionResponseType.DeferredChannelMessageWithSource"/>. | |||||
| /// </summary> | |||||
| /// <returns> | |||||
| /// A string that contains json to write back to the incoming http request. | |||||
| /// </returns> | |||||
| 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<MessageFlags>.Unspecified | |||||
| } | |||||
| }; | |||||
| lock (_lock) | |||||
| { | |||||
| if (HasResponded) | |||||
| { | |||||
| throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); | |||||
| } | |||||
| } | |||||
| lock (_lock) | |||||
| { | |||||
| HasResponded = true; | |||||
| } | |||||
| return SerializePayload(response); | |||||
| } | |||||
| /// <summary> | |||||
| /// Sends a followup message for this interaction. | |||||
| /// </summary> | |||||
| /// <param name="text">The text of the message to be sent.</param> | |||||
| /// <param name="embeds">A array of embeds to send with this response. Max 10.</param> | |||||
| /// <param name="isTTS"><see langword="true"/> if the message should be read out by a text-to-speech reader, otherwise <see langword="false"/>.</param> | |||||
| /// <param name="ephemeral"><see langword="true"/> if the response should be hidden to everyone besides the invoker of the command, otherwise <see langword="false"/>.</param> | |||||
| /// <param name="allowedMentions">The allowed mentions for this response.</param> | |||||
| /// <param name="options">The request options for this response.</param> | |||||
| /// <param name="component">A <see cref="MessageComponent"/> to be sent with this response.</param> | |||||
| /// <param name="embed">A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored.</param> | |||||
| /// <returns> | |||||
| /// The sent message. | |||||
| /// </returns> | |||||
| public override async Task<RestFollowupMessage> 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<Embed>(); | |||||
| 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<API.AllowedMentions>.Unspecified, | |||||
| IsTTS = isTTS, | |||||
| Embeds = embeds.Select(x => x.ToModel()).ToArray(), | |||||
| Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified | |||||
| }; | |||||
| if (ephemeral) | |||||
| args.Flags = MessageFlags.Ephemeral; | |||||
| return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); | |||||
| } | |||||
| /// <summary> | |||||
| /// Sends a followup message for this interaction. | |||||
| /// </summary> | |||||
| /// <param name="text">The text of the message to be sent.</param> | |||||
| /// <param name="fileStream">The file to upload.</param> | |||||
| /// <param name="fileName">The file name of the attachment.</param> | |||||
| /// <param name="embeds">A array of embeds to send with this response. Max 10.</param> | |||||
| /// <param name="isTTS"><see langword="true"/> if the message should be read out by a text-to-speech reader, otherwise <see langword="false"/>.</param> | |||||
| /// <param name="ephemeral"><see langword="true"/> if the response should be hidden to everyone besides the invoker of the command, otherwise <see langword="false"/>.</param> | |||||
| /// <param name="allowedMentions">The allowed mentions for this response.</param> | |||||
| /// <param name="options">The request options for this response.</param> | |||||
| /// <param name="component">A <see cref="MessageComponent"/> to be sent with this response.</param> | |||||
| /// <param name="embed">A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored.</param> | |||||
| /// <returns> | |||||
| /// The sent message. | |||||
| /// </returns> | |||||
| public override async Task<RestFollowupMessage> 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<Embed>(); | |||||
| 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<API.AllowedMentions>.Unspecified, | |||||
| IsTTS = isTTS, | |||||
| Embeds = embeds.Select(x => x.ToModel()).ToArray(), | |||||
| Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified, | |||||
| File = fileStream is not null ? new MultipartFile(fileStream, fileName) : Optional<MultipartFile>.Unspecified | |||||
| }; | |||||
| if (ephemeral) | |||||
| args.Flags = MessageFlags.Ephemeral; | |||||
| return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); | |||||
| } | |||||
| /// <summary> | |||||
| /// Sends a followup message for this interaction. | |||||
| /// </summary> | |||||
| /// <param name="text">The text of the message to be sent.</param> | |||||
| /// <param name="filePath">The file to upload.</param> | |||||
| /// <param name="fileName">The file name of the attachment.</param> | |||||
| /// <param name="embeds">A array of embeds to send with this response. Max 10.</param> | |||||
| /// <param name="isTTS"><see langword="true"/> if the message should be read out by a text-to-speech reader, otherwise <see langword="false"/>.</param> | |||||
| /// <param name="ephemeral"><see langword="true"/> if the response should be hidden to everyone besides the invoker of the command, otherwise <see langword="false"/>.</param> | |||||
| /// <param name="allowedMentions">The allowed mentions for this response.</param> | |||||
| /// <param name="options">The request options for this response.</param> | |||||
| /// <param name="component">A <see cref="MessageComponent"/> to be sent with this response.</param> | |||||
| /// <param name="embed">A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored.</param> | |||||
| /// <returns> | |||||
| /// The sent message. | |||||
| /// </returns> | |||||
| public override async Task<RestFollowupMessage> 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<Embed>(); | |||||
| 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<API.AllowedMentions>.Unspecified, | |||||
| IsTTS = isTTS, | |||||
| Embeds = embeds.Select(x => x.ToModel()).ToArray(), | |||||
| Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified, | |||||
| File = !string.IsNullOrEmpty(filePath) ? new MultipartFile(new MemoryStream(File.ReadAllBytes(filePath), false), fileName) : Optional<MultipartFile>.Unspecified | |||||
| }; | |||||
| if (ephemeral) | |||||
| args.Flags = MessageFlags.Ephemeral; | |||||
| return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); | |||||
| } | |||||
| /// <summary> | |||||
| /// Responds to an Interaction with type <see cref="InteractionResponseType.ChannelMessageWithSource"/>. | |||||
| /// </summary> | |||||
| /// <param name="text">The text of the message to be sent.</param> | |||||
| /// <param name="embeds">A array of embeds to send with this response. Max 10.</param> | |||||
| /// <param name="isTTS"><see langword="true"/> if the message should be read out by a text-to-speech reader, otherwise <see langword="false"/>.</param> | |||||
| /// <param name="ephemeral"><see langword="true"/> if the response should be hidden to everyone besides the invoker of the command, otherwise <see langword="false"/>.</param> | |||||
| /// <param name="allowedMentions">The allowed mentions for this response.</param> | |||||
| /// <param name="options">The request options for this response.</param> | |||||
| /// <param name="component">A <see cref="MessageComponent"/> to be sent with this response.</param> | |||||
| /// <param name="embed">A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored.</param> | |||||
| /// <exception cref="ArgumentOutOfRangeException">Message content is too long, length must be less or equal to <see cref="DiscordConfig.MaxMessageSize"/>.</exception> | |||||
| /// <exception cref="InvalidOperationException">The parameters provided were invalid or the token was invalid.</exception> | |||||
| /// <returns> | |||||
| /// A string that contains json to write back to the incoming http request. | |||||
| /// </returns> | |||||
| 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<Embed>(); | |||||
| 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<API.AllowedMentions>.Unspecified, | |||||
| Embeds = embeds.Select(x => x.ToModel()).ToArray(), | |||||
| TTS = isTTS, | |||||
| Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified, | |||||
| Flags = ephemeral ? MessageFlags.Ephemeral : Optional<MessageFlags>.Unspecified | |||||
| } | |||||
| }; | |||||
| lock (_lock) | |||||
| { | |||||
| if (HasResponded) | |||||
| { | |||||
| throw new InvalidOperationException("Cannot respond twice to the same interaction"); | |||||
| } | |||||
| } | |||||
| lock (_lock) | |||||
| { | |||||
| HasResponded = true; | |||||
| } | |||||
| return SerializePayload(response); | |||||
| } | |||||
| /// <inheritdoc/> | |||||
| public override async Task<RestFollowupMessage> FollowupWithFilesAsync( | |||||
| IEnumerable<FileAttachment> 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<Embed>(); | |||||
| 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<API.Embed[]>.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional<API.AllowedMentions>.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified }; | |||||
| return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options).ConfigureAwait(false); | |||||
| } | |||||
| /// <inheritdoc/> | |||||
| public override Task<RestFollowupMessage> 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); | |||||
| } | |||||
| /// <inheritdoc/> | |||||
| 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; | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents data sent from a <see cref="InteractionType.ModalSubmit"/> Interaction. | |||||
| /// </summary> | |||||
| public class RestModalData : IComponentInteractionData, IModalInteractionData | |||||
| { | |||||
| /// <inheritdoc/> | |||||
| public string CustomId { get; } | |||||
| /// <summary> | |||||
| /// Represents the <see cref="Modal"/>s components submitted by the user. | |||||
| /// </summary> | |||||
| public IReadOnlyCollection<RestMessageComponentData> Components { get; } | |||||
| /// <inheritdoc/> | |||||
| public ComponentType Type => ComponentType.ModalSubmit; | |||||
| /// <inheritdoc/> | |||||
| public IReadOnlyCollection<string> Values | |||||
| => throw new NotSupportedException("Modal interactions do not have values!"); | |||||
| /// <inheritdoc/> | |||||
| public string Value | |||||
| => throw new NotSupportedException("Modal interactions do not have value!"); | |||||
| IReadOnlyCollection<IComponentInteractionData> IModalInteractionData.Components => Components; | |||||
| internal RestModalData(Model model) | |||||
| { | |||||
| CustomId = model.CustomId; | |||||
| Components = model.Components | |||||
| .SelectMany(x => x.Components) | |||||
| .Select(x => new RestMessageComponentData(x)) | |||||
| .ToArray(); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -100,6 +100,9 @@ namespace Discord.Rest | |||||
| if (model.Type == InteractionType.ApplicationCommandAutocomplete) | if (model.Type == InteractionType.ApplicationCommandAutocomplete) | ||||
| return await RestAutocompleteInteraction.CreateAsync(client, model).ConfigureAwait(false); | return await RestAutocompleteInteraction.CreateAsync(client, model).ConfigureAwait(false); | ||||
| if (model.Type == InteractionType.ModalSubmit) | |||||
| return await RestModal.CreateAsync(client, model).ConfigureAwait(false); | |||||
| return null; | return null; | ||||
| } | } | ||||
| @@ -180,6 +183,9 @@ namespace Discord.Rest | |||||
| var model = await InteractionHelper.ModifyInteractionResponseAsync(Discord, Token, func, options); | var model = await InteractionHelper.ModifyInteractionResponseAsync(Discord, Token, func, options); | ||||
| return RestInteractionMessage.Create(Discord, model, Token, Channel); | return RestInteractionMessage.Create(Discord, model, Token, Channel); | ||||
| } | } | ||||
| /// <inheritdoc/> | |||||
| public abstract string RespondWithModal(Modal modal, RequestOptions options = null); | |||||
| /// <inheritdoc/> | /// <inheritdoc/> | ||||
| 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); | 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 IDiscordInteraction.DeferAsync(bool ephemeral, RequestOptions options) | ||||
| => Task.FromResult(Defer(ephemeral, options)); | => Task.FromResult(Defer(ephemeral, options)); | ||||
| /// <inheritdoc/> | /// <inheritdoc/> | ||||
| Task IDiscordInteraction.RespondWithModalAsync(Modal modal, RequestOptions options) | |||||
| => Task.FromResult(RespondWithModal(modal, options)); | |||||
| /// <inheritdoc/> | |||||
| async Task<IUserMessage> IDiscordInteraction.FollowupAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, | async Task<IUserMessage> IDiscordInteraction.FollowupAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, | ||||
| MessageComponent components, Embed embed, RequestOptions options) | MessageComponent components, Embed embed, RequestOptions options) | ||||
| => await FollowupAsync(text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); | => await FollowupAsync(text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); | ||||
| @@ -36,6 +36,7 @@ namespace Discord.Rest | |||||
| } | } | ||||
| public override string Defer(bool ephemeral = false, RequestOptions options = null) => throw new NotSupportedException(); | 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 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<RestFollowupMessage> 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<RestFollowupMessage> 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<RestFollowupMessage> 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(); | public override Task<RestFollowupMessage> 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(); | ||||
| @@ -112,7 +112,8 @@ namespace Discord.Rest | |||||
| => throw new NotSupportedException("Autocomplete interactions don't support this method!"); | => throw new NotSupportedException("Autocomplete interactions don't support this method!"); | ||||
| public override Task<RestFollowupMessage> FollowupWithFilesAsync(IEnumerable<FileAttachment> 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) | public override Task<RestFollowupMessage> FollowupWithFilesAsync(IEnumerable<FileAttachment> 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!"); | => 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 | //IAutocompleteInteraction | ||||
| /// <inheritdoc/> | /// <inheritdoc/> | ||||
| @@ -56,6 +56,13 @@ namespace Discord.Net.Converters | |||||
| interaction.Data = autocompleteData; | interaction.Data = autocompleteData; | ||||
| } | } | ||||
| break; | break; | ||||
| case InteractionType.ModalSubmit: | |||||
| { | |||||
| var modalData = new API.ModalInteractionData(); | |||||
| serializer.Populate(result.CreateReader(), modalData); | |||||
| interaction.Data = modalData; | |||||
| } | |||||
| break; | |||||
| } | } | ||||
| } | } | ||||
| else | else | ||||
| @@ -32,6 +32,9 @@ namespace Discord.Net.Converters | |||||
| case ComponentType.SelectMenu: | case ComponentType.SelectMenu: | ||||
| messageComponent = new API.SelectMenuComponent(); | messageComponent = new API.SelectMenuComponent(); | ||||
| break; | break; | ||||
| case ComponentType.TextInput: | |||||
| messageComponent = new API.TextInputComponent(); | |||||
| break; | |||||
| } | } | ||||
| serializer.Populate(jsonObject.CreateReader(), messageComponent); | serializer.Populate(jsonObject.CreateReader(), messageComponent); | ||||
| return messageComponent; | return messageComponent; | ||||
| @@ -634,6 +634,15 @@ namespace Discord.WebSocket | |||||
| remove => _autocompleteExecuted.Remove(value); | remove => _autocompleteExecuted.Remove(value); | ||||
| } | } | ||||
| internal readonly AsyncEvent<Func<SocketAutocompleteInteraction, Task>> _autocompleteExecuted = new AsyncEvent<Func<SocketAutocompleteInteraction, Task>>(); | internal readonly AsyncEvent<Func<SocketAutocompleteInteraction, Task>> _autocompleteExecuted = new AsyncEvent<Func<SocketAutocompleteInteraction, Task>>(); | ||||
| /// <summary> | |||||
| /// Fired when a modal is submitted. | |||||
| /// </summary> | |||||
| public event Func<SocketModal, Task> ModalSubmitted | |||||
| { | |||||
| add => _modalSubmitted.Add(value); | |||||
| remove => _modalSubmitted.Remove(value); | |||||
| } | |||||
| internal readonly AsyncEvent<Func<SocketModal, Task>> _modalSubmitted = new AsyncEvent<Func<SocketModal, Task>>(); | |||||
| /// <summary> | /// <summary> | ||||
| /// Fired when a guild application command is created. | /// Fired when a guild application command is created. | ||||
| @@ -468,6 +468,7 @@ namespace Discord.WebSocket | |||||
| client.UserCommandExecuted += (arg) => _userCommandExecuted.InvokeAsync(arg); | client.UserCommandExecuted += (arg) => _userCommandExecuted.InvokeAsync(arg); | ||||
| client.MessageCommandExecuted += (arg) => _messageCommandExecuted.InvokeAsync(arg); | client.MessageCommandExecuted += (arg) => _messageCommandExecuted.InvokeAsync(arg); | ||||
| client.AutocompleteExecuted += (arg) => _autocompleteExecuted.InvokeAsync(arg); | client.AutocompleteExecuted += (arg) => _autocompleteExecuted.InvokeAsync(arg); | ||||
| client.ModalSubmitted += (arg) => _modalSubmitted.InvokeAsync(arg); | |||||
| client.ThreadUpdated += (thread1, thread2) => _threadUpdated.InvokeAsync(thread1, thread2); | client.ThreadUpdated += (thread1, thread2) => _threadUpdated.InvokeAsync(thread1, thread2); | ||||
| client.ThreadCreated += (thread) => _threadCreated.InvokeAsync(thread); | client.ThreadCreated += (thread) => _threadCreated.InvokeAsync(thread); | ||||
| @@ -78,7 +78,7 @@ namespace Discord.API | |||||
| if (msg != null) | if (msg != null) | ||||
| { | { | ||||
| #if DEBUG_PACKETS | #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 | #endif | ||||
| await _receivedGatewayEvent.InvokeAsync((GatewayOpCode)msg.Operation, msg.Sequence, msg.Type, msg.Payload).ConfigureAwait(false); | 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 (msg != null) | ||||
| { | { | ||||
| #if DEBUG_PACKETS | #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 | #endif | ||||
| await _receivedGatewayEvent.InvokeAsync((GatewayOpCode)msg.Operation, msg.Sequence, msg.Type, msg.Payload).ConfigureAwait(false); | await _receivedGatewayEvent.InvokeAsync((GatewayOpCode)msg.Operation, msg.Sequence, msg.Type, msg.Payload).ConfigureAwait(false); | ||||
| @@ -2274,6 +2274,9 @@ namespace Discord.WebSocket | |||||
| case SocketAutocompleteInteraction autocomplete: | case SocketAutocompleteInteraction autocomplete: | ||||
| await TimedInvokeAsync(_autocompleteExecuted, nameof(AutocompleteExecuted), autocomplete).ConfigureAwait(false); | await TimedInvokeAsync(_autocompleteExecuted, nameof(AutocompleteExecuted), autocomplete).ConfigureAwait(false); | ||||
| break; | break; | ||||
| case SocketModal modal: | |||||
| await TimedInvokeAsync(_modalSubmitted, nameof(ModalSubmitted), modal).ConfigureAwait(false); | |||||
| break; | |||||
| } | } | ||||
| } | } | ||||
| break; | break; | ||||
| @@ -438,6 +438,41 @@ namespace Discord.WebSocket | |||||
| HasResponded = true; | HasResponded = true; | ||||
| } | } | ||||
| /// <inheritdoc/> | |||||
| 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 | //IComponentInteraction | ||||
| /// <inheritdoc/> | /// <inheritdoc/> | ||||
| IComponentInteractionData IComponentInteraction.Data => Data; | IComponentInteractionData IComponentInteraction.Data => Data; | ||||
| @@ -23,11 +23,31 @@ namespace Discord.WebSocket | |||||
| /// </summary> | /// </summary> | ||||
| public IReadOnlyCollection<string> Values { get; } | public IReadOnlyCollection<string> Values { get; } | ||||
| /// <summary> | |||||
| /// Gets the value of a <see cref="TextInputComponent"/> interaction response. | |||||
| /// </summary> | |||||
| public string Value { get; } | |||||
| internal SocketMessageComponentData(Model model) | internal SocketMessageComponentData(Model model) | ||||
| { | { | ||||
| CustomId = model.CustomId; | CustomId = model.CustomId; | ||||
| Type = model.ComponentType; | Type = model.ComponentType; | ||||
| Values = model.Values.GetValueOrDefault(); | 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; | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -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 | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents a user submitted <see cref="Discord.Modal"/> received via GateWay. | |||||
| /// </summary> | |||||
| public class SocketModal : SocketInteraction, IDiscordInteraction, IModalInteraction | |||||
| { | |||||
| /// <summary> | |||||
| /// The data for this <see cref="Modal"/> interaction. | |||||
| /// </summary> | |||||
| /// <value></value> | |||||
| 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; | |||||
| } | |||||
| /// <inheritdoc/> | |||||
| public override bool HasResponded { get; internal set; } | |||||
| private object _lock = new object(); | |||||
| /// <inheritdoc/> | |||||
| public override async Task RespondWithFilesAsync( | |||||
| IEnumerable<FileAttachment> 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<Embed>(); | |||||
| 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<string>.Unspecified, | |||||
| AllowedMentions = allowedMentions != null ? allowedMentions?.ToModel() : Optional<API.AllowedMentions>.Unspecified, | |||||
| Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional<API.Embed[]>.Unspecified, | |||||
| IsTTS = isTTS, | |||||
| MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified, | |||||
| Flags = ephemeral ? MessageFlags.Ephemeral : Optional<MessageFlags>.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; | |||||
| } | |||||
| /// <inheritdoc/> | |||||
| 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<Embed>(); | |||||
| 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<string>.Unspecified, | |||||
| AllowedMentions = allowedMentions?.ToModel(), | |||||
| Embeds = embeds.Select(x => x.ToModel()).ToArray(), | |||||
| TTS = isTTS, | |||||
| Flags = ephemeral ? MessageFlags.Ephemeral : Optional<MessageFlags>.Unspecified, | |||||
| Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.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; | |||||
| } | |||||
| /// <inheritdoc/> | |||||
| public override async Task<RestFollowupMessage> 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<Embed>(); | |||||
| 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<API.AllowedMentions>.Unspecified, | |||||
| IsTTS = isTTS, | |||||
| Embeds = embeds.Select(x => x.ToModel()).ToArray(), | |||||
| Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified | |||||
| }; | |||||
| if (ephemeral) | |||||
| args.Flags = MessageFlags.Ephemeral; | |||||
| return await InteractionHelper.SendFollowupAsync(Discord.Rest, args, Token, Channel, options); | |||||
| } | |||||
| /// <inheritdoc/> | |||||
| public override async Task<RestFollowupMessage> FollowupWithFilesAsync( | |||||
| IEnumerable<FileAttachment> 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<Embed>(); | |||||
| 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<API.Embed[]>.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional<API.AllowedMentions>.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified }; | |||||
| return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options).ConfigureAwait(false); | |||||
| } | |||||
| /// <inheritdoc/> | |||||
| 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<API.InteractionCallbackData>.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; | |||||
| } | |||||
| } | |||||
| /// <inheritdoc/> | |||||
| 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; | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| { | |||||
| /// <summary> | |||||
| /// Represents data sent from a <see cref="InteractionType.ModalSubmit"/>. | |||||
| /// </summary> | |||||
| public class SocketModalData : IDiscordInteractionData, IModalInteractionData | |||||
| { | |||||
| /// <summary> | |||||
| /// Gets the <see cref="Modal"/>'s Custom Id. | |||||
| /// </summary> | |||||
| public string CustomId { get; } | |||||
| /// <summary> | |||||
| /// Gets the <see cref="Modal"/>'s components submitted by the user. | |||||
| /// </summary> | |||||
| public IReadOnlyCollection<SocketMessageComponentData> Components { get; } | |||||
| internal SocketModalData(Model model) | |||||
| { | |||||
| CustomId = model.CustomId; | |||||
| Components = model.Components | |||||
| .SelectMany(x => x.Components) | |||||
| .Select(x => new SocketMessageComponentData(x)) | |||||
| .ToArray(); | |||||
| } | |||||
| IReadOnlyCollection<IComponentInteractionData> IModalInteractionData.Components => Components; | |||||
| } | |||||
| } | |||||
| @@ -100,6 +100,10 @@ namespace Discord.WebSocket | |||||
| public override Task RespondWithFilesAsync(IEnumerable<FileAttachment> 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) | public override Task RespondWithFilesAsync(IEnumerable<FileAttachment> 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!"); | => throw new NotSupportedException("Autocomplete interactions don't support this method!"); | ||||
| /// <inheritdoc/> | |||||
| public override Task RespondWithModalAsync(Modal modal, RequestOptions requestOptions = null) | |||||
| => throw new NotSupportedException("Autocomplete interactions cannot have normal responces!"); | |||||
| //IAutocompleteInteraction | //IAutocompleteInteraction | ||||
| /// <inheritdoc/> | /// <inheritdoc/> | ||||
| IAutocompleteInteractionData IAutocompleteInteraction.Data => Data; | IAutocompleteInteractionData IAutocompleteInteraction.Data => Data; | ||||
| @@ -1,4 +1,3 @@ | |||||
| using Discord.Net.Rest; | |||||
| using Discord.Rest; | using Discord.Rest; | ||||
| using System; | using System; | ||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||
| @@ -135,6 +134,42 @@ namespace Discord.WebSocket | |||||
| HasResponded = true; | HasResponded = true; | ||||
| } | } | ||||
| /// <inheritdoc/> | |||||
| 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( | public override async Task RespondWithFilesAsync( | ||||
| IEnumerable<FileAttachment> attachments, | IEnumerable<FileAttachment> attachments, | ||||
| string text = null, | string text = null, | ||||
| @@ -108,6 +108,9 @@ namespace Discord.WebSocket | |||||
| if (model.Type == InteractionType.ApplicationCommandAutocomplete) | if (model.Type == InteractionType.ApplicationCommandAutocomplete) | ||||
| return SocketAutocompleteInteraction.Create(client, model, channel); | return SocketAutocompleteInteraction.Create(client, model, channel); | ||||
| if (model.Type == InteractionType.ModalSubmit) | |||||
| return SocketModal.Create(client, model, channel); | |||||
| return null; | return null; | ||||
| } | } | ||||
| @@ -387,6 +390,13 @@ namespace Discord.WebSocket | |||||
| /// </returns> | /// </returns> | ||||
| public abstract Task DeferAsync(bool ephemeral = false, RequestOptions options = null); | public abstract Task DeferAsync(bool ephemeral = false, RequestOptions options = null); | ||||
| /// <summary> | |||||
| /// Responds to this interaction with a <see cref="Modal"/>. | |||||
| /// </summary> | |||||
| /// <param name="modal">The <see cref="Modal"/> to respond with.</param> | |||||
| /// <param name="options">The request options for this <see langword="async"/> request.</param> | |||||
| /// <returns>A task that represents the asynchronous operation of responding to the interaction.</returns> | |||||
| public abstract Task RespondWithModalAsync(Modal modal, RequestOptions options = null); | |||||
| #endregion | #endregion | ||||
| #region IDiscordInteraction | #region IDiscordInteraction | ||||