@@ -43,6 +43,9 @@ | |||
<Compile Include="..\Discord.Net.Commands\CommandBuilder.cs"> | |||
<Link>CommandBuilder.cs</Link> | |||
</Compile> | |||
<Compile Include="..\Discord.Net.Commands\CommandMap.cs"> | |||
<Link>CommandMap.cs</Link> | |||
</Compile> | |||
<Compile Include="..\Discord.Net.Commands\CommandParser.cs"> | |||
<Link>CommandParser.cs</Link> | |||
</Compile> | |||
@@ -1,23 +1,102 @@ | |||
using System; | |||
using System.Collections.Generic; | |||
using System.Threading.Tasks; | |||
namespace Discord.Commands | |||
{ | |||
public sealed class CommandParameter | |||
{ | |||
public string Name { get; } | |||
public bool IsOptional { get; } | |||
public bool IsCatchAll { get; } | |||
public CommandParameter(string name, bool isOptional, bool isCatchAll) | |||
{ | |||
Name = name; | |||
IsOptional = isOptional; | |||
IsCatchAll = isCatchAll; | |||
} | |||
} | |||
public sealed class Command | |||
{ | |||
public string Text { get; } | |||
public int? MinArgs { get; internal set; } | |||
public int? MaxArgs { get; internal set; } | |||
public int? MinArgs { get; private set; } | |||
public int? MaxArgs { get; private set; } | |||
public int MinPerms { get; internal set; } | |||
public bool IsHidden { get; internal set; } | |||
public string Description { get; internal set; } | |||
internal Func<CommandEventArgs, Task> Handler; | |||
public IEnumerable<string> Aliases => _aliases; | |||
private string[] _aliases; | |||
public IEnumerable<CommandParameter> Parameters => _parameters; | |||
private CommandParameter[] _parameters; | |||
private Func<CommandEventArgs, Task> _handler; | |||
internal Command(string text) | |||
{ | |||
Text = text; | |||
IsHidden = false; // Set false by default to avoid null error | |||
Description = "No description set for this command."; | |||
IsHidden = false; | |||
_aliases = new string[0]; | |||
_parameters = new CommandParameter[0]; | |||
} | |||
internal void SetAliases(string[] aliases) | |||
{ | |||
_aliases = aliases; | |||
} | |||
internal void SetParameters(CommandParameter[] parameters) | |||
{ | |||
_parameters = parameters; | |||
if (parameters != null) | |||
{ | |||
if (parameters.Length == 0) | |||
{ | |||
MinArgs = 0; | |||
MaxArgs = 0; | |||
} | |||
else | |||
{ | |||
if (parameters[parameters.Length - 1].IsCatchAll) | |||
MaxArgs = null; | |||
else | |||
MaxArgs = parameters.Length; | |||
int? optionalStart = null; | |||
for (int i = parameters.Length - 1; i >= 0; i--) | |||
{ | |||
if (parameters[i].IsOptional) | |||
optionalStart = i; | |||
else | |||
break; | |||
} | |||
if (optionalStart == null) | |||
MinArgs = MaxArgs; | |||
else | |||
MinArgs = optionalStart.Value; | |||
} | |||
} | |||
} | |||
internal void SetHandler(Func<CommandEventArgs, Task> func) | |||
{ | |||
_handler = func; | |||
} | |||
internal void SetHandler(Action<CommandEventArgs> func) | |||
{ | |||
_handler = e => { func(e); return TaskHelper.CompletedTask; }; | |||
} | |||
internal Task Run(CommandEventArgs args) | |||
{ | |||
var task = _handler(args); | |||
if (task != null) | |||
return task; | |||
else | |||
return TaskHelper.CompletedTask; | |||
} | |||
} | |||
} |
@@ -1,50 +1,55 @@ | |||
using System; | |||
using System.Collections.Generic; | |||
using System.Linq; | |||
using System.Threading.Tasks; | |||
namespace Discord.Commands | |||
{ | |||
public sealed class CommandBuilder | |||
{ | |||
private readonly CommandsPlugin _plugin; | |||
private readonly Command _command; | |||
public CommandBuilder(Command command) | |||
private List<CommandParameter> _params; | |||
private bool _hasOptional, _hasCatchAll; | |||
private string _prefix; | |||
public CommandBuilder(CommandsPlugin plugin, Command command, string prefix) | |||
{ | |||
_plugin = plugin; | |||
_command = command; | |||
} | |||
public CommandBuilder ArgsEqual(int argCount) | |||
{ | |||
_command.MinArgs = argCount; | |||
_command.MaxArgs = argCount; | |||
return this; | |||
} | |||
public CommandBuilder ArgsAtLeast(int minArgCount) | |||
{ | |||
_command.MinArgs = minArgCount; | |||
_command.MaxArgs = null; | |||
return this; | |||
} | |||
public CommandBuilder ArgsAtMost(int maxArgCount) | |||
_params = new List<CommandParameter>(); | |||
_prefix = prefix; | |||
} | |||
public CommandBuilder Alias(params string[] aliases) | |||
{ | |||
_command.MinArgs = null; | |||
_command.MaxArgs = maxArgCount; | |||
aliases = aliases.Select(x => AppendPrefix(_prefix, x)).ToArray(); | |||
_command.SetAliases(aliases); | |||
return this; | |||
} | |||
public CommandBuilder ArgsBetween(int minArgCount, int maxArgCount) | |||
public CommandBuilder Info(string description) | |||
{ | |||
_command.MinArgs = minArgCount; | |||
_command.MaxArgs = maxArgCount; | |||
_command.Description = description; | |||
return this; | |||
} | |||
public CommandBuilder NoArgs() | |||
public CommandBuilder Parameter(string name, bool isOptional = false, bool isCatchAll = false) | |||
{ | |||
_command.MinArgs = 0; | |||
_command.MaxArgs = 0; | |||
if (_hasCatchAll) | |||
throw new Exception("No parameters may be added after the catch-all"); | |||
if (_hasOptional && isOptional) | |||
throw new Exception("Non-optional parameters may not be added after an optional one"); | |||
_params.Add(new CommandParameter(name, isOptional, isCatchAll)); | |||
if (isOptional) | |||
_hasOptional = true; | |||
if (isCatchAll) | |||
_hasCatchAll = true; | |||
return this; | |||
} | |||
public CommandBuilder AnyArgs() | |||
public CommandBuilder IsHidden() | |||
{ | |||
_command.MinArgs = null; | |||
_command.MaxArgs = null; | |||
_command.IsHidden = true; | |||
return this; | |||
} | |||
@@ -54,32 +59,45 @@ namespace Discord.Commands | |||
return this; | |||
} | |||
public CommandBuilder Desc(string desc) | |||
{ | |||
_command.Description = desc; | |||
return this; | |||
} | |||
public CommandBuilder IsHidden() | |||
{ | |||
_command.IsHidden = true; | |||
return this; | |||
} | |||
public CommandBuilder Do(Func<CommandEventArgs, Task> func) | |||
public void Do(Func<CommandEventArgs, Task> func) | |||
{ | |||
_command.Handler = func; | |||
return this; | |||
_command.SetHandler(func); | |||
Build(); | |||
} | |||
public CommandBuilder Do(Action<CommandEventArgs> func) | |||
public void Do(Action<CommandEventArgs> func) | |||
{ | |||
_command.Handler = e => { func(e); return TaskHelper.CompletedTask; }; | |||
return this; | |||
_command.SetHandler(func); | |||
Build(); | |||
} | |||
private void Build() | |||
{ | |||
_command.SetParameters(_params.ToArray()); | |||
foreach (var alias in _command.Aliases) | |||
_plugin.Map.AddCommand(alias, _command); | |||
_plugin.AddCommand(_command); | |||
} | |||
internal static string AppendPrefix(string prefix, string cmd) | |||
{ | |||
if (cmd != "") | |||
{ | |||
if (prefix != "") | |||
return prefix + ' ' + cmd; | |||
else | |||
return cmd; | |||
} | |||
else | |||
{ | |||
if (prefix != "") | |||
return prefix; | |||
else | |||
throw new ArgumentOutOfRangeException(nameof(cmd)); | |||
} | |||
} | |||
} | |||
public sealed class CommandGroupBuilder | |||
{ | |||
private readonly CommandsPlugin _plugin; | |||
internal readonly CommandsPlugin _plugin; | |||
private readonly string _prefix; | |||
private int _defaultMinPermissions; | |||
@@ -104,25 +122,9 @@ namespace Discord.Commands | |||
=> CreateCommand(""); | |||
public CommandBuilder CreateCommand(string cmd) | |||
{ | |||
string text; | |||
if (cmd != "") | |||
{ | |||
if (_prefix != "") | |||
text = _prefix + ' ' + cmd; | |||
else | |||
text = cmd; | |||
} | |||
else | |||
{ | |||
if (_prefix != "") | |||
text = _prefix; | |||
else | |||
throw new ArgumentOutOfRangeException(nameof(cmd)); | |||
} | |||
var command = new Command(text); | |||
var command = new Command(CommandBuilder.AppendPrefix(_prefix, cmd)); | |||
command.MinPerms = _defaultMinPermissions; | |||
_plugin.AddCommand(command); | |||
return new CommandBuilder(command); | |||
return new CommandBuilder(_plugin, command, _prefix); | |||
} | |||
} | |||
} |
@@ -0,0 +1,84 @@ | |||
using System; | |||
using System.Collections.Generic; | |||
namespace Discord.Commands | |||
{ | |||
internal class CommandMap | |||
{ | |||
private CommandMap _parent; | |||
private Command _command; | |||
private readonly Dictionary<string, CommandMap> _subCommands; | |||
public CommandMap(CommandMap parent) | |||
{ | |||
_parent = parent; | |||
_subCommands = new Dictionary<string, CommandMap>(); | |||
} | |||
public CommandMap GetMap(string text) | |||
{ | |||
CommandMap map; | |||
if (_subCommands.TryGetValue(text, out map)) | |||
return map; | |||
else | |||
return null; | |||
} | |||
public Command GetCommand() | |||
{ | |||
if (_command != null) | |||
return _command; | |||
else if (_parent != null) | |||
return _parent.GetCommand(); | |||
else | |||
return null; | |||
} | |||
public Command GetCommand(string text) | |||
{ | |||
return GetCommand(0, text.Split(' ')); | |||
} | |||
public Command GetCommand(int index, string[] parts) | |||
{ | |||
if (index != parts.Length) | |||
{ | |||
string nextPart = parts[index]; | |||
CommandMap nextGroup; | |||
if (_subCommands.TryGetValue(nextPart, out nextGroup)) | |||
{ | |||
var cmd = nextGroup.GetCommand(index + 1, parts); | |||
if (cmd != null) | |||
return cmd; | |||
} | |||
} | |||
if (_command != null) | |||
return _command; | |||
return null; | |||
} | |||
public void AddCommand(string text, Command command) | |||
{ | |||
AddCommand(0, text.Split(' '), command); | |||
} | |||
public void AddCommand(int index, string[] parts, Command command) | |||
{ | |||
if (index != parts.Length) | |||
{ | |||
string nextPart = parts[index]; | |||
CommandMap nextGroup; | |||
if (!_subCommands.TryGetValue(nextPart, out nextGroup)) | |||
{ | |||
nextGroup = new CommandMap(this); | |||
_subCommands.Add(nextPart, nextGroup); | |||
} | |||
nextGroup.AddCommand(index + 1, parts, command); | |||
} | |||
else | |||
{ | |||
if (_command != null) | |||
throw new InvalidOperationException("A command has already been added with this path."); | |||
_command = command; | |||
} | |||
} | |||
} | |||
} |
@@ -15,37 +15,69 @@ namespace Discord.Commands | |||
} | |||
//TODO: Check support for escaping | |||
public static class CommandParser | |||
internal static class CommandParser | |||
{ | |||
private enum CommandParserPart | |||
{ | |||
None, | |||
CommandName, | |||
Parameter, | |||
QuotedParameter, | |||
DoubleQuotedParameter | |||
} | |||
public static bool Parse(string input, out string command, out CommandPart[] args) | |||
public static bool ParseCommand(string input, CommandMap map, out Command command, out int endPos) | |||
{ | |||
return Parse(input, out command, out args, true); | |||
} | |||
public static bool ParseArgs(string input, out CommandPart[] args) | |||
{ | |||
string ignored; | |||
return Parse(input, out ignored, out args, false); | |||
int startPosition = 0; | |||
int endPosition = 0; | |||
int inputLength = input.Length; | |||
bool isEscaped = false; | |||
command = null; | |||
endPos = 0; | |||
if (input == "") | |||
return false; | |||
while (endPosition < inputLength) | |||
{ | |||
char currentChar = input[endPosition++]; | |||
if (isEscaped) | |||
isEscaped = false; | |||
else if (currentChar == '\\') | |||
isEscaped = true; | |||
if ((!isEscaped && currentChar == ' ') || endPosition >= inputLength) | |||
{ | |||
int length = (currentChar == ' ' ? endPosition - 1 : endPosition) - startPosition; | |||
string temp = input.Substring(startPosition, length); | |||
if (temp == "") | |||
startPosition = endPosition; | |||
else | |||
{ | |||
var newMap = map.GetMap(temp); | |||
if (newMap != null) | |||
{ | |||
map = newMap; | |||
endPos = endPosition; | |||
} | |||
else | |||
break; | |||
startPosition = endPosition; | |||
} | |||
} | |||
} | |||
command = map.GetCommand(); //Work our way backwards to find a command that matches our input | |||
return command != null; | |||
} | |||
private static bool Parse(string input, out string command, out CommandPart[] args, bool parseCommand) | |||
public static bool ParseArgs(string input, int startPos, Command command, out CommandPart[] args) | |||
{ | |||
CommandParserPart currentPart = parseCommand ? CommandParserPart.CommandName : CommandParserPart.None; | |||
int startPosition = 0; | |||
int endPosition = 0; | |||
CommandParserPart currentPart = CommandParserPart.None; | |||
int startPosition = startPos; | |||
int endPosition = startPos; | |||
int inputLength = input.Length; | |||
bool isEscaped = false; | |||
List<CommandPart> argList = new List<CommandPart>(); | |||
command = null; | |||
args = null; | |||
if (input == "") | |||
@@ -61,21 +93,6 @@ namespace Discord.Commands | |||
switch (currentPart) | |||
{ | |||
case CommandParserPart.CommandName: | |||
if ((!isEscaped && currentChar == ' ') || endPosition >= inputLength) | |||
{ | |||
int length = (currentChar == ' ' ? endPosition - 1 : endPosition) - startPosition; | |||
string temp = input.Substring(startPosition, length); | |||
if (temp == "") | |||
startPosition = endPosition; | |||
else | |||
{ | |||
currentPart = CommandParserPart.None; | |||
command = temp; | |||
startPosition = endPosition; | |||
} | |||
} | |||
break; | |||
case CommandParserPart.None: | |||
if ((!isEscaped && currentChar == '\"')) | |||
{ | |||
@@ -126,9 +143,6 @@ namespace Discord.Commands | |||
} | |||
} | |||
if (parseCommand && (command == null || command == "")) | |||
return false; | |||
args = argList.ToArray(); | |||
return true; | |||
} | |||
@@ -22,7 +22,7 @@ namespace Discord.Commands | |||
} | |||
} | |||
public enum CommandErrorType { Exception, UnknownCommand, BadPermissions, BadArgCount } | |||
public enum CommandErrorType { Exception, UnknownCommand, BadPermissions, BadArgCount, InvalidInput } | |||
public class CommandErrorEventArgs : CommandEventArgs | |||
{ | |||
public CommandErrorType ErrorType { get; } | |||
@@ -10,29 +10,30 @@ namespace Discord.Commands | |||
public partial class CommandsPlugin | |||
{ | |||
private readonly DiscordClient _client; | |||
private Func<User, int> _getPermissions; | |||
private Dictionary<string, Command> _commands; | |||
private readonly Func<User, int> _getPermissions; | |||
public Dictionary<string, Command> Commands => _commands; | |||
public IEnumerable<Command> Commands => _commands; | |||
private readonly List<Command> _commands; | |||
internal CommandMap Map => _map; | |||
private readonly CommandMap _map; | |||
public char CommandChar { get { return CommandChars[0]; } set { CommandChars = new List<char> { value }; } } // This could possibly be removed entirely. Not sure. | |||
public List<char> CommandChars { get; set; } | |||
public bool UseCommandChar { get; set; } | |||
public IEnumerable<char> CommandChars { get { return _commandChars; } set { _commandChars = value.ToArray(); } } | |||
private char[] _commandChars; | |||
public bool RequireCommandCharInPublic { get; set; } | |||
public bool RequireCommandCharInPrivate { get; set; } | |||
public bool HelpInPublic { get; set; } | |||
public CommandsPlugin(DiscordClient client, Func<User, int> getPermissions = null, bool builtInHelp = false) | |||
{ | |||
_client = client; // Wait why is this even set | |||
_client = client; | |||
_getPermissions = getPermissions; | |||
_commands = new Dictionary<string, Command>(); | |||
_commands = new List<Command>(); | |||
_map = new CommandMap(null); | |||
CommandChar = '!'; // Kept around to keep from possibly throwing an error. Might not be necessary. | |||
CommandChars = new List<char> { '!', '?', '/' }; | |||
UseCommandChar = true; | |||
_commandChars = new char[] { '!' }; | |||
RequireCommandCharInPublic = true; | |||
RequireCommandCharInPrivate = true; | |||
HelpInPublic = true; | |||
@@ -40,9 +41,9 @@ namespace Discord.Commands | |||
if (builtInHelp) | |||
{ | |||
CreateCommand("help") | |||
.ArgsBetween(0, 1) | |||
.Parameter("command", isOptional: true) | |||
.IsHidden() | |||
.Desc("Returns information about commands.") | |||
.Info("Returns information about commands.") | |||
.Do(async e => | |||
{ | |||
if (e.Command.Text != "help") | |||
@@ -50,29 +51,18 @@ namespace Discord.Commands | |||
else | |||
{ | |||
if (e.Args == null) | |||
{ | |||
StringBuilder output = new StringBuilder(); | |||
bool first = true; | |||
{ | |||
int permissions = getPermissions(e.User); | |||
StringBuilder output = new StringBuilder(); | |||
output.AppendLine("These are the commands you can use:"); | |||
output.Append("`"); | |||
int permissions = getPermissions(e.User); | |||
foreach (KeyValuePair<string, Command> k in _commands) | |||
{ | |||
if (permissions >= k.Value.MinPerms && !k.Value.IsHidden) | |||
if (first) | |||
{ | |||
output.Append(k.Key); | |||
first = false; | |||
} | |||
else | |||
output.Append($", {k.Key}"); | |||
} | |||
output.Append(string.Join(", ", _commands.Select(x => permissions >= x.MinPerms && !x.IsHidden))); | |||
output.Append("`"); | |||
if (CommandChars.Count == 1) | |||
output.AppendLine($"{Environment.NewLine}You can use `{CommandChars[0]}` to call a command."); | |||
if (_commandChars.Length == 1) | |||
output.AppendLine($"\nYou can use `{_commandChars[0]}` to call a command."); | |||
else | |||
output.AppendLine($"{Environment.NewLine}You can use `{String.Join(" ", CommandChars.Take(CommandChars.Count - 1))}` and `{CommandChars.Last()}` to call a command."); | |||
output.AppendLine($"\nYou can use `{string.Join(" ", CommandChars.Take(_commandChars.Length - 1))}` and `{_commandChars.Last()}` to call a command."); | |||
output.AppendLine("`help <command>` can tell you more about how to use a command."); | |||
@@ -80,8 +70,9 @@ namespace Discord.Commands | |||
} | |||
else | |||
{ | |||
if (_commands.ContainsKey(e.Args[0])) | |||
await Reply(e, CommandDetails(_commands[e.Args[0]])); | |||
var cmd = _map.GetCommand(e.Args[0]); | |||
if (cmd != null) | |||
await Reply(e, CommandDetails(cmd)); | |||
else | |||
await Reply(e, $"`{e.Args[0]}` is not a valid command."); | |||
} | |||
@@ -99,7 +90,7 @@ namespace Discord.Commands | |||
string msg = e.Message.Text; | |||
if (msg.Length == 0) return; | |||
if (UseCommandChar) | |||
if (_commandChars.Length > 0) | |||
{ | |||
bool isPrivate = e.Message.Channel.IsPrivate; | |||
bool hasCommandChar = CommandChars.Contains(msg[0]); | |||
@@ -112,44 +103,41 @@ namespace Discord.Commands | |||
return; // Same, but public. | |||
} | |||
string cmd; | |||
CommandPart[] args; | |||
if (!CommandParser.Parse(msg, out cmd, out args)) | |||
return; | |||
if (_commands.ContainsKey(cmd)) | |||
{ | |||
Command comm = _commands[cmd]; | |||
//Clean args | |||
//Parse command | |||
Command command; | |||
int argPos; | |||
CommandParser.ParseCommand(msg, _map, out command, out argPos); | |||
if (command == null) | |||
{ | |||
CommandEventArgs errorArgs = new CommandEventArgs(e.Message, null, null, null); | |||
RaiseCommandError(CommandErrorType.UnknownCommand, errorArgs); | |||
return; | |||
} | |||
else | |||
{ | |||
//Parse arguments | |||
CommandPart[] args; | |||
if (!CommandParser.ParseArgs(msg, argPos, command, out args)) | |||
{ | |||
CommandEventArgs errorArgs = new CommandEventArgs(e.Message, null, null, null); | |||
RaiseCommandError(CommandErrorType.InvalidInput, errorArgs); | |||
return; | |||
} | |||
int argCount = args.Length; | |||
string[] newArgs = null; | |||
if (comm.MaxArgs != null && argCount > 0) | |||
{ | |||
newArgs = new string[(int)comm.MaxArgs]; | |||
for (int j = 0; j < newArgs.Length; j++) | |||
newArgs[j] = args[j].Value; | |||
} | |||
else if (comm.MaxArgs == null && comm.MinArgs == null) | |||
{ | |||
newArgs = new string[argCount]; | |||
for (int j = 0; j < newArgs.Length; j++) | |||
newArgs[j] = args[j].Value; | |||
} | |||
//Get information for the rest of the steps | |||
int userPermissions = _getPermissions != null ? _getPermissions(e.Message.User) : 0; | |||
var eventArgs = new CommandEventArgs(e.Message, comm, userPermissions, newArgs); | |||
var eventArgs = new CommandEventArgs(e.Message, command, userPermissions, args.Select(x => x.Value).ToArray()); | |||
// Check permissions | |||
if (userPermissions < comm.MinPerms) | |||
if (userPermissions < command.MinPerms) | |||
{ | |||
RaiseCommandError(CommandErrorType.BadPermissions, eventArgs); | |||
return; | |||
} | |||
//Check arg count | |||
if (argCount < comm.MinArgs) | |||
if (argCount < command.MinArgs) | |||
{ | |||
RaiseCommandError(CommandErrorType.BadArgCount, eventArgs); | |||
return; | |||
@@ -159,21 +147,13 @@ namespace Discord.Commands | |||
try | |||
{ | |||
RaiseRanCommand(eventArgs); | |||
var task = comm.Handler(eventArgs); | |||
if (task != null) | |||
await task.ConfigureAwait(false); | |||
} | |||
catch (Exception ex) | |||
{ | |||
RaiseCommandError(CommandErrorType.Exception, eventArgs, ex); | |||
} | |||
} | |||
else | |||
{ | |||
CommandEventArgs eventArgs = new CommandEventArgs(e.Message, null, null, null); | |||
RaiseCommandError(CommandErrorType.UnknownCommand, eventArgs); | |||
return; | |||
} | |||
await command.Run(eventArgs).ConfigureAwait(false); | |||
} | |||
catch (Exception ex) | |||
{ | |||
RaiseCommandError(CommandErrorType.Exception, eventArgs, ex); | |||
} | |||
} | |||
}; | |||
} | |||
@@ -198,7 +178,7 @@ namespace Discord.Commands | |||
else if (command.MinArgs == null && command.MaxArgs != null) | |||
output.Append($" ≤{command.MaxArgs.ToString()} Args"); | |||
output.Append($": {command.Description}"); | |||
output.Append($": {command.Description ?? "No description set for this command."}"); | |||
return output.ToString(); | |||
} | |||
@@ -216,13 +196,14 @@ namespace Discord.Commands | |||
public CommandBuilder CreateCommand(string cmd) | |||
{ | |||
var command = new Command(cmd); | |||
_commands.Add(cmd, command); | |||
return new CommandBuilder(command); | |||
_commands.Add(command); | |||
return new CommandBuilder(null, command, ""); | |||
} | |||
internal void AddCommand(Command command) | |||
{ | |||
_commands.Add(command.Text, command); | |||
_commands.Add(command); | |||
_map.AddCommand(command.Text, command); | |||
} | |||
} | |||
} |