Browse Source

Interaction Service Complex Parameters (#2155)

* Interaction Service Complex Parameters

* add complex parameters

* add complex parameters

* fix build errors

* add argument parsing

* add nested complex parameter checks

* add inline docs

* add preferred constructor declaration

* fix autocompletehandlers for complex parameters

* make GetConstructor private

* use flattened params in ToProps method

* make DiscordType of SlashParameter nullable

* add docs to Flattened parameters collection and move the GetComplexParameterCtor method

* add inline docs to SlashCommandParameterBuilder.ComplexParameterFields

* add check for validating required/optinal parameter order

* implement change requests

* return internal ParseResult as ExecuteResult

Co-Authored-By: Cenk Ergen <57065323+Cenngo@users.noreply.github.com>

* fix merge errors

Co-authored-by: Cenk Ergen <57065323+Cenngo@users.noreply.github.com>
tags/3.4.0
Quin Lynch GitHub 3 years ago
parent
commit
9ba64f62d1
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 315 additions and 33 deletions
  1. +30
    -0
      src/Discord.Net.Interactions/Attributes/ComplexParameterAttribute.cs
  2. +10
    -0
      src/Discord.Net.Interactions/Attributes/ComplexParameterCtorAttribute.cs
  3. +56
    -1
      src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs
  4. +66
    -2
      src/Discord.Net.Interactions/Builders/Parameters/SlashCommandParameterBuilder.cs
  5. +3
    -0
      src/Discord.Net.Interactions/Info/Commands/CommandInfo.cs
  6. +82
    -22
      src/Discord.Net.Interactions/Info/Commands/SlashCommandInfo.cs
  7. +28
    -2
      src/Discord.Net.Interactions/Info/Parameters/SlashCommandParameterInfo.cs
  8. +1
    -3
      src/Discord.Net.Interactions/InteractionService.cs
  9. +36
    -0
      src/Discord.Net.Interactions/Results/ParseResult.cs
  10. +3
    -3
      src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs

+ 30
- 0
src/Discord.Net.Interactions/Attributes/ComplexParameterAttribute.cs View File

@@ -0,0 +1,30 @@
using System;

namespace Discord.Interactions
{
/// <summary>
/// Registers a parameter as a complex parameter.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
public class ComplexParameterAttribute : Attribute
{
/// <summary>
/// Gets the parameter array of the constructor method that should be prioritized.
/// </summary>
public Type[] PrioritizedCtorSignature { get; }

/// <summary>
/// Registers a slash command parameter as a complex parameter.
/// </summary>
public ComplexParameterAttribute() { }

/// <summary>
/// Registers a slash command parameter as a complex parameter with a specified constructor signature.
/// </summary>
/// <param name="types">Type array of the preferred constructor parameters.</param>
public ComplexParameterAttribute(Type[] types)
{
PrioritizedCtorSignature = types;
}
}
}

+ 10
- 0
src/Discord.Net.Interactions/Attributes/ComplexParameterCtorAttribute.cs View File

@@ -0,0 +1,10 @@
using System;

namespace Discord.Interactions
{
/// <summary>
/// Tag a type constructor as the preferred Complex command constructor.
/// </summary>
[AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = true)]
public class ComplexParameterCtorAttribute : Attribute { }
}

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

@@ -397,7 +397,6 @@ namespace Discord.Interactions.Builders
builder.Description = paramInfo.Name;
builder.IsRequired = !paramInfo.IsOptional;
builder.DefaultValue = paramInfo.DefaultValue;
builder.SetParameterType(paramType, services);

