* 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
@@ -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; | |||
} | |||
} | |||
} |
@@ -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 { } | |||
} |
@@ -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(); | |||
} | |||
} | |||
} | |||
} |
@@ -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. | |||
@@ -37,6 +39,11 @@ namespace Discord.Interactions.Builders | |||
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> | |||
public bool Autocomplete { get; set; } | |||
@@ -47,6 +54,16 @@ namespace Discord.Interactions.Builders | |||
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> | |||
public IAutocompleteHandler AutocompleteHandler { get; set; } | |||
@@ -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; | |||
} | |||
@@ -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/> | |||
@@ -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; | |||
} | |||
} | |||
} |
@@ -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; | |||
} | |||
} | |||
} |
@@ -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); | |||
} | |||
} | |||
@@ -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}"; | |||
} | |||
} |
@@ -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) | |||