Skip to content

Commit 26dc5e4

Browse files
authored
Merge pull request #340 from VelvetToroyashi/velvet/feat/variadic-slash-arguments
Add array support for slash commands
2 parents 6d28bbb + 71a2596 commit 26dc5e4

File tree

4 files changed

+109
-54
lines changed

4 files changed

+109
-54
lines changed

Remora.Discord.Commands/Extensions/ApplicationCommandDataExtensions.cs

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
using System.Collections;
2525
using System.Collections.Generic;
2626
using System.Linq;
27+
using System.Text.RegularExpressions;
2728
using JetBrains.Annotations;
2829
using Remora.Discord.API.Abstractions.Objects;
2930

@@ -35,6 +36,8 @@ namespace Remora.Discord.Commands.Extensions;
3536
[PublicAPI]
3637
public static class ApplicationCommandDataExtensions
3738
{
39+
private static readonly Regex _parameterNameRegex = new(@"(?<Name>\S+)__\d{1,2}$", RegexOptions.Compiled);
40+
3841
/// <summary>
3942
/// Unpacks an interaction into a command name string and a set of parameters.
4043
/// </summary>
@@ -100,15 +103,31 @@ out IReadOnlyDictionary<string, IReadOnlyList<string>>? parameters
100103

101104
if (options.Count > 1)
102105
{
103-
// multiple parameters
104-
var unpackedParameters = new Dictionary<string, IReadOnlyList<string>>();
106+
var tempParameters = new Dictionary<string, IReadOnlyList<string>>();
105107
foreach (var option in options)
106108
{
107109
var (name, values) = UnpackInteractionParameter(option);
108-
unpackedParameters.Add(name, values);
110+
111+
name = _parameterNameRegex.Replace(name, "$1");
112+
if (!tempParameters.TryGetValue(name, out var existingValues))
113+
{
114+
tempParameters[name] = values;
115+
}
116+
else
117+
{
118+
if (existingValues is List<string> casted)
119+
{
120+
casted.AddRange(values);
121+
}
122+
else
123+
{
124+
tempParameters[name] = (List<string>)[..existingValues, ..values];
125+
}
126+
}
109127
}
110128

111-
parameters = unpackedParameters;
129+
parameters = tempParameters;
130+
112131
return;
113132
}
114133

@@ -154,16 +173,19 @@ IApplicationCommandInteractionDataOption option
154173
throw new InvalidOperationException();
155174
}
156175

157-
var values = new List<string>();
158176
if (optionValue.Value is ICollection collection)
159177
{
160-
values.AddRange(collection.Cast<object>().Select(o => o.ToString() ?? string.Empty));
178+
var valueStrings = collection.Cast<object>().Select(o => o.ToString() ?? string.Empty).ToArray();
179+
return (option.Name, valueStrings);
161180
}
162181
else
163182
{
164-
values.Add(optionValue.Value.ToString() ?? string.Empty);
165-
}
183+
var value = optionValue.Value.ToString() ?? string.Empty;
166184

167-
return (option.Name, values);
185+
#pragma warning disable SA1010 // Stylecop doesn't handle collection expressions correctly here
186+
// Casting to string[] is optional, but absolves reliance on Roslyn making an inline array here.
187+
return (option.Name, (string[])[value]);
188+
#pragma warning restore SA1010
189+
}
168190
}
169191
}

Remora.Discord.Commands/Extensions/CommandTreeExtensions.cs

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
using Remora.Discord.Commands.Services;
3939
using Remora.Rest.Core;
4040
using static Remora.Discord.API.Abstractions.Objects.ApplicationCommandOptionType;
41+
using RangeAttribute = Remora.Commands.Attributes.RangeAttribute;
4142

4243
namespace Remora.Discord.Commands.Extensions;
4344

@@ -712,15 +713,6 @@ ILocalizationProvider localizationProvider
712713
parameter
713714
);
714715
}
715-
case NamedCollectionParameterShape or PositionalCollectionParameterShape:
716-
{
717-
throw new UnsupportedParameterFeatureException
718-
(
719-
"Collection parameters are not supported in slash commands.",
720-
command,
721-
parameter
722-
);
723-
}
724716
}
725717

726718
var actualParameterType = parameter.GetActualParameterType();
@@ -739,27 +731,61 @@ ILocalizationProvider localizationProvider
739731

740732
var (channelTypes, minValue, maxValue, minLength, maxLength) = GetParameterConstraints(command, parameter);
741733

