* 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": [ | |||
| 120 | |||
| ], | |||
| "editor.insertSpaces": true, | |||
| "files.exclude": { | |||
| "**/.git": true, | |||
| "**/.svn": true, | |||
| "**/.hg": true, | |||
| "**/CVS": true, | |||
| "**/.DS_Store": true, | |||
| "docs/": true, | |||
| "**/obj": true, | |||
| "**/bin": true, | |||
| "samples/": true, | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -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. | |||
| ## Modals | |||
| Modal commands last parameter must be an implementation of `IModal`. | |||
| A Modal implementation would look like this: | |||
| [!code-csharp[Modal Command](samples/intro/modal.cs)] | |||
| > [!NOTE] | |||
| > If you are using Modals in the interaction service it is **highly | |||
| > recommended** that you enable `PreCompiledLambdas` in your config | |||
| > to prevent performance issues. | |||
| ## Interaction Context | |||
| Every command module provides its commands with an execution context. | |||
| @@ -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 | |||
| - name: Select menus | |||
| topicUid: Guides.MessageComponents.SelectMenus | |||
| - name: Text Input | |||
| topicUid: Guides.MessageComponents.TextInputs | |||
| - name: Advanced Concepts | |||
| topicUid: Guides.MessageComponents.Advanced | |||
| - name: Modal Basics | |||
| items: | |||
| - name: Introduction | |||
| topicUid: Guides.Modals.Intro | |||
| - name: Guild Events | |||
| items: | |||
| - name: Introduction | |||
| @@ -332,5 +332,13 @@ namespace Discord | |||
| /// A task that represents the asynchronous operation of deferring the interaction. | |||
| /// </returns> | |||
| 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> | |||
| /// Respond with a set of choices to a autocomplete interaction. | |||
| /// </summary> | |||
| ApplicationCommandAutocompleteResult = 8 | |||
| ApplicationCommandAutocompleteResult = 8, | |||
| /// <summary> | |||
| /// Respond by showing the user a modal. | |||
| /// </summary> | |||
| Modal = 9, | |||
| } | |||
| } | |||
| @@ -23,6 +23,11 @@ namespace Discord | |||
| /// <summary> | |||
| /// An autocomplete request sent from discord. | |||
| /// </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> | |||
| public MessageComponent Build() | |||
| { | |||
| if (_actionRows?.SelectMany(x => x.Components)?.Any(x => x.Type == ComponentType.TextInput) ?? false) | |||
| throw new ArgumentException("TextInputComponents are not allowed in messages.", nameof(ActionRows)); | |||
| if (_actionRows?.SelectMany(x => x.Components)?.Any(x => x.Type == ComponentType.ModalSubmit) ?? false) | |||
| throw new ArgumentException("ModalSubmit components are not allowed in messages.", nameof(ActionRows)); | |||
| return _actionRows != null | |||
| ? new MessageComponent(_actionRows.Select(x => x.Build()).ToList()) | |||
| : MessageComponent.Empty; | |||
| @@ -1093,4 +1098,248 @@ namespace Discord | |||
| return new SelectMenuOption(Label, Value, Description, Emote, IsDefault); | |||
| } | |||
| } | |||
| public class TextInputBuilder | |||
| { | |||
| public const int LargestMaxLength = 4000; | |||
| /// <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> | |||
| /// A select menu for picking from choices. | |||
| /// </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 | |||
| { | |||
| /// <summary> | |||
| /// Gets the components Custom Id that was clicked. | |||
| /// Gets the component's Custom Id that was clicked. | |||
| /// </summary> | |||
| string CustomId { get; } | |||
| @@ -21,5 +21,10 @@ namespace Discord | |||
| /// Gets the value(s) of a <see cref="SelectMenuComponent"/> interaction response. | |||
| /// </summary> | |||
| 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<ComponentCommandBuilder> _componentCommands; | |||
| private readonly List<AutocompleteCommandBuilder> _autocompleteCommands; | |||
| private readonly List<ModalCommandBuilder> _modalCommands; | |||
| /// <summary> | |||
| /// Gets the underlying Interaction Service. | |||
| @@ -92,6 +93,11 @@ namespace Discord.Interactions.Builders | |||
| /// </summary> | |||
| 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 ModuleBuilder (InteractionService interactionService, ModuleBuilder parent = null) | |||
| @@ -105,6 +111,7 @@ namespace Discord.Interactions.Builders | |||
| _contextCommands = new List<ContextCommandBuilder>(); | |||
| _componentCommands = new List<ComponentCommandBuilder>(); | |||
| _autocompleteCommands = new List<AutocompleteCommandBuilder>(); | |||
| _modalCommands = new List<ModalCommandBuilder> (); | |||
| _preconditions = new List<PreconditionAttribute>(); | |||
| } | |||
| @@ -152,7 +159,7 @@ namespace Discord.Interactions.Builders | |||
| /// <returns> | |||
| /// The builder instance. | |||
| /// </returns> | |||
| public ModuleBuilder WithDefaultPermision (bool permission) | |||
| public ModuleBuilder WithDefaultPermission (bool permission) | |||
| { | |||
| DefaultPermission = permission; | |||
| return this; | |||
| @@ -310,6 +317,21 @@ namespace Discord.Interactions.Builders | |||
| configure(command); | |||
| _autocompleteCommands.Add(command); | |||
| return this; | |||
| } | |||
| /// Adds a modal command builder to <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> | |||
| @@ -103,6 +103,7 @@ namespace Discord.Interactions.Builders | |||
| var validContextCommands = methods.Where(IsValidContextCommandDefinition); | |||
| var validInteractions = methods.Where(IsValidComponentCommandDefinition); | |||
| var validAutocompleteCommands = methods.Where(IsValidAutocompleteCommandDefinition); | |||
| var validModalCommands = methods.Where(IsValidModalCommanDefinition); | |||
| Func<IServiceProvider, IInteractionModuleBase> createInstance = commandService._useCompiledLambda ? | |||
| ReflectionUtils<IInteractionModuleBase>.CreateLambdaBuilder(typeInfo, commandService) : ReflectionUtils<IInteractionModuleBase>.CreateBuilder(typeInfo, commandService); | |||
| @@ -118,6 +119,9 @@ namespace Discord.Interactions.Builders | |||
| foreach(var method in validAutocompleteCommands) | |||
| builder.AddAutocompleteCommand(x => BuildAutocompleteCommand(x, createInstance, method, commandService, services)); | |||
| foreach(var method in validModalCommands) | |||
| builder.AddModalCommand(x => BuildModalCommand(x, createInstance, method, commandService, services)); | |||
| } | |||
| private static void BuildSubModules (ModuleBuilder parent, IEnumerable<TypeInfo> subModules, IList<TypeInfo> builtTypes, InteractionService commandService, | |||
| @@ -298,6 +302,47 @@ namespace Discord.Interactions.Builders | |||
| 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, | |||
| MethodInfo methodInfo, InteractionService commandService) | |||
| { | |||
| @@ -400,7 +445,9 @@ namespace Discord.Interactions.Builders | |||
| builder.Name = Regex.Replace(builder.Name, "(?<=[a-z])(?=[A-Z])", "-").ToLower(); | |||
| } | |||
| private static void BuildParameter (CommandParameterBuilder builder, ParameterInfo paramInfo) | |||
| private static void BuildParameter<TInfo, TBuilder> (ParameterBuilder<TInfo, TBuilder> builder, ParameterInfo paramInfo) | |||
| where TInfo : class, IParameterInfo | |||
| where TBuilder : ParameterBuilder<TInfo, TBuilder> | |||
| { | |||
| var attributes = paramInfo.GetCustomAttributes(); | |||
| var paramType = paramInfo.ParameterType; | |||
| @@ -428,6 +475,84 @@ namespace Discord.Interactions.Builders | |||
| } | |||
| #endregion | |||
| #region Modals | |||
| public static ModalInfo BuildModalInfo(Type modalType) | |||
| { | |||
| if (!typeof(IModal).IsAssignableFrom(modalType)) | |||
| throw new InvalidOperationException($"{modalType.FullName} isn't an implementation of {typeof(IModal).FullName}"); | |||
| var instance = Activator.CreateInstance(modalType, false) as IModal; | |||
| try | |||
| { | |||
| var builder = new ModalBuilder(modalType) | |||
| { | |||
| Title = instance.Title | |||
| }; | |||
| var inputs = modalType.GetProperties().Where(IsValidModalInputDefinition); | |||
| foreach (var prop in inputs) | |||
| { | |||
| var componentType = prop.GetCustomAttribute<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) | |||
| { | |||
| return ModuleTypeInfo.IsAssignableFrom(typeInfo) && | |||
| @@ -467,5 +592,21 @@ namespace Discord.Interactions.Builders | |||
| !methodInfo.IsGenericMethod && | |||
| methodInfo.GetParameters().Length == 0; | |||
| } | |||
| private static bool IsValidModalCommanDefinition(MethodInfo methodInfo) | |||
| { | |||
| return methodInfo.IsDefined(typeof(ModalInteractionAttribute)) && | |||
| (methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task<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="additionalArgs">Provide additional string parameters to the method along with the auto generated parameters.</param> | |||
| /// <returns> | |||
| /// A task representing the asyncronous command execution process. | |||
| /// A task representing the asynchronous command execution process. | |||
| /// </returns> | |||
| 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> | |||
| public IReadOnlyCollection<AutocompleteCommandInfo> AutocompleteCommands { get; } | |||
| public IReadOnlyCollection<ModalCommandInfo> ModalCommands { get; } | |||
| /// <summary> | |||
| /// Gets the declaring type of this module, if <see cref="IsSubModule"/> is <see langword="true"/>. | |||
| /// </summary> | |||
| @@ -112,6 +114,7 @@ namespace Discord.Interactions | |||
| ContextCommands = BuildContextCommands(builder).ToImmutableArray(); | |||
| ComponentCommands = BuildComponentCommands(builder).ToImmutableArray(); | |||
| AutocompleteCommands = BuildAutocompleteCommands(builder).ToImmutableArray(); | |||
| ModalCommands = BuildModalCommands(builder).ToImmutableArray(); | |||
| SubModules = BuildSubModules(builder, commandService, services).ToImmutableArray(); | |||
| Attributes = BuildAttributes(builder).ToImmutableArray(); | |||
| Preconditions = BuildPreconditions(builder).ToImmutableArray(); | |||
| @@ -171,6 +174,16 @@ namespace Discord.Interactions | |||
| return result; | |||
| } | |||
| private IEnumerable<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) | |||
| { | |||
| 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); | |||
| 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 | |||
| @@ -53,21 +53,29 @@ namespace Discord.Interactions | |||
| 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(); | |||
| /// <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 CommandMap<SlashCommandInfo> _slashCommandMap; | |||
| private readonly ConcurrentDictionary<ApplicationCommandType, CommandMap<ContextCommandInfo>> _contextCommandMaps; | |||
| private readonly CommandMap<ComponentCommandInfo> _componentCommandMap; | |||
| private readonly CommandMap<AutocompleteCommandInfo> _autocompleteCommandMap; | |||
| private readonly CommandMap<ModalCommandInfo> _modalCommandMap; | |||
| private readonly HashSet<ModuleInfo> _moduleDefs; | |||
| private readonly ConcurrentDictionary<Type, TypeConverter> _typeConverters; | |||
| private readonly ConcurrentDictionary<Type, Type> _genericTypeConverters; | |||
| private readonly ConcurrentDictionary<Type, IAutocompleteHandler> _autocompleteHandlers = new(); | |||
| private readonly ConcurrentDictionary<Type, ModalInfo> _modalInfos = new(); | |||
| private readonly SemaphoreSlim _lock; | |||
| internal readonly Logger _cmdLogger; | |||
| internal readonly LogManager _logManager; | |||
| 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 RunMode _runMode; | |||
| internal readonly RestResponseCallback _restResponseCallback; | |||
| @@ -97,6 +105,16 @@ namespace Discord.Interactions | |||
| /// </summary> | |||
| 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> | |||
| /// Initialize a <see cref="InteractionService"/> with provided configurations. | |||
| /// </summary> | |||
| @@ -145,6 +163,7 @@ namespace Discord.Interactions | |||
| _contextCommandMaps = new ConcurrentDictionary<ApplicationCommandType, CommandMap<ContextCommandInfo>>(); | |||
| _componentCommandMap = new CommandMap<ComponentCommandInfo>(this, config.InteractionCustomIdDelimiters); | |||
| _autocompleteCommandMap = new CommandMap<AutocompleteCommandInfo>(this); | |||
| _modalCommandMap = new CommandMap<ModalCommandInfo>(this, config.InteractionCustomIdDelimiters); | |||
| _getRestClient = getRestClient; | |||
| @@ -155,6 +174,7 @@ namespace Discord.Interactions | |||
| _throwOnError = config.ThrowOnError; | |||
| _wildCardExp = config.WildCardExpression; | |||
| _useCompiledLambda = config.UseCompiledLambda; | |||
| _exitOnMissingModalField = config.ExitOnMissingModalField; | |||
| _enableAutocompleteHandlers = config.EnableAutocompleteHandlers; | |||
| _autoServiceScopes = config.AutoServiceScopes; | |||
| _restResponseCallback = config.RestResponseCallback; | |||
| @@ -509,6 +529,9 @@ namespace Discord.Interactions | |||
| foreach (var command in module.AutocompleteCommands) | |||
| _autocompleteCommandMap.AddCommand(command.GetCommandKeywords(), command); | |||
| foreach (var command in module.ModalCommands) | |||
| _modalCommandMap.AddCommand(command, command.IgnoreGroupNames); | |||
| foreach (var subModule in module.SubModules) | |||
| LoadModuleInternal(subModule); | |||
| } | |||
| @@ -654,7 +677,7 @@ namespace Discord.Interactions | |||
| public async Task<IResult> ExecuteCommandAsync (IInteractionContext context, IServiceProvider services) | |||
| { | |||
| var interaction = context.Interaction; | |||
| return interaction switch | |||
| { | |||
| ISlashCommandInteraction slashCommand => await ExecuteSlashCommandAsync(context, slashCommand, services).ConfigureAwait(false), | |||
| @@ -662,6 +685,7 @@ namespace Discord.Interactions | |||
| IUserCommandInteraction userCommand => await ExecuteContextCommandAsync(context, userCommand.Data.Name, ApplicationCommandType.User, services).ConfigureAwait(false), | |||
| IMessageCommandInteraction messageCommand => await ExecuteContextCommandAsync(context, messageCommand.Data.Name, ApplicationCommandType.Message, services).ConfigureAwait(false), | |||
| IAutocompleteInteraction autocomplete => await ExecuteAutocompleteAsync(context, autocomplete, services).ConfigureAwait(false), | |||
| IModalInteraction modalCommand => await ExecuteModalCommandAsync(context, modalCommand.Data.CustomId, services).ConfigureAwait(false), | |||
| _ => throw new InvalidOperationException($"{interaction.Type} interaction type cannot be executed by the Interaction service"), | |||
| }; | |||
| } | |||
| @@ -745,6 +769,20 @@ namespace Discord.Interactions | |||
| return await commandResult.Command.ExecuteAsync(context, services).ConfigureAwait(false); | |||
| } | |||
| private async Task<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) | |||
| { | |||
| if (_typeConverters.TryGetValue(type, out var specific)) | |||
| @@ -819,6 +857,24 @@ namespace Discord.Interactions | |||
| _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) | |||
| { | |||
| services ??= EmptyServiceProvider.Instance; | |||
| @@ -36,6 +36,9 @@ namespace Discord.Interactions | |||
| /// <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. | |||
| /// </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; | |||
| /// <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. | |||
| /// </summary> | |||
| 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> | |||
| @@ -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 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 propsExp = Expression.Parameter(typeof(object[]), "props"); | |||
| @@ -137,17 +198,8 @@ namespace Discord.Interactions | |||
| var memberInit = Expression.MemberInit(newExp, memberExps); | |||
| 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); | |||
| return instance; | |||
| @@ -21,6 +21,7 @@ namespace Discord.API | |||
| { | |||
| ComponentType.Button => new ButtonComponent(x as Discord.ButtonComponent), | |||
| ComponentType.SelectMenu => new SelectMenuComponent(x as Discord.SelectMenuComponent), | |||
| ComponentType.TextInput => new TextInputComponent(x as Discord.TextInputComponent), | |||
| _ => null | |||
| }; | |||
| }).ToArray(); | |||
| @@ -24,5 +24,11 @@ namespace Discord.API | |||
| [JsonProperty("choices")] | |||
| 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")] | |||
| 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")] | |||
| public bool Disabled { get; set; } | |||
| [JsonProperty("values")] | |||
| public Optional<string[]> Values { get; set; } | |||
| public SelectMenuComponent() { } | |||
| 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); | |||
| } | |||
| /// <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); | |||
| } | |||
| /// <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 | |||
| /// <inheritdoc/> | |||
| IComponentInteractionData IComponentInteraction.Data => Data; | |||
| @@ -27,11 +27,26 @@ namespace Discord.Rest | |||
| /// </summary> | |||
| public IReadOnlyCollection<string> Values { get; } | |||
| /// <inheritdoc/> | |||
| public string Value { get; } | |||
| internal RestMessageComponentData(Model model) | |||
| { | |||
| CustomId = model.CustomId; | |||
| Type = model.ComponentType; | |||
| Values = model.Values.GetValueOrDefault(); | |||
| } | |||
| internal RestMessageComponentData(IMessageComponent component) | |||
| { | |||
| CustomId = component.CustomId; | |||
| Type = component.Type; | |||
| if (component is API.TextInputComponent textInput) | |||
| Value = textInput.Value.Value; | |||
| if (component is API.SelectMenuComponent select) | |||
| Values = select.Values.Value; | |||
| } | |||
| } | |||
| } | |||
| @@ -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) | |||
| return await RestAutocompleteInteraction.CreateAsync(client, model).ConfigureAwait(false); | |||
| if (model.Type == InteractionType.ModalSubmit) | |||
| return await RestModal.CreateAsync(client, model).ConfigureAwait(false); | |||
| return null; | |||
| } | |||
| @@ -180,6 +183,9 @@ namespace Discord.Rest | |||
| var model = await InteractionHelper.ModifyInteractionResponseAsync(Discord, Token, func, options); | |||
| return RestInteractionMessage.Create(Discord, model, Token, Channel); | |||
| } | |||
| /// <inheritdoc/> | |||
| public abstract string RespondWithModal(Modal modal, RequestOptions options = null); | |||
| /// <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); | |||
| @@ -294,6 +300,9 @@ namespace Discord.Rest | |||
| Task IDiscordInteraction.DeferAsync(bool ephemeral, RequestOptions options) | |||
| => Task.FromResult(Defer(ephemeral, options)); | |||
| /// <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, | |||
| MessageComponent components, Embed embed, RequestOptions options) | |||
| => 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 RespondWithModal(Modal modal, RequestOptions options = null) => throw new NotSupportedException(); | |||
| public override string Respond(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); | |||
| public override Task<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(); | |||
| @@ -112,7 +112,8 @@ namespace Discord.Rest | |||
| => 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) | |||
| => 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 | |||
| /// <inheritdoc/> | |||
| @@ -56,6 +56,13 @@ namespace Discord.Net.Converters | |||
| interaction.Data = autocompleteData; | |||
| } | |||
| break; | |||
| case InteractionType.ModalSubmit: | |||
| { | |||
| var modalData = new API.ModalInteractionData(); | |||
| serializer.Populate(result.CreateReader(), modalData); | |||
| interaction.Data = modalData; | |||
| } | |||
| break; | |||
| } | |||
| } | |||
| else | |||
| @@ -32,6 +32,9 @@ namespace Discord.Net.Converters | |||
| case ComponentType.SelectMenu: | |||
| messageComponent = new API.SelectMenuComponent(); | |||
| break; | |||
| case ComponentType.TextInput: | |||
| messageComponent = new API.TextInputComponent(); | |||
| break; | |||
| } | |||
| serializer.Populate(jsonObject.CreateReader(), messageComponent); | |||
| return messageComponent; | |||
| @@ -634,6 +634,15 @@ namespace Discord.WebSocket | |||
| remove => _autocompleteExecuted.Remove(value); | |||
| } | |||
| 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> | |||
| /// Fired when a guild application command is created. | |||
| @@ -468,6 +468,7 @@ namespace Discord.WebSocket | |||
| client.UserCommandExecuted += (arg) => _userCommandExecuted.InvokeAsync(arg); | |||
| client.MessageCommandExecuted += (arg) => _messageCommandExecuted.InvokeAsync(arg); | |||
| client.AutocompleteExecuted += (arg) => _autocompleteExecuted.InvokeAsync(arg); | |||
| client.ModalSubmitted += (arg) => _modalSubmitted.InvokeAsync(arg); | |||
| client.ThreadUpdated += (thread1, thread2) => _threadUpdated.InvokeAsync(thread1, thread2); | |||
| client.ThreadCreated += (thread) => _threadCreated.InvokeAsync(thread); | |||
| @@ -78,7 +78,7 @@ namespace Discord.API | |||
| if (msg != null) | |||
| { | |||
| #if DEBUG_PACKETS | |||
| Console.WriteLine($"<- {(GatewayOpCode)msg.Operation} [{msg.Type ?? "none"}] : {(msg.Payload as Newtonsoft.Json.Linq.JToken)?.ToString().Length}"); | |||
| Console.WriteLine($"<- {(GatewayOpCode)msg.Operation} [{msg.Type ?? "none"}] : {(msg.Payload as Newtonsoft.Json.Linq.JToken)}"); | |||
| #endif | |||
| await _receivedGatewayEvent.InvokeAsync((GatewayOpCode)msg.Operation, msg.Sequence, msg.Type, msg.Payload).ConfigureAwait(false); | |||
| @@ -95,7 +95,7 @@ namespace Discord.API | |||
| if (msg != null) | |||
| { | |||
| #if DEBUG_PACKETS | |||
| Console.WriteLine($"<- {(GatewayOpCode)msg.Operation} [{msg.Type ?? "none"}] : {(msg.Payload as Newtonsoft.Json.Linq.JToken)?.ToString().Length}"); | |||
| Console.WriteLine($"<- {(GatewayOpCode)msg.Operation} [{msg.Type ?? "none"}] : {(msg.Payload as Newtonsoft.Json.Linq.JToken)}"); | |||
| #endif | |||
| await _receivedGatewayEvent.InvokeAsync((GatewayOpCode)msg.Operation, msg.Sequence, msg.Type, msg.Payload).ConfigureAwait(false); | |||
| @@ -2274,6 +2274,9 @@ namespace Discord.WebSocket | |||
| case SocketAutocompleteInteraction autocomplete: | |||
| await TimedInvokeAsync(_autocompleteExecuted, nameof(AutocompleteExecuted), autocomplete).ConfigureAwait(false); | |||
| break; | |||
| case SocketModal modal: | |||
| await TimedInvokeAsync(_modalSubmitted, nameof(ModalSubmitted), modal).ConfigureAwait(false); | |||
| break; | |||
| } | |||
| } | |||
| break; | |||
| @@ -438,6 +438,41 @@ namespace Discord.WebSocket | |||
| 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 | |||
| /// <inheritdoc/> | |||
| IComponentInteractionData IComponentInteraction.Data => Data; | |||
| @@ -23,11 +23,31 @@ namespace Discord.WebSocket | |||
| /// </summary> | |||
| 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) | |||
| { | |||
| CustomId = model.CustomId; | |||
| Type = model.ComponentType; | |||
| Values = model.Values.GetValueOrDefault(); | |||
| Value = model.Value.GetValueOrDefault(); | |||
| } | |||
| internal SocketMessageComponentData(IMessageComponent component) | |||
| { | |||
| CustomId = component.CustomId; | |||
| Type = component.Type; | |||
| Value = component.Type == ComponentType.TextInput | |||
| ? (component as API.TextInputComponent).Value.Value | |||
| : null; | |||
| Values = component.Type == ComponentType.SelectMenu | |||
| ? (component as API.SelectMenuComponent).Values.Value | |||
| : null; | |||
| } | |||
| } | |||
| } | |||
| @@ -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) | |||
| => 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 | |||
| /// <inheritdoc/> | |||
| IAutocompleteInteractionData IAutocompleteInteraction.Data => Data; | |||
| @@ -1,4 +1,3 @@ | |||
| using Discord.Net.Rest; | |||
| using Discord.Rest; | |||
| using System; | |||
| using System.Collections.Generic; | |||
| @@ -135,6 +134,42 @@ namespace Discord.WebSocket | |||
| HasResponded = true; | |||
| } | |||
| /// <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( | |||
| IEnumerable<FileAttachment> attachments, | |||
| string text = null, | |||
| @@ -108,6 +108,9 @@ namespace Discord.WebSocket | |||
| if (model.Type == InteractionType.ApplicationCommandAutocomplete) | |||
| return SocketAutocompleteInteraction.Create(client, model, channel); | |||
| if (model.Type == InteractionType.ModalSubmit) | |||
| return SocketModal.Create(client, model, channel); | |||
| return null; | |||
| } | |||
| @@ -387,6 +390,13 @@ namespace Discord.WebSocket | |||
| /// </returns> | |||
| 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 | |||
| #region IDiscordInteraction | |||