diff --git a/src/Discord.Net.Commands/Attributes/CommandAttribute.cs b/src/Discord.Net.Commands/Attributes/CommandAttribute.cs index db4e877d8..014668405 100644 --- a/src/Discord.Net.Commands/Attributes/CommandAttribute.cs +++ b/src/Discord.Net.Commands/Attributes/CommandAttribute.cs @@ -6,13 +6,14 @@ namespace Discord.Commands public class CommandAttribute : Attribute { public string Text { get; } - public string Name { get; } - public CommandAttribute(string name) : this(name, name) { } - public CommandAttribute(string text, string name) + public CommandAttribute() { - Text = text.ToLowerInvariant(); - Name = name; + Text = null; + } + public CommandAttribute(string text) + { + Text = text; } } } diff --git a/src/Discord.Net.Commands/Command.cs b/src/Discord.Net.Commands/Command.cs index 568b645d9..d3b94b94c 100644 --- a/src/Discord.Net.Commands/Command.cs +++ b/src/Discord.Net.Commands/Command.cs @@ -1,35 +1,121 @@ using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; using System.Reflection; +using System.Threading.Tasks; namespace Discord.Commands { + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] public class Command { - private Action _action; + private readonly object _instance; + private readonly Func, Task> _action; public string Name { get; } public string Description { get; } public string Text { get; } + public Module Module { get; } + public IReadOnlyList Parameters { get; } - internal Command(CommandAttribute attribute, MethodInfo methodInfo) + internal Command(Module module, object instance, CommandAttribute attribute, MethodInfo methodInfo) { + Module = module; + _instance = instance; + + Name = methodInfo.Name; + Text = attribute.Text; + var description = methodInfo.GetCustomAttribute(); if (description != null) Description = description.Text; - Name = attribute.Name; - Text = attribute.Text; + Parameters = BuildParameters(methodInfo); + _action = BuildAction(methodInfo); } - public void Invoke(IMessage msg) + public async Task Parse(IMessage msg, SearchResult searchResult) { - _action.Invoke(msg); + if (!searchResult.IsSuccess) + return ParseResult.FromError(searchResult); + + return await CommandParser.ParseArgs(this, msg, searchResult.ArgText, 0).ConfigureAwait(false); + } + public async Task Execute(IMessage msg, ParseResult parseResult) + { + if (!parseResult.IsSuccess) + return ExecuteResult.FromError(parseResult); + + try + { + await _action.Invoke(msg, parseResult.Values);//Note: This code may need context + return ExecuteResult.FromSuccess(); + } + catch (Exception ex) + { + return ExecuteResult.FromError(ex); + } } - private void BuildAction() + private IReadOnlyList BuildParameters(MethodInfo methodInfo) { - _action = null; - //TODO: Implement + var parameters = methodInfo.GetParameters(); + var paramBuilder = ImmutableArray.CreateBuilder(parameters.Length - 1); + for (int i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + var type = parameter.ParameterType; + + if (i == 0) + { + if (type != typeof(IMessage)) + throw new InvalidOperationException("The first parameter of a command must be IMessage."); + else + continue; + } + + var typeInfo = type.GetTypeInfo(); + if (typeInfo.IsEnum) + type = Enum.GetUnderlyingType(type); + + var reader = Module.Service.GetTypeReader(type); + if (reader == null) + throw new InvalidOperationException($"This type ({type.FullName}) is not supported."); + + bool isUnparsed = parameter.GetCustomAttribute() != null; + if (isUnparsed) + { + if (type != typeof(string)) + throw new InvalidOperationException("Unparsed parameters only support the string type."); + else if (i != parameters.Length - 1) + throw new InvalidOperationException("Unparsed parameters must be the last parameter in a command."); + } + + string name = parameter.Name; + string description = typeInfo.GetCustomAttribute()?.Text; + bool isOptional = parameter.IsOptional; + object defaultValue = parameter.HasDefaultValue ? parameter.DefaultValue : null; + + paramBuilder.Add(new CommandParameter(name, description, reader, isOptional, isUnparsed, defaultValue)); + } + return paramBuilder.ToImmutable(); + } + private Func, Task> BuildAction(MethodInfo methodInfo) + { + //TODO: Temporary reflection hack. Lets build an actual expression tree here. + return (msg, args) => + { + object[] newArgs = new object[args.Count + 1]; + newArgs[0] = msg; + for (int i = 0; i < args.Count; i++) + newArgs[i + 1] = args[i]; + var result = methodInfo.Invoke(_instance, newArgs); + return result as Task ?? Task.CompletedTask; + }; } + + public override string ToString() => Name; + private string DebuggerDisplay => $"{Module.Name}.{Name} ({Text})"; } } diff --git a/src/Discord.Net.Commands/CommandError.cs b/src/Discord.Net.Commands/CommandError.cs new file mode 100644 index 000000000..135930dd9 --- /dev/null +++ b/src/Discord.Net.Commands/CommandError.cs @@ -0,0 +1,20 @@ +namespace Discord.Commands +{ + public enum CommandError + { + //Search + UnknownCommand, + + //Parse + ParseFailed, + BadArgCount, + + //Parse (Type Reader) + CastFailed, + ObjectNotFound, + MultipleMatches, + + //Execute + Exception, + } +} diff --git a/src/Discord.Net.Commands/CommandParameter.cs b/src/Discord.Net.Commands/CommandParameter.cs new file mode 100644 index 000000000..0a5952c48 --- /dev/null +++ b/src/Discord.Net.Commands/CommandParameter.cs @@ -0,0 +1,34 @@ +using System.Diagnostics; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + //TODO: Add support for Multiple + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class CommandParameter + { + private readonly TypeReader _reader; + + public string Name { get; } + public string Description { get; } + public bool IsOptional { get; } + public bool IsUnparsed { get; } + internal object DefaultValue { get; } + + public CommandParameter(string name, string description, TypeReader reader, bool isOptional, bool isUnparsed, object defaultValue) + { + _reader = reader; + IsOptional = isOptional; + IsUnparsed = isUnparsed; + DefaultValue = defaultValue; + } + + public async Task Parse(IMessage context, string input) + { + return await _reader.Read(context, input).ConfigureAwait(false); + } + + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name}{(IsOptional ? " (Optional)" : "")}{(IsUnparsed ? " (Unparsed)" : "")}"; + } +} diff --git a/src/Discord.Net.Commands/CommandParser.cs b/src/Discord.Net.Commands/CommandParser.cs new file mode 100644 index 000000000..e683d1de9 --- /dev/null +++ b/src/Discord.Net.Commands/CommandParser.cs @@ -0,0 +1,144 @@ +using System.Collections.Immutable; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal static class CommandParser + { + private enum ParserPart + { + None, + Parameter, + QuotedParameter + } + + //TODO: Check support for escaping + public static async Task ParseArgs(Command command, IMessage context, string input, int startPos) + { + CommandParameter curParam = null; + StringBuilder argBuilder = new StringBuilder(input.Length); + int endPos = input.Length; + var curPart = ParserPart.None; + int lastArgEndPos = int.MinValue; + var argList = ImmutableArray.CreateBuilder(); + bool isEscaping = false; + char c; + + for (int curPos = startPos; curPos <= endPos; curPos++) + { + if (curPos < endPos) + c = input[curPos]; + else + c = '\0'; + + //If this character is escaped, skip it + if (isEscaping) + { + if (curPos != endPos) + { + argBuilder.Append(c); + isEscaping = false; + continue; + } + } + //Are we escaping the next character? + if (c == '\\') + { + isEscaping = true; + continue; + } + + //If we're processing an unparsed parameter, ignore all other logic + if (curParam != null && curParam.IsUnparsed) + { + argBuilder.Append(c); + continue; + } + + //If we're not currently processing one, are we starting the next argument yet? + if (curPart == ParserPart.None) + { + if (char.IsWhiteSpace(c) || curPos == endPos) + continue; //Skip whitespace between arguments + else if (curPos == lastArgEndPos) + return ParseResult.FromError(CommandError.ParseFailed, "There must be at least one character of whitespace between arguments."); + else + { + curParam = command.Parameters.Count > argList.Count ? command.Parameters[argList.Count] : null; + if (curParam.IsUnparsed) + { + argBuilder.Append(c); + continue; + } + if (c == '\"') + { + curPart = ParserPart.QuotedParameter; + continue; + } + curPart = ParserPart.Parameter; + } + } + + //Has this parameter ended yet? + string argString = null; + if (curPart == ParserPart.Parameter) + { + if (curPos == endPos || char.IsWhiteSpace(c)) + { + argString = argBuilder.ToString(); + lastArgEndPos = curPos; + } + else + argBuilder.Append(c); + } + else if (curPart == ParserPart.QuotedParameter) + { + if (c == '\"') + { + argString = argBuilder.ToString(); //Remove quotes + lastArgEndPos = curPos + 1; + } + else + argBuilder.Append(c); + } + + if (argString != null) + { + if (curParam == null) + return ParseResult.FromError(CommandError.BadArgCount, "The input text has too many parameters."); + + var typeReaderResult = await curParam.Parse(context, argString).ConfigureAwait(false); + if (!typeReaderResult.IsSuccess) + return ParseResult.FromError(typeReaderResult); + argList.Add(typeReaderResult.Value); + + curParam = null; + curPart = ParserPart.None; + argBuilder.Clear(); + } + } + + if (curParam != null && curParam.IsUnparsed) + argList.Add(argBuilder.ToString()); + + if (isEscaping) + return ParseResult.FromError(CommandError.ParseFailed, "Input text may not end on an incomplete escape."); + if (curPart == ParserPart.QuotedParameter) + return ParseResult.FromError(CommandError.ParseFailed, "A quoted parameter is incomplete"); + + if (argList.Count < command.Parameters.Count) + { + for (int i = argList.Count; i < command.Parameters.Count; i++) + { + var param = command.Parameters[i]; + if (!param.IsOptional) + return ParseResult.FromError(CommandError.BadArgCount, "The input text has too few parameters."); + argList.Add(param.DefaultValue); + } + } + + return ParseResult.FromSuccess(argList.ToImmutable()); + } + } +} diff --git a/src/Discord.Net.Commands/CommandService.cs b/src/Discord.Net.Commands/CommandService.cs index 2b9555b73..6c089d262 100644 --- a/src/Discord.Net.Commands/CommandService.cs +++ b/src/Discord.Net.Commands/CommandService.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; using System.Linq; using System.Reflection; using System.Threading; @@ -14,6 +15,7 @@ namespace Discord.Commands private readonly SemaphoreSlim _moduleLock; private readonly ConcurrentDictionary _modules; private readonly ConcurrentDictionary> _map; + private readonly Dictionary _typeReaders; public IEnumerable Modules => _modules.Select(x => x.Value); public IEnumerable Commands => _modules.SelectMany(x => x.Value.Commands); @@ -23,6 +25,113 @@ namespace Discord.Commands _moduleLock = new SemaphoreSlim(1, 1); _modules = new ConcurrentDictionary(); _map = new ConcurrentDictionary>(); + _typeReaders = new Dictionary + { + [typeof(string)] = new GenericTypeReader((m, s) => Task.FromResult(TypeReaderResult.FromSuccess(s))), + [typeof(byte)] = new GenericTypeReader((m, s) => + { + byte value; + if (byte.TryParse(s, out value)) return Task.FromResult(TypeReaderResult.FromSuccess(value)); + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse Byte")); + }), + [typeof(sbyte)] = new GenericTypeReader((m, s) => + { + sbyte value; + if (sbyte.TryParse(s, out value)) return Task.FromResult(TypeReaderResult.FromSuccess(value)); + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse SByte")); + }), + [typeof(ushort)] = new GenericTypeReader((m, s) => + { + ushort value; + if (ushort.TryParse(s, out value)) return Task.FromResult(TypeReaderResult.FromSuccess(value)); + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse UInt16")); + }), + [typeof(short)] = new GenericTypeReader((m, s) => + { + short value; + if (short.TryParse(s, out value)) return Task.FromResult(TypeReaderResult.FromSuccess(value)); + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse Int16")); + }), + [typeof(uint)] = new GenericTypeReader((m, s) => + { + uint value; + if (uint.TryParse(s, out value)) return Task.FromResult(TypeReaderResult.FromSuccess(value)); + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse UInt32")); + }), + [typeof(int)] = new GenericTypeReader((m, s) => + { + int value; + if (int.TryParse(s, out value)) return Task.FromResult(TypeReaderResult.FromSuccess(value)); + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse Int32")); + }), + [typeof(ulong)] = new GenericTypeReader((m, s) => + { + ulong value; + if (ulong.TryParse(s, out value)) return Task.FromResult(TypeReaderResult.FromSuccess(value)); + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse UInt64")); + }), + [typeof(long)] = new GenericTypeReader((m, s) => + { + long value; + if (long.TryParse(s, out value)) return Task.FromResult(TypeReaderResult.FromSuccess(value)); + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse Int64")); + }), + [typeof(float)] = new GenericTypeReader((m, s) => + { + float value; + if (float.TryParse(s, out value)) return Task.FromResult(TypeReaderResult.FromSuccess(value)); + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse Single")); + }), + [typeof(double)] = new GenericTypeReader((m, s) => + { + double value; + if (double.TryParse(s, out value)) return Task.FromResult(TypeReaderResult.FromSuccess(value)); + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse Double")); + }), + [typeof(decimal)] = new GenericTypeReader((m, s) => + { + decimal value; + if (decimal.TryParse(s, out value)) return Task.FromResult(TypeReaderResult.FromSuccess(value)); + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse Decimal")); + }), + [typeof(DateTime)] = new GenericTypeReader((m, s) => + { + DateTime value; + if (DateTime.TryParse(s, out value)) return Task.FromResult(TypeReaderResult.FromSuccess(value)); + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse DateTime")); + }), + [typeof(DateTimeOffset)] = new GenericTypeReader((m, s) => + { + DateTimeOffset value; + if (DateTimeOffset.TryParse(s, out value)) return Task.FromResult(TypeReaderResult.FromSuccess(value)); + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse DateTimeOffset")); + }), + + [typeof(IMessage)] = new MessageTypeReader(), + [typeof(IChannel)] = new ChannelTypeReader(), + [typeof(IGuildChannel)] = new ChannelTypeReader(), + [typeof(ITextChannel)] = new ChannelTypeReader(), + [typeof(IVoiceChannel)] = new ChannelTypeReader(), + [typeof(IRole)] = new RoleTypeReader(), + [typeof(IUser)] = new UserTypeReader(), + [typeof(IGuildUser)] = new UserTypeReader() + }; + } + + public void AddTypeReader(TypeReader reader) + { + _typeReaders[typeof(T)] = reader; + } + public void AddTypeReader(Type type, TypeReader reader) + { + _typeReaders[type] = reader; + } + internal TypeReader GetTypeReader(Type type) + { + TypeReader reader; + if (_typeReaders.TryGetValue(type, out reader)) + return reader; + return null; } public async Task Load(object module) @@ -46,7 +155,7 @@ namespace Discord.Commands } private Module LoadInternal(object module, TypeInfo typeInfo) { - var loadedModule = new Module(module, typeInfo); + var loadedModule = new Module(this, module, typeInfo); _modules[module] = loadedModule; foreach (var cmd in loadedModule.Commands) @@ -114,7 +223,7 @@ namespace Discord.Commands } //TODO: C#7 Candidate for tuple - public SearchResults Search(string input) + public SearchResult Search(string input) { string lowerInput = input.ToLowerInvariant(); @@ -125,21 +234,25 @@ namespace Discord.Commands { endPos = input.IndexOf(' ', startPos); string cmdText = endPos == -1 ? input.Substring(startPos) : input.Substring(startPos, endPos - startPos); - startPos = endPos + 1; if (!_map.TryGetValue(cmdText, out group)) break; bestGroup = group; + if (endPos == -1) + { + startPos = input.Length; + break; + } + else + startPos = endPos + 1; } - - ImmutableArray cmds; + if (bestGroup != null) { lock (bestGroup) - cmds = bestGroup.ToImmutableArray(); + return SearchResult.FromSuccess(bestGroup.ToImmutableArray(), input.Substring(startPos)); } else - cmds = ImmutableArray.Create(); - return new SearchResults(cmds, startPos); + return SearchResult.FromError(CommandError.UnknownCommand, "Unknown command."); } } } diff --git a/src/Discord.Net.Commands/Module.cs b/src/Discord.Net.Commands/Module.cs index 230b3abd7..c2a7d4280 100644 --- a/src/Discord.Net.Commands/Module.cs +++ b/src/Discord.Net.Commands/Module.cs @@ -1,27 +1,33 @@ using System.Collections.Generic; +using System.Diagnostics; using System.Reflection; namespace Discord.Commands { + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] public class Module { + public CommandService Service { get; } public string Name { get; } public IEnumerable Commands { get; } - internal Module(object parent, TypeInfo typeInfo) + internal Module(CommandService service, object instance, TypeInfo typeInfo) { + Service = service; + Name = typeInfo.Name; + List commands = new List(); - SearchClass(parent, commands, typeInfo); + SearchClass(instance, commands, typeInfo); Commands = commands; } - private void SearchClass(object parent, List commands, TypeInfo typeInfo) + private void SearchClass(object instance, List commands, TypeInfo typeInfo) { foreach (var method in typeInfo.DeclaredMethods) { var cmdAttr = method.GetCustomAttribute(); if (cmdAttr != null) - commands.Add(new Command(cmdAttr, method)); + commands.Add(new Command(this, instance, cmdAttr, method)); } foreach (var type in typeInfo.DeclaredNestedTypes) { @@ -29,5 +35,8 @@ namespace Discord.Commands SearchClass(ReflectionUtils.CreateObject(type), commands, type); } } + + public override string ToString() => Name; + private string DebuggerDisplay => Name; } } diff --git a/src/Discord.Net.Commands/Readers/ChannelTypeReader.cs b/src/Discord.Net.Commands/Readers/ChannelTypeReader.cs new file mode 100644 index 000000000..4a1350fee --- /dev/null +++ b/src/Discord.Net.Commands/Readers/ChannelTypeReader.cs @@ -0,0 +1,48 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal class ChannelTypeReader : TypeReader + where T : class, IChannel + { + public override async Task Read(IMessage context, string input) + { + IGuildChannel guildChannel = context.Channel as IGuildChannel; + IChannel result = null; + + if (guildChannel != null) + { + //By Id + ulong id; + if (MentionUtils.TryParseChannel(input, out id) || ulong.TryParse(input, out id)) + { + var channel = await guildChannel.Guild.GetChannelAsync(id).ConfigureAwait(false); + if (channel != null) + result = channel; + } + + //By Name + if (result == null) + { + var channels = await guildChannel.Guild.GetChannelsAsync().ConfigureAwait(false); + var filteredChannels = channels.Where(x => string.Equals(input, x.Name, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (filteredChannels.Length > 1) + return TypeReaderResult.FromError(CommandError.MultipleMatches, "Multiple channels found."); + else if (filteredChannels.Length == 1) + result = filteredChannels[0]; + } + } + + if (result == null) + return TypeReaderResult.FromError(CommandError.ObjectNotFound, "Channel not found."); + + T castResult = result as T; + if (castResult == null) + return TypeReaderResult.FromError(CommandError.CastFailed, $"Channel is not a {typeof(T).Name}."); + else + return TypeReaderResult.FromSuccess(castResult); + } + } +} diff --git a/src/Discord.Net.Commands/Readers/GenericTypeReader.cs b/src/Discord.Net.Commands/Readers/GenericTypeReader.cs new file mode 100644 index 000000000..97bc7d94c --- /dev/null +++ b/src/Discord.Net.Commands/Readers/GenericTypeReader.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal class GenericTypeReader : TypeReader + { + private readonly Func> _action; + + public GenericTypeReader(Func> action) + { + _action = action; + } + + public override Task Read(IMessage context, string input) => _action(context, input); + } +} diff --git a/src/Discord.Net.Commands/Readers/MessageTypeReader.cs b/src/Discord.Net.Commands/Readers/MessageTypeReader.cs new file mode 100644 index 000000000..50ec7000a --- /dev/null +++ b/src/Discord.Net.Commands/Readers/MessageTypeReader.cs @@ -0,0 +1,24 @@ +using System.Globalization; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal class MessageTypeReader : TypeReader + { + public override Task Read(IMessage context, string input) + { + //By Id + ulong id; + if (ulong.TryParse(input, NumberStyles.None, CultureInfo.InvariantCulture, out id)) + { + var msg = context.Channel.GetCachedMessage(id); + if (msg == null) + return Task.FromResult(TypeReaderResult.FromError(CommandError.ObjectNotFound, "Message not found.")); + else + return Task.FromResult(TypeReaderResult.FromSuccess(msg)); + } + + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse Message Id.")); + } + } +} diff --git a/src/Discord.Net.Commands/Readers/RoleTypeReader.cs b/src/Discord.Net.Commands/Readers/RoleTypeReader.cs new file mode 100644 index 000000000..10aee6b1c --- /dev/null +++ b/src/Discord.Net.Commands/Readers/RoleTypeReader.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal class RoleTypeReader : TypeReader + { + public override Task Read(IMessage context, string input) + { + IGuildChannel guildChannel = context.Channel as IGuildChannel; + + if (guildChannel != null) + { + //By Id + ulong id; + if (MentionUtils.TryParseRole(input, out id) || ulong.TryParse(input, out id)) + { + var channel = guildChannel.Guild.GetRole(id); + if (channel != null) + return Task.FromResult(TypeReaderResult.FromSuccess(channel)); + } + + //By Name + var roles = guildChannel.Guild.Roles; + var filteredRoles = roles.Where(x => string.Equals(input, x.Name, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (filteredRoles.Length > 1) + return Task.FromResult(TypeReaderResult.FromError(CommandError.MultipleMatches, "Multiple roles found.")); + else if (filteredRoles.Length == 1) + return Task.FromResult(TypeReaderResult.FromSuccess(filteredRoles[0])); + } + + return Task.FromResult(TypeReaderResult.FromError(CommandError.ObjectNotFound, "Role not found.")); + } + } +} diff --git a/src/Discord.Net.Commands/Readers/TypeReader.cs b/src/Discord.Net.Commands/Readers/TypeReader.cs new file mode 100644 index 000000000..d1dedd9c8 --- /dev/null +++ b/src/Discord.Net.Commands/Readers/TypeReader.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace Discord.Commands +{ + public abstract class TypeReader + { + public abstract Task Read(IMessage context, string input); + } +} diff --git a/src/Discord.Net.Commands/Readers/UserTypeReader.cs b/src/Discord.Net.Commands/Readers/UserTypeReader.cs new file mode 100644 index 000000000..c80ac2816 --- /dev/null +++ b/src/Discord.Net.Commands/Readers/UserTypeReader.cs @@ -0,0 +1,66 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal class UserTypeReader : TypeReader + where T : class, IUser + { + public override async Task Read(IMessage context, string input) + { + IGuildChannel guildChannel = context.Channel as IGuildChannel; + IUser result = null; + + if (guildChannel != null) + { + //By Id + ulong id; + if (MentionUtils.TryParseUser(input, out id) || ulong.TryParse(input, out id)) + { + var user = await guildChannel.Guild.GetUserAsync(id).ConfigureAwait(false); + if (user != null) + result = user; + } + + //By Username + Discriminator + if (result == null) + { + int index = input.LastIndexOf('#'); + if (index >= 0) + { + string username = input.Substring(0, index); + ushort discriminator; + if (ushort.TryParse(input.Substring(index + 1), out discriminator)) + { + var users = await guildChannel.Guild.GetUsersAsync().ConfigureAwait(false); + result = users.Where(x => + x.DiscriminatorValue == discriminator && + string.Equals(username, x.Username, StringComparison.OrdinalIgnoreCase)).FirstOrDefault(); + } + } + } + + //By Username + if (result == null) + { + var users = await guildChannel.Guild.GetUsersAsync().ConfigureAwait(false); + var filteredUsers = users.Where(x => string.Equals(input, x.Username, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (filteredUsers.Length > 1) + return TypeReaderResult.FromError(CommandError.MultipleMatches, "Multiple users found."); + else if (filteredUsers.Length == 1) + result = filteredUsers[0]; + } + } + + if (result == null) + return TypeReaderResult.FromError(CommandError.ObjectNotFound, "User not found."); + + T castResult = result as T; + if (castResult == null) + return TypeReaderResult.FromError(CommandError.CastFailed, $"User is not a {typeof(T).Name}."); + else + return TypeReaderResult.FromSuccess(castResult); + } + } +} diff --git a/src/Discord.Net.Commands/Results/ExecuteResult.cs b/src/Discord.Net.Commands/Results/ExecuteResult.cs new file mode 100644 index 000000000..a06e8dd99 --- /dev/null +++ b/src/Discord.Net.Commands/Results/ExecuteResult.cs @@ -0,0 +1,35 @@ +using System; +using System.Diagnostics; + +namespace Discord.Commands +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public struct ExecuteResult : IResult + { + public Exception Exception { get; } + + public CommandError? Error { get; } + public string ErrorReason { get; } + + public bool IsSuccess => !Error.HasValue; + + private ExecuteResult(Exception exception, CommandError? error, string errorReason) + { + Exception = exception; + Error = error; + ErrorReason = errorReason; + } + + internal static ExecuteResult FromSuccess() + => new ExecuteResult(null, null, null); + internal static ExecuteResult FromError(CommandError error, string reason) + => new ExecuteResult(null, error, reason); + internal static ExecuteResult FromError(Exception ex) + => new ExecuteResult(ex, CommandError.Exception, ex.Message); + internal static ExecuteResult FromError(ParseResult result) + => new ExecuteResult(null, result.Error, result.ErrorReason); + + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + private string DebuggerDisplay => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Commands/Results/IResult.cs b/src/Discord.Net.Commands/Results/IResult.cs new file mode 100644 index 000000000..928d1139e --- /dev/null +++ b/src/Discord.Net.Commands/Results/IResult.cs @@ -0,0 +1,9 @@ +namespace Discord.Commands +{ + public interface IResult + { + CommandError? Error { get; } + string ErrorReason { get; } + bool IsSuccess { get; } + } +} diff --git a/src/Discord.Net.Commands/Results/ParseResult.cs b/src/Discord.Net.Commands/Results/ParseResult.cs new file mode 100644 index 000000000..e7e886b1a --- /dev/null +++ b/src/Discord.Net.Commands/Results/ParseResult.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Diagnostics; + +namespace Discord.Commands +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public struct ParseResult : IResult + { + public IReadOnlyList Values { get; } + + public CommandError? Error { get; } + public string ErrorReason { get; } + + public bool IsSuccess => !Error.HasValue; + + private ParseResult(IReadOnlyList values, CommandError? error, string errorReason) + { + Values = values; + Error = error; + ErrorReason = errorReason; + } + + internal static ParseResult FromSuccess(IReadOnlyList values) + => new ParseResult(values, null, null); + internal static ParseResult FromError(CommandError error, string reason) + => new ParseResult(null, error, reason); + internal static ParseResult FromError(SearchResult result) + => new ParseResult(null, result.Error, result.ErrorReason); + internal static ParseResult FromError(TypeReaderResult result) + => new ParseResult(null, result.Error, result.ErrorReason); + + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + private string DebuggerDisplay => IsSuccess ? $"Success ({Values.Count} Values)" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Commands/Results/SearchResult.cs b/src/Discord.Net.Commands/Results/SearchResult.cs new file mode 100644 index 000000000..0c7d671e3 --- /dev/null +++ b/src/Discord.Net.Commands/Results/SearchResult.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Diagnostics; + +namespace Discord.Commands +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public struct SearchResult : IResult + { + public IReadOnlyList Commands { get; } + public string ArgText { get; } + + public CommandError? Error { get; } + public string ErrorReason { get; } + + public bool IsSuccess => !Error.HasValue; + + private SearchResult(IReadOnlyList commands, string argText, CommandError? error, string errorReason) + { + Commands = commands; + ArgText = argText; + Error = error; + ErrorReason = errorReason; + } + + internal static SearchResult FromSuccess(IReadOnlyList commands, string argText) + => new SearchResult(commands, argText, null, null); + internal static SearchResult FromError(CommandError error, string reason) + => new SearchResult(null, null, error, reason); + + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + private string DebuggerDisplay => IsSuccess ? $"Success ({Commands.Count} Results)" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Commands/Results/TypeReaderResult.cs b/src/Discord.Net.Commands/Results/TypeReaderResult.cs new file mode 100644 index 000000000..932f1299b --- /dev/null +++ b/src/Discord.Net.Commands/Results/TypeReaderResult.cs @@ -0,0 +1,30 @@ +using System.Diagnostics; + +namespace Discord.Commands +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public struct TypeReaderResult : IResult + { + public object Value { get; } + + public CommandError? Error { get; } + public string ErrorReason { get; } + + public bool IsSuccess => !Error.HasValue; + + private TypeReaderResult(object value, CommandError? error, string errorReason) + { + Value = value; + Error = error; + ErrorReason = errorReason; + } + + public static TypeReaderResult FromSuccess(object value) + => new TypeReaderResult(value, null, null); + public static TypeReaderResult FromError(CommandError error, string reason) + => new TypeReaderResult(null, error, reason); + + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + private string DebuggerDisplay => IsSuccess ? $"Success ({Value})" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Commands/SearchResults.cs b/src/Discord.Net.Commands/SearchResults.cs deleted file mode 100644 index 724b61ecc..000000000 --- a/src/Discord.Net.Commands/SearchResults.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; - -namespace Discord.Commands -{ - public struct SearchResults - { - IReadOnlyList Commands { get; } - int ArgsPos { get; } - - public SearchResults(IReadOnlyList commands, int argsPos) - { - Commands = commands; - ArgsPos = argsPos; - } - } -}