* Implement Modals (#428) * Socket Modal Support * fix shareded client support * Properly use `HasResponded` instead of `_hasResponded` * `ModalBuilder` and `TextInputBuilder` validation. * make orginisation more consistant. * Rest Modals. * Docs + add missing methods * fix message signatures and missing abstract members * modal changes * um????? * update modal docs * update docs - again for some reason * cleanup * fix message signatures * add modal commands support to interaction service * Fix _hasResponded * update to new unsupported standard. * Sending modals with Interaction service. * fix spelling in ComponentBuilder * sending IModals when responding to interactions * interaction service modals * fix rest modals * spelling and minor improvements. * improve interaction service modal proformance * use precompiled lambda for interaction service modals * respect user compiled lambda choice * changes to modals in the interaction service (more) * support compiled lambdas in modal properties. * modal interactions tweaks * fix inline doc * more modal docs * configure responce to faild modal component * init * solve runtime errors * solve build errors * add default value parsing * make modal info caching static * make ModalUtils static * add inline docs * fix build errors * code cleanup * Introduce Required and Label properties as seperate attributes. * replace internal dictionary of ModalInfo with a list * change input building logic of modals * update RespondWithModalAsync method * add initial value parameter back to ModalTextInput and fix optional modal field * add missing inline docs * dispose the reference modal instance after building * code cleanup on modalcommandbuilder * Update docs/guides/int_basics/message-components/text-input.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/message-components/text-input.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/modals/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/modals/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/modals/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/modals/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/modals/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/modals/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_basics/modals/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_framework/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_framework/intro.md Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update docs/guides/int_framework/samples/intro/modal.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/MessageComponents/IComponentInteractionData.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputComponent.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteraction.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Interactions/Attributes/Commands/ModalInteractionAttribute.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Interactions/Attributes/Modals/RequiredInputAttribute.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.Interactions/InteractionServiceConfig.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponentData.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Update src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModalData.cs Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * update interaction service modal docs * implements ExitOnMissingmModalField config option and adds Type field to modal info * Add WithValue to text input builders * Fix rare NRE on component enumeration * Fix RequestOptions being required in some methods * Use 'OfType' instead of 'Where' * Remove android unsported warning * Change publicity of properties in IInputComponeontBuilder.cs Co-authored-by: Cenk Ergen <57065323+Cenngo@users.noreply.github.com> Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com> * Remove complex parameter ref Co-authored-by: CottageDwellingCat <80918250+CottageDwellingCat@users.noreply.github.com> Co-authored-by: Cenk Ergen <57065323+Cenngo@users.noreply.github.com> Co-authored-by: Jared L <48422312+lhjt@users.noreply.github.com>tags/3.3.0
@@ -8,16 +8,16 @@ | |||||
"editor.rulers": [ | "editor.rulers": [ | ||||
120 | 120 | ||||
], | ], | ||||
"editor.insertSpaces": true, | |||||
"files.exclude": { | "files.exclude": { | ||||
"**/.git": true, | "**/.git": true, | ||||
"**/.svn": true, | "**/.svn": true, | ||||
"**/.hg": true, | "**/.hg": true, | ||||
"**/CVS": true, | "**/CVS": true, | ||||
"**/.DS_Store": true, | "**/.DS_Store": true, | ||||
"docs/": true, | |||||
"**/obj": true, | "**/obj": true, | ||||
"**/bin": true, | "**/bin": true, | ||||
"samples/": true, | "samples/": true, | ||||
} | } | ||||
} | } | ||||
} | |||||
} |
@@ -0,0 +1,46 @@ | |||||
--- | |||||
uid: Guides.MessageComponents.TextInputs | |||||
title: Text Input Components | |||||
--- | |||||
# Text Input Components | |||||
> [!WARNING] | |||||
> Text input components can only be used in | |||||
> [modals](../modals/intro.md). | |||||
Text input components are a type of MessageComponents that can only be | |||||
used in modals. Texts inputs can be longer (the `Paragraph`) style or | |||||
shorter (the `Short` style). Text inputs have a variable min and max | |||||
length. | |||||
 | |||||
## Creating text inputs | |||||
Text input components can be built using the `TextInputBuilder`. | |||||
The simplest text input can built with: | |||||
```cs | |||||
var tb = new TextInputBuilder() | |||||
.WithLabel("My Text") | |||||
.WithCustomId("text_input"); | |||||
``` | |||||
and would produce a component that looks like: | |||||
 | |||||
Additional options can be specified to control the placeholder, style, | |||||
and min/max length of the input: | |||||
```cs | |||||
var tb = new TextInputBuilder() | |||||
.WithLabel("Labeled") | |||||
.WithCustomId("text_input") | |||||
.WithStyle(TextInputStyle.Paragraph) | |||||
.WithMinLength(6); | |||||
.WithMaxLength(42) | |||||
.WithRequired(true) | |||||
.WithPlaceholder("Consider this place held."); | |||||
``` | |||||
 | |||||