foreach (var attribute in attributes)
{
@@ -435,12 +434,32 @@ namespace Discord.Interactions.Builders
case MinValueAttribute minValue:
builder.MinValue = minValue.Value;
break;
case ComplexParameterAttribute complexParameter:
{
builder.IsComplexParameter = true;
ConstructorInfo ctor = GetComplexParameterConstructor(paramInfo.ParameterType.GetTypeInfo(), complexParameter);

foreach (var parameter in ctor.GetParameters())
{
if (parameter.IsDefined(typeof(ComplexParameterAttribute)))
throw new InvalidOperationException("You cannot create nested complex parameters.");

builder.AddComplexParameterField(fieldBuilder => BuildSlashParameter(fieldBuilder, parameter, services));
}

var initializer = builder.Command.Module.InteractionService._useCompiledLambda ?
ReflectionUtils<object>.CreateLambdaConstructorInvoker(paramInfo.ParameterType.GetTypeInfo()) : ctor.Invoke;
builder.ComplexParameterInitializer = args => initializer(args);
}
break;
default:
builder.AddAttributes(attribute);
break;
}
}

builder.SetParameterType(paramType, services);

// Replace pascal casings with '-'
builder.Name = Regex.Replace(builder.Name, "(?<=[a-z])(?=[A-Z])", "-").ToLower();
}
@@ -608,5 +627,41 @@ namespace Discord.Interactions.Builders
propertyInfo.SetMethod?.IsStatic == false &&
propertyInfo.IsDefined(typeof(ModalInputAttribute));
}
private static ConstructorInfo GetComplexParameterConstructor(TypeInfo typeInfo, ComplexParameterAttribute complexParameter)
{
var ctors = typeInfo.GetConstructors();

if (ctors.Length == 0)
throw new InvalidOperationException($"No constructor found for \"{typeInfo.FullName}\".");

if (complexParameter.PrioritizedCtorSignature is not null)
{
var ctor = typeInfo.GetConstructor(complexParameter.PrioritizedCtorSignature);

if (ctor is null)
throw new InvalidOperationException($"No constructor was found with the signature: {string.Join(",", complexParameter.PrioritizedCtorSignature.Select(x => x.Name))}");

return ctor;
}

var prioritizedCtors = ctors.Where(x => x.IsDefined(typeof(ComplexParameterCtorAttribute), true));

switch (prioritizedCtors.Count())
{
case > 1:
throw new InvalidOperationException($"{nameof(ComplexParameterCtorAttribute)} can only be used once in a type.");
case 1:
return prioritizedCtors.First();
}

switch (ctors.Length)
{
case > 1:
throw new InvalidOperationException($"Multiple constructors found for \"{typeInfo.FullName}\".");
default:
return ctors.First();
}
}
}
}

