diff --git a/src/Discord.Net.Interactions/Builders/Commands/ComponentCommandBuilder.cs b/src/Discord.Net.Interactions/Builders/Commands/ComponentCommandBuilder.cs index e42dfabce..dd857498c 100644 --- a/src/Discord.Net.Interactions/Builders/Commands/ComponentCommandBuilder.cs +++ b/src/Discord.Net.Interactions/Builders/Commands/ComponentCommandBuilder.cs @@ -5,7 +5,7 @@ namespace Discord.Interactions.Builders /// /// Represents a builder for creating . /// - public sealed class ComponentCommandBuilder : CommandBuilder + public sealed class ComponentCommandBuilder : CommandBuilder { protected override ComponentCommandBuilder Instance => this; @@ -26,9 +26,9 @@ namespace Discord.Interactions.Builders /// /// The builder instance. /// - public override ComponentCommandBuilder AddParameter (Action configure) + public override ComponentCommandBuilder AddParameter (Action configure) { - var parameter = new CommandParameterBuilder(this); + var parameter = new ComponentCommandParameterBuilder(this); configure(parameter); AddParameters(parameter); return this; diff --git a/src/Discord.Net.Interactions/Builders/Parameters/ComponentCommandParameterBuilder.cs b/src/Discord.Net.Interactions/Builders/Parameters/ComponentCommandParameterBuilder.cs index 666fbef88..56de1fc36 100644 --- a/src/Discord.Net.Interactions/Builders/Parameters/ComponentCommandParameterBuilder.cs +++ b/src/Discord.Net.Interactions/Builders/Parameters/ComponentCommandParameterBuilder.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Discord.Interactions.Builders { @@ -29,7 +25,7 @@ namespace Discord.Interactions.Builders public ComponentCommandParameterBuilder SetParameterType(Type type, IServiceProvider services = null) { base.SetParameterType(type); - TypeReader = Command.Module.InteractionService.GetTypeConverter(ParameterType, services); + TypeReader = Command.Module.InteractionService.GetTypeReader(ParameterType, services); return this; } diff --git a/src/Discord.Net.Interactions/Entities/ITypeHandler.cs b/src/Discord.Net.Interactions/Entities/ITypeHandler.cs new file mode 100644 index 000000000..4626ed850 --- /dev/null +++ b/src/Discord.Net.Interactions/Entities/ITypeHandler.cs @@ -0,0 +1,9 @@ +using System; + +namespace Discord.Interactions +{ + internal interface ITypeHandler + { + public bool CanConvertTo(Type type); + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs index 0e43af3a8..87fc1664e 100644 --- a/src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs +++ b/src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs @@ -11,10 +11,10 @@ namespace Discord.Interactions /// /// Represents the info class of an attribute based method for handling Component Interaction events. /// - public class ComponentCommandInfo : CommandInfo + public class ComponentCommandInfo : CommandInfo { /// - public override IReadOnlyCollection Parameters { get; } + public override IReadOnlyCollection Parameters { get; } /// public override bool SupportsWildCards => true; @@ -73,14 +73,16 @@ namespace Discord.Interactions if (componentValues is not null) { - if (Parameters.Last().ParameterType == typeof(string[])) - args[args.Length - 1] = componentValues.ToArray(); + var lastParam = Parameters.Last(); + + if (lastParam.ParameterType.IsArray) + args[args.Length - 1] = componentValues.Select(async x => await lastParam.TypeReader.ReadAsync(context, x, services).ConfigureAwait(false)).ToArray(); else return ExecuteResult.FromError(InteractionCommandError.BadArgs, $"Select Menu Interaction handlers must accept a {typeof(string[]).FullName} as its last parameter"); } for (var i = 0; i < strCount; i++) - args[i] = values.ElementAt(i); + args[i] = await Parameters.ElementAt(i).TypeReader.ReadAsync(context, values.ElementAt(i), services).ConfigureAwait(false); return await RunAsync(context, args, services).ConfigureAwait(false); } diff --git a/src/Discord.Net.Interactions/InteractionService.cs b/src/Discord.Net.Interactions/InteractionService.cs index f8202096f..38fbb7549 100644 --- a/src/Discord.Net.Interactions/InteractionService.cs +++ b/src/Discord.Net.Interactions/InteractionService.cs @@ -66,8 +66,8 @@ namespace Discord.Interactions private readonly CommandMap _autocompleteCommandMap; private readonly CommandMap _modalCommandMap; private readonly HashSet _moduleDefs; - private readonly ConcurrentDictionary _typeConverters; - private readonly ConcurrentDictionary _genericTypeConverters; + private readonly TypeMap _typeConverterMap; + private readonly TypeMap _typeReaderMap; private readonly ConcurrentDictionary _autocompleteHandlers = new(); private readonly ConcurrentDictionary _modalInfos = new(); private readonly SemaphoreSlim _lock; @@ -179,7 +179,10 @@ namespace Discord.Interactions _autoServiceScopes = config.AutoServiceScopes; _restResponseCallback = config.RestResponseCallback; - _genericTypeConverters = new ConcurrentDictionary + _typeConverterMap = new TypeMap(this, new Dictionary + { + [typeof(TimeSpan)] = new TimeSpanConverter() + }, new Dictionary { [typeof(IChannel)] = typeof(DefaultChannelConverter<>), [typeof(IRole)] = typeof(DefaultRoleConverter<>), @@ -189,12 +192,9 @@ namespace Discord.Interactions [typeof(IConvertible)] = typeof(DefaultValueConverter<>), [typeof(Enum)] = typeof(EnumConverter<>), [typeof(Nullable<>)] = typeof(NullableConverter<>), - }; + }); - _typeConverters = new ConcurrentDictionary - { - [typeof(TimeSpan)] = new TimeSpanConverter() - }; + _typeReaderMap = new TypeMap(this); } /// @@ -769,61 +769,24 @@ namespace Discord.Interactions return await commandResult.Command.ExecuteAsync(context, services).ConfigureAwait(false); } - private async Task ExecuteModalCommandAsync(IInteractionContext context, string input, IServiceProvider services) - { - var result = _modalCommandMap.GetCommand(input); - - if (!result.IsSuccess) - { - await _cmdLogger.DebugAsync($"Unknown custom interaction id, skipping execution ({input.ToUpper()})"); - - await _componentCommandExecutedEvent.InvokeAsync(null, context, result).ConfigureAwait(false); - return result; - } - return await result.Command.ExecuteAsync(context, services, result.RegexCaptureGroups).ConfigureAwait(false); - } - - internal TypeConverter GetTypeConverter (Type type, IServiceProvider services = null) - { - if (_typeConverters.TryGetValue(type, out var specific)) - return specific; - else if (_genericTypeConverters.Any(x => x.Key.IsAssignableFrom(type) - || (x.Key.IsGenericTypeDefinition && type.IsGenericType && x.Key.GetGenericTypeDefinition() == type.GetGenericTypeDefinition()))) - { - services ??= EmptyServiceProvider.Instance; - - var converterType = GetMostSpecificTypeConverter(type); - var converter = ReflectionUtils.CreateObject(converterType.MakeGenericType(type).GetTypeInfo(), this, services); - _typeConverters[type] = converter; - return converter; - } - - else if (_typeConverters.Any(x => x.Value.CanConvertTo(type))) - return _typeConverters.First(x => x.Value.CanConvertTo(type)).Value; - - throw new ArgumentException($"No type {nameof(TypeConverter)} is defined for this {type.FullName}", "type"); - } + internal TypeConverter GetTypeConverter(Type type, IServiceProvider services = null) + => _typeConverterMap.Get(type, services); /// /// Add a concrete type . /// /// Primary target of the . /// The instance. - public void AddTypeConverter (TypeConverter converter) => - AddTypeConverter(typeof(T), converter); + public void AddTypeConverter(TypeConverter converter) => + _typeConverterMap.AddConcrete(converter); /// /// Add a concrete type . /// /// Primary target of the . /// The instance. - public void AddTypeConverter (Type type, TypeConverter converter) - { - if (!converter.CanConvertTo(type)) - throw new ArgumentException($"This {converter.GetType().FullName} cannot read {type.FullName} and cannot be registered as its {nameof(TypeConverter)}"); - - _typeConverters[type] = converter; - } + public void AddTypeConverter(Type type, TypeConverter converter) => + _typeConverterMap.AddConcrete(type, converter); /// /// Add a generic type . @@ -831,58 +794,55 @@ namespace Discord.Interactions /// Generic Type constraint of the of the . /// Type of the . - public void AddGenericTypeConverter (Type converterType) => - AddGenericTypeConverter(typeof(T), converterType); + public void AddGenericTypeConverter(Type converterType) => + _typeConverterMap.AddGeneric(converterType); /// /// Add a generic type . /// /// Generic Type constraint of the of the . /// Type of the . - public void AddGenericTypeConverter (Type targetType, Type converterType) - { - if (!converterType.IsGenericTypeDefinition) - throw new ArgumentException($"{converterType.FullName} is not generic."); - - var genericArguments = converterType.GetGenericArguments(); + public void AddGenericTypeConverter(Type targetType, Type converterType) => + _typeConverterMap.AddGeneric(targetType, converterType); - if (genericArguments.Count() > 1) - throw new InvalidOperationException($"Valid generic {converterType.FullName}s cannot have more than 1 generic type parameter"); + internal TypeReader GetTypeReader(Type type, IServiceProvider services = null) + => _typeReaderMap.Get(type, services); - var constraints = genericArguments.SelectMany(x => x.GetGenericParameterConstraints()); - - if (!constraints.Any(x => x.IsAssignableFrom(targetType))) - throw new InvalidOperationException($"This generic class does not support type {targetType.FullName}"); + /// + /// Add a concrete type . + /// + /// Primary target of the . + /// The instance. + public void AddTypeReader(TypeReader reader) => + _typeReaderMap.AddConcrete(reader); /// - /// Serialize an object using a into a to be placed in a Component CustomId. + /// Add a concrete type . /// - /// Type of the object to be serialized. - /// Object to be serialized. - /// Services that will be passed on to the TypeReader. - /// - /// A task representing the conversion process. The task result contains the result of the conversion. - /// - public Task SerializeValueAsync(T obj, IServiceProvider services = null) => - _typeReaderMap.Get(typeof(T), services).SerializeAsync(obj); + /// Primary target of the . + /// The instance. + public void AddTypeReader(Type type, TypeReader converter) => + _typeReaderMap.AddConcrete(type, converter); /// - /// Loads and caches an for the provided . + /// Add a generic type . /// - /// Type of to be loaded. - /// - /// The built instance. - /// - /// - public ModalInfo AddModalInfo() where T : class, IModal - { - var type = typeof(T); + /// Generic Type constraint of the of the . + /// Type of the . - if (_modalInfos.ContainsKey(type)) - throw new InvalidOperationException($"Modal type {type.FullName} already exists."); + public void AddGenericTypeReader(Type readerType) => + _typeReaderMap.AddGeneric(readerType); - return ModalUtils.GetOrAdd(type); - } + /// + /// Add a generic type . + /// + /// Generic Type constraint of the of the . + /// Type of the . + public void AddGenericTypeReader(Type targetType, Type readerType) => + _typeConverterMap.AddGeneric(targetType, readerType); + + public string SerializeWithTypeReader(object obj, IServiceProvider services = null) => + _typeReaderMap.Get(typeof(T), services)?.Serialize(obj); internal IAutocompleteHandler GetAutocompleteHandler(Type autocompleteHandlerType, IServiceProvider services = null) { @@ -1043,21 +1003,6 @@ namespace Discord.Interactions _lock.Dispose(); } - private Type GetMostSpecificTypeConverter (Type type) - { - if (_genericTypeConverters.TryGetValue(type, out var matching)) - return matching; - - if (type.IsGenericType && _genericTypeConverters.TryGetValue(type.GetGenericTypeDefinition(), out var genericDefinition)) - return genericDefinition; - - var typeInterfaces = type.GetInterfaces(); - var candidates = _genericTypeConverters.Where(x => x.Key.IsAssignableFrom(type)) - .OrderByDescending(x => typeInterfaces.Count(y => y.IsAssignableFrom(x.Key))); - - return candidates.First().Value; - } - private void EnsureClientReady() { if (RestClient?.CurrentUser is null || RestClient?.CurrentUser?.Id == 0) diff --git a/src/Discord.Net.Interactions/Map/TypeMap.cs b/src/Discord.Net.Interactions/Map/TypeMap.cs new file mode 100644 index 000000000..d08423657 --- /dev/null +++ b/src/Discord.Net.Interactions/Map/TypeMap.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Discord.Interactions +{ + internal class TypeMap where T : class, ITypeHandler + { + private readonly ConcurrentDictionary _concretes; + private readonly ConcurrentDictionary _generics; + private readonly InteractionService _interactionService; + + public TypeMap(InteractionService interactionService, IDictionary concretes = null, IDictionary generics = null) + { + _interactionService = interactionService; + _concretes = concretes is not null ? new(concretes) : new(); + _generics = generics is not null ? new(generics) : new(); + } + + internal T Get(Type type, IServiceProvider services = null) + { + if (_concretes.TryGetValue(type, out var specific)) + return specific; + + else if (_generics.Any(x => x.Key.IsAssignableFrom(type) + || (x.Key.IsGenericTypeDefinition && type.IsGenericType && x.Key.GetGenericTypeDefinition() == type.GetGenericTypeDefinition()))) + { + services ??= EmptyServiceProvider.Instance; + + var converterType = GetMostSpecific(type); + var converter = ReflectionUtils.CreateObject(converterType.MakeGenericType(type).GetTypeInfo(), _interactionService, services); + _concretes[type] = converter; + return converter; + } + + else if (_concretes.Any(x => x.Value.CanConvertTo(type))) + return _concretes.First(x => x.Value.CanConvertTo(type)).Value; + + throw new ArgumentException($"No type {nameof(T)} is defined for this {type.FullName}", "type"); + } + + public void AddConcrete(T converter) => + AddConcrete(typeof(TTarget), converter); + + public void AddConcrete(Type type, T converter) + { + if (!converter.CanConvertTo(type)) + throw new ArgumentException($"This {converter.GetType().FullName} cannot read {type.FullName} and cannot be registered as its {nameof(TypeConverter)}"); + + _concretes[type] = converter; + } + + public void AddGeneric(Type converterType) => + AddGeneric(typeof(TTarget), converterType); + + public void AddGeneric(Type targetType, Type converterType) + { + if (!converterType.IsGenericTypeDefinition) + throw new ArgumentException($"{converterType.FullName} is not generic."); + + var genericArguments = converterType.GetGenericArguments(); + + if (genericArguments.Count() > 1) + throw new InvalidOperationException($"Valid generic {converterType.FullName}s cannot have more than 1 generic type parameter"); + + var constraints = genericArguments.SelectMany(x => x.GetGenericParameterConstraints()); + + if (!constraints.Any(x => x.IsAssignableFrom(targetType))) + throw new InvalidOperationException($"This generic class does not support type {targetType.FullName}"); + + _generics[targetType] = converterType; + } + + private Type GetMostSpecific(Type type) + { + if (_generics.TryGetValue(type, out var matching)) + return matching; + + if (type.IsGenericType && _generics.TryGetValue(type.GetGenericTypeDefinition(), out var genericDefinition)) + return genericDefinition; + + var typeInterfaces = type.GetInterfaces(); + var candidates = _generics.Where(x => x.Key.IsAssignableFrom(type)) + .OrderByDescending(x => typeInterfaces.Count(y => y.IsAssignableFrom(x.Key))); + + return candidates.First().Value; + } + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/TypeConverter.cs b/src/Discord.Net.Interactions/TypeConverters/TypeConverter.cs index 360b6ce4a..8361831be 100644 --- a/src/Discord.Net.Interactions/TypeConverters/TypeConverter.cs +++ b/src/Discord.Net.Interactions/TypeConverters/TypeConverter.cs @@ -6,7 +6,7 @@ namespace Discord.Interactions /// /// Base class for creating TypeConverters. uses TypeConverters to interface with Slash Command parameters. /// - public abstract class TypeConverter + public abstract class TypeConverter : ITypeHandler { /// /// Will be used to search for alternative TypeConverters whenever the Command Service encounters an unknown parameter type. diff --git a/src/Discord.Net.Interactions/TypeReaders/ChannelTypeReader.cs b/src/Discord.Net.Interactions/TypeReaders/ChannelTypeReader.cs new file mode 100644 index 000000000..8b9ef8d54 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeReaders/ChannelTypeReader.cs @@ -0,0 +1,45 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// A for parsing objects implementing . + /// + /// + /// This is shipped with Discord.Net and is used by default to parse any + /// implemented object within a command. The TypeReader will attempt to first parse the + /// input by mention, then the snowflake identifier, then by name; the highest candidate will be chosen as the + /// final output; otherwise, an erroneous is returned. + /// + /// The type to be checked; must implement . + public class ChannelTypeReader : TypeReader + where T : class, IChannel + { + /// + public override async Task ReadAsync(IInteractionContext context, object input, IServiceProvider services) + { + if (context.Guild is null) + { + var str = input as string; + + if (ulong.TryParse(str, out var channelId)) + return TypeConverterResult.FromSuccess(await context.Guild.GetChannelAsync(channelId).ConfigureAwait(false)); + + if (MentionUtils.TryParseChannel(str, out channelId)) + return TypeConverterResult.FromSuccess(await context.Guild.GetChannelAsync(channelId).ConfigureAwait(false)); + + var channels = await context.Guild.GetChannelsAsync().ConfigureAwait(false); + var nameMatch = channels.FirstOrDefault(x => string.Equals(x.Name, str, StringComparison.OrdinalIgnoreCase)); + + if (nameMatch is not null) + return TypeConverterResult.FromSuccess(nameMatch); + } + + return TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, "Channel not found."); + } + + public override string Serialize(object value) => (value as IChannel)?.Id.ToString(); + } +} diff --git a/src/Discord.Net.Interactions/TypeReaders/EnumTypeReader.cs b/src/Discord.Net.Interactions/TypeReaders/EnumTypeReader.cs new file mode 100644 index 000000000..4c9773b5b --- /dev/null +++ b/src/Discord.Net.Interactions/TypeReaders/EnumTypeReader.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal class EnumTypeReader : TypeReader where T : struct, Enum + { + /// + public override Task ReadAsync(IInteractionContext context, string input, IServiceProvider services) + { + if (Enum.TryParse(input, out var result)) + return Task.FromResult(TypeConverterResult.FromSuccess(result)); + else + return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"Value {input} cannot be converted to {nameof(T)}")); + } + + public override string Serialize(object value) => value.ToString(); + } +} diff --git a/src/Discord.Net.Interactions/TypeReaders/MessageTypeReader.cs b/src/Discord.Net.Interactions/TypeReaders/MessageTypeReader.cs new file mode 100644 index 000000000..67100d7d0 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeReaders/MessageTypeReader.cs @@ -0,0 +1,27 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// A for parsing objects implementing . + /// + /// The type to be checked; must implement . + public class MessageTypeReader : TypeReader + where T : class, IMessage + { + /// + public override async Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + //By Id (1.0) + if (ulong.TryParse(input, NumberStyles.None, CultureInfo.InvariantCulture, out ulong id)) + { + if (await context.Channel.GetMessageAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) is T msg) + return TypeReaderResult.FromSuccess(msg); + } + + return TypeReaderResult.FromError(CommandError.ObjectNotFound, "Message not found."); + } + } +} diff --git a/src/Discord.Net.Interactions/TypeReaders/NullableTypeReader.cs b/src/Discord.Net.Interactions/TypeReaders/NullableTypeReader.cs new file mode 100644 index 000000000..f68bf6e2c --- /dev/null +++ b/src/Discord.Net.Interactions/TypeReaders/NullableTypeReader.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal static class NullableTypeReader + { + public static TypeReader Create(Type type, TypeReader reader) + { + var constructor = typeof(NullableTypeReader<>).MakeGenericType(type).GetTypeInfo().DeclaredConstructors.First(); + return (TypeReader)constructor.Invoke(new object[] { reader }); + } + } + + internal class NullableTypeReader : TypeReader + where T : struct + { + private readonly TypeReader _baseTypeReader; + + public NullableTypeReader(TypeReader baseTypeReader) + { + _baseTypeReader = baseTypeReader; + } + + /// + public override async Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + if (string.Equals(input, "null", StringComparison.OrdinalIgnoreCase) || string.Equals(input, "nothing", StringComparison.OrdinalIgnoreCase)) + return TypeReaderResult.FromSuccess(new T?()); + return await _baseTypeReader.ReadAsync(context, input, services).ConfigureAwait(false); + } + } +} diff --git a/src/Discord.Net.Interactions/TypeReaders/RoleTypeReader.cs b/src/Discord.Net.Interactions/TypeReaders/RoleTypeReader.cs new file mode 100644 index 000000000..4c9aaf4d8 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeReaders/RoleTypeReader.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// A for parsing objects implementing . + /// + /// The type to be checked; must implement . + public class RoleTypeReader : TypeReader + where T : class, IRole + { + /// + public override Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + if (context.Guild != null) + { + var results = new Dictionary(); + var roles = context.Guild.Roles; + + //By Mention (1.0) + if (MentionUtils.TryParseRole(input, out var id)) + AddResult(results, context.Guild.GetRole(id) as T, 1.00f); + + //By Id (0.9) + if (ulong.TryParse(input, NumberStyles.None, CultureInfo.InvariantCulture, out id)) + AddResult(results, context.Guild.GetRole(id) as T, 0.90f); + + //By Name (0.7-0.8) + foreach (var role in roles.Where(x => string.Equals(input, x.Name, StringComparison.OrdinalIgnoreCase))) + AddResult(results, role as T, role.Name == input ? 0.80f : 0.70f); + + if (results.Count > 0) + return Task.FromResult(TypeReaderResult.FromSuccess(results.Values.ToReadOnlyCollection())); + } + return Task.FromResult(TypeReaderResult.FromError(CommandError.ObjectNotFound, "Role not found.")); + } + + private void AddResult(Dictionary results, T role, float score) + { + if (role != null && !results.ContainsKey(role.Id)) + results.Add(role.Id, new TypeReaderValue(role, score)); + } + } +} diff --git a/src/Discord.Net.Interactions/TypeReaders/TimeSpanTypeReader.cs b/src/Discord.Net.Interactions/TypeReaders/TimeSpanTypeReader.cs new file mode 100644 index 000000000..5448553b3 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeReaders/TimeSpanTypeReader.cs @@ -0,0 +1,55 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal class TimeSpanTypeReader : TypeReader + { + /// + /// TimeSpan try parse formats. + /// + private static readonly string[] Formats = + { + "%d'd'%h'h'%m'm'%s's'", // 4d3h2m1s + "%d'd'%h'h'%m'm'", // 4d3h2m + "%d'd'%h'h'%s's'", // 4d3h 1s + "%d'd'%h'h'", // 4d3h + "%d'd'%m'm'%s's'", // 4d 2m1s + "%d'd'%m'm'", // 4d 2m + "%d'd'%s's'", // 4d 1s + "%d'd'", // 4d + "%h'h'%m'm'%s's'", // 3h2m1s + "%h'h'%m'm'", // 3h2m + "%h'h'%s's'", // 3h 1s + "%h'h'", // 3h + "%m'm'%s's'", // 2m1s + "%m'm'", // 2m + "%s's'", // 1s + }; + + /// + public override Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + if (string.IsNullOrEmpty(input)) + throw new ArgumentException(message: $"{nameof(input)} must not be null or empty.", paramName: nameof(input)); + + var isNegative = input[0] == '-'; // Char for CultureInfo.InvariantCulture.NumberFormat.NegativeSign + if (isNegative) + { + input = input.Substring(1); + } + + if (TimeSpan.TryParseExact(input.ToLowerInvariant(), Formats, CultureInfo.InvariantCulture, out var timeSpan)) + { + return isNegative + ? Task.FromResult(TypeReaderResult.FromSuccess(-timeSpan)) + : Task.FromResult(TypeReaderResult.FromSuccess(timeSpan)); + } + else + { + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse TimeSpan")); + } + } + } +} diff --git a/src/Discord.Net.Interactions/TypeReaders/TypeReader.cs b/src/Discord.Net.Interactions/TypeReaders/TypeReader.cs new file mode 100644 index 000000000..62276c973 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeReaders/TypeReader.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Base class for creating s. uses s to parse string values into entities. + /// + /// + /// s are mainly used to parse message component values. For interfacing with Slash Command parameters use s instead. + /// + public abstract class TypeReader : ITypeHandler + { + /// + /// Will be used to search for alternative TypeReaders whenever the Command Service encounters an unknown parameter type. + /// + /// + /// + public abstract bool CanConvertTo(Type type); + + /// + /// Will be used to read the incoming payload before executing the method body. + /// + /// Command exexution context. + /// Raw string input value. + /// Service provider that will be used to initialize the command module. + /// The result of the read process. + public abstract Task ReadAsync(IInteractionContext context, object input, IServiceProvider services); + + /// + /// Will be used to manipulate the outgoing command option, before the command gets registered to Discord. + /// + public virtual string Serialize(object value) => null; + } + + /// + public abstract class TypeReader : TypeReader + { + /// + public sealed override bool CanConvertTo(Type type) => + typeof(T).IsAssignableFrom(type); + } +} diff --git a/src/Discord.Net.Interactions/TypeReaders/UserTypeReader.cs b/src/Discord.Net.Interactions/TypeReaders/UserTypeReader.cs new file mode 100644 index 000000000..c0104e341 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeReaders/UserTypeReader.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// A for parsing objects implementing . + /// + /// The type to be checked; must implement . + public class UserTypeReader : TypeReader + where T : class, IUser + { + /// + public override async Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + var results = new Dictionary(); + IAsyncEnumerable channelUsers = context.Channel.GetUsersAsync(CacheMode.CacheOnly).Flatten(); // it's better + IReadOnlyCollection guildUsers = ImmutableArray.Create(); + + if (context.Guild != null) + guildUsers = await context.Guild.GetUsersAsync(CacheMode.CacheOnly).ConfigureAwait(false); + + //By Mention (1.0) + if (MentionUtils.TryParseUser(input, out var id)) + { + if (context.Guild != null) + AddResult(results, await context.Guild.GetUserAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) as T, 1.00f); + else + AddResult(results, await context.Channel.GetUserAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) as T, 1.00f); + } + + //By Id (0.9) + if (ulong.TryParse(input, NumberStyles.None, CultureInfo.InvariantCulture, out id)) + { + if (context.Guild != null) + AddResult(results, await context.Guild.GetUserAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) as T, 0.90f); + else + AddResult(results, await context.Channel.GetUserAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) as T, 0.90f); + } + + //By Username + Discriminator (0.7-0.85) + int index = input.LastIndexOf('#'); + if (index >= 0) + { + string username = input.Substring(0, index); + if (ushort.TryParse(input.Substring(index + 1), out ushort discriminator)) + { + var channelUser = await channelUsers.FirstOrDefaultAsync(x => x.DiscriminatorValue == discriminator && + string.Equals(username, x.Username, StringComparison.OrdinalIgnoreCase)).ConfigureAwait(false); + AddResult(results, channelUser as T, channelUser?.Username == username ? 0.85f : 0.75f); + + var guildUser = guildUsers.FirstOrDefault(x => x.DiscriminatorValue == discriminator && + string.Equals(username, x.Username, StringComparison.OrdinalIgnoreCase)); + AddResult(results, guildUser as T, guildUser?.Username == username ? 0.80f : 0.70f); + } + } + + //By Username (0.5-0.6) + { + await channelUsers + .Where(x => string.Equals(input, x.Username, StringComparison.OrdinalIgnoreCase)) + .ForEachAsync(channelUser => AddResult(results, channelUser as T, channelUser.Username == input ? 0.65f : 0.55f)) + .ConfigureAwait(false); + + foreach (var guildUser in guildUsers.Where(x => string.Equals(input, x.Username, StringComparison.OrdinalIgnoreCase))) + AddResult(results, guildUser as T, guildUser.Username == input ? 0.60f : 0.50f); + } + + //By Nickname (0.5-0.6) + { + await channelUsers + .Where(x => string.Equals(input, (x as IGuildUser)?.Nickname, StringComparison.OrdinalIgnoreCase)) + .ForEachAsync(channelUser => AddResult(results, channelUser as T, (channelUser as IGuildUser).Nickname == input ? 0.65f : 0.55f)) + .ConfigureAwait(false); + + foreach (var guildUser in guildUsers.Where(x => string.Equals(input, x.Nickname, StringComparison.OrdinalIgnoreCase))) + AddResult(results, guildUser as T, guildUser.Nickname == input ? 0.60f : 0.50f); + } + + if (results.Count > 0) + return TypeReaderResult.FromSuccess(results.Values.ToImmutableArray()); + return TypeReaderResult.FromError(CommandError.ObjectNotFound, "User not found."); + } + + private void AddResult(Dictionary results, T user, float score) + { + if (user != null && !results.ContainsKey(user.Id)) + results.Add(user.Id, new TypeReaderValue(user, score)); + } + } +}