@@ -0,0 +1,135 @@ | |||||
--- | |||||
uid: Guides.Modals.Intro | |||||
title: Getting Started with Modals | |||||
--- | |||||
# Modals | |||||
## Getting started with modals | |||||
This guide will show you how to use modals and give a few examples of | |||||
valid use cases. If your question is not covered by this guide ask in the | |||||
[Discord.Net Discord Server](https://discord.gg/dnet). | |||||
### What is a modal? | |||||
Modals are forms bots can send when responding to interactions. Modals | |||||
are sent to Discord as an array of message components and converted | |||||
into the form layout by user's clients. Modals are required to have a | |||||
custom id, title, and at least one component. | |||||
 | |||||
When users submit modals, your client fires the ModalSubmitted event. | |||||
You can get the components of the modal from the `Data.Components` property | |||||
on the SocketModal: | |||||
 | |||||
### Using modals | |||||
Lets create a simple modal with an entry field for users to | |||||
tell us their favorite food. We can start by creating a slash | |||||
command that will respond with the modal. | |||||
```cs | |||||
[SlashCommand("food", "Tell us about your favorite food!")] | |||||
public async Task FoodPreference() | |||||
{ | |||||
// send a modal | |||||
} | |||||
``` | |||||
Now that we have our command set up, we need to build a modal. | |||||
We can use the aptly named `ModalBuilder` for that: | |||||
| Method | Description | | |||||
| --------------- | ----------------------------------------- | | |||||
| `WithTitle` | Sets the modal's title. | | |||||
| `WithCustomId` | Sets the modal's custom id. | | |||||
| `AddTextInput` | Adds a `TextInputBuilder` to the modal. | | |||||
| `AddComponents` | Adds multiple components to the modal. | | |||||
| `Build` | Builds the `ModalBuilder` into a `Modal`. | | |||||
We know we need to add a text input to the modal, so let's look at that | |||||
method's parameters. | |||||
| Parameter | Description | | |||||
| ------------- | ------------------------------------------ | | |||||
| `label` | Sets the input's label. | | |||||
| `customId` | Sets the input's custom id. | | |||||
| `style` | Sets the input's style. | | |||||
| `placeholder` | Sets the input's placeholder. | | |||||
| `minLength` | Sets the minimum input length. | | |||||
| `maxLength` | Sets the maximum input length. | | |||||
| `required` | Sets whether or not the modal is required. | | |||||
| `value` | Sets the input's default value. | | |||||
To make a basic text input we would only need to set the `label` and | |||||
`customId`, but in this example we will also use the `placeholder` | |||||
parameter. Next we can build our modal: | |||||
```cs | |||||
var mb = new ModalBuilder() | |||||
.WithTitle("Fav Food") | |||||
.WithCustomId("food_menu") | |||||
.AddTextInput("What??", "food_name", placeholder:"Pizza") | |||||
.AddTextInput("Why??", "food_reason", TextInputStyle.Paragraph, | |||||
"Kus it's so tasty"); | |||||
``` | |||||
Now that we have a ModalBuilder we can update our command to respond | |||||
with the modal. | |||||
```cs | |||||
[SlashCommand("food", "Tell us about your favorite food!")] | |||||
public async Task FoodPreference() | |||||
{ | |||||
var mb = new ModalBuilder() | |||||
.WithTitle("Fav Food") | |||||
.WithCustomId("food_menu") | |||||
.AddTextInput("What??", "food_name", placeholder:"Pizza") | |||||
.AddTextInput("Why??", "food_reason", TextInputStyle.Paragraph, | |||||
"Kus it's so tasty"); | |||||
await Context.Interaction.RespondWithModalAsync(mb.Build()); | |||||
} | |||||
``` | |||||
When we run the command, our modal should pop up: | |||||
 | |||||
### Respond to modals | |||||
> [!WARNING] | |||||
> Modals can not be sent when respoding to a modal. | |||||
Once a user has submitted the modal, we need to let everyone know what | |||||
their favorite food is. We can start by hooking a task to the client's | |||||
`ModalSubmitted` event. | |||||
```cs | |||||
_client.ModalSubmitted += async modal => | |||||
{ | |||||
// Get the values of components. | |||||
List<SocketMessageComponentData> components = | |||||
modal.Data.Components.ToList(); | |||||
string food = components | |||||
.Where(x => x.CustomId == "food_name").First().Value; | |||||
string reason = components | |||||
.Where(x => x.CustomId == "food_reason").First().Value; | |||||
// Build the message to send. | |||||
string message = "hey @everyone; I just learned " + | |||||
$"{modal.User.Mention}'s favorite food is " + | |||||
$"{food} because {reason}."; | |||||
// Specify the AllowedMentions so we don't actually ping everyone. | |||||
AllowedMentions mentions = new AllowedMentions(); | |||||
mentions.AllowedTypes = AllowedMentionTypes.Users; | |||||
// Respond to the modal. | |||||
await modal.RespondAsync(message, allowedMentions:mentions); | |||||
} | |||||
``` | |||||
Now responding to the modal should inform everyone of our tasty | |||||
choices. | |||||
 |
@@ -198,6 +198,18 @@ Autocomplete commands must be parameterless methods. A valid Autocomplete comman | |||||
Alternatively, you can use the [AutocompleteHandlers] to simplify this workflow. | Alternatively, you can use the [AutocompleteHandlers] to simplify this workflow. | ||||
## Modals | |||||
Modal commands last parameter must be an implementation of `IModal`. | |||||
A Modal implementation would look like this: | |||||
[!code-csharp[Modal Command](samples/intro/modal.cs)] | |||||
> [!NOTE] | |||||
> If you are using Modals in the interaction service it is **highly | |||||
> recommended** that you enable `PreCompiledLambdas` in your config | |||||
> to prevent performance issues. | |||||
## Interaction Context | ## Interaction Context | ||||
Every command module provides its commands with an execution context. | Every command module provides its commands with an execution context. | ||||
@@ -0,0 +1,36 @@ | |||||
// Registers a command that will respond with a modal. | |||||
[SlashCommand("food", "Tell us about your favorite food.")] | |||||
public async Task Command() | |||||
=> await Context.Interaction.RespondWithModalAsync<FoodModal>("food_menu"); | |||||
// Defines the modal that will be sent. | |||||
public class FoodModal : IModal | |||||
{ | |||||
public string Title => "Fav Food"; | |||||
// Strings with the ModalTextInput attribute will automatically become components. | |||||
[InputLabel("What??")] | |||||
[ModalTextInput("food_name", placeholder: "Pizza", maxLength: 20)] | |||||
public string Food { get; set; } | |||||
// Additional paremeters can be specified to further customize the input. | |||||
[InputLabel("Why??")] | |||||
[ModalTextInput("food_reason", TextInputStyle.Paragraph, "Kuz it's tasty", maxLength: 500)] | |||||
public string Reason { get; set; } | |||||
} | |||||
// Responds to the modal. | |||||
[ModalInteraction("food_menu")] | |||||
public async Task ModalResponce(FoodModal modal) | |||||
{ | |||||
// Build the message to send. | |||||
string message = "hey @everyone, I just learned " + | |||||
$"{Context.User.Mention}'s favorite food is " + | |||||
$"{modal.Food} because {modal.Reason}."; | |||||
// Specify the AllowedMentions so we don't actually ping everyone. | |||||
AllowedMentions mentions = new(); | |||||
mentions.AllowedTypes = AllowedMentionTypes.Users; | |||||
// Respond to the modal. | |||||
await RespondAsync(message, allowedMentions: mentions, ephemeral: true); | |||||
} |
@@ -91,8 +91,14 @@ | |||||
topicUid: Guides.MessageComponents.Buttons | topicUid: Guides.MessageComponents.Buttons | ||||
- name: Select menus | - name: Select menus | ||||
topicUid: Guides.MessageComponents.SelectMenus | topicUid: Guides.MessageComponents.SelectMenus | ||||
- name: Text Input | |||||
topicUid: Guides.MessageComponents.TextInputs | |||||
- name: Advanced Concepts | - name: Advanced Concepts | ||||
topicUid: Guides.MessageComponents.Advanced | topicUid: Guides.MessageComponents.Advanced | ||||
- name: Modal Basics | |||||
items: | |||||
- name: Introduction | |||||
topicUid: Guides.Modals.Intro | |||||
- name: Guild Events | - name: Guild Events | ||||
items: | items: | ||||
- name: Introduction | - name: Introduction | ||||
@@ -332,5 +332,13 @@ namespace Discord | |||||
/// A task that represents the asynchronous operation of deferring the interaction. | /// A task that represents the asynchronous operation of deferring the interaction. | ||||
/// </returns> | /// </returns> | ||||
Task DeferAsync(bool ephemeral = false, RequestOptions options = null); | Task DeferAsync(bool ephemeral = false, RequestOptions options = null); | ||||
/// <summary> | |||||
/// Responds to the interaction with a modal. | |||||
/// </summary> | |||||
/// <param name="modal">The modal to respond with.</param> | |||||
/// <param name="options">The request options for this <see langword="async"/> request.</param> | |||||
/// <returns>A task that represents the asynchronous operation of responding to the interaction.</returns> | |||||
Task RespondWithModalAsync(Modal modal, RequestOptions options = null); | |||||
} | } | ||||
} | } |
@@ -41,6 +41,11 @@ namespace Discord | |||||
/// <summary> | /// <summary> | ||||
/// Respond with a set of choices to a autocomplete interaction. | /// Respond with a set of choices to a autocomplete interaction. | ||||
/// </summary> | /// </summary> | ||||
ApplicationCommandAutocompleteResult = 8 | |||||
ApplicationCommandAutocompleteResult = 8, | |||||
/// <summary> | |||||
/// Respond by showing the user a modal. | |||||
/// </summary> | |||||
Modal = 9, | |||||
} | } | ||||
} | } |
@@ -23,6 +23,11 @@ namespace Discord | |||||
/// <summary> | /// <summary> | ||||
/// An autocomplete request sent from discord. | /// An autocomplete request sent from discord. | ||||
/// </summary> | /// </summary> | ||||
ApplicationCommandAutocomplete = 4 | |||||
ApplicationCommandAutocomplete = 4, | |||||
/// <summary> | |||||
/// A modal sent from discord. | |||||
/// </summary> | |||||
ModalSubmit = 5, | |||||
} | } | ||||
} | } |
@@ -276,6 +276,11 @@ namespace Discord | |||||
/// <returns>A <see cref="MessageComponent"/> that can be sent with <see cref="IMessageChannel.SendMessageAsync"/>.</returns> | /// <returns>A <see cref="MessageComponent"/> that can be sent with <see cref="IMessageChannel.SendMessageAsync"/>.</returns> | ||||
public MessageComponent Build() | public MessageComponent Build() | ||||
{ | { | ||||
if (_actionRows?.SelectMany(x => x.Components)?.Any(x => x.Type == ComponentType.TextInput) ?? false) | |||||
throw new ArgumentException("TextInputComponents are not allowed in messages.", nameof(ActionRows)); | |||||
if (_actionRows?.SelectMany(x => x.Components)?.Any(x => x.Type == ComponentType.ModalSubmit) ?? false) | |||||
throw new ArgumentException("ModalSubmit components are not allowed in messages.", nameof(ActionRows)); | |||||
return _actionRows != null | return _actionRows != null | ||||
? new MessageComponent(_actionRows.Select(x => x.Build()).ToList()) | ? new MessageComponent(_actionRows.Select(x => x.Build()).ToList()) | ||||
: MessageComponent.Empty; | : MessageComponent.Empty; | ||||
@@ -1093,4 +1098,248 @@ namespace Discord | |||||
return new SelectMenuOption(Label, Value, Description, Emote, IsDefault); | return new SelectMenuOption(Label, Value, Description, Emote, IsDefault); | ||||
} | } | ||||
} | } | ||||
public class TextInputBuilder | |||||
{ | |||||
public const int LargestMaxLength = 4000; | |||||
/// <summary> | |||||
/// Gets or sets the custom id of the current text input. | |||||
/// </summary> | |||||
/// <exception cref="ArgumentException" accessor="set"><see cref="CustomId"/> length exceeds <see cref="ComponentBuilder.MaxCustomIdLength"/></exception> | |||||
/// <exception cref="ArgumentException" accessor="set"><see cref="CustomId"/> length subceeds 1.</exception> | |||||
public string CustomId | |||||
{ | |||||
get => _customId; | |||||
set => _customId = value?.Length switch | |||||
{ | |||||
> ComponentBuilder.MaxCustomIdLength => throw new ArgumentOutOfRangeException(nameof(value), $"Custom Id length must be less or equal to {ComponentBuilder.MaxCustomIdLength}."), | |||||
0 => throw new ArgumentOutOfRangeException(nameof(value), "Custom Id length must be at least 1."), | |||||
_ => value | |||||
}; | |||||
} | |||||
/// <summary> | |||||
/// Gets or sets the style of the current text input. | |||||
/// </summary> | |||||
public TextInputStyle Style { get; set; } = TextInputStyle.Short; | |||||
/// <summary> | |||||
/// Gets or sets the label of the current text input. | |||||
/// </summary> | |||||
public string Label { get; set; } | |||||
/// <summary> | |||||
/// Gets or sets the placeholder of the current text input. | |||||
/// </summary> | |||||
/// <exception cref="ArgumentException"><see cref="Placeholder"/> is longer than 100 characters</exception> | |||||
public string Placeholder | |||||
{ | |||||
get => _placeholder; | |||||
set => _placeholder = (value?.Length ?? 0) <= 100 | |||||
? value | |||||
: throw new ArgumentException("Placeholder cannot have more than 100 characters."); | |||||
} | |||||
/// <summary> | |||||
/// Gets or sets the minimum length of the current text input. | |||||
/// </summary> | |||||
/// <exception cref="ArgumentOutOfRangeException"><see cref="MinLength"/> is less than 0.</exception> | |||||
/// <exception cref="ArgumentOutOfRangeException"><see cref="MinLength"/> is greater than <see cref="LargestMaxLength"/>.</exception> | |||||
/// <exception cref="ArgumentOutOfRangeException"><see cref="MinLength"/> is greater than <see cref="MaxLength"/>.</exception> | |||||
public int? MinLength | |||||
{ | |||||
get => _minLength; | |||||
set | |||||
{ | |||||
if (value < 0) | |||||
throw new ArgumentOutOfRangeException(nameof(value), $"MinLength must not be less than 0"); | |||||
if (value > LargestMaxLength) | |||||
throw new ArgumentOutOfRangeException(nameof(value), $"MinLength must not be greater than {LargestMaxLength}"); | |||||
if (value > (MaxLength ?? LargestMaxLength)) | |||||
throw new ArgumentOutOfRangeException(nameof(value), $"MinLength must be less than MaxLength"); | |||||
_minLength = value; | |||||
} | |||||
} | |||||
/// <summary> | |||||
/// Gets or sets the maximum length of the current text input. | |||||
/// </summary> | |||||
/// <exception cref="ArgumentOutOfRangeException"><see cref="MaxLength"/> is less than 0.</exception> | |||||
/// <exception cref="ArgumentOutOfRangeException"><see cref="MaxLength"/> is greater than <see cref="LargestMaxLength"/>.</exception> | |||||
/// <exception cref="ArgumentOutOfRangeException"><see cref="MaxLength"/> is less than <see cref="MinLength"/>.</exception> | |||||
public int? MaxLength | |||||
{ | |||||
get => _maxLength; | |||||
set | |||||
{ | |||||
if (value < 0) | |||||
throw new ArgumentOutOfRangeException(nameof(value), $"MaxLength must not be less than 0"); | |||||
if (value > LargestMaxLength) | |||||
throw new ArgumentOutOfRangeException(nameof(value), $"MaxLength most not be greater than {LargestMaxLength}"); | |||||
if (value < (MinLength ?? -1)) | |||||
throw new ArgumentOutOfRangeException(nameof(value), $"MaxLength must be greater than MinLength ({MinLength})"); | |||||
_maxLength = value; | |||||
} | |||||
} | |||||
/// <summary> | |||||
/// Gets or sets whether the user is required to input text. | |||||
/// </summary> | |||||
public bool? Required { get; set; } | |||||
/// <summary> | |||||
/// Gets or sets the default value of the text input. | |||||
/// </summary> | |||||
/// <exception cref="ArgumentOutOfRangeException"><see cref="Value.Length"/> is less than 0.</exception> | |||||
/// <exception cref="ArgumentOutOfRangeException"> | |||||
/// <see cref="Value.Length"/> is greater than <see cref="LargestMaxLength"/> or <see cref="MaxLength"/>. | |||||
/// </exception> | |||||
public string Value | |||||
{ | |||||
get => _value; | |||||
set | |||||
{ | |||||
if (value?.Length > (MaxLength ?? LargestMaxLength)) | |||||
throw new ArgumentOutOfRangeException(nameof(value), $"Value must not be longer than {MaxLength ?? LargestMaxLength}."); | |||||
if (value?.Length < (MinLength ?? 0)) | |||||
throw new ArgumentOutOfRangeException(nameof(value), $"Value must not be shorter than {MinLength}"); | |||||
_value = value; | |||||
} | |||||
} | |||||
private string _customId; | |||||
private int? _maxLength; | |||||
private int? _minLength; | |||||
private string _placeholder; | |||||
private string _value; | |||||
/// <summary> | |||||
/// Creates a new instance of a <see cref="TextInputBuilder"/>. | |||||
/// </summary> | |||||
/// <param name="label">The text input's label.</param> | |||||
/// <param name="style">The text input's style.</param> | |||||
/// <param name="customId">The text input's custom id.</param> | |||||
/// <param name="placeholder">The text input's placeholder.</param> | |||||
/// <param name="minLength">The text input's minimum length.</param> | |||||
/// <param name="maxLength">The text input's maximum length.</param> | |||||
/// <param name="required">The text input's required value.</param> | |||||
public TextInputBuilder (string label, string customId, TextInputStyle style = TextInputStyle.Short, string placeholder = null, | |||||
int? minLength = null, int? maxLength = null, bool? required = null, string value = null) | |||||
{ | |||||
Label = label; | |||||
Style = style; | |||||
CustomId = customId; | |||||
Placeholder = placeholder; | |||||
MinLength = minLength; | |||||
MaxLength = maxLength; | |||||
Required = required; | |||||
Value = value; | |||||
} | |||||
/// <summary> | |||||
/// Creates a new instance of a <see cref="TextInputBuilder"/>. | |||||
/// </summary> | |||||
public TextInputBuilder() | |||||
{ | |||||
} | |||||
/// <summary> | |||||
/// Sets the label of the current builder. | |||||
/// </summary> | |||||
/// <param name="label">The value to set.</param> | |||||
/// <returns>The current builder. </returns> | |||||
public TextInputBuilder WithLabel(string label) | |||||
{ | |||||
Label = label; | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Sets the style of the current builder. | |||||
/// </summary> | |||||
/// <param name="style">The value to set.</param> | |||||
/// <returns>The current builder. </returns> | |||||
public TextInputBuilder WithStyle(TextInputStyle style) | |||||
{ | |||||
Style = style; | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Sets the custom id of the current builder. | |||||
/// </summary> | |||||
/// <param name="customId">The value to set.</param> | |||||
/// <returns>The current builder. </returns> | |||||
public TextInputBuilder WithCustomId(string customId) | |||||
{ | |||||
CustomId = customId; | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Sets the placeholder of the current builder. | |||||
/// </summary> | |||||
/// <param name="placeholder">The value to set.</param> | |||||
/// <returns>The current builder. </returns> | |||||
public TextInputBuilder WithPlaceholder(string placeholder) | |||||
{ | |||||
Placeholder = placeholder; | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Sets the value of the current builder. | |||||
/// </summary> | |||||
/// <param name="value">The value to set</param> | |||||
/// <returns>The current builder.</returns> | |||||
public TextInputBuilder WithValue(string value) | |||||
{ | |||||
Value = value; | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Sets the minimum length of the current builder. | |||||
/// </summary> | |||||
/// <param name="placeholder">The value to set.</param> | |||||
/// <returns>The current builder. </returns> | |||||
public TextInputBuilder WithMinLength(int minLength) | |||||
{ | |||||
MinLength = minLength; | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Sets the maximum length of the current builder. | |||||
/// </summary> | |||||
/// <param name="placeholder">The value to set.</param> | |||||
/// <returns>The current builder. </returns> | |||||
public TextInputBuilder WithMaxLength(int maxLength) | |||||
{ | |||||
MaxLength = maxLength; | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Sets the required value of the current builder. | |||||
/// </summary> | |||||
/// <param name="required">The value to set.</param> | |||||
/// <returns>The current builder. </returns> | |||||
public TextInputBuilder WithRequired(bool required) | |||||
{ | |||||
Required = required; | |||||
return this; | |||||
} | |||||
public TextInputComponent Build() | |||||
{ | |||||
if (string.IsNullOrEmpty(CustomId)) | |||||
throw new ArgumentException("TextInputComponents must have a custom id.", nameof(CustomId)); | |||||
if (string.IsNullOrWhiteSpace(Label)) | |||||
throw new ArgumentException("TextInputComponents must have a label.", nameof(Label)); | |||||
return new TextInputComponent(CustomId, Label, Placeholder, MinLength, MaxLength, Style, Required, Value); | |||||
} | |||||
} | |||||
} | } |
@@ -18,6 +18,16 @@ namespace Discord | |||||
/// <summary> | /// <summary> | ||||
/// A select menu for picking from choices. | /// A select menu for picking from choices. | ||||
/// </summary> | /// </summary> | ||||
SelectMenu = 3 | |||||
SelectMenu = 3, | |||||
/// <summary> | |||||
/// A box for entering text. | |||||
/// </summary> | |||||
TextInput = 4, | |||||
/// <summary> | |||||
/// An interaction sent when a model is submitted. | |||||
/// </summary> | |||||
ModalSubmit = 5, | |||||
} | } | ||||
} | } |
@@ -8,7 +8,7 @@ namespace Discord | |||||
public interface IComponentInteractionData : IDiscordInteractionData | public interface IComponentInteractionData : IDiscordInteractionData | ||||
{ | { | ||||
/// <summary> | /// <summary> | ||||
/// Gets the components Custom Id that was clicked. | |||||
/// Gets the component's Custom Id that was clicked. | |||||
/// </summary> | /// </summary> | ||||
string CustomId { get; } | string CustomId { get; } | ||||
@@ -21,5 +21,10 @@ namespace Discord | |||||
/// Gets the value(s) of a <see cref="SelectMenuComponent"/> interaction response. | /// Gets the value(s) of a <see cref="SelectMenuComponent"/> interaction response. | ||||
/// </summary> | /// </summary> | ||||
IReadOnlyCollection<string> Values { get; } | IReadOnlyCollection<string> Values { get; } | ||||
/// <summary> | |||||
/// Gets the value of a <see cref="TextInputComponent"/> interaction response. | |||||
/// </summary> | |||||
public string Value { get; } | |||||
} | } | ||||
} | } |
@@ -0,0 +1,62 @@ | |||||
namespace Discord | |||||
{ | |||||
/// <summary> | |||||
/// Respresents a <see cref="IMessageComponent"/> text input. | |||||
/// </summary> | |||||
public class TextInputComponent : IMessageComponent | |||||
{ | |||||
/// <inheritdoc/> | |||||
public ComponentType Type => ComponentType.TextInput; | |||||
/// <inheritdoc/> | |||||
public string CustomId { get; } | |||||
/// <summary> | |||||
/// Gets the label of the component; this is the text shown above it. | |||||
/// </summary> | |||||
public string Label { get; } | |||||
/// <summary> | |||||
/// Gets the placeholder of the component. | |||||
/// </summary> | |||||
public string Placeholder { get; } | |||||
/// <summary> | |||||
/// Gets the minimum length of the inputted text. | |||||
/// </summary> | |||||
public int? MinLength { get; } | |||||
/// <summary> | |||||
/// Gets the maximum length of the inputted text. | |||||
/// </summary> | |||||
public int? MaxLength { get; } | |||||
/// <summary> | |||||
/// Gets the style of the component. | |||||
/// </summary> | |||||
public TextInputStyle Style { get; } | |||||
/// <summary> | |||||
/// Gets whether users are required to input text. | |||||
/// </summary> | |||||
public bool? Required { get; } | |||||
/// <summary> | |||||
/// Gets the default value of the component. | |||||
/// </summary> | |||||
public string Value { get; } | |||||
internal TextInputComponent(string customId, string label, string placeholder, int? minLength, int? maxLength, | |||||
TextInputStyle style, bool? required, string value) | |||||
{ | |||||
CustomId = customId; | |||||
Label = label; | |||||
Placeholder = placeholder; | |||||
MinLength = minLength; | |||||
MaxLength = maxLength; | |||||
Style = style; | |||||
Required = required; | |||||
Value = value; | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,14 @@ | |||||
namespace Discord | |||||
{ | |||||
public enum TextInputStyle | |||||
{ | |||||
/// <summary> | |||||
/// Intended for short, single-line text. | |||||
/// </summary> | |||||
Short = 1, | |||||
/// <summary> | |||||
/// Intended for longer or multiline text. | |||||
/// </summary> | |||||
Paragraph = 2, | |||||
} | |||||
} |
@@ -0,0 +1,13 @@ | |||||
namespace Discord | |||||
{ | |||||
/// <summary> | |||||
/// Represents an interaction type for Modals. | |||||
/// </summary> | |||||
public interface IModalInteraction : IDiscordInteraction | |||||
{ | |||||
/// <summary> | |||||
/// Gets the data received with this interaction; contains the clicked button. | |||||
/// </summary> | |||||
new IModalInteractionData Data { get; } | |||||
} | |||||
} |
@@ -0,0 +1,20 @@ | |||||
using System.Collections.Generic; | |||||
namespace Discord | |||||
{ | |||||
/// <summary> | |||||
/// Represents the data sent with the <see cref="IModalInteraction"/>. | |||||
/// </summary> | |||||
public interface IModalInteractionData : IDiscordInteractionData | |||||
{ | |||||
/// <summary> | |||||
/// Gets the <see cref="Modal"/>'s Custom Id. | |||||
/// </summary> | |||||
string CustomId { get; } | |||||
/// <summary> | |||||
/// Gets the <see cref="Modal"/> components submitted by the user. | |||||
/// </summary> | |||||
IReadOnlyCollection<IComponentInteractionData> Components { get; } | |||||
} | |||||
} |
@@ -0,0 +1,37 @@ | |||||
using System; | |||||
using System.Collections.Generic; | |||||
using System.Linq; | |||||
using System.Text; | |||||
using System.Threading.Tasks; | |||||
namespace Discord | |||||
{ | |||||
/// <summary> | |||||
/// Represents a modal interaction. | |||||
/// </summary> | |||||
public class Modal : IMessageComponent | |||||
{ | |||||
/// <inheritdoc/> | |||||
public ComponentType Type => ComponentType.ModalSubmit; | |||||
/// <summary> | |||||
/// Gets the title of the modal. | |||||
/// </summary> | |||||
public string Title { get; set; } | |||||
/// <inheritdoc/> | |||||
public string CustomId { get; set; } | |||||
/// <summary> | |||||
/// Gets the components in the modal. | |||||
/// </summary> | |||||
public ModalComponent Component { get; set; } | |||||
internal Modal(string title, string customId, ModalComponent components) | |||||
{ | |||||
Title = title; | |||||
CustomId = customId; | |||||
Component = components; | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,268 @@ | |||||
using System; | |||||
using System.Collections.Generic; | |||||
using System.Linq; | |||||
using System.Text; | |||||
using System.Threading.Tasks; | |||||
namespace Discord | |||||
{ | |||||
public class ModalBuilder | |||||
{ | |||||
/// <summary> | |||||
/// Gets or sets the components of the current modal. | |||||
/// </summary> | |||||
public ModalComponentBuilder Components { get; set; } = new(); | |||||
/// <summary> | |||||
/// Gets or sets the title of the current modal. | |||||
/// </summary> | |||||
public string Title { get; set; } | |||||
/// <summary> | |||||
/// Gets or sets the custom id of the current modal. | |||||
/// </summary> | |||||
public string CustomId | |||||
{ | |||||
get => _customId; | |||||
set => _customId = value?.Length switch | |||||
{ | |||||
> ComponentBuilder.MaxCustomIdLength => throw new ArgumentOutOfRangeException(nameof(value), $"Custom Id length must be less or equal to {ComponentBuilder.MaxCustomIdLength}."), | |||||
0 => throw new ArgumentOutOfRangeException(nameof(value), "Custom Id length must be at least 1."), | |||||
_ => value | |||||
}; | |||||
} | |||||
private string _customId; | |||||
public ModalBuilder() { } | |||||
/// <summary> | |||||
/// Creates a new instance of a <see cref="ModalBuilder"/> | |||||
/// </summary> | |||||
/// <param name="title">The modal's title.</param> | |||||
/// <param name="customId">The modal's customId.</param> | |||||
/// <param name="components">The modal's components.</param> | |||||
/// <exception cref="ArgumentException">Only TextInputComponents are allowed.</exception> | |||||
public ModalBuilder(string title, string customId, ModalComponentBuilder components = null) | |||||
{ | |||||
Title = title; | |||||
CustomId = customId; | |||||
Components = components ?? new(); | |||||
} | |||||
/// <summary> | |||||
/// Sets the title of the current modal. | |||||
/// </summary> | |||||
/// <param name="title">The value to set the title to.</param> | |||||
/// <returns>The current builder.</returns> | |||||
public ModalBuilder WithTitle(string title) | |||||
{ | |||||
Title = title; | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Sets the custom id of the current modal. | |||||
/// </summary> | |||||
/// <param name="title">The value to set the custom id to.</param> | |||||
/// <returns>The current builder.</returns> | |||||
public ModalBuilder WithCustomId(string customId) | |||||
{ | |||||
CustomId = customId; | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Adds a component to the current builder. | |||||
/// </summary> | |||||
/// <param name="title">The component to add.</param> | |||||
/// <returns>The current builder.</returns> | |||||
public ModalBuilder AddTextInput(TextInputBuilder component) | |||||
{ | |||||
Components.WithTextInput(component); | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Adds a <see cref="TextInputBuilder"/> to the current builder. | |||||
/// </summary> | |||||
/// <param name="customId">The input's custom id.</param> | |||||
/// <param name="label">The input's label.</param> | |||||
/// <param name="placeholder">The input's placeholder text.</param> | |||||
/// <param name="minLength">The input's minimum length.</param> | |||||
/// <param name="maxLength">The input's maximum length.</param> | |||||
/// <param name="style">The input's style.</param> | |||||
/// <returns>The current builder.</returns> | |||||
public ModalBuilder AddTextInput(string label, string customId, TextInputStyle style = TextInputStyle.Short, | |||||
string placeholder = "", int? minLength = null, int? maxLength = null, bool? required = null, string value = null) | |||||
=> AddTextInput(new(label, customId, style, placeholder, minLength, maxLength, required, value)); | |||||
/// <summary> | |||||
/// Adds multiple components to the current builder. | |||||
/// </summary> | |||||
/// <param name="components">The components to add.</param> | |||||
/// <returns>The current builder</returns> | |||||
public ModalBuilder AddComponents(List<IMessageComponent> components, int row) | |||||
{ | |||||
components.ForEach(x => Components.AddComponent(x, row)); | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Builds this builder into a <see cref="Modal"/>. | |||||
/// </summary> | |||||
/// <returns>A <see cref="Modal"/> with the same values as this builder.</returns> | |||||
/// <exception cref="ArgumentException">Only TextInputComponents are allowed.</exception> | |||||
/// <exception cref="ArgumentException">Modals must have a custom id.</exception> | |||||
/// <exception cref="ArgumentException">Modals must have a title.</exception> | |||||
public Modal Build() | |||||
{ | |||||
if (string.IsNullOrEmpty(CustomId)) | |||||
throw new ArgumentException("Modals must have a custom id.", nameof(CustomId)); | |||||
if (string.IsNullOrWhiteSpace(Title)) | |||||
throw new ArgumentException("Modals must have a title.", nameof(Title)); | |||||
if (Components.ActionRows?.SelectMany(x => x.Components).Any(x => x.Type != ComponentType.TextInput) ?? false) | |||||
throw new ArgumentException($"Only TextInputComponents are allowed.", nameof(Components)); | |||||
return new(Title, CustomId, Components.Build()); | |||||
} | |||||
} | |||||
/// <summary> | |||||
/// Represents a builder for creating a <see cref="ModalComponent"/>. | |||||
/// </summary> | |||||
public class ModalComponentBuilder | |||||
{ | |||||
/// <summary> | |||||
/// The max length of a <see cref="IMessageComponent.CustomId"/>. | |||||
/// </summary> | |||||
public const int MaxCustomIdLength = 100; | |||||
/// <summary> | |||||
/// The max amount of rows a <see cref="ModalComponent"/> can have. | |||||
/// </summary> | |||||
public const int MaxActionRowCount = 5; | |||||
/// <summary> | |||||
/// Gets or sets the Action Rows for this Component Builder. | |||||
/// </summary> | |||||
/// <exception cref="ArgumentNullException" accessor="set"><see cref="ActionRows"/> cannot be null.</exception> | |||||
/// <exception cref="ArgumentException" accessor="set"><see cref="ActionRows"/> count exceeds <see cref="MaxActionRowCount"/>.</exception> | |||||
public List<ActionRowBuilder> ActionRows | |||||
{ | |||||
get => _actionRows; | |||||
set | |||||
{ | |||||
if (value == null) | |||||
throw new ArgumentNullException(nameof(value), $"{nameof(ActionRows)} cannot be null."); | |||||
if (value.Count > MaxActionRowCount) | |||||
throw new ArgumentOutOfRangeException(nameof(value), $"Action row count must be less than or equal to {MaxActionRowCount}."); | |||||
_actionRows = value; | |||||
} | |||||
} | |||||
private List<ActionRowBuilder> _actionRows; | |||||
/// <summary> | |||||
/// Creates a new builder from the provided list of components. | |||||
/// </summary> | |||||
/// <param name="components">The components to create the builder from.</param> | |||||
/// <returns>The newly created builder.</returns> | |||||
public static ComponentBuilder FromComponents(IReadOnlyCollection<IMessageComponent> components) | |||||
{ | |||||
var builder = new ComponentBuilder(); | |||||
for (int i = 0; i != components.Count; i++) | |||||
{ | |||||
var component = components.ElementAt(i); | |||||
builder.AddComponent(component, i); | |||||
} | |||||
return builder; | |||||
} | |||||
internal void AddComponent(IMessageComponent component, int row) | |||||
{ | |||||
switch (component) | |||||
{ | |||||
case TextInputComponent text: | |||||
WithTextInput(text.Label, text.CustomId, text.Style, text.Placeholder, text.MinLength, text.MaxLength, row); | |||||
break; | |||||
case ActionRowComponent actionRow: | |||||
foreach (var cmp in actionRow.Components) | |||||
AddComponent(cmp, row); | |||||
break; | |||||
} | |||||
} | |||||
/// <summary> | |||||
/// Adds a <see cref="TextInputBuilder"/> to the <see cref="ComponentBuilder"/> at the specific row. | |||||
/// If the row cannot accept the component then it will add it to a row that can. | |||||
/// </summary> | |||||
/// <param name="customId">The input's custom id.</param> | |||||
/// <param name="label">The input's label.</param> | |||||
/// <param name="placeholder">The input's placeholder text.</param> | |||||
/// <param name="minLength">The input's minimum length.</param> | |||||
/// <param name="maxLength">The input's maximum length.</param> | |||||
/// <param name="style">The input's style.</param> | |||||
/// <returns>The current builder.</returns> | |||||
public ModalComponentBuilder WithTextInput(string label, string customId, TextInputStyle style = TextInputStyle.Short, | |||||
string placeholder = null, int? minLength = null, int? maxLength = null, int row = 0, bool? required = null, | |||||
string value = null) | |||||
=> WithTextInput(new(label, customId, style, placeholder, minLength, maxLength, required, value), row); | |||||
/// <summary> | |||||
/// Adds a <see cref="TextInputBuilder"/> to the <see cref="ModalComponentBuilder"/> at the specific row. | |||||
/// If the row cannot accept the component then it will add it to a row that can. | |||||
/// </summary> | |||||
/// <param name="text">The <see cref="TextInputBuilder"> to add.</param> | |||||
/// <param name="row">The row to add the text input.</param> | |||||
/// <exception cref="InvalidOperationException">There are no more rows to add a text input to.</exception> | |||||
/// <exception cref="ArgumentException"><paramref name="row"/> must be less than <see cref="MaxActionRowCount"/>.</exception> | |||||
/// <returns>The current builder.</returns> | |||||
public ModalComponentBuilder WithTextInput(TextInputBuilder text, int row = 0) | |||||
{ | |||||
Preconditions.LessThan(row, MaxActionRowCount, nameof(row)); | |||||
var builtButton = text.Build(); | |||||
if (_actionRows == null) | |||||
{ | |||||
_actionRows = new List<ActionRowBuilder> | |||||
{ | |||||
new ActionRowBuilder().AddComponent(builtButton) | |||||
}; | |||||
} | |||||
else | |||||
{ | |||||
if (_actionRows.Count == row) | |||||
_actionRows.Add(new ActionRowBuilder().AddComponent(builtButton)); | |||||
else | |||||
{ | |||||
ActionRowBuilder actionRow; | |||||
if (_actionRows.Count > row) | |||||
actionRow = _actionRows.ElementAt(row); | |||||
else | |||||
{ | |||||
actionRow = new ActionRowBuilder(); | |||||
_actionRows.Add(actionRow); | |||||
} | |||||
if (actionRow.CanTakeComponent(builtButton)) | |||||
actionRow.AddComponent(builtButton); | |||||
else if (row < MaxActionRowCount) | |||||
WithTextInput(text, row + 1); | |||||
else | |||||
throw new InvalidOperationException($"There are no more rows to add {nameof(text)} to."); | |||||
} | |||||
} | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Get a <see cref="ModalComponent"/> representing the builder. | |||||
/// </summary> | |||||
/// <returns>A <see cref="ModalComponent"/> representing the builder.</returns> | |||||
public ModalComponent Build() | |||||
=> new (ActionRows?.Select(x => x.Build()).ToList()); | |||||
} | |||||
} |
@@ -0,0 +1,20 @@ | |||||
using System.Collections.Generic; | |||||
namespace Discord | |||||
{ | |||||
/// <summary> | |||||
/// Represents a component object used in <see cref="Modal"/>s. | |||||
/// </summary> | |||||
public class ModalComponent | |||||
{ | |||||
/// <summary> | |||||
/// Gets the components to be used in a modal. | |||||
/// </summary> | |||||
public IReadOnlyCollection<ActionRowComponent> Components { get; } | |||||
internal ModalComponent(List<ActionRowComponent> components) | |||||
{ | |||||
Components = components; | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,44 @@ | |||||
using System; | |||||
namespace Discord.Interactions | |||||
{ | |||||
/// <summary> | |||||
/// Create a Modal interaction handler. CustomId represents | |||||
/// the CustomId of the Modal that will be handled. | |||||
/// </summary> | |||||
/// <remarks> | |||||
/// <see cref="GroupAttribute"/>s will add prefixes to this command if <see cref="IgnoreGroupNames"/> is set to <see langword="false"/> | |||||
/// CustomID supports a Wild Card pattern where you can use the <see cref="InteractionServiceConfig.WildCardExpression"/> to match a set of CustomIDs. | |||||
/// </remarks> | |||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] | |||||
public sealed class ModalInteractionAttribute : Attribute | |||||
{ | |||||
/// <summary> | |||||
/// Gets the string to compare the Modal CustomIDs with. | |||||
/// </summary> | |||||
public string CustomId { get; } | |||||
/// <summary> | |||||
/// Gets <see langword="true"/> if <see cref="GroupAttribute"/>s will be ignored while creating this command and this method will be treated as a top level command. | |||||
/// </summary> | |||||
public bool IgnoreGroupNames { get; } | |||||
/// <summary> | |||||
/// Gets the run mode this command gets executed with. | |||||
/// </summary> | |||||
public RunMode RunMode { get; } | |||||
/// <summary> | |||||
/// Create a command for modal interaction handling. | |||||
/// </summary> | |||||
/// <param name="customId">String to compare the modal CustomIDs with.</param> | |||||
/// <param name="ignoreGroupNames">If <see langword="true"/> <see cref="GroupAttribute"/>s will be ignored while creating this command and this method will be treated as a top level command.</param> | |||||
/// <param name="runMode">Set the run mode of the command.</param> | |||||
public ModalInteractionAttribute(string customId, bool ignoreGroupNames = false, RunMode runMode = RunMode.Default) | |||||
{ | |||||
CustomId = customId; | |||||
IgnoreGroupNames = ignoreGroupNames; | |||||
RunMode = runMode; | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,25 @@ | |||||
using System; | |||||
namespace Discord.Interactions | |||||
{ | |||||
/// <summary> | |||||
/// Creates a custom label for an modal input. | |||||
/// </summary> | |||||
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] | |||||
public class InputLabelAttribute : Attribute | |||||
{ | |||||
/// <summary> | |||||
/// Gets the label of the input. | |||||
/// </summary> | |||||
public string Label { get; } | |||||
/// <summary> | |||||
/// Creates a custom label for an modal input. | |||||
/// </summary> | |||||
/// <param name="label">The label of the input.</param> | |||||
public InputLabelAttribute(string label) | |||||
{ | |||||
Label = label; | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,32 @@ | |||||
using System; | |||||
namespace Discord.Interactions | |||||
{ | |||||
/// <summary> | |||||
/// Mark an <see cref="IModal"/> property as a modal input field. | |||||
/// </summary> | |||||
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] | |||||
public abstract class ModalInputAttribute : Attribute | |||||
{ | |||||
/// <summary> | |||||
/// Gets the custom id of the text input. | |||||
/// </summary> | |||||
public string CustomId { get; } | |||||
/// <summary> | |||||
/// Gets the type of the component. | |||||
/// </summary> | |||||
public abstract ComponentType ComponentType { get; } | |||||
/// <summary> | |||||
/// Create a new <see cref="ModalInputAttribute"/>. | |||||
/// </summary> | |||||
/// <param name="label">The label of the input.</param> | |||||
/// <param name="customId">The custom id of the input.</param> | |||||
/// <param name="required">Whether the user is required to input a value.></param> | |||||
protected ModalInputAttribute(string customId) | |||||
{ | |||||
CustomId = customId; | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,55 @@ | |||||
namespace Discord.Interactions | |||||
{ | |||||
/// <summary> | |||||
/// Marks a <see cref="IModal"/> property as a text input. | |||||
/// </summary> | |||||
public sealed class ModalTextInputAttribute : ModalInputAttribute | |||||
{ | |||||
/// <inheritdoc/> | |||||
public override ComponentType ComponentType => ComponentType.TextInput; | |||||
/// <summary> | |||||
/// Gets the style of the text input. | |||||
/// </summary> | |||||
public TextInputStyle Style { get; } | |||||
/// <summary> | |||||
/// Gets the placeholder of the text input. | |||||
/// </summary> | |||||
public string Placeholder { get; } | |||||
/// <summary> | |||||
/// Gets the minimum length of the text input. | |||||
/// </summary> | |||||
public int MinLength { get; } | |||||
/// <summary> | |||||
/// Gets the maximum length of the text input. | |||||
/// </summary> | |||||
public int MaxLength { get; } | |||||
/// <summary> | |||||
/// Gets the initial value to be displayed by this input. | |||||
/// </summary> | |||||
public string InitialValue { get; } | |||||
/// <summary> | |||||
/// Create a new <see cref="ModalTextInputAttribute"/>. | |||||
/// </summary> | |||||
/// <param name="customId"The custom id of the text input.></param> | |||||
/// <param name="style">The style of the text input.</param> | |||||
/// <param name="placeholder">The placeholder of the text input.</param> | |||||
/// <param name="minLength">The minimum length of the text input's content.</param> | |||||
/// <param name="maxLength">The maximum length of the text input's content.</param> | |||||
/// <param name="initValue">The initial value to be displayed by this input.</param> | |||||
public ModalTextInputAttribute(string customId, TextInputStyle style = TextInputStyle.Short, string placeholder = null, int minLength = 1, int maxLength = 4000, string initValue = null) | |||||
: base(customId) | |||||
{ | |||||
Style = style; | |||||
Placeholder = placeholder; | |||||
MinLength = minLength; | |||||
MaxLength = maxLength; | |||||
InitialValue = initValue; | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,25 @@ | |||||
using System; | |||||
namespace Discord.Interactions | |||||
{ | |||||
/// <summary> | |||||
/// Sets the input as required or optional. | |||||
/// </summary> | |||||
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] | |||||
public class RequiredInputAttribute : Attribute | |||||
{ | |||||
/// <summary> | |||||
/// Gets whether or not user input is required for this input. | |||||
/// </summary> | |||||
public bool IsRequired { get; } | |||||
/// <summary> | |||||
/// Sets the input as required or optinal. | |||||
/// </summary> | |||||
/// <param name="isRequired">Whether or not user input is required for this input.</param> | |||||
public RequiredInputAttribute(bool isRequired = true) | |||||
{ | |||||
IsRequired = isRequired; | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,44 @@ | |||||
using System; | |||||
namespace Discord.Interactions.Builders | |||||
{ | |||||
/// <summary> | |||||
/// Represents a builder for creating a <see cref="ModalCommandInfo"/>. | |||||
/// </summary> | |||||
public class ModalCommandBuilder : CommandBuilder<ModalCommandInfo, ModalCommandBuilder, ModalCommandParameterBuilder> | |||||
{ | |||||
protected override ModalCommandBuilder Instance => this; | |||||
/// <summary> | |||||
/// Initializes a new <see cref="ModalCommandBuilder"/>. | |||||
/// </summary> | |||||
/// <param name="module">Parent module of this modal.</param> | |||||
public ModalCommandBuilder(ModuleBuilder module) : base(module) { } | |||||
/// <summary> | |||||
/// Initializes a new <see cref="ModalCommandBuilder"/>. | |||||
/// </summary> | |||||
/// <param name="module">Parent module of this modal.</param> | |||||
/// <param name="name">Name of this modal.</param> | |||||
/// <param name="callback">Execution callback of this modal.</param> | |||||
public ModalCommandBuilder(ModuleBuilder module, string name, ExecuteCallback callback) : base(module, name, callback) { } | |||||
/// <summary> | |||||
/// Adds a modal parameter to the parameters collection. | |||||
/// </summary> | |||||
/// <param name="configure"><see cref="ModalCommandParameterBuilder"/> factory.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public override ModalCommandBuilder AddParameter(Action<ModalCommandParameterBuilder> configure) | |||||
{ | |||||
var parameter = new ModalCommandParameterBuilder(this); | |||||
configure(parameter); | |||||
AddParameters(parameter); | |||||
return this; | |||||
} | |||||
internal override ModalCommandInfo Build(ModuleInfo module, InteractionService commandService) => | |||||
new(this, module, commandService); | |||||
} | |||||
} |
@@ -0,0 +1,105 @@ | |||||
using System; | |||||
using System.Collections.Generic; | |||||
namespace Discord.Interactions.Builders | |||||
{ | |||||
/// <summary> | |||||
/// Represent a builder for creating <see cref="InputComponentInfo"/>. | |||||
/// </summary> | |||||
public interface IInputComponentBuilder | |||||
{ | |||||
/// <summary> | |||||
/// Gets the parent modal of this input component. | |||||
/// </summary> | |||||
ModalBuilder Modal { get; } | |||||
/// <summary> | |||||
/// Gets the custom id of this input component. | |||||
/// </summary> | |||||
string CustomId { get; } | |||||
/// <summary> | |||||
/// Gets the label of this input component. | |||||
/// </summary> | |||||
string Label { get; } | |||||
/// <summary> | |||||
/// Gets whether this input component is required. | |||||
/// </summary> | |||||
bool IsRequired { get; } | |||||
/// <summary> | |||||
/// Gets the component type of this input component. | |||||
/// </summary> | |||||
ComponentType ComponentType { get; } | |||||
/// <summary> | |||||
/// Get the reference type of this input component. | |||||
/// </summary> | |||||
Type Type { get; } | |||||
/// <summary> | |||||
/// Gets the default value of this input component. | |||||
/// </summary> | |||||
object DefaultValue { get; } | |||||
/// <summary> | |||||
/// Gets a collection of the attributes of this component. | |||||
/// </summary> | |||||
IReadOnlyCollection<Attribute> Attributes { get; } | |||||
/// <summary> | |||||
/// Sets <see cref="CustomId"/>. | |||||
/// </summary> | |||||
/// <param name="customId">New value of the <see cref="CustomId"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
IInputComponentBuilder WithCustomId(string customId); | |||||
/// <summary> | |||||
/// Sets <see cref="Label"/>. | |||||
/// </summary> | |||||
/// <param name="label">New value of the <see cref="Label"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
IInputComponentBuilder WithLabel(string label); | |||||
/// <summary> | |||||
/// Sets <see cref="IsRequired"/>. | |||||
/// </summary> | |||||
/// <param name="isRequired">New value of the <see cref="IsRequired"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
IInputComponentBuilder SetIsRequired(bool isRequired); | |||||
/// <summary> | |||||
/// Sets <see cref="Type"/>. | |||||
/// </summary> | |||||
/// <param name="type">New value of the <see cref="Type"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
IInputComponentBuilder WithType(Type type); | |||||
/// <summary> | |||||
/// Sets <see cref="DefaultValue"/>. | |||||
/// </summary> | |||||
/// <param name="value">New value of the <see cref="DefaultValue"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
IInputComponentBuilder SetDefaultValue(object value); | |||||
/// <summary> | |||||
/// Adds attributes to <see cref="Attributes"/>. | |||||
/// </summary> | |||||
/// <param name="attributes">New attributes to be added to <see cref="Attributes"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
IInputComponentBuilder WithAttributes(params Attribute[] attributes); | |||||
} | |||||
} |
@@ -0,0 +1,164 @@ | |||||
using System; | |||||
using System.Collections.Generic; | |||||
namespace Discord.Interactions.Builders | |||||
{ | |||||
/// <summary> | |||||
/// Represents the base builder class for creating <see cref="InputComponentInfo"/>. | |||||
/// </summary> | |||||
/// <typeparam name="TInfo">The <see cref="InputComponentInfo"/> this builder yields when built.</typeparam> | |||||
/// <typeparam name="TBuilder">Inherited <see cref="InputComponentBuilder{TInfo, TBuilder}"/> type.</typeparam> | |||||
public abstract class InputComponentBuilder<TInfo, TBuilder> : IInputComponentBuilder | |||||
where TInfo : InputComponentInfo | |||||
where TBuilder : InputComponentBuilder<TInfo, TBuilder> | |||||
{ | |||||
private readonly List<Attribute> _attributes; | |||||
protected abstract TBuilder Instance { get; } | |||||
/// <inheritdoc/> | |||||
public ModalBuilder Modal { get; } | |||||
/// <inheritdoc/> | |||||
public string CustomId { get; set; } | |||||
/// <inheritdoc/> | |||||
public string Label { get; set; } | |||||
/// <inheritdoc/> | |||||
public bool IsRequired { get; set; } = true; | |||||
/// <inheritdoc/> | |||||
public ComponentType ComponentType { get; internal set; } | |||||
/// <inheritdoc/> | |||||
public Type Type { get; private set; } | |||||
/// <inheritdoc/> | |||||
public object DefaultValue { get; set; } | |||||
/// <inheritdoc/> | |||||
public IReadOnlyCollection<Attribute> Attributes => _attributes; | |||||
/// <summary> | |||||
/// Creates an instance of <see cref="InputComponentBuilder{TInfo, TBuilder}"/> | |||||
/// </summary> | |||||
/// <param name="modal">Parent modal of this input component.</param> | |||||
public InputComponentBuilder(ModalBuilder modal) | |||||
{ | |||||
Modal = modal; | |||||
_attributes = new(); | |||||
} | |||||
/// <summary> | |||||
/// Sets <see cref="CustomId"/>. | |||||
/// </summary> | |||||
/// <param name="customId">New value of the <see cref="CustomId"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public TBuilder WithCustomId(string customId) | |||||
{ | |||||
CustomId = customId; | |||||
return Instance; | |||||
} | |||||
/// <summary> | |||||
/// Sets <see cref="Label"/>. | |||||
/// </summary> | |||||
/// <param name="label">New value of the <see cref="Label"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public TBuilder WithLabel(string label) | |||||
{ | |||||
Label = label; | |||||
return Instance; | |||||
} | |||||
/// <summary> | |||||
/// Sets <see cref="IsRequired"/>. | |||||
/// </summary> | |||||
/// <param name="isRequired">New value of the <see cref="IsRequired"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public TBuilder SetIsRequired(bool isRequired) | |||||
{ | |||||
IsRequired = isRequired; | |||||
return Instance; | |||||
} | |||||
/// <summary> | |||||
/// Sets <see cref="ComponentType"/>. | |||||
/// </summary> | |||||
/// <param name="componentType">New value of the <see cref="ComponentType"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public TBuilder WithComponentType(ComponentType componentType) | |||||
{ | |||||
ComponentType = componentType; | |||||
return Instance; | |||||
} | |||||
/// <summary> | |||||
/// Sets <see cref="Type"/>. | |||||
/// </summary> | |||||
/// <param name="type">New value of the <see cref="Type"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public TBuilder WithType(Type type) | |||||
{ | |||||
Type = type; | |||||
return Instance; | |||||
} | |||||
/// <summary> | |||||
/// Sets <see cref="DefaultValue"/>. | |||||
/// </summary> | |||||
/// <param name="value">New value of the <see cref="DefaultValue"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public TBuilder SetDefaultValue(object value) | |||||
{ | |||||
DefaultValue = value; | |||||
return Instance; | |||||
} | |||||
/// <summary> | |||||
/// Adds attributes to <see cref="Attributes"/>. | |||||
/// </summary> | |||||
/// <param name="attributes">New attributes to be added to <see cref="Attributes"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public TBuilder WithAttributes(params Attribute[] attributes) | |||||
{ | |||||
_attributes.AddRange(attributes); | |||||
return Instance; | |||||
} | |||||
internal abstract TInfo Build(ModalInfo modal); | |||||
//IInputComponentBuilder | |||||
/// <inheritdoc/> | |||||
IInputComponentBuilder IInputComponentBuilder.WithCustomId(string customId) => WithCustomId(customId); | |||||
/// <inheritdoc/> | |||||
IInputComponentBuilder IInputComponentBuilder.WithLabel(string label) => WithCustomId(label); | |||||
/// <inheritdoc/> | |||||
IInputComponentBuilder IInputComponentBuilder.WithType(Type type) => WithType(type); | |||||
/// <inheritdoc/> | |||||
IInputComponentBuilder IInputComponentBuilder.SetDefaultValue(object value) => SetDefaultValue(value); | |||||
/// <inheritdoc/> | |||||
IInputComponentBuilder IInputComponentBuilder.WithAttributes(params Attribute[] attributes) => WithAttributes(attributes); | |||||
/// <inheritdoc/> | |||||
IInputComponentBuilder IInputComponentBuilder.SetIsRequired(bool isRequired) => SetIsRequired(isRequired); | |||||
} | |||||
} |
@@ -0,0 +1,109 @@ | |||||
namespace Discord.Interactions.Builders | |||||
{ | |||||
/// <summary> | |||||
/// Represents a builder for creating <see cref="TextInputComponentInfo"/>. | |||||
/// </summary> | |||||
public class TextInputComponentBuilder : InputComponentBuilder<TextInputComponentInfo, TextInputComponentBuilder> | |||||
{ | |||||
protected override TextInputComponentBuilder Instance => this; | |||||
/// <summary> | |||||
/// Gets and sets the style of the text input. | |||||
/// </summary> | |||||
public TextInputStyle Style { get; set; } | |||||
/// <summary> | |||||
/// Gets and sets the placeholder of the text input. | |||||
/// </summary> | |||||
public string Placeholder { get; set; } | |||||
/// <summary> | |||||
/// Gets and sets the minimum length of the text input. | |||||
/// </summary> | |||||
public int MinLength { get; set; } | |||||
/// <summary> | |||||
/// Gets and sets the maximum length of the text input. | |||||
/// </summary> | |||||
public int MaxLength { get; set; } | |||||
/// <summary> | |||||
/// Gets and sets the initial value to be displayed by this input. | |||||
/// </summary> | |||||
public string InitialValue { get; set; } | |||||
/// <summary> | |||||
/// Initializes a new <see cref="TextInputComponentBuilder"/>. | |||||
/// </summary> | |||||
/// <param name="modal">Parent modal of this component.</param> | |||||
public TextInputComponentBuilder(ModalBuilder modal) : base(modal) { } | |||||
/// <summary> | |||||
/// Sets <see cref="Style"/>. | |||||
/// </summary> | |||||
/// <param name="style">New value of the <see cref="SetValue(string)"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public TextInputComponentBuilder WithStyle(TextInputStyle style) | |||||
{ | |||||
Style = style; | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Sets <see cref="Placeholder"/>. | |||||
/// </summary> | |||||
/// <param name="placeholder">New value of the <see cref="Placeholder"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public TextInputComponentBuilder WithPlaceholder(string placeholder) | |||||
{ | |||||
Placeholder = placeholder; | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Sets <see cref="MinLength"/>. | |||||
/// </summary> | |||||
/// <param name="minLenght">New value of the <see cref="MinLength"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public TextInputComponentBuilder WithMinLenght(int minLenght) | |||||
{ | |||||
MinLength = minLenght; | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Sets <see cref="MaxLength"/>. | |||||
/// </summary> | |||||
/// <param name="maxLenght">New value of the <see cref="MaxLength"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public TextInputComponentBuilder WithMaxLenght(int maxLenght) | |||||
{ | |||||
MaxLength = maxLenght; | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Sets <see cref="InitialValue"/>. | |||||
/// </summary> | |||||
/// <param name="value">New value of the <see cref="InitialValue"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public TextInputComponentBuilder WithInitialValue(string value) | |||||
{ | |||||
InitialValue = value; | |||||
return this; | |||||
} | |||||
internal override TextInputComponentInfo Build(ModalInfo modal) => | |||||
new(this, modal); | |||||
} | |||||
} |
@@ -0,0 +1,81 @@ | |||||
using System; | |||||
using System.Collections.Generic; | |||||
using System.Linq; | |||||
namespace Discord.Interactions.Builders | |||||
{ | |||||
/// <summary> | |||||
/// Represents a builder for creating <see cref="ModalInfo"/>. | |||||
/// </summary> | |||||
public class ModalBuilder | |||||
{ | |||||
internal readonly List<IInputComponentBuilder> _components; | |||||
/// <summary> | |||||
/// Gets the initialization delegate for this modal. | |||||
/// </summary> | |||||
public ModalInitializer ModalInitializer { get; internal set; } | |||||
/// <summary> | |||||
/// Gets the title of this modal. | |||||
/// </summary> | |||||
public string Title { get; set; } | |||||
/// <summary> | |||||
/// Gets the <see cref="IModal"/> implementation used to initialize this object. | |||||
/// </summary> | |||||
public Type Type { get; } | |||||
/// <summary> | |||||
/// Gets a collection of the components of this modal. | |||||
/// </summary> | |||||
public IReadOnlyCollection<IInputComponentBuilder> Components => _components; | |||||
internal ModalBuilder(Type type) | |||||
{ | |||||
if (!typeof(IModal).IsAssignableFrom(type)) | |||||
throw new ArgumentException($"Must be an implementation of {nameof(IModal)}", nameof(type)); | |||||
_components = new(); | |||||
} | |||||
/// <summary> | |||||
/// Initializes a new <see cref="ModalBuilder"/> | |||||
/// </summary> | |||||
/// <param name="modalInitializer">The initialization delegate for this modal.</param> | |||||
public ModalBuilder(Type type, ModalInitializer modalInitializer) : this(type) | |||||
{ | |||||
ModalInitializer = modalInitializer; | |||||
} | |||||
/// <summary> | |||||
/// Sets <see cref="Title"/>. | |||||
/// </summary> | |||||
/// <param name="title">New value of the <see cref="Title"/>.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public ModalBuilder WithTitle(string title) | |||||
{ | |||||
Title = title; | |||||
return this; | |||||
} | |||||
/// <summary> | |||||
/// Adds text components to <see cref="TextComponents"/>. | |||||
/// </summary> | |||||
/// <param name="configure">Text Component builder factory.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public ModalBuilder AddTextComponent(Action<TextInputComponentBuilder> configure) | |||||
{ | |||||
var builder = new TextInputComponentBuilder(this); | |||||
configure(builder); | |||||
_components.Add(builder); | |||||
return this; | |||||
} | |||||
internal ModalInfo Build() => new(this); | |||||
} | |||||
} |
@@ -16,6 +16,7 @@ namespace Discord.Interactions.Builders | |||||
private readonly List<ContextCommandBuilder> _contextCommands; | private readonly List<ContextCommandBuilder> _contextCommands; | ||||
private readonly List<ComponentCommandBuilder> _componentCommands; | private readonly List<ComponentCommandBuilder> _componentCommands; | ||||
private readonly List<AutocompleteCommandBuilder> _autocompleteCommands; | private readonly List<AutocompleteCommandBuilder> _autocompleteCommands; | ||||
private readonly List<ModalCommandBuilder> _modalCommands; | |||||
/// <summary> | /// <summary> | ||||
/// Gets the underlying Interaction Service. | /// Gets the underlying Interaction Service. | ||||
@@ -92,6 +93,11 @@ namespace Discord.Interactions.Builders | |||||
/// </summary> | /// </summary> | ||||
public IReadOnlyList<AutocompleteCommandBuilder> AutocompleteCommands => _autocompleteCommands; | public IReadOnlyList<AutocompleteCommandBuilder> AutocompleteCommands => _autocompleteCommands; | ||||
/// <summary> | |||||
/// Gets a collection of the Modal Commands of this module. | |||||
/// </summary> | |||||
public IReadOnlyList<ModalCommandBuilder> ModalCommands => _modalCommands; | |||||
internal TypeInfo TypeInfo { get; set; } | internal TypeInfo TypeInfo { get; set; } | ||||
internal ModuleBuilder (InteractionService interactionService, ModuleBuilder parent = null) | internal ModuleBuilder (InteractionService interactionService, ModuleBuilder parent = null) | ||||
@@ -105,6 +111,7 @@ namespace Discord.Interactions.Builders | |||||
_contextCommands = new List<ContextCommandBuilder>(); | _contextCommands = new List<ContextCommandBuilder>(); | ||||
_componentCommands = new List<ComponentCommandBuilder>(); | _componentCommands = new List<ComponentCommandBuilder>(); | ||||
_autocompleteCommands = new List<AutocompleteCommandBuilder>(); | _autocompleteCommands = new List<AutocompleteCommandBuilder>(); | ||||
_modalCommands = new List<ModalCommandBuilder> (); | |||||
_preconditions = new List<PreconditionAttribute>(); | _preconditions = new List<PreconditionAttribute>(); | ||||
} | } | ||||
@@ -152,7 +159,7 @@ namespace Discord.Interactions.Builders | |||||
/// <returns> | /// <returns> | ||||
/// The builder instance. | /// The builder instance. | ||||
/// </returns> | /// </returns> | ||||
public ModuleBuilder WithDefaultPermision (bool permission) | |||||
public ModuleBuilder WithDefaultPermission (bool permission) | |||||
{ | { | ||||
DefaultPermission = permission; | DefaultPermission = permission; | ||||
return this; | return this; | ||||
@@ -310,6 +317,21 @@ namespace Discord.Interactions.Builders | |||||
configure(command); | configure(command); | ||||
_autocompleteCommands.Add(command); | _autocompleteCommands.Add(command); | ||||
return this; | return this; | ||||
} | |||||
/// Adds a modal command builder to <see cref="ModalCommands"/>. | |||||
/// </summary> | |||||
/// <param name="configure"><see cref="ModalCommands"/> factory.</param> | |||||
/// <returns> | |||||
/// The builder instance. | |||||
/// </returns> | |||||
public ModuleBuilder AddModalCommand(Action<ModalCommandBuilder> configure) | |||||
{ | |||||
var command = new ModalCommandBuilder(this); | |||||
configure(command); | |||||
_modalCommands.Add(command); | |||||
return this; | |||||
} | } | ||||
/// <summary> | /// <summary> | ||||
@@ -103,6 +103,7 @@ namespace Discord.Interactions.Builders | |||||
var validContextCommands = methods.Where(IsValidContextCommandDefinition); | var validContextCommands = methods.Where(IsValidContextCommandDefinition); | ||||
var validInteractions = methods.Where(IsValidComponentCommandDefinition); | var validInteractions = methods.Where(IsValidComponentCommandDefinition); | ||||
var validAutocompleteCommands = methods.Where(IsValidAutocompleteCommandDefinition); | var validAutocompleteCommands = methods.Where(IsValidAutocompleteCommandDefinition); | ||||
var validModalCommands = methods.Where(IsValidModalCommanDefinition); | |||||
Func<IServiceProvider, IInteractionModuleBase> createInstance = commandService._useCompiledLambda ? | Func<IServiceProvider, IInteractionModuleBase> createInstance = commandService._useCompiledLambda ? | ||||
ReflectionUtils<IInteractionModuleBase>.CreateLambdaBuilder(typeInfo, commandService) : ReflectionUtils<IInteractionModuleBase>.CreateBuilder(typeInfo, commandService); | ReflectionUtils<IInteractionModuleBase>.CreateLambdaBuilder(typeInfo, commandService) : ReflectionUtils<IInteractionModuleBase>.CreateBuilder(typeInfo, commandService); | ||||
@@ -118,6 +119,9 @@ namespace Discord.Interactions.Builders | |||||
foreach(var method in validAutocompleteCommands) | foreach(var method in validAutocompleteCommands) | ||||
builder.AddAutocompleteCommand(x => BuildAutocompleteCommand(x, createInstance, method, commandService, services)); | builder.AddAutocompleteCommand(x => BuildAutocompleteCommand(x, createInstance, method, commandService, services)); | ||||
foreach(var method in validModalCommands) | |||||
builder.AddModalCommand(x => BuildModalCommand(x, createInstance, method, commandService, services)); | |||||
} | } | ||||
private static void BuildSubModules (ModuleBuilder parent, IEnumerable<TypeInfo> subModules, IList<TypeInfo> builtTypes, InteractionService commandService, | private static void BuildSubModules (ModuleBuilder parent, IEnumerable<TypeInfo> subModules, IList<TypeInfo> builtTypes, InteractionService commandService, | ||||
@@ -298,6 +302,47 @@ namespace Discord.Interactions.Builders | |||||
builder.Callback = CreateCallback(createInstance, methodInfo, commandService); | builder.Callback = CreateCallback(createInstance, methodInfo, commandService); | ||||
} | } | ||||
private static void BuildModalCommand(ModalCommandBuilder builder, Func<IServiceProvider, IInteractionModuleBase> createInstance, MethodInfo methodInfo, | |||||
InteractionService commandService, IServiceProvider services) | |||||
{ | |||||
var parameters = methodInfo.GetParameters(); | |||||
if (parameters.Count(x => typeof(IModal).IsAssignableFrom(x.ParameterType)) > 1) | |||||
throw new InvalidOperationException($"A modal command can only have one {nameof(IModal)} parameter."); | |||||
if (!parameters.All(x => x.ParameterType == typeof(string) || typeof(IModal).IsAssignableFrom(x.ParameterType))) | |||||
throw new InvalidOperationException($"All parameters of a modal command must be either a string or an implementation of {nameof(IModal)}"); | |||||
var attributes = methodInfo.GetCustomAttributes(); | |||||
builder.MethodName = methodInfo.Name; | |||||
foreach (var attribute in attributes) | |||||
{ | |||||
switch (attribute) | |||||
{ | |||||
case ModalInteractionAttribute modal: | |||||
{ | |||||
builder.Name = modal.CustomId; | |||||
builder.RunMode = modal.RunMode; | |||||
builder.IgnoreGroupNames = modal.IgnoreGroupNames; | |||||
} | |||||
break; | |||||
case PreconditionAttribute precondition: | |||||
builder.WithPreconditions(precondition); | |||||
break; | |||||
default: | |||||
builder.WithAttributes(attribute); | |||||
break; | |||||
} | |||||
} | |||||
foreach (var parameter in parameters) | |||||
builder.AddParameter(x => BuildParameter(x, parameter)); | |||||
builder.Callback = CreateCallback(createInstance, methodInfo, commandService); | |||||
} | |||||
private static ExecuteCallback CreateCallback (Func<IServiceProvider, IInteractionModuleBase> createInstance, | private static ExecuteCallback CreateCallback (Func<IServiceProvider, IInteractionModuleBase> createInstance, | ||||
MethodInfo methodInfo, InteractionService commandService) | MethodInfo methodInfo, InteractionService commandService) | ||||
{ | { | ||||
@@ -400,7 +445,9 @@ namespace Discord.Interactions.Builders | |||||
builder.Name = Regex.Replace(builder.Name, "(?<=[a-z])(?=[A-Z])", "-").ToLower(); | builder.Name = Regex.Replace(builder.Name, "(?<=[a-z])(?=[A-Z])", "-").ToLower(); | ||||
} | } | ||||
private static void BuildParameter (CommandParameterBuilder builder, ParameterInfo paramInfo) | |||||
private static void BuildParameter<TInfo, TBuilder> (ParameterBuilder<TInfo, TBuilder> builder, ParameterInfo paramInfo) | |||||
where TInfo : class, IParameterInfo | |||||
where TBuilder : ParameterBuilder<TInfo, TBuilder> | |||||
{ | { | ||||
var attributes = paramInfo.GetCustomAttributes(); | var attributes = paramInfo.GetCustomAttributes(); | ||||
var paramType = paramInfo.ParameterType; | var paramType = paramInfo.ParameterType; | ||||
@@ -428,6 +475,84 @@ namespace Discord.Interactions.Builders | |||||
} | } | ||||
#endregion | #endregion | ||||
#region Modals | |||||
public static ModalInfo BuildModalInfo(Type modalType) | |||||
{ | |||||
if (!typeof(IModal).IsAssignableFrom(modalType)) | |||||
throw new InvalidOperationException($"{modalType.FullName} isn't an implementation of {typeof(IModal).FullName}"); | |||||
var instance = Activator.CreateInstance(modalType, false) as IModal; | |||||
try | |||||
{ | |||||
var builder = new ModalBuilder(modalType) | |||||
{ | |||||
Title = instance.Title | |||||
}; | |||||
var inputs = modalType.GetProperties().Where(IsValidModalInputDefinition); | |||||
foreach (var prop in inputs) | |||||
{ | |||||
var componentType = prop.GetCustomAttribute<ModalInputAttribute>()?.ComponentType; | |||||
switch (componentType) | |||||
{ | |||||
case ComponentType.TextInput: | |||||
builder.AddTextComponent(x => BuildTextInput(x, prop, prop.GetValue(instance))); | |||||
break; | |||||
case null: | |||||
throw new InvalidOperationException($"{prop.Name} of {prop.DeclaringType.Name} isn't a valid modal input field."); | |||||
default: | |||||
throw new InvalidOperationException($"Component type {componentType} cannot be used in modals."); | |||||
} | |||||
} | |||||
var memberInit = ReflectionUtils<IModal>.CreateLambdaMemberInit(modalType.GetTypeInfo(), modalType.GetConstructor(Type.EmptyTypes), x => x.IsDefined(typeof(ModalInputAttribute))); | |||||
builder.ModalInitializer = (args) => memberInit(Array.Empty<object>(), args); | |||||
return builder.Build(); | |||||
} | |||||
finally | |||||
{ | |||||
(instance as IDisposable)?.Dispose(); | |||||
} | |||||
} | |||||
private static void BuildTextInput(TextInputComponentBuilder builder, PropertyInfo propertyInfo, object defaultValue) | |||||
{ | |||||
var attributes = propertyInfo.GetCustomAttributes(); | |||||
builder.Label = propertyInfo.Name; | |||||
builder.DefaultValue = defaultValue; | |||||
builder.WithType(propertyInfo.PropertyType); | |||||
foreach(var attribute in attributes) | |||||
{ | |||||
switch (attribute) | |||||
{ | |||||
case ModalTextInputAttribute textInput: | |||||
builder.CustomId = textInput.CustomId; | |||||
builder.ComponentType = textInput.ComponentType; | |||||
builder.Style = textInput.Style; | |||||
builder.Placeholder = textInput.Placeholder; | |||||
builder.MaxLength = textInput.MaxLength; | |||||
builder.MinLength = textInput.MinLength; | |||||
builder.InitialValue = textInput.InitialValue; | |||||
break; | |||||
case RequiredInputAttribute requiredInput: | |||||
builder.IsRequired = requiredInput.IsRequired; | |||||
break; | |||||
case InputLabelAttribute inputLabel: | |||||
builder.Label = inputLabel.Label; | |||||
break; | |||||
default: | |||||
builder.WithAttributes(attribute); | |||||
break; | |||||
} | |||||
} | |||||
} | |||||
#endregion | |||||
internal static bool IsValidModuleDefinition (TypeInfo typeInfo) | internal static bool IsValidModuleDefinition (TypeInfo typeInfo) | ||||
{ | { | ||||
return ModuleTypeInfo.IsAssignableFrom(typeInfo) && | return ModuleTypeInfo.IsAssignableFrom(typeInfo) && | ||||
@@ -467,5 +592,21 @@ namespace Discord.Interactions.Builders | |||||
!methodInfo.IsGenericMethod && | !methodInfo.IsGenericMethod && | ||||
methodInfo.GetParameters().Length == 0; | methodInfo.GetParameters().Length == 0; | ||||
} | } | ||||
private static bool IsValidModalCommanDefinition(MethodInfo methodInfo) | |||||
{ | |||||
return methodInfo.IsDefined(typeof(ModalInteractionAttribute)) && | |||||
(methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task<RuntimeResult>)) && | |||||
!methodInfo.IsStatic && | |||||
!methodInfo.IsGenericMethod && | |||||
typeof(IModal).IsAssignableFrom(methodInfo.GetParameters().Last().ParameterType); | |||||
} | |||||
private static bool IsValidModalInputDefinition(PropertyInfo propertyInfo) | |||||
{ | |||||
return propertyInfo.SetMethod?.IsPublic == true && | |||||
propertyInfo.SetMethod?.IsStatic == false && | |||||
propertyInfo.IsDefined(typeof(ModalInputAttribute)); | |||||
} | |||||
} | } | ||||
} | } |
@@ -0,0 +1,45 @@ | |||||
using System; | |||||
namespace Discord.Interactions.Builders | |||||
{ | |||||
/// <summary> | |||||
/// Represents a builder for creating <see cref="ModalCommandBuilder"/>. | |||||
/// </summary> | |||||
public class ModalCommandParameterBuilder : ParameterBuilder<ModalCommandParameterInfo, ModalCommandParameterBuilder> | |||||
{ | |||||
protected override ModalCommandParameterBuilder Instance => this; | |||||
/// <summary> | |||||
/// Gets the built <see cref="ModalInfo"/> class for this parameter, if <see cref="IsModalParameter"/> is <see langword="true"/>. | |||||
/// </summary> | |||||
public ModalInfo Modal { get; private set; } | |||||
/// <summary> | |||||
/// Gets whether or not this parameter is an <see cref="IModal"/>. | |||||
/// </summary> | |||||
public bool IsModalParameter => Modal is not null; | |||||
internal ModalCommandParameterBuilder(ICommandBuilder command) : base(command) { } | |||||
/// <summary> | |||||
/// Initializes a new <see cref="ModalCommandParameterBuilder"/>. | |||||
/// </summary> | |||||
/// <param name="command">Parent command of this parameter.</param> | |||||
/// <param name="name">Name of this command.</param> | |||||
/// <param name="type">Type of this parameter.</param> | |||||
public ModalCommandParameterBuilder(ICommandBuilder command, string name, Type type) : base(command, name, type) { } | |||||
/// <inheritdoc/> | |||||
public override ModalCommandParameterBuilder SetParameterType(Type type) | |||||
{ | |||||
if (typeof(IModal).IsAssignableFrom(type)) | |||||
Modal = ModalUtils.GetOrAdd(type); | |||||
return base.SetParameterType(type); | |||||
} | |||||
internal override ModalCommandParameterInfo Build(ICommandInfo command) => | |||||
new(this, command); | |||||
} | |||||
} |
@@ -0,0 +1,13 @@ | |||||
namespace Discord.Interactions | |||||
{ | |||||
/// <summary> | |||||
/// Represents a generic <see cref="Modal"/> for use with the interaction service. | |||||
/// </summary> | |||||
public interface IModal | |||||
{ | |||||
/// <summary> | |||||
/// Gets the modal's title. | |||||
/// </summary> | |||||
string Title { get; } | |||||
} | |||||
} |
@@ -0,0 +1,37 @@ | |||||
using System; | |||||
using System.Threading.Tasks; | |||||
namespace Discord.Interactions | |||||
{ | |||||
public static class IDiscordInteractionExtentions | |||||
{ | |||||
/// <summary> | |||||
/// Respond to an interaction with a <see cref="IModal"/>. | |||||
/// </summary> | |||||
/// <typeparam name="T">Type of the <see cref="IModal"/> implementation.</typeparam> | |||||
/// <param name="interaction">The interaction to respond to.</param> | |||||
/// <param name="options">The request options for this <see langword="async"/> request.</param> | |||||
/// <returns>A task that represents the asynchronous operation of responding to the interaction.</returns> | |||||
public static async Task RespondWithModalAsync<T>(this IDiscordInteraction interaction, string customId, RequestOptions options = null) | |||||
where T : class, IModal | |||||
{ | |||||
if (!ModalUtils.TryGet<T>(out var modalInfo)) | |||||
throw new ArgumentException($"{typeof(T).FullName} isn't referenced by any registered Modal Interaction Command and doesn't have a cached {typeof(ModalInfo)}"); | |||||
var builder = new ModalBuilder(modalInfo.Title, customId); | |||||
foreach(var input in modalInfo.Components) | |||||
switch (input) | |||||
{ | |||||
case TextInputComponentInfo textComponent: | |||||
builder.AddTextInput(textComponent.Label, textComponent.CustomId, textComponent.Style, textComponent.Placeholder, textComponent.IsRequired ? textComponent.MinLength : null, | |||||
textComponent.MaxLength, textComponent.IsRequired, textComponent.InitialValue); | |||||
break; | |||||
default: | |||||
throw new InvalidOperationException($"{input.GetType().FullName} isn't a valid component info class"); | |||||
} | |||||
await interaction.RespondWithModalAsync(builder.Build(), options).ConfigureAwait(false); | |||||
} | |||||
} | |||||
} |
@@ -35,7 +35,7 @@ namespace Discord.Interactions | |||||
/// <param name="services">Services that will be used while initializing the <see cref="InteractionModuleBase{T}"/>.</param> | /// <param name="services">Services that will be used while initializing the <see cref="InteractionModuleBase{T}"/>.</param> | ||||
/// <param name="additionalArgs">Provide additional string parameters to the method along with the auto generated parameters.</param> | /// <param name="additionalArgs">Provide additional string parameters to the method along with the auto generated parameters.</param> | ||||
/// <returns> | /// <returns> | ||||
/// A task representing the asyncronous command execution process. | |||||
/// A task representing the asynchronous command execution process. | |||||
/// </returns> | /// </returns> | ||||
public async Task<IResult> ExecuteAsync(IInteractionContext context, IServiceProvider services, params string[] additionalArgs) | public async Task<IResult> ExecuteAsync(IInteractionContext context, IServiceProvider services, params string[] additionalArgs) | ||||
{ | { | ||||
@@ -0,0 +1,81 @@ | |||||
using System; | |||||
using System.Collections.Generic; | |||||
using System.Collections.Immutable; | |||||
using System.Linq; | |||||
using System.Threading.Tasks; | |||||
namespace Discord.Interactions | |||||
{ | |||||
/// <summary> | |||||
/// Represents the info class of an attribute based method for handling Modal Interaction events. | |||||
/// </summary> | |||||
public class ModalCommandInfo : CommandInfo<ModalCommandParameterInfo> | |||||
{ | |||||
/// <summary> | |||||
/// Gets the <see cref="ModalInfo"/> class for this commands <see cref="IModal"/> parameter. | |||||
/// </summary> | |||||
public ModalInfo Modal { get; } | |||||
/// <inheritdoc/> | |||||
public override bool SupportsWildCards => true; | |||||
/// <inheritdoc/> | |||||
public override IReadOnlyCollection<ModalCommandParameterInfo> Parameters { get; } | |||||
internal ModalCommandInfo(Builders.ModalCommandBuilder builder, ModuleInfo module, InteractionService commandService) : base(builder, module, commandService) | |||||
{ | |||||
Parameters = builder.Parameters.Select(x => x.Build(this)).ToImmutableArray(); | |||||
Modal = Parameters.Last().Modal; | |||||
} | |||||
/// <inheritdoc/> | |||||
public override async Task<IResult> ExecuteAsync(IInteractionContext context, IServiceProvider services) | |||||
=> await ExecuteAsync(context, services, null).ConfigureAwait(false); | |||||
/// <summary> | |||||
/// Execute this command using dependency injection. | |||||
/// </summary> | |||||
/// <param name="context">Context that will be injected to the <see cref="InteractionModuleBase{T}"/>.</param> | |||||
/// <param name="services">Services that will be used while initializing the <see cref="InteractionModuleBase{T}"/>.</param> | |||||
/// <param name="additionalArgs">Provide additional string parameters to the method along with the auto generated parameters.</param> | |||||
/// <returns> | |||||
/// A task representing the asynchronous command execution process. | |||||
/// </returns> | |||||
public async Task<IResult> ExecuteAsync(IInteractionContext context, IServiceProvider services, params string[] additionalArgs) | |||||
{ | |||||
if (context.Interaction is not IModalInteraction modalInteraction) | |||||
return ExecuteResult.FromError(InteractionCommandError.ParseFailed, $"Provided {nameof(IInteractionContext)} doesn't belong to a Modal Interaction."); | |||||
try | |||||
{ | |||||
var args = new List<object>(); | |||||
if (additionalArgs is not null) | |||||
args.AddRange(additionalArgs); | |||||
var modal = Modal.CreateModal(modalInteraction, Module.CommandService._exitOnMissingModalField); | |||||
args.Add(modal); | |||||
return await RunAsync(context, args.ToArray(), services); | |||||
} | |||||
catch (Exception ex) | |||||
{ | |||||
var result = ExecuteResult.FromError(ex); | |||||
await InvokeModuleEvent(context, result).ConfigureAwait(false); | |||||
return result; | |||||
} | |||||
} | |||||
/// <inheritdoc/> | |||||
protected override Task InvokeModuleEvent(IInteractionContext context, IResult result) | |||||
=> CommandService._modalCommandExecutedEvent.InvokeAsync(this, context, result); | |||||
/// <inheritdoc/> | |||||
protected override string GetLogString(IInteractionContext context) | |||||
{ | |||||
if (context.Guild != null) | |||||
return $"Modal Command: \"{base.ToString()}\" for {context.User} in {context.Guild}/{context.Channel}"; | |||||
else | |||||
return $"Modal Command: \"{base.ToString()}\" for {context.User} in {context.Channel}"; | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,64 @@ | |||||
using System; | |||||
using System.Collections.Generic; | |||||
using System.Collections.Immutable; | |||||
namespace Discord.Interactions | |||||
{ | |||||
/// <summary> | |||||
/// Represents the base info class for <see cref="IModal"/> input components. | |||||
/// </summary> | |||||
public abstract class InputComponentInfo | |||||
{ | |||||
/// <summary> | |||||
/// Gets the parent modal of this component. | |||||
/// </summary> | |||||
public ModalInfo Modal { get; } | |||||
/// <summary> | |||||
/// Gets the custom id of this component. | |||||
/// </summary> | |||||
public string CustomId { get; } | |||||
/// <summary> | |||||
/// Gets the label of this component. | |||||
/// </summary> | |||||
public string Label { get; } | |||||
/// <summary> | |||||
/// Gets whether or not this component requires a user input. | |||||
/// </summary> | |||||
public bool IsRequired { get; } | |||||
/// <summary> | |||||
/// Gets the type of this component. | |||||
/// </summary> | |||||
public ComponentType ComponentType { get; } | |||||
/// <summary> | |||||
/// Gets the reference type of this component. | |||||
/// </summary> | |||||
public Type Type { get; } | |||||
/// <summary> | |||||
/// Gets the default value of this component. | |||||
/// </summary> | |||||
public object DefaultValue { get; } | |||||
/// <summary> | |||||
/// Gets a collection of the attributes of this command. | |||||
/// </summary> | |||||
public IReadOnlyCollection<Attribute> Attributes { get; } | |||||
protected InputComponentInfo(Builders.IInputComponentBuilder builder, ModalInfo modal) | |||||
{ | |||||
Modal = modal; | |||||
CustomId = builder.CustomId; | |||||
Label = builder.Label; | |||||
IsRequired = builder.IsRequired; | |||||
ComponentType = builder.ComponentType; | |||||
Type = builder.Type; | |||||
DefaultValue = builder.DefaultValue; | |||||
Attributes = builder.Attributes.ToImmutableArray(); | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,42 @@ | |||||
namespace Discord.Interactions | |||||
{ | |||||
/// <summary> | |||||
/// Represents the <see cref="InputComponentInfo"/> class for <see cref="ComponentType.TextInput"/> type. | |||||
/// </summary> | |||||
public class TextInputComponentInfo : InputComponentInfo | |||||
{ | |||||
/// <summary> | |||||
/// Gets the style of the text input. | |||||
/// </summary> | |||||
public TextInputStyle Style { get; } | |||||
/// <summary> | |||||
/// Gets the placeholder of the text input. | |||||
/// </summary> | |||||
public string Placeholder { get; } | |||||
/// <summary> | |||||
/// Gets the minimum length of the text input. | |||||
/// </summary> | |||||
public int MinLength { get; } | |||||
/// <summary> | |||||
/// Gets the maximum length of the text input. | |||||
/// </summary> | |||||
public int MaxLength { get; } | |||||
/// <summary> | |||||
/// Gets the initial value to be displayed by this input. | |||||
/// </summary> | |||||
public string InitialValue { get; } | |||||
internal TextInputComponentInfo(Builders.TextInputComponentBuilder builder, ModalInfo modal) : base(builder, modal) | |||||
{ | |||||
Style = builder.Style; | |||||
Placeholder = builder.Placeholder; | |||||
MinLength = builder.MinLength; | |||||
MaxLength = builder.MaxLength; | |||||
InitialValue = builder.InitialValue; | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,90 @@ | |||||
using System; | |||||
using System.Collections.Generic; | |||||
using System.Collections.Immutable; | |||||
using System.Linq; | |||||
namespace Discord.Interactions | |||||
{ | |||||
/// <summary> | |||||
/// Represents a cached object initialization delegate. | |||||
/// </summary> | |||||
/// <param name="args">Property arguments array.</param> | |||||
/// <returns> | |||||
/// Returns the constructed object. | |||||
/// </returns> | |||||
public delegate IModal ModalInitializer(object[] args); | |||||
/// <summary> | |||||
/// Represents the info class of an <see cref="IModal"/> form. | |||||
/// </summary> | |||||
public class ModalInfo | |||||
{ | |||||
internal readonly ModalInitializer _initializer; | |||||
/// <summary> | |||||
/// Gets the title of this modal. | |||||
/// </summary> | |||||
public string Title { get; } | |||||
/// <summary> | |||||
/// Gets the <see cref="IModal"/> implementation used to initialize this object. | |||||
/// </summary> | |||||
public Type Type { get; } | |||||
/// <summary> | |||||
/// Gets a collection of the components of this modal. | |||||
/// </summary> | |||||
public IReadOnlyCollection<InputComponentInfo> Components { get; } | |||||
/// <summary> | |||||
/// Gets a collection of the text components of this modal. | |||||
/// </summary> | |||||
public IReadOnlyCollection<TextInputComponentInfo> TextComponents { get; } | |||||
internal ModalInfo(Builders.ModalBuilder builder) | |||||
{ | |||||
Title = builder.Title; | |||||
Type = builder.Type; | |||||
Components = builder.Components.Select(x => x switch | |||||
{ | |||||
Builders.TextInputComponentBuilder textComponent => textComponent.Build(this), | |||||
_ => throw new InvalidOperationException($"{x.GetType().FullName} isn't a supported modal input component builder type.") | |||||
}).ToImmutableArray(); | |||||
TextComponents = Components.OfType<TextInputComponentInfo>().ToImmutableArray(); | |||||
_initializer = builder.ModalInitializer; | |||||
} | |||||
/// <summary> | |||||
/// Creates an <see cref="IModal"/> and fills it with provided message components. | |||||
/// </summary> | |||||
/// <param name="components"><see cref="IModalInteraction"/> that will be injected into the modal.</param> | |||||
/// <returns> | |||||
/// A <see cref="IModal"/> filled with the provided components. | |||||
/// </returns> | |||||
public IModal CreateModal(IModalInteraction modalInteraction, bool throwOnMissingField = false) | |||||
{ | |||||
var args = new object[Components.Count]; | |||||
var components = modalInteraction.Data.Components.ToList(); | |||||
for (var i = 0; i < Components.Count; i++) | |||||
{ | |||||
var input = Components.ElementAt(i); | |||||
var component = components.Find(x => x.CustomId == input.CustomId); | |||||
if (component is null) | |||||
{ | |||||
if (!throwOnMissingField) | |||||
args[i] = input.DefaultValue; | |||||
else | |||||
throw new InvalidOperationException($"Modal interaction is missing the required field: {input.CustomId}"); | |||||
} | |||||
else | |||||
args[i] = component.Value; | |||||
} | |||||
return _initializer(args); | |||||
} | |||||
} | |||||
} |
@@ -68,6 +68,8 @@ namespace Discord.Interactions | |||||
/// </summary> | /// </summary> | ||||
public IReadOnlyCollection<AutocompleteCommandInfo> AutocompleteCommands { get; } | public IReadOnlyCollection<AutocompleteCommandInfo> AutocompleteCommands { get; } | ||||
public IReadOnlyCollection<ModalCommandInfo> ModalCommands { get; } | |||||
/// <summary> | /// <summary> | ||||
/// Gets the declaring type of this module, if <see cref="IsSubModule"/> is <see langword="true"/>. | /// Gets the declaring type of this module, if <see cref="IsSubModule"/> is <see langword="true"/>. | ||||
/// </summary> | /// </summary> | ||||
@@ -112,6 +114,7 @@ namespace Discord.Interactions | |||||
ContextCommands = BuildContextCommands(builder).ToImmutableArray(); | ContextCommands = BuildContextCommands(builder).ToImmutableArray(); | ||||
ComponentCommands = BuildComponentCommands(builder).ToImmutableArray(); | ComponentCommands = BuildComponentCommands(builder).ToImmutableArray(); | ||||
AutocompleteCommands = BuildAutocompleteCommands(builder).ToImmutableArray(); | AutocompleteCommands = BuildAutocompleteCommands(builder).ToImmutableArray(); | ||||
ModalCommands = BuildModalCommands(builder).ToImmutableArray(); | |||||
SubModules = BuildSubModules(builder, commandService, services).ToImmutableArray(); | SubModules = BuildSubModules(builder, commandService, services).ToImmutableArray(); | ||||
Attributes = BuildAttributes(builder).ToImmutableArray(); | Attributes = BuildAttributes(builder).ToImmutableArray(); | ||||
Preconditions = BuildPreconditions(builder).ToImmutableArray(); | Preconditions = BuildPreconditions(builder).ToImmutableArray(); | ||||
@@ -171,6 +174,16 @@ namespace Discord.Interactions | |||||
return result; | return result; | ||||
} | } | ||||
private IEnumerable<ModalCommandInfo> BuildModalCommands(ModuleBuilder builder) | |||||
{ | |||||
var result = new List<ModalCommandInfo>(); | |||||
foreach (var commandBuilder in builder.ModalCommands) | |||||
result.Add(commandBuilder.Build(this, CommandService)); | |||||
return result; | |||||
} | |||||
private IEnumerable<Attribute> BuildAttributes (ModuleBuilder builder) | private IEnumerable<Attribute> BuildAttributes (ModuleBuilder builder) | ||||
{ | { | ||||
var result = new List<Attribute>(); | var result = new List<Attribute>(); | ||||
@@ -0,0 +1,28 @@ | |||||
using Discord.Interactions.Builders; | |||||
namespace Discord.Interactions | |||||
{ | |||||
/// <summary> | |||||
/// Represents the base parameter info class for <see cref="InteractionService"/> modals. | |||||
/// </summary> | |||||
public class ModalCommandParameterInfo : CommandParameterInfo | |||||
{ | |||||
/// <summary> | |||||
/// Gets the <see cref="ModalInfo"/> class for this parameter if <see cref="IsModalParameter"/> is true. | |||||
/// </summary> | |||||
public ModalInfo Modal { get; private set; } | |||||
/// <summary> | |||||
/// Gets whether this parameter is an <see cref="IModal"/> | |||||
/// </summary> | |||||
public bool IsModalParameter => Modal is not null; | |||||
/// <inheritdoc/> | |||||
public new ModalCommandInfo Command => base.Command as ModalCommandInfo; | |||||
internal ModalCommandParameterInfo(ModalCommandParameterBuilder builder, ICommandInfo command) : base(builder, command) | |||||
{ | |||||
Modal = builder.Modal; | |||||
} | |||||
} | |||||
} |
@@ -114,6 +114,13 @@ namespace Discord.Interactions | |||||
var response = await Context.Interaction.GetOriginalResponseAsync().ConfigureAwait(false); | var response = await Context.Interaction.GetOriginalResponseAsync().ConfigureAwait(false); | ||||
await response.DeleteAsync().ConfigureAwait(false); | await response.DeleteAsync().ConfigureAwait(false); | ||||
} | } | ||||
/// <inheritdoc cref="IDiscordInteraction.RespondWithModalAsync(Modal, RequestOptions)"/> | |||||
protected virtual async Task RespondWithModalAsync(Modal modal, RequestOptions options = null) => await Context.Interaction.RespondWithModalAsync(modal); | |||||
/// <inheritdoc cref="IDiscordInteractionExtentions.RespondWithModalAsync(IDiscordInteraction, IModal, RequestOptions)"/> | |||||
protected virtual async Task RespondWithModalAsync<T>(string customId, RequestOptions options = null) where T : class, IModal | |||||
=> await Context.Interaction.RespondWithModalAsync<T>(customId, options); | |||||
//IInteractionModuleBase | //IInteractionModuleBase | ||||
@@ -53,21 +53,29 @@ namespace Discord.Interactions | |||||
public event Func<IAutocompleteHandler, IInteractionContext, IResult, Task> AutocompleteHandlerExecuted { add { _autocompleteHandlerExecutedEvent.Add(value); } remove { _autocompleteHandlerExecutedEvent.Remove(value); } } | public event Func<IAutocompleteHandler, IInteractionContext, IResult, Task> AutocompleteHandlerExecuted { add { _autocompleteHandlerExecutedEvent.Add(value); } remove { _autocompleteHandlerExecutedEvent.Remove(value); } } | ||||
internal readonly AsyncEvent<Func<IAutocompleteHandler, IInteractionContext, IResult, Task>> _autocompleteHandlerExecutedEvent = new(); | internal readonly AsyncEvent<Func<IAutocompleteHandler, IInteractionContext, IResult, Task>> _autocompleteHandlerExecutedEvent = new(); | ||||
/// <summary> | |||||
/// Occurs when a Modal command is executed. | |||||
/// </summary> | |||||
public event Func<ModalCommandInfo, IInteractionContext, IResult, Task> ModalCommandExecuted { add { _modalCommandExecutedEvent.Add(value); } remove { _modalCommandExecutedEvent.Remove(value); } } | |||||
internal readonly AsyncEvent<Func<ModalCommandInfo, IInteractionContext, IResult, Task>> _modalCommandExecutedEvent = new(); | |||||
private readonly ConcurrentDictionary<Type, ModuleInfo> _typedModuleDefs; | private readonly ConcurrentDictionary<Type, ModuleInfo> _typedModuleDefs; | ||||
private readonly CommandMap<SlashCommandInfo> _slashCommandMap; | private readonly CommandMap<SlashCommandInfo> _slashCommandMap; | ||||
private readonly ConcurrentDictionary<ApplicationCommandType, CommandMap<ContextCommandInfo>> _contextCommandMaps; | private readonly ConcurrentDictionary<ApplicationCommandType, CommandMap<ContextCommandInfo>> _contextCommandMaps; | ||||
private readonly CommandMap<ComponentCommandInfo> _componentCommandMap; | private readonly CommandMap<ComponentCommandInfo> _componentCommandMap; | ||||
private readonly CommandMap<AutocompleteCommandInfo> _autocompleteCommandMap; | private readonly CommandMap<AutocompleteCommandInfo> _autocompleteCommandMap; | ||||
private readonly CommandMap<ModalCommandInfo> _modalCommandMap; | |||||
private readonly HashSet<ModuleInfo> _moduleDefs; | private readonly HashSet<ModuleInfo> _moduleDefs; | ||||
private readonly ConcurrentDictionary<Type, TypeConverter> _typeConverters; | private readonly ConcurrentDictionary<Type, TypeConverter> _typeConverters; | ||||
private readonly ConcurrentDictionary<Type, Type> _genericTypeConverters; | private readonly ConcurrentDictionary<Type, Type> _genericTypeConverters; | ||||
private readonly ConcurrentDictionary<Type, IAutocompleteHandler> _autocompleteHandlers = new(); | private readonly ConcurrentDictionary<Type, IAutocompleteHandler> _autocompleteHandlers = new(); | ||||
private readonly ConcurrentDictionary<Type, ModalInfo> _modalInfos = new(); | |||||
private readonly SemaphoreSlim _lock; | private readonly SemaphoreSlim _lock; | ||||
internal readonly Logger _cmdLogger; | internal readonly Logger _cmdLogger; | ||||
internal readonly LogManager _logManager; | internal readonly LogManager _logManager; | ||||
internal readonly Func<DiscordRestClient> _getRestClient; | internal readonly Func<DiscordRestClient> _getRestClient; | ||||
internal readonly bool _throwOnError, _useCompiledLambda, _enableAutocompleteHandlers, _autoServiceScopes; | |||||
internal readonly bool _throwOnError, _useCompiledLambda, _enableAutocompleteHandlers, _autoServiceScopes, _exitOnMissingModalField; | |||||
internal readonly string _wildCardExp; | internal readonly string _wildCardExp; | ||||
internal readonly RunMode _runMode; | internal readonly RunMode _runMode; | ||||
internal readonly RestResponseCallback _restResponseCallback; | internal readonly RestResponseCallback _restResponseCallback; | ||||
@@ -98,6 +106,16 @@ namespace Discord.Interactions | |||||
public IReadOnlyCollection<ComponentCommandInfo> ComponentCommands => _moduleDefs.SelectMany(x => x.ComponentCommands).ToList(); | public IReadOnlyCollection<ComponentCommandInfo> ComponentCommands => _moduleDefs.SelectMany(x => x.ComponentCommands).ToList(); | ||||
/// <summary> | /// <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. | /// Initialize a <see cref="InteractionService"/> with provided configurations. | ||||
/// </summary> | /// </summary> | ||||
/// <param name="discord">The discord client.</param> | /// <param name="discord">The discord client.</param> | ||||
@@ -145,6 +163,7 @@ namespace Discord.Interactions | |||||
_contextCommandMaps = new ConcurrentDictionary<ApplicationCommandType, CommandMap<ContextCommandInfo>>(); | _contextCommandMaps = new ConcurrentDictionary<ApplicationCommandType, CommandMap<ContextCommandInfo>>(); | ||||
_componentCommandMap = new CommandMap<ComponentCommandInfo>(this, config.InteractionCustomIdDelimiters); | _componentCommandMap = new CommandMap<ComponentCommandInfo>(this, config.InteractionCustomIdDelimiters); | ||||
_autocompleteCommandMap = new CommandMap<AutocompleteCommandInfo>(this); | _autocompleteCommandMap = new CommandMap<AutocompleteCommandInfo>(this); | ||||
_modalCommandMap = new CommandMap<ModalCommandInfo>(this, config.InteractionCustomIdDelimiters); | |||||
_getRestClient = getRestClient; | _getRestClient = getRestClient; | ||||
@@ -155,6 +174,7 @@ namespace Discord.Interactions | |||||
_throwOnError = config.ThrowOnError; | _throwOnError = config.ThrowOnError; | ||||
_wildCardExp = config.WildCardExpression; | _wildCardExp = config.WildCardExpression; | ||||
_useCompiledLambda = config.UseCompiledLambda; | _useCompiledLambda = config.UseCompiledLambda; | ||||
_exitOnMissingModalField = config.ExitOnMissingModalField; | |||||
_enableAutocompleteHandlers = config.EnableAutocompleteHandlers; | _enableAutocompleteHandlers = config.EnableAutocompleteHandlers; | ||||
_autoServiceScopes = config.AutoServiceScopes; | _autoServiceScopes = config.AutoServiceScopes; | ||||
_restResponseCallback = config.RestResponseCallback; | _restResponseCallback = config.RestResponseCallback; | ||||
@@ -509,6 +529,9 @@ namespace Discord.Interactions | |||||
foreach (var command in module.AutocompleteCommands) | foreach (var command in module.AutocompleteCommands) | ||||
_autocompleteCommandMap.AddCommand(command.GetCommandKeywords(), command); | _autocompleteCommandMap.AddCommand(command.GetCommandKeywords(), command); | ||||
foreach (var command in module.ModalCommands) | |||||
_modalCommandMap.AddCommand(command, command.IgnoreGroupNames); | |||||
foreach (var subModule in module.SubModules) | foreach (var subModule in module.SubModules) | ||||
LoadModuleInternal(subModule); | LoadModuleInternal(subModule); | ||||
} | } | ||||
@@ -654,7 +677,7 @@ namespace Discord.Interactions | |||||
public async Task<IResult> ExecuteCommandAsync (IInteractionContext context, IServiceProvider services) | public async Task<IResult> ExecuteCommandAsync (IInteractionContext context, IServiceProvider services) | ||||
{ | { | ||||
var interaction = context.Interaction; | var interaction = context.Interaction; | ||||
return interaction switch | return interaction switch | ||||
{ | { | ||||
ISlashCommandInteraction slashCommand => await ExecuteSlashCommandAsync(context, slashCommand, services).ConfigureAwait(false), | ISlashCommandInteraction slashCommand => await ExecuteSlashCommandAsync(context, slashCommand, services).ConfigureAwait(false), | ||||
@@ -662,6 +685,7 @@ namespace Discord.Interactions | |||||
IUserCommandInteraction userCommand => await ExecuteContextCommandAsync(context, userCommand.Data.Name, ApplicationCommandType.User, services).ConfigureAwait(false), | IUserCommandInteraction userCommand => await ExecuteContextCommandAsync(context, userCommand.Data.Name, ApplicationCommandType.User, services).ConfigureAwait(false), | ||||
IMessageCommandInteraction messageCommand => await ExecuteContextCommandAsync(context, messageCommand.Data.Name, ApplicationCommandType.Message, services).ConfigureAwait(false), | IMessageCommandInteraction messageCommand => await ExecuteContextCommandAsync(context, messageCommand.Data.Name, ApplicationCommandType.Message, services).ConfigureAwait(false), | ||||
IAutocompleteInteraction autocomplete => await ExecuteAutocompleteAsync(context, autocomplete, services).ConfigureAwait(false), | IAutocompleteInteraction autocomplete => await ExecuteAutocompleteAsync(context, autocomplete, services).ConfigureAwait(false), | ||||
IModalInteraction modalCommand => await ExecuteModalCommandAsync(context, modalCommand.Data.CustomId, services).ConfigureAwait(false), | |||||
_ => throw new InvalidOperationException($"{interaction.Type} interaction type cannot be executed by the Interaction service"), | _ => throw new InvalidOperationException($"{interaction.Type} interaction type cannot be executed by the Interaction service"), | ||||
}; | }; | ||||
} | } | ||||
@@ -745,6 +769,20 @@ namespace Discord.Interactions | |||||
return await commandResult.Command.ExecuteAsync(context, services).ConfigureAwait(false); | return await commandResult.Command.ExecuteAsync(context, services).ConfigureAwait(false); | ||||
} | } | ||||
private async Task<IResult> ExecuteModalCommandAsync(IInteractionContext context, string input, IServiceProvider services) | |||||
{ | |||||
var result = _modalCommandMap.GetCommand(input); | |||||
if (!result.IsSuccess) | |||||
{ | |||||
await _cmdLogger.DebugAsync($"Unknown custom interaction id, skipping execution ({input.ToUpper()})"); | |||||
await _componentCommandExecutedEvent.InvokeAsync(null, context, result).ConfigureAwait(false); | |||||
return result; | |||||
} | |||||
return await result.Command.ExecuteAsync(context, services, result.RegexCaptureGroups).ConfigureAwait(false); | |||||
} | |||||
internal TypeConverter GetTypeConverter (Type type, IServiceProvider services = null) | internal TypeConverter GetTypeConverter (Type type, IServiceProvider services = null) | ||||
{ | { | ||||
if (_typeConverters.TryGetValue(type, out var specific)) | if (_typeConverters.TryGetValue(type, out var specific)) | ||||
@@ -819,6 +857,24 @@ namespace Discord.Interactions | |||||
_genericTypeConverters[targetType] = converterType; | _genericTypeConverters[targetType] = converterType; | ||||
} | } | ||||
/// <summary> | |||||
/// Loads and caches an <see cref="ModalInfo"/> for the provided <see cref="IModal"/>. | |||||
/// </summary> | |||||
/// <typeparam name="T">Type of <see cref="IModal"/> to be loaded.</typeparam> | |||||
/// <returns> | |||||
/// The built <see cref="ModalInfo"/> instance. | |||||
/// </returns> | |||||
/// <exception cref="InvalidOperationException"></exception> | |||||
public ModalInfo AddModalInfo<T>() where T : class, IModal | |||||
{ | |||||
var type = typeof(T); | |||||
if (_modalInfos.ContainsKey(type)) | |||||
throw new InvalidOperationException($"Modal type {type.FullName} already exists."); | |||||
return ModalUtils.GetOrAdd(type); | |||||
} | |||||
internal IAutocompleteHandler GetAutocompleteHandler(Type autocompleteHandlerType, IServiceProvider services = null) | internal IAutocompleteHandler GetAutocompleteHandler(Type autocompleteHandlerType, IServiceProvider services = null) | ||||
{ | { | ||||
services ??= EmptyServiceProvider.Instance; | services ??= EmptyServiceProvider.Instance; | ||||
@@ -36,6 +36,9 @@ namespace Discord.Interactions | |||||
/// <summary> | /// <summary> | ||||
/// Gets or sets the option to use compiled lambda expressions to create module instances and execute commands. This method improves performance at the cost of memory. | /// Gets or sets the option to use compiled lambda expressions to create module instances and execute commands. This method improves performance at the cost of memory. | ||||
/// </summary> | /// </summary> | ||||
/// <remarks> | |||||
/// For performance reasons, if you frequently use <see cref="Modal"/>s with the service, it is highly recommended that you enable compiled lambdas. | |||||
/// </remarks> | |||||
public bool UseCompiledLambda { get; set; } = false; | public bool UseCompiledLambda { get; set; } = false; | ||||
/// <summary> | /// <summary> | ||||
@@ -56,6 +59,11 @@ namespace Discord.Interactions | |||||
/// Gets or sets delegate to be used by the <see cref="InteractionService"/> when responding to a Rest based interaction. | /// Gets or sets delegate to be used by the <see cref="InteractionService"/> when responding to a Rest based interaction. | ||||
/// </summary> | /// </summary> | ||||
public RestResponseCallback RestResponseCallback { get; set; } = (ctx, str) => Task.CompletedTask; | public RestResponseCallback RestResponseCallback { get; set; } = (ctx, str) => Task.CompletedTask; | ||||
/// <summary> | |||||
/// Gets or sets whether a command execution should exit when a modal command encounters a missing modal component value. | |||||
/// </summary> | |||||
public bool ExitOnMissingModalField { get; set; } = false; | |||||
} | } | ||||
/// <summary> | /// <summary> | ||||
@@ -0,0 +1,51 @@ | |||||
using Discord.Interactions.Builders; | |||||
using System; | |||||
using System.Collections.Concurrent; | |||||
using System.Collections.Generic; | |||||
namespace Discord.Interactions | |||||
{ | |||||
internal static class ModalUtils | |||||
{ | |||||
private static ConcurrentDictionary<Type, ModalInfo> _modalInfos = new(); | |||||
public static IReadOnlyCollection<ModalInfo> Modals => _modalInfos.Values.ToReadOnlyCollection(); | |||||
public static ModalInfo GetOrAdd(Type type) | |||||
{ | |||||
if (!typeof(IModal).IsAssignableFrom(type)) | |||||
throw new ArgumentException($"Must be an implementation of {nameof(IModal)}", nameof(type)); | |||||
return _modalInfos.GetOrAdd(type, ModuleClassBuilder.BuildModalInfo(type)); | |||||
} | |||||
public static ModalInfo GetOrAdd<T>() where T : class, IModal | |||||
=> GetOrAdd(typeof(T)); | |||||
public static bool TryGet(Type type, out ModalInfo modalInfo) | |||||
{ | |||||
if (!typeof(IModal).IsAssignableFrom(type)) | |||||
throw new ArgumentException($"Must be an implementation of {nameof(IModal)}", nameof(type)); | |||||
return _modalInfos.TryGetValue(type, out modalInfo); | |||||
} | |||||
public static bool TryGet<T>(out ModalInfo modalInfo) where T : class, IModal | |||||
=> TryGet(typeof(T), out modalInfo); | |||||
public static bool TryRemove(Type type, out ModalInfo modalInfo) | |||||
{ | |||||
if (!typeof(IModal).IsAssignableFrom(type)) | |||||
throw new ArgumentException($"Must be an implementation of {nameof(IModal)}", nameof(type)); | |||||
return _modalInfos.TryRemove(type, out modalInfo); | |||||
} | |||||
public static bool TryRemove<T>(out ModalInfo modalInfo) where T : class, IModal | |||||
=> TryRemove(typeof(T), out modalInfo); | |||||
public static void Clear() => _modalInfos.Clear(); | |||||
public static int Count() => _modalInfos.Count; | |||||
} | |||||
} |
@@ -112,6 +112,67 @@ namespace Discord.Interactions | |||||
var parameters = constructor.GetParameters(); | var parameters = constructor.GetParameters(); | ||||
var properties = GetProperties(typeInfo); | var properties = GetProperties(typeInfo); | ||||
var lambda = CreateLambdaMemberInit(typeInfo, constructor); | |||||
return (services) => | |||||
{ | |||||
var args = new object[parameters.Length]; | |||||
var props = new object[properties.Length]; | |||||
for (int i = 0; i < parameters.Length; i++) | |||||
args[i] = GetMember(commandService, services, parameters[i].ParameterType, typeInfo); | |||||
for (int i = 0; i < properties.Length; i++) | |||||
props[i] = GetMember(commandService, services, properties[i].PropertyType, typeInfo); | |||||
var instance = lambda(args, props); | |||||
return instance; | |||||
}; | |||||
} | |||||
internal static Func<object[], T> CreateLambdaConstructorInvoker(TypeInfo typeInfo) | |||||
{ | |||||
var constructor = GetConstructor(typeInfo); | |||||
var parameters = constructor.GetParameters(); | |||||
var argsExp = Expression.Parameter(typeof(object[]), "args"); | |||||
var parameterExps = new Expression[parameters.Length]; | |||||
for (var i = 0; i < parameters.Length; i++) | |||||
{ | |||||
var indexExp = Expression.Constant(i); | |||||
var accessExp = Expression.ArrayIndex(argsExp, indexExp); | |||||
parameterExps[i] = Expression.Convert(accessExp, parameters[i].ParameterType); | |||||
} | |||||
var newExp = Expression.New(constructor, parameterExps); | |||||
return Expression.Lambda<Func<object[], T>>(newExp, argsExp).Compile(); | |||||
} | |||||
/// <summary> | |||||
/// Create a compiled lambda property setter. | |||||
/// </summary> | |||||
internal static Action<T, object> CreateLambdaPropertySetter(PropertyInfo propertyInfo) | |||||
{ | |||||
var instanceParam = Expression.Parameter(typeof(T), "instance"); | |||||
var valueParam = Expression.Parameter(typeof(object), "value"); | |||||
var prop = Expression.Property(instanceParam, propertyInfo); | |||||
var assign = Expression.Assign(prop, Expression.Convert(valueParam, propertyInfo.PropertyType)); | |||||
return Expression.Lambda<Action<T, object>>(assign, instanceParam, valueParam).Compile(); | |||||
} | |||||
internal static Func<object[], object[], T> CreateLambdaMemberInit(TypeInfo typeInfo, ConstructorInfo constructor, Predicate<PropertyInfo> propertySelect = null) | |||||
{ | |||||
propertySelect ??= x => true; | |||||
var parameters = constructor.GetParameters(); | |||||
var properties = GetProperties(typeInfo).Where(x => propertySelect(x)).ToArray(); | |||||
var argsExp = Expression.Parameter(typeof(object[]), "args"); | var argsExp = Expression.Parameter(typeof(object[]), "args"); | ||||
var propsExp = Expression.Parameter(typeof(object[]), "props"); | var propsExp = Expression.Parameter(typeof(object[]), "props"); | ||||
@@ -137,17 +198,8 @@ namespace Discord.Interactions | |||||
var memberInit = Expression.MemberInit(newExp, memberExps); | var memberInit = Expression.MemberInit(newExp, memberExps); | ||||
var lambda = Expression.Lambda<Func<object[], object[], T>>(memberInit, argsExp, propsExp).Compile(); | var lambda = Expression.Lambda<Func<object[], object[], T>>(memberInit, argsExp, propsExp).Compile(); | ||||
return (services) => | |||||
return (args, props) => | |||||
{ | { | ||||
var args = new object[parameters.Length]; | |||||
var props = new object[properties.Length]; | |||||
for (int i = 0; i < parameters.Length; i++) | |||||
args[i] = GetMember(commandService, services, parameters[i].ParameterType, typeInfo); | |||||
for (int i = 0; i < properties.Length; i++) | |||||
props[i] = GetMember(commandService, services, properties[i].PropertyType, typeInfo); | |||||
var instance = lambda(args, props); | var instance = lambda(args, props); | ||||
return instance; | return instance; | ||||
@@ -21,6 +21,7 @@ namespace Discord.API | |||||
{ | { | ||||
ComponentType.Button => new ButtonComponent(x as Discord.ButtonComponent), | ComponentType.Button => new ButtonComponent(x as Discord.ButtonComponent), | ||||
ComponentType.SelectMenu => new SelectMenuComponent(x as Discord.SelectMenuComponent), | ComponentType.SelectMenu => new SelectMenuComponent(x as Discord.SelectMenuComponent), | ||||
ComponentType.TextInput => new TextInputComponent(x as Discord.TextInputComponent), | |||||
_ => null | _ => null | ||||
}; | }; | ||||
}).ToArray(); | }).ToArray(); | ||||
@@ -24,5 +24,11 @@ namespace Discord.API | |||||
[JsonProperty("choices")] | [JsonProperty("choices")] | ||||
public Optional<ApplicationCommandOptionChoice[]> Choices { get; set; } | public Optional<ApplicationCommandOptionChoice[]> Choices { get; set; } | ||||
[JsonProperty("title")] | |||||
public Optional<string> Title { get; set; } | |||||
[JsonProperty("custom_id")] | |||||
public Optional<string> CustomId { get; set; } | |||||
} | } | ||||
} | } |
@@ -12,5 +12,8 @@ namespace Discord.API | |||||
[JsonProperty("values")] | [JsonProperty("values")] | ||||
public Optional<string[]> Values { get; set; } | public Optional<string[]> Values { get; set; } | ||||
[JsonProperty("value")] | |||||
public Optional<string> Value { get; set; } | |||||
} | } | ||||
} | } |
@@ -0,0 +1,13 @@ | |||||
using Newtonsoft.Json; | |||||
namespace Discord.API | |||||
{ | |||||
internal class ModalInteractionData : IDiscordInteractionData | |||||
{ | |||||
[JsonProperty("custom_id")] | |||||
public string CustomId { get; set; } | |||||
[JsonProperty("components")] | |||||
public API.ActionRowComponent[] Components { get; set; } | |||||
} | |||||
} |
@@ -26,6 +26,8 @@ namespace Discord.API | |||||
[JsonProperty("disabled")] | [JsonProperty("disabled")] | ||||
public bool Disabled { get; set; } | public bool Disabled { get; set; } | ||||
[JsonProperty("values")] | |||||
public Optional<string[]> Values { get; set; } | |||||
public SelectMenuComponent() { } | public SelectMenuComponent() { } | ||||
public SelectMenuComponent(Discord.SelectMenuComponent component) | public SelectMenuComponent(Discord.SelectMenuComponent component) | ||||
@@ -0,0 +1,49 @@ | |||||
using Newtonsoft.Json; | |||||
namespace Discord.API | |||||
{ | |||||
internal class TextInputComponent : IMessageComponent | |||||
{ | |||||
[JsonProperty("type")] | |||||
public ComponentType Type { get; set; } | |||||
[JsonProperty("style")] | |||||
public TextInputStyle Style { get; set; } | |||||
[JsonProperty("custom_id")] | |||||
public string CustomId { get; set; } | |||||
[JsonProperty("label")] | |||||
public string Label { get; set; } | |||||
[JsonProperty("placeholder")] | |||||
public Optional<string> Placeholder { get; set; } | |||||
[JsonProperty("min_length")] | |||||
public Optional<int> MinLength { get; set; } | |||||
[JsonProperty("max_length")] | |||||
public Optional<int> MaxLength { get; set; } | |||||
[JsonProperty("value")] | |||||
public Optional<string> Value { get; set; } | |||||
[JsonProperty("required")] | |||||
public Optional<bool> Required { get; set; } | |||||
public TextInputComponent() { } | |||||
public TextInputComponent(Discord.TextInputComponent component) | |||||
{ | |||||
Type = component.Type; | |||||
Style = component.Style; | |||||
CustomId = component.CustomId; | |||||
Label = component.Label; | |||||
Placeholder = component.Placeholder; | |||||
MinLength = component.MinLength ?? Optional<int>.Unspecified; | |||||
MaxLength = component.MaxLength ?? Optional<int>.Unspecified; | |||||
Required = component.Required ?? Optional<bool>.Unspecified; | |||||
Value = component.Value ?? Optional<string>.Unspecified; | |||||
} | |||||
} | |||||
} |
@@ -316,5 +316,45 @@ namespace Discord.Rest | |||||
return SerializePayload(response); | return SerializePayload(response); | ||||
} | } | ||||
/// <summary> | |||||
/// Responds to the interaction with a modal. | |||||
/// </summary> | |||||
/// <param name="modal">The modal to respond with.</param> | |||||
/// <param name="options">The request options for this <see langword="async"/> request.</param> | |||||
/// <returns>A string that contains json to write back to the incoming http request.</returns> | |||||
/// <exception cref="TimeoutException"></exception> | |||||
/// <exception cref="InvalidOperationException"></exception> | |||||
public override string RespondWithModal(Modal modal, RequestOptions options = null) | |||||
{ | |||||
if (!InteractionHelper.CanSendResponse(this)) | |||||
throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds of no response/acknowledgement"); | |||||
var response = new API.InteractionResponse | |||||
{ | |||||
Type = InteractionResponseType.Modal, | |||||
Data = new API.InteractionCallbackData | |||||
{ | |||||
CustomId = modal.CustomId, | |||||
Title = modal.Title, | |||||
Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() | |||||
} | |||||
}; | |||||
lock (_lock) | |||||
{ | |||||
if (HasResponded) | |||||
{ | |||||
throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); | |||||
} | |||||
} | |||||
lock (_lock) | |||||
{ | |||||
HasResponded = true; | |||||
} | |||||
return SerializePayload(response); | |||||
} | |||||
} | } | ||||
} | } |
@@ -446,6 +446,46 @@ namespace Discord.Rest | |||||
return SerializePayload(response); | return SerializePayload(response); | ||||
} | } | ||||
/// <summary> | |||||
/// Responds to the interaction with a modal. | |||||
/// </summary> | |||||
/// <param name="modal">The modal to respond with.</param> | |||||
/// <param name="options">The request options for this <see langword="async"/> request.</param> | |||||
/// <returns>A string that contains json to write back to the incoming http request.</returns> | |||||
/// <exception cref="TimeoutException"></exception> | |||||
/// <exception cref="InvalidOperationException"></exception> | |||||
public override string RespondWithModal(Modal modal, RequestOptions options = null) | |||||
{ | |||||
if (!InteractionHelper.CanSendResponse(this)) | |||||
throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds of no response/acknowledgement"); | |||||
var response = new API.InteractionResponse | |||||
{ | |||||
Type = InteractionResponseType.Modal, | |||||
Data = new API.InteractionCallbackData | |||||
{ | |||||
CustomId = modal.CustomId, | |||||
Title = modal.Title, | |||||
Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() | |||||
} | |||||
}; | |||||
lock (_lock) | |||||
{ | |||||
if (HasResponded) | |||||
{ | |||||
throw new InvalidOperationException("Cannot respond or defer twice to the same interaction."); | |||||
} | |||||
} | |||||
lock (_lock) | |||||
{ | |||||
HasResponded = true; | |||||
} | |||||
return SerializePayload(response); | |||||
} | |||||
//IComponentInteraction | //IComponentInteraction | ||||
/// <inheritdoc/> | /// <inheritdoc/> | ||||
IComponentInteractionData IComponentInteraction.Data => Data; | IComponentInteractionData IComponentInteraction.Data => Data; | ||||
@@ -27,11 +27,26 @@ namespace Discord.Rest | |||||
/// </summary> | /// </summary> | ||||
public IReadOnlyCollection<string> Values { get; } | public IReadOnlyCollection<string> Values { get; } | ||||
/// <inheritdoc/> | |||||
public string Value { get; } | |||||
internal RestMessageComponentData(Model model) | internal RestMessageComponentData(Model model) | ||||
{ | { | ||||
CustomId = model.CustomId; | CustomId = model.CustomId; | ||||
Type = model.ComponentType; | Type = model.ComponentType; | ||||
Values = model.Values.GetValueOrDefault(); | Values = model.Values.GetValueOrDefault(); | ||||
} | } | ||||
internal RestMessageComponentData(IMessageComponent component) | |||||
{ | |||||
CustomId = component.CustomId; | |||||
Type = component.Type; | |||||
if (component is API.TextInputComponent textInput) | |||||
Value = textInput.Value.Value; | |||||
if (component is API.SelectMenuComponent select) | |||||
Values = select.Values.Value; | |||||
} | |||||
} | } | ||||
} | } |
@@ -0,0 +1,402 @@ | |||||
using Discord.Net.Rest; | |||||
using Discord.Rest; | |||||
using System; | |||||
using System.Collections.Generic; | |||||
using System.IO; | |||||
using System.Linq; | |||||
using System.Reflection; | |||||
using System.Threading.Tasks; | |||||
using DataModel = Discord.API.ModalInteractionData; | |||||
using ModelBase = Discord.API.Interaction; | |||||
namespace Discord.Rest | |||||
{ | |||||
/// <summary> | |||||
/// Represents a user submitted <see cref="Modal"/>. | |||||
/// </summary> | |||||
public class RestModal : RestInteraction, IDiscordInteraction, IModalInteraction | |||||
{ | |||||
internal RestModal(DiscordRestClient client, ModelBase model) | |||||
: base(client, model.Id) | |||||
{ | |||||
var dataModel = model.Data.IsSpecified | |||||
? (DataModel)model.Data.Value | |||||
: null; | |||||
Data = new RestModalData(dataModel); | |||||
} | |||||
internal new static async Task<RestModal> CreateAsync(DiscordRestClient client, ModelBase model) | |||||
{ | |||||
var entity = new RestModal(client, model); | |||||
await entity.UpdateAsync(client, model); | |||||
return entity; | |||||
} | |||||
private object _lock = new object(); | |||||
/// <summary> | |||||
/// Acknowledges this interaction with the <see cref="InteractionResponseType.DeferredChannelMessageWithSource"/>. | |||||
/// </summary> | |||||
/// <returns> | |||||
/// A string that contains json to write back to the incoming http request. | |||||
/// </returns> | |||||
public override string Defer(bool ephemeral = false, RequestOptions options = null) | |||||
{ | |||||
if (!InteractionHelper.CanSendResponse(this)) | |||||
throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); | |||||
var response = new API.InteractionResponse | |||||
{ | |||||
Type = InteractionResponseType.DeferredChannelMessageWithSource, | |||||
Data = new API.InteractionCallbackData | |||||
{ | |||||
Flags = ephemeral ? MessageFlags.Ephemeral : Optional<MessageFlags>.Unspecified | |||||
} | |||||
}; | |||||
lock (_lock) | |||||
{ | |||||
if (HasResponded) | |||||
{ | |||||
throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); | |||||
} | |||||
} | |||||
lock (_lock) | |||||
{ | |||||
HasResponded = true; | |||||
} | |||||
return SerializePayload(response); | |||||
} | |||||
/// <summary> | |||||
/// Sends a followup message for this interaction. | |||||
/// </summary> | |||||
/// <param name="text">The text of the message to be sent.</param> | |||||
/// <param name="embeds">A array of embeds to send with this response. Max 10.</param> | |||||
/// <param name="isTTS"><see langword="true"/> if the message should be read out by a text-to-speech reader, otherwise <see langword="false"/>.</param> | |||||
/// <param name="ephemeral"><see langword="true"/> if the response should be hidden to everyone besides the invoker of the command, otherwise <see langword="false"/>.</param> | |||||
/// <param name="allowedMentions">The allowed mentions for this response.</param> | |||||
/// <param name="options">The request options for this response.</param> | |||||
/// <param name="component">A <see cref="MessageComponent"/> to be sent with this response.</param> | |||||
/// <param name="embed">A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored.</param> | |||||
/// <returns> | |||||
/// The sent message. | |||||
/// </returns> | |||||
public override async Task<RestFollowupMessage> FollowupAsync( | |||||
string text = null, | |||||
Embed[] embeds = null, | |||||
bool isTTS = false, | |||||
bool ephemeral = false, | |||||
AllowedMentions allowedMentions = null, | |||||
MessageComponent component = null, | |||||
Embed embed = null, | |||||
RequestOptions options = null) | |||||
{ | |||||
if (!IsValidToken) | |||||
throw new InvalidOperationException("Interaction token is no longer valid"); | |||||
embeds ??= Array.Empty<Embed>(); | |||||
if (embed != null) | |||||
embeds = new[] { embed }.Concat(embeds).ToArray(); | |||||
Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); | |||||
Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); | |||||
Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); | |||||
var args = new API.Rest.CreateWebhookMessageParams | |||||
{ | |||||
Content = text, | |||||
AllowedMentions = allowedMentions?.ToModel() ?? Optional<API.AllowedMentions>.Unspecified, | |||||
IsTTS = isTTS, | |||||
Embeds = embeds.Select(x => x.ToModel()).ToArray(), | |||||
Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified | |||||
}; | |||||
if (ephemeral) | |||||
args.Flags = MessageFlags.Ephemeral; | |||||
return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); | |||||
} | |||||
/// <summary> | |||||
/// Sends a followup message for this interaction. | |||||
/// </summary> | |||||
/// <param name="text">The text of the message to be sent.</param> | |||||
/// <param name="fileStream">The file to upload.</param> | |||||
/// <param name="fileName">The file name of the attachment.</param> | |||||
/// <param name="embeds">A array of embeds to send with this response. Max 10.</param> | |||||
/// <param name="isTTS"><see langword="true"/> if the message should be read out by a text-to-speech reader, otherwise <see langword="false"/>.</param> | |||||
/// <param name="ephemeral"><see langword="true"/> if the response should be hidden to everyone besides the invoker of the command, otherwise <see langword="false"/>.</param> | |||||
/// <param name="allowedMentions">The allowed mentions for this response.</param> | |||||
/// <param name="options">The request options for this response.</param> | |||||
/// <param name="component">A <see cref="MessageComponent"/> to be sent with this response.</param> | |||||
/// <param name="embed">A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored.</param> | |||||
/// <returns> | |||||
/// The sent message. | |||||
/// </returns> | |||||
public override async Task<RestFollowupMessage> FollowupWithFileAsync( | |||||
Stream fileStream, | |||||
string fileName, | |||||
string text = null, | |||||
Embed[] embeds = null, | |||||
bool isTTS = false, | |||||
bool ephemeral = false, | |||||
AllowedMentions allowedMentions = null, | |||||
MessageComponent component = null, | |||||
Embed embed = null, | |||||
RequestOptions options = null) | |||||
{ | |||||
if (!IsValidToken) | |||||
throw new InvalidOperationException("Interaction token is no longer valid"); | |||||
embeds ??= Array.Empty<Embed>(); | |||||
if (embed != null) | |||||
embeds = new[] { embed }.Concat(embeds).ToArray(); | |||||
Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); | |||||
Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); | |||||
Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); | |||||
Preconditions.NotNull(fileStream, nameof(fileStream), "File Stream must have data"); | |||||
Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); | |||||
var args = new API.Rest.CreateWebhookMessageParams | |||||
{ | |||||
Content = text, | |||||
AllowedMentions = allowedMentions?.ToModel() ?? Optional<API.AllowedMentions>.Unspecified, | |||||
IsTTS = isTTS, | |||||
Embeds = embeds.Select(x => x.ToModel()).ToArray(), | |||||
Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified, | |||||
File = fileStream is not null ? new MultipartFile(fileStream, fileName) : Optional<MultipartFile>.Unspecified | |||||
}; | |||||
if (ephemeral) | |||||
args.Flags = MessageFlags.Ephemeral; | |||||
return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); | |||||
} | |||||
/// <summary> | |||||
/// Sends a followup message for this interaction. | |||||
/// </summary> | |||||
/// <param name="text">The text of the message to be sent.</param> | |||||
/// <param name="filePath">The file to upload.</param> | |||||
/// <param name="fileName">The file name of the attachment.</param> | |||||
/// <param name="embeds">A array of embeds to send with this response. Max 10.</param> | |||||
/// <param name="isTTS"><see langword="true"/> if the message should be read out by a text-to-speech reader, otherwise <see langword="false"/>.</param> | |||||
/// <param name="ephemeral"><see langword="true"/> if the response should be hidden to everyone besides the invoker of the command, otherwise <see langword="false"/>.</param> | |||||
/// <param name="allowedMentions">The allowed mentions for this response.</param> | |||||
/// <param name="options">The request options for this response.</param> | |||||
/// <param name="component">A <see cref="MessageComponent"/> to be sent with this response.</param> | |||||
/// <param name="embed">A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored.</param> | |||||
/// <returns> | |||||
/// The sent message. | |||||
/// </returns> | |||||
public override async Task<RestFollowupMessage> FollowupWithFileAsync( | |||||
string filePath, | |||||
string text = null, | |||||
string fileName = null, | |||||
Embed[] embeds = null, | |||||
bool isTTS = false, | |||||
bool ephemeral = false, | |||||
AllowedMentions allowedMentions = null, | |||||
MessageComponent component = null, | |||||
Embed embed = null, | |||||
RequestOptions options = null) | |||||
{ | |||||
if (!IsValidToken) | |||||
throw new InvalidOperationException("Interaction token is no longer valid"); | |||||
embeds ??= Array.Empty<Embed>(); | |||||
if (embed != null) | |||||
embeds = new[] { embed }.Concat(embeds).ToArray(); | |||||
Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); | |||||
Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); | |||||
Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); | |||||
Preconditions.NotNullOrEmpty(filePath, nameof(filePath), "Path must exist"); | |||||
fileName ??= Path.GetFileName(filePath); | |||||
Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); | |||||
var args = new API.Rest.CreateWebhookMessageParams | |||||
{ | |||||
Content = text, | |||||
AllowedMentions = allowedMentions?.ToModel() ?? Optional<API.AllowedMentions>.Unspecified, | |||||
IsTTS = isTTS, | |||||
Embeds = embeds.Select(x => x.ToModel()).ToArray(), | |||||
Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified, | |||||
File = !string.IsNullOrEmpty(filePath) ? new MultipartFile(new MemoryStream(File.ReadAllBytes(filePath), false), fileName) : Optional<MultipartFile>.Unspecified | |||||
}; | |||||
if (ephemeral) | |||||
args.Flags = MessageFlags.Ephemeral; | |||||
return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); | |||||
} | |||||
/// <summary> | |||||
/// Responds to an Interaction with type <see cref="InteractionResponseType.ChannelMessageWithSource"/>. | |||||
/// </summary> | |||||
/// <param name="text">The text of the message to be sent.</param> | |||||
/// <param name="embeds">A array of embeds to send with this response. Max 10.</param> | |||||
/// <param name="isTTS"><see langword="true"/> if the message should be read out by a text-to-speech reader, otherwise <see langword="false"/>.</param> | |||||
/// <param name="ephemeral"><see langword="true"/> if the response should be hidden to everyone besides the invoker of the command, otherwise <see langword="false"/>.</param> | |||||
/// <param name="allowedMentions">The allowed mentions for this response.</param> | |||||
/// <param name="options">The request options for this response.</param> | |||||
/// <param name="component">A <see cref="MessageComponent"/> to be sent with this response.</param> | |||||
/// <param name="embed">A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored.</param> | |||||
/// <exception cref="ArgumentOutOfRangeException">Message content is too long, length must be less or equal to <see cref="DiscordConfig.MaxMessageSize"/>.</exception> | |||||
/// <exception cref="InvalidOperationException">The parameters provided were invalid or the token was invalid.</exception> | |||||
/// <returns> | |||||
/// A string that contains json to write back to the incoming http request. | |||||
/// </returns> | |||||
public override string Respond( | |||||
string text = null, | |||||
Embed[] embeds = null, | |||||
bool isTTS = false, | |||||
bool ephemeral = false, | |||||
AllowedMentions allowedMentions = null, | |||||
MessageComponent component = null, | |||||
Embed embed = null, | |||||
RequestOptions options = null) | |||||
{ | |||||
if (!IsValidToken) | |||||
throw new InvalidOperationException("Interaction token is no longer valid"); | |||||
if (!InteractionHelper.CanSendResponse(this)) | |||||
throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); | |||||
embeds ??= Array.Empty<Embed>(); | |||||
if (embed != null) | |||||
embeds = new[] { embed }.Concat(embeds).ToArray(); | |||||
Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); | |||||
Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); | |||||
Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); | |||||
// check that user flag and user Id list are exclusive, same with role flag and role Id list | |||||
if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) | |||||
{ | |||||
if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && | |||||
allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) | |||||
{ | |||||
throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); | |||||
} | |||||
if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && | |||||
allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) | |||||
{ | |||||
throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); | |||||
} | |||||
} | |||||
var response = new API.InteractionResponse | |||||
{ | |||||
Type = InteractionResponseType.ChannelMessageWithSource, | |||||
Data = new API.InteractionCallbackData | |||||
{ | |||||
Content = text, | |||||
AllowedMentions = allowedMentions?.ToModel() ?? Optional<API.AllowedMentions>.Unspecified, | |||||
Embeds = embeds.Select(x => x.ToModel()).ToArray(), | |||||
TTS = isTTS, | |||||
Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified, | |||||
Flags = ephemeral ? MessageFlags.Ephemeral : Optional<MessageFlags>.Unspecified | |||||
} | |||||
}; | |||||
lock (_lock) | |||||
{ | |||||
if (HasResponded) | |||||
{ | |||||
throw new InvalidOperationException("Cannot respond twice to the same interaction"); | |||||
} | |||||
} | |||||
lock (_lock) | |||||
{ | |||||
HasResponded = true; | |||||
} | |||||
return SerializePayload(response); | |||||
} | |||||
/// <inheritdoc/> | |||||
public override async Task<RestFollowupMessage> FollowupWithFilesAsync( | |||||
IEnumerable<FileAttachment> attachments, | |||||
string text = null, | |||||
Embed[] embeds = null, | |||||
bool isTTS = false, | |||||
bool ephemeral = false, | |||||
AllowedMentions allowedMentions = null, | |||||
MessageComponent components = null, | |||||
Embed embed = null, | |||||
RequestOptions options = null) | |||||
{ | |||||
if (!IsValidToken) | |||||
throw new InvalidOperationException("Interaction token is no longer valid"); | |||||
embeds ??= Array.Empty<Embed>(); | |||||
if (embed != null) | |||||
embeds = new[] { embed }.Concat(embeds).ToArray(); | |||||
Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); | |||||
Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); | |||||
Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); | |||||
foreach (var attachment in attachments) | |||||
{ | |||||
Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); | |||||
} | |||||
// check that user flag and user Id list are exclusive, same with role flag and role Id list | |||||
if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) | |||||
{ | |||||
if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && | |||||
allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) | |||||
{ | |||||
throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); | |||||
} | |||||
if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && | |||||
allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) | |||||
{ | |||||
throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); | |||||
} | |||||
} | |||||
var flags = MessageFlags.None; | |||||
if (ephemeral) | |||||
flags |= MessageFlags.Ephemeral; | |||||
var args = new API.Rest.UploadWebhookFileParams(attachments.ToArray()) { Flags = flags, Content = text, IsTTS = isTTS, Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional<API.Embed[]>.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional<API.AllowedMentions>.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified }; | |||||
return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options).ConfigureAwait(false); | |||||
} | |||||
/// <inheritdoc/> | |||||
public override Task<RestFollowupMessage> FollowupWithFileAsync( | |||||
FileAttachment attachment, | |||||
string text = null, | |||||
Embed[] embeds = null, | |||||
bool isTTS = false, | |||||
bool ephemeral = false, | |||||
AllowedMentions allowedMentions = null, | |||||
MessageComponent components = null, | |||||
Embed embed = null, | |||||
RequestOptions options = null) | |||||
{ | |||||
return FollowupWithFilesAsync(new FileAttachment[] { attachment }, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); | |||||
} | |||||
/// <inheritdoc/> | |||||
public override string RespondWithModal(Modal modal, RequestOptions requestOptions = null) | |||||
=> throw new NotSupportedException("Modal interactions cannot have modal responces!"); | |||||
public new RestModalData Data { get; set; } | |||||
IModalInteractionData IModalInteraction.Data => Data; | |||||
} | |||||
} |
@@ -0,0 +1,45 @@ | |||||
using System.Collections.Generic; | |||||
using System.Linq; | |||||
using System; | |||||
using Model = Discord.API.ModalInteractionData; | |||||
using InterationModel = Discord.API.Interaction; | |||||
using DataModel = Discord.API.MessageComponentInteractionData; | |||||
namespace Discord.Rest | |||||
{ | |||||
/// <summary> | |||||
/// Represents data sent from a <see cref="InteractionType.ModalSubmit"/> Interaction. | |||||
/// </summary> | |||||
public class RestModalData : IComponentInteractionData, IModalInteractionData | |||||
{ | |||||
/// <inheritdoc/> | |||||
public string CustomId { get; } | |||||
/// <summary> | |||||
/// Represents the <see cref="Modal"/>s components submitted by the user. | |||||
/// </summary> | |||||
public IReadOnlyCollection<RestMessageComponentData> Components { get; } | |||||
/// <inheritdoc/> | |||||
public ComponentType Type => ComponentType.ModalSubmit; | |||||
/// <inheritdoc/> | |||||
public IReadOnlyCollection<string> Values | |||||
=> throw new NotSupportedException("Modal interactions do not have values!"); | |||||
/// <inheritdoc/> | |||||
public string Value | |||||
=> throw new NotSupportedException("Modal interactions do not have value!"); | |||||
IReadOnlyCollection<IComponentInteractionData> IModalInteractionData.Components => Components; | |||||
internal RestModalData(Model model) | |||||
{ | |||||
CustomId = model.CustomId; | |||||
Components = model.Components | |||||
.SelectMany(x => x.Components) | |||||
.Select(x => new RestMessageComponentData(x)) | |||||
.ToArray(); | |||||
} | |||||
} | |||||
} |
@@ -100,6 +100,9 @@ namespace Discord.Rest | |||||
if (model.Type == InteractionType.ApplicationCommandAutocomplete) | if (model.Type == InteractionType.ApplicationCommandAutocomplete) | ||||
return await RestAutocompleteInteraction.CreateAsync(client, model).ConfigureAwait(false); | return await RestAutocompleteInteraction.CreateAsync(client, model).ConfigureAwait(false); | ||||
if (model.Type == InteractionType.ModalSubmit) | |||||
return await RestModal.CreateAsync(client, model).ConfigureAwait(false); | |||||
return null; | return null; | ||||
} | } | ||||
@@ -181,6 +184,9 @@ namespace Discord.Rest | |||||
return RestInteractionMessage.Create(Discord, model, Token, Channel); | return RestInteractionMessage.Create(Discord, model, Token, Channel); | ||||
} | } | ||||
/// <inheritdoc/> | /// <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); | 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); | ||||
/// <summary> | /// <summary> | ||||
@@ -294,6 +300,9 @@ namespace Discord.Rest | |||||
Task IDiscordInteraction.DeferAsync(bool ephemeral, RequestOptions options) | Task IDiscordInteraction.DeferAsync(bool ephemeral, RequestOptions options) | ||||
=> Task.FromResult(Defer(ephemeral, options)); | => Task.FromResult(Defer(ephemeral, options)); | ||||
/// <inheritdoc/> | /// <inheritdoc/> | ||||
Task IDiscordInteraction.RespondWithModalAsync(Modal modal, RequestOptions options) | |||||
=> Task.FromResult(RespondWithModal(modal, options)); | |||||
/// <inheritdoc/> | |||||
async Task<IUserMessage> IDiscordInteraction.FollowupAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, | async Task<IUserMessage> IDiscordInteraction.FollowupAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, | ||||
MessageComponent components, Embed embed, RequestOptions options) | MessageComponent components, Embed embed, RequestOptions options) | ||||
=> await FollowupAsync(text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); | => await FollowupAsync(text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); | ||||
@@ -36,6 +36,7 @@ namespace Discord.Rest | |||||
} | } | ||||
public override string Defer(bool ephemeral = false, RequestOptions options = null) => throw new NotSupportedException(); | public override string Defer(bool ephemeral = false, RequestOptions options = null) => throw new NotSupportedException(); | ||||
public override string RespondWithModal(Modal modal, RequestOptions options = null) => throw new NotSupportedException(); | |||||
public override string Respond(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); | public override string Respond(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); | ||||
public override Task<RestFollowupMessage> FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); | public override Task<RestFollowupMessage> FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); | ||||
public override Task<RestFollowupMessage> FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); | public override Task<RestFollowupMessage> FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); | ||||
@@ -112,7 +112,8 @@ namespace Discord.Rest | |||||
=> throw new NotSupportedException("Autocomplete interactions don't support this method!"); | => throw new NotSupportedException("Autocomplete interactions don't support this method!"); | ||||
public override Task<RestFollowupMessage> FollowupWithFilesAsync(IEnumerable<FileAttachment> attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) | public override Task<RestFollowupMessage> FollowupWithFilesAsync(IEnumerable<FileAttachment> attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) | ||||
=> throw new NotSupportedException("Autocomplete interactions don't support this method!"); | => throw new NotSupportedException("Autocomplete interactions don't support this method!"); | ||||
public override string RespondWithModal(Modal modal, RequestOptions options = null) | |||||
=> throw new NotSupportedException("Autocomplete interactions don't support this method!"); | |||||
//IAutocompleteInteraction | //IAutocompleteInteraction | ||||
/// <inheritdoc/> | /// <inheritdoc/> | ||||
@@ -56,6 +56,13 @@ namespace Discord.Net.Converters | |||||
interaction.Data = autocompleteData; | interaction.Data = autocompleteData; | ||||
} | } | ||||
break; | break; | ||||
case InteractionType.ModalSubmit: | |||||
{ | |||||
var modalData = new API.ModalInteractionData(); | |||||
serializer.Populate(result.CreateReader(), modalData); | |||||
interaction.Data = modalData; | |||||
} | |||||
break; | |||||
} | } | ||||
} | } | ||||
else | else | ||||
@@ -32,6 +32,9 @@ namespace Discord.Net.Converters | |||||
case ComponentType.SelectMenu: | case ComponentType.SelectMenu: | ||||
messageComponent = new API.SelectMenuComponent(); | messageComponent = new API.SelectMenuComponent(); | ||||
break; | break; | ||||
case ComponentType.TextInput: | |||||
messageComponent = new API.TextInputComponent(); | |||||
break; | |||||
} | } | ||||
serializer.Populate(jsonObject.CreateReader(), messageComponent); | serializer.Populate(jsonObject.CreateReader(), messageComponent); | ||||
return messageComponent; | return messageComponent; | ||||
@@ -634,6 +634,15 @@ namespace Discord.WebSocket | |||||
remove => _autocompleteExecuted.Remove(value); | remove => _autocompleteExecuted.Remove(value); | ||||
} | } | ||||
internal readonly AsyncEvent<Func<SocketAutocompleteInteraction, Task>> _autocompleteExecuted = new AsyncEvent<Func<SocketAutocompleteInteraction, Task>>(); | internal readonly AsyncEvent<Func<SocketAutocompleteInteraction, Task>> _autocompleteExecuted = new AsyncEvent<Func<SocketAutocompleteInteraction, Task>>(); | ||||
/// <summary> | |||||
/// Fired when a modal is submitted. | |||||
/// </summary> | |||||
public event Func<SocketModal, Task> ModalSubmitted | |||||
{ | |||||
add => _modalSubmitted.Add(value); | |||||
remove => _modalSubmitted.Remove(value); | |||||
} | |||||
internal readonly AsyncEvent<Func<SocketModal, Task>> _modalSubmitted = new AsyncEvent<Func<SocketModal, Task>>(); | |||||
/// <summary> | /// <summary> | ||||
/// Fired when a guild application command is created. | /// Fired when a guild application command is created. | ||||
@@ -468,6 +468,7 @@ namespace Discord.WebSocket | |||||
client.UserCommandExecuted += (arg) => _userCommandExecuted.InvokeAsync(arg); | client.UserCommandExecuted += (arg) => _userCommandExecuted.InvokeAsync(arg); | ||||
client.MessageCommandExecuted += (arg) => _messageCommandExecuted.InvokeAsync(arg); | client.MessageCommandExecuted += (arg) => _messageCommandExecuted.InvokeAsync(arg); | ||||
client.AutocompleteExecuted += (arg) => _autocompleteExecuted.InvokeAsync(arg); | client.AutocompleteExecuted += (arg) => _autocompleteExecuted.InvokeAsync(arg); | ||||
client.ModalSubmitted += (arg) => _modalSubmitted.InvokeAsync(arg); | |||||
client.ThreadUpdated += (thread1, thread2) => _threadUpdated.InvokeAsync(thread1, thread2); | client.ThreadUpdated += (thread1, thread2) => _threadUpdated.InvokeAsync(thread1, thread2); | ||||
client.ThreadCreated += (thread) => _threadCreated.InvokeAsync(thread); | client.ThreadCreated += (thread) => _threadCreated.InvokeAsync(thread); | ||||
@@ -78,7 +78,7 @@ namespace Discord.API | |||||
if (msg != null) | if (msg != null) | ||||
{ | { | ||||
#if DEBUG_PACKETS | #if DEBUG_PACKETS | ||||
Console.WriteLine($"<- {(GatewayOpCode)msg.Operation} [{msg.Type ?? "none"}] : {(msg.Payload as Newtonsoft.Json.Linq.JToken)?.ToString().Length}"); | |||||
Console.WriteLine($"<- {(GatewayOpCode)msg.Operation} [{msg.Type ?? "none"}] : {(msg.Payload as Newtonsoft.Json.Linq.JToken)}"); | |||||
#endif | #endif | ||||
await _receivedGatewayEvent.InvokeAsync((GatewayOpCode)msg.Operation, msg.Sequence, msg.Type, msg.Payload).ConfigureAwait(false); | await _receivedGatewayEvent.InvokeAsync((GatewayOpCode)msg.Operation, msg.Sequence, msg.Type, msg.Payload).ConfigureAwait(false); | ||||
@@ -95,7 +95,7 @@ namespace Discord.API | |||||
if (msg != null) | if (msg != null) | ||||
{ | { | ||||
#if DEBUG_PACKETS | #if DEBUG_PACKETS | ||||
Console.WriteLine($"<- {(GatewayOpCode)msg.Operation} [{msg.Type ?? "none"}] : {(msg.Payload as Newtonsoft.Json.Linq.JToken)?.ToString().Length}"); | |||||
Console.WriteLine($"<- {(GatewayOpCode)msg.Operation} [{msg.Type ?? "none"}] : {(msg.Payload as Newtonsoft.Json.Linq.JToken)}"); | |||||
#endif | #endif | ||||
await _receivedGatewayEvent.InvokeAsync((GatewayOpCode)msg.Operation, msg.Sequence, msg.Type, msg.Payload).ConfigureAwait(false); | await _receivedGatewayEvent.InvokeAsync((GatewayOpCode)msg.Operation, msg.Sequence, msg.Type, msg.Payload).ConfigureAwait(false); | ||||
@@ -2274,6 +2274,9 @@ namespace Discord.WebSocket | |||||
case SocketAutocompleteInteraction autocomplete: | case SocketAutocompleteInteraction autocomplete: | ||||
await TimedInvokeAsync(_autocompleteExecuted, nameof(AutocompleteExecuted), autocomplete).ConfigureAwait(false); | await TimedInvokeAsync(_autocompleteExecuted, nameof(AutocompleteExecuted), autocomplete).ConfigureAwait(false); | ||||
break; | break; | ||||
case SocketModal modal: | |||||
await TimedInvokeAsync(_modalSubmitted, nameof(ModalSubmitted), modal).ConfigureAwait(false); | |||||
break; | |||||
} | } | ||||
} | } | ||||
break; | break; | ||||
@@ -438,6 +438,41 @@ namespace Discord.WebSocket | |||||
HasResponded = true; | HasResponded = true; | ||||
} | } | ||||
/// <inheritdoc/> | |||||
public override async Task RespondWithModalAsync(Modal modal, RequestOptions options = null) | |||||
{ | |||||
if (!IsValidToken) | |||||
throw new InvalidOperationException("Interaction token is no longer valid"); | |||||
if (!InteractionHelper.CanSendResponse(this)) | |||||
throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); | |||||
var response = new API.InteractionResponse | |||||
{ | |||||
Type = InteractionResponseType.Modal, | |||||
Data = new API.InteractionCallbackData | |||||
{ | |||||
CustomId = modal.CustomId, | |||||
Title = modal.Title, | |||||
Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() | |||||
} | |||||
}; | |||||
lock (_lock) | |||||
{ | |||||
if (HasResponded) | |||||
{ | |||||
throw new InvalidOperationException("Cannot respond twice to the same interaction"); | |||||
} | |||||
} | |||||
await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); | |||||
lock (_lock) | |||||
{ | |||||
HasResponded = true; | |||||
} | |||||
} | |||||
//IComponentInteraction | //IComponentInteraction | ||||
/// <inheritdoc/> | /// <inheritdoc/> | ||||
IComponentInteractionData IComponentInteraction.Data => Data; | IComponentInteractionData IComponentInteraction.Data => Data; | ||||
@@ -23,11 +23,31 @@ namespace Discord.WebSocket | |||||
/// </summary> | /// </summary> | ||||
public IReadOnlyCollection<string> Values { get; } | public IReadOnlyCollection<string> Values { get; } | ||||
/// <summary> | |||||
/// Gets the value of a <see cref="TextInputComponent"/> interaction response. | |||||
/// </summary> | |||||
public string Value { get; } | |||||
internal SocketMessageComponentData(Model model) | internal SocketMessageComponentData(Model model) | ||||
{ | { | ||||
CustomId = model.CustomId; | CustomId = model.CustomId; | ||||
Type = model.ComponentType; | Type = model.ComponentType; | ||||
Values = model.Values.GetValueOrDefault(); | Values = model.Values.GetValueOrDefault(); | ||||
Value = model.Value.GetValueOrDefault(); | |||||
} | |||||
internal SocketMessageComponentData(IMessageComponent component) | |||||
{ | |||||
CustomId = component.CustomId; | |||||
Type = component.Type; | |||||
Value = component.Type == ComponentType.TextInput | |||||
? (component as API.TextInputComponent).Value.Value | |||||
: null; | |||||
Values = component.Type == ComponentType.SelectMenu | |||||
? (component as API.SelectMenuComponent).Values.Value | |||||
: null; | |||||
} | } | ||||
} | } | ||||
} | } |
@@ -0,0 +1,302 @@ | |||||
using Discord.Net.Rest; | |||||
using Discord.Rest; | |||||
using System; | |||||
using System.Collections.Generic; | |||||
using System.IO; | |||||
using System.Linq; | |||||
using System.Reflection; | |||||
using System.Threading.Tasks; | |||||
using DataModel = Discord.API.ModalInteractionData; | |||||
using ModelBase = Discord.API.Interaction; | |||||
namespace Discord.WebSocket | |||||
{ | |||||
/// <summary> | |||||
/// Represents a user submitted <see cref="Discord.Modal"/> received via GateWay. | |||||
/// </summary> | |||||
public class SocketModal : SocketInteraction, IDiscordInteraction, IModalInteraction | |||||
{ | |||||
/// <summary> | |||||
/// The data for this <see cref="Modal"/> interaction. | |||||
/// </summary> | |||||
/// <value></value> | |||||
public new SocketModalData Data { get; set; } | |||||
internal SocketModal(DiscordSocketClient client, ModelBase model, ISocketMessageChannel channel) | |||||
: base(client, model.Id, channel) | |||||
{ | |||||
var dataModel = model.Data.IsSpecified | |||||
? (DataModel)model.Data.Value | |||||
: null; | |||||
Data = new SocketModalData(dataModel); | |||||
} | |||||
internal new static SocketModal Create(DiscordSocketClient client, ModelBase model, ISocketMessageChannel channel) | |||||
{ | |||||
var entity = new SocketModal(client, model, channel); | |||||
entity.Update(model); | |||||
return entity; | |||||
} | |||||
/// <inheritdoc/> | |||||
public override bool HasResponded { get; internal set; } | |||||
private object _lock = new object(); | |||||
/// <inheritdoc/> | |||||
public override async Task RespondWithFilesAsync( | |||||
IEnumerable<FileAttachment> attachments, | |||||
string text = null, | |||||
Embed[] embeds = null, | |||||
bool isTTS = false, | |||||
bool ephemeral = false, | |||||
AllowedMentions allowedMentions = null, | |||||
MessageComponent components = null, | |||||
Embed embed = null, | |||||
RequestOptions options = null) | |||||
{ | |||||
if (!IsValidToken) | |||||
throw new InvalidOperationException("Interaction token is no longer valid"); | |||||
if (!InteractionHelper.CanSendResponse(this)) | |||||
throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); | |||||
embeds ??= Array.Empty<Embed>(); | |||||
if (embed != null) | |||||
embeds = new[] { embed }.Concat(embeds).ToArray(); | |||||
Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); | |||||
Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); | |||||
Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); | |||||
// check that user flag and user Id list are exclusive, same with role flag and role Id list | |||||
if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) | |||||
{ | |||||
if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && | |||||
allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) | |||||
{ | |||||
throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); | |||||
} | |||||
if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && | |||||
allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) | |||||
{ | |||||
throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); | |||||
} | |||||
} | |||||
var response = new API.Rest.UploadInteractionFileParams(attachments?.ToArray()) | |||||
{ | |||||
Type = InteractionResponseType.ChannelMessageWithSource, | |||||
Content = text ?? Optional<string>.Unspecified, | |||||
AllowedMentions = allowedMentions != null ? allowedMentions?.ToModel() : Optional<API.AllowedMentions>.Unspecified, | |||||
Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional<API.Embed[]>.Unspecified, | |||||
IsTTS = isTTS, | |||||
MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified, | |||||
Flags = ephemeral ? MessageFlags.Ephemeral : Optional<MessageFlags>.Unspecified | |||||
}; | |||||
lock (_lock) | |||||
{ | |||||
if (HasResponded) | |||||
{ | |||||
throw new InvalidOperationException("Cannot respond, update, or defer the same interaction twice"); | |||||
} | |||||
} | |||||
await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); | |||||
HasResponded = true; | |||||
} | |||||
/// <inheritdoc/> | |||||
public override async Task RespondAsync( | |||||
string text = null, | |||||
Embed[] embeds = null, | |||||
bool isTTS = false, | |||||
bool ephemeral = false, | |||||
AllowedMentions allowedMentions = null, | |||||
MessageComponent components = null, | |||||
Embed embed = null, | |||||
RequestOptions options = null) | |||||
{ | |||||
if (!IsValidToken) | |||||
throw new InvalidOperationException("Interaction token is no longer valid"); | |||||
if (!InteractionHelper.CanSendResponse(this)) | |||||
throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); | |||||
embeds ??= Array.Empty<Embed>(); | |||||
if (embed != null) | |||||
embeds = new[] { embed }.Concat(embeds).ToArray(); | |||||
Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); | |||||
Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); | |||||
Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); | |||||
// check that user flag and user Id list are exclusive, same with role flag and role Id list | |||||
if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) | |||||
{ | |||||
if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && | |||||
allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) | |||||
{ | |||||
throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); | |||||
} | |||||
if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && | |||||
allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) | |||||
{ | |||||
throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); | |||||
} | |||||
} | |||||
var response = new API.InteractionResponse | |||||
{ | |||||
Type = InteractionResponseType.ChannelMessageWithSource, | |||||
Data = new API.InteractionCallbackData | |||||
{ | |||||
Content = text ?? Optional<string>.Unspecified, | |||||
AllowedMentions = allowedMentions?.ToModel(), | |||||
Embeds = embeds.Select(x => x.ToModel()).ToArray(), | |||||
TTS = isTTS, | |||||
Flags = ephemeral ? MessageFlags.Ephemeral : Optional<MessageFlags>.Unspecified, | |||||
Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified | |||||
} | |||||
}; | |||||
lock (_lock) | |||||
{ | |||||
if (HasResponded) | |||||
{ | |||||
throw new InvalidOperationException("Cannot respond, update, or defer twice to the same interaction"); | |||||
} | |||||
} | |||||
await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); | |||||
HasResponded = true; | |||||
} | |||||
/// <inheritdoc/> | |||||
public override async Task<RestFollowupMessage> FollowupAsync( | |||||
string text = null, | |||||
Embed[] embeds = null, | |||||
bool isTTS = false, | |||||
bool ephemeral = false, | |||||
AllowedMentions allowedMentions = null, | |||||
MessageComponent components = null, | |||||
Embed embed = null, | |||||
RequestOptions options = null) | |||||
{ | |||||
if (!IsValidToken) | |||||
throw new InvalidOperationException("Interaction token is no longer valid"); | |||||
embeds ??= Array.Empty<Embed>(); | |||||
if (embed != null) | |||||
embeds = new[] { embed }.Concat(embeds).ToArray(); | |||||
Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); | |||||
Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); | |||||
Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); | |||||
var args = new API.Rest.CreateWebhookMessageParams | |||||
{ | |||||
Content = text, | |||||
AllowedMentions = allowedMentions?.ToModel() ?? Optional<API.AllowedMentions>.Unspecified, | |||||
IsTTS = isTTS, | |||||
Embeds = embeds.Select(x => x.ToModel()).ToArray(), | |||||
Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified | |||||
}; | |||||
if (ephemeral) | |||||
args.Flags = MessageFlags.Ephemeral; | |||||
return await InteractionHelper.SendFollowupAsync(Discord.Rest, args, Token, Channel, options); | |||||
} | |||||
/// <inheritdoc/> | |||||
public override async Task<RestFollowupMessage> FollowupWithFilesAsync( | |||||
IEnumerable<FileAttachment> attachments, | |||||
string text = null, | |||||
Embed[] embeds = null, | |||||
bool isTTS = false, | |||||
bool ephemeral = false, | |||||
AllowedMentions allowedMentions = null, | |||||
MessageComponent components = null, | |||||
Embed embed = null, | |||||
RequestOptions options = null) | |||||
{ | |||||
if (!IsValidToken) | |||||
throw new InvalidOperationException("Interaction token is no longer valid"); | |||||
embeds ??= Array.Empty<Embed>(); | |||||
if (embed != null) | |||||
embeds = new[] { embed }.Concat(embeds).ToArray(); | |||||
Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); | |||||
Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); | |||||
Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); | |||||
foreach (var attachment in attachments) | |||||
{ | |||||
Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); | |||||
} | |||||
// check that user flag and user Id list are exclusive, same with role flag and role Id list | |||||
if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) | |||||
{ | |||||
if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && | |||||
allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) | |||||
{ | |||||
throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); | |||||
} | |||||
if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && | |||||
allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) | |||||
{ | |||||
throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); | |||||
} | |||||
} | |||||
var flags = MessageFlags.None; | |||||
if (ephemeral) | |||||
flags |= MessageFlags.Ephemeral; | |||||
var args = new API.Rest.UploadWebhookFileParams(attachments.ToArray()) { Flags = flags, Content = text, IsTTS = isTTS, Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional<API.Embed[]>.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional<API.AllowedMentions>.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional<API.ActionRowComponent[]>.Unspecified }; | |||||
return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options).ConfigureAwait(false); | |||||
} | |||||
/// <inheritdoc/> | |||||
public override async Task DeferAsync(bool ephemeral = false, RequestOptions options = null) | |||||
{ | |||||
if (!InteractionHelper.CanSendResponse(this)) | |||||
throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds of no response/acknowledgement"); | |||||
var response = new API.InteractionResponse | |||||
{ | |||||
Type = InteractionResponseType.DeferredUpdateMessage, | |||||
Data = ephemeral ? new API.InteractionCallbackData { Flags = MessageFlags.Ephemeral } : Optional<API.InteractionCallbackData>.Unspecified | |||||
}; | |||||
lock (_lock) | |||||
{ | |||||
if (HasResponded) | |||||
{ | |||||
throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); | |||||
} | |||||
} | |||||
await Discord.Rest.ApiClient.CreateInteractionResponseAsync(response, Id, Token, options).ConfigureAwait(false); | |||||
lock (_lock) | |||||
{ | |||||
HasResponded = true; | |||||
} | |||||
} | |||||
/// <inheritdoc/> | |||||
public override Task RespondWithModalAsync(Modal modal, RequestOptions options = null) | |||||
=> throw new NotSupportedException("You cannot respond to a modal with a modal!"); | |||||
IModalInteractionData IModalInteraction.Data => Data; | |||||
} | |||||
} |
@@ -0,0 +1,36 @@ | |||||
using System.Collections.Generic; | |||||
using System.Linq; | |||||
using System; | |||||
using Model = Discord.API.ModalInteractionData; | |||||
using InterationModel = Discord.API.Interaction; | |||||
using DataModel = Discord.API.MessageComponentInteractionData; | |||||
namespace Discord.WebSocket | |||||
{ | |||||
/// <summary> | |||||
/// Represents data sent from a <see cref="InteractionType.ModalSubmit"/>. | |||||
/// </summary> | |||||
public class SocketModalData : IDiscordInteractionData, IModalInteractionData | |||||
{ | |||||
/// <summary> | |||||
/// Gets the <see cref="Modal"/>'s Custom Id. | |||||
/// </summary> | |||||
public string CustomId { get; } | |||||
/// <summary> | |||||
/// Gets the <see cref="Modal"/>'s components submitted by the user. | |||||
/// </summary> | |||||
public IReadOnlyCollection<SocketMessageComponentData> Components { get; } | |||||
internal SocketModalData(Model model) | |||||
{ | |||||
CustomId = model.CustomId; | |||||
Components = model.Components | |||||
.SelectMany(x => x.Components) | |||||
.Select(x => new SocketMessageComponentData(x)) | |||||
.ToArray(); | |||||
} | |||||
IReadOnlyCollection<IComponentInteractionData> IModalInteractionData.Components => Components; | |||||
} | |||||
} |
@@ -100,6 +100,10 @@ namespace Discord.WebSocket | |||||
public override Task RespondWithFilesAsync(IEnumerable<FileAttachment> attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) | public override Task RespondWithFilesAsync(IEnumerable<FileAttachment> attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) | ||||
=> throw new NotSupportedException("Autocomplete interactions don't support this method!"); | => throw new NotSupportedException("Autocomplete interactions don't support this method!"); | ||||
/// <inheritdoc/> | |||||
public override Task RespondWithModalAsync(Modal modal, RequestOptions requestOptions = null) | |||||
=> throw new NotSupportedException("Autocomplete interactions cannot have normal responces!"); | |||||
//IAutocompleteInteraction | //IAutocompleteInteraction | ||||
/// <inheritdoc/> | /// <inheritdoc/> | ||||
IAutocompleteInteractionData IAutocompleteInteraction.Data => Data; | IAutocompleteInteractionData IAutocompleteInteraction.Data => Data; | ||||
@@ -1,4 +1,3 @@ | |||||
using Discord.Net.Rest; | |||||
using Discord.Rest; | using Discord.Rest; | ||||
using System; | using System; | ||||
using System.Collections.Generic; | using System.Collections.Generic; | ||||
@@ -135,6 +134,42 @@ namespace Discord.WebSocket | |||||
HasResponded = true; | HasResponded = true; | ||||
} | } | ||||
/// <inheritdoc/> | |||||
public override async Task RespondWithModalAsync(Modal modal, RequestOptions options = null) | |||||
{ | |||||
if (!IsValidToken) | |||||
throw new InvalidOperationException("Interaction token is no longer valid"); | |||||
if (!InteractionHelper.CanSendResponse(this)) | |||||
throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); | |||||
var response = new API.InteractionResponse | |||||
{ | |||||
Type = InteractionResponseType.Modal, | |||||
Data = new API.InteractionCallbackData | |||||
{ | |||||
CustomId = modal.CustomId, | |||||
Title = modal.Title, | |||||
Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() | |||||
} | |||||
}; | |||||
lock (_lock) | |||||
{ | |||||
if (HasResponded) | |||||
{ | |||||
throw new InvalidOperationException("Cannot respond twice to the same interaction"); | |||||
} | |||||
} | |||||
await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); | |||||
lock (_lock) | |||||
{ | |||||
HasResponded = true; | |||||
} | |||||
} | |||||
public override async Task RespondWithFilesAsync( | public override async Task RespondWithFilesAsync( | ||||
IEnumerable<FileAttachment> attachments, | IEnumerable<FileAttachment> attachments, | ||||
string text = null, | string text = null, | ||||
@@ -108,6 +108,9 @@ namespace Discord.WebSocket | |||||
if (model.Type == InteractionType.ApplicationCommandAutocomplete) | if (model.Type == InteractionType.ApplicationCommandAutocomplete) | ||||
return SocketAutocompleteInteraction.Create(client, model, channel); | return SocketAutocompleteInteraction.Create(client, model, channel); | ||||
if (model.Type == InteractionType.ModalSubmit) | |||||
return SocketModal.Create(client, model, channel); | |||||
return null; | return null; | ||||
} | } | ||||
@@ -387,6 +390,13 @@ namespace Discord.WebSocket | |||||
/// </returns> | /// </returns> | ||||
public abstract Task DeferAsync(bool ephemeral = false, RequestOptions options = null); | public abstract Task DeferAsync(bool ephemeral = false, RequestOptions options = null); | ||||
/// <summary> | |||||
/// Responds to this interaction with a <see cref="Modal"/>. | |||||
/// </summary> | |||||
/// <param name="modal">The <see cref="Modal"/> to respond with.</param> | |||||
/// <param name="options">The request options for this <see langword="async"/> request.</param> | |||||
/// <returns>A task that represents the asynchronous operation of responding to the interaction.</returns> | |||||
public abstract Task RespondWithModalAsync(Modal modal, RequestOptions options = null); | |||||
#endregion | #endregion | ||||
#region IDiscordInteraction | #region IDiscordInteraction | ||||