diff --git a/README.md b/README.md index 7136d32dc..845f996e8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Discord.Net v0.8.0-beta1 +# Discord.Net v0.8.1-Beta An unofficial .Net API Wrapper for the Discord client (http://discordapp.com). Check out the [documentation](https://discordnet.readthedocs.org/en/latest/) or join the [Discord API Chat](https://discord.gg/0SBTUU1wZTVjAMPx). diff --git a/src/Discord.Net.Commands.Net45/Discord.Net.Commands.csproj b/src/Discord.Net.Commands.Net45/Discord.Net.Commands.csproj index 69f042ac3..3a77ea6ed 100644 --- a/src/Discord.Net.Commands.Net45/Discord.Net.Commands.csproj +++ b/src/Discord.Net.Commands.Net45/Discord.Net.Commands.csproj @@ -7,7 +7,7 @@ {1B5603B4-6F8F-4289-B945-7BAAE523D740} Library Properties - Discord + Discord.Commands Discord.Net.Commands 512 v4.5 @@ -43,14 +43,23 @@ CommandBuilder.cs + + CommandExtensions.cs + + + CommandMap.cs + CommandParser.cs - - CommandsPlugin.cs + + CommandService.cs + + + CommandService.Events.cs - - CommandsPlugin.Events.cs + + CommandServiceConfig.cs diff --git a/src/Discord.Net.Commands/Command.cs b/src/Discord.Net.Commands/Command.cs index f8728f4c6..15ad98b95 100644 --- a/src/Discord.Net.Commands/Command.cs +++ b/src/Discord.Net.Commands/Command.cs @@ -1,21 +1,111 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; namespace Discord.Commands { + public enum ParameterType + { + /// Catches a single required parameter. + Required, + /// Catches a single optional parameter. + Optional, + /// Catches a zero or more optional parameters. + Multiple, + /// Catches all remaining text as a single optional parameter. + Unparsed + } + public sealed class CommandParameter + { + public string Name { get; } + public ParameterType Type { get; } + + public CommandParameter(string name, ParameterType type) + { + Name = name; + Type = type; + } + } + public sealed class Command { public string Text { get; } - public int? MinArgs { get; internal set; } - public int? MaxArgs { get; internal set; } - public int MinPerms { get; internal set; } - internal readonly string[] Parts; - internal Func Handler; + public int? MinArgs { get; private set; } + public int? MaxArgs { get; private set; } + public int MinPermissions { get; internal set; } + public bool IsHidden { get; internal set; } + public string Description { get; internal set; } + + public IEnumerable Aliases => _aliases; + private string[] _aliases; + + public IEnumerable Parameters => _parameters; + internal CommandParameter[] _parameters; + + private Func _handler; internal Command(string text) { Text = text; - Parts = text.ToLowerInvariant().Split(' '); + 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].Type == ParameterType.Multiple) + MaxArgs = null; + else + MaxArgs = parameters.Length; + + int? optionalStart = null; + for (int i = parameters.Length - 1; i >= 0; i--) + { + if (parameters[i].Type == ParameterType.Optional) + optionalStart = i; + else + break; + } + if (optionalStart == null) + MinArgs = MaxArgs; + else + MinArgs = optionalStart.Value; + } + } + } + + internal void SetHandler(Func func) + { + _handler = func; + } + internal void SetHandler(Action 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; } } } diff --git a/src/Discord.Net.Commands/CommandBuilder.cs b/src/Discord.Net.Commands/CommandBuilder.cs index 0db5a388f..99a8ae97c 100644 --- a/src/Discord.Net.Commands/CommandBuilder.cs +++ b/src/Discord.Net.Commands/CommandBuilder.cs @@ -1,79 +1,111 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; namespace Discord.Commands { public sealed class CommandBuilder { + private readonly CommandService _service; private readonly Command _command; - public CommandBuilder(Command command) + private List _params; + private bool _allowRequired, _isClosed; + private string _prefix; + + public CommandBuilder(CommandService service, Command command, string prefix) { + _service = service; _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(); + _prefix = prefix; + _allowRequired = true; + _isClosed = false; + } + + 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, ParameterType type = ParameterType.Required) { - _command.MinArgs = 0; - _command.MaxArgs = 0; + if (_isClosed) + throw new Exception($"No parameters may be added after a {nameof(ParameterType.Multiple)} or {nameof(ParameterType.Unparsed)} parameter."); + if (!_allowRequired && type == ParameterType.Required) + throw new Exception($"{nameof(ParameterType.Required)} parameters may not be added after an optional one"); + + _params.Add(new CommandParameter(name, type)); + + if (type == ParameterType.Optional) + _allowRequired = false; + if (type == ParameterType.Multiple || type == ParameterType.Unparsed) + _isClosed = true; return this; } - public CommandBuilder AnyArgs() + public CommandBuilder Hide() { - _command.MinArgs = null; - _command.MaxArgs = null; + _command.IsHidden = true; return this; } public CommandBuilder MinPermissions(int level) { - _command.MinPerms = level; + _command.MinPermissions = level; return this; } - public CommandBuilder Do(Func func) + public void Do(Func func) { - _command.Handler = func; - return this; + _command.SetHandler(func); + Build(); } - public CommandBuilder Do(Action func) + public void Do(Action 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) + _service.Map.AddCommand(alias, _command); + _service.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 CommandService _service; private readonly string _prefix; private int _defaultMinPermissions; - internal CommandGroupBuilder(CommandsPlugin plugin, string prefix, int defaultMinPermissions) + internal CommandGroupBuilder(CommandService service, string prefix, int defaultMinPermissions) { - _plugin = plugin; + _service = service; _prefix = prefix; _defaultMinPermissions = defaultMinPermissions; } @@ -85,32 +117,16 @@ namespace Discord.Commands public CommandGroupBuilder CreateCommandGroup(string cmd, Action config = null) { - config(new CommandGroupBuilder(_plugin, _prefix + ' ' + cmd, _defaultMinPermissions)); + config(new CommandGroupBuilder(_service, _prefix + ' ' + cmd, _defaultMinPermissions)); return this; } public CommandBuilder CreateCommand() => 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); - command.MinPerms = _defaultMinPermissions; - _plugin.AddCommand(command); - return new CommandBuilder(command); + var command = new Command(CommandBuilder.AppendPrefix(_prefix, cmd)); + command.MinPermissions = _defaultMinPermissions; + return new CommandBuilder(_service, command, _prefix); } } } diff --git a/src/Discord.Net.Commands/CommandExtensions.cs b/src/Discord.Net.Commands/CommandExtensions.cs new file mode 100644 index 000000000..e6d7a0ee3 --- /dev/null +++ b/src/Discord.Net.Commands/CommandExtensions.cs @@ -0,0 +1,8 @@ +namespace Discord.Commands +{ + public static class CommandExtensions + { + public static CommandService Commands(this DiscordClient client) + => client.GetService(); + } +} diff --git a/src/Discord.Net.Commands/CommandMap.cs b/src/Discord.Net.Commands/CommandMap.cs new file mode 100644 index 000000000..d066a8910 --- /dev/null +++ b/src/Discord.Net.Commands/CommandMap.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.Commands +{ + internal class CommandMap + { + private CommandMap _parent; + private Command _command; + private readonly Dictionary _subCommands; + + public Command Command => _command; + public IEnumerable SubCommands => _subCommands.Select(x => x.Value.Command).Where(x => x != null); + + public CommandMap(CommandMap parent) + { + _parent = parent; + _subCommands = new Dictionary(); + } + + public CommandMap GetMap(string text) + { + return GetMap(0, text.Split(' ')); + } + public CommandMap GetMap(int index, string[] parts) + { + if (index != parts.Length) + { + string nextPart = parts[index]; + CommandMap nextGroup; + if (_subCommands.TryGetValue(nextPart, out nextGroup)) + return nextGroup.GetMap(index + 1, parts); + else + return null; + } + return this; + } + + 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; + } + } + } +} diff --git a/src/Discord.Net.Commands/CommandParser.cs b/src/Discord.Net.Commands/CommandParser.cs index 76a4b4006..90b00e977 100644 --- a/src/Discord.Net.Commands/CommandParser.cs +++ b/src/Discord.Net.Commands/CommandParser.cs @@ -2,51 +2,24 @@ namespace Discord.Commands { - public class CommandPart - { - public string Value { get; } - public int Index { get; } - - internal CommandPart(string value, int index) - { - Value = value; - Index = index; - } - } - - //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) - { - 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); - } - - private static bool Parse(string input, out string command, out CommandPart[] args, bool parseCommand) + public static bool ParseCommand(string input, CommandMap map, out Command command, out int endPos) { - CommandParserPart currentPart = parseCommand ? CommandParserPart.CommandName : CommandParserPart.None; int startPosition = 0; int endPosition = 0; - int inputLength = input.Length; + int inputLength = input.Length; bool isEscaped = false; - List argList = new List(); - command = null; - args = null; + endPos = 0; if (input == "") return false; @@ -59,23 +32,71 @@ namespace Discord.Commands else if (currentChar == '\\') isEscaped = true; - switch (currentPart) + if ((!isEscaped && currentChar == ' ') || endPosition >= inputLength) { - 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 + { + var newMap = map.GetMap(temp); + if (newMap != null) { - 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; - } - } + 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; + } + + //TODO: Check support for escaping + public static CommandErrorType? ParseArgs(string input, int startPos, Command command, out string[] args) + { + CommandParserPart currentPart = CommandParserPart.None; + int startPosition = startPos; + int endPosition = startPos; + int inputLength = input.Length; + bool isEscaped = false; + + var expectedArgs = command._parameters; + List argList = new List(); + CommandParameter parameter = null; + + args = null; + + if (input == "") + return CommandErrorType.InvalidInput; + + while (endPosition < inputLength) + { + if (startPosition == endPosition && (parameter == null || parameter.Type != ParameterType.Multiple)) //Is first char of a new arg + { + if (argList.Count == command.MaxArgs) + return CommandErrorType.BadArgCount; + + parameter = command._parameters[argList.Count]; + if (parameter.Type == ParameterType.Unparsed) + { + argList.Add(input.Substring(startPosition)); break; + } + } + + char currentChar = input[endPosition++]; + if (isEscaped) + isEscaped = false; + else if (currentChar == '\\') + isEscaped = true; + + switch (currentPart) + { case CommandParserPart.None: if ((!isEscaped && currentChar == '\"')) { @@ -96,7 +117,7 @@ namespace Discord.Commands else { currentPart = CommandParserPart.None; - argList.Add(new CommandPart(temp, startPosition)); + argList.Add(temp); startPosition = endPosition; } } @@ -106,31 +127,36 @@ namespace Discord.Commands { string temp = input.Substring(startPosition, endPosition - startPosition - 1); currentPart = CommandParserPart.None; - argList.Add(new CommandPart(temp, startPosition)); + argList.Add(temp); startPosition = endPosition; } else if (endPosition >= inputLength) - return false; + return CommandErrorType.InvalidInput; break; case CommandParserPart.DoubleQuotedParameter: if ((!isEscaped && currentChar == '\"')) { string temp = input.Substring(startPosition, endPosition - startPosition - 1); currentPart = CommandParserPart.None; - argList.Add(new CommandPart(temp, startPosition)); + argList.Add(temp); startPosition = endPosition; } else if (endPosition >= inputLength) - return false; + return CommandErrorType.InvalidInput; break; } } - if (parseCommand && (command == null || command == "")) - return false; + if (argList.Count < command.MinArgs) + { + /*if (command._parameters[command._parameters.Length - 1].Type == ParameterType.Unparsed) + argList.Add(""); + else*/ + return CommandErrorType.BadArgCount; + } args = argList.ToArray(); - return true; + return null; } } } diff --git a/src/Discord.Net.Commands/CommandService.Events.cs b/src/Discord.Net.Commands/CommandService.Events.cs new file mode 100644 index 000000000..92a3d429c --- /dev/null +++ b/src/Discord.Net.Commands/CommandService.Events.cs @@ -0,0 +1,54 @@ +using System; + +namespace Discord.Commands +{ + public class CommandEventArgs + { + public Message Message { get; } + public Command Command { get; } + public int? UserPermissions { get; } + public string[] Args { get; } + + public User User => Message.User; + public Channel Channel => Message.Channel; + public Server Server => Message.Channel.Server; + + public CommandEventArgs(Message message, Command command, int? userPermissions, string[] args) + { + Message = message; + Command = command; + UserPermissions = userPermissions; + Args = args; + } + } + + public enum CommandErrorType { Exception, UnknownCommand, BadPermissions, BadArgCount, InvalidInput } + public class CommandErrorEventArgs : CommandEventArgs + { + public CommandErrorType ErrorType { get; } + public Exception Exception { get; } + + public CommandErrorEventArgs(CommandErrorType errorType, CommandEventArgs baseArgs, Exception ex) + : base(baseArgs.Message, baseArgs.Command, baseArgs.UserPermissions, baseArgs.Args) + { + Exception = ex; + ErrorType = errorType; + } + } + + public partial class CommandService + { + public event EventHandler RanCommand; + private void RaiseRanCommand(CommandEventArgs args) + { + if (RanCommand != null) + RanCommand(this, args); + } + public event EventHandler CommandError; + private void RaiseCommandError(CommandErrorType errorType, CommandEventArgs args, Exception ex = null) + { + if (CommandError != null) + CommandError(this, new CommandErrorEventArgs(errorType, args, ex)); + } + } +} diff --git a/src/Discord.Net.Commands/CommandService.cs b/src/Discord.Net.Commands/CommandService.cs new file mode 100644 index 000000000..efc84d7b2 --- /dev/null +++ b/src/Discord.Net.Commands/CommandService.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// A Discord.Net client with extensions for handling common bot operations like text commands. + public partial class CommandService : IService + { + private DiscordClient _client; + + CommandServiceConfig Config { get; } + public IEnumerable Commands => _commands; + private readonly List _commands; + + internal CommandMap Map => _map; + private readonly CommandMap _map; + + public CommandService(CommandServiceConfig config) + { + Config = config; + _commands = new List(); + _map = new CommandMap(null); + } + + void IService.Install(DiscordClient client) + { + _client = client; + Config.Lock(); + + if (Config.HelpMode != HelpMode.Disable) + { + CreateCommand("help") + .Parameter("command", ParameterType.Multiple) + .Hide() + .Info("Returns information about commands.") + .Do(async e => + { + Channel channel = Config.HelpMode == HelpMode.Public ? e.Channel : await client.CreatePMChannel(e.User); + if (e.Args.Length > 0) //Show command help + { + var cmd = _map.GetCommand(string.Join(" ", e.Args)); + if (cmd != null) + await ShowHelp(cmd, e.User, channel); + else + await client.SendMessage(channel, "Unable to display help: unknown command."); + } + else //Show general help + await ShowHelp(e.User, channel); + }); + } + + client.MessageReceived += async (s, e) => + { + if (_commands.Count == 0) return; + if (e.Message.IsAuthor) return; + + string msg = e.Message.Text; + if (msg.Length == 0) return; + + //Check for command char if one is provided + var chars = Config.CommandChars; + if (chars.Length > 0) + { + if (!chars.Contains(msg[0])) + return; + msg = msg.Substring(1); + } + + //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 + { + int userPermissions = Config.PermissionResolver?.Invoke(e.Message.User) ?? 0; + + //Parse arguments + string[] args; + var error = CommandParser.ParseArgs(msg, argPos, command, out args); + if (error != null) + { + var errorArgs = new CommandEventArgs(e.Message, command, userPermissions, null); + RaiseCommandError(error.Value, errorArgs); + return; + } + + var eventArgs = new CommandEventArgs(e.Message, command, userPermissions, args); + + // Check permissions + if (userPermissions < command.MinPermissions) + { + RaiseCommandError(CommandErrorType.BadPermissions, eventArgs); + return; + } + + // Run the command + try + { + RaiseRanCommand(eventArgs); + await command.Run(eventArgs).ConfigureAwait(false); + } + catch (Exception ex) + { + RaiseCommandError(CommandErrorType.Exception, eventArgs, ex); + } + } + }; + } + + public Task ShowHelp(User user, Channel channel) + { + int permissions = Config.PermissionResolver(user); + + StringBuilder output = new StringBuilder(); + output.AppendLine("These are the commands you can use:"); + output.Append(string.Join(", ", _commands + .Where(x => permissions >= x.MinPermissions && !x.IsHidden) + .Select(x => '`' + x.Text + '`'))); + + var chars = Config.CommandChars; + if (chars.Length > 0) + { + if (chars.Length == 1) + output.AppendLine($"\nYou can use `{chars[0]}` to call a command."); + else + output.AppendLine($"\nYou can use `{string.Join(" ", chars.Take(chars.Length - 1))}` or `{chars.Last()}` to call a command."); + } + + output.AppendLine("`help ` can tell you more about how to use a command."); + + return _client.SendMessage(channel, output.ToString()); + } + + public Task ShowHelp(Command command, User user, Channel channel) + { + StringBuilder output = new StringBuilder(); + + output.Append($"`{command.Text}`"); + + if (command.MinArgs != null && command.MaxArgs != null) + { + if (command.MinArgs == command.MaxArgs) + { + if (command.MaxArgs != 0) + output.Append($" {command.MinArgs.ToString()} Args"); + } + else + output.Append($" {command.MinArgs.ToString()} - {command.MaxArgs.ToString()} Args"); + } + else if (command.MinArgs != null && command.MaxArgs == null) + output.Append($" ≥{command.MinArgs.ToString()} Args"); + else if (command.MinArgs == null && command.MaxArgs != null) + output.Append($" ≤{command.MaxArgs.ToString()} Args"); + + output.Append($": {command.Description ?? "No description set for this command."}"); + + return _client.SendMessage(channel, output.ToString()); + } + + public void CreateCommandGroup(string cmd, Action config = null) + => config(new CommandGroupBuilder(this, cmd, 0)); + public CommandBuilder CreateCommand(string cmd) + { + var command = new Command(cmd); + _commands.Add(command); + return new CommandBuilder(this, command, ""); + } + + internal void AddCommand(Command command) + { + _commands.Add(command); + _map.AddCommand(command.Text, command); + } + } +} diff --git a/src/Discord.Net.Commands/CommandServiceConfig.cs b/src/Discord.Net.Commands/CommandServiceConfig.cs new file mode 100644 index 000000000..f2903d861 --- /dev/null +++ b/src/Discord.Net.Commands/CommandServiceConfig.cs @@ -0,0 +1,49 @@ +using System; + +namespace Discord.Commands +{ + public enum HelpMode + { + /// Disable the automatic help command. + Disable, + /// Use the automatic help command and respond in the channel the command is used. + Public, + /// Use the automatic help command and respond in a private message. + Private + } + public class CommandServiceConfig + { + public Func PermissionResolver { get { return _permissionsResolver; } set { SetValue(ref _permissionsResolver, value); } } + private Func _permissionsResolver; + + public char? CommandChar + { + get + { + return _commandChars.Length > 0 ? _commandChars[0] : (char?)null; + } + set + { + if (value != null) + CommandChars = new char[] { value.Value }; + else + CommandChars = new char[0]; + } + } + public char[] CommandChars { get { return _commandChars; } set { SetValue(ref _commandChars, value); } } + private char[] _commandChars = new char[] { '!' }; + + public HelpMode HelpMode { get { return _helpMode; } set { SetValue(ref _helpMode, value); } } + private HelpMode _helpMode = HelpMode.Disable; + + //Lock + protected bool _isLocked; + internal void Lock() { _isLocked = true; } + protected void SetValue(ref T storage, T value) + { + if (_isLocked) + throw new InvalidOperationException("Unable to modify a discord client's configuration after it has been created."); + storage = value; + } + } +} diff --git a/src/Discord.Net.Commands/CommandsPlugin.Events.cs b/src/Discord.Net.Commands/CommandsPlugin.Events.cs deleted file mode 100644 index 5901aa2a7..000000000 --- a/src/Discord.Net.Commands/CommandsPlugin.Events.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; - -namespace Discord.Commands -{ - public class PermissionException : Exception { public PermissionException() : base("User does not have permission to run this command.") { } } - public class CommandEventArgs - { - public Message Message { get; } - public Command Command { get; } - public string CommandText { get; } - public string ArgText { get; } - public int? Permissions { get; } - public string[] Args { get; } - - public User User => Message.User; - public Channel Channel => Message.Channel; - public Server Server => Message.Channel.Server; - - public CommandEventArgs(Message message, Command command, string commandText, string argText, int? permissions, string[] args) - { - Message = message; - Command = command; - CommandText = commandText; - ArgText = argText; - Permissions = permissions; - Args = args; - } - } - public class CommandErrorEventArgs : CommandEventArgs - { - public Exception Exception { get; } - - public CommandErrorEventArgs(CommandEventArgs baseArgs, Exception ex) - : base(baseArgs.Message, baseArgs.Command, baseArgs.CommandText, baseArgs.ArgText, baseArgs.Permissions, baseArgs.Args) - { - Exception = ex; - } - } - public partial class CommandsPlugin - { - public event EventHandler RanCommand; - private void RaiseRanCommand(CommandEventArgs args) - { - if (RanCommand != null) - RanCommand(this, args); - } - public event EventHandler UnknownCommand; - private void RaiseUnknownCommand(CommandEventArgs args) - { - if (UnknownCommand != null) - UnknownCommand(this, args); - } - public event EventHandler CommandError; - private void RaiseCommandError(CommandEventArgs args, Exception ex) - { - if (CommandError != null) - CommandError(this, new CommandErrorEventArgs(args, ex)); - } - } -} diff --git a/src/Discord.Net.Commands/CommandsPlugin.cs b/src/Discord.Net.Commands/CommandsPlugin.cs deleted file mode 100644 index 44abce8f4..000000000 --- a/src/Discord.Net.Commands/CommandsPlugin.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Discord.Commands -{ - /// A Discord.Net client with extensions for handling common bot operations like text commands. - public partial class CommandsPlugin - { - private readonly DiscordClient _client; - private List _commands; - private Func _getPermissions; - - public IEnumerable Commands => _commands; - - public char CommandChar { get; set; } - public bool UseCommandChar { get; set; } - public bool RequireCommandCharInPublic { get; set; } - public bool RequireCommandCharInPrivate { get; set; } - - public CommandsPlugin(DiscordClient client, Func getPermissions = null) - { - _client = client; - _getPermissions = getPermissions; - _commands = new List(); - - CommandChar = '/'; - UseCommandChar = false; - RequireCommandCharInPublic = true; - RequireCommandCharInPrivate = true; - - client.MessageReceived += async (s, e) => - { - //If commands aren't being used, don't bother processing them - if (_commands.Count == 0) - return; - - //Ignore messages from ourselves - if (e.Message.User == client.CurrentUser) - return; - - //Check for the command character - string msg = e.Message.Text; - if (UseCommandChar) - { - if (msg.Length == 0) - return; - bool isPrivate = e.Message.Channel.IsPrivate; - bool hasCommandChar = msg[0] == CommandChar; - if (hasCommandChar) - msg = msg.Substring(1); - if (!isPrivate && RequireCommandCharInPublic && !hasCommandChar) - return; - if (isPrivate && RequireCommandCharInPrivate && !hasCommandChar) - return; - } - - CommandPart[] args; - if (!CommandParser.ParseArgs(msg, out args)) - return; - - for (int i = 0; i < _commands.Count; i++) - { - Command cmd = _commands[i]; - - //Check Command Parts - if (args.Length < cmd.Parts.Length) - continue; - - bool isValid = true; - for (int j = 0; j < cmd.Parts.Length; j++) - { - if (!string.Equals(args[j].Value, cmd.Parts[j], StringComparison.OrdinalIgnoreCase)) - { - isValid = false; - break; - } - } - if (!isValid) - continue; - - //Check Arg Count - int argCount = args.Length - cmd.Parts.Length; - if (argCount < cmd.MinArgs || argCount > cmd.MaxArgs) - continue; - - //Clean Args - string[] newArgs = new string[argCount]; - for (int j = 0; j < newArgs.Length; j++) - newArgs[j] = args[j + cmd.Parts.Length].Value; - - //Get ArgText - string argText; - if (argCount == 0) - argText = ""; - else - argText = msg.Substring(args[cmd.Parts.Length].Index); - - //Check Permissions - int permissions = _getPermissions != null ? _getPermissions(e.Message.User) : 0; - var eventArgs = new CommandEventArgs(e.Message, cmd, msg, argText, permissions, newArgs); - if (permissions < cmd.MinPerms) - { - RaiseCommandError(eventArgs, new PermissionException()); - return; - } - - //Run Command - RaiseRanCommand(eventArgs); - try - { - var task = cmd.Handler(eventArgs); - if (task != null) - await task.ConfigureAwait(false); - } - catch (Exception ex) - { - RaiseCommandError(eventArgs, ex); - } - break; - } - }; - } - - public void CreateCommandGroup(string cmd, Action config = null) - => config(new CommandGroupBuilder(this, cmd, 0)); - public CommandBuilder CreateCommand(string cmd) - { - var command = new Command(cmd); - _commands.Add(command); - return new CommandBuilder(command); - } - - internal void AddCommand(Command command) - { - _commands.Add(command); - } - } -} diff --git a/src/Discord.Net.Net45/Discord.Net.csproj b/src/Discord.Net.Net45/Discord.Net.csproj index 0b3717e57..9447f3985 100644 --- a/src/Discord.Net.Net45/Discord.Net.csproj +++ b/src/Discord.Net.Net45/Discord.Net.csproj @@ -88,8 +88,8 @@ API\Enums\PermissionTarget.cs - - API\Enums\Regions.cs + + API\Enums\Region.cs API\Enums\StringEnum.cs @@ -232,6 +232,9 @@ HttpException.cs + + IService.cs + Models\Channel.cs diff --git a/src/Discord.Net/API/Enums/ChannelType.cs b/src/Discord.Net/API/Enums/ChannelType.cs index 066d12565..7d787ae67 100644 --- a/src/Discord.Net/API/Enums/ChannelType.cs +++ b/src/Discord.Net/API/Enums/ChannelType.cs @@ -24,5 +24,11 @@ return new ChannelType(value); } } + + public static implicit operator ChannelType(string value) => FromString(value); + public static bool operator ==(ChannelType a, ChannelType b) => a?._value == b?._value; + public static bool operator !=(ChannelType a, ChannelType b) => a?._value != b?._value; + public override bool Equals(object obj) => (obj as ChannelType)?._value == _value; + public override int GetHashCode() => _value.GetHashCode(); } } diff --git a/src/Discord.Net/API/Enums/PermissionTarget.cs b/src/Discord.Net/API/Enums/PermissionTarget.cs index 28427f247..d501dc72b 100644 --- a/src/Discord.Net/API/Enums/PermissionTarget.cs +++ b/src/Discord.Net/API/Enums/PermissionTarget.cs @@ -24,5 +24,11 @@ return new PermissionTarget(value); } } + + public static implicit operator PermissionTarget(string value) => FromString(value); + public static bool operator ==(PermissionTarget a, PermissionTarget b) => a?._value == b?._value; + public static bool operator !=(PermissionTarget a, PermissionTarget b) => a?._value != b?._value; + public override bool Equals(object obj) => (obj as PermissionTarget)?._value == _value; + public override int GetHashCode() => _value.GetHashCode(); } } diff --git a/src/Discord.Net/API/Enums/Regions.cs b/src/Discord.Net/API/Enums/Region.cs similarity index 71% rename from src/Discord.Net/API/Enums/Regions.cs rename to src/Discord.Net/API/Enums/Region.cs index 505778ad5..090f7cbda 100644 --- a/src/Discord.Net/API/Enums/Regions.cs +++ b/src/Discord.Net/API/Enums/Region.cs @@ -34,5 +34,11 @@ return new Region(value); } } + + public static implicit operator Region(string value) => FromString(value); + public static bool operator ==(Region a, Region b) => a?._value == b?._value; + public static bool operator !=(Region a, Region b) => a?._value != b?._value; + public override bool Equals(object obj) => (obj as Region)?._value == _value; + public override int GetHashCode() => _value.GetHashCode(); } } diff --git a/src/Discord.Net/API/Enums/StringEnum.cs b/src/Discord.Net/API/Enums/StringEnum.cs index 88a527dd2..98cf70e0e 100644 --- a/src/Discord.Net/API/Enums/StringEnum.cs +++ b/src/Discord.Net/API/Enums/StringEnum.cs @@ -2,7 +2,7 @@ { public abstract class StringEnum { - private string _value; + protected string _value; protected StringEnum(string value) { _value = value; @@ -10,43 +10,5 @@ public string Value => _value; public override string ToString() => _value; - - public override bool Equals(object obj) - { - var enum2 = obj as StringEnum; - if (enum2 == (StringEnum)null) - return false; - else - return _value == enum2._value; - } - public override int GetHashCode() - { - return _value.GetHashCode(); - } - - public static bool operator ==(StringEnum a, StringEnum b) - { - return a?._value == b?._value; - } - public static bool operator !=(StringEnum a, StringEnum b) - { - return a?._value != b?._value; - } - public static bool operator ==(StringEnum a, string b) - { - return a?._value == b; - } - public static bool operator !=(StringEnum a, string b) - { - return a?._value != b; - } - public static bool operator ==(string a, StringEnum b) - { - return a == b?._value; - } - public static bool operator !=(string a, StringEnum b) - { - return a != b?._value; - } } } diff --git a/src/Discord.Net/API/Enums/UserStatus.cs b/src/Discord.Net/API/Enums/UserStatus.cs index 493c9ec50..367a796a0 100644 --- a/src/Discord.Net/API/Enums/UserStatus.cs +++ b/src/Discord.Net/API/Enums/UserStatus.cs @@ -28,5 +28,11 @@ return new UserStatus(value); } } + + public static implicit operator UserStatus(string value) => FromString(value); + public static bool operator ==(UserStatus a, UserStatus b) => a?._value == b?._value; + public static bool operator !=(UserStatus a, UserStatus b) => a?._value != b?._value; + public override bool Equals(object obj) => (obj as UserStatus)?._value == _value; + public override int GetHashCode() => _value.GetHashCode(); } } diff --git a/src/Discord.Net/DiscordClient.Channels.cs b/src/Discord.Net/DiscordClient.Channels.cs index 52ce846ec..beef45a35 100644 --- a/src/Discord.Net/DiscordClient.Channels.cs +++ b/src/Discord.Net/DiscordClient.Channels.cs @@ -84,26 +84,30 @@ namespace Discord public IEnumerable FindChannels(Server server, string name, ChannelType type = null, bool exactMatch = false) { if (server == null) throw new ArgumentNullException(nameof(server)); + if (name == null) throw new ArgumentNullException(nameof(name)); CheckReady(); + + var query = server.Channels.Where(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); - IEnumerable result; - if (!exactMatch && name.StartsWith("#")) - { - string name2 = name.Substring(1); - result = _channels.Where(x => x.Server == server && - string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase) || - string.Equals(x.Name, name2, StringComparison.OrdinalIgnoreCase)); - } - else + if (!exactMatch && name.Length >= 2) { - result = _channels.Where(x => x.Server == server && - string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); + if (name[0] == '<' && name[1] == '#' && name[name.Length - 1] == '>') //Parse mention + { + string id = name.Substring(2, name.Length - 3); + var channel = _channels[id]; + if (channel != null) + query = query.Concat(new Channel[] { channel }); + } + else if (name[0] == '#' && (type == null || type == ChannelType.Text)) //If we somehow get text starting with # but isn't a mention + { + string name2 = name.Substring(1); + query = query.Concat(server.TextChannels.Where(x => string.Equals(x.Name, name2, StringComparison.OrdinalIgnoreCase))); + } } - if (type != (string)null) - result = result.Where(x => x.Type == type); - - return result; + if (type != null) + query = query.Where(x => x.Type == type); + return query; } /// Creates a new channel with the provided name and type. @@ -111,7 +115,7 @@ namespace Discord { if (server == null) throw new ArgumentNullException(nameof(server)); if (name == null) throw new ArgumentNullException(nameof(name)); - if (type == (string)null) throw new ArgumentNullException(nameof(type)); + if (type == null) throw new ArgumentNullException(nameof(type)); CheckReady(); var response = await _api.CreateChannel(server.Id, name, type.Value).ConfigureAwait(false); diff --git a/src/Discord.Net/DiscordClient.Messages.cs b/src/Discord.Net/DiscordClient.Messages.cs index 28b3c068c..aaf930941 100644 --- a/src/Discord.Net/DiscordClient.Messages.cs +++ b/src/Discord.Net/DiscordClient.Messages.cs @@ -93,7 +93,6 @@ namespace Discord { if (channel == null) throw new ArgumentNullException(nameof(channel)); if (text == null) throw new ArgumentNullException(nameof(text)); - if (text.Length > MaxMessageSize) throw new ArgumentOutOfRangeException(nameof(text), $"Message must be {MaxMessageSize} characters or less."); CheckReady(); return SendMessage(channel, text, false); @@ -103,7 +102,6 @@ namespace Discord { if (channel == null) throw new ArgumentNullException(nameof(channel)); if (text == null) throw new ArgumentNullException(nameof(text)); - if (text.Length > MaxMessageSize) throw new ArgumentOutOfRangeException(nameof(text), $"Message must be {MaxMessageSize} characters or less."); CheckReady(); return SendMessage(channel, text, false); @@ -113,7 +111,6 @@ namespace Discord { if (user == null) throw new ArgumentNullException(nameof(user)); if (text == null) throw new ArgumentNullException(nameof(text)); - if (text.Length > MaxMessageSize) throw new ArgumentOutOfRangeException(nameof(text), $"Message must be {MaxMessageSize} characters or less."); CheckReady(); var channel = await CreatePMChannel(user).ConfigureAwait(false); @@ -122,7 +119,8 @@ namespace Discord private async Task SendMessage(Channel channel, string text, bool isTextToSpeech) { Message msg; - var userIds = !channel.IsPrivate ? Mention.GetUserIds(text).Distinct() : new string[0]; + var server = channel.Server; + if (Config.UseMessageQueue) { var nonce = GenerateNonce(); @@ -136,14 +134,24 @@ namespace Discord ChannelId = channel.Id, IsTextToSpeech = isTextToSpeech }); - msg.Mentions = userIds.Select(x => _users[x, channel.Server.Id]).Where(x => x != null).ToArray(); - msg.IsQueued = true; msg.Nonce = nonce; + msg.IsQueued = true; + + if (text.Length > MaxMessageSize) + throw new ArgumentOutOfRangeException(nameof(text), $"Message must be {MaxMessageSize} characters or less."); + _pendingMessages.Enqueue(msg); } else { - var model = await _api.SendMessage(channel.Id, text, userIds, null, isTextToSpeech).ConfigureAwait(false); + var mentionedUsers = new List(); + if (!channel.IsPrivate) + text = Mention.CleanUserMentions(this, server, text, mentionedUsers); + + if (text.Length > MaxMessageSize) + throw new ArgumentOutOfRangeException(nameof(text), $"Message must be {MaxMessageSize} characters or less."); + + var model = await _api.SendMessage(channel.Id, text, mentionedUsers.Select(x => x.Id), null, isTextToSpeech).ConfigureAwait(false); msg = _messages.GetOrAdd(model.Id, channel.Id, model.Author.Id); msg.Update(model); RaiseMessageSent(msg); @@ -168,13 +176,17 @@ namespace Discord { if (message == null) throw new ArgumentNullException(nameof(message)); if (text == null) throw new ArgumentNullException(nameof(text)); - if (text.Length > MaxMessageSize) throw new ArgumentOutOfRangeException(nameof(text), $"Message must be {MaxMessageSize} characters or less."); CheckReady(); - if (text != null && text.Length > MaxMessageSize) - text = text.Substring(0, MaxMessageSize); + var channel = message.Channel; + var mentionedUsers = new List(); + if (!channel.IsPrivate) + text = Mention.CleanUserMentions(this, channel.Server, text, mentionedUsers); + + if (text.Length > MaxMessageSize) + throw new ArgumentOutOfRangeException(nameof(text), $"Message must be {MaxMessageSize} characters or less."); - return _api.EditMessage(message.Id, message.Channel.Id, text, Mention.GetUserIds(text)); + return _api.EditMessage(message.Id, message.Channel.Id, text, mentionedUsers.Select(x => x.Id)); } /// Deletes the provided message. @@ -258,7 +270,7 @@ namespace Discord SendMessageResponse response = null; try { - response = await _api.SendMessage(msg.Channel.Id, msg.RawText, msg.Mentions.Select(x => x.Id), msg.Nonce, msg.IsTTS).ConfigureAwait(false); + response = await _api.SendMessage(msg.Channel.Id, msg.RawText, msg.MentionedUsers.Select(x => x.Id), msg.Nonce, msg.IsTTS).ConfigureAwait(false); } catch (WebException) { break; } catch (HttpException) { hasFailed = true; } diff --git a/src/Discord.Net/DiscordClient.Servers.cs b/src/Discord.Net/DiscordClient.Servers.cs index ff66948f4..2066be5b7 100644 --- a/src/Discord.Net/DiscordClient.Servers.cs +++ b/src/Discord.Net/DiscordClient.Servers.cs @@ -84,7 +84,7 @@ namespace Discord public async Task CreateServer(string name, Region region) { if (name == null) throw new ArgumentNullException(nameof(name)); - if (region == (string)null) throw new ArgumentNullException(nameof(region)); + if (region == null) throw new ArgumentNullException(nameof(region)); CheckReady(); var response = await _api.CreateServer(name, region.Value).ConfigureAwait(false); diff --git a/src/Discord.Net/DiscordClient.Users.cs b/src/Discord.Net/DiscordClient.Users.cs index e63fd04c1..aa7e931b1 100644 --- a/src/Discord.Net/DiscordClient.Users.cs +++ b/src/Discord.Net/DiscordClient.Users.cs @@ -141,7 +141,7 @@ namespace Discord return _users[user?.Id, server.Id]; } - /// Returns all users in with the specified server and name, along with their server-specific data. + /// Returns all users with the specified server and name, along with their server-specific data. /// Name formats supported: Name and @Name. Search is case-insensitive. public IEnumerable FindUsers(Server server, string name, string discriminator = null, bool exactMatch = false) { @@ -149,16 +149,39 @@ namespace Discord if (name == null) throw new ArgumentNullException(nameof(name)); CheckReady(); - IEnumerable query; - if (!exactMatch && name.StartsWith("@")) + return FindUsers(server.Members, server.Id, name, discriminator, exactMatch); + } + /// Returns all users with the specified channel and name, along with their server-specific data. + /// Name formats supported: Name and @Name. Search is case-insensitive. + public IEnumerable FindUsers(Channel channel, string name, string discriminator = null, bool exactMatch = false) + { + if (channel == null) throw new ArgumentNullException(nameof(channel)); + if (name == null) throw new ArgumentNullException(nameof(name)); + CheckReady(); + + return FindUsers(channel.Members, channel.IsPrivate ? null : channel.Server.Id, name, discriminator, exactMatch); + } + + private IEnumerable FindUsers(IEnumerable users, string serverId, string name, string discriminator = null, bool exactMatch = false) + { + var query = users.Where(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); + + if (!exactMatch && name.Length >= 2) { - string name2 = name.Substring(1); - query = server.Members.Where(x => - string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase) || - string.Equals(x.Name, name2, StringComparison.OrdinalIgnoreCase)); + if (name[0] == '<' && name[1] == '@' && name[name.Length - 1] == '>') //Parse mention + { + string id = name.Substring(2, name.Length - 3); + var channel = _users[id, serverId]; + if (channel != null) + query = query.Concat(new User[] { channel }); + } + else if (name[0] == '@') //If we somehow get text starting with @ but isn't a mention + { + string name2 = name.Substring(1); + query = query.Concat(users.Where(x => string.Equals(x.Name, name2, StringComparison.OrdinalIgnoreCase))); + } } - else - query = server.Members.Where(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); + if (discriminator != null) query = query.Where(x => x.Discriminator == discriminator); return query; @@ -227,7 +250,7 @@ namespace Discord public Task SetStatus(UserStatus status) { - if (status == (string)null) throw new ArgumentNullException(nameof(status)); + if (status == null) throw new ArgumentNullException(nameof(status)); if (status != UserStatus.Online && status != UserStatus.Idle) throw new ArgumentException($"Invalid status, must be {UserStatus.Online} or {UserStatus.Idle}", nameof(status)); CheckReady(); diff --git a/src/Discord.Net/DiscordClient.cs b/src/Discord.Net/DiscordClient.cs index 48c03e372..8dbb33b96 100644 --- a/src/Discord.Net/DiscordClient.cs +++ b/src/Discord.Net/DiscordClient.cs @@ -17,6 +17,7 @@ namespace Discord private readonly JsonSerializer _serializer; private readonly ConcurrentQueue _pendingMessages; private readonly ConcurrentDictionary _voiceClients; + private readonly Dictionary _services; private bool _sentInitialLog; private uint _nextVoiceClientId; private UserStatus _status; @@ -46,6 +47,7 @@ namespace Discord _roles = new Roles(this, cacheLock); _servers = new Servers(this, cacheLock); _globalUsers = new GlobalUsers(this, cacheLock); + _services = new Dictionary(); _status = UserStatus.Online; @@ -168,7 +170,6 @@ namespace Discord _serializer.MissingMemberHandling = MissingMemberHandling.Error; #endif } - internal override VoiceWebSocket CreateVoiceSocket() { var socket = base.CreateVoiceSocket(); @@ -216,7 +217,6 @@ namespace Discord await Connect(token).ConfigureAwait(false); return token; } - /// Connects to the Discord server with the provided token. public async Task Connect(string token) { @@ -273,6 +273,22 @@ namespace Discord _currentUser = null; } + public void AddService(T obj) + where T : class, IService + { + _services.Add(typeof(T), obj); + obj.Install(this); + } + public T GetService() + where T : class, IService + { + IService service; + if (_services.TryGetValue(typeof(T), out service)) + return service as T; + else + return null; + } + protected override IEnumerable GetTasks() { if (Config.UseMessageQueue) @@ -294,7 +310,8 @@ namespace Discord var data = e.Payload.ToObject(_serializer); _currentUser = _users.GetOrAdd(data.User.Id, null); _currentUser.Update(data.User); - foreach (var model in data.Guilds) + _currentUser.GlobalUser.Update(data.User); + foreach (var model in data.Guilds) { if (!model.Unavailable) { diff --git a/src/Discord.Net/Helpers/Mention.cs b/src/Discord.Net/Helpers/Mention.cs index d718df155..b3c6ac649 100644 --- a/src/Discord.Net/Helpers/Mention.cs +++ b/src/Discord.Net/Helpers/Mention.cs @@ -8,6 +8,7 @@ namespace Discord { private static readonly Regex _userRegex = new Regex(@"<@([0-9]+?)>", RegexOptions.Compiled); private static readonly Regex _channelRegex = new Regex(@"<#([0-9]+?)>", RegexOptions.Compiled); + private static readonly Regex _roleRegex = new Regex(@"@everyone", RegexOptions.Compiled); /// Returns the string used to create a user mention. public static string User(User user) @@ -15,42 +16,50 @@ namespace Discord /// Returns the string used to create a channel mention. public static string Channel(Channel channel) => $"<#{channel.Id}>"; - /// Returns the string used to create a channel mention. + /// Returns the string used to create a mention to everyone in a channel. public static string Everyone() => $"@everyone"; - internal static string ConvertToNames(DiscordClient client, Server server, string text) + internal static string CleanUserMentions(DiscordClient client, Server server, string text, List users = null) { - text = _userRegex.Replace(text, new MatchEvaluator(e => + return _userRegex.Replace(text, new MatchEvaluator(e => { string id = e.Value.Substring(2, e.Value.Length - 3); var user = client.Users[id, server?.Id]; if (user != null) + { + if (users != null) + users.Add(user); return '@' + user.Name; + } else //User not found return '@' + e.Value; })); - if (server != null) + } + internal static string CleanChannelMentions(DiscordClient client, Server server, string text, List channels = null) + { + return _channelRegex.Replace(text, new MatchEvaluator(e => { - text = _channelRegex.Replace(text, new MatchEvaluator(e => + string id = e.Value.Substring(2, e.Value.Length - 3); + var channel = client.Channels[id]; + if (channel != null && channel.Server.Id == server.Id) { - string id = e.Value.Substring(2, e.Value.Length - 3); - var channel = client.Channels[id]; - if (channel != null && channel.Server.Id == server.Id) - return '#' + channel.Name; - else //Channel not found + if (channels != null) + channels.Add(channel); + return '#' + channel.Name; + } + else //Channel not found return '#' + e.Value; - })); - } - return text; + })); } - - internal static IEnumerable GetUserIds(string text) + internal static string CleanRoleMentions(DiscordClient client, User user, Channel channel, string text, List roles = null) { - return _userRegex.Matches(text) - .OfType() - .Select(x => x.Groups[1].Value) - .Where(x => x != null); + return _roleRegex.Replace(text, new MatchEvaluator(e => + { + if (roles != null && user.GetPermissions(channel).MentionEveryone) + roles.Add(channel.Server.EveryoneRole); + return e.Value; + })); } } } diff --git a/src/Discord.Net/IService.cs b/src/Discord.Net/IService.cs new file mode 100644 index 000000000..cf60f70d1 --- /dev/null +++ b/src/Discord.Net/IService.cs @@ -0,0 +1,7 @@ +namespace Discord +{ + public interface IService + { + void Install(DiscordClient client); + } +} diff --git a/src/Discord.Net/Models/Channel.cs b/src/Discord.Net/Models/Channel.cs index 4ce0b1b51..98f2f1c9e 100644 --- a/src/Discord.Net/Models/Channel.cs +++ b/src/Discord.Net/Models/Channel.cs @@ -146,14 +146,12 @@ namespace Discord var cacheLength = _client.Config.MessageCacheLength; if (cacheLength > 0) { - while (_messages.Count > cacheLength - 1) + var oldestIds = _messages.Select(x => x.Value.Id).OrderBy(x => x).Take(_messages.Count - cacheLength); + foreach (var id in oldestIds) { - var oldest = _messages.Select(x => x.Value.Id).OrderBy(x => x).FirstOrDefault(); - if (oldest != null) - { - if (_messages.TryRemove(oldest, out message)) - _client.Messages.TryRemove(oldest); - } + Message removed; + if (_messages.TryRemove(id, out removed)) + _client.Messages.TryRemove(id); } _messages.TryAdd(message.Id, message); } @@ -207,6 +205,8 @@ namespace Discord user.UpdateChannelPermissions(this); } + public override bool Equals(object obj) => obj is Channel && (obj as Channel).Id == Id; + public override int GetHashCode() => Id.GetHashCode(); public override string ToString() => Name ?? Id; } } diff --git a/src/Discord.Net/Models/Color.cs b/src/Discord.Net/Models/Color.cs index 5b5972d48..33887deeb 100644 --- a/src/Discord.Net/Models/Color.cs +++ b/src/Discord.Net/Models/Color.cs @@ -77,5 +77,9 @@ namespace Discord uint mask = (uint)~(0xFF << bit); _rawValue = (_rawValue & mask) | ((uint)value << bit); } + + public override bool Equals(object obj) => obj is Color && (obj as Color)._rawValue == _rawValue; + public override int GetHashCode() => _rawValue.GetHashCode(); + public override string ToString() => '#' + _rawValue.ToString("X"); } } diff --git a/src/Discord.Net/Models/GlobalUser.cs b/src/Discord.Net/Models/GlobalUser.cs index 34848c641..6eb3a24ae 100644 --- a/src/Discord.Net/Models/GlobalUser.cs +++ b/src/Discord.Net/Models/GlobalUser.cs @@ -69,6 +69,8 @@ namespace Discord _client.GlobalUsers.TryRemove(Id); } + public override bool Equals(object obj) => obj is GlobalUser && (obj as GlobalUser).Id == Id; + public override int GetHashCode() => Id.GetHashCode(); public override string ToString() => Id; } } diff --git a/src/Discord.Net/Models/Invite.cs b/src/Discord.Net/Models/Invite.cs index e42c5352d..841e6353c 100644 --- a/src/Discord.Net/Models/Invite.cs +++ b/src/Discord.Net/Models/Invite.cs @@ -5,7 +5,62 @@ using Newtonsoft.Json; namespace Discord { public sealed class Invite : CachedObject - { + { + public sealed class ServerInfo + { + /// Returns the unique identifier of this server. + public string Id { get; } + /// Returns the name of this server. + public string Name { get; } + + internal ServerInfo(string id, string name) + { + Id = id; + Name = name; + } + } + public sealed class ChannelInfo + { + /// Returns the unique identifier of this channel. + public string Id { get; } + /// Returns the name of this channel. + public string Name { get; } + + internal ChannelInfo(string id, string name) + { + Id = id; + Name = name; + } + } + public sealed class InviterInfo + { + /// Returns the unique identifier for this user. + public string Id { get; } + /// Returns the name of this user. + public string Name { get; } + /// Returns the by-name unique identifier for this user. + public string Discriminator { get; } + /// Returns the unique identifier for this user's avatar. + public string AvatarId { get; } + /// Returns the full path to this user's avatar. + public string AvatarUrl => User.GetAvatarUrl(Id, AvatarId); + + internal InviterInfo(string id, string name, string discriminator, string avatarId) + { + Id = id; + Name = name; + Discriminator = discriminator; + AvatarId = avatarId; + } + } + + /// Returns information about the server this invite is attached to. + public ServerInfo Server { get; private set; } + /// Returns information about the channel this invite is attached to. + public ChannelInfo Channel { get; private set; } + /// Returns information about the user that created this invite. + public InviterInfo Inviter { get; private set; } + /// Returns, if enabled, an alternative human-readable code for URLs. public string XkcdCode { get; } /// Time (in seconds) until the invite expires. Set to 0 to never expire. @@ -23,77 +78,23 @@ namespace Discord /// Returns a URL for this invite using XkcdCode if available or Id if not. public string Url => API.Endpoints.InviteUrl(XkcdCode ?? Id); - /// Returns the user that created this invite. - [JsonIgnore] - public User Inviter => _inviter.Value; - private readonly Reference _inviter; - private User _generatedInviter; - - /// Returns the server this invite is to. - [JsonIgnore] - public Server Server => _server.Value; - private readonly Reference _server; - private Server _generatedServer; - - /// Returns the channel this invite is to. - [JsonIgnore] - public Channel Channel => _channel.Value; - private readonly Reference _channel; - private Channel _generatedChannel; - internal Invite(DiscordClient client, string code, string xkcdPass, string serverId, string inviterId, string channelId) : base(client, code) { XkcdCode = xkcdPass; - _server = new Reference(serverId, x => - { - var server = _client.Servers[x]; - if (server == null) - { - server = _generatedServer = new Server(client, x); - server.Cache(); - } - return server; - }); - _inviter = new Reference(serverId, x => - { - var inviter = _client.Users[x, _server.Id]; - if (inviter == null) - { - inviter = _generatedInviter = new User(client, x, _server.Id); - inviter.Cache(); - } - return inviter; - }); - _channel = new Reference(serverId, x => - { - var channel = _client.Channels[x]; - if (channel == null) - { - channel = _generatedChannel = new Channel(client, x, _server.Id, null); - channel.Cache(); - } - return channel; - }); - } - internal override void LoadReferences() - { - _server.Load(); - _inviter.Load(); - _channel.Load(); } + internal override void LoadReferences() { } internal override void UnloadReferences() { } - internal void Update(InviteReference model) { - if (model.Guild != null && _generatedServer != null) - _generatedServer.Update(model.Guild); - if (model.Inviter != null && _generatedInviter != null) - _generatedInviter.Update(model.Inviter); - if (model.Channel != null && _generatedChannel != null) - _generatedChannel.Update(model.Channel); - } + if (model.Guild != null) + Server = new ServerInfo(model.Guild.Id, model.Guild.Name); + if (model.Channel != null) + Channel = new ChannelInfo(model.Channel.Id, model.Channel.Name); + if (model.Inviter != null) + Inviter = new InviterInfo(model.Inviter.Id, model.Inviter.Username, model.Inviter.Discriminator, model.Inviter.Avatar); + } internal void Update(InviteInfo model) { Update(model as InviteReference); @@ -112,6 +113,8 @@ namespace Discord CreatedAt = model.CreatedAt.Value; } + public override bool Equals(object obj) => obj is Invite && (obj as Invite).Id == Id; + public override int GetHashCode() => Id.GetHashCode(); public override string ToString() => XkcdCode ?? Id; } } diff --git a/src/Discord.Net/Models/Message.cs b/src/Discord.Net/Models/Message.cs index e1a676880..a9d045414 100644 --- a/src/Discord.Net/Models/Message.cs +++ b/src/Discord.Net/Models/Message.cs @@ -90,17 +90,13 @@ namespace Discord Height = height; } } - - private string _cleanText; - + /// Returns the local unique identifier for this message. public string Nonce { get; internal set; } /// Returns true if the logged-in user was mentioned. /// This is not set to true if the user was mentioned with @everyone (see IsMentioningEverone). public bool IsMentioningMe { get; private set; } - /// Returns true if @everyone was mentioned by someone with permissions to do so. - public bool IsMentioningEveryone { get; private set; } /// Returns true if the message was sent as text-to-speech by someone with permissions to do so. public bool IsTTS { get; private set; } /// Returns true if the message is still in the outgoing message queue. @@ -110,8 +106,7 @@ namespace Discord /// Returns the raw content of this message as it was received from the server.. public string RawText { get; private set; } /// Returns the content of this message with any special references such as mentions converted. - /// This value is lazy loaded and only processed on first request. Each subsequent request will pull from cache. - public string Text => _cleanText != null ? _cleanText : (_cleanText = Mention.ConvertToNames(_client, Server, RawText)); + public string Text { get; private set; } /// Returns the timestamp for when this message was sent. public DateTime Timestamp { get; private set; } /// Returns the timestamp for when this message was last edited. @@ -125,8 +120,16 @@ namespace Discord /// Returns a collection of all users mentioned in this message. [JsonIgnore] - public IEnumerable Mentions { get; internal set; } - + public IEnumerable MentionedUsers { get; internal set; } + + /// Returns a collection of all channels mentioned in this message. + [JsonIgnore] + public IEnumerable MentionedChannels { get; internal set; } + + /// Returns a collection of all roles mentioned in this message. + [JsonIgnore] + public IEnumerable MentionedRoles { get; internal set; } + /// Returns the server containing the channel this message was sent to. [JsonIgnore] public Server Server => _channel.Value.Server; @@ -174,6 +177,8 @@ namespace Discord internal void Update(MessageInfo model) { + var channel = Channel; + var server = channel.Server; if (model.Attachments != null) { Attachments = model.Attachments @@ -197,9 +202,7 @@ namespace Discord return new Embed(x.Url, x.Type, x.Title, x.Description, author, provider, thumbnail); }).ToArray(); } - - if (model.IsMentioningEveryone != null) - IsMentioningEveryone = model.IsMentioningEveryone.Value; + if (model.IsTextToSpeech != null) IsTTS = model.IsTextToSpeech.Value; if (model.Timestamp != null) @@ -208,16 +211,46 @@ namespace Discord EditedTimestamp = model.EditedTimestamp; if (model.Mentions != null) { - Mentions = model.Mentions.Select(x => _client.Users[x.Id, Channel.Server?.Id]).ToArray(); - IsMentioningMe = model.Mentions.Any(x => x.Id == _client.CurrentUserId); + MentionedUsers = model.Mentions + .Select(x => _client.Users[x.Id, Channel.Server?.Id]) + .ToArray(); } if (model.Content != null) { - RawText = model.Content; - _cleanText = null; + string text = model.Content; + RawText = text; + + //var mentionedUsers = new List(); + var mentionedChannels = new List(); + var mentionedRoles = new List(); + text = Mention.CleanUserMentions(_client, server, text/*, mentionedUsers*/); + if (server != null) + { + text = Mention.CleanChannelMentions(_client, server, text, mentionedChannels); + text = Mention.CleanRoleMentions(_client, User, channel, text, mentionedRoles); + } + Text = text; + + //MentionedUsers = mentionedUsers; + MentionedChannels = mentionedChannels; + MentionedRoles = mentionedRoles; } - } + if (server != null) + { + var me = server.CurrentUser; + IsMentioningMe = (MentionedUsers?.Contains(me) ?? false) || + (MentionedRoles?.Any(x => me.HasRole(x)) ?? false); + } + else + { + var me = _client.CurrentUser; + IsMentioningMe = MentionedUsers?.Contains(me) ?? false; + } + } + + public override bool Equals(object obj) => obj is Message && (obj as Message).Id == Id; + public override int GetHashCode() => Id.GetHashCode(); public override string ToString() => $"{User}: {RawText}"; } } diff --git a/src/Discord.Net/Models/Permissions.cs b/src/Discord.Net/Models/Permissions.cs index bd65436e1..492699b17 100644 --- a/src/Discord.Net/Models/Permissions.cs +++ b/src/Discord.Net/Models/Permissions.cs @@ -137,6 +137,9 @@ namespace Discord if (_isLocked) throw new InvalidOperationException("Unable to edit cached permissions directly, use Copy() to make an editable copy."); } + + public override bool Equals(object obj) => obj is Permissions && (obj as Permissions)._rawValue == _rawValue; + public override int GetHashCode() => _rawValue.GetHashCode(); } public sealed class DualChannelPermissions @@ -214,5 +217,10 @@ namespace Discord Deny.SetBit(pos, false); } } + + public override bool Equals(object obj) => obj is DualChannelPermissions && + (obj as DualChannelPermissions).Allow.Equals(Allow) && + (obj as DualChannelPermissions).Deny.Equals(Deny); + public override int GetHashCode() => unchecked(Allow.GetHashCode() + Deny.GetHashCode()); } } diff --git a/src/Discord.Net/Models/Role.cs b/src/Discord.Net/Models/Role.cs index bbf896404..b625e738d 100644 --- a/src/Discord.Net/Models/Role.cs +++ b/src/Discord.Net/Models/Role.cs @@ -70,6 +70,8 @@ namespace Discord member.UpdateServerPermissions(); } + public override bool Equals(object obj) => obj is Role && (obj as Role).Id == Id; + public override int GetHashCode() => Id.GetHashCode(); public override string ToString() => Name ?? Id; } } diff --git a/src/Discord.Net/Models/Server.cs b/src/Discord.Net/Models/Server.cs index ce4961d8f..18ea3e9b7 100644 --- a/src/Discord.Net/Models/Server.cs +++ b/src/Discord.Net/Models/Server.cs @@ -252,6 +252,8 @@ namespace Discord } } + public override bool Equals(object obj) => obj is Server && (obj as Server).Id == Id; + public override int GetHashCode() => Id.GetHashCode(); public override string ToString() => Name ?? Id; } } diff --git a/src/Discord.Net/Models/User.cs b/src/Discord.Net/Models/User.cs index 3abd56c74..1b75db58c 100644 --- a/src/Discord.Net/Models/User.cs +++ b/src/Discord.Net/Models/User.cs @@ -10,6 +10,7 @@ namespace Discord public class User : CachedObject { internal static string GetId(string userId, string serverId) => (serverId ?? "Private") + '_' + userId; + internal static string GetAvatarUrl(string userId, string avatarId) => avatarId != null ? Endpoints.UserAvatar(userId, avatarId) : null; private ConcurrentDictionary _channels; private ConcurrentDictionary _permissions; @@ -24,7 +25,7 @@ namespace Discord /// Returns the unique identifier for this user's current avatar. public string AvatarId { get; private set; } /// Returns the URL to this user's current avatar. - public string AvatarUrl => AvatarId != null ? API.Endpoints.UserAvatar(Id, AvatarId) : null; + public string AvatarUrl => GetAvatarUrl(Id, AvatarId); /// Returns the datetime that this user joined this server. public DateTime JoinedAt { get; private set; } @@ -370,6 +371,8 @@ namespace Discord return _roles.ContainsKey(role.Id); } + public override bool Equals(object obj) => obj is User && (obj as User).Id == Id; + public override int GetHashCode() => Id.GetHashCode(); public override string ToString() => Name ?? Id; } } \ No newline at end of file diff --git a/src/Discord.Net/Net/Rest/SharpRestEngine.cs b/src/Discord.Net/Net/Rest/SharpRestEngine.cs index ff15fbff5..c8bb858bd 100644 --- a/src/Discord.Net/Net/Rest/SharpRestEngine.cs +++ b/src/Discord.Net/Net/Rest/SharpRestEngine.cs @@ -52,19 +52,15 @@ namespace Discord.Net.Rest } private async Task Send(RestRequest request, CancellationToken cancelToken) { - bool hasRetried = false; + int retryCount = 0; while (true) { var response = await _client.ExecuteTaskAsync(request, cancelToken).ConfigureAwait(false); int statusCode = (int)response.StatusCode; if (statusCode == 0) //Internal Error { - if (!hasRetried) - { - //SSL/TTS Error seems to work if we immediately retry - hasRetried = true; - continue; - } + if (response.ErrorException.HResult == -2146233079 && retryCount++ < 5) //The request was aborted: Could not create SSL/TLS secure channel. + continue; //Seems to work if we immediately retry throw response.ErrorException; } if (statusCode < 200 || statusCode >= 300) //2xx = Success