742-
var parameterOption = new ApplicationCommandOption
743-
(
744-
parameter.GetDiscordType(),
745-
name,
746-
parameter.Description,
747-
default,
748-
!parameter.IsOmissible(),
749-
choices,
750-
ChannelTypes: channelTypes,
751-
EnableAutocomplete: enableAutocomplete,
752-
MinValue: minValue,
753-
MaxValue: maxValue,
754-
NameLocalizations: localizedNames.Count > 0 ? new(localizedNames) : default,
755-
DescriptionLocalizations: localizedDescriptions.Count > 0 ? new(localizedDescriptions) : default,
756-
MinLength: minLength,
757-
MaxLength: maxLength
758-
);
734+
if (parameter is not (NamedCollectionParameterShape or PositionalCollectionParameterShape))
735+
{
736+
var parameterOption = new ApplicationCommandOption
737+
(
738+
parameter.GetDiscordType(),
739+
name,
740+
parameter.Description,
741+
default,
742+
!parameter.IsOmissible(),
743+
choices,
744+
ChannelTypes: channelTypes,
745+
EnableAutocomplete: enableAutocomplete,
746+
MinValue: minValue,
747+
MaxValue: maxValue,
748+
NameLocalizations: localizedNames.Count > 0 ? new(localizedNames) : default,
749+
DescriptionLocalizations: localizedDescriptions.Count > 0 ? new(localizedDescriptions) : default,
750+
MinLength: minLength,
751+
MaxLength: maxLength
752+
);
759753

760-
parameterOptions.Add(parameterOption);
754+
parameterOptions.Add(parameterOption);
755+
756+
continue;
757+
}
758+
759+
// Collection parameters
760+
var rangeAttribute = parameter.Parameter.GetCustomAttribute<RangeAttribute>();
761+
var (minElements, maxElements) = (rangeAttribute?.GetMin() ?? 1, rangeAttribute?.GetMax());
762+
763+
for (ulong i = 0; i < (maxElements ?? minElements); i++)
764+
{
765+
var parameterOption = new ApplicationCommandOption
766+
(
767+
parameter.GetDiscordType(),
768+
$"{name}__{i + 1}",
769+
parameter.Description,
770+
default,
771+
i < minElements && !parameter.IsOmissible(),
772+
choices,
773+
ChannelTypes: channelTypes,
774+
EnableAutocomplete: enableAutocomplete,
775+
MinValue: minValue,
776+
MaxValue: maxValue,
777+
NameLocalizations: localizedNames.Count > 0 ? new(localizedNames) : default,
778+
DescriptionLocalizations: localizedDescriptions.Count > 0 ? new(localizedDescriptions) : default,
779+
MinLength: minLength,
780+
MaxLength: maxLength
781+
);
782+
783+
parameterOptions.Add(parameterOption);
784+
}
761785
}
762786

787+
parameterOptions = parameterOptions.OrderByDescending(p => p.IsRequired.OrDefault(true)).ToList();
788+
763789
if (parameterOptions.Count > _maxCommandParameters)
764790
{
765791
throw new UnsupportedFeatureException

Remora.Discord.Commands/Extensions/ParameterShapeExtensions.cs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
//
2222

2323
using System;
24+
using System.Collections.Generic;
2425
using System.Linq;
2526
using JetBrains.Annotations;
2627
using Remora.Commands.Extensions;
@@ -42,15 +43,35 @@ public static class ParameterShapeExtensions
4243
/// Gets the actual underlying type of the parameter, unwrapping things like nullables and optionals.
4344
/// </summary>
4445
/// <param name="shape">The parameter shape.</param>
46+
/// <param name="unwrapCollections">Whether to unwrap collections as well.</param>
4547
/// <returns>The actual type.</returns>
46-
public static Type GetActualParameterType(this IParameterShape shape)
48+
public static Type GetActualParameterType(this IParameterShape shape, bool unwrapCollections = false)
4749
{
50+
var parameterType = shape.Parameter.ParameterType;
51+
4852
// Unwrap the parameter type if it's a Nullable<T> or Optional<T>
4953
// TODO: Maybe more cases?
50-
var parameterType = shape.Parameter.ParameterType;
51-
return parameterType.IsNullable() || parameterType.IsOptional()
54+
parameterType = parameterType.IsNullable() || parameterType.IsOptional()
5255
? parameterType.GetGenericArguments().Single()
5356
: parameterType;
57+
58+
// IsCollection loves to inexplicably return false for IReadOnlyList<T> and friends, so we'll just do it manually
59+
if (!unwrapCollections || parameterType == typeof(string))
60+
{
61+
return parameterType;
62+
}
63+
64+
var interfaces = parameterType.GetInterfaces();
65+
var collectionTypes = interfaces
66+
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>))
67+
.ToList();
68+
69+
return collectionTypes.Count switch
70+
{
71+
0 => parameterType,
72+
1 => collectionTypes[0].GetGenericArguments()[0],
73+
_ => throw new InvalidOperationException($"{parameterType.Name} has multiple implementations for IEnumerable<>, which is ambiguous.")
74+
};
5475
}
5576

5677
/// <summary>
@@ -66,7 +87,7 @@ public static ApplicationCommandOptionType GetDiscordType(this IParameterShape s
6687
return (ApplicationCommandOptionType)typeHint.TypeHint;
6788
}
6889

69-
return shape.GetActualParameterType() switch
90+
return shape.GetActualParameterType(true) switch
7091
{
7192
var t when t == typeof(bool) => ApplicationCommandOptionType.Boolean,
7293
var t when typeof(IPartialRole).IsAssignableFrom(t) => Role,

Tests/Remora.Discord.Commands.Tests/Extensions/CommandTreeExtensionTests.cs

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -112,20 +112,6 @@ public void ThrowsIfAGroupHasTooManyCommands()
112112
Assert.Throws<UnsupportedFeatureException>(() => tree.CreateApplicationCommands());
113113
}
114114

115-
/// <summary>
116-
/// Tests whether the method responds appropriately to a failure case.
117-
/// </summary>
118-
[Fact]
119-
public void ThrowsIfACommandContainsACollectionParameter()
120-
{
121-
var builder = new CommandTreeBuilder();
122-
builder.RegisterModule<CollectionsAreNotSupported>();
123-
124-
var tree = builder.Build();
125-
126-
Assert.Throws<UnsupportedParameterFeatureException>(() => tree.CreateApplicationCommands());
127-
}
128-
129115
/// <summary>
130116
/// Tests whether the method responds appropriately to a failure case.
131117
/// </summary>

0 commit comments

Comments
 (0)