+ 66
- 2
src/Discord.Net.Interactions/Builders/Parameters/SlashCommandParameterBuilder.cs View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace Discord.Interactions.Builders
{
@@ -10,6 +11,7 @@ namespace Discord.Interactions.Builders
{
private readonly List<ParameterChoice> _choices = new();
private readonly List<ChannelType> _channelTypes = new();
private readonly List<SlashCommandParameterBuilder> _complexParameterFields = new();

/// <summary>
/// Gets or sets the description of this parameter.
@@ -36,6 +38,11 @@ namespace Discord.Interactions.Builders
/// </summary>
public IReadOnlyCollection<ChannelType> ChannelTypes => _channelTypes;

/// <summary>
/// Gets the constructor parameters of this parameter, if <see cref="IsComplexParameter"/> is <see langword="true"/>.
/// </summary>
public IReadOnlyCollection<SlashCommandParameterBuilder> ComplexParameterFields => _complexParameterFields;

/// <summary>
/// Gets or sets whether this parameter should be configured for Autocomplete Interactions.
/// </summary>
@@ -46,6 +53,16 @@ namespace Discord.Interactions.Builders
/// </summary>
public TypeConverter TypeConverter { get; private set; }

/// <summary>
/// Gets whether this type should be treated as a complex parameter.
/// </summary>
public bool IsComplexParameter { get; internal set; }

/// <summary>
/// Gets the initializer delegate for this parameter, if <see cref="IsComplexParameter"/> is <see langword="true"/>.
/// </summary>
public ComplexParameterInitializer ComplexParameterInitializer { get; internal set; }

/// <summary>
/// Gets or sets the <see cref="IAutocompleteHandler"/> of this parameter.
/// </summary>
@@ -60,7 +77,14 @@ namespace Discord.Interactions.Builders
/// <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 SlashCommandParameterBuilder(ICommandBuilder command, string name, Type type) : base(command, name, type) { }
public SlashCommandParameterBuilder(ICommandBuilder command, string name, Type type, ComplexParameterInitializer complexParameterInitializer = null)
: base(command, name, type)
{
ComplexParameterInitializer = complexParameterInitializer;

if (complexParameterInitializer is not null)
IsComplexParameter = true;
}

/// <summary>
/// Sets <see cref="Description"/>.
@@ -168,7 +192,47 @@ namespace Discord.Interactions.Builders
public SlashCommandParameterBuilder SetParameterType(Type type, IServiceProvider services = null)
{
base.SetParameterType(type);
TypeConverter = Command.Module.InteractionService.GetTypeConverter(ParameterType, services);

if(!IsComplexParameter)
TypeConverter = Command.Module.InteractionService.GetTypeConverter(ParameterType, services);

return this;
}

/// <summary>
/// Adds a parameter builders to <see cref="ComplexParameterFields"/>.
/// </summary>
/// <param name="configure"><see cref="SlashCommandParameterBuilder"/> factory.</param>
/// <returns>
/// The builder instance.
/// </returns>
/// <exception cref="InvalidOperationException">Thrown if the added field has a <see cref="ComplexParameterAttribute"/>.</exception>
public SlashCommandParameterBuilder AddComplexParameterField(Action<SlashCommandParameterBuilder> configure)
{
SlashCommandParameterBuilder builder = new(Command);
configure(builder);

if(builder.IsComplexParameter)
throw new InvalidOperationException("You cannot create nested complex parameters.");

_complexParameterFields.Add(builder);
return this;
}

/// <summary>
/// Adds parameter builders to <see cref="ComplexParameterFields"/>.
/// </summary>
/// <param name="fields">New parameter builders to be added to <see cref="ComplexParameterFields"/>.</param>
/// <returns>
/// The builder instance.
/// </returns>
/// <exception cref="InvalidOperationException">Thrown if the added field has a <see cref="ComplexParameterAttribute"/>.</exception>
public SlashCommandParameterBuilder AddComplexParameterFields(params SlashCommandParameterBuilder[] fields)
{
if(fields.Any(x => x.IsComplexParameter))
throw new InvalidOperationException("You cannot create nested complex parameters.");

_complexParameterFields.AddRange(fields);
return this;
}



+ 3
- 0
src/Discord.Net.Interactions/Info/Commands/CommandInfo.cs View File

@@ -31,6 +31,8 @@ namespace Discord.Interactions
private readonly ExecuteCallback _action;
private readonly ILookup<string, PreconditionAttribute> _groupedPreconditions;

internal IReadOnlyDictionary<string, TParameter> _parameterDictionary { get; }

/// <inheritdoc/>
public ModuleInfo Module { get; }

@@ -79,6 +81,7 @@ namespace Discord.Interactions

_action = builder.Callback;
_groupedPreconditions = builder.Preconditions.ToLookup(x => x.Group, x => x, StringComparer.Ordinal);
_parameterDictionary = Parameters?.ToDictionary(x => x.Name, x => x).ToImmutableDictionary();
}

/// <inheritdoc/>


+ 82
- 22
src/Discord.Net.Interactions/Info/Commands/SlashCommandInfo.cs View File

@@ -13,6 +13,8 @@ namespace Discord.Interactions
/// </summary>
public class SlashCommandInfo : CommandInfo<SlashCommandParameterInfo>, IApplicationCommandInfo
{
internal IReadOnlyDictionary<string, SlashCommandParameterInfo> _flattenedParameterDictionary { get; }

/// <summary>
/// Gets the command description that will be displayed on Discord.
/// </summary>
@@ -30,11 +32,23 @@ namespace Discord.Interactions
/// <inheritdoc/>
public override bool SupportsWildCards => false;

/// <summary>
/// Gets the flattened collection of command parameters and complex parameter fields.
/// </summary>
public IReadOnlyCollection<SlashCommandParameterInfo> FlattenedParameters { get; }

internal SlashCommandInfo (Builders.SlashCommandBuilder builder, ModuleInfo module, InteractionService commandService) : base(builder, module, commandService)
{
Description = builder.Description;
DefaultPermission = builder.DefaultPermission;
Parameters = builder.Parameters.Select(x => x.Build(this)).ToImmutableArray();
FlattenedParameters = FlattenParameters(Parameters).ToImmutableArray();

for (var i = 0; i < FlattenedParameters.Count - 1; i++)
if (!FlattenedParameters.ElementAt(i).IsRequired && FlattenedParameters.ElementAt(i + 1).IsRequired)
throw new InvalidOperationException("Optional parameters must appear after all required parameters, ComplexParameters with optional parameters must be located at the end.");

_flattenedParameterDictionary = FlattenedParameters?.ToDictionary(x => x.Name, x => x).ToImmutableDictionary();
}

/// <inheritdoc/>
@@ -56,45 +70,81 @@ namespace Discord.Interactions
{
try
{
if (paramList?.Count() < argList?.Count())
return ExecuteResult.FromError(InteractionCommandError.BadArgs ,"Command was invoked with too many parameters");

var args = new object[paramList.Count()];

for (var i = 0; i < paramList.Count(); i++)
{
var parameter = paramList.ElementAt(i);

var arg = argList?.Find(x => string.Equals(x.Name, parameter.Name, StringComparison.OrdinalIgnoreCase));
var result = await ParseArgument(parameter, context, argList, services).ConfigureAwait(false);

if (arg == default)
if(!result.IsSuccess)
{
if (parameter.IsRequired)
return ExecuteResult.FromError(InteractionCommandError.BadArgs, "Command was invoked with too few parameters");
else
args[i] = parameter.DefaultValue;
var execResult = ExecuteResult.FromError(result);
await InvokeModuleEvent(context, execResult).ConfigureAwait(false);
return execResult;
}

if (result is ParseResult parseResult)
args[i] = parseResult.Value;
else
{
var typeConverter = parameter.TypeConverter;
return ExecuteResult.FromError(InteractionCommandError.BadArgs, "Command parameter parsing failed for an unknown reason.");
}

var readResult = await typeConverter.ReadAsync(context, arg, services).ConfigureAwait(false);
return await RunAsync(context, args, services).ConfigureAwait(false);
}
catch (Exception ex)
{
var result = ExecuteResult.FromError(ex);
await InvokeModuleEvent(context, result).ConfigureAwait(false);
return result;
}
}

if (!readResult.IsSuccess)
{
await InvokeModuleEvent(context, readResult).ConfigureAwait(false);
return readResult;
}
private async Task<IResult> ParseArgument(SlashCommandParameterInfo parameterInfo, IInteractionContext context, List<IApplicationCommandInteractionDataOption> argList,
IServiceProvider services)
{
if (parameterInfo.IsComplexParameter)
{
var ctorArgs = new object[parameterInfo.ComplexParameterFields.Count];

args[i] = readResult.Value;
}
for (var i = 0; i < ctorArgs.Length; i++)
{
var result = await ParseArgument(parameterInfo.ComplexParameterFields.ElementAt(i), context, argList, services).ConfigureAwait(false);

if (!result.IsSuccess)
return result;

if (result is ParseResult parseResult)
ctorArgs[i] = parseResult.Value;
else
return ExecuteResult.FromError(InteractionCommandError.BadArgs, "Complex command parsing failed for an unknown reason.");
}

return await RunAsync(context, args, services).ConfigureAwait(false);
return ParseResult.FromSuccess(parameterInfo._complexParameterInitializer(ctorArgs));
}
catch (Exception ex)
else
{
return ExecuteResult.FromError(ex);
var arg = argList?.Find(x => string.Equals(x.Name, parameterInfo.Name, StringComparison.OrdinalIgnoreCase));

if (arg == default)
{
if (parameterInfo.IsRequired)
return ExecuteResult.FromError(InteractionCommandError.BadArgs, "Command was invoked with too few parameters");
else
return ParseResult.FromSuccess(parameterInfo.DefaultValue);
}
else
{
var typeConverter = parameterInfo.TypeConverter;

var readResult = await typeConverter.ReadAsync(context, arg, services).ConfigureAwait(false);

if (!readResult.IsSuccess)
return readResult;

return ParseResult.FromSuccess(readResult.Value);
}
}
}

@@ -108,5 +158,15 @@ namespace Discord.Interactions
else
return $"Slash Command: \"{base.ToString()}\" for {context.User} in {context.Channel}";
}

private static IEnumerable<SlashCommandParameterInfo> FlattenParameters(IEnumerable<SlashCommandParameterInfo> parameters)
{
foreach (var parameter in parameters)
if (!parameter.IsComplexParameter)
yield return parameter;
else
foreach(var complexParameterField in parameter.ComplexParameterFields)
yield return complexParameterField;
}
}
}

