Browse Source

Feature: Implement modals (#2087)

* 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
Quin Lynch GitHub 3 years ago
parent
commit
c8f175e11a
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 3502 additions and 25 deletions
  1. +2
    -2
      Discord.Net.code-workspace
  2. BIN
      docs/guides/int_basics/message-components/images/image7.png
  3. BIN
      docs/guides/int_basics/message-components/images/image8.png
  4. BIN
      docs/guides/int_basics/message-components/images/image9.png
  5. +46
    -0
      docs/guides/int_basics/message-components/text-input.md
  6. BIN
      docs/guides/int_basics/modals/images/image1.png
  7. BIN
      docs/guides/int_basics/modals/images/image2.png
  8. BIN
      docs/guides/int_basics/modals/images/image3.png
  9. BIN
      docs/guides/int_basics/modals/images/image4.png
  10. +135
    -0
      docs/guides/int_basics/modals/intro.md
  11. +12
    -0
      docs/guides/int_framework/intro.md
  12. +36
    -0
      docs/guides/int_framework/samples/intro/modal.cs
  13. +6
    -0
      docs/guides/toc.yml
  14. +8
    -0
      src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs
  15. +6
    -1
      src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs
  16. +6
    -1
      src/Discord.Net.Core/Entities/Interactions/InteractionType.cs
  17. +249
    -0
      src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs
  18. +11
    -1
      src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentType.cs
  19. +6
    -1
      src/Discord.Net.Core/Entities/Interactions/MessageComponents/IComponentInteractionData.cs
  20. +62
    -0
      src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputComponent.cs
  21. +14
    -0
      src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputStyle.cs
  22. +13
    -0
      src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteraction.cs
  23. +20
    -0
      src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteractionData.cs
  24. +37
    -0
      src/Discord.Net.Core/Entities/Interactions/Modals/Modal.cs
  25. +268
    -0
      src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs
  26. +20
    -0
      src/Discord.Net.Core/Entities/Interactions/Modals/ModalComponent.cs
  27. +44
    -0
      src/Discord.Net.Interactions/Attributes/Commands/ModalInteractionAttribute.cs
  28. +25
    -0
      src/Discord.Net.Interactions/Attributes/Modals/InputLabelAttribute.cs
  29. +32
    -0
      src/Discord.Net.Interactions/Attributes/Modals/ModalInputAttribute.cs
  30. +55
    -0
      src/Discord.Net.Interactions/Attributes/Modals/ModalTextInputAttribute.cs
  31. +25
    -0
      src/Discord.Net.Interactions/Attributes/Modals/RequiredInputAttribute.cs
  32. +44
    -0
      src/Discord.Net.Interactions/Builders/Commands/ModalCommandBuilder.cs
  33. +105
    -0
      src/Discord.Net.Interactions/Builders/Modals/Inputs/IInputComponentBuilder.cs
  34. +164
    -0
      src/Discord.Net.Interactions/Builders/Modals/Inputs/InputComponentBuilder.cs
  35. +109
    -0
      src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs
  36. +81
    -0
      src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs
  37. +23
    -1
      src/Discord.Net.Interactions/Builders/ModuleBuilder.cs
  38. +142
    -1
      src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs
  39. +45
    -0
      src/Discord.Net.Interactions/Builders/Parameters/ModalCommandParameterBuilder.cs
  40. +13
    -0
      src/Discord.Net.Interactions/Entities/IModal.cs
  41. +37
    -0
      src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs
  42. +1
    -1
      src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs
  43. +81
    -0
      src/Discord.Net.Interactions/Info/Commands/ModalCommandInfo.cs
  44. +64
    -0
      src/Discord.Net.Interactions/Info/InputComponents/InputComponentInfo.cs
  45. +42
    -0
      src/Discord.Net.Interactions/Info/InputComponents/TextInputComponentInfo.cs
  46. +90
    -0
      src/Discord.Net.Interactions/Info/ModalInfo.cs
  47. +13
    -0
      src/Discord.Net.Interactions/Info/ModuleInfo.cs
  48. +28
    -0
      src/Discord.Net.Interactions/Info/Parameters/ModalCommandParameterInfo.cs
  49. +7
    -0
      src/Discord.Net.Interactions/InteractionModuleBase.cs
  50. +58
    -2
      src/Discord.Net.Interactions/InteractionService.cs
  51. +8
    -0
      src/Discord.Net.Interactions/InteractionServiceConfig.cs
  52. +51
    -0
      src/Discord.Net.Interactions/Utilities/ModalUtils.cs
  53. +62
    -10
      src/Discord.Net.Interactions/Utilities/ReflectionUtils.cs
  54. +1
    -0
      src/Discord.Net.Rest/API/Common/ActionRowComponent.cs
  55. +6
    -0
      src/Discord.Net.Rest/API/Common/InteractionCallbackData.cs
  56. +3
    -0
      src/Discord.Net.Rest/API/Common/MessageComponentInteractionData.cs
  57. +13
    -0
      src/Discord.Net.Rest/API/Common/ModalInteractionData.cs
  58. +2
    -0
      src/Discord.Net.Rest/API/Common/SelectMenuComponent.cs
  59. +49
    -0
      src/Discord.Net.Rest/API/Common/TextInputComponent.cs
  60. +40
    -0
      src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs
  61. +40
    -0
      src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs
  62. +15
    -0
      src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponentData.cs
  63. +402
    -0
      src/Discord.Net.Rest/Entities/Interactions/Modals/RestModal.cs
  64. +45
    -0
      src/Discord.Net.Rest/Entities/Interactions/Modals/RestModalData.cs
  65. +9
    -0
      src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs
  66. +1
    -0
      src/Discord.Net.Rest/Entities/Interactions/RestPingInteraction.cs
  67. +2
    -1
      src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteraction.cs
  68. +7
    -0
      src/Discord.Net.Rest/Net/Converters/InteractionConverter.cs
  69. +3
    -0
      src/Discord.Net.Rest/Net/Converters/MessageComponentConverter.cs
  70. +9
    -0
      src/Discord.Net.WebSocket/BaseSocketClient.Events.cs
  71. +1
    -0
      src/Discord.Net.WebSocket/DiscordShardedClient.cs
  72. +2
    -2
      src/Discord.Net.WebSocket/DiscordSocketApiClient.cs
  73. +3
    -0
      src/Discord.Net.WebSocket/DiscordSocketClient.cs
  74. +35
    -0
      src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs
  75. +20
    -0
      src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponentData.cs
  76. +302
    -0
      src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModal.cs
  77. +36
    -0
      src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModalData.cs
  78. +4
    -0
      src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteraction.cs
  79. +36
    -1
      src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs
  80. +10
    -0
      src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs

+ 2
- 2
Discord.Net.code-workspace View File

@@ -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,
}
}
}
}

