Skip to content

Commit

Permalink
Fixed WaitForModalAsync() and added overload (DSharpPlus#1230)
Browse files Browse the repository at this point in the history
* Fixed WaitForModalAsync and added overload

Created an event handler dedicated for modals. WaitForModalAsync() will no longer result in an internal NRE.

Added a overload for WaitForModalAsync() that takes a user.

* Minor formatting

* Resolving code reviews from @VelvetThePanda
  • Loading branch information
DWaffles authored Mar 10, 2022
1 parent a4b193e commit dbf97af
Show file tree
Hide file tree
Showing 5 changed files with 306 additions and 5 deletions.
97 changes: 97 additions & 0 deletions DSharpPlus.Interactivity/EventHandling/ModalEventWaiter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// This file is part of the DSharpPlus project.
//
// Copyright (c) 2015 Mike Santiago
// Copyright (c) 2016-2022 DSharpPlus Contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

using System;
using System.Linq;
using System.Threading.Tasks;
using ConcurrentCollections;
using DSharpPlus.EventArgs;
using Microsoft.Extensions.Logging;

namespace DSharpPlus.Interactivity.EventHandling
{
/// <summary>
/// Modal version of <see cref="EventWaiter{T}"/>
/// </summary>
internal class ModalEventWaiter : IDisposable
{
private DiscordClient Client { get; }

/// <summary>
/// Collection of <see cref = "ModalMatchRequest"/> representing requests to wait for modals.
/// </summary>
private ConcurrentHashSet<ModalMatchRequest> MatchRequests { get; } = new();

public ModalEventWaiter(DiscordClient client)
{
this.Client = client;
this.Client.ModalSubmitted += this.Handle; //registering Handle event to be fired upon ModalSubmitted
}

/// <summary>
/// Waits for a specified <see cref="ModalMatchRequest"/>'s predicate to be fufilled.
/// </summary>
/// <param name="request">The request to wait for a match.</param>
/// <returns>The returned args, or null if it timed out.</returns>
public async Task<ModalSubmitEventArgs> WaitForMatchAsync(ModalMatchRequest request)
{
this.MatchRequests.Add(request);

try
{
return await request.Tcs.Task.ConfigureAwait(false); // awaits request until completeion or cancellation
}
catch (Exception e)
{
this.Client.Logger.LogError(InteractivityEvents.InteractivityWaitError, e, "An exception was thrown while waiting for a modal.");
return null;
}
finally
{
this.MatchRequests.TryRemove(request);
}
}

/// <summary>
/// Is called whenever <see cref="ModalSubmitEventArgs"/> is fired. Checks to see submitted modal matches any of the current requests.
/// </summary>
/// <param name="_"></param>
/// <param name="args">The <see cref="ModalSubmitEventArgs"/> to match.</param>
/// <returns>A task that represents matching the requests.</returns>
private Task Handle(DiscordClient _, ModalSubmitEventArgs args)
{
foreach (var req in this.MatchRequests.ToArray()) // ToArray to get a copy of the collection that won't be modified during iteration
{
if (req.ModalId == args.Interaction.Data.CustomId && req.IsMatch(args)) // will catch all matches
req.Tcs.TrySetResult(args);
}
return Task.CompletedTask;
}

public void Dispose()
{
this.MatchRequests.Clear();
this.Client.ModalSubmitted -= this.Handle;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// This file is part of the DSharpPlus project.
//
// Copyright (c) 2015 Mike Santiago
// Copyright (c) 2016-2022 DSharpPlus Contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

using System;
using System.Threading;
using System.Threading.Tasks;
using DSharpPlus.EventArgs;

namespace DSharpPlus.Interactivity.EventHandling
{
/// <summary>
/// Represents a match request for a modal of the given Id and predicate.
/// </summary>
internal class ModalMatchRequest
{
/// <summary>
/// The custom Id of the modal.
/// </summary>
public string ModalId { get; }

/// <summary>
/// The completion source that represents the result of the match.
/// </summary>
public TaskCompletionSource<ModalSubmitEventArgs> Tcs { get; private set; } = new();

protected CancellationToken Cancellation { get; }

/// <summary>
/// The predicate/criteria that this match will be fulfilled under.
/// </summary>
protected Func<ModalSubmitEventArgs, bool> Predicate { get; }

public ModalMatchRequest(string modal_id, Func<ModalSubmitEventArgs, bool> predicate, CancellationToken cancellation)
{
this.ModalId = modal_id;
this.Predicate = predicate;
this.Cancellation = cancellation;
this.Cancellation.Register(() => this.Tcs.TrySetResult(null)); // "TrySetCancelled would probably be better but I digress" - Velvet // "TrySetCancelled throws an exception when you await the task, actually" - Velvet, 2022
}

/// <summary>
/// Checks whether the <see cref="ModalSubmitEventArgs"/> matches the predicate criteria.
/// </summary>
/// <param name="args">The <see cref="ModalSubmitEventArgs"/> to check.</param>
/// <returns>Whether the <see cref="ModalSubmitEventArgs"/> matches the predicate.</returns>
public bool IsMatch(ModalSubmitEventArgs args)
=> this.Predicate(args);
}
}
60 changes: 56 additions & 4 deletions DSharpPlus.Interactivity/InteractivityExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ public class InteractivityExtension : BaseExtension

private ComponentEventWaiter ComponentEventWaiter;

private ModalEventWaiter ModalEventWaiter;

private ReactionCollector ReactionCollector;

private Poller Poller;
Expand All @@ -81,6 +83,7 @@ protected internal override void Setup(DiscordClient client)
this.Paginator = new Paginator(this.Client);
this._compPaginator = new(this.Client, this.Config);
this.ComponentEventWaiter = new(this.Client, this.Config);
this.ModalEventWaiter = new(this.Client);

}

Expand Down Expand Up @@ -115,12 +118,61 @@ public async Task<ReadOnlyCollection<PollEmoji>> DoPollAsync(DiscordMessage m, I
}

/// <summary>
/// Waits for a user to submit a modal.
/// Waits for a modal with the specified id to be submitted.
/// </summary>
/// <param name="modal_id">The id of the modal to wait for.</param>
/// <param name="modal_id">The id of the modal to wait for. Should be unique to avoid issues.</param>
/// <param name="timeoutOverride">Override the timeout period in <see cref="InteractivityConfiguration"/>.</param>
/// <returns>A <see cref="InteractivityResult{ModalSubmitEventArgs}"/> with a modal if the interactivity did not time out.</returns>
public Task<InteractivityResult<ModalSubmitEventArgs>> WaitForModalAsync(string modal_id, TimeSpan? timeoutOverride = null)
=> this.WaitForEventArgsAsync<ModalSubmitEventArgs>(m => m.Interaction.Data.CustomId == modal_id, timeoutOverride);
=> this.WaitForModalAsync(modal_id, this.GetCancellationToken(timeoutOverride));

/// <summary>
/// Waits for a modal with the specified id to be submitted.
/// </summary>
/// <param name="modal_id">The id of the modal to wait for. Should be unique to avoid issues.</param>
/// <param name="token">A custom cancellation token that can be cancelled at any point.</param>
/// <returns>A <see cref="InteractivityResult{ModalSubmitEventArgs}"/> with a modal if the interactivity did not time out.</returns>
public async Task<InteractivityResult<ModalSubmitEventArgs>> WaitForModalAsync(string modal_id, CancellationToken token)
{
if (string.IsNullOrEmpty(modal_id) || modal_id.Length > 100)
throw new ArgumentException("Custom ID must be between 1 and 100 characters.");

var matchRequest = new ModalMatchRequest(modal_id,
c => c.Interaction.Data.CustomId == modal_id, cancellation: token);
var result = await this.ModalEventWaiter.WaitForMatchAsync(matchRequest).ConfigureAwait(false);

return new(result is null, result);
}

/// <summary>
/// Waits for a modal with the specificed custom id to be submitted by the given user.
/// </summary>
/// <param name="modal_id">The id of the modal to wait for. Should be unique to avoid issues.</param>
/// <param name="user">The user to wait for the modal from.</param>
/// <param name="timeoutOverride">Override the timeout period in <see cref="InteractivityConfiguration"/>.</param>
/// <returns>A <see cref="InteractivityResult{ModalSubmitEventArgs}"/> with a modal if the interactivity did not time out.</returns>
public Task<InteractivityResult<ModalSubmitEventArgs>> WaitForModalAsync(string modal_id, DiscordUser user, TimeSpan? timeoutOverride = null)
=> this.WaitForModalAsync(modal_id, user, this.GetCancellationToken(timeoutOverride));

/// <summary>
/// Waits for a modal with the specificed custom id to be submitted by the given user.
/// </summary>
/// <param name="modal_id">The id of the modal to wait for. Should be unique to avoid issues.</param>
/// <param name="user">The user to wait for the modal from.</param>
/// <param name="token">A custom cancellation token that can be cancelled at any point.</param>
/// <returns>A <see cref="InteractivityResult{ModalSubmitEventArgs}"/> with a modal if the interactivity did not time out.</returns>
public async Task<InteractivityResult<ModalSubmitEventArgs>> WaitForModalAsync(string modal_id, DiscordUser user, CancellationToken token)
{
if (string.IsNullOrEmpty(modal_id) || modal_id.Length > 100)
throw new ArgumentException("Custom ID must be between 1 and 100 characters.");

var matchRequest = new ModalMatchRequest(modal_id,
c => c.Interaction.Data.CustomId == modal_id &&
c.Interaction.User.Id == user.Id, cancellation: token);
var result = await this.ModalEventWaiter.WaitForMatchAsync(matchRequest).ConfigureAwait(false);

return new(result is null, result);
}

/// <summary>
/// Waits for any button in the specified collection to be pressed.
Expand Down Expand Up @@ -604,7 +656,7 @@ public async Task<InteractivityResult<T>> WaitForEventArgsAsync<T>(Func<T, bool>
{
var timeout = timeoutoverride ?? this.Config.Timeout;

using var waiter = new EventWaiter<T>(this.Client);
var waiter = new EventWaiter<T>(this.Client);
var res = await waiter.WaitForMatch(new MatchRequest<T>(predicate, timeout)).ConfigureAwait(false);
return new InteractivityResult<T>(res == null, res);
}
Expand Down
81 changes: 81 additions & 0 deletions DSharpPlus.Test/ModalCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,15 @@
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using DSharpPlus.CommandsNext;
using DSharpPlus.CommandsNext.Attributes;
using DSharpPlus.Entities;
using DSharpPlus.Interactivity.Extensions;
using DSharpPlus.SlashCommands;

namespace DSharpPlus.Test
{
Expand All @@ -32,4 +37,80 @@ public class ModalCommand : BaseCommandModule
[Command]
public async Task Modal(CommandContext ctx) => await ctx.RespondAsync(m => m.WithContent("\u200b").AddComponents(new DiscordButtonComponent(ButtonStyle.Primary, "modal", "Press for modal")));
}

[SlashCommandGroup("modal", "Slash command group for modal test commands.")]
public class ModalSlashCommands : ApplicationCommandModule
{
[SlashCommand("user", "Modal")]
public async Task ModalUserCommandAsync(InteractionContext ctx)
{
var modal = new DiscordInteractionResponseBuilder()
.WithTitle("Modal User")
.WithCustomId("id-modal")
.AddComponents(new TextInputComponent(label: "User", customId: "id-user", value: "id-modal", max_length: 32));
await ctx.CreateResponseAsync(InteractionResponseType.Modal, modal);

var interactivity = ctx.Client.GetInteractivity();
var response = await interactivity.WaitForModalAsync("id-modal", user: ctx.User, timeoutOverride: TimeSpan.FromSeconds(30));

if (!response.TimedOut)
{
var inter = response.Result.Interaction;
var embed = this.ModalSubmittedEmbed(ctx.User, inter, response.Result.Values);
await inter.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder().AddEmbed(embed));
}
else
await ctx.Channel.SendMessageAsync("Request timed out");
}
[SlashCommand("generic", "Modal")]
public async Task ModalGenericCommandAsync(InteractionContext ctx)
{
var modal = new DiscordInteractionResponseBuilder()
.WithTitle("Modal Generic")
.WithCustomId("id-modal")
.AddComponents(new TextInputComponent(label: "Generic", customId: "id-generic", value: "id-modal", max_length: 32));
await ctx.CreateResponseAsync(InteractionResponseType.Modal, modal);

var interactivity = ctx.Client.GetInteractivity();
var response = await interactivity.WaitForModalAsync("id-modal", timeoutOverride: TimeSpan.FromSeconds(30));

if (!response.TimedOut)
{
var inter = response.Result.Interaction;
var embed = this.ModalSubmittedEmbed(ctx.User, inter, response.Result.Values);
await inter.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder().AddEmbed(embed));
}
else
await ctx.Channel.SendMessageAsync("Request timed out");
}
[SlashCommand("salted", "Unique modal id.")]
public async Task ModalSaltedCommandAsync(InteractionContext ctx)
{
var modalId = $"id-modal-{ctx.User.Id}";
var modal = new DiscordInteractionResponseBuilder()
.WithTitle("Modal Salted")
.WithCustomId(modalId)
.AddComponents(new TextInputComponent(label: "Salted", customId: "id-salted", value: modalId, max_length: 32));
await ctx.CreateResponseAsync(InteractionResponseType.Modal, modal);

var interactivity = ctx.Client.GetInteractivity();
var response = await interactivity.WaitForModalAsync(modalId, timeoutOverride: TimeSpan.FromSeconds(30));

if (!response.TimedOut)
{
var inter = response.Result.Interaction;
var embed = this.ModalSubmittedEmbed(ctx.User, inter, response.Result.Values);
await inter.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder().AddEmbed(embed));
}
else
await ctx.Channel.SendMessageAsync("Request timed out");
}
private DiscordEmbed ModalSubmittedEmbed(DiscordUser expectedUser, DiscordInteraction inter, IReadOnlyDictionary<string, string> values)
{
return new DiscordEmbedBuilder()
.WithAuthor(name: $"Modal Submitted: {inter.Data.CustomId}", iconUrl: inter.User.AvatarUrl)
.WithDescription(string.Join("\n", values.Select(x => $"{x.Key}: {x.Value}")))
.AddField("Expected", expectedUser.Mention, true).AddField("Actual", inter.User.Mention, true);
}
}
}
4 changes: 3 additions & 1 deletion DSharpPlus.Test/TestBot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,9 @@ public TestBot(TestBotConfig cfg, int shardid)

private async Task Discord_ModalSubmitted(DiscordClient sender, ModalSubmitEventArgs e)
{
await e.Interaction.CreateResponseAsync(InteractionResponseType.UpdateMessage, new DiscordInteractionResponseBuilder().WithContent("Thank you!"));
bool testWaitForModal = true;
if(!testWaitForModal)
await e.Interaction.CreateResponseAsync(InteractionResponseType.UpdateMessage, new DiscordInteractionResponseBuilder().WithContent("Thank you!"));

this.Discord.Logger.LogInformation("Got callback from user {User}, {Modal}", e.Interaction.User, e.Values);
}
Expand Down

0 comments on commit dbf97af

Please sign in to comment.