diff --git a/Remora.Discord.Commands/Extensions/ApplicationCommandDataExtensions.cs b/Remora.Discord.Commands/Extensions/ApplicationCommandDataExtensions.cs index ea78a6dd5e..166defb35b 100644 --- a/Remora.Discord.Commands/Extensions/ApplicationCommandDataExtensions.cs +++ b/Remora.Discord.Commands/Extensions/ApplicationCommandDataExtensions.cs @@ -24,6 +24,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using JetBrains.Annotations; using Remora.Discord.API.Abstractions.Objects; @@ -35,6 +36,8 @@ namespace Remora.Discord.Commands.Extensions; [PublicAPI] public static class ApplicationCommandDataExtensions { + private static readonly Regex _parameterNameRegex = new(@"(?\S+)__\d{1,2}$", RegexOptions.Compiled); + /// /// Unpacks an interaction into a command name string and a set of parameters. /// @@ -100,15 +103,31 @@ out IReadOnlyDictionary>? parameters if (options.Count > 1) { - // multiple parameters - var unpackedParameters = new Dictionary>(); + var tempParameters = new Dictionary>(); foreach (var option in options) { var (name, values) = UnpackInteractionParameter(option); - unpackedParameters.Add(name, values); + + name = _parameterNameRegex.Replace(name, "$1"); + if (!tempParameters.TryGetValue(name, out var existingValues)) + { + tempParameters[name] = values; + } + else + { + if (existingValues is List casted) + { + casted.AddRange(values); + } + else + { + tempParameters[name] = (List)[..existingValues, ..values]; + } + } } - parameters = unpackedParameters; + parameters = tempParameters; + return; } @@ -154,16 +173,19 @@ IApplicationCommandInteractionDataOption option throw new InvalidOperationException(); } - var values = new List(); if (optionValue.Value is ICollection collection) { - values.AddRange(collection.Cast().Select(o => o.ToString() ?? string.Empty)); + var valueStrings = collection.Cast().Select(o => o.ToString() ?? string.Empty).ToArray(); + return (option.Name, valueStrings); } else { - values.Add(optionValue.Value.ToString() ?? string.Empty); - } + var value = optionValue.Value.ToString() ?? string.Empty; - return (option.Name, values); + #pragma warning disable SA1010 // Stylecop doesn't handle collection expressions correctly here + // Casting to string[] is optional, but absolves reliance on Roslyn making an inline array here. + return (option.Name, (string[])[value]); + #pragma warning restore SA1010 + } } } diff --git a/Remora.Discord.Commands/Extensions/CommandTreeExtensions.cs b/Remora.Discord.Commands/Extensions/CommandTreeExtensions.cs index b2e47c9294..ba66b116f8 100644 --- a/Remora.Discord.Commands/Extensions/CommandTreeExtensions.cs +++ b/Remora.Discord.Commands/Extensions/CommandTreeExtensions.cs @@ -37,7 +37,9 @@ using Remora.Discord.Commands.Attributes; using Remora.Discord.Commands.Services; using Remora.Rest.Core; +using Remora.Rest.Extensions; using static Remora.Discord.API.Abstractions.Objects.ApplicationCommandOptionType; +using RangeAttribute = Remora.Commands.Attributes.RangeAttribute; namespace Remora.Discord.Commands.Extensions; @@ -712,15 +714,6 @@ ILocalizationProvider localizationProvider parameter ); } - case NamedCollectionParameterShape or PositionalCollectionParameterShape: - { - throw new UnsupportedParameterFeatureException - ( - "Collection parameters are not supported in slash commands.", - command, - parameter - ); - } } var actualParameterType = parameter.GetActualParameterType(); @@ -739,27 +732,61 @@ ILocalizationProvider localizationProvider var (channelTypes, minValue, maxValue, minLength, maxLength) = GetParameterConstraints(command, parameter); - var parameterOption = new ApplicationCommandOption - ( - parameter.GetDiscordType(), - name, - parameter.Description, - default, - !parameter.IsOmissible(), - choices, - ChannelTypes: channelTypes, - EnableAutocomplete: enableAutocomplete, - MinValue: minValue, - MaxValue: maxValue, - NameLocalizations: localizedNames.Count > 0 ? new(localizedNames) : default, - DescriptionLocalizations: localizedDescriptions.Count > 0 ? new(localizedDescriptions) : default, - MinLength: minLength, - MaxLength: maxLength - ); + if (parameter is not (NamedCollectionParameterShape or PositionalCollectionParameterShape)) + { + var parameterOption = new ApplicationCommandOption + ( + parameter.GetDiscordType(), + name, + parameter.Description, + default, + !parameter.IsOmissible(), + choices, + ChannelTypes: channelTypes, + EnableAutocomplete: enableAutocomplete, + MinValue: minValue, + MaxValue: maxValue, + NameLocalizations: localizedNames.Count > 0 ? new(localizedNames) : default, + DescriptionLocalizations: localizedDescriptions.Count > 0 ? new(localizedDescriptions) : default, + MinLength: minLength, + MaxLength: maxLength + ); - parameterOptions.Add(parameterOption); + parameterOptions.Add(parameterOption); + + continue; + } + + // Collection parameters + var rangeAttribute = parameter.Parameter.GetCustomAttribute(); + var (minElements, maxElements) = (rangeAttribute?.GetMin() ?? 1, rangeAttribute?.GetMax()); + + for (ulong i = 0; i < (maxElements ?? minElements); i++) + { + var parameterOption = new ApplicationCommandOption + ( + parameter.GetDiscordType(), + $"{name}__{i + 1}", + parameter.Description, + default, + i < minElements && !parameter.IsOmissible(), + choices, + ChannelTypes: channelTypes, + EnableAutocomplete: enableAutocomplete, + MinValue: minValue, + MaxValue: maxValue, + NameLocalizations: localizedNames.Count > 0 ? new(localizedNames) : default, + DescriptionLocalizations: localizedDescriptions.Count > 0 ? new(localizedDescriptions) : default, + MinLength: minLength, + MaxLength: maxLength + ); + + parameterOptions.Add(parameterOption); + } } + parameterOptions = parameterOptions.OrderByDescending(p => p.IsRequired.OrDefault(true)).ToList(); + if (parameterOptions.Count > _maxCommandParameters) { throw new UnsupportedFeatureException diff --git a/Remora.Discord.Commands/Extensions/ParameterShapeExtensions.cs b/Remora.Discord.Commands/Extensions/ParameterShapeExtensions.cs index 957d9e8fad..dc796585f7 100644 --- a/Remora.Discord.Commands/Extensions/ParameterShapeExtensions.cs +++ b/Remora.Discord.Commands/Extensions/ParameterShapeExtensions.cs @@ -21,6 +21,7 @@ // using System; +using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; using Remora.Commands.Extensions; @@ -42,15 +43,35 @@ public static class ParameterShapeExtensions /// Gets the actual underlying type of the parameter, unwrapping things like nullables and optionals. /// /// The parameter shape. + /// Whether to unwrap collections as well. /// The actual type. - public static Type GetActualParameterType(this IParameterShape shape) + public static Type GetActualParameterType(this IParameterShape shape, bool unwrapCollections = false) { + var parameterType = shape.Parameter.ParameterType; + // Unwrap the parameter type if it's a Nullable or Optional // TODO: Maybe more cases? - var parameterType = shape.Parameter.ParameterType; - return parameterType.IsNullable() || parameterType.IsOptional() + parameterType = parameterType.IsNullable() || parameterType.IsOptional() ? parameterType.GetGenericArguments().Single() : parameterType; + + // IsCollection loves to inexplicably return false for IReadOnlyList and friends, so we'll just do it manually + if (!unwrapCollections || parameterType == typeof(string)) + { + return parameterType; + } + + var interfaces = parameterType.GetInterfaces(); + var collectionTypes = interfaces + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + .ToList(); + + return collectionTypes.Count switch + { + 0 => parameterType, + 1 => collectionTypes[0].GetGenericArguments()[0], + _ => throw new InvalidOperationException($"{parameterType.Name} has multiple implementations for IEnumerable<>, which is ambiguous.") + }; } /// @@ -66,7 +87,7 @@ public static ApplicationCommandOptionType GetDiscordType(this IParameterShape s return (ApplicationCommandOptionType)typeHint.TypeHint; } - return shape.GetActualParameterType() switch + return shape.GetActualParameterType(true) switch { var t when t == typeof(bool) => ApplicationCommandOptionType.Boolean, var t when typeof(IPartialRole).IsAssignableFrom(t) => Role, diff --git a/Tests/Remora.Discord.Commands.Tests/Extensions/CommandTreeExtensionTests.cs b/Tests/Remora.Discord.Commands.Tests/Extensions/CommandTreeExtensionTests.cs index edca340e69..53d76f2b8a 100644 --- a/Tests/Remora.Discord.Commands.Tests/Extensions/CommandTreeExtensionTests.cs +++ b/Tests/Remora.Discord.Commands.Tests/Extensions/CommandTreeExtensionTests.cs @@ -112,20 +112,6 @@ public void ThrowsIfAGroupHasTooManyCommands() Assert.Throws(() => tree.CreateApplicationCommands()); } - /// - /// Tests whether the method responds appropriately to a failure case. - /// - [Fact] - public void ThrowsIfACommandContainsACollectionParameter() - { - var builder = new CommandTreeBuilder(); - builder.RegisterModule(); - - var tree = builder.Build(); - - Assert.Throws(() => tree.CreateApplicationCommands()); - } - /// /// Tests whether the method responds appropriately to a failure case. ///