BIN
docs/guides/int_basics/message-components/images/image7.png View File

Before After
Width: 392  |  Height: 364  |  Size: 20 KiB

BIN
docs/guides/int_basics/message-components/images/image8.png View File

Before After
Width: 430  |  Height: 81  |  Size: 1.8 KiB

BIN
docs/guides/int_basics/message-components/images/image9.png View File

Before After
Width: 425  |  Height: 283  |  Size: 11 KiB

+ 46
- 0
docs/guides/int_basics/message-components/text-input.md View File

@@ -0,0 +1,46 @@
---
uid: Guides.MessageComponents.TextInputs
title: Text Input Components
---

# Text Input Components

> [!WARNING]
> Text input components can only be used in
> [modals](../modals/intro.md).

Text input components are a type of MessageComponents that can only be
used in modals. Texts inputs can be longer (the `Paragraph`) style or
shorter (the `Short` style). Text inputs have a variable min and max
length.

![A modal with short and paragraph text inputs](images/image7.png)

## Creating text inputs
Text input components can be built using the `TextInputBuilder`.
The simplest text input can built with:
```cs
var tb = new TextInputBuilder()
.WithLabel("My Text")
.WithCustomId("text_input");
```

and would produce a component that looks like:

![basic text input component](images/image8.png)

Additional options can be specified to control the placeholder, style,
and min/max length of the input:
```cs
var tb = new TextInputBuilder()
.WithLabel("Labeled")
.WithCustomId("text_input")
.WithStyle(TextInputStyle.Paragraph)
.WithMinLength(6);
.WithMaxLength(42)
.WithRequired(true)
.WithPlaceholder("Consider this place held.");
```

