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