+ 28
- 2
src/Discord.Net.Interactions/Info/Parameters/SlashCommandParameterInfo.cs View File

@@ -1,13 +1,25 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;

namespace Discord.Interactions
{
/// <summary>
/// Represents a cached argument constructor delegate.
/// </summary>
/// <param name="args">Method arguments array.</param>
/// <returns>
/// Returns the constructed object.
/// </returns>
public delegate object ComplexParameterInitializer(object[] args);

/// <summary>
/// Represents the parameter info class for <see cref="SlashCommandInfo"/> commands.
/// </summary>
public class SlashCommandParameterInfo : CommandParameterInfo
{
internal readonly ComplexParameterInitializer _complexParameterInitializer;

/// <inheritdoc/>
public new SlashCommandInfo Command => base.Command as SlashCommandInfo;

@@ -43,9 +55,14 @@ namespace Discord.Interactions
public bool IsAutocomplete { get; }

/// <summary>
/// Gets the Discord option type this parameter represents.
/// Gets whether this type should be treated as a complex parameter.
/// </summary>
public ApplicationCommandOptionType DiscordOptionType => TypeConverter.GetDiscordType();
public bool IsComplexParameter { get; }

/// <summary>
/// Gets the Discord option type this parameter represents. If the parameter is not a complex parameter.
/// </summary>
public ApplicationCommandOptionType? DiscordOptionType => TypeConverter?.GetDiscordType();

/// <summary>
/// Gets the parameter choices of this Slash Application Command parameter.
@@ -57,6 +74,11 @@ namespace Discord.Interactions
/// </summary>
public IReadOnlyCollection<ChannelType> ChannelTypes { get; }

/// <summary>
/// Gets the constructor parameters of this parameter, if <see cref="IsComplexParameter"/> is <see langword="true"/>.
/// </summary>
public IReadOnlyCollection<SlashCommandParameterInfo> ComplexParameterFields { get; }

internal SlashCommandParameterInfo(Builders.SlashCommandParameterBuilder builder, SlashCommandInfo command) : base(builder, command)
{
TypeConverter = builder.TypeConverter;
@@ -64,9 +86,13 @@ namespace Discord.Interactions
Description = builder.Description;
MaxValue = builder.MaxValue;
MinValue = builder.MinValue;
IsComplexParameter = builder.IsComplexParameter;
IsAutocomplete = builder.Autocomplete;
Choices = builder.Choices.ToImmutableArray();
ChannelTypes = builder.ChannelTypes.ToImmutableArray();
ComplexParameterFields = builder.ComplexParameterFields?.Select(x => x.Build(command)).ToImmutableArray();

_complexParameterInitializer = builder.ComplexParameterInitializer;
}
}
}

