Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add array support for slash commands #340

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -35,6 +36,8 @@
[PublicAPI]
public static class ApplicationCommandDataExtensions
{
private static readonly Regex _parameterNameRegex = new(@"(?<Name>\S+)__\d{1,2}$", RegexOptions.Compiled);

/// <summary>
/// Unpacks an interaction into a command name string and a set of parameters.
/// </summary>
Expand Down Expand Up @@ -100,15 +103,31 @@

if (options.Count > 1)
{
// multiple parameters
var unpackedParameters = new Dictionary<string, IReadOnlyList<string>>();
var tempParameters = new Dictionary<string, IReadOnlyList<string>>();
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<string> casted)
{
casted.AddRange(values);
}
else
{
tempParameters[name] = (List<string>)[..existingValues, ..values];

Check warning

Code scanning / InspectCode

Redundant cast Warning

Type cast is redundant
}
}
}

parameters = unpackedParameters;
parameters = tempParameters;

return;
}

Expand Down Expand Up @@ -154,16 +173,19 @@
throw new InvalidOperationException();
}

var values = new List<string>();
if (optionValue.Value is ICollection collection)
{
values.AddRange(collection.Cast<object>().Select(o => o.ToString() ?? string.Empty));
var valueStrings = collection.Cast<object>().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]);

Check warning

Code scanning / InspectCode

Redundant cast Warning

Type cast is redundant
#pragma warning restore SA1010
}
}
}
81 changes: 54 additions & 27 deletions Remora.Discord.Commands/Extensions/CommandTreeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Services;
using Remora.Rest.Core;
using Remora.Rest.Extensions;

Check warning

Code scanning / InspectCode

Redundant using directive Warning

Using directive is not required by the code and can be safely removed
using static Remora.Discord.API.Abstractions.Objects.ApplicationCommandOptionType;
using RangeAttribute = Remora.Commands.Attributes.RangeAttribute;

namespace Remora.Discord.Commands.Extensions;

Expand Down Expand Up @@ -712,15 +714,6 @@
parameter
);
}
case NamedCollectionParameterShape or PositionalCollectionParameterShape:
{
throw new UnsupportedParameterFeatureException
(
"Collection parameters are not supported in slash commands.",
command,
parameter
);
}
}

var actualParameterType = parameter.GetActualParameterType();
Expand All @@ -739,27 +732,61 @@

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<RangeAttribute>();
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
Expand Down
29 changes: 25 additions & 4 deletions Remora.Discord.Commands/Extensions/ParameterShapeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
//

using System;
using System.Collections.Generic;
Fixed Show fixed Hide fixed
using System.Linq;
using JetBrains.Annotations;
using Remora.Commands.Extensions;
Expand All @@ -42,15 +43,35 @@ public static class ParameterShapeExtensions
/// Gets the actual underlying type of the parameter, unwrapping things like nullables and optionals.
/// </summary>
/// <param name="shape">The parameter shape.</param>
/// <param name="unwrapCollections">Whether to unwrap collections as well.</param>
/// <returns>The actual type.</returns>
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<T> or Optional<T>
// 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<T> 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.")
};
}

/// <summary>
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,20 +112,6 @@ public void ThrowsIfAGroupHasTooManyCommands()
Assert.Throws<UnsupportedFeatureException>(() => tree.CreateApplicationCommands());
}

/// <summary>
/// Tests whether the method responds appropriately to a failure case.
/// </summary>
[Fact]
public void ThrowsIfACommandContainsACollectionParameter()
{
var builder = new CommandTreeBuilder();
builder.RegisterModule<CollectionsAreNotSupported>();

var tree = builder.Build();

Assert.Throws<UnsupportedParameterFeatureException>(() => tree.CreateApplicationCommands());
}

/// <summary>
/// Tests whether the method responds appropriately to a failure case.
/// </summary>
Expand Down
Loading