![more advanced text input](images/image9.png)


BIN
docs/guides/int_basics/modals/images/image1.png View File

Before After
Width: 424  |  Height: 422  |  Size: 35 KiB

BIN
docs/guides/int_basics/modals/images/image2.png View File

Before After
Width: 365  |  Height: 404  |  Size: 25 KiB

BIN
docs/guides/int_basics/modals/images/image3.png View File

Before After
Width: 440  |  Height: 410  |  Size: 22 KiB

BIN
docs/guides/int_basics/modals/images/image4.png View File

Before After
Width: 740  |  Height: 70  |  Size: 23 KiB

+ 135
- 0
docs/guides/int_basics/modals/intro.md View File

@@ -0,0 +1,135 @@
---
uid: Guides.Modals.Intro
title: Getting Started with Modals
---
# Modals

## Getting started with modals
This guide will show you how to use modals and give a few examples of
valid use cases. If your question is not covered by this guide ask in the
[Discord.Net Discord Server](https://discord.gg/dnet).

### What is a modal?
Modals are forms bots can send when responding to interactions. Modals
are sent to Discord as an array of message components and converted
into the form layout by user's clients. Modals are required to have a
custom id, title, and at least one component.

![Screenshot of a modal](images/image2.png)

When users submit modals, your client fires the ModalSubmitted event.
You can get the components of the modal from the `Data.Components` property
on the SocketModal:

![Screenshot of modal data](images/image1.png)

### Using modals

Lets create a simple modal with an entry field for users to
tell us their favorite food. We can start by creating a slash
command that will respond with the modal.
```cs
[SlashCommand("food", "Tell us about your favorite food!")]
public async Task FoodPreference()
{
// send a modal
}
```

Now that we have our command set up, we need to build a modal.
We can use the aptly named `ModalBuilder` for that:

| Method | Description |
| --------------- | ----------------------------------------- |
| `WithTitle` | Sets the modal's title. |
| `WithCustomId` | Sets the modal's custom id. |
| `AddTextInput` | Adds a `TextInputBuilder` to the modal. |
| `AddComponents` | Adds multiple components to the modal. |
| `Build` | Builds the `ModalBuilder` into a `Modal`. |

We know we need to add a text input to the modal, so let's look at that
method's parameters.

| Parameter | Description |
| ------------- | ------------------------------------------ |
| `label` | Sets the input's label. |
| `customId` | Sets the input's custom id. |
| `style` | Sets the input's style. |
| `placeholder` | Sets the input's placeholder. |
| `minLength` | Sets the minimum input length. |
| `maxLength` | Sets the maximum input length. |
| `required` | Sets whether or not the modal is required. |
| `value` | Sets the input's default value. |

To make a basic text input we would only need to set the `label` and
`customId`, but in this example we will also use the `placeholder`
parameter. Next we can build our modal:

```cs
var mb = new ModalBuilder()
.WithTitle("Fav Food")
.WithCustomId("food_menu")
.AddTextInput("What??", "food_name", placeholder:"Pizza")
.AddTextInput("Why??", "food_reason", TextInputStyle.Paragraph,
"Kus it's so tasty");
```

Now that we have a ModalBuilder we can update our command to respond
with the modal.

```cs
[SlashCommand("food", "Tell us about your favorite food!")]
public async Task FoodPreference()
{
var mb = new ModalBuilder()
.WithTitle("Fav Food")
.WithCustomId("food_menu")
.AddTextInput("What??", "food_name", placeholder:"Pizza")
.AddTextInput("Why??", "food_reason", TextInputStyle.Paragraph,
"Kus it's so tasty");
await Context.Interaction.RespondWithModalAsync(mb.Build());
}
```

When we run the command, our modal should pop up:

![screenshot of the above modal](images/image3.png)

### Respond to modals

> [!WARNING]
> Modals can not be sent when respoding to a modal.

Once a user has submitted the modal, we need to let everyone know what
their favorite food is. We can start by hooking a task to the client's
`ModalSubmitted` event.
```cs
_client.ModalSubmitted += async modal =>
{
// Get the values of components.
List<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.

![Response of the modal submitted event](images/image4.png)

+ 12
- 0
docs/guides/int_framework/intro.md View File

@@ -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.


+ 36
- 0
docs/guides/int_framework/samples/intro/modal.cs View File

@@ -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);
}

+ 6
- 0
docs/guides/toc.yml View File

@@ -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


+ 8
- 0
src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs View File

@@ -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);
}
}

+ 6
- 1
src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs View File

@@ -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,
}
}

+ 6
- 1
src/Discord.Net.Core/Entities/Interactions/InteractionType.cs View File

@@ -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,
}
}

+ 249
- 0
src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs View File

@@ -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);
}
}
}

+ 11
- 1
src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentType.cs View File

@@ -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,
}
}

+ 6
- 1
src/Discord.Net.Core/Entities/Interactions/MessageComponents/IComponentInteractionData.cs View File

@@ -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; }
}
}

+ 62
- 0
src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputComponent.cs View File

@@ -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;
}
}
}

+ 14
- 0
src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputStyle.cs View File

@@ -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,
}
}

+ 13
- 0
src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteraction.cs View File

@@ -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; }
}
}

+ 20
- 0
src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteractionData.cs View File

@@ -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; }
}
}

+ 37
- 0
src/Discord.Net.Core/Entities/Interactions/Modals/Modal.cs View File

@@ -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;
}
}
}

+ 268
- 0
src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs View File

@@ -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());
}
}

+ 20
- 0
src/Discord.Net.Core/Entities/Interactions/Modals/ModalComponent.cs View File

@@ -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;
}
}
}

+ 44
- 0
src/Discord.Net.Interactions/Attributes/Commands/ModalInteractionAttribute.cs View File

@@ -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;
}
}
}

+ 25
- 0
src/Discord.Net.Interactions/Attributes/Modals/InputLabelAttribute.cs View File

@@ -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;
}
}
}

+ 32
- 0
src/Discord.Net.Interactions/Attributes/Modals/ModalInputAttribute.cs View File

@@ -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;
}
}
}

+ 55
- 0
src/Discord.Net.Interactions/Attributes/Modals/ModalTextInputAttribute.cs View File

@@ -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;
}
}
}

+ 25
- 0
src/Discord.Net.Interactions/Attributes/Modals/RequiredInputAttribute.cs View File

@@ -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;
}
}
}

+ 44
- 0
src/Discord.Net.Interactions/Builders/Commands/ModalCommandBuilder.cs View File

@@ -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);
}
}

+ 105
- 0
src/Discord.Net.Interactions/Builders/Modals/Inputs/IInputComponentBuilder.cs View File

@@ -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);
}
}

+ 164
- 0
src/Discord.Net.Interactions/Builders/Modals/Inputs/InputComponentBuilder.cs View File

@@ -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);
}
}

+ 109
- 0
src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs View File

@@ -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);
}
}

+ 81
- 0
src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs View File

@@ -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);
}
}

+ 23
- 1
src/Discord.Net.Interactions/Builders/ModuleBuilder.cs View File

@@ -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>


+ 142
- 1
src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs View File

@@ -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));
}
}
}

+ 45
- 0
src/Discord.Net.Interactions/Builders/Parameters/ModalCommandParameterBuilder.cs View File

@@ -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);
}
}

+ 13
- 0
src/Discord.Net.Interactions/Entities/IModal.cs View File

@@ -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; }
}
}

+ 37
- 0
src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs View File

@@ -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);
}
}
}

+ 1
- 1
src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs View File

@@ -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)
{


+ 81
- 0
src/Discord.Net.Interactions/Info/Commands/ModalCommandInfo.cs View File

@@ -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}";
}
}
}

+ 64
- 0
src/Discord.Net.Interactions/Info/InputComponents/InputComponentInfo.cs View File

@@ -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();
}
}
}

+ 42
- 0
src/Discord.Net.Interactions/Info/InputComponents/TextInputComponentInfo.cs View File

@@ -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;
}
}
}

+ 90
- 0
src/Discord.Net.Interactions/Info/ModalInfo.cs View File

@@ -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);
}
}
}

+ 13
- 0
src/Discord.Net.Interactions/Info/ModuleInfo.cs View File

@@ -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>();


+ 28
- 0
src/Discord.Net.Interactions/Info/Parameters/ModalCommandParameterInfo.cs View File

@@ -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;
}
}
}

+ 7
- 0
src/Discord.Net.Interactions/InteractionModuleBase.cs View File

@@ -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



+ 58
- 2
src/Discord.Net.Interactions/InteractionService.cs View File

@@ -53,21 +53,29 @@ namespace Discord.Interactions
public event Func<IAutocompleteHandler, IInteractionContext, IResult, Task> AutocompleteHandlerExecuted { add { _autocompleteHandlerExecutedEvent.Add(value); } remove { _autocompleteHandlerExecutedEvent.Remove(value); } }
internal readonly AsyncEvent<Func<IAutocompleteHandler, IInteractionContext, IResult, Task>> _autocompleteHandlerExecutedEvent = new();

/// <summary>
/// Occurs when a Modal command is executed.
/// </summary>
public event Func<ModalCommandInfo, IInteractionContext, IResult, Task> ModalCommandExecuted { add { _modalCommandExecutedEvent.Add(value); } remove { _modalCommandExecutedEvent.Remove(value); } }
internal readonly AsyncEvent<Func<ModalCommandInfo, IInteractionContext, IResult, Task>> _modalCommandExecutedEvent = new();

private readonly ConcurrentDictionary<Type, ModuleInfo> _typedModuleDefs;
private readonly CommandMap<SlashCommandInfo> _slashCommandMap;
private readonly ConcurrentDictionary<ApplicationCommandType, CommandMap<ContextCommandInfo>> _contextCommandMaps;
private readonly CommandMap<ComponentCommandInfo> _componentCommandMap;
private readonly CommandMap<AutocompleteCommandInfo> _autocompleteCommandMap;
private readonly CommandMap<ModalCommandInfo> _modalCommandMap;
private readonly HashSet<ModuleInfo> _moduleDefs;
private readonly ConcurrentDictionary<Type, TypeConverter> _typeConverters;
private readonly ConcurrentDictionary<Type, Type> _genericTypeConverters;
private readonly ConcurrentDictionary<Type, IAutocompleteHandler> _autocompleteHandlers = new();
private readonly ConcurrentDictionary<Type, ModalInfo> _modalInfos = new();
private readonly SemaphoreSlim _lock;
internal readonly Logger _cmdLogger;
internal readonly LogManager _logManager;
internal readonly Func<DiscordRestClient> _getRestClient;

internal readonly bool _throwOnError, _useCompiledLambda, _enableAutocompleteHandlers, _autoServiceScopes;
internal readonly bool _throwOnError, _useCompiledLambda, _enableAutocompleteHandlers, _autoServiceScopes, _exitOnMissingModalField;
internal readonly string _wildCardExp;
internal readonly RunMode _runMode;
internal readonly RestResponseCallback _restResponseCallback;
@@ -97,6 +105,16 @@ namespace Discord.Interactions
/// </summary>
public IReadOnlyCollection<ComponentCommandInfo> ComponentCommands => _moduleDefs.SelectMany(x => x.ComponentCommands).ToList();

/// <summary>
/// Represents all Modal Commands loaded within <see cref="InteractionService"/>.
/// </summary>
public IReadOnlyCollection<ModalCommandInfo> ModalCommands => _moduleDefs.SelectMany(x => x.ModalCommands).ToList();

/// <summary>
/// Gets a collection of the cached <see cref="ModalInfo"/> classes that are referenced in registered <see cref="ModalCommandInfo"/>s.
/// </summary>
public IReadOnlyCollection<ModalInfo> Modals => ModalUtils.Modals;

/// <summary>
/// Initialize a <see cref="InteractionService"/> with provided configurations.
/// </summary>
@@ -145,6 +163,7 @@ namespace Discord.Interactions
_contextCommandMaps = new ConcurrentDictionary<ApplicationCommandType, CommandMap<ContextCommandInfo>>();
_componentCommandMap = new CommandMap<ComponentCommandInfo>(this, config.InteractionCustomIdDelimiters);
_autocompleteCommandMap = new CommandMap<AutocompleteCommandInfo>(this);
_modalCommandMap = new CommandMap<ModalCommandInfo>(this, config.InteractionCustomIdDelimiters);

_getRestClient = getRestClient;

@@ -155,6 +174,7 @@ namespace Discord.Interactions
_throwOnError = config.ThrowOnError;
_wildCardExp = config.WildCardExpression;
_useCompiledLambda = config.UseCompiledLambda;
_exitOnMissingModalField = config.ExitOnMissingModalField;
_enableAutocompleteHandlers = config.EnableAutocompleteHandlers;
_autoServiceScopes = config.AutoServiceScopes;
_restResponseCallback = config.RestResponseCallback;
@@ -509,6 +529,9 @@ namespace Discord.Interactions
foreach (var command in module.AutocompleteCommands)
_autocompleteCommandMap.AddCommand(command.GetCommandKeywords(), command);

foreach (var command in module.ModalCommands)
_modalCommandMap.AddCommand(command, command.IgnoreGroupNames);

foreach (var subModule in module.SubModules)
LoadModuleInternal(subModule);
}
@@ -654,7 +677,7 @@ namespace Discord.Interactions
public async Task<IResult> ExecuteCommandAsync (IInteractionContext context, IServiceProvider services)
{
var interaction = context.Interaction;
return interaction switch
{
ISlashCommandInteraction slashCommand => await ExecuteSlashCommandAsync(context, slashCommand, services).ConfigureAwait(false),
@@ -662,6 +685,7 @@ namespace Discord.Interactions
IUserCommandInteraction userCommand => await ExecuteContextCommandAsync(context, userCommand.Data.Name, ApplicationCommandType.User, services).ConfigureAwait(false),
IMessageCommandInteraction messageCommand => await ExecuteContextCommandAsync(context, messageCommand.Data.Name, ApplicationCommandType.Message, services).ConfigureAwait(false),
IAutocompleteInteraction autocomplete => await ExecuteAutocompleteAsync(context, autocomplete, services).ConfigureAwait(false),
IModalInteraction modalCommand => await ExecuteModalCommandAsync(context, modalCommand.Data.CustomId, services).ConfigureAwait(false),
_ => throw new InvalidOperationException($"{interaction.Type} interaction type cannot be executed by the Interaction service"),
};
}
@@ -745,6 +769,20 @@ namespace Discord.Interactions
return await commandResult.Command.ExecuteAsync(context, services).ConfigureAwait(false);
}

private async Task<IResult> ExecuteModalCommandAsync(IInteractionContext context, string input, IServiceProvider services)
{
var result = _modalCommandMap.GetCommand(input);

if (!result.IsSuccess)
{
await _cmdLogger.DebugAsync($"Unknown custom interaction id, skipping execution ({input.ToUpper()})");

await _componentCommandExecutedEvent.InvokeAsync(null, context, result).ConfigureAwait(false);
return result;
}
return await result.Command.ExecuteAsync(context, services, result.RegexCaptureGroups).ConfigureAwait(false);
}

internal TypeConverter GetTypeConverter (Type type, IServiceProvider services = null)
{
if (_typeConverters.TryGetValue(type, out var specific))
@@ -819,6 +857,24 @@ namespace Discord.Interactions
_genericTypeConverters[targetType] = converterType;
}

/// <summary>
/// Loads and caches an <see cref="ModalInfo"/> for the provided <see cref="IModal"/>.
/// </summary>
/// <typeparam name="T">Type of <see cref="IModal"/> to be loaded.</typeparam>
/// <returns>
/// The built <see cref="ModalInfo"/> instance.
/// </returns>
/// <exception cref="InvalidOperationException"></exception>
public ModalInfo AddModalInfo<T>() where T : class, IModal
{
var type = typeof(T);

if (_modalInfos.ContainsKey(type))
throw new InvalidOperationException($"Modal type {type.FullName} already exists.");

return ModalUtils.GetOrAdd(type);
}

internal IAutocompleteHandler GetAutocompleteHandler(Type autocompleteHandlerType, IServiceProvider services = null)
{
services ??= EmptyServiceProvider.Instance;


+ 8
- 0
src/Discord.Net.Interactions/InteractionServiceConfig.cs View File

@@ -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>


+ 51
- 0
src/Discord.Net.Interactions/Utilities/ModalUtils.cs View File

@@ -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;
}
}

+ 62
- 10
src/Discord.Net.Interactions/Utilities/ReflectionUtils.cs View File

@@ -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;


+ 1
- 0
src/Discord.Net.Rest/API/Common/ActionRowComponent.cs View File

@@ -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();


+ 6
- 0
src/Discord.Net.Rest/API/Common/InteractionCallbackData.cs View File

@@ -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; }
}
}

+ 3
- 0
src/Discord.Net.Rest/API/Common/MessageComponentInteractionData.cs View File

@@ -12,5 +12,8 @@ namespace Discord.API

[JsonProperty("values")]
public Optional<string[]> Values { get; set; }

[JsonProperty("value")]
public Optional<string> Value { get; set; }
}
}

+ 13
- 0
src/Discord.Net.Rest/API/Common/ModalInteractionData.cs View File

@@ -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; }
}
}

+ 2
- 0
src/Discord.Net.Rest/API/Common/SelectMenuComponent.cs View File

@@ -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)


+ 49
- 0
src/Discord.Net.Rest/API/Common/TextInputComponent.cs View File

@@ -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;
}
}
}

+ 40
- 0
src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs View File

@@ -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);
}
}
}

+ 40
- 0
src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs View File

@@ -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;


+ 15
- 0
src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponentData.cs View File

@@ -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;
}
}
}

+ 402
- 0
src/Discord.Net.Rest/Entities/Interactions/Modals/RestModal.cs View File

@@ -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;
}
}

+ 45
- 0
src/Discord.Net.Rest/Entities/Interactions/Modals/RestModalData.cs View File

@@ -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();
}
}
}

+ 9
- 0
src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs View File

@@ -100,6 +100,9 @@ namespace Discord.Rest
if (model.Type == InteractionType.ApplicationCommandAutocomplete)
return await RestAutocompleteInteraction.CreateAsync(client, model).ConfigureAwait(false);

if (model.Type == InteractionType.ModalSubmit)
return await RestModal.CreateAsync(client, model).ConfigureAwait(false);

return null;
}

@@ -180,6 +183,9 @@ namespace Discord.Rest
var model = await InteractionHelper.ModifyInteractionResponseAsync(Discord, Token, func, options);
return RestInteractionMessage.Create(Discord, model, Token, Channel);
}
/// <inheritdoc/>
public abstract string RespondWithModal(Modal modal, RequestOptions options = null);
/// <inheritdoc/>
public abstract string Respond(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null);

@@ -294,6 +300,9 @@ namespace Discord.Rest
Task IDiscordInteraction.DeferAsync(bool ephemeral, RequestOptions options)
=> Task.FromResult(Defer(ephemeral, options));
/// <inheritdoc/>
Task IDiscordInteraction.RespondWithModalAsync(Modal modal, RequestOptions options)
=> Task.FromResult(RespondWithModal(modal, options));
/// <inheritdoc/>
async Task<IUserMessage> IDiscordInteraction.FollowupAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions,
MessageComponent components, Embed embed, RequestOptions options)
=> await FollowupAsync(text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false);


+ 1
- 0
src/Discord.Net.Rest/Entities/Interactions/RestPingInteraction.cs View File

@@ -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();


+ 2
- 1
src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteraction.cs View File

@@ -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/>


+ 7
- 0
src/Discord.Net.Rest/Net/Converters/InteractionConverter.cs View File

@@ -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


+ 3
- 0
src/Discord.Net.Rest/Net/Converters/MessageComponentConverter.cs View File

@@ -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;


+ 9
- 0
src/Discord.Net.WebSocket/BaseSocketClient.Events.cs View File

@@ -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.


+ 1
- 0
src/Discord.Net.WebSocket/DiscordShardedClient.cs View File

@@ -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);


+ 2
- 2
src/Discord.Net.WebSocket/DiscordSocketApiClient.cs View File

@@ -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);


+ 3
- 0
src/Discord.Net.WebSocket/DiscordSocketClient.cs View File

@@ -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;


+ 35
- 0
src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs View File

@@ -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;


+ 20
- 0
src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponentData.cs View File

@@ -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;
}
}
}

+ 302
- 0
src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModal.cs View File

@@ -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;
}
}

+ 36
- 0
src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModalData.cs View File

@@ -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;
}
}

+ 4
- 0
src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteraction.cs View File

@@ -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;


+ 36
- 1
src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs View File

@@ -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,


+ 10
- 0
src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs View File

@@ -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


Loading…
Cancel
Save