+ 1
- 3
src/Discord.Net.Interactions/InteractionService.cs View File

@@ -747,9 +747,7 @@ namespace Discord.Interactions

if(autocompleteHandlerResult.IsSuccess)
{
var parameter = autocompleteHandlerResult.Command.Parameters.FirstOrDefault(x => string.Equals(x.Name, interaction.Data.Current.Name, StringComparison.Ordinal));

if(parameter?.AutocompleteHandler is not null)
if (autocompleteHandlerResult.Command._flattenedParameterDictionary.TryGetValue(interaction.Data.Current.Name, out var parameter) && parameter?.AutocompleteHandler is not null)
return await parameter.AutocompleteHandler.ExecuteAsync(context, interaction, parameter, services).ConfigureAwait(false);
}
}


+ 36
- 0
src/Discord.Net.Interactions/Results/ParseResult.cs View File

@@ -0,0 +1,36 @@
using System;

namespace Discord.Interactions
{
internal struct ParseResult : IResult
{
public object Value { get; }

public InteractionCommandError? Error { get; }

public string ErrorReason { get; }

public bool IsSuccess => !Error.HasValue;

private ParseResult(object value, InteractionCommandError? error, string reason)
{
Value = value;
Error = error;
ErrorReason = reason;
}

public static ParseResult FromSuccess(object value) =>
new ParseResult(value, null, null);

public static ParseResult FromError(Exception exception) =>
new ParseResult(null, InteractionCommandError.Exception, exception.Message);

public static ParseResult FromError(InteractionCommandError error, string reason) =>
new ParseResult(null, error, reason);

public static ParseResult FromError(IResult result) =>
new ParseResult(null, result.Error, result.ErrorReason);

public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}";
}
}

+ 3
- 3
src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs View File

@@ -13,7 +13,7 @@ namespace Discord.Interactions
{
Name = parameterInfo.Name,
Description = parameterInfo.Description,
Type = parameterInfo.DiscordOptionType,
Type = parameterInfo.DiscordOptionType.Value,
IsRequired = parameterInfo.IsRequired,
Choices = parameterInfo.Choices?.Select(x => new ApplicationCommandOptionChoiceProperties
{
@@ -46,7 +46,7 @@ namespace Discord.Interactions
if (commandInfo.Parameters.Count > SlashCommandBuilder.MaxOptionsCount)
throw new InvalidOperationException($"Slash Commands cannot have more than {SlashCommandBuilder.MaxOptionsCount} command parameters");

props.Options = commandInfo.Parameters.Select(x => x.ToApplicationCommandOptionProps())?.ToList() ?? Optional<List<ApplicationCommandOptionProperties>>.Unspecified;
props.Options = commandInfo.FlattenedParameters.Select(x => x.ToApplicationCommandOptionProps())?.ToList() ?? Optional<List<ApplicationCommandOptionProperties>>.Unspecified;

return props;
}
@@ -58,7 +58,7 @@ namespace Discord.Interactions
Description = commandInfo.Description,
Type = ApplicationCommandOptionType.SubCommand,
IsRequired = false,
Options = commandInfo.Parameters?.Select(x => x.ToApplicationCommandOptionProps())?.ToList()
Options = commandInfo.FlattenedParameters?.Select(x => x.ToApplicationCommandOptionProps())?.ToList()
};

public static ApplicationCommandProperties ToApplicationCommandProps(this ContextCommandInfo commandInfo)


Loading…
Cancel
Save